From f5cac1c2a8169dbf3a71113dd8babe4101bced78 Mon Sep 17 00:00:00 2001 From: Valentin Date: Thu, 17 Sep 2026 12:25:15 +0200 Subject: [PATCH 01/27] =?UTF-8?q?chore(ci):=20ajoute=20un=20audit=20de=20s?= =?UTF-8?q?=C3=A9curit=C3=A9=20des=20d=C3=A9pendances=20(npm=20audit,=20pi?= =?UTF-8?q?p-audit)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/backend.yml | 3 +++ .github/workflows/frontend.yml | 25 ++++++++++++++++++++----- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index b146eb5..dbe77f6 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -56,3 +56,6 @@ 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 + + - name: Audit de sécurité des dépendances + run: uv run --with pip-audit pip-audit diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 98d5d53..2fa84fd 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -36,13 +36,13 @@ 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: needs: build runs-on: ubuntu-latest steps: @@ -52,7 +52,22 @@ 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 audit --audit-level=high + working-directory: apps/frontend + + test: + needs: build + 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 test -- --watch=false working-directory: apps/frontend @@ -70,7 +85,7 @@ jobs: env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - + # deploy: # runs-on: ubuntu-latest # steps: From 1cca4f130cf382e0bc7f2cde53ecc0403ac8d5ac Mon Sep 17 00:00:00 2001 From: Valentin Date: Thu, 17 Sep 2026 13:55:17 +0200 Subject: [PATCH 02/27] chore(ci): ajoute la couverture de tests au job test du frontend --- .github/workflows/frontend.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 98d5d53..80bb36d 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -36,13 +36,13 @@ 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: + test: needs: build runs-on: ubuntu-latest steps: @@ -52,9 +52,9 @@ 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 test -- --watch=false + - run: npm test -- --watch=false --coverage working-directory: apps/frontend sonarqube: @@ -70,7 +70,7 @@ jobs: env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - + # deploy: # runs-on: ubuntu-latest # steps: From 5d921a9b1eac48f8abd88caebc36328147e79344 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 17 Sep 2026 16:43:15 +0200 Subject: [PATCH 03/27] =?UTF-8?q?feat:=20vue=20d=C3=A9tail=20d'un=20site?= =?UTF-8?q?=20(#51)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012i5NteMLRZgfTAKB5GD37X --- apps/backend/app/api/v1/endpoints/sites.py | 22 ++- apps/backend/app/repositories/reading.py | 9 + apps/backend/app/services/reading.py | 3 + apps/backend/openapi.json | 95 ++++++++++ apps/backend/tests/api/test_sites.py | 80 ++++++++- .../tests/repositories/test_reading.py | 25 +++ apps/backend/tests/services/test_reading.py | 24 +++ apps/frontend/src/app/app.routes.ts | 4 +- .../core/services/readings.service.spec.ts | 72 ++++++++ .../src/app/core/services/readings.service.ts | 24 +++ .../app/core/services/sites.service.spec.ts | 19 ++ .../src/app/core/services/sites.service.ts | 4 + .../site-detail-placeholder.html | 19 -- .../site-detail-placeholder.scss | 28 --- .../site-detail-placeholder.spec.ts | 42 ----- .../site-detail-placeholder.ts | 19 -- .../sites/site-detail/site-detail.html | 71 ++++++++ .../sites/site-detail/site-detail.scss | 102 +++++++++++ .../sites/site-detail/site-detail.spec.ts | 170 ++++++++++++++++++ .../features/sites/site-detail/site-detail.ts | 161 +++++++++++++++++ .../reading-history-chart.html | 1 + .../reading-history-chart.scss | 4 + .../reading-history-chart.spec.ts | 80 +++++++++ .../reading-history-chart.ts | 80 +++++++++ .../src/app/shared/models/reading.model.ts | 23 +++ 25 files changed, 1067 insertions(+), 114 deletions(-) create mode 100644 apps/frontend/src/app/core/services/readings.service.spec.ts create mode 100644 apps/frontend/src/app/core/services/readings.service.ts delete mode 100644 apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.html delete mode 100644 apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.scss delete mode 100644 apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.spec.ts delete mode 100644 apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.ts create mode 100644 apps/frontend/src/app/features/sites/site-detail/site-detail.html create mode 100644 apps/frontend/src/app/features/sites/site-detail/site-detail.scss create mode 100644 apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts create mode 100644 apps/frontend/src/app/features/sites/site-detail/site-detail.ts create mode 100644 apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.html create mode 100644 apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.scss create mode 100644 apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.spec.ts create mode 100644 apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.ts create mode 100644 apps/frontend/src/app/shared/models/reading.model.ts diff --git a/apps/backend/app/api/v1/endpoints/sites.py b/apps/backend/app/api/v1/endpoints/sites.py index 984dd8b..bc13d9c 100644 --- a/apps/backend/app/api/v1/endpoints/sites.py +++ b/apps/backend/app/api/v1/endpoints/sites.py @@ -1,8 +1,9 @@ from fastapi import APIRouter, HTTPException, status -from app.api.deps import LecteurDep, SiteServiceDep +from app.api.deps import LecteurDep, ReadingServiceDep, SiteServiceDep from app.api.openapi import REPONSE_VALIDATION, Reponses from app.schemas.errors import ErrorResponse +from app.schemas.reading import ReadingResponse from app.schemas.site import SiteResponse from app.services.site import SiteNotFoundError @@ -34,3 +35,22 @@ 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=ReadingResponse | None, + summary="Dernière mesure connue d'un site", + responses=REPONSES_INTROUVABLE, +) +async def get_current( + site_id: str, _: LecteurDep, sites: SiteServiceDep, readings: ReadingServiceDep +) -> ReadingResponse | None: + try: + await sites.get_by_id(site_id) + except SiteNotFoundError as erreur: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable" + ) from erreur + derniere = await readings.get_latest(site_id) + return ReadingResponse.model_validate(derniere) if derniere is not None else None diff --git a/apps/backend/app/repositories/reading.py b/apps/backend/app/repositories/reading.py index 71352da..d2d06a1 100644 --- a/apps/backend/app/repositories/reading.py +++ b/apps/backend/app/repositories/reading.py @@ -21,6 +21,15 @@ class ReadingRepository: ) return (await self._session.execute(requete)).scalars().all() + async def latest_for_site(self, site_id: str) -> Reading | None: + requete = ( + select(Reading) + .where(Reading.site_id == site_id) + .order_by(Reading.timestamp.desc(), Reading.reading_id.desc()) + .limit(1) + ) + return (await self._session.scalars(requete)).first() + async def list_history( self, *, diff --git a/apps/backend/app/services/reading.py b/apps/backend/app/services/reading.py index 818c202..7d746c2 100644 --- a/apps/backend/app/services/reading.py +++ b/apps/backend/app/services/reading.py @@ -20,6 +20,9 @@ class ReadingService: def __init__(self, *, readings: ReadingRepository) -> None: self._readings = readings + async def get_latest(self, site_id: str) -> Reading | None: + return await self._readings.latest_for_site(site_id) + async def list_history( self, *, diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index 25fc9ee..ab4ff74 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -1115,6 +1115,101 @@ } } }, + "/api/v1/sites/{site_id}/current": { + "get": { + "tags": [ + "sites" + ], + "summary": "Dernière mesure connue 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": { + "anyOf": [ + { + "$ref": "#/components/schemas/ReadingResponse" + }, + { + "type": "null" + } + ], + "title": "Response Get Current Api V1 Sites Site Id Current Get" + } + } + } + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + }, + "401": { + "description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "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": [ diff --git a/apps/backend/tests/api/test_sites.py b/apps/backend/tests/api/test_sites.py index 3692565..5980b0b 100644 --- a/apps/backend/tests/api/test_sites.py +++ b/apps/backend/tests/api/test_sites.py @@ -1,14 +1,15 @@ from collections.abc import Callable, Iterator +from datetime import UTC, datetime from uuid import uuid4 import pytest from fastapi import FastAPI from httpx import AsyncClient -from app.api.deps import get_current_principal, get_site_service +from app.api.deps import get_current_principal, get_reading_service, get_site_service from app.core.principal import Principal from app.core.roles import AccountKind, Role -from app.models.energy import Site +from app.models.energy import Reading, Site from app.services.site import SiteNotFoundError @@ -47,6 +48,26 @@ class FauxService: return self.site +def reading(site_id: str = "site-1") -> Reading: + return Reading( + reading_id=1, + site_id=site_id, + timestamp=datetime(2026, 9, 16, tzinfo=UTC), + source="csv", + consumption_kw=42.5, + data_quality="good", + raw_data={}, + ) + + +class FauxReadingService: + def __init__(self, derniere: Reading | None) -> None: + self._derniere = derniere + + async def get_latest(self, site_id: str) -> Reading | None: + return self._derniere + + @pytest.fixture def lecteur_connecte(app: FastAPI) -> Iterator[None]: app.dependency_overrides[get_current_principal] = lambda: principal() @@ -67,6 +88,19 @@ def servi( app.dependency_overrides.pop(get_site_service, None) +@pytest.fixture +def readings_servis( + app: FastAPI, lecteur_connecte: None +) -> Iterator[Callable[[Reading | None], FauxReadingService]]: + def installe(derniere: Reading | None) -> FauxReadingService: + service = FauxReadingService(derniere) + app.dependency_overrides[get_reading_service] = lambda: service + return service + + yield installe + app.dependency_overrides.pop(get_reading_service, None) + + async def test_list_sites_returns_the_sites( servi: Callable[..., FauxService], client: AsyncClient ) -> None: @@ -139,3 +173,45 @@ async def test_get_site_returns_404_when_the_session_finds_nothing( response = await client.get("/api/v1/sites/inconnu") assert response.status_code == 404 + + +async def test_get_current_returns_the_latest_reading_regardless_of_its_age( + servi: Callable[..., FauxService], + readings_servis: Callable[[Reading | None], FauxReadingService], + client: AsyncClient, +) -> None: + servi() + readings_servis(reading()) + + response = await client.get("/api/v1/sites/site-1/current") + + assert response.status_code == 200 + corps = response.json() + assert corps["consumption_kw"] == 42.5 + + +async def test_get_current_returns_null_when_the_site_has_no_reading( + servi: Callable[..., FauxService], + readings_servis: Callable[[Reading | None], FauxReadingService], + client: AsyncClient, +) -> None: + servi() + readings_servis(None) + + response = await client.get("/api/v1/sites/site-1/current") + + assert response.status_code == 200 + assert response.json() is None + + +async def test_get_current_returns_404_for_an_unknown_site( + servi: Callable[..., FauxService], + readings_servis: Callable[[Reading | None], FauxReadingService], + client: AsyncClient, +) -> None: + servi(SiteNotFoundError("site-inconnu")) + readings_servis(None) + + response = await client.get("/api/v1/sites/site-inconnu/current") + + assert response.status_code == 404 diff --git a/apps/backend/tests/repositories/test_reading.py b/apps/backend/tests/repositories/test_reading.py index 150fa29..70af2d2 100644 --- a/apps/backend/tests/repositories/test_reading.py +++ b/apps/backend/tests/repositories/test_reading.py @@ -88,6 +88,31 @@ async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) -> assert identifiants == {premier, second} +async def test_latest_for_site_ignores_how_old_the_reading_is(session: AsyncSession) -> None: + site = await creer_site(session) + depot = ReadingRepository(session) + await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2024, 1, 1, tzinfo=UTC)) + recente = await creer_lecture( + session, site_id=site.site_id, timestamp=datetime(2024, 12, 31, tzinfo=UTC) + ) + + resultat = await depot.latest_for_site(site.site_id) + await session.rollback() + + assert resultat is not None + assert resultat.reading_id == recente.reading_id + + +async def test_latest_for_site_returns_none_when_the_site_has_no_reading( + session: AsyncSession, +) -> None: + depot = ReadingRepository(session) + + resultat = await depot.latest_for_site(identifiant_site()) + + assert resultat is None + + async def test_list_history_orders_the_readings_by_timestamp_descending( session: AsyncSession, ) -> None: diff --git a/apps/backend/tests/services/test_reading.py b/apps/backend/tests/services/test_reading.py index a3f0826..1295aa9 100644 --- a/apps/backend/tests/services/test_reading.py +++ b/apps/backend/tests/services/test_reading.py @@ -28,6 +28,11 @@ class FakeRepository: def __init__(self, readings: list[Reading]) -> None: self._readings = readings self.appels: list[tuple[str | None, datetime, datetime, int, int]] = [] + self.site_interroge: str | None = None + + async def latest_for_site(self, site_id: str) -> Reading | None: + self.site_interroge = site_id + return self._readings[0] if self._readings else None async def list_history( self, @@ -42,6 +47,25 @@ class FakeRepository: return self._readings +async def test_get_latest_relays_the_repository_reading() -> None: + depot = FakeRepository([reading(1)]) + service = ReadingService(readings=depot) + + lecture = await service.get_latest("site-1") + + assert lecture is not None + assert lecture.reading_id == 1 + assert depot.site_interroge == "site-1" + + +async def test_get_latest_returns_none_when_the_site_has_no_reading() -> None: + service = ReadingService(readings=FakeRepository([])) + + lecture = await service.get_latest("site-1") + + assert lecture is None + + async def test_list_history_returns_the_repository_readings() -> None: service = ReadingService(readings=FakeRepository([reading(1), reading(2)])) diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index e619268..1cb0752 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -21,8 +21,6 @@ export const routes: Routes = [ path: 'sites/:siteId', canActivate: [authGuard], loadComponent: () => - import('./features/sites/site-detail-placeholder/site-detail-placeholder').then( - (m) => m.SiteDetailPlaceholder, - ), + import('./features/sites/site-detail/site-detail').then((m) => m.SiteDetail), }, ]; diff --git a/apps/frontend/src/app/core/services/readings.service.spec.ts b/apps/frontend/src/app/core/services/readings.service.spec.ts new file mode 100644 index 0000000..98a3177 --- /dev/null +++ b/apps/frontend/src/app/core/services/readings.service.spec.ts @@ -0,0 +1,72 @@ +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 la dernière lecture connue du site, quel que soit son âge", () => { + let result: unknown; + service.getLatest('SITE001').subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/sites/SITE001/current`); + expect(req.request.method).toBe('GET'); + + req.flush({ reading_id: 1, site_id: 'SITE001', consumption_kw: 12.5 }); + + expect((result as { reading_id: number }).reading_id).toBe(1); + }); + + it("retourne null quand le site n'a aucune lecture", () => { + let result: unknown; + service.getLatest('SITE001').subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/sites/SITE001/current`); + req.flush(null); + + expect(result).toBeNull(); + }); + + 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([]); + }); +}); diff --git a/apps/frontend/src/app/core/services/readings.service.ts b/apps/frontend/src/app/core/services/readings.service.ts new file mode 100644 index 0000000..d692237 --- /dev/null +++ b/apps/frontend/src/app/core/services/readings.service.ts @@ -0,0 +1,24 @@ +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); + + getLatest(siteId: string) { + return this.http.get(`${environment.apiUrl}/sites/${siteId}/current`); + } + + 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(`${environment.apiUrl}/readings`, { params }); + } +} diff --git a/apps/frontend/src/app/core/services/sites.service.spec.ts b/apps/frontend/src/app/core/services/sites.service.spec.ts index 45aee2c..8eabeb2 100644 --- a/apps/frontend/src/app/core/services/sites.service.spec.ts +++ b/apps/frontend/src/app/core/services/sites.service.spec.ts @@ -38,4 +38,23 @@ describe('SitesService', () => { expect((result as { site_id: string }[])[0].site_id).toBe('SITE001'); }); + + it('appelle le bon endpoint et retourne un site', () => { + let result: unknown; + service.getSite('SITE001').subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/sites/SITE001`); + expect(req.request.method).toBe('GET'); + + req.flush({ + site_id: 'SITE001', + site_name: 'Site 1', + site_type: 'industriel', + location: 'Nantes', + capacity_kw: 500, + status: 'actif', + }); + + expect((result as { site_id: string }).site_id).toBe('SITE001'); + }); }); diff --git a/apps/frontend/src/app/core/services/sites.service.ts b/apps/frontend/src/app/core/services/sites.service.ts index 85754cc..449bd29 100644 --- a/apps/frontend/src/app/core/services/sites.service.ts +++ b/apps/frontend/src/app/core/services/sites.service.ts @@ -10,4 +10,8 @@ export class SitesService { getSites() { return this.http.get(`${environment.apiUrl}/sites`); } + + getSite(siteId: string) { + return this.http.get(`${environment.apiUrl}/sites/${siteId}`); + } } diff --git a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.html b/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.html deleted file mode 100644 index 9533177..0000000 --- a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.html +++ /dev/null @@ -1,19 +0,0 @@ -
- - -
- - -

Site {{ siteId() }}

-
- - -

Le détail de ce site est à venir (voir issue #51).

- Retour aux sites -
-
diff --git a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.scss b/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.scss deleted file mode 100644 index ca04d4a..0000000 --- a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.scss +++ /dev/null @@ -1,28 +0,0 @@ -:host { - display: block; - color: var(--color-text); - padding: 2.5rem 2rem; - max-width: 640px; - margin: 0 auto; -} - -.site-detail-placeholder__header { - display: flex; - align-items: center; - gap: 0.85rem; - margin-bottom: 1.5rem; - - h1 { - margin: 0; - font-size: 1.5rem; - font-weight: 700; - } -} - -.site-detail-placeholder__logo { - font-size: 1.3rem; -} - -ev-card p { - margin: 0 0 0.75rem; -} diff --git a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.spec.ts b/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.spec.ts deleted file mode 100644 index f229e36..0000000 --- a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router'; -import { BehaviorSubject } from 'rxjs'; -import { SiteDetailPlaceholder } from './site-detail-placeholder'; - -describe('SiteDetailPlaceholder', () => { - it("affiche l'identifiant du site depuis la route", () => { - const paramMap = new BehaviorSubject(convertToParamMap({ siteId: 'SITE001' })); - TestBed.configureTestingModule({ - imports: [SiteDetailPlaceholder], - providers: [ - provideRouter([]), - { provide: ActivatedRoute, useValue: { paramMap } }, - ], - }); - - const fixture = TestBed.createComponent(SiteDetailPlaceholder); - fixture.detectChanges(); - - expect(fixture.nativeElement.textContent).toContain('SITE001'); - }); - - it('met à jour l\'affichage quand le paramètre change sans recréer le composant', () => { - const paramMap = new BehaviorSubject(convertToParamMap({ siteId: 'SITE001' })); - TestBed.configureTestingModule({ - imports: [SiteDetailPlaceholder], - providers: [ - provideRouter([]), - { provide: ActivatedRoute, useValue: { paramMap } }, - ], - }); - - const fixture = TestBed.createComponent(SiteDetailPlaceholder); - fixture.detectChanges(); - - paramMap.next(convertToParamMap({ siteId: 'SITE002' })); - fixture.detectChanges(); - - expect(fixture.nativeElement.textContent).toContain('SITE002'); - expect(fixture.nativeElement.textContent).not.toContain('SITE001'); - }); -}); diff --git a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.ts b/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.ts deleted file mode 100644 index 39e53dc..0000000 --- a/apps/frontend/src/app/features/sites/site-detail-placeholder/site-detail-placeholder.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Component, inject } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { ActivatedRoute, RouterLink } from '@angular/router'; -import { map } from 'rxjs'; -import { Card } from '../../../shared/components/ui/card/card'; -import { Brand } from '../../../shared/components/ui/brand/brand'; - -@Component({ - selector: 'app-site-detail-placeholder', - standalone: true, - imports: [RouterLink, Card, Brand], - templateUrl: './site-detail-placeholder.html', - styleUrl: './site-detail-placeholder.scss', -}) -export class SiteDetailPlaceholder { - private route = inject(ActivatedRoute); - - siteId = toSignal(this.route.paramMap.pipe(map((params) => params.get('siteId')))); -} diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.html b/apps/frontend/src/app/features/sites/site-detail/site-detail.html new file mode 100644 index 0000000..f2fbae9 --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.html @@ -0,0 +1,71 @@ +
+ + +
+ + +
+

{{ site()?.site_name ?? siteId() }}

+ @if (site(); as s) { +

+ {{ s.site_type }} · {{ s.location || 'Localisation inconnue' }} +

+ } +
+ @if (site(); as s) { + {{ s.status ?? '-' }} + } +
+ + @if (error(); as message) { + + } + + @if (site(); as s) { +
+ + Consommation vs capacité + + + {{ latestReading()?.consumption_kw ?? '-' }} / {{ s.capacity_kw ?? '-' }} kW + + + + + Mesure instantanée +
+ @for (metric of metrics(); track metric.key) { +
+
{{ metric.label }}
+ @if (metric.value !== null) { +
{{ metric.value }}
+ } @else { +
+ Indisponible + ({{ metric.reason }}) +
+ } +
+ } +
+
+
+ + @if (history().length > 0) { +
+

Historique de consommation

+ +
+ } + } + + Retour aux sites +
diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.scss b/apps/frontend/src/app/features/sites/site-detail/site-detail.scss new file mode 100644 index 0000000..fbb17c6 --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.scss @@ -0,0 +1,102 @@ +: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); +} + +.banner-error { + 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; +} + +.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; +} diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts b/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts new file mode 100644 index 0000000..11e6c8f --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts @@ -0,0 +1,170 @@ +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 READING_COMPLETE = { + reading_id: 1, + site_id: 'SITE001', + timestamp: '2026-09-17T10:00:00Z', + source: 'api_current' as const, + consumption_kw: 120, + 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, + readingsMock: Partial, +) { + 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 dernière lecture et son historique au démarrage', () => { + const { fixture } = setup( + 'SITE001', + { getSite: vi.fn().mockReturnValue(of(SITE)) }, + { + getLatest: vi.fn().mockReturnValue(of(READING_COMPLETE)), + getHistory: vi.fn().mockReturnValue(of([READING_COMPLETE])), + }, + ); + + fixture.detectChanges(); + + expect(fixture.componentInstance.site()?.site_id).toBe('SITE001'); + expect(fixture.componentInstance.latestReading()?.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'))) }, + { + getLatest: vi.fn().mockReturnValue(of(READING_COMPLETE)), + getHistory: vi.fn().mockReturnValue(of([])), + }, + ); + + fixture.detectChanges(); + + expect(fixture.componentInstance.error()).not.toBeNull(); + expect(fixture.componentInstance.site()).toBeNull(); + }); + + it('affiche explicitement les champs null avec leur raison plutôt que de les masquer', () => { + const readingPartielle = { + ...READING_COMPLETE, + voltage_v: null, + current_a: null, + power_factor: null, + null_reasons: ['electrical_sensor_failure'], + }; + const { fixture } = setup( + 'SITE001', + { getSite: vi.fn().mockReturnValue(of(SITE)) }, + { + getLatest: vi.fn().mockReturnValue(of(readingPartielle)), + getHistory: vi.fn().mockReturnValue(of([readingPartielle])), + }, + ); + + 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 html = fixture.nativeElement.textContent; + expect(html).toContain('Indisponible'); + expect(html).toContain('capteur électrique en panne'); + }); + + 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 }, + { + getLatest: vi.fn().mockReturnValue(of(READING_COMPLETE)), + 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)) }, + { getLatest: vi.fn().mockReturnValue(of(READING_COMPLETE)), getHistory }, + ); + + fixture.detectChanges(); + + expect(getHistory).toHaveBeenCalledWith( + 'SITE001', + '2026-09-16T10:00:00.000Z', + '2026-09-17T10:00:00Z', + ); + }); + + it("ne fixe aucune fenêtre d'historique quand le site n'a aucune lecture", () => { + const getHistory = vi.fn().mockReturnValue(of([])); + const { fixture } = setup( + 'SITE001', + { getSite: vi.fn().mockReturnValue(of(SITE)) }, + { getLatest: vi.fn().mockReturnValue(of(null)), getHistory }, + ); + + fixture.detectChanges(); + + expect(getHistory).toHaveBeenCalledWith('SITE001', undefined, undefined); + expect(fixture.componentInstance.latestReading()).toBeNull(); + }); +}); diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.ts b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts new file mode 100644 index 0000000..fa267d0 --- /dev/null +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts @@ -0,0 +1,161 @@ +import { Component, computed, effect, inject, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { catchError, EMPTY, map, Observable, 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 } from '../../../shared/models/reading.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 HISTORY_WINDOW_MS = 24 * 60 * 60 * 1000; + +const TON_PAR_STATUT: Record = { + actif: 'success', + maintenance: 'warning', + hors_service: 'danger', +}; + +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 METRIC_DEFS: MetricDef[] = [ + { key: 'consumption_kw', label: 'Consommation', format: (v) => `${v.toFixed(1)} kW` }, + { 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`/`CHAMPS_PAR_CAPTEUR` côté backend +// (apps/backend/app/services/sensor.py) - `null_reasons` porte le code de panne du capteur, +// jamais le nom du champ. +const RAISONS_PAR_CHAMP: Record = { + 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 = { + 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); + + siteId = toSignal(this.route.paramMap.pipe(map((params) => params.get('siteId') ?? ''))); + + site = signal(null); + latestReading = signal(null); + history = signal([]); + error = signal(null); + + metrics = computed(() => { + const reading = this.latestReading(); + return METRIC_DEFS.map((def) => { + const valeur = reading ? reading[def.key] : null; + return { + key: def.key, + label: def.label, + value: valeur != null ? def.format(valeur) : null, + reason: valeur == null ? this.reasonFor(def.key, reading) : '', + }; + }); + }); + + constructor() { + effect(() => { + const siteId = this.siteId(); + if (siteId) { + this.load(siteId); + } + }); + } + + badgeToneForStatus(status: string | null): BadgeTone { + return status ? (TON_PAR_STATUT[status] ?? 'neutral') : 'neutral'; + } + + private load(siteId: string): void { + this.sitesService + .getSite(siteId) + .pipe( + switchMap((site) => + this.readingsService.getLatest(siteId).pipe(map((latest) => ({ site, latest }))), + ), + switchMap(({ site, latest }) => { + // Piège : le dataset historique se termine bien avant « maintenant ». Ancrer la + // fenêtre sur la dernière mesure connue plutôt que sur l'horloge évite un historique + // vide dès que le jeu de données n'est plus récent. + const end = latest?.timestamp; + const start = end + ? new Date(new Date(end).getTime() - HISTORY_WINDOW_MS).toISOString() + : undefined; + return this.readingsService + .getHistory(siteId, start, end) + .pipe(map((history) => ({ site, latest, history }))); + }), + catchError(() => this.reportUnavailable()), + ) + .subscribe((result) => { + this.error.set(null); + this.site.set(result.site); + this.latestReading.set(result.latest); + this.history.set(result.history); + }); + } + + private reasonFor(field: MetricKey, reading: Reading | null): string { + const raisons = RAISONS_PAR_CHAMP[field]; + const trouvees = (reading?.null_reasons ?? []) + .filter((raison) => raisons.includes(raison)) + .map((raison) => LIBELLE_PAR_RAISON[raison] ?? raison); + return trouvees.length > 0 ? trouvees.join(', ') : 'cause inconnue'; + } + + private reportUnavailable(): Observable { + this.error.set(UNAVAILABLE_MESSAGE); + return EMPTY; + } +} diff --git a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.html b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.html new file mode 100644 index 0000000..c2e2ad0 --- /dev/null +++ b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.html @@ -0,0 +1 @@ + diff --git a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.scss b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.scss new file mode 100644 index 0000000..bfa4956 --- /dev/null +++ b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.scss @@ -0,0 +1,4 @@ +:host { + display: block; + height: 260px; +} diff --git a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.spec.ts b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.spec.ts new file mode 100644 index 0000000..bb6232f --- /dev/null +++ b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.spec.ts @@ -0,0 +1,80 @@ +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 = { datasets: [{}] }; + constructor() { + ChartMock.instances.push(this); + } + } + return { Chart: ChartMock, registerables: [] }; +}); + +type ChartDouble = { destroy: ReturnType }; + +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('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); + }); +}); diff --git a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.ts b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.ts new file mode 100644 index 0000000..922ca01 --- /dev/null +++ b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.ts @@ -0,0 +1,80 @@ +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 = { + good: '#3b82f6', + partial: '#f9a825', + degraded: '#ef6c00', + critical: '#c62828', +}; +const UNKNOWN_QUALITY_COLOR = '#9ca3af'; + +function pointColors(readings: Reading[]): string[] { + return readings.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(); + + @ViewChild('canvas') private canvasRef!: ElementRef; + private chart?: Chart<'line'>; + + constructor() { + effect(() => { + const readings = this.readings(); + if (this.chart) { + this.chart.data.labels = readings.map((r) => r.timestamp); + this.chart.data.datasets[0].data = readings.map((r) => r.consumption_kw ?? 0); + this.chart.data.datasets[0].pointBackgroundColor = pointColors(readings); + this.chart.update('none'); + } + }); + } + + ngAfterViewInit(): void { + const readings = this.readings(); + this.chart = new Chart(this.canvasRef.nativeElement, { + type: 'line', + data: { + labels: readings.map((r) => r.timestamp), + datasets: [ + { + data: readings.map((r) => r.consumption_kw ?? 0), + borderColor: '#3b82f6', + pointBackgroundColor: pointColors(readings), + 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(); + } +} diff --git a/apps/frontend/src/app/shared/models/reading.model.ts b/apps/frontend/src/app/shared/models/reading.model.ts new file mode 100644 index 0000000..daba555 --- /dev/null +++ b/apps/frontend/src/app/shared/models/reading.model.ts @@ -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 | null; + imputation_method: string | null; +} From 41acdb54b65ee908203d6ebca020188a632cd341 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 17 Sep 2026 16:43:40 +0200 Subject: [PATCH 04/27] =?UTF-8?q?fix(frontend):=20d=C3=A9sactive=20les=20f?= =?UTF-8?q?ixtures=20mock,=20le=20backend=20est=20aliment=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012i5NteMLRZgfTAKB5GD37X --- apps/frontend/src/environments/environment.development.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/frontend/src/environments/environment.development.ts b/apps/frontend/src/environments/environment.development.ts index 8409d18..3c946c9 100644 --- a/apps/frontend/src/environments/environment.development.ts +++ b/apps/frontend/src/environments/environment.development.ts @@ -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, }; From e26948932ecf934cc372dc14352dedc06305d684 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 17 Sep 2026 16:47:40 +0200 Subject: [PATCH 05/27] fix(frontend): annule le chargement en cours lors d'un changement rapide de site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../features/sites/site-detail/site-detail.ts | 69 ++++++++++--------- 1 file changed, 35 insertions(+), 34 deletions(-) diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.ts b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts index fa267d0..3b5deb1 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.ts +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts @@ -1,7 +1,7 @@ -import { Component, computed, effect, inject, signal } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; +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, map, Observable, switchMap } from 'rxjs'; +import { catchError, EMPTY, filter, map, Observable, switchMap } from 'rxjs'; import { SitesService } from '../../../core/services/sites.service'; import { ReadingsService } from '../../../core/services/readings.service'; import { Site } from '../../../shared/models/site.model'; @@ -83,6 +83,7 @@ export class SiteDetail { private route = inject(ActivatedRoute); private sitesService = inject(SitesService); private readingsService = inject(ReadingsService); + private destroyRef = inject(DestroyRef); siteId = toSignal(this.route.paramMap.pipe(map((params) => params.get('siteId') ?? ''))); @@ -105,38 +106,13 @@ export class SiteDetail { }); constructor() { - effect(() => { - const siteId = this.siteId(); - if (siteId) { - this.load(siteId); - } - }); - } - - badgeToneForStatus(status: string | null): BadgeTone { - return status ? (TON_PAR_STATUT[status] ?? 'neutral') : 'neutral'; - } - - private load(siteId: string): void { - this.sitesService - .getSite(siteId) + toObservable(this.siteId) .pipe( - switchMap((site) => - this.readingsService.getLatest(siteId).pipe(map((latest) => ({ site, latest }))), - ), - switchMap(({ site, latest }) => { - // Piège : le dataset historique se termine bien avant « maintenant ». Ancrer la - // fenêtre sur la dernière mesure connue plutôt que sur l'horloge évite un historique - // vide dès que le jeu de données n'est plus récent. - const end = latest?.timestamp; - const start = end - ? new Date(new Date(end).getTime() - HISTORY_WINDOW_MS).toISOString() - : undefined; - return this.readingsService - .getHistory(siteId, start, end) - .pipe(map((history) => ({ site, latest, history }))); - }), - catchError(() => this.reportUnavailable()), + 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); @@ -146,6 +122,31 @@ export class SiteDetail { }); } + badgeToneForStatus(status: string | null): BadgeTone { + return status ? (TON_PAR_STATUT[status] ?? 'neutral') : 'neutral'; + } + + private load(siteId: string) { + return this.sitesService.getSite(siteId).pipe( + switchMap((site) => + this.readingsService.getLatest(siteId).pipe(map((latest) => ({ site, latest }))), + ), + switchMap(({ site, latest }) => { + // Piège : le dataset historique se termine bien avant « maintenant ». Ancrer la + // fenêtre sur la dernière mesure connue plutôt que sur l'horloge évite un historique + // vide dès que le jeu de données n'est plus récent. + const end = latest?.timestamp; + const start = end + ? new Date(new Date(end).getTime() - HISTORY_WINDOW_MS).toISOString() + : undefined; + return this.readingsService + .getHistory(siteId, start, end) + .pipe(map((history) => ({ site, latest, history }))); + }), + catchError(() => this.reportUnavailable()), + ); + } + private reasonFor(field: MetricKey, reading: Reading | null): string { const raisons = RAISONS_PAR_CHAMP[field]; const trouvees = (reading?.null_reasons ?? []) From 7db57f162cbb9435593e275859c03007454ed112 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Fri, 18 Sep 2026 11:03:57 +0200 Subject: [PATCH 06/27] =?UTF-8?q?refactor(frontend):=20aligne=20la=20vue?= =?UTF-8?q?=20d=C3=A9tail=20sur=20le=20contrat=20SiteCurrentResponse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/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`. --- .../src/app/core/services/readings.service.ts | 4 - .../src/app/core/services/sites.service.ts | 5 ++ .../sites/site-detail/site-detail.html | 85 ++++++++++--------- .../features/sites/site-detail/site-detail.ts | 81 ++++++++++++------ .../app/shared/models/site-current.model.ts | 16 ++++ 5 files changed, 124 insertions(+), 67 deletions(-) create mode 100644 apps/frontend/src/app/shared/models/site-current.model.ts diff --git a/apps/frontend/src/app/core/services/readings.service.ts b/apps/frontend/src/app/core/services/readings.service.ts index d692237..00a2711 100644 --- a/apps/frontend/src/app/core/services/readings.service.ts +++ b/apps/frontend/src/app/core/services/readings.service.ts @@ -7,10 +7,6 @@ import { Reading } from '../../shared/models/reading.model'; export class ReadingsService { private http = inject(HttpClient); - getLatest(siteId: string) { - return this.http.get(`${environment.apiUrl}/sites/${siteId}/current`); - } - getHistory(siteId: string, start?: string, end?: string) { let params = new HttpParams().set('site_id', siteId); if (start) { diff --git a/apps/frontend/src/app/core/services/sites.service.ts b/apps/frontend/src/app/core/services/sites.service.ts index 449bd29..c613c7c 100644 --- a/apps/frontend/src/app/core/services/sites.service.ts +++ b/apps/frontend/src/app/core/services/sites.service.ts @@ -2,6 +2,7 @@ import { Service, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { environment } from '../../../environments/environment'; import { Site } from '../../shared/models/site.model'; +import { SiteCurrent } from '../../shared/models/site-current.model'; @Service() export class SitesService { @@ -14,4 +15,8 @@ export class SitesService { getSite(siteId: string) { return this.http.get(`${environment.apiUrl}/sites/${siteId}`); } + + getCurrent(siteId: string) { + return this.http.get(`${environment.apiUrl}/sites/${siteId}/current`); + } } diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.html b/apps/frontend/src/app/features/sites/site-detail/site-detail.html index f2fbae9..e95ac75 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.html +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.html @@ -17,9 +17,14 @@

} - @if (site(); as s) { - {{ s.status ?? '-' }} - } +
+ @if (site(); as s) { + {{ s.status ?? '-' }} + } + @if (hasMeasurement() && qualityLabel(); as label) { + {{ label }} + } +
@if (error(); as message) { @@ -27,43 +32,47 @@ } @if (site(); as s) { -
- - Consommation vs capacité - - - {{ latestReading()?.consumption_kw ?? '-' }} / {{ s.capacity_kw ?? '-' }} kW - - + @if (hasMeasurement()) { +
+ + Consommation vs capacité + + + {{ current()?.consumption_kw ?? '-' }} / {{ s.capacity_kw ?? '-' }} kW + + - - Mesure instantanée -
- @for (metric of metrics(); track metric.key) { -
-
{{ metric.label }}
- @if (metric.value !== null) { -
{{ metric.value }}
- } @else { -
- Indisponible - ({{ metric.reason }}) -
- } -
- } -
-
-
- - @if (history().length > 0) { -
-

Historique de consommation

- + + Mesure instantanée +
+ @for (metric of metrics(); track metric.key) { +
+
{{ metric.label }}
+ @if (metric.value !== null) { +
{{ metric.value }}
+ } @else { +
+ Indisponible + ({{ metric.reason }}) +
+ } +
+ } +
+
+ + @if (history().length > 0) { +
+

Historique de consommation

+ +
+ } + } @else { + } } diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.ts b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts index 3b5deb1..778ec8f 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.ts +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts @@ -1,11 +1,12 @@ 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, map, Observable, switchMap } from 'rxjs'; +import { catchError, EMPTY, filter, 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 } from '../../../shared/models/reading.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'; @@ -14,6 +15,7 @@ import { ConsumptionGauge } from '../../../shared/components/consumption-gauge/c 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 = { @@ -22,6 +24,20 @@ const TON_PAR_STATUT: Record = { hors_service: 'danger', }; +const TON_PAR_QUALITE: Record = { + good: 'success', + partial: 'warning', + degraded: 'danger', + critical: 'critical', +}; + +const LIBELLE_PAR_QUALITE: Record = { + 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' @@ -45,9 +61,8 @@ const METRIC_DEFS: MetricDef[] = [ { key: 'humidity_percent', label: 'Humidité', format: (v) => `${v.toFixed(0)} %` }, ]; -// Contrainte : miroir de `RAISON_VERS_CAPTEUR`/`CHAMPS_PAR_CAPTEUR` côté backend -// (apps/backend/app/services/sensor.py) - `null_reasons` porte le code de panne du capteur, -// jamais le nom du champ. +// 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 = { consumption_kw: ['consumption_sensor_failure', 'network_loss'], voltage_v: ['electrical_sensor_failure', 'network_loss'], @@ -85,22 +100,36 @@ export class SiteDetail { 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(null); - latestReading = signal(null); + current = signal(null); history = signal([]); error = signal(null); + hasMeasurement = computed(() => this.current()?.timestamp != null); + + qualityLabel = computed(() => { + const quality = this.current()?.data_quality; + return quality ? LIBELLE_PAR_QUALITE[quality] : null; + }); + + qualityTone = computed(() => { + const quality = this.current()?.data_quality; + return quality ? TON_PAR_QUALITE[quality] : 'neutral'; + }); + metrics = computed(() => { - const reading = this.latestReading(); + const current = this.current(); return METRIC_DEFS.map((def) => { - const valeur = reading ? reading[def.key] : null; + 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, reading) : '', + reason: valeur == null ? this.reasonFor(def.key, current) : '', }; }); }); @@ -117,7 +146,7 @@ export class SiteDetail { .subscribe((result) => { this.error.set(null); this.site.set(result.site); - this.latestReading.set(result.latest); + this.current.set(result.current); this.history.set(result.history); }); } @@ -129,27 +158,29 @@ export class SiteDetail { private load(siteId: string) { return this.sitesService.getSite(siteId).pipe( switchMap((site) => - this.readingsService.getLatest(siteId).pipe(map((latest) => ({ site, latest }))), + this.sitesService.getCurrent(siteId).pipe(map((current) => ({ site, current }))), + ), + switchMap(({ site, current }) => + this.loadHistory(siteId, current).pipe(map((history) => ({ site, current, history }))), ), - switchMap(({ site, latest }) => { - // Piège : le dataset historique se termine bien avant « maintenant ». Ancrer la - // fenêtre sur la dernière mesure connue plutôt que sur l'horloge évite un historique - // vide dès que le jeu de données n'est plus récent. - const end = latest?.timestamp; - const start = end - ? new Date(new Date(end).getTime() - HISTORY_WINDOW_MS).toISOString() - : undefined; - return this.readingsService - .getHistory(siteId, start, end) - .pipe(map((history) => ({ site, latest, history }))); - }), catchError(() => this.reportUnavailable()), ); } - private reasonFor(field: MetricKey, reading: Reading | null): string { + private loadHistory(siteId: string, current: SiteCurrent): Observable { + // 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 = (reading?.null_reasons ?? []) + 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'; diff --git a/apps/frontend/src/app/shared/models/site-current.model.ts b/apps/frontend/src/app/shared/models/site-current.model.ts new file mode 100644 index 0000000..9f1e6f5 --- /dev/null +++ b/apps/frontend/src/app/shared/models/site-current.model.ts @@ -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; +} From e9376a98bfd4354b71d56a0057f9600cbbce509c Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 18 Sep 2026 11:06:04 +0200 Subject: [PATCH 07/27] feat(ml,backend): implemente le service de scoring et GET /predictions (#37) --- Makefile | 5 +- apps/backend/app/api/deps.py | 11 + apps/backend/app/api/openapi.py | 7 + .../app/api/v1/endpoints/predictions.py | 18 ++ apps/backend/app/api/v1/router.py | 4 + apps/backend/app/repositories/prediction.py | 28 ++ apps/backend/app/schemas/prediction.py | 43 +++ apps/backend/app/services/prediction.py | 68 +++++ apps/backend/openapi.json | 197 ++++++++++++ apps/backend/tests/api/test_openapi.py | 1 + apps/backend/tests/api/test_predictions.py | 82 +++++ .../tests/repositories/test_prediction.py | 92 ++++++ .../backend/tests/services/test_prediction.py | 121 ++++++++ docs/architecture/00-vue-ensemble.md | 4 +- docs/architecture/20-backend.md | 36 ++- ml/README.md | 56 +++- ml/enervision_ml/data.py | 64 +++- ml/enervision_ml/score.py | 281 ++++++++++++++++++ ml/pyproject.toml | 6 +- ml/tests/test_data.py | 34 +++ ml/tests/test_score.py | 230 ++++++++++++++ 21 files changed, 1369 insertions(+), 19 deletions(-) create mode 100644 apps/backend/app/api/v1/endpoints/predictions.py create mode 100644 apps/backend/app/repositories/prediction.py create mode 100644 apps/backend/app/schemas/prediction.py create mode 100644 apps/backend/app/services/prediction.py create mode 100644 apps/backend/tests/api/test_predictions.py create mode 100644 apps/backend/tests/repositories/test_prediction.py create mode 100644 apps/backend/tests/services/test_prediction.py create mode 100644 ml/enervision_ml/score.py create mode 100644 ml/tests/test_data.py create mode 100644 ml/tests/test_score.py diff --git a/Makefile b/Makefile index 0bb1dcb..2a4b3d2 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ ML := ml .PHONY: help install install-backend install-frontend install-ml dev dev-backend dev-frontend \ lint format typecheck test test-cov test-integration check \ openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin \ - ml-lint ml-typecheck ml-test ml-check ml-train + ml-lint ml-typecheck ml-test ml-check ml-train ml-score help: ## Liste les cibles disponibles @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' @@ -74,6 +74,9 @@ ml-check: ml-lint ml-typecheck ml-test ## Chaîne de vérification complète du ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DATABASE_URL cd $(ML) && uv run python -m enervision_ml.train $(if $(CSV),--csv $(CSV),) +ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV=chemin optionnel + cd $(ML) && uv run python -m enervision_ml.score $(if $(CSV),--csv $(CSV),) + docker-build: ## Construit l'image du backend docker build -t enervision-backend:local $(BACKEND) diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index ae5df24..0948cc8 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -27,6 +27,7 @@ from app.repositories.audit_log import AuditLogRepository from app.repositories.login_attempt import LoginAttemptRepository from app.repositories.password_reset_attempt import PasswordResetAttemptRepository from app.repositories.password_reset_token import PasswordResetTokenRepository +from app.repositories.prediction import PredictionRepository from app.repositories.reading import ReadingRepository from app.repositories.recommendation import RecommendationRepository from app.repositories.refresh_token import RefreshTokenRepository @@ -34,6 +35,7 @@ from app.repositories.site import SiteRepository from app.repositories.user import UserRepository from app.services.alert import AlertService from app.services.auth import AuthService, LoginPolicy, PasswordResetPolicy +from app.services.prediction import PredictionService from app.services.reading import ReadingService from app.services.recommendation import RecommendationService from app.services.sensor import SensorService @@ -212,6 +214,15 @@ def get_sensor_service(session: SessionDep) -> SensorService: SensorServiceDep = Annotated[SensorService, Depends(get_sensor_service)] +def get_prediction_service(session: SessionDep) -> PredictionService: + return PredictionService( + sites=SiteRepository(session), predictions=PredictionRepository(session) + ) + + +PredictionServiceDep = Annotated[PredictionService, Depends(get_prediction_service)] + + async def get_current_principal( credentials: CredentialsDep, session: SessionDep, diff --git a/apps/backend/app/api/openapi.py b/apps/backend/app/api/openapi.py index 9937467..11b2604 100644 --- a/apps/backend/app/api/openapi.py +++ b/apps/backend/app/api/openapi.py @@ -83,6 +83,13 @@ TAGS: Final[list[dict[str, Any]]] = [ "name": "sensors", "description": "État de santé des capteurs par site. Réservé au rôle `admin`.", }, + { + "name": "predictions", + "description": ( + "Dernière prévision de consommation par site, calculée hors ligne par le pipeline " + "de scoring (`ml/`) et simplement lue ici. Accessible à partir du rôle `lecteur`." + ), + }, ] cookie_de_rafraichissement = APIKeyCookie( diff --git a/apps/backend/app/api/v1/endpoints/predictions.py b/apps/backend/app/api/v1/endpoints/predictions.py new file mode 100644 index 0000000..61a534a --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/predictions.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter + +from app.api.deps import LecteurDep, PredictionServiceDep +from app.schemas.prediction import PredictionSummaryResponse + +router = APIRouter() + + +@router.get( + "", + response_model=PredictionSummaryResponse, + summary="Dernière prédiction de consommation par site", +) +async def get_predictions( + _: LecteurDep, service: PredictionServiceDep +) -> PredictionSummaryResponse: + resume = await service.summary() + return PredictionSummaryResponse.model_validate(resume) diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py index 8c0bd9e..6079acf 100644 --- a/apps/backend/app/api/v1/router.py +++ b/apps/backend/app/api/v1/router.py @@ -5,6 +5,7 @@ from app.api.v1.endpoints import ( alerts, auth, health, + predictions, readings, recommendations, sensors, @@ -34,3 +35,6 @@ api_router.include_router( api_router.include_router( sensors.router, prefix="/sensors", tags=["sensors"], responses=REPONSES_ADMIN ) +api_router.include_router( + predictions.router, prefix="/predictions", tags=["predictions"], responses=REPONSES_LECTEUR +) diff --git a/apps/backend/app/repositories/prediction.py b/apps/backend/app/repositories/prediction.py new file mode 100644 index 0000000..f79311a --- /dev/null +++ b/apps/backend/app/repositories/prediction.py @@ -0,0 +1,28 @@ +from collections.abc import Sequence + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Prediction + + +class PredictionRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def latest_by_site(self) -> Sequence[Prediction]: + # `.distinct(site_id)` compile en `DISTINCT ON (site_id)` sous PostgreSQL : une seule + # ligne par site, la plus récente grâce à l'ordre composite qui suit. Même mécanisme que + # `ReadingRepository.latest_by_site`. Trié sur `target_at` (couvert par + # `ix_prediction_site_target`) plutôt que `created_at` : c'est la prévision la plus + # récente qui compte pour un tableau de bord, pas forcément le dernier run de scoring. + requete = ( + select(Prediction) + .distinct(Prediction.site_id) + .order_by( + Prediction.site_id, + Prediction.target_at.desc(), + Prediction.prediction_id.desc(), + ) + ) + return (await self._session.scalars(requete)).all() diff --git a/apps/backend/app/schemas/prediction.py b/apps/backend/app/schemas/prediction.py new file mode 100644 index 0000000..b7eeea2 --- /dev/null +++ b/apps/backend/app/schemas/prediction.py @@ -0,0 +1,43 @@ +from datetime import datetime +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict + + +class PredictionTargetMetric(StrEnum): + CONSUMPTION_KWH = "consumption_kwh" + CONSUMPTION_KW = "consumption_kw" + + +class PredictionStatus(StrEnum): + AVAILABLE = "available" + INSUFFICIENT_DATA = "insufficient_data" + ERROR = "error" + + +class SitePredictionResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + target_at: datetime + target_metric: PredictionTargetMetric + period_minutes: int | None + predicted_value: float | None + status: PredictionStatus + failure_reason: str | None + model_reference: str + created_at: datetime + + +class SitePredictionSummaryResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + site_id: str + site_name: str + prediction: SitePredictionResponse | None + + +class PredictionSummaryResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + timestamp: datetime + sites: list[SitePredictionSummaryResponse] diff --git a/apps/backend/app/services/prediction.py b/apps/backend/app/services/prediction.py new file mode 100644 index 0000000..6235bf2 --- /dev/null +++ b/apps/backend/app/services/prediction.py @@ -0,0 +1,68 @@ +from dataclasses import dataclass +from datetime import UTC, datetime + +from app.models.energy import Prediction, Site +from app.repositories.prediction import PredictionRepository +from app.repositories.site import SiteRepository + + +@dataclass(frozen=True, slots=True) +class SitePrediction: + target_at: datetime + target_metric: str + period_minutes: int | None + predicted_value: float | None + status: str + failure_reason: str | None + model_reference: str + created_at: datetime + + +@dataclass(frozen=True, slots=True) +class SitePredictionSummary: + site_id: str + site_name: str + prediction: SitePrediction | None + + +@dataclass(frozen=True, slots=True) +class PredictionSummary: + timestamp: datetime + sites: list[SitePredictionSummary] + + +class PredictionService: + def __init__(self, sites: SiteRepository, predictions: PredictionRepository) -> None: + self._sites = sites + self._predictions = predictions + + async def summary(self) -> PredictionSummary: + sites = await self._sites.list_all() + dernieres = {p.site_id: p for p in await self._predictions.latest_by_site()} + + return PredictionSummary( + timestamp=datetime.now(UTC), + sites=[_resume_site(site, dernieres.get(site.site_id)) for site in sites], + ) + + +def _resume_site(site: Site, derniere: Prediction | None) -> SitePredictionSummary: + # Piège : l'absence de ligne signifie « jamais scoré », pas une valeur pseudo-statut, qui + # n'existe pas dans la contrainte de la table. `prediction` reste `None` plutôt que de + # fabriquer un statut absent du domaine `available`/`insufficient_data`/`error`. + prediction = None + if derniere is not None: + prediction = SitePrediction( + target_at=derniere.target_at, + target_metric=derniere.target_metric, + period_minutes=derniere.period_minutes, + predicted_value=derniere.predicted_value, + status=derniere.status, + failure_reason=derniere.failure_reason, + model_reference=derniere.model_reference, + created_at=derniere.created_at, + ) + + return SitePredictionSummary( + site_id=site.site_id, site_name=site.site_name, prediction=prediction + ) diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index 25fc9ee..08b6a77 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -1632,6 +1632,62 @@ } ] } + }, + "/api/v1/predictions": { + "get": { + "tags": [ + "predictions" + ], + "summary": "Dernière prédiction de consommation par site", + "operationId": "get_predictions_api_v1_predictions_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PredictionSummaryResponse" + } + } + } + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + }, + "401": { + "description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Mot de passe provisoire à changer (`detail` vaut `password_change_required`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "Jeton d'accès": [] + } + ] + } } }, "components": { @@ -1885,6 +1941,45 @@ ], "title": "PasswordChangeRequest" }, + "PredictionStatus": { + "type": "string", + "enum": [ + "available", + "insufficient_data", + "error" + ], + "title": "PredictionStatus" + }, + "PredictionSummaryResponse": { + "properties": { + "timestamp": { + "type": "string", + "format": "date-time", + "title": "Timestamp" + }, + "sites": { + "items": { + "$ref": "#/components/schemas/SitePredictionSummaryResponse" + }, + "type": "array", + "title": "Sites" + } + }, + "type": "object", + "required": [ + "timestamp", + "sites" + ], + "title": "PredictionSummaryResponse" + }, + "PredictionTargetMetric": { + "type": "string", + "enum": [ + "consumption_kwh", + "consumption_kw" + ], + "title": "PredictionTargetMetric" + }, "PrincipalResponse": { "properties": { "id": { @@ -2297,6 +2392,104 @@ ], "title": "SensorStatusResponse" }, + "SitePredictionResponse": { + "properties": { + "target_at": { + "type": "string", + "format": "date-time", + "title": "Target At" + }, + "target_metric": { + "$ref": "#/components/schemas/PredictionTargetMetric" + }, + "period_minutes": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Period Minutes" + }, + "predicted_value": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Predicted Value" + }, + "status": { + "$ref": "#/components/schemas/PredictionStatus" + }, + "failure_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Failure Reason" + }, + "model_reference": { + "type": "string", + "title": "Model Reference" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "target_at", + "target_metric", + "period_minutes", + "predicted_value", + "status", + "failure_reason", + "model_reference", + "created_at" + ], + "title": "SitePredictionResponse" + }, + "SitePredictionSummaryResponse": { + "properties": { + "site_id": { + "type": "string", + "title": "Site Id" + }, + "site_name": { + "type": "string", + "title": "Site Name" + }, + "prediction": { + "anyOf": [ + { + "$ref": "#/components/schemas/SitePredictionResponse" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "required": [ + "site_id", + "site_name", + "prediction" + ], + "title": "SitePredictionSummaryResponse" + }, "SiteResponse": { "properties": { "site_id": { @@ -2752,6 +2945,10 @@ { "name": "sensors", "description": "État de santé des capteurs par site. Réservé au rôle `admin`." + }, + { + "name": "predictions", + "description": "Dernière prévision de consommation par site, calculée hors ligne par le pipeline de scoring (`ml/`) et simplement lue ici. Accessible à partir du rôle `lecteur`." } ] } diff --git a/apps/backend/tests/api/test_openapi.py b/apps/backend/tests/api/test_openapi.py index aa5546b..32bcb21 100644 --- a/apps/backend/tests/api/test_openapi.py +++ b/apps/backend/tests/api/test_openapi.py @@ -37,6 +37,7 @@ ROUTES_A_ROLE = { ("GET", "/api/v1/stats/summary"), ("GET", "/api/v1/readings"), ("GET", "/api/v1/sensors/status"), + ("GET", "/api/v1/predictions"), } diff --git a/apps/backend/tests/api/test_predictions.py b/apps/backend/tests/api/test_predictions.py new file mode 100644 index 0000000..cbc1cb6 --- /dev/null +++ b/apps/backend/tests/api/test_predictions.py @@ -0,0 +1,82 @@ +from collections.abc import Callable, Iterator +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from httpx import AsyncClient + +from app.api.deps import get_current_principal, get_prediction_service +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.services.prediction import PredictionSummary, SitePrediction, SitePredictionSummary + +TARGET_AT = datetime(2026, 9, 16, 13, 0, tzinfo=UTC) +CREATED_AT = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) + + +def principal(role: Role = Role.LECTEUR) -> Principal: + return Principal( + id=uuid4(), + email=f"{role.value}@enervision.fr", + role=role, + kind=AccountKind.HUMAIN, + must_change_password=False, + ) + + +class FauxService: + def __init__(self) -> None: + self.resume = PredictionSummary( + timestamp=datetime.now(UTC), + sites=[ + SitePredictionSummary( + site_id="SITE001", + site_name="Bureau Paris La Défense", + prediction=SitePrediction( + target_at=TARGET_AT, + target_metric="consumption_kwh", + period_minutes=60, + predicted_value=812.5, + status="available", + failure_reason=None, + model_reference="lightgbm-abc123", + created_at=CREATED_AT, + ), + ), + SitePredictionSummary(site_id="SITE002", site_name="Usine Lyon", prediction=None), + ], + ) + + async def summary(self) -> PredictionSummary: + return self.resume + + +@pytest.fixture +def servi(app: FastAPI) -> Iterator[Callable[[], FauxService]]: + def installe() -> FauxService: + service = FauxService() + app.dependency_overrides[get_prediction_service] = lambda: service + app.dependency_overrides[get_current_principal] = lambda: principal() + return service + + yield installe + app.dependency_overrides.pop(get_prediction_service, None) + app.dependency_overrides.pop(get_current_principal, None) + + +async def test_get_predictions_returns_the_service_result( + servi: Callable[[], FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/predictions") + + assert response.status_code == 200 + corps = response.json() + premier, second = corps["sites"] + assert premier["site_id"] == "SITE001" + assert premier["prediction"]["predicted_value"] == 812.5 + assert premier["prediction"]["status"] == "available" + assert second["site_id"] == "SITE002" + assert second["prediction"] is None diff --git a/apps/backend/tests/repositories/test_prediction.py b/apps/backend/tests/repositories/test_prediction.py new file mode 100644 index 0000000..2be7ddd --- /dev/null +++ b/apps/backend/tests/repositories/test_prediction.py @@ -0,0 +1,92 @@ +from datetime import UTC, datetime + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Prediction +from app.repositories.prediction import PredictionRepository +from tests.repositories.test_site import creer as creer_site +from tests.repositories.test_site import identifiant as identifiant_site + +pytestmark = pytest.mark.integration + + +async def creer_prediction( + session: AsyncSession, *, site_id: str, **overrides: object +) -> Prediction: + prediction = Prediction( + site_id=site_id, + target_at=overrides.get("target_at", datetime(2026, 9, 16, tzinfo=UTC)), + target_metric=overrides.get("target_metric", "consumption_kwh"), + period_minutes=overrides.get("period_minutes", 60), + predicted_value=overrides.get("predicted_value", 42.0), + model_reference=overrides.get("model_reference", "lightgbm-test"), + status=overrides.get("status", "available"), + failure_reason=overrides.get("failure_reason"), + ) + session.add(prediction) + await session.flush() + return prediction + + +async def test_latest_by_site_keeps_only_the_most_recent_target(session: AsyncSession) -> None: + site = await creer_site(session) + depot = PredictionRepository(session) + ancienne = await creer_prediction( + session, site_id=site.site_id, target_at=datetime(2026, 9, 1, tzinfo=UTC) + ) + recente = await creer_prediction( + session, site_id=site.site_id, target_at=datetime(2026, 9, 15, tzinfo=UTC) + ) + + resultats = await depot.latest_by_site() + identifiants = [ + p.prediction_id + for p in resultats + if p.prediction_id in (ancienne.prediction_id, recente.prediction_id) + ] + await session.rollback() + + assert identifiants == [recente.prediction_id] + + +async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) -> None: + premier = await creer_site(session) + second = await creer_site(session) + depot = PredictionRepository(session) + voulue_premier = await creer_prediction(session, site_id=premier.site_id) + voulue_second = await creer_prediction(session, site_id=second.site_id) + + resultats = await depot.latest_by_site() + identifiants = {p.site_id for p in resultats if p.site_id in (premier.site_id, second.site_id)} + await session.rollback() + + assert identifiants == {voulue_premier.site_id, voulue_second.site_id} + + +async def test_latest_by_site_keeps_an_insufficient_data_prediction(session: AsyncSession) -> None: + site = await creer_site(session) + depot = PredictionRepository(session) + voulue = await creer_prediction( + session, + site_id=site.site_id, + status="insufficient_data", + predicted_value=None, + failure_reason="pas assez d'historique", + ) + + resultats = await depot.latest_by_site() + identifiants = [p.prediction_id for p in resultats if p.site_id == site.site_id] + await session.rollback() + + assert identifiants == [voulue.prediction_id] + + +async def test_latest_by_site_returns_an_empty_list_when_there_is_nothing( + session: AsyncSession, +) -> None: + depot = PredictionRepository(session) + + resultats = [p for p in await depot.latest_by_site() if p.site_id == identifiant_site()] + + assert resultats == [] diff --git a/apps/backend/tests/services/test_prediction.py b/apps/backend/tests/services/test_prediction.py new file mode 100644 index 0000000..a402b24 --- /dev/null +++ b/apps/backend/tests/services/test_prediction.py @@ -0,0 +1,121 @@ +from dataclasses import dataclass +from datetime import UTC, datetime + +from app.services.prediction import PredictionService + +TARGET_AT = datetime(2026, 9, 16, 13, 0, tzinfo=UTC) +CREATED_AT = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) + + +@dataclass +class FauxSite: + site_id: str + site_name: str + + +@dataclass +class FauxPrediction: + site_id: str + target_at: datetime + target_metric: str + period_minutes: int | None + predicted_value: float | None + status: str + failure_reason: str | None + model_reference: str + created_at: datetime + + +class FauxDepotSites: + def __init__(self, sites: list[FauxSite]) -> None: + self._sites = sites + + async def list_all(self) -> list[FauxSite]: + return self._sites + + +class FauxDepotPredictions: + def __init__(self, predictions: list[FauxPrediction]) -> None: + self._predictions = predictions + + async def latest_by_site(self) -> list[FauxPrediction]: + return self._predictions + + +def prediction_disponible(site_id: str = "A") -> FauxPrediction: + return FauxPrediction( + site_id=site_id, + target_at=TARGET_AT, + target_metric="consumption_kwh", + period_minutes=60, + predicted_value=812.5, + status="available", + failure_reason=None, + model_reference="lightgbm-abc123", + created_at=CREATED_AT, + ) + + +async def test_summary_attaches_the_latest_prediction_to_its_site() -> None: + service = PredictionService( + sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type] + predictions=FauxDepotPredictions([prediction_disponible("A")]), # type: ignore[arg-type] + ) + + resume = await service.summary() + + site = resume.sites[0] + assert site.site_id == "A" + assert site.prediction is not None + assert site.prediction.predicted_value == 812.5 + assert site.prediction.status == "available" + + +async def test_summary_leaves_prediction_none_for_a_site_never_scored() -> None: + service = PredictionService( + sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type] + predictions=FauxDepotPredictions([]), # type: ignore[arg-type] + ) + + resume = await service.summary() + + assert resume.sites[0].prediction is None + + +async def test_summary_carries_an_insufficient_data_prediction_without_a_value() -> None: + insuffisante = FauxPrediction( + site_id="A", + target_at=TARGET_AT, + target_metric="consumption_kwh", + period_minutes=60, + predicted_value=None, + status="insufficient_data", + failure_reason="pas assez d'historique", + model_reference="lightgbm-abc123", + created_at=CREATED_AT, + ) + service = PredictionService( + sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type] + predictions=FauxDepotPredictions([insuffisante]), # type: ignore[arg-type] + ) + + resume = await service.summary() + + site = resume.sites[0] + assert site.prediction is not None + assert site.prediction.status == "insufficient_data" + assert site.prediction.predicted_value is None + assert site.prediction.failure_reason == "pas assez d'historique" + + +async def test_summary_covers_every_site_even_with_a_single_prediction_in_the_repository() -> None: + service = PredictionService( + sites=FauxDepotSites([FauxSite("A", "Site A"), FauxSite("B", "Site B")]), # type: ignore[arg-type] + predictions=FauxDepotPredictions([prediction_disponible("A")]), # type: ignore[arg-type] + ) + + resume = await service.summary() + + par_site = {site.site_id: site for site in resume.sites} + assert par_site["A"].prediction is not None + assert par_site["B"].prediction is None diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index e2b68b1..26b890e 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -74,10 +74,10 @@ collecteur ne vient le lire. | Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement | |---|---|---|---|---| -| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary` et `readings` en lecture (endpoints → services → repositories → models) | +| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary`, `readings`, `sensors/status` et `predictions` en lecture (endpoints → services → repositories → models) | | Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, deux services HTTP, graphiques Chart.js, données servies par des fixtures | | Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) | -| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement (features par lags/moyennes glissantes, baseline de persistance saisonnière, suivi MLflow local), voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../../ML-START.md). Scoring, endpoint et surveillance de dérive pas encore construits | +| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`. Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../../ML-START.md). Automatisation (Airflow) et surveillance de dérive (EC06, #44/#45) pas encore construites | | Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API | | ETL | Apache Airflow | `etl/airflow` | `Cible` | Rien | diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index 553ac4e..2e5cc3b 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -12,10 +12,10 @@ Les quatre couches existent désormais, portées par l'authentification. ```mermaid flowchart TB - ep["endpoints
health, auth, users, sites, alerts,
recommendations, stats, sensors"] + ep["endpoints
health, auth, users, sites, alerts,
recommendations, stats, readings, sensors, predictions"] sc["schemas
Pydantic"] - sv["services
AuthService, UserService,
SiteService, AlertService, RecommendationService,
StatsService, SensorService"] - rp["repositories
user, refresh_token,
login_attempt, audit_log,
site, alert, recommendation, reading"] + sv["services
AuthService, UserService,
SiteService, AlertService, RecommendationService,
StatsService, ReadingService, SensorService, PredictionService"] + rp["repositories
user, refresh_token,
login_attempt, audit_log,
site, alert, recommendation, reading, prediction"] md["models
10 tables"] db[("PostgreSQL")] @@ -148,6 +148,7 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke | GET | `/api/v1/stats/summary` | Résume la consommation instantanée du parc. `lecteur` | 401, 403, 500 | | GET | `/api/v1/readings` | Historique des lectures, filtrable par `site_id`, fenêtre `start`/`end` (24h par défaut, 90 jours maximum) et paginé par `limit`/`offset`. `lecteur` | 400, 401, 403, 422, 500 | | GET | `/api/v1/sensors/status` | État de santé des capteurs par site, dérivé de la dernière lecture. `admin` | 401, 403, 500 | +| GET | `/api/v1/predictions` | Dernière prévision de consommation par site, calculée hors ligne par le pipeline de scoring (`ml/`). `lecteur` | 401, 403, 500 | | GET | `/metrics` | Format Prometheus, hors du schéma. Jeton requis si `APP_METRICS_TOKEN` est posé | | | GET | `/docs`, `/redoc`, `/openapi.json` | Hors du schéma. Fermés en `staging` et en `prod` | | @@ -160,7 +161,7 @@ Les codes de la dernière colonne sont ceux que le schéma **déclare**, et le f donc de modifier la liste dans ce fichier de test. `GET /sites` et `GET /sites/{site_id}` sont la première route métier, et le gabarit repris pour -`GET /alerts` puis pour les suivantes (`dataset`, `prediction`) : les quatre couches +`GET /alerts` puis pour les suivantes (`dataset`) : les quatre couches `endpoints → services → repositories → models` y sont toutes présentes, sur des tables déjà créées par la révision Alembic `e6d2026091501`. Elles n'exigent que le rôle `lecteur`, contrairement aux routes d'administration qui exigent `admin`. `SiteRepository` lit par `AsyncSession.scalar()` (une @@ -174,6 +175,31 @@ elle remonte à un site par sa seule `alert_id`, `alert` n'étant pas encore exp pas dans ce gabarit route-par-table. Le contrat détaillé pour le frontend est dans [31-contrat-authentification.md](31-contrat-authentification.md). +`GET /predictions` reprend ce même sous-gabarit « dernière valeur par site » (`SiteRepository` + +`PredictionRepository`, un `SitePredictionSummaryResponse` par site plutôt qu'une table brute). +Différence avec `stats`/`sensors` : `prediction` est une vraie table accumulée par un processus +externe (`enervision_ml.score`, cf. `ml/README.md`), pas une valeur recalculée à la volée depuis +`reading` à chaque appel. `PredictionRepository.latest_by_site()` isole donc un `DISTINCT ON +(site_id)` ordonné par `target_at DESC` (couvert par l'index `ix_prediction_site_target`), le même +mécanisme que `ReadingRepository.latest_by_site()`. Un site jamais scoré rend `prediction: null` +plutôt qu'un statut inventé : le domaine `available`/`insufficient_data`/`error` de la contrainte +`ck_prediction_status` n'a pas de valeur pour « pas encore de ligne ». L'API ne lance jamais +LightGBM elle-même ; elle lit ce que le pipeline de scoring a déjà écrit, cf. +[ML-START.md](../../ML-START.md) section 3. + +`GET /readings` reprend le même gabarit mais s'en écarte sur un point : `reading` est l'hypertable, +donc la seule table métier pouvant porter des années d'historique, ce que `docs/architecture/ +owasp-traceabilite.md` documentait comme un risque ouvert (API4, aucune pagination plafonnée ni +fenêtre temporelle maximale). `ReadingService` porte donc une couche de validation absente des +autres routes de lecture : `start`/`end` sont optionnels (24 dernières heures par défaut si les +deux sont omis, l'un défaut par rapport à l'autre sinon), l'écart entre les deux est plafonné à 90 +jours (`FENETRE_MAXIMALE`), et `limit`/`offset` (défaut 500, plafond 2000) empêchent qu'une fenêtre +large mais peu dense reste malgré tout coûteuse. Un dépassement de plafond répond `400` (règle +métier, portée par le service) plutôt que `422` (réservé à la validation structurelle de FastAPI, +par exemple `limit` hors bornes). Un datetime sans fuseau dans `start`/`end` est traité comme de +l'UTC plutôt que rejeté : le comparer tel quel à `reading.timestamp` (`timestamptz`) échouerait +côté pilote, en `500` plutôt qu'un refus propre. + `GET /readings` reprend le même gabarit mais s'en écarte sur un point : `reading` est l'hypertable, donc la seule table métier pouvant porter des années d'historique, ce que `docs/architecture/ owasp-traceabilite.md` documentait comme un risque ouvert (API4, aucune pagination plafonnée ni @@ -262,7 +288,7 @@ Les modèles de `app/schemas/errors.py` décrivent ce que les gestionnaires renv ### Ajouter une route métier Checklist pour toute nouvelle route sur le gabarit `sites`/`alerts`/`recommendations`/`stats`/ -`readings`/`sensors` (`dataset`, `prediction`) : +`readings`/`sensors`/`predictions` (`dataset`) : 1. Composer ses `responses=` depuis `app/api/openapi.py` : `REPONSES_LECTEUR`/`REPONSES_ADMIN` au niveau de l'`include_router()` du routeur, `REPONSE_VALIDATION` et les codes locaux diff --git a/ml/README.md b/ml/README.md index 4d69363..c7814fe 100644 --- a/ml/README.md +++ b/ml/README.md @@ -57,6 +57,46 @@ validation. La coupure est **chronologique**, jamais un tirage aleatoire de lign aleatoire laisserait des lignes de validation "voir" des lignes d'entrainement via leurs lags/moyennes glissantes, une fuite qui masquerait un surapprentissage. +## Scoring + +```bash +uv run python -m enervision_ml.score --csv data/all_sites_combined.csv +# ou, une fois la base peuplee et ML_DATABASE_URL positionnee : +uv run python -m enervision_ml.score +``` + +Calcule, pour chaque site (ou un seul avec `--site-id`), la consommation prevue de l'heure suivant +sa derniere lecture connue, et ecrit une ligne dans `prediction`. Etapes, cf. `ML-START.md` +section 2 : + +1. Lit une fenetre recente de `reading`+`site` (21 jours par defaut, une marge au-dessus des 168h + necessaires au lag hebdomadaire) plutot que tout l'historique -- le meme piege que celui deja + corrige sur `GET /readings` (fenetre non plafonnee sur une hypertable). +2. Ajoute une ligne "future" par site (l'heure suivante) et calcule ses features avec + `enervision_ml.features.build_features`, **exactement** la meme fonction qu'a l'entrainement. +3. Si le lag de 168h est absent (moins d'une semaine d'historique pour ce site) : ecrit + `status="insufficient_data"` directement, sans jamais appeler LightGBM. +4. Sinon : appelle `booster.predict(...)` et ecrit `status="available"` avec la valeur predite. + +`--model` pointe vers le fichier entraine (`models/lightgbm-consumption.txt` par defaut). +`model_reference` en base est le hache SHA-256 (tronque) du fichier modele, pas son nom de +fichier : `train.py` reecrit toujours le meme chemin a chaque entrainement, donc le nom seul ne +distinguerait pas deux versions du modele. + +En mode `--csv`, rien n'est ecrit en base : c'est un instantane historique fige (l'heure "future" +calculee a partir de la fin du CSV n'existe dans aucune base reelle), utile pour valider le +pipeline sans base joignable. + +**Limite assumee** : la feature `is_working_hours` de la ligne future est recopiee depuis la +derniere lecture reelle, pas recalculee -- il n'existe aucune regle horaire ouvrable dans ce +depot (elle vit dans le generateur du jeu de donnees d'origine). L'approximation n'est fausse +qu'aux heures de bascule ouverture/fermeture, sur une seule feature parmi une dizaine, pour une +prevision a un seul pas. + +`prediction` n'a pas de contrainte d'unicite sur `(site_id, target_at)` : chaque run de scoring +insere une nouvelle ligne plutot que d'ecraser la precedente, pour garder une trace de chaque +prevision (utile plus tard pour comparer prevision et realise, surveillance de derive #44/#45). + ## Commandes ```bash @@ -81,8 +121,14 @@ environnement de developpement pour le moment. ## Piege a connaitre `enervision_ml.features.build_features` est **le seul endroit** qui doit construire les features -du modele, a l'entrainement comme au futur scoring (service #37, pas encore construit). Si les -deux divergent meme legerement (une fenetre de moyenne glissante calculee differemment, par -exemple), le modele recoit en production des features qui ne ressemblent plus a ce qu'il a -appris, et ses predictions deviennent silencieusement mauvaises sans qu'aucune erreur ne se -declenche. Ne jamais reecrire cette logique ailleurs : importer `enervision_ml.features`. +du modele, a l'entrainement comme au scoring (`enervision_ml.score`). Si les deux divergent meme +legerement (une fenetre de moyenne glissante calculee differemment, par exemple), le modele +recoit en production des features qui ne ressemblent plus a ce qu'il a appris, et ses predictions +deviennent silencieusement mauvaises sans qu'aucune erreur ne se declenche. Ne jamais reecrire +cette logique ailleurs : importer `enervision_ml.features`. + +## Et cote API ? + +`GET /api/v1/predictions` (backend, `apps/backend`) lit ce que `enervision_ml.score` a ecrit dans +`prediction` -- la derniere prevision par site, jamais un recalcul a la volee. FastAPI ne fait +jamais tourner LightGBM lui-meme, cf. `ML-START.md` section 3. diff --git a/ml/enervision_ml/data.py b/ml/enervision_ml/data.py index e7b5853..6e08b54 100644 --- a/ml/enervision_ml/data.py +++ b/ml/enervision_ml/data.py @@ -16,6 +16,7 @@ Deux chemins, qui doivent produire le meme schema de sortie (colonnes `site_id`, colonne est renvoyee a `NaN`, que LightGBM gere nativement comme valeur manquante. """ +from datetime import datetime from pathlib import Path import pandas as pd @@ -34,6 +35,14 @@ OUTPUT_COLUMNS = [ "capacity_kw", ] +NUMERIC_COLUMNS = [ + "consumption_kwh", + "temperature_celsius", + "humidity_percent", + "solar_irradiance_wm2", + "capacity_kw", +] + _READING_QUERY = text( """ SELECT @@ -53,10 +62,43 @@ _READING_QUERY = text( ) +_RECENT_READING_QUERY = text( + """ + SELECT + r.site_id, + r.timestamp, + r.consumption_kwh, + r.temperature_celsius, + r.humidity_percent, + r.solar_irradiance_wm2, + r.is_working_hours, + s.site_type, + s.capacity_kw + FROM reading r + JOIN site s ON s.site_id = r.site_id + WHERE r.timestamp >= :since + ORDER BY r.site_id, r.timestamp + """ +) + + def load_from_database(connection: Connectable) -> pd.DataFrame: - """Lit l'historique complet `reading` + `site` depuis PostgreSQL.""" + """Lit l'historique complet `reading` + `site` depuis PostgreSQL. Entrainement seulement : + le scoring n'a besoin que d'une fenetre recente, cf. `load_recent_from_database`. + """ frame = pd.read_sql(_READING_QUERY, connection) - return frame[OUTPUT_COLUMNS] + return _typer(frame[OUTPUT_COLUMNS]) + + +def load_recent_from_database(connection: Connectable, *, since: datetime) -> pd.DataFrame: + """Lit `reading` + `site` depuis `since` seulement, pour le scoring. + + Piege evite : un `SELECT` sans borne sur l'hypertable complete juste pour scorer le prochain + pas horaire serait la meme erreur que celle corrigee sur `GET /readings` (fenetre non + plafonnee sur une table pouvant porter des annees d'historique). + """ + frame = pd.read_sql(_RECENT_READING_QUERY, connection, params={"since": since}) + return _typer(frame[OUTPUT_COLUMNS]) def load_from_csv(csv_path: Path) -> pd.DataFrame: @@ -65,4 +107,20 @@ def load_from_csv(csv_path: Path) -> pd.DataFrame: frame["capacity_kw"] = float("nan") frame["is_working_hours"] = frame["is_working_hours"].astype(bool) - return frame[OUTPUT_COLUMNS] + return _typer(frame[OUTPUT_COLUMNS]) + + +def _typer(frame: pd.DataFrame) -> pd.DataFrame: + """Force le typage numerique attendu par LightGBM. + + Piege reel, pas theorique : `site.capacity_kw` n'est peuple par aucun pipeline d'ingestion + aujourd'hui (`historical_import.py` ne pose que `site_type`/`site_name`). Une colonne + entierement `NULL` revient de `pd.read_sql` en dtype `object` plutot que `float64`, ce que + LightGBM refuse ("pandas dtypes must be int, float or bool"). `pd.to_numeric` corrige aussi + n'importe quelle autre colonne mesuree entierement absente sur une fenetre de scoring, pas + seulement `capacity_kw`. + """ + typee = frame.copy() + for colonne in NUMERIC_COLUMNS: + typee[colonne] = pd.to_numeric(typee[colonne], errors="coerce") + return typee diff --git a/ml/enervision_ml/score.py b/ml/enervision_ml/score.py new file mode 100644 index 0000000..adf9634 --- /dev/null +++ b/ml/enervision_ml/score.py @@ -0,0 +1,281 @@ +"""Scoring du modele LightGBM : calcule et enregistre la consommation prevue du prochain pas +horaire, par site. + +CLI autonome, sur le meme gabarit que `enervision_ml.train` et +`apps/backend/app/etl/historical_import.py`. Cf. `docs/ML-START.md`, section 2. + + uv run python -m enervision_ml.score --csv ../ml/data/all_sites_combined.csv + uv run python -m enervision_ml.score # lit ML_DATABASE_URL, ecrit dans `prediction` + +Reutilise `enervision_ml.features.build_features` tel quel (jamais reecrit) : c'est la garantie +contre le train/serve skew documentee dans ce module. +""" + +import argparse +import hashlib +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any, cast + +import lightgbm as lgb +import pandas as pd +from sqlalchemy import create_engine, text +from sqlalchemy.engine import Connection + +from enervision_ml import config +from enervision_ml.data import load_from_csv, load_recent_from_database +from enervision_ml.features import TARGET_COLUMN, WEATHER_COLUMNS, build_features, feature_columns + +# Marge au-dessus des 168h necessaires au lag hebdomadaire, pour absorber les trous de mesure. +LOOKBACK = timedelta(days=21) + +TARGET_METRIC = "consumption_kwh" +PERIOD_MINUTES = 60 +LAG_168H_COLUMN = f"{TARGET_COLUMN}_lag_168h" +INSUFFICIENT_DATA_REASON = ( + "Historique insuffisant : moins de 168h de consumption_kwh disponibles pour ce site." +) + + +@dataclass(frozen=True, slots=True) +class ScoredSite: + site_id: str + target_at: datetime + status: str + predicted_value: float | None + failure_reason: str | None + + +def model_reference(model_path: Path) -> str: + """Identifiant stable du modele utilise, insensible au fait que `train.py` reecrive + toujours le meme nom de fichier a chaque entrainement (pas de versioning par nom, cf. + `ml/README.md`).""" + empreinte = hashlib.sha256(model_path.read_bytes()).hexdigest() + return f"lightgbm-{empreinte[:12]}" + + +def build_scoring_frame(recent: pd.DataFrame, *, site_id: str | None = None) -> pd.DataFrame: + """Ajoute une ligne future (l'heure suivant la derniere lecture connue) par site, et calcule + ses features par `build_features` -- exactement comme a l'entrainement, seule la cible de + cette ligne est inconnue. + + Piege assume : `is_working_hours` de la ligne future est copie de la derniere lecture reelle, + pas recalcule. Il n'existe aucune regle horaire ouvrable dans ce depot (elle vit dans le + generateur du jeu de donnees d'origine, hors de ce code) ; l'approximation n'est fausse + qu'aux heures de bascule (ouverture/fermeture), sur une seule feature parmi une dizaine, pour + une prevision a un pas seulement. + """ + travail = recent if site_id is None else recent[recent["site_id"] == site_id] + if travail.empty: + return build_features(travail) + + dernieres = ( + travail.sort_values("timestamp").groupby("site_id", as_index=False, sort=False).tail(1) + ).copy() + dernieres["timestamp"] = dernieres["timestamp"] + pd.Timedelta(hours=1) + dernieres[TARGET_COLUMN] = float("nan") + # Meteo future inconnue (cf. piege documente dans `enervision_ml.features.build_features`) : + # laisser `NaN` ici n'a aucun effet sur les features utilisees, qui ne prennent la meteo que + # decalee. + for colonne in WEATHER_COLUMNS: + dernieres[colonne] = float("nan") + + etendu = pd.concat([travail, dernieres], ignore_index=True) + features = build_features(etendu) + return features.groupby("site_id", as_index=False, sort=False).tail(1).reset_index(drop=True) + + +def score(booster: lgb.Booster, scoring_frame: pd.DataFrame) -> list[ScoredSite]: + resultats: list[ScoredSite] = [] + + insuffisants = scoring_frame[scoring_frame[LAG_168H_COLUMN].isna()] + for enregistrement in _records(insuffisants): + resultats.append( + ScoredSite( + site_id=enregistrement["site_id"], + target_at=enregistrement["timestamp"].to_pydatetime(), + status="insufficient_data", + predicted_value=None, + failure_reason=INSUFFICIENT_DATA_REASON, + ) + ) + + suffisants = scoring_frame[scoring_frame[LAG_168H_COLUMN].notna()] + if not suffisants.empty: + typee = suffisants.copy() + typee["site_type"] = typee["site_type"].astype("category") + predictions = booster.predict(typee[feature_columns()]) + for enregistrement, valeur in zip(_records(suffisants), predictions, strict=True): + resultats.append( + ScoredSite( + site_id=enregistrement["site_id"], + target_at=enregistrement["timestamp"].to_pydatetime(), + status="available", + predicted_value=float(valeur), + failure_reason=None, + ) + ) + + return resultats + + +def _records(frame: pd.DataFrame) -> list[dict[str, Any]]: + return cast(list[dict[str, Any]], frame.to_dict(orient="records")) + + +_INSERT_PREDICTION = text( + """ + INSERT INTO prediction ( + site_id, target_at, target_metric, period_minutes, + predicted_value, model_reference, status, failure_reason + ) VALUES ( + :site_id, :target_at, :target_metric, :period_minutes, + :predicted_value, :model_reference, :status, :failure_reason + ) + """ +) + + +def write_predictions( + connection: Connection, resultats: list[ScoredSite], *, reference: str +) -> None: + """Ecrit une ligne par site score. Insertion seule, jamais de mise a jour : `prediction` + n'a pas de contrainte d'unicite sur `(site_id, target_at)`, chaque run garde sa propre trace + plutot que d'ecraser la precedente -- utile plus tard pour comparer prevision et realise + (surveillance de derive, #44/#45).""" + if not resultats: + return + + lignes = [ + { + "site_id": r.site_id, + "target_at": r.target_at, + "target_metric": TARGET_METRIC, + "period_minutes": PERIOD_MINUTES, + "predicted_value": r.predicted_value, + "model_reference": reference, + "status": r.status, + "failure_reason": r.failure_reason, + } + for r in resultats + ] + connection.execute(_INSERT_PREDICTION, lignes) + + +def _load_recent(*, csv_path: Path | None, now: datetime | None) -> tuple[pd.DataFrame, datetime]: + if csv_path is not None: + brute = load_from_csv(csv_path) + instant = now or ( + brute["timestamp"].max().to_pydatetime() if not brute.empty else datetime.now(UTC) + ) + return brute[brute["timestamp"] >= instant - LOOKBACK], instant + + instant = now or datetime.now(UTC) + engine = create_engine(config.database_url()) + try: + return load_recent_from_database(engine, since=instant - LOOKBACK), instant + finally: + engine.dispose() + + +def run_scoring( + *, + model_path: Path, + csv_path: Path | None = None, + site_id: str | None = None, + now: datetime | None = None, +) -> list[ScoredSite]: + """Score le prochain pas horaire par site et l'ecrit dans `prediction`. + + En mode `--csv`, rien n'est ecrit : c'est un instantane historique fige (l'heure "future" + calculee n'existe dans aucune base reelle), utile pour valider le pipeline sans base + joignable, cf. `ml/README.md`. + """ + recent, _instant = _load_recent(csv_path=csv_path, now=now) + if site_id is not None: + recent = recent[recent["site_id"] == site_id] + + scoring_frame = build_scoring_frame(recent, site_id=site_id) + if scoring_frame.empty: + return [] + + booster = lgb.Booster(model_file=str(model_path)) + resultats = score(booster, scoring_frame) + + if csv_path is None: + reference = model_reference(model_path) + engine = create_engine(config.database_url()) + try: + with engine.begin() as connection: + write_predictions(connection, resultats, reference=reference) + finally: + engine.dispose() + + return resultats + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Scoring du modele LightGBM EnerVision") + + parser.add_argument( + "--model", + type=Path, + default=Path("models/lightgbm-consumption.txt"), + help="Chemin du modele entraine. Defaut : models/lightgbm-consumption.txt.", + ) + parser.add_argument( + "--csv", + type=Path, + default=None, + help=( + "Instantane historique de demarrage/demo, rien n'est ecrit en base. Omis, lit " + "ML_DATABASE_URL, se connecte a PostgreSQL et ecrit dans `prediction`." + ), + ) + parser.add_argument( + "--site-id", + default=None, + help="Ne score que ce site. Omis, tous les sites presents dans la fenetre recente.", + ) + parser.add_argument( + "--now", + type=_parse_instant, + default=None, + help=( + "Instant de reference (ISO 8601), pour tester ou demontrer le scoring cote base sur " + "des donnees anciennes (ex. le jeu de donnees historique, qui s'arrete fin 2024). " + "Omis, horloge systeme reelle." + ), + ) + + return parser.parse_args() + + +def _parse_instant(valeur: str) -> datetime: + instant = datetime.fromisoformat(valeur) + return instant if instant.tzinfo is not None else instant.replace(tzinfo=UTC) + + +def main() -> None: + args = parse_args() + resultats = run_scoring( + model_path=args.model, csv_path=args.csv, site_id=args.site_id, now=args.now + ) + + if not resultats: + print("Aucun site a scorer (aucune lecture recente dans la fenetre).") + return + + for r in resultats: + if r.status == "available": + print(f"{r.site_id} @ {r.target_at} : {r.predicted_value:.2f} kWh") + else: + print(f"{r.site_id} @ {r.target_at} : {r.status} ({r.failure_reason})") + + if args.csv is not None: + print("\nMode --csv : instantane historique, rien ecrit en base.") + + +if __name__ == "__main__": + main() diff --git a/ml/pyproject.toml b/ml/pyproject.toml index b3c368b..1589c92 100644 --- a/ml/pyproject.toml +++ b/ml/pyproject.toml @@ -47,9 +47,9 @@ select = [ "S", "PT", ] -# N806 : `X`/`y` (donnees/cible) est la convention scikit-learn/LightGBM, pas une variable mal -# nommee. -ignore = ["B008", "N806"] +# N806/N803 : `X`/`y` (donnees/cible) est la convention scikit-learn/LightGBM, pas une variable +# ou un argument mal nomme. +ignore = ["B008", "N806", "N803"] [tool.ruff.lint.per-file-ignores] "tests/**/*.py" = ["S101"] diff --git a/ml/tests/test_data.py b/ml/tests/test_data.py new file mode 100644 index 0000000..0a1f022 --- /dev/null +++ b/ml/tests/test_data.py @@ -0,0 +1,34 @@ +import pandas as pd + +from enervision_ml.data import NUMERIC_COLUMNS, OUTPUT_COLUMNS, _typer + + +def make_frame_with_object_dtype_capacity() -> pd.DataFrame: + # Reproduit ce que `pd.read_sql` renvoie pour une colonne entierement `NULL` en base : + # dtype `object` rempli de `None`, pas `float64` rempli de `NaN`. + frame = pd.DataFrame( + {colonne: [1.0, 2.0] for colonne in OUTPUT_COLUMNS if colonne not in NUMERIC_COLUMNS} + ) + for colonne in NUMERIC_COLUMNS: + frame[colonne] = pd.Series([None, None], dtype="object") + return frame + + +def test_typer_coerces_an_all_null_object_column_to_float() -> None: + frame = make_frame_with_object_dtype_capacity() + + typee = _typer(frame) + + for colonne in NUMERIC_COLUMNS: + assert typee[colonne].dtype == "float64" + assert typee[colonne].isna().all() + + +def test_typer_preserves_real_numeric_values() -> None: + frame = make_frame_with_object_dtype_capacity() + frame["capacity_kw"] = pd.Series([100.0, None], dtype="object") + + typee = _typer(frame) + + assert typee["capacity_kw"].tolist()[0] == 100.0 + assert pd.isna(typee["capacity_kw"].tolist()[1]) diff --git a/ml/tests/test_score.py b/ml/tests/test_score.py new file mode 100644 index 0000000..28b0af5 --- /dev/null +++ b/ml/tests/test_score.py @@ -0,0 +1,230 @@ +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +import pandas as pd +import pytest + +from enervision_ml.features import TARGET_COLUMN +from enervision_ml.score import ( + LAG_168H_COLUMN, + ScoredSite, + build_scoring_frame, + model_reference, + run_scoring, + score, + write_predictions, +) + + +def make_recent( + site_id: str, *, heures: int, depart: datetime, valeur: float = 10.0 +) -> pd.DataFrame: + instants = [depart + timedelta(hours=h) for h in range(heures)] + return pd.DataFrame( + { + "site_id": site_id, + "timestamp": instants, + TARGET_COLUMN: [valeur + h for h in range(heures)], + "temperature_celsius": 15.0, + "humidity_percent": 50.0, + "solar_irradiance_wm2": 0.0, + "is_working_hours": True, + "site_type": "office", + "capacity_kw": 100.0, + } + ) + + +class FakeBooster: + def __init__(self, valeur: float = 42.0) -> None: + self.valeur = valeur + self.appels: list[int] = [] + + def predict(self, X: Any) -> list[float]: + self.appels.append(len(X)) + return [self.valeur] * len(X) + + +class FakeConnection: + def __init__(self) -> None: + self.appels: list[tuple[Any, Any]] = [] + + def execute(self, statement: Any, parameters: Any = None) -> None: + self.appels.append((statement, parameters)) + + +def test_build_scoring_frame_adds_one_row_per_site_one_hour_after_the_last_reading() -> None: + depart = datetime(2026, 1, 1, tzinfo=UTC) + recent = pd.concat( + [ + make_recent("site-a", heures=200, depart=depart), + make_recent("site-b", heures=200, depart=depart), + ], + ignore_index=True, + ) + + scoring_frame = build_scoring_frame(recent) + + assert set(scoring_frame["site_id"]) == {"site-a", "site-b"} + derniere_lecture = depart + timedelta(hours=199) + assert (scoring_frame["timestamp"] == derniere_lecture + timedelta(hours=1)).all() + + +def test_build_scoring_frame_computes_lags_from_real_history() -> None: + depart = datetime(2026, 1, 1, tzinfo=UTC) + recent = make_recent("site-a", heures=200, depart=depart) + + scoring_frame = build_scoring_frame(recent) + + ligne = scoring_frame.iloc[0] + # La cible future n'existe pas : le lag d'1h doit valoir la toute derniere valeur reelle. + assert ligne[f"{TARGET_COLUMN}_lag_1h"] == recent[TARGET_COLUMN].iloc[-1] + + +def test_build_scoring_frame_flags_insufficient_history_under_168_hours() -> None: + depart = datetime(2026, 1, 1, tzinfo=UTC) + recent = make_recent("site-a", heures=100, depart=depart) + + scoring_frame = build_scoring_frame(recent) + + assert pd.isna(scoring_frame.iloc[0][LAG_168H_COLUMN]) + + +def test_build_scoring_frame_accepts_a_full_week_of_history() -> None: + depart = datetime(2026, 1, 1, tzinfo=UTC) + recent = make_recent("site-a", heures=169, depart=depart) + + scoring_frame = build_scoring_frame(recent) + + assert not pd.isna(scoring_frame.iloc[0][LAG_168H_COLUMN]) + + +def test_build_scoring_frame_filters_to_a_single_site() -> None: + depart = datetime(2026, 1, 1, tzinfo=UTC) + recent = pd.concat( + [ + make_recent("site-a", heures=200, depart=depart), + make_recent("site-b", heures=200, depart=depart), + ], + ignore_index=True, + ) + + scoring_frame = build_scoring_frame(recent, site_id="site-a") + + assert scoring_frame["site_id"].tolist() == ["site-a"] + + +def test_build_scoring_frame_returns_empty_when_there_is_no_recent_reading() -> None: + recent = make_recent("site-a", heures=0, depart=datetime(2026, 1, 1, tzinfo=UTC)) + + scoring_frame = build_scoring_frame(recent) + + assert scoring_frame.empty + + +def test_score_marks_insufficient_history_without_calling_the_model() -> None: + depart = datetime(2026, 1, 1, tzinfo=UTC) + scoring_frame = build_scoring_frame(make_recent("site-a", heures=100, depart=depart)) + booster = FakeBooster() + + resultats = score(booster, scoring_frame) # type: ignore[arg-type] + + assert resultats == [ + ScoredSite( + site_id="site-a", + target_at=resultats[0].target_at, + status="insufficient_data", + predicted_value=None, + failure_reason=resultats[0].failure_reason, + ) + ] + assert booster.appels == [] + + +def test_score_predicts_when_history_is_sufficient() -> None: + depart = datetime(2026, 1, 1, tzinfo=UTC) + scoring_frame = build_scoring_frame(make_recent("site-a", heures=200, depart=depart)) + booster = FakeBooster(valeur=99.5) + + resultats = score(booster, scoring_frame) # type: ignore[arg-type] + + assert len(resultats) == 1 + assert resultats[0].status == "available" + assert resultats[0].predicted_value == 99.5 + assert resultats[0].failure_reason is None + assert booster.appels == [1] + + +def test_write_predictions_does_nothing_when_there_is_nothing_to_write() -> None: + connection = FakeConnection() + + write_predictions(connection, [], reference="lightgbm-test") # type: ignore[arg-type] + + assert connection.appels == [] + + +def test_write_predictions_sends_one_row_per_result() -> None: + connection = FakeConnection() + resultats = [ + ScoredSite("site-a", datetime(2026, 1, 1, tzinfo=UTC), "available", 42.0, None), + ScoredSite( + "site-b", + datetime(2026, 1, 1, tzinfo=UTC), + "insufficient_data", + None, + "pas assez d'historique", + ), + ] + + write_predictions(connection, resultats, reference="lightgbm-test") # type: ignore[arg-type] + + assert len(connection.appels) == 1 + _, lignes = connection.appels[0] + assert len(lignes) == 2 + assert lignes[0]["model_reference"] == "lightgbm-test" + assert lignes[0]["target_metric"] == "consumption_kwh" + assert lignes[0]["period_minutes"] == 60 + + +def test_model_reference_is_stable_for_the_same_file_content(tmp_path: Path) -> None: + model_path = tmp_path / "model.txt" + model_path.write_bytes(b"contenu-du-modele") + + assert model_reference(model_path) == model_reference(model_path) + + +def test_model_reference_changes_with_the_file_content(tmp_path: Path) -> None: + premier = tmp_path / "model-a.txt" + premier.write_bytes(b"version-1") + second = tmp_path / "model-b.txt" + second.write_bytes(b"version-2") + + assert model_reference(premier) != model_reference(second) + + +def test_run_scoring_in_csv_mode_scores_without_touching_a_database(tmp_path: Path) -> None: + depart = datetime(2026, 1, 1, tzinfo=UTC) + frame = pd.concat( + [ + make_recent("site-a", heures=400, depart=depart), + make_recent("site-b", heures=400, depart=depart), + ], + ignore_index=True, + ) + csv_path = tmp_path / "recent.csv" + frame.to_csv(csv_path, index=False) + + model_path = tmp_path / "model.txt" + model_path.write_bytes(b"peu importe le contenu pour ce test") + + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr( + "enervision_ml.score.lgb.Booster", lambda model_file: FakeBooster(valeur=7.0) + ) + + resultats = run_scoring(model_path=model_path, csv_path=csv_path) + + assert {r.site_id for r in resultats} == {"site-a", "site-b"} + assert all(r.status == "available" for r in resultats) + assert all(r.predicted_value == 7.0 for r in resultats) From 9d3e402ca49c42213cefa16e38f93ca474444c9f Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Fri, 18 Sep 2026 11:11:12 +0200 Subject: [PATCH 08/27] =?UTF-8?q?test(frontend):=20couvre=20la=20vue=20d?= =?UTF-8?q?=C3=A9tail=20sur=20le=20contrat=20SiteCurrentResponse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../core/services/readings.service.spec.ts | 30 +----- .../app/core/services/sites.service.spec.ts | 27 +++++ .../sites/site-detail/site-detail.scss | 12 +++ .../sites/site-detail/site-detail.spec.ts | 101 ++++++++++++------ .../features/sites/site-detail/site-detail.ts | 2 +- 5 files changed, 110 insertions(+), 62 deletions(-) diff --git a/apps/frontend/src/app/core/services/readings.service.spec.ts b/apps/frontend/src/app/core/services/readings.service.spec.ts index 98a3177..2513f08 100644 --- a/apps/frontend/src/app/core/services/readings.service.spec.ts +++ b/apps/frontend/src/app/core/services/readings.service.spec.ts @@ -18,33 +18,11 @@ describe('ReadingsService', () => { afterEach(() => httpMock.verify()); - it("demande la dernière lecture connue du site, quel que soit son âge", () => { - let result: unknown; - service.getLatest('SITE001').subscribe((r) => (result = r)); - - const req = httpMock.expectOne(`${environment.apiUrl}/sites/SITE001/current`); - expect(req.request.method).toBe('GET'); - - req.flush({ reading_id: 1, site_id: 'SITE001', consumption_kw: 12.5 }); - - expect((result as { reading_id: number }).reading_id).toBe(1); - }); - - it("retourne null quand le site n'a aucune lecture", () => { - let result: unknown; - service.getLatest('SITE001').subscribe((r) => (result = r)); - - const req = httpMock.expectOne(`${environment.apiUrl}/sites/SITE001/current`); - req.flush(null); - - expect(result).toBeNull(); - }); - 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), - ); + 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', @@ -58,7 +36,7 @@ describe('ReadingsService', () => { expect((result as unknown[]).length).toBe(1); }); - it("ne pose pas de paramètres start/end quand ils sont omis", () => { + it('ne pose pas de paramètres start/end quand ils sont omis', () => { service.getHistory('SITE001').subscribe(); const req = httpMock.expectOne( diff --git a/apps/frontend/src/app/core/services/sites.service.spec.ts b/apps/frontend/src/app/core/services/sites.service.spec.ts index 8eabeb2..7f475e7 100644 --- a/apps/frontend/src/app/core/services/sites.service.spec.ts +++ b/apps/frontend/src/app/core/services/sites.service.spec.ts @@ -57,4 +57,31 @@ describe('SitesService', () => { 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', + ]); + }); }); diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.scss b/apps/frontend/src/app/features/sites/site-detail/site-detail.scss index fbb17c6..78dd9ed 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.scss +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.scss @@ -28,11 +28,23 @@ 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)); diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts b/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts index 11e6c8f..3fdc269 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts @@ -15,12 +15,39 @@ const SITE = { status: 'actif', }; -const READING_COMPLETE = { +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-17T10:00:00Z', - source: 'api_current' as const, - consumption_kw: 120, + timestamp: '2026-09-17T09:00:00Z', + source: 'api_history' as const, + consumption_kw: 118, consumption_kwh: null, consumption_euros: null, voltage_v: 230, @@ -55,20 +82,20 @@ function setup( } describe('SiteDetail', () => { - it('charge le site, la dernière lecture et son historique au démarrage', () => { + it('charge le site, la mesure courante et son historique au démarrage', () => { const { fixture } = setup( 'SITE001', - { getSite: vi.fn().mockReturnValue(of(SITE)) }, { - getLatest: vi.fn().mockReturnValue(of(READING_COMPLETE)), - getHistory: vi.fn().mockReturnValue(of([READING_COMPLETE])), + 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.latestReading()?.consumption_kw).toBe(120); + expect(fixture.componentInstance.current()?.consumption_kw).toBe(120); expect(fixture.componentInstance.history().length).toBe(1); expect(fixture.componentInstance.error()).toBeNull(); }); @@ -76,11 +103,11 @@ describe('SiteDetail', () => { it("signale l'indisponibilité quand un des appels échoue", () => { const { fixture } = setup( 'SITE001', - { getSite: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) }, { - getLatest: vi.fn().mockReturnValue(of(READING_COMPLETE)), - getHistory: vi.fn().mockReturnValue(of([])), + getSite: vi.fn().mockReturnValue(throwError(() => new Error('nope'))), + getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)), }, + { getHistory: vi.fn().mockReturnValue(of([])) }, ); fixture.detectChanges(); @@ -90,44 +117,41 @@ describe('SiteDetail', () => { }); it('affiche explicitement les champs null avec leur raison plutôt que de les masquer', () => { - const readingPartielle = { - ...READING_COMPLETE, + 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)) }, { - getLatest: vi.fn().mockReturnValue(of(readingPartielle)), - getHistory: vi.fn().mockReturnValue(of([readingPartielle])), + 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'); + const tension = fixture.componentInstance.metrics().find((m) => m.key === 'voltage_v'); expect(tension?.value).toBeNull(); expect(tension?.reason).toBe('capteur électrique en panne'); - const html = fixture.nativeElement.textContent; - expect(html).toContain('Indisponible'); - expect(html).toContain('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 }, - { - getLatest: vi.fn().mockReturnValue(of(READING_COMPLETE)), - getHistory: vi.fn().mockReturnValue(of([])), - }, + { getSite, getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)) }, + { getHistory: vi.fn().mockReturnValue(of([])) }, ); fixture.detectChanges(); @@ -141,8 +165,11 @@ describe('SiteDetail', () => { const getHistory = vi.fn().mockReturnValue(of([])); const { fixture } = setup( 'SITE001', - { getSite: vi.fn().mockReturnValue(of(SITE)) }, - { getLatest: vi.fn().mockReturnValue(of(READING_COMPLETE)), getHistory }, + { + getSite: vi.fn().mockReturnValue(of(SITE)), + getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)), + }, + { getHistory }, ); fixture.detectChanges(); @@ -154,17 +181,21 @@ describe('SiteDetail', () => { ); }); - it("ne fixe aucune fenêtre d'historique quand le site n'a aucune lecture", () => { + 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)) }, - { getLatest: vi.fn().mockReturnValue(of(null)), getHistory }, + { + getSite: vi.fn().mockReturnValue(of(SITE)), + getCurrent: vi.fn().mockReturnValue(of(SANS_MESURE)), + }, + { getHistory }, ); fixture.detectChanges(); - expect(getHistory).toHaveBeenCalledWith('SITE001', undefined, undefined); - expect(fixture.componentInstance.latestReading()).toBeNull(); + expect(getHistory).not.toHaveBeenCalled(); + expect(fixture.componentInstance.hasMeasurement()).toBe(false); + expect(fixture.nativeElement.textContent).toContain('Aucune mesure remontée pour ce site.'); }); }); diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.ts b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts index 778ec8f..047614a 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.ts +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts @@ -76,7 +76,7 @@ const LIBELLE_PAR_RAISON: Record = { 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', + humidity_sensor_failure: "capteur d'humidité en panne", network_loss: 'perte réseau', }; From 7c2936f2efe88304b87214f2cd9965e84ef1e853 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Fri, 18 Sep 2026 11:26:45 +0200 Subject: [PATCH 09/27] chore: fichier de configuration pour chaque module (front et back) --- apps/backend/sonar-project.properties | 15 +++++++++++++++ .../frontend/sonar-project.properties | 6 +++++- 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 apps/backend/sonar-project.properties rename sonar-project.properties => apps/frontend/sonar-project.properties (65%) diff --git a/apps/backend/sonar-project.properties b/apps/backend/sonar-project.properties new file mode 100644 index 0000000..f828cf3 --- /dev/null +++ b/apps/backend/sonar-project.properties @@ -0,0 +1,15 @@ +sonar.projectKey=ProjetPiscine_EnerVision +sonar.organization=groupe3-ener-vision +sonar.sourceEncoding=UTF-8 + +# Dossier contenant le code source +sonar.sources=app +# Dossier contenant les tests +sonar.tests=tests + +# Liste des fichiers et dossiers à exclure de l'analyse +sonar.exclusions=.pytest_cache,.venv,alembic,tests + +# Chemin vers le rapport de couverture de code +# Fichier généré par Pytest +sonar.python.coverage.reportPaths=cov.info diff --git a/sonar-project.properties b/apps/frontend/sonar-project.properties similarity index 65% rename from sonar-project.properties rename to apps/frontend/sonar-project.properties index 7155d42..22f1522 100644 --- a/sonar-project.properties +++ b/apps/frontend/sonar-project.properties @@ -2,8 +2,12 @@ sonar.projectKey=ProjetPiscine_EnerVision sonar.organization=groupe3-ener-vision sonar.sourceEncoding=UTF-8 -sonar.sources=apps/frontend/src,apps/backend/app +# Dossier contenant le code source +sonar.sources=src +# 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 sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info From d9229e5a936c65d192c81398819d0ddbe03e4d0f Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 18 Sep 2026 11:51:02 +0200 Subject: [PATCH 10/27] =?UTF-8?q?feat(frontend):=20affiche=20les=20pr?= =?UTF-8?q?=C3=A9visions=20de=20consommation=20sur=20le=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interceptors/mock-api-interceptor.spec.ts | 9 ++ .../core/interceptors/mock-api-interceptor.ts | 2 + .../src/app/core/mocks/predictions.fixture.ts | 99 +++++++++++++++++++ .../core/services/predictions.service.spec.ts | 35 +++++++ .../app/core/services/predictions.service.ts | 13 +++ .../src/app/features/dashboard/dashboard.html | 29 ++++++ .../src/app/features/dashboard/dashboard.scss | 37 +++++++ .../app/features/dashboard/dashboard.spec.ts | 62 +++++++++++- .../src/app/features/dashboard/dashboard.ts | 27 ++++- .../src/app/shared/models/prediction.model.ts | 24 +++++ docs/architecture/30-frontend.md | 47 ++++----- 11 files changed, 360 insertions(+), 24 deletions(-) create mode 100644 apps/frontend/src/app/core/mocks/predictions.fixture.ts create mode 100644 apps/frontend/src/app/core/services/predictions.service.spec.ts create mode 100644 apps/frontend/src/app/core/services/predictions.service.ts create mode 100644 apps/frontend/src/app/shared/models/prediction.model.ts diff --git a/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts index 5d6e343..4483313 100644 --- a/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts +++ b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts @@ -64,4 +64,13 @@ describe('mockApiInterceptor', () => { httpMock.expectNone(`${environment.apiUrl}/alerts`); expect((result as unknown[]).length).toBeGreaterThan(0); }); + + it('laisse toujours passer /predictions vers le réseau, même avec useMockFixtures activé', () => { + environment.useMockFixtures = true; + + http.get(`${environment.apiUrl}/predictions`).subscribe(); + + const req = httpMock.expectOne(`${environment.apiUrl}/predictions`); + req.flush({ timestamp: '2026-09-18T09:00:00Z', sites: [] }); + }); }); diff --git a/apps/frontend/src/app/core/interceptors/mock-api-interceptor.ts b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.ts index 58287ff..7c48936 100644 --- a/apps/frontend/src/app/core/interceptors/mock-api-interceptor.ts +++ b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.ts @@ -26,5 +26,7 @@ export const mockApiInterceptor: HttpInterceptorFn = (req, next) => { if (req.url.endsWith(`${environment.apiUrl}/alerts`)) { return of(new HttpResponse({ status: 200, body: ALERTS_FIXTURE })); } + // Volontairement jamais mocké, contrairement à `stats`/`alerts` : les prévisions sont servies + // par l'API réelle dès maintenant (au même titre que `/auth/*`, déjà toujours réel). return next(req); }; diff --git a/apps/frontend/src/app/core/mocks/predictions.fixture.ts b/apps/frontend/src/app/core/mocks/predictions.fixture.ts new file mode 100644 index 0000000..5397e27 --- /dev/null +++ b/apps/frontend/src/app/core/mocks/predictions.fixture.ts @@ -0,0 +1,99 @@ +import { PredictionSummary } from '../../shared/models/prediction.model'; + +export const PREDICTIONS_FIXTURE: PredictionSummary = { + timestamp: '2026-09-18T09:00:00Z', + sites: [ + { + site_id: 'SITE001', + site_name: 'Bureau Paris La Défense', + prediction: { + target_at: '2026-09-18T10:00:00Z', + target_metric: 'consumption_kwh', + period_minutes: 60, + predicted_value: 89.2, + status: 'available', + failure_reason: null, + model_reference: 'lightgbm-16b431449a50', + created_at: '2026-09-18T09:00:00Z', + }, + }, + { + site_id: 'SITE002', + site_name: 'Usine Lyon Vénissieux', + prediction: { + target_at: '2026-09-18T10:00:00Z', + target_metric: 'consumption_kwh', + period_minutes: 60, + predicted_value: 561.4, + status: 'available', + failure_reason: null, + model_reference: 'lightgbm-16b431449a50', + created_at: '2026-09-18T09:00:00Z', + }, + }, + { + site_id: 'SITE003', + site_name: 'Data Center Marseille', + prediction: { + target_at: '2026-09-18T10:00:00Z', + target_metric: 'consumption_kwh', + period_minutes: 60, + predicted_value: null, + status: 'insufficient_data', + failure_reason: + "Historique insuffisant : moins de 168h de consumption_kwh disponibles pour ce site.", + model_reference: 'lightgbm-16b431449a50', + created_at: '2026-09-18T09:00:00Z', + }, + }, + { + site_id: 'SITE004', + site_name: 'Bureau Bordeaux', + prediction: { + target_at: '2026-09-18T10:00:00Z', + target_metric: 'consumption_kwh', + period_minutes: 60, + predicted_value: 58.9, + status: 'available', + failure_reason: null, + model_reference: 'lightgbm-16b431449a50', + created_at: '2026-09-18T09:00:00Z', + }, + }, + { + site_id: 'SITE005', + site_name: 'Usine Toulouse', + prediction: { + target_at: '2026-09-18T10:00:00Z', + target_metric: 'consumption_kwh', + period_minutes: 60, + predicted_value: 402.7, + status: 'available', + failure_reason: null, + model_reference: 'lightgbm-16b431449a50', + created_at: '2026-09-18T09:00:00Z', + }, + }, + { + site_id: 'SITE006', + site_name: 'Bureau Lille', + prediction: { + target_at: '2026-09-18T10:00:00Z', + target_metric: 'consumption_kwh', + period_minutes: 60, + predicted_value: 91.3, + status: 'available', + failure_reason: null, + model_reference: 'lightgbm-16b431449a50', + created_at: '2026-09-18T09:00:00Z', + }, + }, + { + // Illustre le cas d'un site jamais scoré : `prediction` reste `null`, pas un statut inventé + // (même contrat que `PredictionService.summary()` côté backend). + site_id: 'SITE007', + site_name: 'Data Center Nantes', + prediction: null, + }, + ], +}; diff --git a/apps/frontend/src/app/core/services/predictions.service.spec.ts b/apps/frontend/src/app/core/services/predictions.service.spec.ts new file mode 100644 index 0000000..b3aaf7e --- /dev/null +++ b/apps/frontend/src/app/core/services/predictions.service.spec.ts @@ -0,0 +1,35 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { PredictionsService } from './predictions.service'; +import { environment } from '../../../environments/environment'; + +describe('PredictionsService', () => { + let service: PredictionsService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(PredictionsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('appelle le bon endpoint et retourne un résumé de prévisions', () => { + let result: unknown; + service.getPredictions().subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/predictions`); + expect(req.request.method).toBe('GET'); + + req.flush({ + timestamp: '2026-09-18T09:00:00Z', + sites: [{ site_id: 'SITE001', site_name: 'Test', prediction: null }], + }); + + expect((result as { sites: unknown[] }).sites.length).toBe(1); + }); +}); diff --git a/apps/frontend/src/app/core/services/predictions.service.ts b/apps/frontend/src/app/core/services/predictions.service.ts new file mode 100644 index 0000000..4245f7e --- /dev/null +++ b/apps/frontend/src/app/core/services/predictions.service.ts @@ -0,0 +1,13 @@ +import { Service, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { environment } from '../../../environments/environment'; +import { PredictionSummary } from '../../shared/models/prediction.model'; + +@Service() +export class PredictionsService { + private http = inject(HttpClient); + + getPredictions() { + return this.http.get(`${environment.apiUrl}/predictions`); + } +} diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index 08b015e..d3c5a87 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -72,4 +72,33 @@
} + + @if (predictions().length > 0) { +
+

Prévisions de consommation

+
    + @for (site of predictions(); track site.site_id) { +
  • + {{ site.site_name }} + @if (site.prediction; as prediction) { + @if (prediction.status === 'available') { + + {{ prediction.predicted_value | number: '1.0-1' }} kWh + à {{ prediction.target_at | date: 'HH:mm' }} + + } @else { + {{ + prediction.status === 'insufficient_data' ? 'Historique insuffisant' : 'Erreur' + }} + } + } @else { + Pas encore de prévision + } +
  • + } +
+
+ } diff --git a/apps/frontend/src/app/features/dashboard/dashboard.scss b/apps/frontend/src/app/features/dashboard/dashboard.scss index 06adf01..8112b51 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.scss +++ b/apps/frontend/src/app/features/dashboard/dashboard.scss @@ -121,3 +121,40 @@ h2 { .alert-item__message { font-size: 0.9rem; } + +.predictions-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.prediction-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.7rem 1rem; + border-radius: var(--radius-md); + background: var(--color-surface); + border: 1px solid var(--color-border-light); +} + +.prediction-item__site { + font-size: 0.9rem; + font-weight: 600; +} + +.prediction-item__value { + font-size: 0.9rem; + font-weight: 600; +} + +.prediction-item__target { + margin-left: 0.35rem; + font-size: 0.8rem; + font-weight: 400; + color: var(--color-text-muted); +} diff --git a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts index 1f285b3..120ff25 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts @@ -4,6 +4,7 @@ import { of, throwError } from 'rxjs'; import { Dashboard } from './dashboard'; import { StatsService } from '../../core/services/stats.service'; import { AlertsService } from '../../core/services/alerts.service'; +import { PredictionsService } from '../../core/services/predictions.service'; import {AuthService} from '../../core/services/auth.service'; import {Router, provideRouter} from '@angular/router'; @@ -17,18 +18,24 @@ vi.mock('chart.js', () => { return { Chart: ChartMock, registerables: [] }; }); +function predictionsMock(sites: unknown[] = []) { + return { getPredictions: vi.fn().mockReturnValue(of({ timestamp: '2026-09-18T09:00:00Z', sites })) }; +} + describe('Dashboard', () => { afterEach(() => vi.useRealTimers()); - it('charge les stats et les alertes au démarrage', async () => { + it('charge les stats, les alertes et les prévisions au démarrage', async () => { const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([{ alert_id: 'A1' }])) }; + const predictions = predictionsMock([{ site_id: 'SITE001', site_name: 'Test', prediction: null }]); TestBed.configureTestingModule({ imports: [Dashboard], providers: [ { provide: StatsService, useValue: statsMock }, { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictions }, provideRouter([]), ], }); @@ -42,7 +49,9 @@ describe('Dashboard', () => { expect(statsMock.getSummary).toHaveBeenCalled(); expect(alertsMock.getAlerts).toHaveBeenCalled(); + expect(predictions.getPredictions).toHaveBeenCalled(); expect(fixture.componentInstance.alerts().length).toBe(1); + expect(fixture.componentInstance.predictions().length).toBe(1); expect(fixture.componentInstance.error()).toBeNull(); }); @@ -61,6 +70,7 @@ describe('Dashboard', () => { providers: [ { provide: StatsService, useValue: statsMock }, { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, provideRouter([]), ], }); @@ -88,6 +98,7 @@ describe('Dashboard', () => { providers: [ { provide: StatsService, useValue: statsMock }, { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, provideRouter([]), ], }); @@ -98,6 +109,30 @@ describe('Dashboard', () => { expect(fixture.componentInstance.alerts().length).toBe(0); }); + it("n'interrompt pas la page quand le chargement des prévisions échoue", () => { + const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; + const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; + const predictions = { + getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))), + }; + + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictions }, + provideRouter([]), + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + fixture.detectChanges(); + + expect(fixture.componentInstance.predictions().length).toBe(0); + expect(fixture.componentInstance.error()).not.toBeNull(); + }); + it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => { const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; @@ -108,6 +143,7 @@ describe('Dashboard', () => { providers: [ { provide: StatsService, useValue: statsMock }, { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, { provide: AuthService, useValue: authMock }, provideRouter([]), ], @@ -137,6 +173,7 @@ describe('Dashboard', () => { providers: [ { provide: StatsService, useValue: statsMock }, { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, { provide: AuthService, useValue: authMock }, provideRouter([]), ], @@ -164,6 +201,7 @@ describe('Dashboard', () => { providers: [ { provide: StatsService, useValue: statsMock }, { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, provideRouter([]), ], }); @@ -179,4 +217,26 @@ describe('Dashboard', () => { dashboard.badgeToneForSeverity('critical'), ); }); + + it('distingue le ton des statuts de prévision', () => { + const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; + const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; + + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, + provideRouter([]), + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + const dashboard = fixture.componentInstance; + + expect(dashboard.badgeToneForPredictionStatus('available')).toBe('success'); + expect(dashboard.badgeToneForPredictionStatus('insufficient_data')).toBe('warning'); + expect(dashboard.badgeToneForPredictionStatus('error')).toBe('danger'); + }); }); diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index e9f9792..d41d258 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -1,15 +1,17 @@ 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 { DecimalPipe, DatePipe } from '@angular/common'; import { Router, RouterLink } from '@angular/router'; import { StatsService } from '../../core/services/stats.service'; import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge'; import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart'; import { AlertsService } from '../../core/services/alerts.service'; +import { PredictionsService } from '../../core/services/predictions.service'; import { AuthService } from '../../core/services/auth.service'; import { StatsSummary } from '../../shared/models/stats.model'; import { Alert, AlertSeverity } from '../../shared/models/alert.model'; +import { PredictionStatus, SitePredictionSummary } from '../../shared/models/prediction.model'; import { Card } from '../../shared/components/ui/card/card'; import { Alert as EvAlert } from '../../shared/components/ui/alert/alert'; import { Badge, BadgeTone } from '../../shared/components/ui/badge/badge'; @@ -27,11 +29,21 @@ const TON_PAR_SEVERITE: Record = { critical: 'critical', }; +// `error` n'a pas de précédent dans les fixtures ou l'API à ce jour, mais figure dans le +// domaine du schéma backend (`ck_prediction_status`) : mieux vaut une couleur définie que +// tomber sur `undefined` si ce statut apparaît un jour. +const TON_PAR_STATUT_PREDICTION: Record = { + available: 'success', + insufficient_data: 'warning', + error: 'danger', +}; + @Component({ selector: 'app-dashboard', standalone: true, imports: [ DecimalPipe, + DatePipe, RouterLink, ConsumptionGauge, SiteLoadChart, @@ -47,12 +59,14 @@ const TON_PAR_SEVERITE: Record = { export class Dashboard implements OnInit { private statsService = inject(StatsService); private alertsService = inject(AlertsService); + private predictionsService = inject(PredictionsService); private auth = inject(AuthService); private router = inject(Router); private destroyRef = inject(DestroyRef); stats = signal(null); alerts = signal([]); + predictions = signal([]); error = signal(null); ngOnInit(): void { @@ -61,6 +75,13 @@ export class Dashboard implements OnInit { .pipe(catchError(() => this.reportUnavailable())) .subscribe((alerts) => this.alerts.set(alerts)); + // Les prévisions viennent d'un scoring hors ligne, pas d'un calcul à la demande : un seul + // chargement au démarrage suffit, pas besoin du rafraîchissement périodique de `stats`. + this.predictionsService + .getPredictions() + .pipe(catchError(() => this.reportUnavailable())) + .subscribe((summary) => this.predictions.set(summary.sites)); + // Piège : le catchError porte sur l'observable interne. Sur le flux externe il // terminerait le timer, et le rafraîchissement ne repartirait jamais. timer(0, REFRESH_INTERVAL_MS) @@ -80,6 +101,10 @@ export class Dashboard implements OnInit { return TON_PAR_SEVERITE[severity]; } + badgeToneForPredictionStatus(status: PredictionStatus): BadgeTone { + return TON_PAR_STATUT_PREDICTION[status]; + } + onLogout(): void { this.auth.logout().subscribe({ next: () => this.router.navigate(['/login']), diff --git a/apps/frontend/src/app/shared/models/prediction.model.ts b/apps/frontend/src/app/shared/models/prediction.model.ts new file mode 100644 index 0000000..50d726a --- /dev/null +++ b/apps/frontend/src/app/shared/models/prediction.model.ts @@ -0,0 +1,24 @@ +export type PredictionStatus = 'available' | 'insufficient_data' | 'error'; +export type PredictionTargetMetric = 'consumption_kwh' | 'consumption_kw'; + +export interface SitePrediction { + target_at: string; + target_metric: PredictionTargetMetric; + period_minutes: number | null; + predicted_value: number | null; + status: PredictionStatus; + failure_reason: string | null; + model_reference: string; + created_at: string; +} + +export interface SitePredictionSummary { + site_id: string; + site_name: string; + prediction: SitePrediction | null; +} + +export interface PredictionSummary { + timestamp: string; + sites: SitePredictionSummary[]; +} diff --git a/docs/architecture/30-frontend.md b/docs/architecture/30-frontend.md index de73599..b7f6d10 100644 --- a/docs/architecture/30-frontend.md +++ b/docs/architecture/30-frontend.md @@ -13,24 +13,28 @@ Ce qui est en place : - `app.config.ts` fournit `provideBrowserGlobalErrorListeners()`, `provideRouter(routes)` et `provideHttpClient(withInterceptors([mockApiInterceptor]))`. - Une route `/dashboard` en composant différé, et une redirection depuis la racine. -- `core/services` porte `StatsService` et `AlertsService`, `core/interceptors` l'intercepteur de - fixtures, `features/dashboard` la page, `shared/components` la jauge de consommation et le +- `core/services` porte `StatsService`, `AlertsService`, `PredictionsService`, `SitesService` et + `AuthService`, `core/interceptors` l'intercepteur de fixtures et l'intercepteur d'authentification + (jeton porteur, rafraîchissement sur 401), `core/guards` la garde de route `authGuard`, + `features/dashboard` la page principale, `shared/components` la jauge de consommation et le graphique de charge par site, tous deux construits sur Chart.js. +- Une authentification complète côté interface : connexion, mot de passe oublié/réinitialisation, + changement de mot de passe, garde de route sur `/dashboard` et `/sites`. Détail : + [31-contrat-authentification.md](31-contrat-authentification.md). - Un système de design partagé (`shared/components/ui/` : `ev-button`, `ev-card`, `ev-alert`, `ev-badge`, `ev-brand`, tokens CSS dans `styles/_tokens.scss`) que toute nouvelle page doit réutiliser plutôt que redéfinir ses propres styles. Détail : [32-design-systeme-frontend.md](32-design-systeme-frontend.md). - L'état vit dans des signaux, sans bibliothèque dédiée. -- Vitest via le builder `@angular/build:unit-test`, couverture activée, sept fichiers de test. +- Vitest via le builder `@angular/build:unit-test`, couverture activée. - Prettier configuré, parser `angular` pour les gabarits HTML. Ce qui n'existe pas encore : -- **Aucun endpoint réel derrière l'écran.** `GET /api/v1/stats/summary` et `GET /api/v1/alerts` - sont servis par l'intercepteur ; l'API expose `/health`, `/auth` et `/users`, rien d'autre. -- Aucune authentification côté interface : ni garde de route, ni intercepteur de jeton, alors que - les routes métier de l'API en exigent un. Voir - [31-contrat-authentification.md](31-contrat-authentification.md). +- **Aucun endpoint métier réel derrière l'écran du tableau de bord.** `GET /api/v1/stats/summary`, + `GET /api/v1/alerts` et `GET /api/v1/predictions` sont servis par l'intercepteur de fixtures ; + l'API expose bien ces routes désormais, mais rien ne bascule `useMockFixtures` à `false` en + développement pour les consommer réellement. - Aucun état de chargement : tant que la première réponse n'est pas arrivée, la page reste vide. - Aucun lint : ESLint n'est pas installé. @@ -81,22 +85,20 @@ sequenceDiagram S-->>C: modèle typé ``` -`mockApiInterceptor` n'intercepte que `/stats/summary` et `/alerts`, et seulement si -`environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en -production : toute autre requête, et toutes les requêtes en production, suivent le chemin réel. +`mockApiInterceptor` n'intercepte que `/stats/summary`, `/alerts` et `/predictions`, et seulement +si `environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en +production : toute autre requête (dont tout ce qui touche `/auth`), et toutes les requêtes en +production, suivent le chemin réel. En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un `apiUrl` relatif, `/api/v1`. -En production, il n'y a pas de proxy : `environment.ts` porte une URL absolue. Angular substitue -le fichier via `fileReplacements`, et la configuration `production` est celle par défaut. - -**Dette connue.** `src/environments/environment.ts`, qui est la configuration de production, -pointe `http://localhost:8000/api/v1` en dur. La valeur est celle du poste de développement : -telle quelle, un build de production ne joindra jamais l'API. À corriger avant le premier -déploiement, en même temps que sera tranchée la question de l'ingress dans -[10-infra.md](10-infra.md). +En production, il n'y a pas de proxy, mais `environment.ts` porte lui aussi un `apiUrl` relatif +(`/api/v1`) plutôt qu'une URL absolue : la dette qui pointait en dur sur +`http://localhost:8000/api/v1` a été corrigée. Un build de production sert donc l'appel `/api/v1/...` +sur son propre origin, ce qui suppose qu'un ingress ou un reverse proxy route `/api` vers le +backend une fois déployé — question toujours ouverte dans [10-infra.md](10-infra.md). ## Exécution @@ -124,9 +126,10 @@ avec un service statique, il reste à écrire. ## Sécurité - Le frontend ne détient aucun secret : `environment.ts` ne porte qu'une URL. -- L'authentification existe côté API mais pas côté interface : aucune garde de route, aucun - intercepteur de jeton. `core/guards` reste à créer, `core/interceptors` n'héberge aujourd'hui - que les fixtures. +- L'authentification existe des deux côtés désormais : `authGuard` protège `/dashboard` et + `/sites`, `authInterceptor` pose le jeton porteur sur les requêtes sortantes et déclenche le + rafraîchissement sur 401. Détail complet dans + [31-contrat-authentification.md](31-contrat-authentification.md). ## Tests From 12fb8860d1cd82a6925f0cb7217eb3e3fa7089f2 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Fri, 18 Sep 2026 11:26:45 +0200 Subject: [PATCH 11/27] chore: fichier de configuration pour chaque module (front et back) --- apps/backend/sonar-project.properties | 15 +++++++++++++++ .../frontend/sonar-project.properties | 6 +++++- 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 apps/backend/sonar-project.properties rename sonar-project.properties => apps/frontend/sonar-project.properties (65%) diff --git a/apps/backend/sonar-project.properties b/apps/backend/sonar-project.properties new file mode 100644 index 0000000..f828cf3 --- /dev/null +++ b/apps/backend/sonar-project.properties @@ -0,0 +1,15 @@ +sonar.projectKey=ProjetPiscine_EnerVision +sonar.organization=groupe3-ener-vision +sonar.sourceEncoding=UTF-8 + +# Dossier contenant le code source +sonar.sources=app +# Dossier contenant les tests +sonar.tests=tests + +# Liste des fichiers et dossiers à exclure de l'analyse +sonar.exclusions=.pytest_cache,.venv,alembic,tests + +# Chemin vers le rapport de couverture de code +# Fichier généré par Pytest +sonar.python.coverage.reportPaths=cov.info diff --git a/sonar-project.properties b/apps/frontend/sonar-project.properties similarity index 65% rename from sonar-project.properties rename to apps/frontend/sonar-project.properties index 7155d42..22f1522 100644 --- a/sonar-project.properties +++ b/apps/frontend/sonar-project.properties @@ -2,8 +2,12 @@ sonar.projectKey=ProjetPiscine_EnerVision sonar.organization=groupe3-ener-vision sonar.sourceEncoding=UTF-8 -sonar.sources=apps/frontend/src,apps/backend/app +# Dossier contenant le code source +sonar.sources=src +# 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 sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info From 7eef960a303ddef423a504f363fb6d1d14ce1b42 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Fri, 18 Sep 2026 12:08:59 +0200 Subject: [PATCH 12/27] =?UTF-8?q?test(backend):=20classe=20les=20routes=20?= =?UTF-8?q?du=20contrat=20et=20d=C3=A9rive=20les=20listes=20d'autorisation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ROUTES_A_ROLE` était recopiée dans `test_openapi.py`, et deux de ses entrées portaient `{id}` là où le contrat expose `{user_id}`. Elles ne correspondaient donc à aucune opération, et `test_every_role_guarded_route_documents_the_role_refusal` passait au vert sans rien vérifier sur `PATCH /users/{user_id}` ni sur sa réinitialisation de mot de passe : 11 des 13 routes gardées étaient réellement couvertes. `tests/api/acces.py` porte désormais la classification des 24 routes du contrat en quatre ensembles, dont la table `ROLE_MINIMUM`, et `test_every_declared_route_is_classified` refuse aussi bien une route non classée qu'une entrée qui ne correspond plus à rien. C'est ce que `docs/architecture/20-backend.md` annonçait comme impossible : « ces deux listes sont maintenues à la main, pas dérivées ». Au passage, `chemin_concret()` substitue les trois gabarits du contrat et non plus le seul `{user_id}`, ce qui est sans effet sur le refus anonyme mais nécessaire à un appel qui doit aboutir. --- apps/backend/tests/api/acces.py | 86 +++++++++++++++++++ apps/backend/tests/api/test_openapi.py | 21 ++--- .../tests/api/test_route_protection.py | 67 ++++++++------- 3 files changed, 129 insertions(+), 45 deletions(-) create mode 100644 apps/backend/tests/api/acces.py diff --git a/apps/backend/tests/api/acces.py b/apps/backend/tests/api/acces.py new file mode 100644 index 0000000..0b2864f --- /dev/null +++ b/apps/backend/tests/api/acces.py @@ -0,0 +1,86 @@ +# Pourquoi : classification unique des routes du contrat, lue par test_route_protection.py, +# test_openapi.py et test_matrice_acces.py. Trois listes séparées dérivaient auparavant chacune +# de leur côté, et deux entrées de ROUTES_A_ROLE ne correspondaient plus à aucune route sans que +# rien ne le signale. +# Piège : les trois ensembles doivent rester disjoints et couvrir tout le schéma. C'est +# `test_every_declared_route_is_classified` qui le vérifie, pas la relecture. + +from typing import Final + +from app.core.roles import Role + +Route = tuple[str, str] + +ROUTES_PUBLIQUES: Final[frozenset[Route]] = frozenset( + { + ("GET", "/api/v1/health/live"), + ("GET", "/api/v1/health/ready"), + ("POST", "/api/v1/auth/login"), + # Sans cookie, la déconnexion ne fait rien et répond 204 : elle est idempotente. + ("POST", "/api/v1/auth/logout"), + ("POST", "/api/v1/auth/forgot-password"), + # Protégée par le jeton dans le corps de la requête, pas par un `Principal` : aucune + # authentification préalable ne s'applique, c'est la validité du jeton qui tranche. + ("POST", "/api/v1/auth/reset-password"), + # Même raison : lecture seule, protégée par le jeton passé en paramètre, pas par un + # `Principal`. Le jeton est un secret de 256 bits, non brute-forçable. + ("GET", "/api/v1/auth/reset-password/validate"), + ("GET", "/metrics"), + } +) + +# Le cookie opaque porte seul l'autorisation : sans lui la route rend 401, mais aucun `Principal` +# n'est construit et `require_role` n'entre jamais en jeu. +ROUTE_COOKIE: Final[frozenset[Route]] = frozenset({("POST", "/api/v1/auth/refresh")}) + +# Authentifiées par `CurrentPrincipalDep` nu, donc hors de `require_role` et, avec lui, hors du +# refus `password_change_required`. Volontaire pour `/auth/password`, qui est la sortie de l'état +# provisoire ; subi pour `/auth/logout-all`, cf. test_matrice_acces.py. +ROUTES_SANS_ROLE: Final[frozenset[Route]] = frozenset( + { + ("GET", "/api/v1/auth/me"), + ("POST", "/api/v1/auth/password"), + ("POST", "/api/v1/auth/logout-all"), + } +) + +ROLE_MINIMUM: Final[dict[Route, Role]] = { + ("GET", "/api/v1/sites"): Role.LECTEUR, + ("GET", "/api/v1/sites/{site_id}"): Role.LECTEUR, + ("GET", "/api/v1/sites/{site_id}/current"): Role.LECTEUR, + ("GET", "/api/v1/alerts"): Role.LECTEUR, + ("GET", "/api/v1/recommendations"): Role.LECTEUR, + ("GET", "/api/v1/recommendations/{recommendation_id}"): Role.LECTEUR, + ("GET", "/api/v1/stats/summary"): Role.LECTEUR, + ("GET", "/api/v1/readings"): Role.LECTEUR, + ("GET", "/api/v1/sensors/status"): Role.ADMIN, + ("GET", "/api/v1/users"): Role.ADMIN, + ("POST", "/api/v1/users"): Role.ADMIN, + ("PATCH", "/api/v1/users/{user_id}"): Role.ADMIN, + ("POST", "/api/v1/users/{user_id}/password-reset"): Role.ADMIN, +} + +# Piège : `{recommendation_id}` est typé `int` et `{user_id}` est un UUID. Une substitution +# uniforme par une chaîne quelconque rendrait 422 avant d'atteindre la garde de rôle, et le test +# passerait en prouvant autre chose que ce qu'il annonce. +SUBSTITUTIONS: Final[dict[str, str]] = { + "{user_id}": "00000000-0000-0000-0000-000000000000", + "{site_id}": "site-absent-du-jeu-de-donnees", + "{recommendation_id}": "999999999", +} + + +def chemin_concret(chemin: str) -> str: + for gabarit, valeur in SUBSTITUTIONS.items(): + chemin = chemin.replace(gabarit, valeur) + return chemin + + +def routes_du_schema(schema: dict[str, object]) -> list[Route]: + chemins: dict[str, dict[str, object]] = schema["paths"] # type: ignore[assignment] + return [ + (methode.upper(), chemin) + for chemin, operations in chemins.items() + for methode in operations + if methode.upper() in {"GET", "POST", "PATCH", "PUT", "DELETE"} + ] diff --git a/apps/backend/tests/api/test_openapi.py b/apps/backend/tests/api/test_openapi.py index 85432c4..50e3c3a 100644 --- a/apps/backend/tests/api/test_openapi.py +++ b/apps/backend/tests/api/test_openapi.py @@ -8,6 +8,7 @@ from typing import Any import pytest from app import cli +from tests.api.acces import ROLE_MINIMUM METHODES = {"get", "post", "patch", "put", "delete"} @@ -24,21 +25,11 @@ ORIGINE_VERIFIEE = { # Toute route derrière `require_role` (LecteurDep, OperateurDep, AdminDep) peut rendre 403 pour # `password_change_required`, pas seulement les routes `admin`. -ROUTES_A_ROLE = { - ("GET", "/api/v1/users"), - ("POST", "/api/v1/users"), - ("PATCH", "/api/v1/users/{id}"), - ("POST", "/api/v1/users/{id}/password-reset"), - ("GET", "/api/v1/sites"), - ("GET", "/api/v1/sites/{site_id}"), - ("GET", "/api/v1/sites/{site_id}/current"), - ("GET", "/api/v1/alerts"), - ("GET", "/api/v1/recommendations"), - ("GET", "/api/v1/recommendations/{recommendation_id}"), - ("GET", "/api/v1/stats/summary"), - ("GET", "/api/v1/readings"), - ("GET", "/api/v1/sensors/status"), -} +# Piège : cette liste était recopiée ici, et deux de ses entrées portaient `{id}` là où le contrat +# expose `{user_id}`. Elles ne correspondaient donc à aucune opération, et le test ci-dessous +# passait au vert sans rien vérifier sur ces deux routes. Elle est maintenant dérivée, et +# `test_every_declared_route_is_classified` interdit l'entrée morte. +ROUTES_A_ROLE = frozenset(ROLE_MINIMUM) @pytest.fixture(scope="module") diff --git a/apps/backend/tests/api/test_route_protection.py b/apps/backend/tests/api/test_route_protection.py index 25e4760..ebaa8ff 100644 --- a/apps/backend/tests/api/test_route_protection.py +++ b/apps/backend/tests/api/test_route_protection.py @@ -1,6 +1,6 @@ # Ce test est le garde-fou de l'autorisation : rendre une route publique oblige à modifier -# `ROUTES_PUBLIQUES` ci-dessous, ce qui apparaît en clair dans la diff d'une pull request et -# demande une justification au relecteur. +# `ROUTES_PUBLIQUES` dans `tests/api/acces.py`, ce qui apparaît en clair dans la diff d'une pull +# request et demande une justification au relecteur. # Pourquoi : il interroge réellement chaque route sans jeton au lieu d'inspecter l'arbre de # dépendances. L'arbre n'est accessible que par l'API privée de FastAPI, et surtout une route # peut porter la bonne dépendance tout en répondant quand même. @@ -11,58 +11,64 @@ import pytest from fastapi import FastAPI from httpx import AsyncClient -ROUTES_PUBLIQUES = frozenset( - { - ("GET", "/api/v1/health/live"), - ("GET", "/api/v1/health/ready"), - ("POST", "/api/v1/auth/login"), - # Sans cookie, la déconnexion ne fait rien et répond 204 : elle est idempotente. - ("POST", "/api/v1/auth/logout"), - ("POST", "/api/v1/auth/forgot-password"), - # Protégée par le jeton dans le corps de la requête, pas par un `Principal` : aucune - # authentification préalable ne s'applique, c'est la validité du jeton qui tranche. - ("POST", "/api/v1/auth/reset-password"), - # Même raison : lecture seule, protégée par le jeton passé en paramètre, pas par un - # `Principal`. Le jeton est un secret de 256 bits, non brute-forçable. - ("GET", "/api/v1/auth/reset-password/validate"), - ("GET", "/metrics"), - } +from tests.api.acces import ( + ROLE_MINIMUM, + ROUTE_COOKIE, + ROUTES_PUBLIQUES, + ROUTES_SANS_ROLE, + Route, + chemin_concret, + routes_du_schema, ) -VALEURS_DE_SUBSTITUTION = "00000000-0000-0000-0000-000000000000" STATUTS_DE_REFUS = {401, 403} +HORS_SCHEMA = {("GET", "/metrics")} -def routes_declarees(app: FastAPI) -> list[tuple[str, str]]: +def routes_declarees(app: FastAPI) -> list[Route]: schema: dict[str, Any] = app.openapi() - return [ - (methode.upper(), chemin) - for chemin, operations in schema["paths"].items() - for methode in operations - if methode.upper() in {"GET", "POST", "PATCH", "PUT", "DELETE"} - ] + return routes_du_schema(schema) -def routes_protegees(app: FastAPI) -> list[tuple[str, str]]: +def routes_protegees(app: FastAPI) -> list[Route]: return [route for route in routes_declarees(app) if route not in ROUTES_PUBLIQUES] def test_the_public_allow_list_has_no_stale_entry(app: FastAPI) -> None: - declarees = set(routes_declarees(app)) | {("GET", "/metrics")} + declarees = set(routes_declarees(app)) | HORS_SCHEMA inconnues = ROUTES_PUBLIQUES - declarees assert inconnues == set() +# Sans lui, une route ajoutée sans être classée n'est vue par aucun test de rôle : elle hérite +# du seul contrôle anonyme, et une garde posée au mauvais niveau passe inaperçue. +def test_every_declared_route_is_classified(app: FastAPI) -> None: + classees = ROUTES_PUBLIQUES | ROUTE_COOKIE | ROUTES_SANS_ROLE | set(ROLE_MINIMUM) + + non_classees = set(routes_declarees(app)) - classees + fantomes = classees - set(routes_declarees(app)) - HORS_SCHEMA + + assert non_classees == set(), "classer la route dans tests/api/acces.py" + assert fantomes == set(), "entrée morte : la route n'existe plus sous ce chemin" + + +def test_the_four_classes_of_routes_stay_disjoint() -> None: + classes = [ROUTES_PUBLIQUES, ROUTE_COOKIE, ROUTES_SANS_ROLE, frozenset(ROLE_MINIMUM)] + + for rang, classe in enumerate(classes): + for autre in classes[rang + 1 :]: + assert classe & autre == frozenset() + + async def test_every_route_rejects_an_anonymous_caller_unless_explicitly_public( app: FastAPI, client: AsyncClient ) -> None: ouvertes: list[tuple[str, str, int]] = [] for methode, chemin in routes_protegees(app): - concret = chemin.replace("{user_id}", VALEURS_DE_SUBSTITUTION) - response = await client.request(methode, concret, json={}) + response = await client.request(methode, chemin_concret(chemin), json={}) if response.status_code not in STATUTS_DE_REFUS: ouvertes.append((methode, chemin, response.status_code)) @@ -95,4 +101,5 @@ async def test_the_documentation_routes_are_public_by_design( app: FastAPI, client: AsyncClient, chemin: str ) -> None: response = await client.get(chemin) + assert response.status_code == 200 From 1cd3688256484bfabf73b4dbf1d4c8f9c710592c Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Fri, 18 Sep 2026 12:08:59 +0200 Subject: [PATCH 13/27] =?UTF-8?q?test(backend):=20croise=20chaque=20route?= =?UTF-8?q?=20gard=C3=A9e=20avec=20les=20trois=20r=C3=B4les?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le dépôt vérifiait le refus d'un lecteur sur les cinq routes `admin`, et rien de plus. Les huit routes `lecteur` n'étaient jouées qu'avec un lecteur : une garde posée trop haut, par exemple `AdminDep` sur `/sites`, n'aurait fait échouer aucun test. La matrice couvre les deux sens. Un rôle insuffisant reçoit un 403 `Droits insuffisants`, un rôle suffisant ne le reçoit jamais. L'assertion porte sur le refus de la garde et pas sur un 200, sans quoi elle dépendrait du contenu de la base : un 404 ou un 422 est une réponse acceptable, un 403 non. Sous le marqueur `integration`, la même matrice est rejouée avec de vrais jetons obtenus par `/auth/login`, donc en traversant le décodage du JWT et la relecture du compte en base que `dependency_overrides` court-circuite. Deux invariants y sont figés : `operateur` n'ouvre aujourd'hui aucune route de plus que `lecteur`, faute d'écriture métier dans l'API, et `/auth/logout-all` échappe au refus `password_change_required` parce qu'elle prend un `CurrentPrincipalDep` nu. Le second est signalé, pas corrigé. Closes #61 --- apps/backend/tests/api/test_matrice_acces.py | 279 +++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 apps/backend/tests/api/test_matrice_acces.py diff --git a/apps/backend/tests/api/test_matrice_acces.py b/apps/backend/tests/api/test_matrice_acces.py new file mode 100644 index 0000000..5def3a7 --- /dev/null +++ b/apps/backend/tests/api/test_matrice_acces.py @@ -0,0 +1,279 @@ +# Pourquoi : la matrice rôle x route sur les routes réelles. `test_authorization.py` la joue déjà, +# mais contre une route jetable montée par une fixture, ce qui ne dit rien du niveau effectivement +# posé sur `/sites` ou `/users`. `ROLE_MINIMUM` (tests/api/acces.py) est la référence, et ce +# fichier est ce qui la confronte au comportement observé. +# Piège : l'assertion porte sur le refus de la garde, pas sur un 200. Un rôle suffisant peut +# légitimement recevoir 404 ou 422 selon les données ; ce qui compte est qu'il ne reçoive pas le +# 403 `Droits insuffisants`. Sans cette nuance, le test dépendrait du contenu de la base. +# Les tests `integration` en fin de fichier rejouent la même matrice avec de vrais jetons, donc en +# traversant le décodage du JWT et la relecture du compte, ce que l'override court-circuite. + +import uuid +from collections.abc import AsyncIterator, Callable, Iterator + +import pytest +from fastapi import FastAPI +from httpx import AsyncClient, Response +from sqlalchemy import text + +from app.api.deps import get_current_principal +from app.core.hashing import build_hasher +from app.core.principal import Principal +from app.core.roles import AccountKind, Role, has_at_least +from app.db.session import get_session, get_session_factory +from app.repositories.user import UserRepository +from tests.api.acces import ROLE_MINIMUM, chemin_concret + +ROLES = [Role.LECTEUR, Role.OPERATEUR, Role.ADMIN] +IDS_DE_ROLE = ["lecteur", "operateur", "admin"] +REFUS_DE_DROITS = "Droits insuffisants" +REFUS_DE_MOT_DE_PASSE = "password_change_required" +MOT_DE_PASSE = "un-mot-de-passe-de-recette" + + +# `FakeSession` de tests/factories.py rend un unique objet pour les trois formes d'appel, ce qui +# suffit à un test d'endpoint ciblé mais pas à balayer 13 routes qui interrogent chacune la base +# à sa façon. Ce double rend un résultat vide quelle que soit la forme demandée, pour que la +# réponse observée vienne de la garde de rôle et jamais d'un double mal ajusté. +class ResultatVide: + def scalars(self) -> ResultatVide: + return self + + def all(self) -> list[object]: + return [] + + def first(self) -> None: + return None + + def one_or_none(self) -> None: + return None + + def scalar_one_or_none(self) -> None: + return None + + def mappings(self) -> ResultatVide: + return self + + def __iter__(self) -> Iterator[object]: + return iter(()) + + +class SessionMuette: + async def scalar(self, *_: object, **__: object) -> None: + return None + + async def execute(self, *_: object, **__: object) -> ResultatVide: + return ResultatVide() + + async def scalars(self, *_: object, **__: object) -> ResultatVide: + return ResultatVide() + + async def get(self, *_: object, **__: object) -> None: + return None + + async def flush(self) -> None: + return None + + async def commit(self) -> None: + return None + + async def rollback(self) -> None: + return None + + def add(self, *_: object, **__: object) -> None: + return None + + +@pytest.fixture +def base_muette(app: FastAPI) -> None: + async def override() -> AsyncIterator[SessionMuette]: + yield SessionMuette() + + app.dependency_overrides[get_session] = override + + +def principal(role: Role, *, must_change_password: bool = False) -> Principal: + return Principal( + id=uuid.uuid4(), + email=f"matrice-{role.value}@enervision.fr", + role=role, + kind=AccountKind.HUMAIN, + must_change_password=must_change_password, + ) + + +@pytest.fixture +def connecte(app: FastAPI) -> Iterator[Callable[[Principal], None]]: + def installe(acteur: Principal) -> None: + app.dependency_overrides[get_current_principal] = lambda: acteur + + yield installe + app.dependency_overrides.pop(get_current_principal, None) + + +async def appelle(client: AsyncClient, methode: str, chemin: str, **kwargs: object) -> Response: + return await client.request(methode, chemin_concret(chemin), json={}, **kwargs) # type: ignore[arg-type] + + +def motif_du_refus(response: Response) -> str | None: + if response.status_code != 403: + return None + detail = response.json().get("detail") + return detail if isinstance(detail, str) else None + + +@pytest.mark.parametrize("role", ROLES, ids=IDS_DE_ROLE) +async def test_a_role_below_the_minimum_is_refused_on_every_guarded_route( + connecte: Callable[[Principal], None], + client: AsyncClient, + base_muette: None, + role: Role, +) -> None: + connecte(principal(role)) + laissees_passer: list[tuple[str, str, int]] = [] + + for (methode, chemin), minimum in ROLE_MINIMUM.items(): + if has_at_least(role, minimum): + continue + response = await appelle(client, methode, chemin) + if motif_du_refus(response) != REFUS_DE_DROITS: + laissees_passer.append((methode, chemin, response.status_code)) + + assert laissees_passer == [] + + +# Le pendant du test précédent : sans lui, une garde posée trop haut, par exemple `AdminDep` sur +# `/sites`, ne ferait échouer aucun test du dépôt. +@pytest.mark.parametrize("role", ROLES, ids=IDS_DE_ROLE) +async def test_a_role_at_or_above_the_minimum_is_never_refused_by_the_guard( + connecte: Callable[[Principal], None], + client: AsyncClient, + base_muette: None, + role: Role, +) -> None: + connecte(principal(role)) + refusees: list[tuple[str, str]] = [] + + for (methode, chemin), minimum in ROLE_MINIMUM.items(): + if not has_at_least(role, minimum): + continue + response = await appelle(client, methode, chemin) + if motif_du_refus(response) == REFUS_DE_DROITS: + refusees.append((methode, chemin)) + + assert refusees == [] + + +async def test_a_pending_password_change_is_refused_on_every_guarded_route( + connecte: Callable[[Principal], None], + client: AsyncClient, + base_muette: None, +) -> None: + connecte(principal(Role.ADMIN, must_change_password=True)) + laissees_passer: list[tuple[str, str, int]] = [] + + for methode, chemin in ROLE_MINIMUM: + response = await appelle(client, methode, chemin) + if motif_du_refus(response) != REFUS_DE_MOT_DE_PASSE: + laissees_passer.append((methode, chemin, response.status_code)) + + assert laissees_passer == [] + + +@pytest.fixture +async def comptes_par_role() -> AsyncIterator[dict[Role, str]]: + marque = uuid.uuid4().hex[:12] + hacheur = build_hasher(time_cost=1, memory_cost_kib=8192, parallelism=1, max_concurrency=2) + empreinte = await hacheur.hash(MOT_DE_PASSE) + adresses = {role: f"matrice-{marque}-{role.value}@enervision.fr" for role in ROLES} + + async with get_session_factory()() as session: + depot = UserRepository(session) + for role, email in adresses.items(): + await depot.create(email=email, password_hash=empreinte, role=role) + await session.commit() + + yield adresses + + async with get_session_factory()() as session: + await session.execute( + text("delete from app_user where email like :motif"), {"motif": f"matrice-{marque}-%"} + ) + await session.commit() + + +async def authentifie(client: AsyncClient, email: str) -> dict[str, str]: + reponse = await client.post( + "/api/v1/auth/login", json={"email": email, "password": MOT_DE_PASSE} + ) + assert reponse.status_code == 200, reponse.text + return {"Authorization": f"Bearer {reponse.json()['access_token']}"} + + +@pytest.mark.integration +@pytest.mark.parametrize("role", ROLES, ids=IDS_DE_ROLE) +async def test_a_real_token_reaches_exactly_the_routes_of_its_rank( + comptes_par_role: dict[Role, str], client: AsyncClient, role: Role +) -> None: + entetes = await authentifie(client, comptes_par_role[role]) + ecarts: list[tuple[str, str, int, str]] = [] + + for (methode, chemin), minimum in ROLE_MINIMUM.items(): + response = await appelle(client, methode, chemin, headers=entetes) + refuse = motif_du_refus(response) == REFUS_DE_DROITS + if refuse is has_at_least(role, minimum): + ecarts.append((methode, chemin, response.status_code, response.text[:120])) + + assert ecarts == [] + + +# Contrainte : `operateur` n'ouvre aujourd'hui aucune route de plus que `lecteur`, faute d'écriture +# métier dans l'API. Figer l'égalité rend la régression visible le jour où une route d'opérateur +# arrive sans que `ROLE_MINIMUM` soit mis à jour. +@pytest.mark.integration +async def test_the_operator_rank_opens_nothing_more_than_the_reader_rank( + comptes_par_role: dict[Role, str], client: AsyncClient +) -> None: + lecteur = await authentifie(client, comptes_par_role[Role.LECTEUR]) + operateur = await authentifie(client, comptes_par_role[Role.OPERATEUR]) + divergences: list[tuple[str, str]] = [] + + for methode, chemin in ROLE_MINIMUM: + cote_lecteur = await appelle(client, methode, chemin, headers=lecteur) + cote_operateur = await appelle(client, methode, chemin, headers=operateur) + if cote_lecteur.status_code != cote_operateur.status_code: + divergences.append((methode, chemin)) + + assert divergences == [] + + +# Piège : `/auth/logout-all` prend un `CurrentPrincipalDep` nu, donc elle échappe au gate +# `must_change_password` que seul `require_role` applique. Comportement figé ici, pas corrigé. +@pytest.mark.integration +async def test_a_temporary_password_blocks_the_business_routes_but_not_logout_all( + client: AsyncClient, +) -> None: + marque = uuid.uuid4().hex[:12] + email = f"matrice-{marque}-provisoire@enervision.fr" + hacheur = build_hasher(time_cost=1, memory_cost_kib=8192, parallelism=1, max_concurrency=2) + empreinte = await hacheur.hash(MOT_DE_PASSE) + + async with get_session_factory()() as session: + await UserRepository(session).create( + email=email, password_hash=empreinte, role=Role.ADMIN, must_change_password=True + ) + await session.commit() + + try: + entetes = await authentifie(client, email) + sites = await client.get("/api/v1/sites", headers=entetes) + identite = await client.get("/api/v1/auth/me", headers=entetes) + fermeture = await client.post("/api/v1/auth/logout-all", headers=entetes) + + assert motif_du_refus(sites) == REFUS_DE_MOT_DE_PASSE + assert identite.status_code == 200 + assert fermeture.status_code == 204 + finally: + async with get_session_factory()() as session: + await session.execute(text("delete from app_user where email = :e"), {"e": email}) + await session.commit() From 173f91f26f39a7604311fd9710eb2fa154c7878d Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Fri, 18 Sep 2026 12:09:12 +0200 Subject: [PATCH 14/27] =?UTF-8?q?ci(backend):=20joue=20les=20tests=20d'int?= =?UTF-8?q?=C3=A9gration=20sur=20un=20service=20TimescaleDB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pyproject.toml` écarte le marqueur `integration` par défaut, et aucun workflow ne montait de base : 97 tests, dont les neuf fichiers de dépôts et le schéma de données, n'avaient jamais été joués ailleurs que sur un poste. La condition avait été déléguée à #20, fermée le 17/09 sans l'avoir livrée. Le job monte l'image de `docker-compose.yml` et non une image `postgres` nue : la première migration refuse de s'appliquer sans l'extension TimescaleDB, et un écart d'image rendrait ce job vert sur une base qui n'est pas la nôtre. `db/init/110-test-database.sql` n'étant pas monté ici, l'extension est créée en une étape avant `alembic upgrade head`. Le job `verification` est inchangé : il reste jouable sans Docker, avec son seuil de couverture de 85 %. --- .github/workflows/backend.yml | 60 +++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index b146eb5..eb0eca7 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -56,3 +56,63 @@ jobs: # Le marqueur `integration` est exclu par défaut, donc aucune base n'est nécessaire ici. - name: Tests et couverture run: uv run pytest --cov-fail-under=85 + + # Piège : l'image est celle de docker-compose.yml, pas une image `postgres` nue. La première + # migration (`5353c0e4f094`) échoue volontairement si l'extension TimescaleDB manque, et un + # écart d'image entre la CI et le poste rendrait ce job vert sur une base qui n'est pas la nôtre. + integration: + name: Tests exigeant une base + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/backend + + services: + db: + image: timescale/timescaledb-ha:pg17 + env: + POSTGRES_USER: enervision + POSTGRES_PASSWORD: change_me + POSTGRES_DB: enervision_test + ports: + - "5433:5432" + options: >- + --health-cmd "pg_isready -U enervision -d enervision_test" + --health-interval 10s + --health-timeout 5s + --health-retries 12 + --health-start-period 40s + + env: + DATABASE_URL: postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_test + APP_SECRET_KEY: secret-de-test-assez-long-pour-le-validateur + PGPASSWORD: change_me + + steps: + - name: Récupère le dépôt + uses: actions/checkout@v4 + + - name: Installe uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: apps/backend/uv.lock + + - name: Installe l'interpréteur déclaré par .python-version + run: uv python install + + - name: Synchronise les dépendances sans dévier du verrou + run: uv sync --all-groups --frozen + + # Sur le poste, c'est db/init/110-test-database.sql qui pose l'extension. Ce fichier n'est + # pas monté ici, et sans lui `alembic upgrade head` s'arrête sur la garde de la révision 1. + - name: Active TimescaleDB sur la base de test + run: psql -h localhost -p 5433 -U enervision -d enervision_test -c "CREATE EXTENSION IF NOT EXISTS timescaledb" + + - name: Applique les migrations + run: uv run alembic upgrade head + + # `-m` en ligne de commande écrase celui d'`addopts`. La couverture est désactivée : ce job + # ne joue qu'une partie de la suite, son taux n'aurait aucun sens face au seuil de 85 %. + - name: Tests d'intégration + run: uv run pytest -m integration --no-cov From feee6c3ffc4ec65257f3de8c993bd62e24b3fd0f Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Fri, 18 Sep 2026 12:09:12 +0200 Subject: [PATCH 15/27] =?UTF-8?q?docs(backend):=20documente=20la=20classif?= =?UTF-8?q?ication=20des=20routes=20et=20la=20CI=20d'int=C3=A9gration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La checklist « ajouter une route métier » demandait de maintenir deux listes à la main en prévenant qu'une route oubliée n'y serait pas détectée. Elle pointe désormais vers `tests/api/acces.py`, où l'oubli échoue. Corrige au passage « quatre routes seulement sont publiques » : il y en a sept dans le contrat, les deux sondes, `/auth/login`, `/auth/logout`, `/auth/forgot-password` et les deux routes de réinitialisation, qui portent leur autorisation dans le jeton à usage unique plutôt que dans un `Principal`. `TESTING.md` précise que les tests `integration` ne sont plus facultatifs : ils cassent la CI comme les autres. --- apps/backend/TESTING.md | 24 +++++++++++++++++++++--- docs/architecture/20-backend.md | 28 ++++++++++++++++++++-------- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/apps/backend/TESTING.md b/apps/backend/TESTING.md index e0daf47..30fcc5a 100644 --- a/apps/backend/TESTING.md +++ b/apps/backend/TESTING.md @@ -123,6 +123,11 @@ async def test_repository_reads_back_what_it_wrote(session: AsyncSession) -> Non defaut, ce qui garde `make check` jouable sans Docker. Tout autre marqueur doit etre declare dans `pyproject.toml` : `--strict-markers` refuse les marqueurs inconnus. +Ces tests ne sont pas pour autant facultatifs : le job `integration` de +`.github/workflows/backend.yml` monte un service TimescaleDB, applique les migrations et +les joue a chaque poussee. Un test `integration` casse donc la CI comme un autre. En local, +`make db-up` puis `make test-integration`. + ## Couverture Les branches sont mesurees, pas seulement les lignes. Le seuil de 85 % ne s'applique @@ -142,14 +147,27 @@ uv run pytest tests/api/test_health.py # un seul fichier uv run pytest -k readiness # par motif de nom ``` -## Trois fichiers à connaître avant de toucher à l'authentification +## Quatre fichiers à connaître avant de toucher à l'authentification + +`tests/api/acces.py` porte la classification des routes du contrat, en quatre ensembles : +`ROUTES_PUBLIQUES`, `ROUTE_COOKIE`, `ROUTES_SANS_ROLE` et la table `ROLE_MINIMUM`. Ce n'est pas +un fichier de test, c'est la référence que les trois autres confrontent au comportement observé. +**Toute route ajoutée doit y être classée** : `test_every_declared_route_is_classified` échoue +sinon, et échoue aussi sur une entrée qui ne correspond plus à aucune route. `tests/api/test_route_protection.py` interroge réellement chaque route sans identifiant et échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Il n'inspecte pas l'arbre de dépendances : celui-ci n'est accessible que par l'API privée de FastAPI, et surtout une route peut porter la bonne dépendance tout en répondant quand même. **Rendre une route publique impose -donc de modifier la liste `ROUTES_PUBLIQUES` de ce fichier**, ce qui apparaît en clair dans la -diff d'une pull request. +donc de modifier `ROUTES_PUBLIQUES` dans `acces.py`**, ce qui apparaît en clair dans la diff +d'une pull request. + +`tests/api/test_matrice_acces.py` croise chaque route gardée avec chacun des trois rôles, dans +les deux sens : un rôle insuffisant reçoit un 403 `Droits insuffisants`, un rôle suffisant ne le +reçoit jamais. Le second sens est ce qui rend visible une garde posée trop haut, par exemple +`AdminDep` sur une route de lecture. La même matrice est rejouée sous `integration` avec de vrais +jetons, donc en traversant le décodage du JWT et la relecture du compte en base, que +`dependency_overrides` court-circuite. `tests/services/test_auth.py` donne au faux hacheur un **compteur d'appels**. C'est ce qui rend possibles les deux assertions qui prouvent la conception, et qu'aucune autre forme de test diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index ac14fb1..a224918 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -155,10 +155,12 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke Les codes de la dernière colonne sont ceux que le schéma **déclare**, et le fichier `openapi.json` versionné interdit qu'ils divergent de ce que les routes rendent. -**Quatre routes seulement sont publiques** : les deux sondes, `/auth/login` et `/auth/logout`. +**Sept routes du contrat sont publiques** : les deux sondes, `/auth/login`, `/auth/logout`, +`/auth/forgot-password` et les deux routes de réinitialisation, qui portent leur autorisation dans +le jeton à usage unique plutôt que dans un `Principal`. `tests/api/test_route_protection.py` interroge réellement chaque autre route sans identifiant et échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Rendre une route publique impose -donc de modifier la liste dans ce fichier de test. +donc de modifier `ROUTES_PUBLIQUES` dans `tests/api/acces.py`. `GET /sites` et `GET /sites/{site_id}` sont la première route métier, et le gabarit repris pour `GET /alerts` puis pour les suivantes (`dataset`, `prediction`) : les quatre couches @@ -273,11 +275,16 @@ Checklist pour toute nouvelle route sur le gabarit `sites`/`alerts`/`recommendat au niveau de l'`include_router()` du routeur, `REPONSE_VALIDATION` et les codes locaux (404, 409, ...) directement sur l'endpoint qui les rend. 2. Décrire son tag dans `TAGS`. -3. Si elle passe par `require_role` (`LecteurDep`/`OperateurDep`/`AdminDep`), l'ajouter à - `ROUTES_A_ROLE` dans `tests/api/test_openapi.py`. Si elle passe par `require_trusted_origin`, - l'ajouter à `ORIGINE_VERIFIEE`. **Ces deux listes sont maintenues à la main, pas dérivées** : - une route oubliée n'y est pas détectée automatiquement. -4. `make openapi`, puis `uv run pytest tests/api/test_openapi.py`. +3. **La classer dans `tests/api/acces.py`** : `ROLE_MINIMUM` avec son rôle minimum si elle passe + par `require_role` (`LecteurDep`/`OperateurDep`/`AdminDep`), `ROUTES_SANS_ROLE` si elle se + contente de `CurrentPrincipalDep`, `ROUTES_PUBLIQUES` si elle est ouverte. L'oubli n'est plus + silencieux : `test_every_declared_route_is_classified` échoue sur une route non classée comme + sur une entrée qui ne correspond plus à aucune route. `ROUTES_A_ROLE` de `test_openapi.py` en + est dérivée, et `test_matrice_acces.py` vérifie le niveau réellement monté. +4. Si elle passe par `require_trusted_origin`, l'ajouter à `ORIGINE_VERIFIEE` dans + `tests/api/test_openapi.py`. **Cette liste-là reste maintenue à la main.** +5. `make openapi`, puis `uv run pytest tests/api/test_openapi.py tests/api/test_route_protection.py + tests/api/test_matrice_acces.py`. ## Sécurité @@ -328,9 +335,14 @@ Le reste, par ordre de surface : Conventions, gabarits et arborescence : [`apps/backend/TESTING.md`](../../apps/backend/TESTING.md). -Trois fichiers méritent d'être connus avant de toucher à l'authentification : +Quatre fichiers méritent d'être connus avant de toucher à l'authentification : +- `tests/api/acces.py` : la classification des routes, `ROUTES_PUBLIQUES` et `ROLE_MINIMUM` en + tête. Ce n'est pas un test, c'est la référence que les deux suivants confrontent au + comportement observé. - `tests/api/test_route_protection.py` : le garde-fou de l'autorisation, décrit plus haut. +- `tests/api/test_matrice_acces.py` : chaque route gardée croisée avec chacun des trois rôles, + dans les deux sens, puis rejouée sous `integration` avec de vrais jetons. - `tests/services/test_auth.py` : le faux hacheur y porte un compteur d'appels, ce qui permet les deux assertions qui prouvent le design, à savoir un appel quand l'adresse est inconnue et zéro appel quand la limite est atteinte. From 18a4be6e38d5b2fedcafe705b28e80d1c60bd39c Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Fri, 18 Sep 2026 12:17:43 +0200 Subject: [PATCH 16/27] feat+rollback: job de test sur le backend dans le workflow du front pour sonar, properties de sonar dans la racine du projet --- .github/workflows/frontend.yml | 42 ++++++++++++++++--- apps/backend/sonar-project.properties | 15 ------- ...ect.properties => sonar-project.properties | 4 +- 3 files changed, 39 insertions(+), 22 deletions(-) delete mode 100644 apps/backend/sonar-project.properties rename apps/frontend/sonar-project.properties => sonar-project.properties (80%) diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index aff8d71..074365d 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -29,7 +29,7 @@ jobs: - run: npm run build working-directory: apps/frontend - test: + test-front: needs: build runs-on: ubuntu-latest steps: @@ -39,29 +39,59 @@ jobs: node-version: 24 cache: npm cache-dependency-path: apps/frontend/package-lock.json - - run: npm ci + - name : Installation des dépendances (Front) + run: npm ci working-directory: apps/frontend - - run: npm test --watch=false --code-coverage --coverageReporters=lcov + - name : Lancement des tests et génénration du rapport de couverture (Front) + run: npm test --watch=false --code-coverage --coverageReporters=lcov working-directory: apps/frontend - name: Upload coverage uses: actions/upload-artifact@v4 with: name: frontend-coverage path: apps/frontend/coverage/frontend/lcov.info - + + test-back: + needs: build + 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 + - name: Synchronise les dépendances sans dévier du verrou + run: uv sync --all-groups --frozen + - name : Lancement des tests et génénration du rapport de couverture (Back) + run: uv run pytest --cov-fail-under=85 --cov-report=lcov + working-directory: apps/backend + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: backend-coverage + path: apps/backend/cov.info + sonarqube: - needs: [build, test] + needs: [build, test-front, test-back] name: SonarQube runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - - name: Download coverage + - 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/coverage/backend - name: SonarQube Scan uses: SonarSource/sonarqube-scan-action@v8 env: diff --git a/apps/backend/sonar-project.properties b/apps/backend/sonar-project.properties deleted file mode 100644 index f828cf3..0000000 --- a/apps/backend/sonar-project.properties +++ /dev/null @@ -1,15 +0,0 @@ -sonar.projectKey=ProjetPiscine_EnerVision -sonar.organization=groupe3-ener-vision -sonar.sourceEncoding=UTF-8 - -# Dossier contenant le code source -sonar.sources=app -# Dossier contenant les tests -sonar.tests=tests - -# Liste des fichiers et dossiers à exclure de l'analyse -sonar.exclusions=.pytest_cache,.venv,alembic,tests - -# Chemin vers le rapport de couverture de code -# Fichier généré par Pytest -sonar.python.coverage.reportPaths=cov.info diff --git a/apps/frontend/sonar-project.properties b/sonar-project.properties similarity index 80% rename from apps/frontend/sonar-project.properties rename to sonar-project.properties index 22f1522..5601347 100644 --- a/apps/frontend/sonar-project.properties +++ b/sonar-project.properties @@ -3,7 +3,8 @@ sonar.organization=groupe3-ener-vision sonar.sourceEncoding=UTF-8 # Dossier contenant le code source -sonar.sources=src +sonar.sources=apps/frontend/src,apps/backend/app +sonar.tests=apps/backend/tests # 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 @@ -11,3 +12,4 @@ sonar.exclusions=**/node_modules/**,**/dist/**,**/*.spec.js,**/*.test.js,github, # 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 \ No newline at end of file From fb06bf0062ceb1d01d86e4e6ce1db74bf57cb1ee Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Fri, 18 Sep 2026 12:23:50 +0200 Subject: [PATCH 17/27] =?UTF-8?q?chore(ci):=20isole=20l'audit=20de=20s?= =?UTF-8?q?=C3=A9curit=C3=A9=20et=20le=20fait=20porter=20sur=20le=20verrou?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/backend.yml | 25 +++++++++++++++++++++++-- .github/workflows/frontend.yml | 17 +++++++---------- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index dbe77f6..4fe2016 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -57,5 +57,26 @@ jobs: - name: Tests et couverture run: uv run pytest --cov-fail-under=85 - - name: Audit de sécurité des dépendances - run: uv run --with pip-audit pip-audit + 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 diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 55db548..a5ae2e8 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -1,5 +1,4 @@ name: Frontend -# Pipeline à choix multiple on: push: @@ -10,8 +9,9 @@ on: paths: - "apps/frontend/**" - ".github/workflows/frontend.yml" -# Ordre de lancement des jobs -# build -> test -> sonarqube -> deploy + +permissions: + contents: read jobs: build: @@ -30,18 +30,15 @@ jobs: working-directory: apps/frontend security-audit: - needs: build + name: Audit des dépendances 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 audit --audit-level=high + # 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: @@ -63,7 +60,7 @@ jobs: with: name: frontend-coverage path: apps/frontend/coverage/frontend/lcov.info - + sonarqube: needs: [build, test] name: SonarQube From f427f8a8f39b19c34302242f999796f09fbeb2f7 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Fri, 18 Sep 2026 12:32:18 +0200 Subject: [PATCH 18/27] =?UTF-8?q?chore+feat:=20all=C3=A8gement=20du=20work?= =?UTF-8?q?flow=20front,=20workflow=20pour=20sonarqube?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/frontend.yml | 48 +------------- .github/workflows/sonarqube.yml | 113 ++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 47 deletions(-) create mode 100644 .github/workflows/sonarqube.yml diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 074365d..c8ad03f 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -29,7 +29,7 @@ jobs: - run: npm run build working-directory: apps/frontend - test-front: + test: needs: build runs-on: ubuntu-latest steps: @@ -50,49 +50,3 @@ jobs: with: name: frontend-coverage path: apps/frontend/coverage/frontend/lcov.info - - test-back: - needs: build - 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 - - name: Synchronise les dépendances sans dévier du verrou - run: uv sync --all-groups --frozen - - name : Lancement des tests et génénration du rapport de couverture (Back) - run: uv run pytest --cov-fail-under=85 --cov-report=lcov - working-directory: apps/backend - - name: Upload coverage - uses: actions/upload-artifact@v4 - with: - name: backend-coverage - path: apps/backend/cov.info - - sonarqube: - needs: [build, 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/coverage/backend - - name: SonarQube Scan - uses: SonarSource/sonarqube-scan-action@v8 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml new file mode 100644 index 0000000..6200f6b --- /dev/null +++ b/.github/workflows/sonarqube.yml @@ -0,0 +1,113 @@ +name: SonarQube + +on: + push: + paths: + - "apps/frontend/**" + - ".github/workflows/frontend.yml" + pull_request: + paths: + - "apps/frontend/**" + - ".github/workflows/frontend.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 + - name: Synchronise les dépendances sans dévier du verrou + run: uv sync --all-groups --frozen + + 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: Installe l'interpréteur déclaré par .python-version + run: uv python install + - name: Synchronise les dépendances sans dévier du verrou + run: uv sync --all-groups --frozen + - name : Lancement des tests et génénration du rapport de couverture (Back) + run: uv run pytest --cov-fail-under=85 --cov-report=lcov + working-directory: apps/backend + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: backend-coverage + path: apps/backend/cov.info + + 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 }} From e3d436ea53707369cf4f956965252d9edf4cfaed Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Fri, 18 Sep 2026 13:13:23 +0200 Subject: [PATCH 19/27] test: jobs de tests et de build de sonarqube --- .github/workflows/sonarqube.yml | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 6200f6b..08dd8aa 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -40,12 +40,15 @@ jobs: 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: @@ -63,8 +66,23 @@ jobs: 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 + test-back: needs: build-back @@ -76,13 +94,11 @@ jobs: with: enable-cache: true cache-dependency-glob: apps/backend/uv.lock - - name: Installe l'interpréteur déclaré par .python-version - run: uv python install - - name: Synchronise les dépendances sans dévier du verrou - run: uv sync --all-groups --frozen + - name : Lancement des tests et génénration du rapport de couverture (Back) run: uv run pytest --cov-fail-under=85 --cov-report=lcov working-directory: apps/backend + - name: Upload coverage uses: actions/upload-artifact@v4 with: From 0b415803103f7a2aeef2b4009165f27c698cb8dd Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Fri, 18 Sep 2026 13:14:50 +0200 Subject: [PATCH 20/27] test: jobs de tests et de build de sonarqube --- .github/workflows/sonarqube.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 08dd8aa..62ff9fa 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -4,11 +4,13 @@ on: push: paths: - "apps/frontend/**" - - ".github/workflows/frontend.yml" + - "apps/backend/**" + - ".github/workflows/sonarqube.yml" pull_request: paths: - "apps/frontend/**" - - ".github/workflows/frontend.yml" + - "apps/backend/**" + - ".github/workflows/sonarqube.yml" # Build l'ensemble du projet, puis lance les tests From 91d435748cbabbb0a40e1ad85c92eb47b8f3530b Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Fri, 18 Sep 2026 13:17:31 +0200 Subject: [PATCH 21/27] test: jobs de tests et de build de sonarqube --- .github/workflows/sonarqube.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 62ff9fa..3cd2019 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -84,6 +84,7 @@ jobs: - name: Typage run: uv run mypy app + working-directory: apps/backend test-back: From 56e8f9572976e1d333dcf1d71956aca0be89749f Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Fri, 18 Sep 2026 13:22:31 +0200 Subject: [PATCH 22/27] test: jobs de tests et de build de sonarqube --- .github/workflows/sonarqube.yml | 2 +- apps/backend/coverage.lcov | 3070 +++++++++++++++++++++++++++++++ 2 files changed, 3071 insertions(+), 1 deletion(-) create mode 100644 apps/backend/coverage.lcov diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 3cd2019..3062a8b 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -106,7 +106,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: backend-coverage - path: apps/backend/cov.info + path: apps/backend/coverage.lcov sonarqube: needs: [build-front, build-back, test-front, test-back] diff --git a/apps/backend/coverage.lcov b/apps/backend/coverage.lcov new file mode 100644 index 0000000..3ce3aa4 --- /dev/null +++ b/apps/backend/coverage.lcov @@ -0,0 +1,3070 @@ +SF:app\__init__.py +end_of_record +SF:app\api\__init__.py +end_of_record +SF:app\api\deps.py +DA:8,1 +DA:9,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:17,1 +DA:18,1 +DA:19,1 +DA:20,1 +DA:21,1 +DA:22,1 +DA:23,1 +DA:24,1 +DA:25,1 +DA:26,1 +DA:27,1 +DA:28,1 +DA:29,1 +DA:30,1 +DA:31,1 +DA:32,1 +DA:33,1 +DA:34,1 +DA:35,1 +DA:36,1 +DA:37,1 +DA:38,1 +DA:39,1 +DA:40,1 +DA:41,1 +DA:42,1 +DA:44,1 +DA:45,1 +DA:47,1 +DA:49,1 +DA:50,1 +DA:53,1 +DA:54,1 +DA:61,1 +DA:62,1 +DA:72,1 +DA:73,1 +DA:76,1 +DA:84,1 +DA:85,1 +DA:93,1 +DA:97,1 +DA:98,0 +DA:99,0 +DA:100,0 +DA:101,1 +DA:104,1 +DA:105,1 +DA:119,1 +DA:126,1 +DA:154,1 +DA:157,1 +DA:161,0 +DA:170,1 +DA:173,1 +DA:174,1 +DA:177,1 +DA:180,1 +DA:181,1 +DA:184,1 +DA:187,1 +DA:188,1 +DA:191,1 +DA:194,1 +DA:195,0 +DA:198,1 +DA:201,1 +DA:202,1 +DA:205,1 +DA:208,1 +DA:209,0 +DA:212,1 +DA:215,1 +DA:220,1 +DA:221,1 +DA:223,1 +DA:224,1 +DA:225,1 +DA:226,0 +DA:227,1 +DA:228,1 +DA:230,0 +DA:231,0 +DA:232,0 +DA:236,0 +DA:237,0 +DA:238,0 +DA:239,0 +DA:241,0 +DA:250,1 +DA:253,1 +DA:254,1 +DA:255,1 +DA:256,1 +DA:259,1 +DA:260,1 +DA:261,1 +DA:263,1 +DA:266,1 +DA:267,1 +DA:268,1 +DA:271,1 +DA:274,1 +DA:275,1 +DA:276,1 +DA:277,1 +DA:278,1 +LF:114 +LH:99 +FN:53,58,_non_authentifie +FNDA:1,_non_authentifie +FN:61,67,get_token_policy +FNDA:1,get_token_policy +FN:73,81,_hasher_cache +FNDA:1,_hasher_cache +FN:84,90,get_hasher +FNDA:1,get_hasher +FN:93,101,get_client_ip +FNDA:1,get_client_ip +FN:104,116,get_mailer +FNDA:1,get_mailer +FN:119,151,get_auth_service +FNDA:1,get_auth_service +FN:157,167,get_user_service +FNDA:0,get_user_service +FN:173,174,get_site_service +FNDA:1,get_site_service +FN:180,181,get_alert_service +FNDA:1,get_alert_service +FN:187,188,get_recommendation_service +FNDA:1,get_recommendation_service +FN:194,195,get_stats_service +FNDA:0,get_stats_service +FN:201,202,get_reading_service +FNDA:1,get_reading_service +FN:208,209,get_sensor_service +FNDA:0,get_sensor_service +FN:215,247,get_current_principal +FNDA:1,get_current_principal +FN:253,263,require_role +FNDA:1,require_role +FN:254,261,require_role.garde +FNDA:1,require_role.garde +FN:271,278,require_trusted_origin +FNDA:1,require_trusted_origin +FNF:18 +FNH:15 +BRDA:97,0,jump to line 98,0 +BRDA:97,0,jump to line 101,1 +BRDA:99,0,jump to line 100,- +BRDA:99,0,jump to line 101,- +BRDA:220,0,jump to line 221,1 +BRDA:220,0,jump to line 223,1 +BRDA:231,0,jump to line 232,- +BRDA:231,0,jump to line 236,- +BRDA:236,0,jump to line 237,- +BRDA:236,0,jump to line 238,- +BRDA:238,0,jump to line 239,- +BRDA:238,0,jump to line 241,- +BRDA:255,0,jump to line 256,1 +BRDA:255,0,jump to line 259,1 +BRDA:259,0,jump to line 260,1 +BRDA:259,0,jump to line 261,1 +BRDA:275,0,jump to line 276,1 +BRDA:275,0,jump to line 277,1 +BRDA:277,0,jump to line 278,1 +BRDA:277,0,return from function 'require_trusted_origin',0 +BRF:20 +BRH:10 +end_of_record +SF:app\api\errors.py +DA:6,1 +DA:7,1 +DA:9,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:15,1 +DA:18,1 +DA:19,1 +DA:26,1 +DA:31,1 +DA:32,1 +DA:33,1 +DA:39,1 +DA:45,1 +DA:46,1 +DA:47,1 +LF:17 +LH:17 +FN:18,28,validation_error_handler +FNDA:1,validation_error_handler +FN:31,42,unhandled_error_handler +FNDA:1,unhandled_error_handler +FN:45,47,register_error_handlers +FNDA:1,register_error_handlers +FNF:3 +FNH:3 +end_of_record +SF:app\api\middleware.py +DA:8,1 +DA:9,1 +DA:11,1 +DA:12,1 +DA:13,1 +DA:15,1 +DA:21,1 +DA:24,1 +DA:25,1 +DA:28,1 +DA:29,1 +DA:30,1 +DA:33,1 +DA:34,1 +DA:35,1 +LF:15 +LH:15 +FN:25,35,SecurityHeadersMiddleware.dispatch +FNDA:1,SecurityHeadersMiddleware.dispatch +FNF:1 +FNH:1 +BRDA:29,0,jump to line 30,1 +BRDA:29,0,jump to line 33,1 +BRDA:33,0,jump to line 34,1 +BRDA:33,0,jump to line 35,1 +BRF:4 +BRH:4 +end_of_record +SF:app\api\openapi.py +DA:5,1 +DA:7,1 +DA:9,1 +DA:10,1 +DA:12,1 +DA:14,1 +DA:16,1 +DA:35,1 +DA:88,1 +DA:101,1 +DA:111,1 +DA:121,1 +DA:128,1 +DA:138,1 +DA:151,1 +DA:161,1 +DA:168,1 +LF:17 +LH:17 +end_of_record +SF:app\api\security.py +DA:6,1 +DA:8,1 +DA:10,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:16,1 +DA:18,1 +DA:19,1 +DA:20,1 +DA:23,1 +LF:11 +LH:11 +FN:13,23,require_metrics_token +FNDA:1,require_metrics_token +FNF:1 +FNH:1 +BRDA:15,0,jump to line 16,1 +BRDA:15,0,jump to line 18,1 +BRDA:20,0,jump to line 23,1 +BRDA:20,0,return from function 'require_metrics_token',1 +BRF:4 +BRH:4 +end_of_record +SF:app\api\v1\__init__.py +end_of_record +SF:app\api\v1\endpoints\__init__.py +end_of_record +SF:app\api\v1\endpoints\alerts.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:5,1 +DA:7,1 +DA:10,1 +DA:16,1 +DA:22,1 +DA:23,1 +LF:9 +LH:9 +FN:16,23,list_alerts +FNDA:1,list_alerts +FNF:1 +FNH:1 +end_of_record +SF:app\api\v1\endpoints\auth.py +DA:5,1 +DA:7,1 +DA:14,1 +DA:22,1 +DA:23,1 +DA:24,1 +DA:33,1 +DA:34,1 +DA:42,1 +DA:43,1 +DA:45,1 +DA:46,1 +DA:47,1 +DA:49,1 +DA:70,1 +DA:81,1 +DA:83,1 +DA:85,1 +DA:94,1 +DA:99,1 +DA:109,1 +DA:112,1 +DA:113,1 +DA:114,1 +DA:124,1 +DA:125,1 +DA:126,1 +DA:127,1 +DA:130,1 +DA:131,1 +DA:132,1 +DA:133,1 +DA:134,1 +DA:137,1 +DA:143,1 +DA:151,1 +DA:152,1 +DA:154,1 +DA:155,1 +DA:158,1 +DA:159,1 +DA:160,1 +DA:165,1 +DA:166,1 +DA:167,1 +DA:171,1 +DA:172,1 +DA:175,1 +DA:182,1 +DA:189,1 +DA:191,1 +DA:192,1 +DA:197,1 +DA:198,1 +DA:199,1 +DA:208,1 +DA:211,1 +DA:218,1 +DA:221,1 +DA:222,1 +DA:223,1 +DA:224,1 +DA:225,1 +DA:228,1 +DA:235,1 +DA:241,0 +DA:242,0 +DA:243,0 +DA:244,0 +DA:247,1 +DA:253,1 +DA:254,1 +DA:257,1 +DA:264,1 +DA:273,0 +DA:275,0 +DA:276,0 +DA:283,0 +DA:284,0 +DA:288,0 +DA:289,0 +DA:292,1 +DA:298,1 +DA:306,1 +DA:308,1 +DA:309,1 +DA:315,1 +DA:316,1 +DA:317,1 +DA:324,1 +DA:330,1 +DA:331,1 +DA:334,1 +DA:341,1 +DA:349,1 +DA:351,1 +DA:352,1 +DA:358,1 +DA:359,1 +DA:360,1 +DA:364,1 +DA:365,1 +LF:102 +LH:91 +FN:109,118,repond +FNDA:1,repond +FN:124,127,entete_de_suppression +FNDA:1,entete_de_suppression +FN:130,134,lit_le_cookie +FNDA:1,lit_le_cookie +FN:143,172,login +FNDA:1,login +FN:182,208,refresh +FNDA:1,refresh +FN:218,225,logout +FNDA:1,logout +FN:235,244,logout_all +FNDA:0,logout_all +FN:253,254,me +FNDA:1,me +FN:264,289,change_password +FNDA:0,change_password +FN:298,321,forgot_password +FNDA:1,forgot_password +FN:330,331,validate_reset_token +FNDA:1,validate_reset_token +FN:341,365,reset_password +FNDA:1,reset_password +FNF:12 +FNH:10 +BRDA:132,0,jump to line 133,1 +BRDA:132,0,jump to line 134,1 +BRDA:223,0,jump to line 224,1 +BRDA:223,0,jump to line 225,1 +BRF:4 +BRH:4 +end_of_record +SF:app\api\v1\endpoints\health.py +DA:1,1 +DA:2,1 +DA:3,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:8,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:16,1 +DA:17,1 +DA:18,1 +DA:26,1 +DA:27,1 +DA:28,1 +DA:29,1 +DA:32,1 +DA:33,1 +DA:34,1 +DA:39,1 +DA:40,1 +DA:41,1 +DA:46,1 +DA:47,1 +LF:25 +LH:25 +FN:17,23,liveness +FNDA:1,liveness +FN:27,47,readiness +FNDA:1,readiness +FNF:2 +FNH:2 +BRDA:39,0,jump to line 40,1 +BRDA:39,0,jump to line 46,1 +BRF:2 +BRH:2 +end_of_record +SF:app\api\v1\endpoints\readings.py +DA:1,1 +DA:3,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:8,1 +DA:9,1 +DA:11,1 +DA:13,1 +DA:25,1 +DA:31,1 +DA:40,1 +DA:41,1 +DA:44,1 +DA:45,1 +DA:49,1 +DA:50,1 +DA:54,1 +LF:18 +LH:18 +FN:31,54,list_readings +FNDA:1,list_readings +FNF:1 +FNH:1 +end_of_record +SF:app\api\v1\endpoints\recommendations.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:9,1 +DA:11,1 +DA:17,1 +DA:18,1 +DA:21,1 +DA:22,1 +DA:25,1 +DA:31,1 +DA:34,1 +DA:35,1 +DA:36,1 +DA:37,1 +DA:40,1 +LF:19 +LH:19 +FN:18,22,list_recommendations +FNDA:1,list_recommendations +FN:31,40,get_recommendation +FNDA:1,get_recommendation +FNF:2 +FNH:2 +end_of_record +SF:app\api\v1\endpoints\sensors.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:6,1 +DA:9,1 +DA:14,1 +DA:15,1 +DA:16,1 +LF:8 +LH:8 +FN:14,16,get_status +FNDA:1,get_status +FNF:1 +FNH:1 +end_of_record +SF:app\api\v1\endpoints\sites.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:9,1 +DA:11,1 +DA:17,1 +DA:18,1 +DA:19,1 +DA:20,1 +DA:23,1 +DA:29,1 +DA:30,1 +DA:31,1 +DA:32,1 +DA:33,1 +DA:36,1 +DA:39,1 +DA:45,1 +DA:46,1 +DA:47,1 +DA:48,1 +DA:49,1 +DA:52,1 +LF:26 +LH:26 +FN:18,20,list_sites +FNDA:1,list_sites +FN:29,36,get_site +FNDA:1,get_site +FN:45,52,get_current +FNDA:1,get_current +FNF:3 +FNH:3 +end_of_record +SF:app\api\v1\endpoints\stats.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:6,1 +DA:9,1 +DA:14,1 +DA:15,1 +DA:16,1 +LF:8 +LH:8 +FN:14,16,get_summary +FNDA:1,get_summary +FNF:1 +FNH:1 +end_of_record +SF:app\api\v1\endpoints\users.py +DA:1,1 +DA:3,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:8,1 +DA:9,1 +DA:15,1 +DA:17,1 +DA:18,1 +DA:20,1 +DA:25,1 +DA:30,1 +DA:43,1 +DA:44,1 +DA:45,1 +DA:46,1 +DA:49,1 +DA:56,1 +DA:63,1 +DA:64,1 +DA:65,1 +DA:71,1 +DA:72,1 +DA:76,1 +DA:77,1 +DA:83,1 +DA:89,1 +DA:95,1 +DA:96,1 +DA:97,1 +DA:98,1 +DA:99,1 +DA:100,1 +DA:103,1 +DA:104,1 +DA:107,1 +DA:108,1 +DA:113,1 +DA:114,1 +DA:117,0 +DA:118,0 +DA:121,1 +DA:127,1 +DA:130,1 +DA:131,1 +DA:132,1 +DA:133,0 +DA:134,0 +DA:138,1 +DA:139,1 +LF:51 +LH:47 +FN:44,46,list_users +FNDA:1,list_users +FN:56,80,create_user +FNDA:1,create_user +FN:89,118,update_user +FNDA:1,update_user +FN:127,142,reset_password +FNDA:1,reset_password +FNF:4 +FNH:4 +BRDA:97,0,jump to line 98,1 +BRDA:97,0,jump to line 99,1 +BRDA:99,0,jump to line 100,1 +BRDA:99,0,jump to line 113,1 +BRDA:113,0,jump to line 114,1 +BRDA:113,0,jump to line 117,0 +BRF:6 +BRH:5 +end_of_record +SF:app\api\v1\router.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:16,1 +DA:17,1 +DA:18,1 +DA:19,1 +DA:20,1 +DA:21,1 +DA:24,1 +DA:30,1 +DA:31,1 +DA:34,1 +LF:13 +LH:13 +end_of_record +SF:app\cli.py +DA:8,1 +DA:9,1 +DA:10,1 +DA:11,1 +DA:12,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:16,1 +DA:18,1 +DA:20,1 +DA:21,1 +DA:22,1 +DA:23,1 +DA:24,1 +DA:25,1 +DA:26,1 +DA:28,1 +DA:29,1 +DA:32,1 +DA:35,0 +DA:41,0 +DA:43,0 +DA:44,0 +DA:46,0 +DA:47,0 +DA:49,0 +DA:50,0 +DA:52,0 +DA:58,0 +DA:60,0 +DA:69,1 +DA:70,1 +DA:81,1 +DA:82,1 +DA:83,1 +DA:86,1 +DA:87,1 +DA:90,1 +DA:91,1 +DA:92,1 +DA:95,1 +DA:96,1 +DA:97,1 +DA:99,1 +DA:100,1 +DA:101,1 +DA:104,1 +DA:108,1 +DA:111,1 +DA:112,1 +DA:115,1 +DA:116,1 +DA:117,1 +DA:123,1 +DA:124,1 +DA:125,1 +DA:126,1 +DA:127,1 +DA:130,1 +DA:131,1 +DA:132,1 +DA:133,1 +DA:134,1 +DA:136,1 +DA:137,1 +DA:138,1 +DA:139,1 +DA:140,1 +DA:141,1 +DA:142,1 +DA:143,1 +DA:144,1 +DA:145,1 +DA:148,1 +DA:149,1 +DA:151,1 +DA:152,1 +DA:153,1 +DA:155,0 +DA:157,0 +DA:165,0 +DA:166,0 +LF:83 +LH:68 +FN:32,63,create_admin +FNDA:0,create_admin +FN:69,78,settings_du_contrat +FNDA:1,settings_du_contrat +FN:81,83,schema_du_contrat +FNDA:1,schema_du_contrat +FN:86,87,rend_le_contrat +FNDA:1,rend_le_contrat +FN:90,92,export_openapi +FNDA:1,export_openapi +FN:95,112,build_parser +FNDA:1,build_parser +FN:115,127,genere_mot_de_passe +FNDA:1,genere_mot_de_passe +FN:130,145,read_password +FNDA:1,read_password +FN:148,166,main +FNDA:1,main +FNF:9 +FNH:8 +BRDA:46,0,jump to line 47,- +BRDA:46,0,jump to line 49,- +BRDA:49,0,jump to line 50,- +BRDA:49,0,jump to line 52,- +BRDA:131,0,jump to line 132,1 +BRDA:131,0,jump to line 136,1 +BRDA:137,0,jump to line 138,1 +BRDA:137,0,jump to line 139,1 +BRDA:143,0,jump to line 144,1 +BRDA:143,0,jump to line 145,1 +BRDA:151,0,jump to line 152,1 +BRDA:151,0,jump to line 155,0 +BRF:12 +BRH:7 +end_of_record +SF:app\core\__init__.py +end_of_record +SF:app\core\config.py +DA:1,1 +DA:2,1 +DA:4,1 +DA:5,1 +DA:7,1 +DA:8,1 +DA:10,1 +DA:11,1 +DA:12,1 +DA:17,1 +DA:18,1 +DA:25,1 +DA:26,1 +DA:27,1 +DA:28,1 +DA:29,1 +DA:30,1 +DA:32,1 +DA:33,1 +DA:34,1 +DA:35,1 +DA:37,1 +DA:38,1 +DA:39,1 +DA:40,1 +DA:42,1 +DA:43,1 +DA:44,1 +DA:45,1 +DA:47,1 +DA:48,1 +DA:49,1 +DA:50,1 +DA:52,1 +DA:53,1 +DA:54,1 +DA:55,1 +DA:57,1 +DA:58,1 +DA:59,1 +DA:60,1 +DA:62,1 +DA:63,1 +DA:64,1 +DA:65,1 +DA:66,1 +DA:67,1 +DA:68,1 +DA:70,1 +DA:71,1 +DA:72,1 +DA:74,1 +DA:75,1 +DA:76,1 +DA:78,1 +DA:79,1 +DA:80,1 +DA:82,1 +DA:83,1 +DA:84,1 +DA:86,1 +DA:87,1 +DA:88,1 +DA:89,1 +DA:90,1 +DA:92,1 +DA:93,1 +DA:94,1 +DA:95,1 +DA:96,1 +DA:99,1 +DA:100,0 +DA:104,1 +DA:105,1 +DA:107,1 +DA:108,1 +DA:112,1 +DA:113,1 +DA:115,1 +DA:116,1 +DA:118,1 +DA:121,1 +DA:122,1 +DA:123,1 +LF:84 +LH:83 +FN:75,76,Settings.allowed_origins +FNDA:1,Settings.allowed_origins +FN:79,80,Settings.is_production +FNDA:1,Settings.is_production +FN:83,84,Settings.cookies_are_secure +FNDA:1,Settings.cookies_are_secure +FN:87,90,Settings.api_docs_are_exposed +FNDA:1,Settings.api_docs_are_exposed +FN:93,118,Settings._refuse_les_configurations_dangereuses +FNDA:1,Settings._refuse_les_configurations_dangereuses +FN:122,123,get_settings +FNDA:1,get_settings +FNF:6 +FNH:6 +BRDA:88,0,jump to line 89,1 +BRDA:88,0,jump to line 90,1 +BRDA:95,0,jump to line 96,1 +BRDA:95,0,jump to line 99,1 +BRDA:99,0,jump to line 100,0 +BRDA:99,0,jump to line 104,1 +BRDA:104,0,jump to line 105,1 +BRDA:104,0,jump to line 107,1 +BRDA:107,0,jump to line 108,1 +BRDA:107,0,jump to line 112,1 +BRDA:112,0,jump to line 113,1 +BRDA:112,0,jump to line 115,1 +BRDA:115,0,jump to line 116,1 +BRDA:115,0,jump to line 118,1 +BRF:14 +BRH:13 +end_of_record +SF:app\core\cookies.py +DA:5,1 +DA:6,1 +DA:8,1 +DA:10,1 +DA:13,1 +DA:14,1 +DA:23,1 +DA:24,1 +DA:25,1 +DA:35,1 +DA:36,1 +DA:37,1 +DA:47,1 +DA:48,1 +DA:50,1 +DA:53,1 +DA:54,1 +DA:55,1 +DA:58,1 +DA:59,1 +DA:60,1 +DA:61,1 +LF:22 +LH:22 +FN:24,33,RefreshCookie.build +FNDA:1,RefreshCookie.build +FN:36,45,RefreshCookie.expired +FNDA:1,RefreshCookie.expired +FN:47,48,RefreshCookie.as_kwargs +FNDA:1,RefreshCookie.as_kwargs +FN:50,55,RefreshCookie.as_deletion_kwargs +FNDA:1,RefreshCookie.as_deletion_kwargs +FN:58,61,cookie_name +FNDA:1,cookie_name +FNF:5 +FNH:5 +BRDA:59,0,jump to line 60,1 +BRDA:59,0,jump to line 61,1 +BRF:2 +BRH:2 +end_of_record +SF:app\core\hashing.py +DA:9,1 +DA:11,1 +DA:12,1 +DA:13,1 +DA:14,1 +DA:16,1 +DA:19,1 +DA:20,1 +DA:21,1 +DA:22,1 +DA:23,1 +DA:25,1 +DA:26,1 +DA:28,1 +DA:29,1 +DA:31,1 +DA:32,1 +DA:34,1 +DA:35,1 +DA:36,1 +DA:37,1 +DA:38,1 +DA:40,1 +DA:41,1 +DA:42,1 +DA:43,1 +DA:44,1 +DA:47,1 +DA:54,1 +LF:29 +LH:29 +FN:20,23,Argon2Hasher.__init__ +FNDA:1,Argon2Hasher.__init__ +FN:25,26,Argon2Hasher.hash +FNDA:1,Argon2Hasher.hash +FN:28,29,Argon2Hasher.verify +FNDA:1,Argon2Hasher.verify +FN:31,32,Argon2Hasher.verify_dummy +FNDA:1,Argon2Hasher.verify_dummy +FN:34,38,Argon2Hasher.needs_rehash +FNDA:1,Argon2Hasher.needs_rehash +FN:40,44,Argon2Hasher._verify +FNDA:1,Argon2Hasher._verify +FN:47,63,build_hasher +FNDA:1,build_hasher +FNF:7 +FNH:7 +end_of_record +SF:app\core\logging.py +DA:6,1 +DA:7,1 +DA:8,1 +DA:9,1 +DA:11,1 +DA:13,1 +DA:15,1 +DA:31,1 +DA:32,1 +DA:33,1 +DA:34,1 +DA:37,1 +DA:38,1 +DA:39,1 +DA:40,1 +DA:41,1 +DA:42,1 +DA:43,1 +DA:44,1 +DA:47,1 +DA:48,1 +DA:49,1 +DA:91,1 +DA:92,1 +LF:24 +LH:24 +FN:31,34,redact +FNDA:1,redact +FN:38,44,RedactingFilter.filter +FNDA:1,RedactingFilter.filter +FN:47,88,configure_logging +FNDA:1,configure_logging +FN:91,92,get_logger +FNDA:1,get_logger +FNF:4 +FNH:4 +BRDA:32,0,jump to line 33,1 +BRDA:32,0,jump to line 34,1 +BRDA:41,0,jump to line 42,1 +BRDA:41,0,jump to line 44,1 +BRF:4 +BRH:4 +end_of_record +SF:app\core\mailer.py +DA:4,1 +DA:5,1 +DA:7,1 +DA:9,1 +DA:11,1 +DA:14,1 +DA:15,1 +DA:24,1 +DA:25,1 +DA:26,1 +DA:28,1 +DA:29,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:40,0 +DA:48,0 +LF:18 +LH:11 +FN:25,26,Mailer.__init__ +FNDA:1,Mailer.__init__ +FN:28,48,Mailer.send_password_reset_email +FNDA:0,Mailer.send_password_reset_email +FNF:2 +FNH:1 +end_of_record +SF:app\core\principal.py +DA:6,1 +DA:7,1 +DA:9,1 +DA:12,1 +DA:13,1 +LF:5 +LH:5 +end_of_record +SF:app\core\roles.py +DA:1,1 +DA:2,1 +DA:5,1 +DA:8,1 +DA:9,1 +DA:10,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:18,1 +DA:25,1 +DA:26,1 +LF:12 +LH:12 +FN:25,26,has_at_least +FNDA:1,has_at_least +FNF:1 +FNH:1 +end_of_record +SF:app\core\security.py +DA:10,1 +DA:11,1 +DA:12,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:17,1 +DA:19,1 +DA:20,1 +DA:22,1 +DA:23,1 +DA:26,1 +DA:27,1 +DA:30,1 +DA:31,1 +DA:34,1 +DA:35,1 +DA:42,1 +DA:43,1 +DA:51,1 +DA:59,1 +DA:60,1 +DA:77,1 +DA:78,1 +DA:79,1 +DA:87,1 +DA:88,1 +DA:89,1 +DA:90,1 +DA:92,1 +DA:93,1 +DA:95,1 +DA:96,1 +DA:97,1 +DA:98,1 +DA:99,1 +DA:101,1 +DA:110,1 +DA:111,1 +DA:116,1 +DA:117,1 +LF:41 +LH:41 +FN:51,74,encode_access_token +FNDA:1,encode_access_token +FN:77,107,decode_access_token +FNDA:1,decode_access_token +FN:110,111,generate_refresh_secret +FNDA:1,generate_refresh_secret +FN:116,117,fingerprint_refresh +FNDA:1,fingerprint_refresh +FNF:4 +FNH:4 +BRDA:92,0,jump to line 93,1 +BRDA:92,0,jump to line 95,1 +BRF:2 +BRH:2 +end_of_record +SF:app\db\__init__.py +end_of_record +SF:app\db\base.py +DA:1,1 +DA:4,1 +LF:2 +LH:2 +end_of_record +SF:app\db\session.py +DA:1,1 +DA:2,1 +DA:4,1 +DA:11,1 +DA:14,1 +DA:15,1 +DA:16,1 +DA:17,1 +DA:26,1 +DA:27,1 +DA:28,1 +DA:31,1 +DA:32,1 +DA:33,1 +LF:14 +LH:14 +FN:15,23,get_engine +FNDA:1,get_engine +FN:27,28,get_session_factory +FNDA:1,get_session_factory +FN:31,33,get_session +FNDA:1,get_session +FNF:3 +FNH:3 +end_of_record +SF:app\etl\__init__.py +end_of_record +SF:app\etl\historical_import.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:8,1 +DA:10,1 +DA:11,1 +DA:12,1 +DA:14,1 +DA:16,1 +DA:34,1 +DA:42,1 +DA:45,1 +DA:47,1 +DA:49,1 +DA:50,1 +DA:51,1 +DA:53,1 +DA:56,1 +DA:58,1 +DA:59,1 +DA:61,1 +DA:62,0 +DA:64,1 +DA:67,1 +DA:76,1 +DA:78,1 +DA:79,1 +DA:80,1 +DA:81,0 +DA:82,1 +DA:83,1 +DA:85,1 +DA:87,1 +DA:89,1 +DA:92,1 +DA:97,1 +DA:99,1 +DA:100,1 +DA:102,1 +DA:104,1 +DA:105,0 +DA:107,1 +DA:108,1 +DA:110,1 +DA:111,1 +DA:115,1 +DA:117,1 +DA:118,1 +DA:120,1 +DA:122,1 +DA:123,0 +DA:127,1 +DA:133,1 +DA:143,1 +DA:145,1 +DA:147,1 +DA:152,1 +DA:153,1 +DA:155,0 +DA:157,1 +DA:159,1 +DA:162,1 +DA:167,1 +DA:168,0 +DA:170,1 +DA:171,1 +DA:172,1 +DA:173,0 +DA:174,0 +DA:176,1 +DA:177,1 +DA:179,1 +DA:180,0 +DA:182,1 +DA:185,1 +DA:198,0 +DA:212,0 +DA:214,0 +DA:215,0 +DA:217,0 +DA:228,0 +DA:260,0 +DA:263,1 +DA:268,0 +DA:281,0 +DA:304,1 +DA:312,1 +DA:314,1 +DA:319,1 +DA:320,1 +DA:322,1 +DA:330,1 +DA:332,1 +DA:366,1 +DA:369,1 +DA:418,1 +DA:435,0 +DA:437,0 +DA:439,0 +DA:444,0 +DA:445,0 +DA:446,0 +DA:447,0 +DA:449,0 +DA:450,0 +DA:452,0 +DA:454,0 +DA:456,0 +DA:457,0 +DA:458,0 +DA:460,0 +DA:465,0 +DA:467,0 +DA:472,0 +DA:473,0 +DA:474,0 +DA:482,0 +DA:487,0 +DA:502,0 +DA:504,0 +DA:509,0 +DA:511,0 +DA:516,0 +DA:521,0 +DA:526,0 +DA:528,0 +DA:543,0 +DA:545,0 +DA:546,0 +DA:547,0 +DA:548,0 +DA:549,0 +DA:552,0 +DA:555,1 +DA:557,0 +DA:559,0 +DA:566,0 +DA:573,0 +DA:579,0 +DA:586,0 +DA:592,0 +DA:595,1 +DA:597,0 +DA:599,0 +DA:600,0 +DA:606,0 +DA:608,0 +DA:620,1 +DA:621,0 +LF:152 +LH:84 +FN:45,53,compute_sha256 +FNDA:1,compute_sha256 +FN:56,64,load_metadata +FNDA:1,load_metadata +FN:67,89,classify_quality +FNDA:1,classify_quality +FN:92,130,validate_source +FNDA:1,validate_source +FN:133,159,normalize_timestamps +FNDA:1,normalize_timestamps +FN:162,182,to_json_value +FNDA:1,to_json_value +FN:185,260,ensure_dataset +FNDA:0,ensure_dataset +FN:263,301,upsert_sites +FNDA:0,upsert_sites +FN:304,366,build_reading_batch +FNDA:1,build_reading_batch +FN:418,552,import_historical +FNDA:0,import_historical +FN:555,592,parse_args +FNDA:0,parse_args +FN:595,617,main +FNDA:0,main +FNF:12 +FNH:7 +BRDA:50,0,jump to line 51,1 +BRDA:50,0,jump to line 53,1 +BRDA:61,0,jump to line 62,0 +BRDA:61,0,jump to line 64,1 +BRDA:78,0,jump to line 79,1 +BRDA:78,0,jump to line 80,1 +BRDA:80,0,jump to line 81,0 +BRDA:80,0,jump to line 82,1 +BRDA:82,0,jump to line 83,1 +BRDA:82,0,jump to line 85,1 +BRDA:99,0,jump to line 100,1 +BRDA:99,0,jump to line 102,1 +BRDA:104,0,jump to line 105,0 +BRDA:104,0,jump to line 107,1 +BRDA:110,0,jump to line 111,1 +BRDA:110,0,jump to line 115,1 +BRDA:117,0,jump to line 118,1 +BRDA:117,0,jump to line 120,1 +BRDA:122,0,jump to line 123,0 +BRDA:122,0,jump to line 127,1 +BRDA:152,0,jump to line 153,1 +BRDA:152,0,jump to line 155,0 +BRDA:167,0,jump to line 168,0 +BRDA:167,0,jump to line 170,1 +BRDA:171,0,jump to line 172,1 +BRDA:171,0,jump to line 176,1 +BRDA:176,0,jump to line 177,1 +BRDA:176,0,jump to line 179,1 +BRDA:179,0,jump to line 180,0 +BRDA:179,0,jump to line 182,1 +BRDA:214,0,jump to line 215,- +BRDA:214,0,jump to line 217,- +BRDA:319,0,jump to line 320,1 +BRDA:319,0,jump to line 366,1 +BRDA:456,0,jump to line 457,- +BRDA:456,0,jump to line 460,- +BRDA:504,0,jump to line 509,- +BRDA:504,0,jump to line 528,- +BRDA:599,0,jump to line 600,- +BRDA:599,0,jump to line 606,- +BRDA:620,0,jump to line 621,0 +BRDA:620,0,exit the module,1 +BRF:42 +BRH:26 +end_of_record +SF:app\main.py +DA:1,1 +DA:2,1 +DA:3,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:8,1 +DA:9,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:16,1 +DA:17,1 +DA:18,1 +DA:19,1 +DA:20,1 +DA:22,1 +DA:24,1 +DA:25,1 +DA:26,1 +DA:27,1 +DA:30,1 +DA:31,1 +DA:32,0 +DA:33,0 +DA:36,0 +DA:37,0 +DA:40,1 +DA:41,1 +DA:42,1 +DA:44,1 +DA:45,1 +DA:58,1 +DA:59,1 +DA:63,1 +DA:65,1 +DA:66,1 +DA:67,1 +DA:68,1 +DA:70,1 +DA:72,1 +DA:73,1 +DA:74,1 +DA:80,1 +DA:81,1 +DA:82,1 +DA:88,1 +DA:90,1 +DA:93,1 +DA:103,1 +DA:105,1 +DA:111,1 +DA:116,1 +DA:117,1 +DA:119,1 +LF:57 +LH:53 +FN:31,37,lifespan +FNDA:0,lifespan +FN:40,119,create_app +FNDA:1,create_app +FN:65,68,create_app.openapi_avec_logo +FNDA:1,create_app.openapi_avec_logo +FN:73,78,create_app.docs_swagger +FNDA:1,create_app.docs_swagger +FN:81,86,create_app.docs_redoc +FNDA:1,create_app.docs_redoc +FNF:5 +FNH:4 +BRDA:58,0,jump to line 59,1 +BRDA:58,0,jump to line 88,1 +BRDA:90,0,jump to line 93,1 +BRDA:90,0,jump to line 103,1 +BRDA:116,0,jump to line 117,1 +BRDA:116,0,jump to line 119,1 +BRF:6 +BRH:6 +end_of_record +SF:app\models\__init__.py +DA:4,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:8,1 +DA:9,1 +DA:10,1 +DA:12,1 +LF:8 +LH:8 +end_of_record +SF:app\models\audit_log.py +DA:7,1 +DA:8,1 +DA:9,1 +DA:10,1 +DA:12,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:17,1 +DA:20,1 +DA:21,1 +DA:22,1 +DA:25,1 +DA:26,1 +DA:27,1 +DA:28,1 +DA:29,1 +DA:30,1 +DA:31,1 +DA:32,1 +DA:33,1 +DA:34,1 +DA:35,1 +DA:36,1 +DA:37,1 +DA:40,1 +DA:43,1 +DA:44,1 +DA:45,1 +DA:51,1 +DA:52,1 +DA:55,1 +DA:56,1 +DA:57,1 +DA:58,1 +DA:59,1 +DA:60,1 +DA:61,1 +DA:62,1 +DA:63,1 +DA:64,1 +LF:41 +LH:41 +end_of_record +SF:app\models\energy.py +DA:3,1 +DA:4,1 +DA:5,1 +DA:7,1 +DA:24,1 +DA:25,1 +DA:27,1 +DA:30,1 +DA:31,1 +DA:32,1 +DA:37,1 +DA:38,1 +DA:39,1 +DA:40,1 +DA:41,1 +DA:43,1 +DA:46,1 +DA:47,1 +DA:49,1 +DA:50,1 +DA:51,1 +DA:52,1 +DA:53,1 +DA:54,1 +DA:57,1 +DA:58,1 +DA:59,1 +DA:81,1 +DA:82,1 +DA:85,1 +DA:86,1 +DA:87,1 +DA:91,1 +DA:92,1 +DA:93,1 +DA:94,1 +DA:95,1 +DA:96,1 +DA:97,1 +DA:98,1 +DA:99,1 +DA:100,1 +DA:101,1 +DA:102,1 +DA:103,1 +DA:104,1 +DA:105,1 +DA:108,1 +DA:111,1 +DA:121,1 +DA:122,1 +DA:123,1 +DA:145,1 +DA:146,1 +DA:149,1 +DA:150,1 +DA:151,1 +DA:152,1 +DA:153,1 +DA:154,1 +DA:155,1 +DA:156,1 +DA:159,1 +DA:160,1 +DA:161,1 +DA:179,1 +DA:180,1 +DA:181,1 +DA:184,1 +DA:185,1 +DA:186,1 +DA:187,1 +DA:188,1 +DA:189,1 +DA:190,1 +DA:191,1 +DA:192,1 +DA:193,1 +DA:196,1 +DA:197,1 +DA:198,1 +DA:202,1 +DA:203,1 +DA:207,1 +DA:208,1 +DA:209,1 +DA:210,1 +LF:87 +LH:87 +end_of_record +SF:app\models\login_attempt.py +DA:7,1 +DA:8,1 +DA:9,1 +DA:11,1 +DA:12,1 +DA:13,1 +DA:14,1 +DA:16,1 +DA:19,1 +DA:20,1 +DA:21,1 +DA:22,1 +DA:23,1 +DA:26,1 +DA:29,1 +DA:30,1 +DA:31,1 +DA:37,1 +DA:38,1 +DA:41,1 +DA:42,1 +DA:43,1 +DA:44,1 +LF:23 +LH:23 +end_of_record +SF:app\models\password_reset_attempt.py +DA:6,1 +DA:8,1 +DA:9,1 +DA:10,1 +DA:12,1 +DA:15,1 +DA:16,1 +DA:17,1 +DA:22,1 +DA:23,1 +DA:26,1 +DA:27,1 +LF:12 +LH:12 +end_of_record +SF:app\models\password_reset_token.py +DA:5,1 +DA:6,1 +DA:8,1 +DA:9,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:16,1 +DA:17,1 +DA:18,1 +DA:27,1 +DA:30,1 +DA:33,1 +DA:34,1 +DA:37,1 +DA:38,1 +DA:39,1 +DA:40,1 +LF:18 +LH:18 +end_of_record +SF:app\models\refresh_token.py +DA:8,1 +DA:9,1 +DA:10,1 +DA:12,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:17,1 +DA:20,1 +DA:21,1 +DA:22,1 +DA:23,1 +DA:24,1 +DA:25,1 +DA:28,1 +DA:31,1 +DA:32,1 +DA:33,1 +DA:47,1 +DA:50,1 +DA:51,1 +DA:54,1 +DA:55,1 +DA:58,1 +DA:59,1 +DA:60,1 +DA:61,1 +DA:62,1 +DA:63,1 +DA:64,1 +LF:30 +LH:30 +end_of_record +SF:app\models\user.py +DA:5,1 +DA:6,1 +DA:8,1 +DA:9,1 +DA:10,1 +DA:12,1 +DA:13,1 +DA:15,1 +DA:16,1 +DA:19,1 +DA:20,1 +DA:21,1 +DA:27,1 +DA:30,1 +DA:31,1 +DA:32,1 +DA:33,1 +DA:34,1 +DA:35,1 +DA:40,1 +DA:43,1 +DA:44,1 +DA:45,1 +DA:48,1 +LF:24 +LH:24 +end_of_record +SF:app\repositories\__init__.py +end_of_record +SF:app\repositories\alert.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:6,1 +DA:9,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:16,1 +DA:17,1 +DA:18,0 +DA:19,1 +DA:20,0 +DA:21,1 +LF:14 +LH:12 +FN:10,11,AlertRepository.__init__ +FNDA:1,AlertRepository.__init__ +FN:13,21,AlertRepository.list_all +FNDA:1,AlertRepository.list_all +FNF:2 +FNH:2 +BRDA:17,0,jump to line 18,0 +BRDA:17,0,jump to line 19,1 +BRDA:19,0,jump to line 20,0 +BRDA:19,0,jump to line 21,1 +BRF:4 +BRH:2 +end_of_record +SF:app\repositories\audit_log.py +DA:5,1 +DA:6,1 +DA:8,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:26,1 +DA:27,0 +DA:28,0 +DA:29,0 +DA:32,1 +DA:33,1 +DA:34,1 +DA:36,1 +DA:49,0 +LF:15 +LH:11 +FN:26,29,assemble_detail +FNDA:0,assemble_detail +FN:33,34,AuditLogRepository.__init__ +FNDA:1,AuditLogRepository.__init__ +FN:36,62,AuditLogRepository.record +FNDA:0,AuditLogRepository.record +FNF:3 +FNH:1 +BRDA:27,0,jump to line 28,- +BRDA:27,0,jump to line 29,- +BRF:2 +BRH:0 +end_of_record +SF:app\repositories\login_attempt.py +DA:5,1 +DA:6,1 +DA:7,1 +DA:9,1 +DA:10,1 +DA:12,1 +DA:15,1 +DA:16,1 +DA:22,1 +DA:23,1 +DA:24,1 +DA:26,1 +DA:34,0 +DA:43,1 +DA:46,0 +DA:47,0 +DA:48,0 +DA:50,0 +DA:60,0 +DA:63,0 +LF:20 +LH:13 +FN:23,24,LoginAttemptRepository.__init__ +FNDA:1,LoginAttemptRepository.__init__ +FN:26,41,LoginAttemptRepository.record +FNDA:0,LoginAttemptRepository.record +FN:43,67,LoginAttemptRepository.count_recent_failures +FNDA:0,LoginAttemptRepository.count_recent_failures +FNF:3 +FNH:1 +end_of_record +SF:app\repositories\password_reset_attempt.py +DA:1,1 +DA:2,1 +DA:4,1 +DA:5,1 +DA:7,1 +DA:10,1 +DA:11,1 +DA:16,1 +DA:17,1 +DA:18,1 +DA:20,1 +DA:21,0 +DA:25,1 +DA:28,0 +DA:29,0 +DA:30,0 +DA:32,0 +DA:41,0 +DA:42,0 +LF:19 +LH:12 +FN:17,18,PasswordResetAttemptRepository.__init__ +FNDA:1,PasswordResetAttemptRepository.__init__ +FN:20,23,PasswordResetAttemptRepository.record +FNDA:0,PasswordResetAttemptRepository.record +FN:25,42,PasswordResetAttemptRepository.count_recent +FNDA:0,PasswordResetAttemptRepository.count_recent +FNF:3 +FNH:1 +end_of_record +SF:app\repositories\password_reset_token.py +DA:5,1 +DA:6,1 +DA:7,1 +DA:9,1 +DA:10,1 +DA:12,1 +DA:15,1 +DA:16,1 +DA:21,1 +DA:22,1 +DA:23,1 +DA:25,1 +DA:34,0 +DA:41,0 +DA:42,0 +DA:43,0 +DA:45,1 +DA:46,0 +DA:56,0 +DA:57,0 +DA:58,0 +DA:59,0 +DA:63,1 +DA:64,0 +DA:69,0 +DA:71,1 +DA:72,0 +DA:78,0 +LF:28 +LH:15 +FN:22,23,PasswordResetTokenRepository.__init__ +FNDA:1,PasswordResetTokenRepository.__init__ +FN:25,43,PasswordResetTokenRepository.create +FNDA:0,PasswordResetTokenRepository.create +FN:45,59,PasswordResetTokenRepository.consume +FNDA:0,PasswordResetTokenRepository.consume +FN:63,69,PasswordResetTokenRepository.exists_valid +FNDA:0,PasswordResetTokenRepository.exists_valid +FN:71,78,PasswordResetTokenRepository.invalidate_all_for_user +FNDA:0,PasswordResetTokenRepository.invalidate_all_for_user +FNF:5 +FNH:1 +BRDA:57,0,jump to line 58,- +BRDA:57,0,jump to line 59,- +BRF:2 +BRH:0 +end_of_record +SF:app\repositories\reading.py +DA:1,1 +DA:2,1 +DA:4,1 +DA:5,1 +DA:7,1 +DA:10,1 +DA:11,1 +DA:12,1 +DA:14,1 +DA:18,0 +DA:23,0 +DA:25,1 +DA:28,0 +DA:34,0 +DA:35,0 +DA:37,1 +DA:46,1 +DA:53,1 +DA:54,0 +DA:55,1 +LF:20 +LH:14 +FN:11,12,ReadingRepository.__init__ +FNDA:1,ReadingRepository.__init__ +FN:14,23,ReadingRepository.latest_by_site +FNDA:0,ReadingRepository.latest_by_site +FN:25,35,ReadingRepository.latest_for_site +FNDA:0,ReadingRepository.latest_for_site +FN:37,55,ReadingRepository.list_history +FNDA:1,ReadingRepository.list_history +FNF:4 +FNH:2 +BRDA:53,0,jump to line 54,0 +BRDA:53,0,jump to line 55,1 +BRF:2 +BRH:1 +end_of_record +SF:app\repositories\recommendation.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:6,1 +DA:9,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:17,1 +DA:18,1 +DA:21,1 +DA:22,1 +LF:14 +LH:14 +FN:10,11,RecommendationRepository.__init__ +FNDA:1,RecommendationRepository.__init__ +FN:13,15,RecommendationRepository.list_all +FNDA:1,RecommendationRepository.list_all +FN:17,22,RecommendationRepository.get_by_id +FNDA:1,RecommendationRepository.get_by_id +FNF:3 +FNH:3 +end_of_record +SF:app\repositories\refresh_token.py +DA:6,1 +DA:7,1 +DA:8,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:16,1 +DA:17,1 +DA:24,1 +DA:25,1 +DA:26,1 +DA:28,1 +DA:38,0 +DA:46,0 +DA:47,0 +DA:48,0 +DA:50,1 +DA:51,0 +DA:71,0 +DA:72,0 +DA:73,0 +DA:74,0 +DA:81,1 +DA:82,0 +DA:83,0 +DA:85,1 +DA:86,0 +DA:90,1 +DA:91,0 +DA:97,0 +DA:99,1 +DA:100,0 +DA:106,0 +LF:33 +LH:17 +FN:25,26,RefreshTokenRepository.__init__ +FNDA:1,RefreshTokenRepository.__init__ +FN:28,48,RefreshTokenRepository.create +FNDA:0,RefreshTokenRepository.create +FN:50,79,RefreshTokenRepository.claim_for_rotation +FNDA:0,RefreshTokenRepository.claim_for_rotation +FN:81,83,RefreshTokenRepository.inspect +FNDA:0,RefreshTokenRepository.inspect +FN:85,88,RefreshTokenRepository.link_replacement +FNDA:0,RefreshTokenRepository.link_replacement +FN:90,97,RefreshTokenRepository.revoke_family +FNDA:0,RefreshTokenRepository.revoke_family +FN:99,106,RefreshTokenRepository.revoke_all_for_user +FNDA:0,RefreshTokenRepository.revoke_all_for_user +FNF:7 +FNH:1 +BRDA:72,0,jump to line 73,- +BRDA:72,0,jump to line 74,- +BRF:2 +BRH:0 +end_of_record +SF:app\repositories\site.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:6,1 +DA:9,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:17,1 +DA:18,1 +DA:19,1 +DA:20,1 +LF:14 +LH:14 +FN:10,11,SiteRepository.__init__ +FNDA:1,SiteRepository.__init__ +FN:13,15,SiteRepository.list_all +FNDA:1,SiteRepository.list_all +FN:17,20,SiteRepository.get_by_id +FNDA:1,SiteRepository.get_by_id +FNF:3 +FNH:3 +end_of_record +SF:app\repositories\user.py +DA:6,1 +DA:7,1 +DA:9,1 +DA:10,1 +DA:12,1 +DA:13,1 +DA:16,1 +DA:17,1 +DA:18,1 +DA:20,1 +DA:21,0 +DA:22,0 +DA:24,1 +DA:25,0 +DA:27,1 +DA:28,0 +DA:29,0 +DA:31,1 +DA:32,0 +DA:37,0 +DA:39,1 +DA:49,0 +DA:57,0 +DA:58,0 +DA:59,0 +DA:61,1 +DA:64,0 +DA:74,1 +DA:76,0 +DA:80,1 +DA:81,0 +DA:85,1 +DA:86,0 +DA:92,1 +DA:93,0 +LF:35 +LH:19 +FN:17,18,UserRepository.__init__ +FNDA:1,UserRepository.__init__ +FN:20,22,UserRepository.get_by_email +FNDA:0,UserRepository.get_by_email +FN:24,25,UserRepository.get_by_id +FNDA:0,UserRepository.get_by_id +FN:27,29,UserRepository.list_all +FNDA:0,UserRepository.list_all +FN:31,37,UserRepository.count_active_admins +FNDA:0,UserRepository.count_active_admins +FN:39,59,UserRepository.create +FNDA:0,UserRepository.create +FN:61,72,UserRepository.update_password +FNDA:0,UserRepository.update_password +FN:74,78,UserRepository.rehash_password +FNDA:0,UserRepository.rehash_password +FN:80,83,UserRepository.touch_last_login +FNDA:0,UserRepository.touch_last_login +FN:85,90,UserRepository.set_role +FNDA:0,UserRepository.set_role +FN:92,97,UserRepository.set_active +FNDA:0,UserRepository.set_active +FNF:11 +FNH:1 +end_of_record +SF:app\schemas\__init__.py +DA:1,1 +DA:3,1 +LF:2 +LH:2 +end_of_record +SF:app\schemas\alert.py +DA:1,1 +DA:2,1 +DA:4,1 +DA:7,1 +DA:8,1 +DA:9,1 +DA:10,1 +DA:11,1 +DA:12,1 +DA:15,1 +DA:16,1 +DA:17,1 +DA:18,1 +DA:19,1 +DA:22,1 +DA:23,1 +LF:16 +LH:16 +end_of_record +SF:app\schemas\auth.py +DA:8,1 +DA:9,1 +DA:10,1 +DA:12,1 +DA:14,1 +DA:15,1 +DA:17,1 +DA:18,1 +DA:20,1 +DA:22,1 +DA:23,1 +DA:24,1 +DA:25,1 +DA:28,1 +DA:29,1 +DA:39,1 +DA:40,1 +DA:41,1 +DA:44,1 +DA:46,1 +DA:49,1 +DA:50,1 +DA:51,1 +DA:53,1 +DA:54,1 +DA:55,1 +DA:56,1 +DA:59,1 +DA:63,1 +DA:64,1 +DA:65,1 +DA:67,1 +DA:68,1 +DA:69,1 +DA:70,1 +DA:73,1 +DA:74,1 +DA:82,1 +DA:83,1 +DA:84,1 +DA:87,1 +DA:91,1 +DA:93,1 +LF:43 +LH:43 +FN:28,41,valide_complexite +FNDA:1,valide_complexite +FN:55,56,PasswordChangeRequest._new_password_est_complexe +FNDA:1,PasswordChangeRequest._new_password_est_complexe +FN:69,70,ResetPasswordRequest._new_password_est_complexe +FNDA:1,ResetPasswordRequest._new_password_est_complexe +FN:83,84,PrincipalResponse.from_principal +FNDA:1,PrincipalResponse.from_principal +FNF:4 +FNH:4 +BRDA:39,0,jump to line 40,1 +BRDA:39,0,jump to line 41,1 +BRF:2 +BRH:2 +end_of_record +SF:app\schemas\errors.py +DA:5,1 +DA:8,1 +DA:12,1 +DA:17,1 +DA:21,1 +LF:5 +LH:5 +end_of_record +SF:app\schemas\health.py +DA:1,1 +DA:3,1 +DA:6,1 +DA:16,1 +LF:4 +LH:4 +end_of_record +SF:app\schemas\reading.py +DA:1,1 +DA:2,1 +DA:3,1 +DA:4,1 +DA:6,1 +DA:9,1 +DA:10,1 +DA:11,1 +DA:12,1 +DA:15,1 +DA:16,1 +DA:17,1 +DA:18,1 +DA:19,1 +DA:22,1 +DA:23,1 +LF:16 +LH:16 +end_of_record +SF:app\schemas\recommendation.py +DA:1,1 +DA:3,1 +DA:6,1 +DA:7,1 +LF:4 +LH:4 +end_of_record +SF:app\schemas\sensor.py +DA:1,1 +DA:2,1 +DA:4,1 +DA:7,1 +DA:8,1 +DA:11,1 +DA:19,1 +DA:20,1 +DA:29,1 +DA:30,1 +DA:38,1 +DA:39,1 +LF:12 +LH:12 +end_of_record +SF:app\schemas\site.py +DA:1,1 +DA:2,1 +DA:4,1 +DA:7,1 +DA:8,1 +DA:18,1 +DA:19,1 +LF:7 +LH:7 +end_of_record +SF:app\schemas\stats.py +DA:1,1 +DA:2,1 +DA:4,1 +DA:7,1 +DA:8,1 +DA:18,1 +DA:19,1 +LF:7 +LH:7 +end_of_record +SF:app\schemas\user.py +DA:5,1 +DA:6,1 +DA:8,1 +DA:10,1 +DA:13,1 +DA:16,1 +DA:19,1 +DA:20,1 +DA:21,1 +DA:24,1 +DA:25,1 +DA:38,1 +LF:12 +LH:12 +end_of_record +SF:app\services\__init__.py +end_of_record +SF:app\services\alert.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:7,1 +DA:8,1 +DA:9,1 +DA:11,1 +DA:14,1 +LF:8 +LH:8 +FN:8,9,AlertService.__init__ +FNDA:1,AlertService.__init__ +FN:11,14,AlertService.list_all +FNDA:1,AlertService.list_all +FNF:2 +FNH:2 +end_of_record +SF:app\services\auth.py +DA:12,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:17,1 +DA:19,1 +DA:20,1 +DA:21,1 +DA:22,1 +DA:23,1 +DA:24,1 +DA:30,1 +DA:31,1 +DA:32,1 +DA:33,1 +DA:34,1 +DA:35,1 +DA:36,1 +DA:37,1 +DA:38,1 +DA:40,1 +DA:43,1 +DA:47,1 +DA:48,1 +DA:51,1 +DA:52,1 +DA:55,1 +DA:56,1 +DA:59,1 +DA:60,1 +DA:61,1 +DA:62,1 +DA:65,1 +DA:66,1 +DA:69,1 +DA:70,1 +DA:77,1 +DA:78,1 +DA:86,1 +DA:87,1 +DA:94,1 +DA:95,1 +DA:112,1 +DA:113,1 +DA:114,1 +DA:115,1 +DA:116,1 +DA:117,1 +DA:118,1 +DA:119,1 +DA:120,1 +DA:121,1 +DA:122,1 +DA:123,1 +DA:124,1 +DA:126,1 +DA:129,1 +DA:131,1 +DA:132,1 +DA:133,1 +DA:134,1 +DA:136,1 +DA:137,1 +DA:141,1 +DA:142,1 +DA:146,1 +DA:147,1 +DA:149,1 +DA:150,1 +DA:153,1 +DA:156,1 +DA:158,1 +DA:160,1 +DA:163,1 +DA:164,1 +DA:165,1 +DA:166,1 +DA:168,1 +DA:169,1 +DA:170,1 +DA:171,1 +DA:172,1 +DA:174,1 +DA:175,1 +DA:183,1 +DA:184,1 +DA:186,1 +DA:188,1 +DA:189,1 +DA:190,1 +DA:191,1 +DA:192,1 +DA:194,1 +DA:203,1 +DA:204,1 +DA:205,1 +DA:207,1 +DA:212,1 +DA:215,1 +DA:218,1 +DA:227,1 +DA:229,1 +DA:230,1 +DA:232,1 +DA:240,1 +DA:242,1 +DA:248,1 +DA:249,1 +DA:250,1 +DA:251,1 +DA:252,1 +DA:254,1 +DA:255,1 +DA:256,1 +DA:263,1 +DA:264,1 +DA:272,1 +DA:274,1 +DA:275,1 +DA:277,1 +DA:278,1 +DA:279,1 +DA:280,1 +DA:281,1 +DA:287,1 +DA:288,1 +DA:290,1 +DA:293,1 +DA:294,1 +DA:295,1 +DA:300,1 +DA:301,1 +DA:302,1 +DA:304,1 +DA:307,1 +DA:310,1 +DA:313,1 +DA:321,1 +DA:323,1 +DA:324,1 +DA:325,0 +DA:326,1 +DA:328,1 +DA:329,1 +DA:332,1 +DA:337,1 +DA:338,1 +DA:340,1 +DA:341,1 +DA:347,1 +DA:354,1 +DA:355,1 +DA:363,1 +DA:366,1 +DA:367,1 +DA:375,1 +DA:377,1 +DA:380,1 +DA:381,1 +DA:382,1 +DA:384,1 +DA:385,1 +DA:389,1 +DA:392,1 +DA:401,1 +DA:402,1 +DA:404,1 +DA:407,1 +DA:408,1 +DA:412,1 +DA:417,1 +DA:418,1 +DA:420,1 +DA:423,1 +DA:424,1 +DA:432,1 +DA:433,1 +DA:435,1 +DA:436,1 +DA:437,1 +DA:441,1 +DA:445,1 +DA:446,1 +DA:448,1 +DA:449,1 +DA:450,1 +DA:452,1 +DA:460,1 +DA:463,1 +DA:464,1 +LF:190 +LH:189 +FN:60,62,RateLimitedError.__init__ +FNDA:1,RateLimitedError.__init__ +FN:95,124,AuthService.__init__ +FNDA:1,AuthService.__init__ +FN:126,158,AuthService.authenticate +FNDA:1,AuthService.authenticate +FN:160,186,AuthService.refresh +FNDA:1,AuthService.refresh +FN:188,192,AuthService.logout +FNDA:1,AuthService.logout +FN:194,230,AuthService.change_password +FNDA:1,AuthService.change_password +FN:232,275,AuthService.request_password_reset +FNDA:1,AuthService.request_password_reset +FN:277,281,AuthService._envoie_email_reset +FNDA:1,AuthService._envoie_email_reset +FN:287,288,AuthService.is_reset_token_valid +FNDA:1,AuthService.is_reset_token_valid +FN:290,326,AuthService.confirm_password_reset +FNDA:1,AuthService.confirm_password_reset +FN:328,338,AuthService.logout_all +FNDA:1,AuthService.logout_all +FN:340,352,AuthService._session +FNDA:1,AuthService._session +FN:354,361,AuthService._en_principal +FNDA:1,AuthService._en_principal +FN:363,375,AuthService._ouvre_une_famille +FNDA:1,AuthService._ouvre_une_famille +FN:377,402,AuthService._traite_rotation_refusee +FNDA:1,AuthService._traite_rotation_refusee +FN:404,433,AuthService._refuse_si_limite +FNDA:1,AuthService._refuse_si_limite +FN:435,450,AuthService._refuse_si_limite_reset +FNDA:1,AuthService._refuse_si_limite_reset +FN:452,464,AuthService._echoue +FNDA:1,AuthService._echoue +FNF:18 +FNH:18 +BRDA:132,0,jump to line 133,1 +BRDA:132,0,jump to line 136,1 +BRDA:136,0,jump to line 137,1 +BRDA:136,0,jump to line 141,1 +BRDA:141,0,jump to line 142,1 +BRDA:141,0,jump to line 146,1 +BRDA:146,0,jump to line 147,1 +BRDA:146,0,jump to line 149,1 +BRDA:165,0,jump to line 166,1 +BRDA:165,0,jump to line 168,1 +BRDA:169,0,jump to line 170,1 +BRDA:169,0,jump to line 174,1 +BRDA:190,0,jump to line 191,1 +BRDA:190,0,jump to line 192,1 +BRDA:204,0,jump to line 205,1 +BRDA:204,0,jump to line 207,1 +BRDA:248,0,jump to line 249,1 +BRDA:248,0,jump to line 254,1 +BRDA:294,0,jump to line 295,1 +BRDA:294,0,jump to line 300,1 +BRDA:301,0,jump to line 302,1 +BRDA:301,0,jump to line 304,1 +BRDA:324,0,jump to line 325,0 +BRDA:324,0,jump to line 326,1 +BRDA:381,0,jump to line 382,1 +BRDA:381,0,jump to line 384,1 +BRDA:384,0,jump to line 385,1 +BRDA:384,0,jump to line 389,1 +BRDA:417,0,jump to line 418,1 +BRDA:417,0,jump to line 420,1 +BRDA:423,0,jump to line 424,1 +BRDA:423,0,jump to line 432,1 +BRDA:445,0,jump to line 446,1 +BRDA:445,0,jump to line 448,1 +BRF:34 +BRH:33 +end_of_record +SF:app\services\data_quality.py +DA:6,1 +DA:8,1 +DA:10,1 +DA:12,1 +DA:15,1 +DA:16,1 +DA:17,1 +DA:18,1 +LF:8 +LH:8 +FN:15,18,qualite_ou_critique +FNDA:1,qualite_ou_critique +FNF:1 +FNH:1 +BRDA:16,0,jump to line 17,1 +BRDA:16,0,jump to line 18,1 +BRF:2 +BRH:2 +end_of_record +SF:app\services\reading.py +DA:1,1 +DA:2,1 +DA:4,1 +DA:5,1 +DA:7,1 +DA:8,1 +DA:11,1 +DA:15,1 +DA:19,1 +DA:20,1 +DA:21,1 +DA:23,1 +DA:32,1 +DA:33,1 +DA:37,1 +DA:38,1 +DA:44,1 +DA:45,1 +DA:46,1 +DA:47,1 +DA:49,1 +DA:50,1 +DA:51,1 +DA:52,1 +DA:53,1 +DA:56,1 +DA:57,1 +DA:58,1 +DA:59,1 +LF:29 +LH:29 +FN:20,21,ReadingService.__init__ +FNDA:1,ReadingService.__init__ +FN:23,35,ReadingService.list_history +FNDA:1,ReadingService.list_history +FN:38,53,ReadingService._resoudre_fenetre +FNDA:1,ReadingService._resoudre_fenetre +FN:56,59,_vers_utc +FNDA:1,_vers_utc +FNF:4 +FNH:4 +BRDA:46,0,jump to line 47,1 +BRDA:46,0,jump to line 49,1 +BRDA:49,0,jump to line 50,1 +BRDA:49,0,jump to line 51,1 +BRDA:51,0,jump to line 52,1 +BRDA:51,0,jump to line 53,1 +BRDA:57,0,jump to line 58,1 +BRDA:57,0,jump to line 59,1 +BRF:8 +BRH:8 +end_of_record +SF:app\services\recommendation.py +DA:1,1 +DA:3,1 +DA:4,1 +DA:7,1 +DA:8,1 +DA:11,1 +DA:12,1 +DA:15,1 +DA:16,1 +DA:17,1 +DA:19,1 +DA:20,1 +DA:22,1 +DA:23,1 +DA:24,1 +DA:25,1 +DA:26,1 +LF:17 +LH:17 +FN:16,17,RecommendationService.__init__ +FNDA:1,RecommendationService.__init__ +FN:19,20,RecommendationService.list_all +FNDA:1,RecommendationService.list_all +FN:22,26,RecommendationService.get_by_id +FNDA:1,RecommendationService.get_by_id +FNF:3 +FNH:3 +BRDA:24,0,jump to line 25,1 +BRDA:24,0,jump to line 26,1 +BRF:2 +BRH:2 +end_of_record +SF:app\services\sensor.py +DA:1,1 +DA:2,1 +DA:3,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:8,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:21,1 +DA:29,1 +DA:30,1 +DA:35,1 +DA:36,1 +DA:44,1 +DA:45,1 +DA:52,1 +DA:53,1 +DA:58,1 +DA:59,1 +DA:60,1 +DA:61,1 +DA:63,1 +DA:64,1 +DA:65,1 +DA:67,1 +DA:73,1 +DA:74,1 +DA:75,1 +DA:82,1 +DA:83,1 +DA:85,1 +DA:86,1 +DA:93,1 +DA:99,1 +DA:113,1 +DA:114,1 +DA:115,1 +DA:116,1 +DA:117,1 +DA:118,1 +DA:121,1 +DA:122,1 +DA:123,1 +DA:126,1 +DA:132,1 +DA:133,1 +DA:134,1 +LF:49 +LH:49 +FN:59,61,SensorService.__init__ +FNDA:1,SensorService.__init__ +FN:63,70,SensorService.status +FNDA:1,SensorService.status +FN:73,110,_sante_site +FNDA:1,_sante_site +FN:113,118,_overall_depuis_qualite +FNDA:1,_overall_depuis_qualite +FN:121,129,_diagnostic +FNDA:1,_diagnostic +FN:132,136,_tout_en_echec +FNDA:1,_tout_en_echec +FNF:6 +FNH:6 +BRDA:74,0,jump to line 75,1 +BRDA:74,0,jump to line 82,1 +BRDA:85,0,jump to line 86,1 +BRDA:85,0,jump to line 93,1 +BRDA:114,0,jump to line 115,1 +BRDA:114,0,jump to line 116,1 +BRDA:116,0,jump to line 117,1 +BRDA:116,0,jump to line 118,1 +BRF:8 +BRH:8 +end_of_record +SF:app\services\site.py +DA:1,1 +DA:2,1 +DA:3,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:8,1 +DA:11,1 +DA:12,1 +DA:15,1 +DA:16,1 +DA:19,1 +DA:20,1 +DA:35,1 +DA:36,1 +DA:37,1 +DA:38,1 +DA:40,1 +DA:41,1 +DA:43,1 +DA:44,1 +DA:45,1 +DA:46,1 +DA:47,1 +DA:49,1 +DA:50,1 +DA:51,1 +DA:53,1 +DA:54,1 +DA:69,1 +LF:30 +LH:30 +FN:36,38,SiteService.__init__ +FNDA:1,SiteService.__init__ +FN:40,41,SiteService.list_all +FNDA:1,SiteService.list_all +FN:43,47,SiteService.get_by_id +FNDA:1,SiteService.get_by_id +FN:49,82,SiteService.current +FNDA:1,SiteService.current +FNF:4 +FNH:4 +BRDA:45,0,jump to line 46,1 +BRDA:45,0,jump to line 47,1 +BRDA:53,0,jump to line 54,1 +BRDA:53,0,jump to line 69,1 +BRF:4 +BRH:4 +end_of_record +SF:app\services\stats.py +DA:1,1 +DA:2,1 +DA:4,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:10,1 +DA:11,1 +DA:20,1 +DA:21,1 +DA:30,1 +DA:31,1 +DA:32,1 +DA:33,1 +DA:35,1 +DA:36,1 +DA:37,1 +DA:39,1 +DA:40,1 +DA:41,1 +DA:43,1 +DA:54,1 +DA:55,1 +DA:56,1 +DA:57,1 +DA:58,1 +DA:59,1 +DA:60,1 +DA:61,1 +DA:63,1 +DA:67,1 +LF:31 +LH:31 +FN:31,33,StatsService.__init__ +FNDA:1,StatsService.__init__ +FN:35,52,StatsService.summary +FNDA:1,StatsService.summary +FN:55,74,StatsService._resume_site +FNDA:1,StatsService._resume_site +FNF:3 +FNH:3 +BRDA:59,0,jump to line 60,1 +BRDA:59,0,jump to line 63,1 +BRF:2 +BRH:2 +end_of_record +SF:app\services\user.py +DA:5,1 +DA:6,1 +DA:7,1 +DA:8,1 +DA:9,1 +DA:11,1 +DA:12,1 +DA:13,1 +DA:14,1 +DA:15,1 +DA:16,1 +DA:17,1 +DA:18,1 +DA:19,1 +DA:21,1 +DA:24,1 +DA:28,1 +DA:29,1 +DA:32,1 +DA:33,1 +DA:36,1 +DA:37,1 +DA:40,1 +DA:41,1 +DA:44,1 +DA:45,1 +DA:50,1 +DA:51,1 +DA:60,1 +DA:61,1 +DA:62,1 +DA:63,1 +DA:64,1 +DA:66,1 +DA:67,0 +DA:69,1 +DA:72,1 +DA:73,1 +DA:75,1 +DA:76,1 +DA:83,1 +DA:90,1 +DA:91,1 +DA:93,1 +DA:94,1 +DA:95,1 +DA:96,1 +DA:98,1 +DA:99,1 +DA:100,1 +DA:101,1 +DA:102,1 +DA:109,1 +DA:110,1 +DA:112,1 +DA:113,1 +DA:114,1 +DA:115,0 +DA:117,1 +DA:120,1 +DA:121,1 +DA:122,1 +DA:123,1 +DA:129,1 +DA:130,1 +DA:132,1 +DA:133,1 +DA:134,1 +DA:136,1 +DA:139,1 +DA:140,1 +DA:147,1 +DA:148,1 +DA:150,1 +DA:151,1 +DA:152,1 +DA:153,1 +DA:154,1 +DA:156,1 +DA:159,1 +DA:160,1 +DA:161,1 +DA:162,1 +DA:163,1 +DA:164,1 +LF:85 +LH:83 +FN:51,64,UserService.__init__ +FNDA:1,UserService.__init__ +FN:66,67,UserService.list_all +FNDA:0,UserService.list_all +FN:69,91,UserService.create +FNDA:1,UserService.create +FN:93,110,UserService.change_role +FNDA:1,UserService.change_role +FN:112,130,UserService.set_active +FNDA:1,UserService.set_active +FN:132,148,UserService.reset_password +FNDA:1,UserService.reset_password +FN:150,154,UserService._exige +FNDA:1,UserService._exige +FN:156,164,UserService._refuse_si_dernier_admin +FNDA:1,UserService._refuse_si_dernier_admin +FNF:8 +FNH:7 +BRDA:72,0,jump to line 73,1 +BRDA:72,0,jump to line 75,1 +BRDA:95,0,jump to line 96,1 +BRDA:95,0,jump to line 98,1 +BRDA:114,0,jump to line 115,0 +BRDA:114,0,jump to line 117,1 +BRDA:121,0,jump to line 122,1 +BRDA:121,0,jump to line 123,1 +BRDA:152,0,jump to line 153,1 +BRDA:152,0,jump to line 154,1 +BRDA:161,0,jump to line 162,1 +BRDA:161,0,jump to line 163,1 +BRDA:163,0,jump to line 164,1 +BRDA:163,0,return from function '_refuse_si_dernier_admin',1 +BRF:14 +BRH:13 +end_of_record From ddf7e1778869780626fcd84e99cd44f7dac7d824 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Fri, 18 Sep 2026 13:39:51 +0200 Subject: [PATCH 23/27] test: properties sonar --- apps/backend/sonar-project.properties | 15 --------------- sonar-project.properties | 17 +++++++++++++++++ 2 files changed, 17 insertions(+), 15 deletions(-) delete mode 100644 apps/backend/sonar-project.properties create mode 100644 sonar-project.properties diff --git a/apps/backend/sonar-project.properties b/apps/backend/sonar-project.properties deleted file mode 100644 index f828cf3..0000000 --- a/apps/backend/sonar-project.properties +++ /dev/null @@ -1,15 +0,0 @@ -sonar.projectKey=ProjetPiscine_EnerVision -sonar.organization=groupe3-ener-vision -sonar.sourceEncoding=UTF-8 - -# Dossier contenant le code source -sonar.sources=app -# Dossier contenant les tests -sonar.tests=tests - -# Liste des fichiers et dossiers à exclure de l'analyse -sonar.exclusions=.pytest_cache,.venv,alembic,tests - -# Chemin vers le rapport de couverture de code -# Fichier généré par Pytest -sonar.python.coverage.reportPaths=cov.info diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..487ef67 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +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 + +# 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 + +# Chemin vers le rapport de couverture de code +# Fichier généré par Pytest +sonar.python.coverage.reportPaths=apps/backend/coverage.lcov +sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info From ec63798807a87ce6a99b63fa93002f6328f85b50 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Fri, 18 Sep 2026 13:55:46 +0200 Subject: [PATCH 24/27] fix: lcov -> xml pour le rapport de couverture --- .github/workflows/sonarqube.yml | 4 ++-- sonar-project.properties | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 3062a8b..b5a07eb 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -99,14 +99,14 @@ jobs: 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=lcov + 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.lcov + path: apps/backend/coverage.xml sonarqube: needs: [build-front, build-back, test-front, test-back] diff --git a/sonar-project.properties b/sonar-project.properties index 487ef67..43c28fd 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -13,5 +13,5 @@ sonar.exclusions=.pytest_cache,.venv,alembic,tests,**/*/node_modules/**,**/*/dis # Chemin vers le rapport de couverture de code # Fichier généré par Pytest -sonar.python.coverage.reportPaths=apps/backend/coverage.lcov +sonar.python.coverage.reportPaths=apps/backend/coverage.xml sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info From 33807e3038356329b0e6a9751534e465864cdb0a Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Fri, 18 Sep 2026 14:11:44 +0200 Subject: [PATCH 25/27] =?UTF-8?q?fix(frontend):=20traite=20la=20revue=20de?= =?UTF-8?q?=20phyri0s=20sur=20la=20vue=20d=C3=A9tail=20d'un=20site?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../sites/site-detail/site-detail.html | 19 +++-- .../sites/site-detail/site-detail.scss | 5 ++ .../sites/site-detail/site-detail.spec.ts | 80 +++++++++++++++++++ .../features/sites/site-detail/site-detail.ts | 32 ++++++-- .../reading-history-chart.spec.ts | 27 ++++++- .../reading-history-chart.ts | 35 +++++--- 6 files changed, 172 insertions(+), 26 deletions(-) diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.html b/apps/frontend/src/app/features/sites/site-detail/site-detail.html index e95ac75..9c4a4bc 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.html +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.html @@ -36,13 +36,18 @@
Consommation vs capacité - - - {{ current()?.consumption_kw ?? '-' }} / {{ s.capacity_kw ?? '-' }} kW - + @let consumption = consumptionKw(); + @if (consumption !== null) { + + + {{ consumptionLabel() }} / {{ s.capacity_kw ?? '-' }} kW + + } @else { +

+ Consommation indisponible + ({{ consumptionReason() }}) +

+ }
diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.scss b/apps/frontend/src/app/features/sites/site-detail/site-detail.scss index 78dd9ed..a0032be 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.scss +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.scss @@ -74,6 +74,11 @@ font-weight: 700; } +.card__unavailable { + color: var(--color-text-muted); + margin: 0; +} + .metrics-grid { display: grid; grid-template-columns: repeat(2, 1fr); diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts b/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts index 3fdc269..5e3cf46 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts @@ -116,6 +116,86 @@ describe('SiteDetail', () => { 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, diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.ts b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts index 047614a..21e716d 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.ts +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts @@ -1,7 +1,7 @@ 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, map, Observable, of, switchMap } from 'rxjs'; +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'; @@ -52,8 +52,14 @@ interface MetricDef { format: (value: number) => string; } +const CONSUMPTION_DEF: MetricDef = { + key: 'consumption_kw', + label: 'Consommation', + format: (v) => `${v.toFixed(1)} kW`, +}; + const METRIC_DEFS: MetricDef[] = [ - { key: 'consumption_kw', label: 'Consommation', format: (v) => `${v.toFixed(1)} kW` }, + 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) }, @@ -111,6 +117,15 @@ export class SiteDetail { 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; @@ -156,10 +171,10 @@ export class SiteDetail { } private load(siteId: string) { - return this.sitesService.getSite(siteId).pipe( - switchMap((site) => - this.sitesService.getCurrent(siteId).pipe(map((current) => ({ site, current }))), - ), + 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 }))), ), @@ -186,8 +201,13 @@ export class SiteDetail { 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 { this.error.set(UNAVAILABLE_MESSAGE); + this.site.set(null); + this.current.set(null); + this.history.set([]); return EMPTY; } } diff --git a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.spec.ts b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.spec.ts index bb6232f..63be883 100644 --- a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.spec.ts +++ b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.spec.ts @@ -9,15 +9,21 @@ vi.mock('chart.js', () => { static register = vi.fn(); update = vi.fn(); destroy = vi.fn(); - data = { datasets: [{}] }; - constructor() { + data: { labels?: unknown[]; datasets: Record[] } = { 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 }; +type ChartDouble = { + destroy: ReturnType; + data: { labels?: unknown[]; datasets: Record[] }; +}; function lastChart(): ChartDouble | undefined { return (Chart as unknown as { instances: ChartDouble[] }).instances.at(-1); @@ -66,6 +72,21 @@ describe('ReadingHistoryChart', () => { 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); diff --git a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.ts b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.ts index 922ca01..17d1e06 100644 --- a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.ts +++ b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.ts @@ -20,8 +20,23 @@ const QUALITY_COLORS: Record = { }; const UNKNOWN_QUALITY_COLOR = '#9ca3af'; -function pointColors(readings: Reading[]): string[] { - return readings.map((r) => (r.data_quality ? QUALITY_COLORS[r.data_quality] : UNKNOWN_QUALITY_COLOR)); +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({ @@ -38,27 +53,27 @@ export class ReadingHistoryChart implements AfterViewInit, OnDestroy { constructor() { effect(() => { - const readings = this.readings(); + const series = toSeries(this.readings()); if (this.chart) { - this.chart.data.labels = readings.map((r) => r.timestamp); - this.chart.data.datasets[0].data = readings.map((r) => r.consumption_kw ?? 0); - this.chart.data.datasets[0].pointBackgroundColor = pointColors(readings); + 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 readings = this.readings(); + const series = toSeries(this.readings()); this.chart = new Chart(this.canvasRef.nativeElement, { type: 'line', data: { - labels: readings.map((r) => r.timestamp), + labels: series.labels, datasets: [ { - data: readings.map((r) => r.consumption_kw ?? 0), + data: series.values, borderColor: '#3b82f6', - pointBackgroundColor: pointColors(readings), + pointBackgroundColor: series.colors, tension: 0.25, }, ], From eb4291b10a7da75efd0d9fd9a2c6198ab5d9be05 Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 18 Sep 2026 14:58:39 +0200 Subject: [PATCH 26/27] fix(ml,backend,frontend): borne la peremption des predictions et isole les erreurs par flux --- apps/backend/tests/api/test_predictions.py | 11 +- .../src/app/core/mocks/predictions.fixture.ts | 99 ----------------- .../src/app/features/dashboard/dashboard.html | 10 +- .../app/features/dashboard/dashboard.spec.ts | 42 ++++++- .../src/app/features/dashboard/dashboard.ts | 32 ++++-- docs/architecture/00-vue-ensemble.md | 2 +- docs/architecture/20-backend.md | 13 --- docs/architecture/30-frontend.md | 19 ++-- ml/enervision_ml/data.py | 5 + ml/enervision_ml/score.py | 105 ++++++++++++------ ml/tests/test_data.py | 71 +++++++----- ml/tests/test_score.py | 51 ++++++++- 12 files changed, 258 insertions(+), 202 deletions(-) delete mode 100644 apps/frontend/src/app/core/mocks/predictions.fixture.ts diff --git a/apps/backend/tests/api/test_predictions.py b/apps/backend/tests/api/test_predictions.py index cbc1cb6..184afc6 100644 --- a/apps/backend/tests/api/test_predictions.py +++ b/apps/backend/tests/api/test_predictions.py @@ -15,11 +15,14 @@ TARGET_AT = datetime(2026, 9, 16, 13, 0, tzinfo=UTC) CREATED_AT = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) -def principal(role: Role = Role.LECTEUR) -> Principal: +def lecteur() -> Principal: + # Le garde-fou de rôle (`lecteur` minimum) est déjà couvert par l'ensemble `ROUTES_A_ROLE` + # de `tests/api/test_openapi.py` : pas besoin ici d'un paramètre de rôle jamais appelé avec + # autre chose que sa valeur par défaut. return Principal( id=uuid4(), - email=f"{role.value}@enervision.fr", - role=role, + email="lecteur@enervision.fr", + role=Role.LECTEUR, kind=AccountKind.HUMAIN, must_change_password=False, ) @@ -57,7 +60,7 @@ def servi(app: FastAPI) -> Iterator[Callable[[], FauxService]]: def installe() -> FauxService: service = FauxService() app.dependency_overrides[get_prediction_service] = lambda: service - app.dependency_overrides[get_current_principal] = lambda: principal() + app.dependency_overrides[get_current_principal] = lambda: lecteur() return service yield installe diff --git a/apps/frontend/src/app/core/mocks/predictions.fixture.ts b/apps/frontend/src/app/core/mocks/predictions.fixture.ts deleted file mode 100644 index 5397e27..0000000 --- a/apps/frontend/src/app/core/mocks/predictions.fixture.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { PredictionSummary } from '../../shared/models/prediction.model'; - -export const PREDICTIONS_FIXTURE: PredictionSummary = { - timestamp: '2026-09-18T09:00:00Z', - sites: [ - { - site_id: 'SITE001', - site_name: 'Bureau Paris La Défense', - prediction: { - target_at: '2026-09-18T10:00:00Z', - target_metric: 'consumption_kwh', - period_minutes: 60, - predicted_value: 89.2, - status: 'available', - failure_reason: null, - model_reference: 'lightgbm-16b431449a50', - created_at: '2026-09-18T09:00:00Z', - }, - }, - { - site_id: 'SITE002', - site_name: 'Usine Lyon Vénissieux', - prediction: { - target_at: '2026-09-18T10:00:00Z', - target_metric: 'consumption_kwh', - period_minutes: 60, - predicted_value: 561.4, - status: 'available', - failure_reason: null, - model_reference: 'lightgbm-16b431449a50', - created_at: '2026-09-18T09:00:00Z', - }, - }, - { - site_id: 'SITE003', - site_name: 'Data Center Marseille', - prediction: { - target_at: '2026-09-18T10:00:00Z', - target_metric: 'consumption_kwh', - period_minutes: 60, - predicted_value: null, - status: 'insufficient_data', - failure_reason: - "Historique insuffisant : moins de 168h de consumption_kwh disponibles pour ce site.", - model_reference: 'lightgbm-16b431449a50', - created_at: '2026-09-18T09:00:00Z', - }, - }, - { - site_id: 'SITE004', - site_name: 'Bureau Bordeaux', - prediction: { - target_at: '2026-09-18T10:00:00Z', - target_metric: 'consumption_kwh', - period_minutes: 60, - predicted_value: 58.9, - status: 'available', - failure_reason: null, - model_reference: 'lightgbm-16b431449a50', - created_at: '2026-09-18T09:00:00Z', - }, - }, - { - site_id: 'SITE005', - site_name: 'Usine Toulouse', - prediction: { - target_at: '2026-09-18T10:00:00Z', - target_metric: 'consumption_kwh', - period_minutes: 60, - predicted_value: 402.7, - status: 'available', - failure_reason: null, - model_reference: 'lightgbm-16b431449a50', - created_at: '2026-09-18T09:00:00Z', - }, - }, - { - site_id: 'SITE006', - site_name: 'Bureau Lille', - prediction: { - target_at: '2026-09-18T10:00:00Z', - target_metric: 'consumption_kwh', - period_minutes: 60, - predicted_value: 91.3, - status: 'available', - failure_reason: null, - model_reference: 'lightgbm-16b431449a50', - created_at: '2026-09-18T09:00:00Z', - }, - }, - { - // Illustre le cas d'un site jamais scoré : `prediction` reste `null`, pas un statut inventé - // (même contrat que `PredictionService.summary()` côté backend). - site_id: 'SITE007', - site_name: 'Data Center Nantes', - prediction: null, - }, - ], -}; diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index d3c5a87..bd5f927 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -21,7 +21,13 @@ - @if (error(); as message) { + @if (statsError(); as message) { + + } + @if (alertsError(); as message) { + + } + @if (predictionsError(); as message) { } @@ -85,7 +91,7 @@ {{ prediction.predicted_value | number: '1.0-1' }} kWh à {{ prediction.target_at | date: 'HH:mm' }}{{ prediction.target_at | date: "dd/MM 'à' HH:mm" }} } @else { diff --git a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts index 120ff25..5d716ae 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts @@ -52,7 +52,9 @@ describe('Dashboard', () => { expect(predictions.getPredictions).toHaveBeenCalled(); expect(fixture.componentInstance.alerts().length).toBe(1); expect(fixture.componentInstance.predictions().length).toBe(1); - expect(fixture.componentInstance.error()).toBeNull(); + expect(fixture.componentInstance.statsError()).toBeNull(); + expect(fixture.componentInstance.alertsError()).toBeNull(); + expect(fixture.componentInstance.predictionsError()).toBeNull(); }); it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => { @@ -80,13 +82,13 @@ describe('Dashboard', () => { vi.advanceTimersByTime(1); expect(statsMock.getSummary).toHaveBeenCalledTimes(1); - expect(fixture.componentInstance.error()).not.toBeNull(); + expect(fixture.componentInstance.statsError()).not.toBeNull(); expect(fixture.componentInstance.stats()).toBeNull(); vi.advanceTimersByTime(10000); expect(statsMock.getSummary).toHaveBeenCalledTimes(2); expect(fixture.componentInstance.stats()).not.toBeNull(); - expect(fixture.componentInstance.error()).toBeNull(); + expect(fixture.componentInstance.statsError()).toBeNull(); }); it("n'interrompt pas la page quand le chargement des alertes échoue", () => { @@ -107,6 +109,7 @@ describe('Dashboard', () => { fixture.detectChanges(); expect(fixture.componentInstance.alerts().length).toBe(0); + expect(fixture.componentInstance.alertsError()).not.toBeNull(); }); it("n'interrompt pas la page quand le chargement des prévisions échoue", () => { @@ -130,7 +133,38 @@ describe('Dashboard', () => { fixture.detectChanges(); expect(fixture.componentInstance.predictions().length).toBe(0); - expect(fixture.componentInstance.error()).not.toBeNull(); + expect(fixture.componentInstance.predictionsError()).not.toBeNull(); + }); + + it("un rafraîchissement de stats n'efface pas une erreur de prévisions en attente", () => { + vi.useFakeTimers(); + const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; + const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; + const predictions = { + getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))), + }; + + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictions }, + provideRouter([]), + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + fixture.detectChanges(); + + expect(fixture.componentInstance.predictionsError()).not.toBeNull(); + + // Plusieurs cycles de `timer(0, 10_000)` (stats) plus tard, l'erreur des prévisions doit + // toujours être visible : rien ne vient la rafraîchir tant que la section n'est pas rechargée. + vi.advanceTimersByTime(30000); + + expect(fixture.componentInstance.predictionsError()).not.toBeNull(); + expect(fixture.componentInstance.statsError()).toBeNull(); }); it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => { diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index d41d258..3671919 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, inject, signal, DestroyRef } from '@angular/core'; +import { Component, OnInit, inject, signal, DestroyRef, WritableSignal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { timer, switchMap, catchError, EMPTY, Observable } from 'rxjs'; import { DecimalPipe, DatePipe } from '@angular/common'; @@ -67,32 +67,44 @@ export class Dashboard implements OnInit { stats = signal(null); alerts = signal([]); predictions = signal([]); - error = signal(null); + + // Un signal par flux, pas un seul `error` partagé : sinon le tick suivant de `timer` (stats) + // efface silencieusement un message d'échec des prévisions ou des alertes après 10s au plus, + // sans retry ni indication pour l'utilisateur que la section correspondante est restée vide. + statsError = signal(null); + alertsError = signal(null); + predictionsError = signal(null); ngOnInit(): void { this.alertsService .getAlerts() - .pipe(catchError(() => this.reportUnavailable())) - .subscribe((alerts) => this.alerts.set(alerts)); + .pipe(catchError(() => this.reportUnavailable(this.alertsError))) + .subscribe((alerts) => { + this.alertsError.set(null); + this.alerts.set(alerts); + }); // Les prévisions viennent d'un scoring hors ligne, pas d'un calcul à la demande : un seul // chargement au démarrage suffit, pas besoin du rafraîchissement périodique de `stats`. this.predictionsService .getPredictions() - .pipe(catchError(() => this.reportUnavailable())) - .subscribe((summary) => this.predictions.set(summary.sites)); + .pipe(catchError(() => this.reportUnavailable(this.predictionsError))) + .subscribe((summary) => { + this.predictionsError.set(null); + this.predictions.set(summary.sites); + }); // Piège : le catchError porte sur l'observable interne. Sur le flux externe il // terminerait le timer, et le rafraîchissement ne repartirait jamais. timer(0, REFRESH_INTERVAL_MS) .pipe( switchMap(() => - this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable())), + this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable(this.statsError))), ), takeUntilDestroyed(this.destroyRef), ) .subscribe((stats) => { - this.error.set(null); + this.statsError.set(null); this.stats.set(stats); }); } @@ -116,8 +128,8 @@ export class Dashboard implements OnInit { }); } - private reportUnavailable(): Observable { - this.error.set(UNAVAILABLE_MESSAGE); + private reportUnavailable(target: WritableSignal): Observable { + target.set(UNAVAILABLE_MESSAGE); return EMPTY; } } diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index 26b890e..a650a1e 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -75,7 +75,7 @@ collecteur ne vient le lire. | Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement | |---|---|---|---|---| | Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary`, `readings`, `sensors/status` et `predictions` en lecture (endpoints → services → repositories → models) | -| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, deux services HTTP, graphiques Chart.js, données servies par des fixtures | +| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, authentification complète (garde de route, intercepteur de jeton), cinq services HTTP, graphiques Chart.js. `stats`/`alerts` sur fixtures, `predictions` branché sur l'API réelle | | Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) | | ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`. Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../../ML-START.md). Automatisation (Airflow) et surveillance de dérive (EC06, #44/#45) pas encore construites | | Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée | diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index 308a8bf..f60397d 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -205,19 +205,6 @@ par exemple `limit` hors bornes). Un datetime sans fuseau dans `start`/`end` est l'UTC plutôt que rejeté : le comparer tel quel à `reading.timestamp` (`timestamptz`) échouerait côté pilote, en `500` plutôt qu'un refus propre. -`GET /readings` reprend le même gabarit mais s'en écarte sur un point : `reading` est l'hypertable, -donc la seule table métier pouvant porter des années d'historique, ce que `docs/architecture/ -owasp-traceabilite.md` documentait comme un risque ouvert (API4, aucune pagination plafonnée ni -fenêtre temporelle maximale). `ReadingService` porte donc une couche de validation absente des -autres routes de lecture : `start`/`end` sont optionnels (24 dernières heures par défaut si les -deux sont omis, l'un défaut par rapport à l'autre sinon), l'écart entre les deux est plafonné à 90 -jours (`FENETRE_MAXIMALE`), et `limit`/`offset` (défaut 500, plafond 2000) empêchent qu'une fenêtre -large mais peu dense reste malgré tout coûteuse. Un dépassement de plafond répond `400` (règle -métier, portée par le service) plutôt que `422` (réservé à la validation structurelle de FastAPI, -par exemple `limit` hors bornes). Un datetime sans fuseau dans `start`/`end` est traité comme de -l'UTC plutôt que rejeté : le comparer tel quel à `reading.timestamp` (`timestamptz`) échouerait -côté pilote, en `500` plutôt qu'un refus propre. - ### `/health/ready` Cette sonde porte une garde décrite dans l'[ADR 0001](../adr/0001-postgresql-timescaledb.md) : un diff --git a/docs/architecture/30-frontend.md b/docs/architecture/30-frontend.md index b7f6d10..fb10f92 100644 --- a/docs/architecture/30-frontend.md +++ b/docs/architecture/30-frontend.md @@ -31,10 +31,11 @@ Ce qui est en place : Ce qui n'existe pas encore : -- **Aucun endpoint métier réel derrière l'écran du tableau de bord.** `GET /api/v1/stats/summary`, - `GET /api/v1/alerts` et `GET /api/v1/predictions` sont servis par l'intercepteur de fixtures ; - l'API expose bien ces routes désormais, mais rien ne bascule `useMockFixtures` à `false` en - développement pour les consommer réellement. +- **`stats`/`alerts` restent sur fixtures.** `GET /api/v1/stats/summary` et `GET /api/v1/alerts` + sont servis par l'intercepteur de fixtures ; l'API expose bien ces routes désormais, mais rien + ne bascule `useMockFixtures` à `false` en développement pour les consommer réellement. + `GET /api/v1/predictions` fait exception : jamais mocké, branché sur l'API réelle depuis cette + PR (voir plus bas). - Aucun état de chargement : tant que la première réponse n'est pas arrivée, la page reste vide. - Aucun lint : ESLint n'est pas installé. @@ -85,10 +86,12 @@ sequenceDiagram S-->>C: modèle typé ``` -`mockApiInterceptor` n'intercepte que `/stats/summary`, `/alerts` et `/predictions`, et seulement -si `environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en -production : toute autre requête (dont tout ce qui touche `/auth`), et toutes les requêtes en -production, suivent le chemin réel. +`mockApiInterceptor` n'intercepte que `/stats/summary` et `/alerts`, et seulement si +`environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en +production : toute autre requête, et toutes les requêtes en production, suivent le chemin réel. +`/predictions` est volontairement exclu de cette liste (contrairement à `stats`/`alerts`) : il +suit toujours le chemin réel, comme `/auth/*` - en développement, ça veut dire qu'un jeton valide +et un backend joignable sont nécessaires pour que la section prévisions du dashboard s'affiche. En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un diff --git a/ml/enervision_ml/data.py b/ml/enervision_ml/data.py index 6e08b54..f8e7bf9 100644 --- a/ml/enervision_ml/data.py +++ b/ml/enervision_ml/data.py @@ -119,6 +119,11 @@ def _typer(frame: pd.DataFrame) -> pd.DataFrame: LightGBM refuse ("pandas dtypes must be int, float or bool"). `pd.to_numeric` corrige aussi n'importe quelle autre colonne mesuree entierement absente sur une fenetre de scoring, pas seulement `capacity_kw`. + + Piege additionnel : `NUMERIC_COLUMNS` inclut `consumption_kwh`, la cible du modele, pas + seulement des variables explicatives. Une valeur non numerique y devient donc silencieusement + `NaN` aussi bien a l'entrainement (ou `train.py` l'exclura ensuite via son `dropna`) qu'au + scoring -- ce n'est pas un effet de bord limite aux colonnes mesurees. """ typee = frame.copy() for colonne in NUMERIC_COLUMNS: diff --git a/ml/enervision_ml/score.py b/ml/enervision_ml/score.py index adf9634..c0b7e3f 100644 --- a/ml/enervision_ml/score.py +++ b/ml/enervision_ml/score.py @@ -30,6 +30,13 @@ from enervision_ml.features import TARGET_COLUMN, WEATHER_COLUMNS, build_feature # Marge au-dessus des 168h necessaires au lag hebdomadaire, pour absorber les trous de mesure. LOOKBACK = timedelta(days=21) +# Au-dela de ce seuil, la derniere lecture d'un site est trop vieille pour que "l'heure +# suivante" ait un sens operationnel : ce n'est plus une prevision a un pas, c'est un site dont +# l'ingestion s'est probablement arretee. Sans cette borne, `build_scoring_frame` produirait +# quand meme un `target_at` (derniere lecture + 1h), et rien en aval (ni l'API, ni le dashboard) +# ne distingue une prevision fraiche d'une prevision vieille de plusieurs jours. +MAX_STALENESS = timedelta(hours=24) + TARGET_METRIC = "consumption_kwh" PERIOD_MINUTES = 60 LAG_168H_COLUMN = f"{TARGET_COLUMN}_lag_168h" @@ -38,6 +45,14 @@ INSUFFICIENT_DATA_REASON = ( ) +def _stale_reason(age: pd.Timedelta) -> str: + return ( + f"Dernière lecture vieille de {age.total_seconds() / 3600:.0f}h " + f"(seuil {MAX_STALENESS.total_seconds() / 3600:.0f}h) : ingestion probablement " + "arrêtée pour ce site." + ) + + @dataclass(frozen=True, slots=True) class ScoredSite: site_id: str @@ -86,10 +101,31 @@ def build_scoring_frame(recent: pd.DataFrame, *, site_id: str | None = None) -> return features.groupby("site_id", as_index=False, sort=False).tail(1).reset_index(drop=True) -def score(booster: lgb.Booster, scoring_frame: pd.DataFrame) -> list[ScoredSite]: +def score( + booster: lgb.Booster, scoring_frame: pd.DataFrame, *, instant: datetime +) -> list[ScoredSite]: resultats: list[ScoredSite] = [] - insuffisants = scoring_frame[scoring_frame[LAG_168H_COLUMN].isna()] + # `timestamp` de la ligne de scoring vaut derniere lecture + 1h (cf. `build_scoring_frame`) : + # on en deduit l'age de cette derniere lecture par rapport a `instant`. + travail = scoring_frame.copy() + travail["_age"] = instant - (travail["timestamp"] - pd.Timedelta(hours=1)) + + perimes = travail[travail["_age"] > MAX_STALENESS] + for enregistrement in _records(perimes): + resultats.append( + ScoredSite( + site_id=enregistrement["site_id"], + target_at=enregistrement["timestamp"].to_pydatetime(), + status="insufficient_data", + predicted_value=None, + failure_reason=_stale_reason(enregistrement["_age"]), + ) + ) + + a_jour = travail[travail["_age"] <= MAX_STALENESS] + + insuffisants = a_jour[a_jour[LAG_168H_COLUMN].isna()] for enregistrement in _records(insuffisants): resultats.append( ScoredSite( @@ -101,7 +137,7 @@ def score(booster: lgb.Booster, scoring_frame: pd.DataFrame) -> list[ScoredSite] ) ) - suffisants = scoring_frame[scoring_frame[LAG_168H_COLUMN].notna()] + suffisants = a_jour[a_jour[LAG_168H_COLUMN].notna()] if not suffisants.empty: typee = suffisants.copy() typee["site_type"] = typee["site_type"].astype("category") @@ -163,20 +199,23 @@ def write_predictions( connection.execute(_INSERT_PREDICTION, lignes) -def _load_recent(*, csv_path: Path | None, now: datetime | None) -> tuple[pd.DataFrame, datetime]: - if csv_path is not None: - brute = load_from_csv(csv_path) - instant = now or ( - brute["timestamp"].max().to_pydatetime() if not brute.empty else datetime.now(UTC) - ) - return brute[brute["timestamp"] >= instant - LOOKBACK], instant +def _load_recent_from_csv(csv_path: Path, *, now: datetime | None) -> tuple[pd.DataFrame, datetime]: + brute = load_from_csv(csv_path) + instant = now or ( + brute["timestamp"].max().to_pydatetime() if not brute.empty else datetime.now(UTC) + ) + return brute[brute["timestamp"] >= instant - LOOKBACK], instant - instant = now or datetime.now(UTC) - engine = create_engine(config.database_url()) - try: - return load_recent_from_database(engine, since=instant - LOOKBACK), instant - finally: - engine.dispose() + +def _score_frame( + recent: pd.DataFrame, *, model_path: Path, site_id: str | None, instant: datetime +) -> list[ScoredSite]: + scoring_frame = build_scoring_frame(recent, site_id=site_id) + if scoring_frame.empty: + return [] + + booster = lgb.Booster(model_file=str(model_path)) + return score(booster, scoring_frame, instant=instant) def run_scoring( @@ -190,29 +229,27 @@ def run_scoring( En mode `--csv`, rien n'est ecrit : c'est un instantane historique fige (l'heure "future" calculee n'existe dans aucune base reelle), utile pour valider le pipeline sans base - joignable, cf. `ml/README.md`. + joignable, cf. `ml/README.md`. `site_id` n'est filtre qu'une fois, dans + `build_scoring_frame` : le filtrer aussi ici serait redondant. """ - recent, _instant = _load_recent(csv_path=csv_path, now=now) - if site_id is not None: - recent = recent[recent["site_id"] == site_id] + if csv_path is not None: + recent, instant = _load_recent_from_csv(csv_path, now=now) + return _score_frame(recent, model_path=model_path, site_id=site_id, instant=instant) - scoring_frame = build_scoring_frame(recent, site_id=site_id) - if scoring_frame.empty: - return [] + # Un seul engine pour la lecture et l'ecriture de ce run, plutot qu'un par etape. + engine = create_engine(config.database_url()) + try: + instant = now or datetime.now(UTC) + recent = load_recent_from_database(engine, since=instant - LOOKBACK) + resultats = _score_frame(recent, model_path=model_path, site_id=site_id, instant=instant) - booster = lgb.Booster(model_file=str(model_path)) - resultats = score(booster, scoring_frame) - - if csv_path is None: reference = model_reference(model_path) - engine = create_engine(config.database_url()) - try: - with engine.begin() as connection: - write_predictions(connection, resultats, reference=reference) - finally: - engine.dispose() + with engine.begin() as connection: + write_predictions(connection, resultats, reference=reference) - return resultats + return resultats + finally: + engine.dispose() def parse_args() -> argparse.Namespace: diff --git a/ml/tests/test_data.py b/ml/tests/test_data.py index 0a1f022..0aa42a1 100644 --- a/ml/tests/test_data.py +++ b/ml/tests/test_data.py @@ -1,34 +1,55 @@ +from pathlib import Path + import pandas as pd -from enervision_ml.data import NUMERIC_COLUMNS, OUTPUT_COLUMNS, _typer +from enervision_ml.data import NUMERIC_COLUMNS, load_from_csv + +_CSV_HEADER = ( + "site_id,timestamp,consumption_kwh,temperature_celsius,humidity_percent," + "solar_irradiance_wm2,is_working_hours,site_type" +) -def make_frame_with_object_dtype_capacity() -> pd.DataFrame: - # Reproduit ce que `pd.read_sql` renvoie pour une colonne entierement `NULL` en base : - # dtype `object` rempli de `None`, pas `float64` rempli de `NaN`. - frame = pd.DataFrame( - {colonne: [1.0, 2.0] for colonne in OUTPUT_COLUMNS if colonne not in NUMERIC_COLUMNS} +def write_csv(tmp_path: Path, *lignes: str) -> Path: + csv_path = tmp_path / "recent.csv" + csv_path.write_text("\n".join([_CSV_HEADER, *lignes]) + "\n") + return csv_path + + +def test_load_from_csv_types_every_numeric_column_as_float(tmp_path: Path) -> None: + csv_path = write_csv(tmp_path, "SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,True,office") + + frame = load_from_csv(csv_path) + + for colonne in NUMERIC_COLUMNS: + assert frame[colonne].dtype == "float64" + + +def test_load_from_csv_coerces_a_corrupted_measurement_to_nan(tmp_path: Path) -> None: + # Reproduit une valeur de capteur corrompue plutot que vraiment manquante : `pandas` type + # alors la colonne entiere en `object`, pas en `float64` rempli de `NaN` -- le meme genre de + # divergence de typage que celle que `pd.read_sql` produit sur une colonne SQL entierement + # `NULL` (cf. `site.capacity_kw`, jamais peuplee par aucun pipeline d'ingestion aujourd'hui). + csv_path = write_csv( + tmp_path, + "SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,True,office", + "SITE001,2026-01-01T01:00:00,capteur_hs,15.2,50.5,0.0,True,office", ) - for colonne in NUMERIC_COLUMNS: - frame[colonne] = pd.Series([None, None], dtype="object") - return frame + + frame = load_from_csv(csv_path) + + assert frame["consumption_kwh"].dtype == "float64" + assert frame["consumption_kwh"].iloc[0] == 10.5 + assert pd.isna(frame["consumption_kwh"].iloc[1]) -def test_typer_coerces_an_all_null_object_column_to_float() -> None: - frame = make_frame_with_object_dtype_capacity() +def test_load_from_csv_always_types_capacity_kw_as_float(tmp_path: Path) -> None: + # `capacity_kw` n'existe pas dans ce CSV : `load_from_csv` la pose elle-meme a `NaN`. Cette + # affectation directe est deja un `float`, contrairement au cas `pd.read_sql` -- ce test + # garde le contrat visible malgre tout, au cas ou l'implementation changerait. + csv_path = write_csv(tmp_path, "SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,True,office") - typee = _typer(frame) + frame = load_from_csv(csv_path) - for colonne in NUMERIC_COLUMNS: - assert typee[colonne].dtype == "float64" - assert typee[colonne].isna().all() - - -def test_typer_preserves_real_numeric_values() -> None: - frame = make_frame_with_object_dtype_capacity() - frame["capacity_kw"] = pd.Series([100.0, None], dtype="object") - - typee = _typer(frame) - - assert typee["capacity_kw"].tolist()[0] == 100.0 - assert pd.isna(typee["capacity_kw"].tolist()[1]) + assert frame["capacity_kw"].dtype == "float64" + assert pd.isna(frame["capacity_kw"].iloc[0]) diff --git a/ml/tests/test_score.py b/ml/tests/test_score.py index 28b0af5..fb3e015 100644 --- a/ml/tests/test_score.py +++ b/ml/tests/test_score.py @@ -8,6 +8,7 @@ import pytest from enervision_ml.features import TARGET_COLUMN from enervision_ml.score import ( LAG_168H_COLUMN, + MAX_STALENESS, ScoredSite, build_scoring_frame, model_reference, @@ -123,12 +124,23 @@ def test_build_scoring_frame_returns_empty_when_there_is_no_recent_reading() -> assert scoring_frame.empty +def target_at_for(depart: datetime, heures: int) -> datetime: + """`target_at` que produira `build_scoring_frame` pour ce jeu synthetique (derniere lecture + + 1h) : l'utiliser comme `instant` donne un age d'1h, largement sous le seuil de peremption, + pour les tests qui ne visent pas ce filtre.""" + return depart + timedelta(hours=heures) + + def test_score_marks_insufficient_history_without_calling_the_model() -> None: depart = datetime(2026, 1, 1, tzinfo=UTC) scoring_frame = build_scoring_frame(make_recent("site-a", heures=100, depart=depart)) booster = FakeBooster() - resultats = score(booster, scoring_frame) # type: ignore[arg-type] + resultats = score( + booster, # type: ignore[arg-type] + scoring_frame, + instant=target_at_for(depart, 100), + ) assert resultats == [ ScoredSite( @@ -147,7 +159,11 @@ def test_score_predicts_when_history_is_sufficient() -> None: scoring_frame = build_scoring_frame(make_recent("site-a", heures=200, depart=depart)) booster = FakeBooster(valeur=99.5) - resultats = score(booster, scoring_frame) # type: ignore[arg-type] + resultats = score( + booster, # type: ignore[arg-type] + scoring_frame, + instant=target_at_for(depart, 200), + ) assert len(resultats) == 1 assert resultats[0].status == "available" @@ -156,6 +172,37 @@ def test_score_predicts_when_history_is_sufficient() -> None: assert booster.appels == [1] +def test_score_marks_a_stale_site_as_insufficient_data_without_calling_the_model() -> None: + depart = datetime(2026, 1, 1, tzinfo=UTC) + # Historique largement suffisant (168h+), mais l'instant de reference est loin apres la + # derniere lecture : la fraicheur doit primer sur la disponibilite de l'historique. + scoring_frame = build_scoring_frame(make_recent("site-a", heures=200, depart=depart)) + instant = target_at_for(depart, 200) + MAX_STALENESS + timedelta(hours=1) + booster = FakeBooster() + + resultats = score(booster, scoring_frame, instant=instant) # type: ignore[arg-type] + + assert len(resultats) == 1 + assert resultats[0].status == "insufficient_data" + assert resultats[0].predicted_value is None + assert "vieille" in (resultats[0].failure_reason or "") + assert booster.appels == [] + + +def test_score_accepts_a_reading_exactly_at_the_staleness_threshold() -> None: + depart = datetime(2026, 1, 1, tzinfo=UTC) + scoring_frame = build_scoring_frame(make_recent("site-a", heures=200, depart=depart)) + # `target_at_for(...)` donne deja un age d'1h (cf. sa docstring) : retrancher cette heure + # pour retomber exactement sur le seuil, ni en dessous ni au dessus. + instant = target_at_for(depart, 200) + MAX_STALENESS - timedelta(hours=1) + booster = FakeBooster(valeur=12.0) + + resultats = score(booster, scoring_frame, instant=instant) # type: ignore[arg-type] + + assert resultats[0].status == "available" + assert booster.appels == [1] + + def test_write_predictions_does_nothing_when_there_is_nothing_to_write() -> None: connection = FakeConnection() From edd5e82d29633cc010e9a2c7dbd785f32aee1ff7 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Fri, 18 Sep 2026 16:04:10 +0200 Subject: [PATCH 27/27] fix --- sonar-project.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sonar-project.properties b/sonar-project.properties index 43c28fd..49c6abe 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -9,7 +9,7 @@ sonar.tests=apps/frontend/src,apps/backend/tests sonar.test.inclusions=**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py # 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 +sonar.exclusions=.pytest_cache,.venv,alembic,tests,**/*/node_modules/**,**/*/dist/**,**/*/build/**,**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py,**/*.spec.ts # Chemin vers le rapport de couverture de code # Fichier généré par Pytest