From f5cac1c2a8169dbf3a71113dd8babe4101bced78 Mon Sep 17 00:00:00 2001 From: Valentin Date: Thu, 17 Sep 2026 12:25:15 +0200 Subject: [PATCH 01/23] =?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/23] 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/23] =?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/23] =?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/23] 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/23] =?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 9d3e402ca49c42213cefa16e38f93ca474444c9f Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Fri, 18 Sep 2026 11:11:12 +0200 Subject: [PATCH 07/23] =?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 08/23] 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 12fb8860d1cd82a6925f0cb7217eb3e3fa7089f2 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Fri, 18 Sep 2026 11:26:45 +0200 Subject: [PATCH 09/23] 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 10/23] =?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 11/23] =?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 12/23] =?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 13/23] =?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 14/23] 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 15/23] =?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 16/23] =?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 17/23] 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 18/23] 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 19/23] 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 20/23] 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 21/23] 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 22/23] 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 23/23] =?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, }, ],