From 07ea8d21dc6ef7a01b141369da8d4a74b10c1e26 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 15:25:14 +0200 Subject: [PATCH 01/87] feat(backend): expose GET /api/v1/sites/{site_id}/current pour l'issue #29 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute la dernière mesure d'un site (SiteService.current), en réutilisant la vérification d'existence déjà en place pour GET /sites/{site_id} : SiteService gagne une dépendance ReadingRepository, sur le modèle de composition déjà utilisé par StatsService/SensorService. Un site connu sans lecture rend 200 avec les champs de mesure à null et data_quality="critical" ; seul un site_id absent rend 404. --- apps/backend/app/api/deps.py | 2 +- apps/backend/app/api/v1/endpoints/sites.py | 20 +- apps/backend/app/repositories/reading.py | 9 + apps/backend/app/schemas/site.py | 20 ++ apps/backend/app/services/site.py | 65 +++++- apps/backend/openapi.json | 221 +++++++++++++++++++++ apps/backend/tests/api/test_openapi.py | 1 + apps/backend/tests/api/test_sites.py | 52 ++++- apps/backend/tests/services/test_site.py | 89 ++++++++- docs/architecture/20-backend.md | 7 +- 10 files changed, 474 insertions(+), 12 deletions(-) diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index aaf7403..eb78758 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -140,7 +140,7 @@ UserServiceDep = Annotated[UserService, Depends(get_user_service)] def get_site_service(session: SessionDep) -> SiteService: - return SiteService(sites=SiteRepository(session)) + return SiteService(sites=SiteRepository(session), readings=ReadingRepository(session)) SiteServiceDep = Annotated[SiteService, Depends(get_site_service)] diff --git a/apps/backend/app/api/v1/endpoints/sites.py b/apps/backend/app/api/v1/endpoints/sites.py index 984dd8b..93923e9 100644 --- a/apps/backend/app/api/v1/endpoints/sites.py +++ b/apps/backend/app/api/v1/endpoints/sites.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, HTTPException, status from app.api.deps import LecteurDep, SiteServiceDep from app.api.openapi import REPONSE_VALIDATION, Reponses from app.schemas.errors import ErrorResponse -from app.schemas.site import SiteResponse +from app.schemas.site import SiteCurrentResponse, SiteResponse from app.services.site import SiteNotFoundError router = APIRouter() @@ -34,3 +34,21 @@ async def get_site(site_id: str, _: LecteurDep, service: SiteServiceDep) -> Site status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable" ) from erreur return SiteResponse.model_validate(site) + + +@router.get( + "/{site_id}/current", + response_model=SiteCurrentResponse, + summary="Dernière mesure d'un site", + responses=REPONSES_INTROUVABLE, +) +async def get_current( + site_id: str, _: LecteurDep, service: SiteServiceDep +) -> SiteCurrentResponse: + try: + actuel = await service.current(site_id) + except SiteNotFoundError as erreur: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable" + ) from erreur + return SiteCurrentResponse.model_validate(actuel) diff --git a/apps/backend/app/repositories/reading.py b/apps/backend/app/repositories/reading.py index 5424b46..c05ae60 100644 --- a/apps/backend/app/repositories/reading.py +++ b/apps/backend/app/repositories/reading.py @@ -19,3 +19,12 @@ class ReadingRepository: .order_by(Reading.site_id, Reading.timestamp.desc()) ) 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()) + .limit(1) + ) + return await self._session.scalar(requete) diff --git a/apps/backend/app/schemas/site.py b/apps/backend/app/schemas/site.py index 82035f5..56a61b7 100644 --- a/apps/backend/app/schemas/site.py +++ b/apps/backend/app/schemas/site.py @@ -1,3 +1,6 @@ +from datetime import datetime +from typing import Literal + from pydantic import BaseModel, ConfigDict @@ -10,3 +13,20 @@ class SiteResponse(BaseModel): location: str | None capacity_kw: float | None status: str | None + + +class SiteCurrentResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + timestamp: datetime | None + site_id: str + site_type: str + consumption_kw: float | None + consumption_kwh: float | None + voltage_v: float | None + current_a: float | None + power_factor: float | None + temperature_celsius: float | None + humidity_percent: float | None + null_reasons: list[str] + data_quality: Literal["good", "partial", "degraded", "critical"] diff --git a/apps/backend/app/services/site.py b/apps/backend/app/services/site.py index 515497a..25a819d 100644 --- a/apps/backend/app/services/site.py +++ b/apps/backend/app/services/site.py @@ -1,8 +1,16 @@ from collections.abc import Sequence +from dataclasses import dataclass +from datetime import datetime +from typing import Literal from app.models.energy import Site +from app.repositories.reading import ReadingRepository from app.repositories.site import SiteRepository +DataQuality = Literal["good", "partial", "degraded", "critical"] + +QUALITES_CONNUES: frozenset[str] = frozenset({"good", "partial", "degraded", "critical"}) + class SiteError(Exception): pass @@ -12,9 +20,26 @@ class SiteNotFoundError(SiteError): pass +@dataclass(frozen=True, slots=True) +class SiteCurrentReading: + timestamp: datetime | None + site_id: str + site_type: str + consumption_kw: float | None + consumption_kwh: float | None + voltage_v: float | None + current_a: float | None + power_factor: float | None + temperature_celsius: float | None + humidity_percent: float | None + null_reasons: list[str] + data_quality: DataQuality + + class SiteService: - def __init__(self, *, sites: SiteRepository) -> None: + def __init__(self, *, sites: SiteRepository, readings: ReadingRepository) -> None: self._sites = sites + self._readings = readings async def list_all(self) -> Sequence[Site]: return await self._sites.list_all() @@ -24,3 +49,41 @@ class SiteService: if site is None: raise SiteNotFoundError(site_id) return site + + async def current(self, site_id: str) -> SiteCurrentReading: + site = await self.get_by_id(site_id) + derniere = await self._readings.latest_for_site(site_id) + + if derniere is None: + return SiteCurrentReading( + timestamp=None, + site_id=site.site_id, + site_type=site.site_type, + consumption_kw=None, + consumption_kwh=None, + voltage_v=None, + current_a=None, + power_factor=None, + temperature_celsius=None, + humidity_percent=None, + null_reasons=[], + data_quality="critical", + ) + + qualite: DataQuality = ( + derniere.data_quality if derniere.data_quality in QUALITES_CONNUES else "critical" + ) + return SiteCurrentReading( + timestamp=derniere.timestamp, + site_id=site.site_id, + site_type=site.site_type, + consumption_kw=derniere.consumption_kw, + consumption_kwh=derniere.consumption_kwh, + voltage_v=derniere.voltage_v, + current_a=derniere.current_a, + power_factor=derniere.power_factor, + temperature_celsius=derniere.temperature_celsius, + humidity_percent=derniere.humidity_percent, + null_reasons=derniere.null_reasons or [], + data_quality=qualite, + ) diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index af962df..6844f72 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -921,6 +921,93 @@ } } }, + "/api/v1/sites/{site_id}/current": { + "get": { + "tags": [ + "sites" + ], + "summary": "Dernière mesure d'un site", + "operationId": "get_current_api_v1_sites__site_id__current_get", + "security": [ + { + "Jeton d'accès": [] + } + ], + "parameters": [ + { + "name": "site_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Site Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SiteCurrentResponse" + } + } + } + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + }, + "401": { + "description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Mot de passe provisoire à changer (`detail` vaut `password_change_required`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + }, + "404": { + "description": "Aucun site ne porte cet identifiant.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/v1/alerts": { "get": { "tags": [ @@ -1572,6 +1659,140 @@ ], "title": "Role" }, + "SiteCurrentResponse": { + "properties": { + "timestamp": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Timestamp" + }, + "site_id": { + "type": "string", + "title": "Site Id" + }, + "site_type": { + "type": "string", + "title": "Site Type" + }, + "consumption_kw": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Consumption Kw" + }, + "consumption_kwh": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Consumption Kwh" + }, + "voltage_v": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Voltage V" + }, + "current_a": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Current A" + }, + "power_factor": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Power Factor" + }, + "temperature_celsius": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Temperature Celsius" + }, + "humidity_percent": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Humidity Percent" + }, + "null_reasons": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Null Reasons" + }, + "data_quality": { + "type": "string", + "enum": [ + "good", + "partial", + "degraded", + "critical" + ], + "title": "Data Quality" + } + }, + "type": "object", + "required": [ + "timestamp", + "site_id", + "site_type", + "consumption_kw", + "consumption_kwh", + "voltage_v", + "current_a", + "power_factor", + "temperature_celsius", + "humidity_percent", + "null_reasons", + "data_quality" + ], + "title": "SiteCurrentResponse" + }, "SiteResponse": { "properties": { "site_id": { diff --git a/apps/backend/tests/api/test_openapi.py b/apps/backend/tests/api/test_openapi.py index f7147da..3112937 100644 --- a/apps/backend/tests/api/test_openapi.py +++ b/apps/backend/tests/api/test_openapi.py @@ -31,6 +31,7 @@ ROUTES_A_ROLE = { ("POST", "/api/v1/users/{id}/password-reset"), ("GET", "/api/v1/sites"), ("GET", "/api/v1/sites/{site_id}"), + ("GET", "/api/v1/sites/{site_id}/current"), ("GET", "/api/v1/alerts"), ("GET", "/api/v1/recommendations"), ("GET", "/api/v1/recommendations/{recommendation_id}"), diff --git a/apps/backend/tests/api/test_sites.py b/apps/backend/tests/api/test_sites.py index 3692565..dea8850 100644 --- a/apps/backend/tests/api/test_sites.py +++ b/apps/backend/tests/api/test_sites.py @@ -1,4 +1,5 @@ from collections.abc import Callable, Iterator +from datetime import UTC, datetime from uuid import uuid4 import pytest @@ -9,7 +10,9 @@ from app.api.deps import get_current_principal, get_site_service from app.core.principal import Principal from app.core.roles import AccountKind, Role from app.models.energy import Site -from app.services.site import SiteNotFoundError +from app.services.site import SiteCurrentReading, SiteNotFoundError + +TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) def principal(role: Role = Role.LECTEUR) -> Principal: @@ -33,10 +36,28 @@ def site(site_id: str = "site-1") -> Site: ) +def lecture_actuelle(site_id: str = "site-1") -> SiteCurrentReading: + return SiteCurrentReading( + timestamp=TIMESTAMP, + site_id=site_id, + site_type="industriel", + consumption_kw=87.34, + consumption_kwh=87.34, + voltage_v=401.2, + current_a=132.5, + power_factor=0.923, + temperature_celsius=22.1, + humidity_percent=58.4, + null_reasons=[], + data_quality="good", + ) + + class FauxService: def __init__(self, erreur: Exception | None = None) -> None: self._erreur = erreur self.site = site() + self.actuel = lecture_actuelle() async def list_all(self) -> list[Site]: return [self.site] @@ -46,6 +67,11 @@ class FauxService: raise self._erreur return self.site + async def current(self, site_id: str) -> SiteCurrentReading: + if self._erreur is not None: + raise self._erreur + return self.actuel + @pytest.fixture def lecteur_connecte(app: FastAPI) -> Iterator[None]: @@ -109,6 +135,30 @@ async def test_get_site_returns_404_for_an_unknown_site( assert response.status_code == 404 +async def test_get_current_returns_the_latest_reading( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/sites/site-1/current") + + assert response.status_code == 200 + corps = response.json() + assert corps["site_id"] == "site-1" + assert corps["data_quality"] == "good" + assert corps["consumption_kw"] == 87.34 + + +async def test_get_current_returns_404_for_an_unknown_site( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi(SiteNotFoundError("site-inconnu")) + + response = await client.get("/api/v1/sites/site-inconnu/current") + + assert response.status_code == 404 + + async def test_list_sites_reaches_the_repository_through_the_session( lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient ) -> None: diff --git a/apps/backend/tests/services/test_site.py b/apps/backend/tests/services/test_site.py index 73ef21f..7e555e8 100644 --- a/apps/backend/tests/services/test_site.py +++ b/apps/backend/tests/services/test_site.py @@ -1,8 +1,13 @@ +from dataclasses import dataclass, field +from datetime import UTC, datetime + import pytest from app.models.energy import Site from app.services.site import SiteNotFoundError, SiteService +TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) + def site(site_id: str = "site-1") -> Site: return Site( @@ -15,6 +20,21 @@ def site(site_id: str = "site-1") -> Site: ) +@dataclass +class FauxLecture: + site_id: str + timestamp: datetime = TIMESTAMP + consumption_kw: float | None = 87.34 + consumption_kwh: float | None = 87.34 + voltage_v: float | None = 401.2 + current_a: float | None = 132.5 + power_factor: float | None = 0.923 + temperature_celsius: float | None = 22.1 + humidity_percent: float | None = 58.4 + null_reasons: list[str] | None = field(default_factory=list) + data_quality: str | None = "good" + + class FakeRepository: def __init__(self, sites: list[Site]) -> None: self._sites = sites @@ -26,24 +46,79 @@ class FakeRepository: return next((s for s in self._sites if s.site_id == site_id), None) -async def test_list_all_returns_the_repository_sites() -> None: - service = SiteService(sites=FakeRepository([site("a"), site("b")])) +class FauxDepotLectures: + def __init__(self, lectures: dict[str, FauxLecture]) -> None: + self._lectures = lectures - sites = await service.list_all() + async def latest_for_site(self, site_id: str) -> FauxLecture | None: + return self._lectures.get(site_id) + + +def service( + sites: list[Site], lectures: dict[str, FauxLecture] | None = None +) -> SiteService: + return SiteService( + sites=FakeRepository(sites), # type: ignore[arg-type] + readings=FauxDepotLectures(lectures or {}), # type: ignore[arg-type] + ) + + +async def test_list_all_returns_the_repository_sites() -> None: + svc = service([site("a"), site("b")]) + + sites = await svc.list_all() assert [s.site_id for s in sites] == ["a", "b"] async def test_get_by_id_returns_the_matching_site() -> None: - service = SiteService(sites=FakeRepository([site("a")])) + svc = service([site("a")]) - trouve = await service.get_by_id("a") + trouve = await svc.get_by_id("a") assert trouve.site_id == "a" async def test_get_by_id_raises_when_the_site_is_unknown() -> None: - service = SiteService(sites=FakeRepository([])) + svc = service([]) with pytest.raises(SiteNotFoundError): - await service.get_by_id("inconnu") + await svc.get_by_id("inconnu") + + +async def test_current_raises_when_the_site_is_unknown() -> None: + svc = service([]) + + with pytest.raises(SiteNotFoundError): + await svc.current("inconnu") + + +async def test_current_returns_every_field_as_null_when_the_site_has_no_reading() -> None: + svc = service([site("a")]) + + actuel = await svc.current("a") + + assert actuel.timestamp is None + assert actuel.consumption_kw is None + assert actuel.data_quality == "critical" + assert actuel.null_reasons == [] + + +async def test_current_copies_every_field_from_the_latest_reading() -> None: + svc = service([site("a")], {"a": FauxLecture(site_id="a")}) + + actuel = await svc.current("a") + + assert actuel.timestamp == TIMESTAMP + assert actuel.site_type == "industriel" + assert actuel.consumption_kw == 87.34 + assert actuel.voltage_v == 401.2 + assert actuel.data_quality == "good" + + +async def test_current_treats_an_unknown_data_quality_as_critical() -> None: + svc = service([site("a")], {"a": FauxLecture(site_id="a", data_quality=None)}) + + actuel = await svc.current("a") + + assert actuel.data_quality == "critical" diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index fec2794..32253fb 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -142,6 +142,7 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke | POST | `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions. `admin` | 401, 403, 404, 422, 500 | | GET | `/api/v1/sites` | Liste les sites. `lecteur` | 401, 403, 500 | | GET | `/api/v1/sites/{site_id}` | Décrit un site. `lecteur` | 401, 403, 404, 422, 500 | +| GET | `/api/v1/sites/{site_id}/current` | Dernière mesure d'un site. `lecteur` | 401, 403, 404, 422, 500 | | GET | `/api/v1/alerts` | Liste les alertes, filtrable par `site_id` et `severity`. `lecteur` | 401, 403, 422, 500 | | GET | `/api/v1/recommendations` | Liste les recommandations. `lecteur` | 401, 403, 500 | | GET | `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation. `lecteur` | 401, 403, 404, 422, 500 | @@ -169,7 +170,11 @@ gabarit à la lettre, `recommendation_id` étant un entier plutôt qu'un texte. porte pas `site_id` : elle remonte à un site par sa seule `alert_id`, `alert` n'étant pas encore exposée. `GET /stats/summary` agrège deux repositories (`SiteRepository`, `ReadingRepository`) dans un service dédié plutôt que d'exposer une table : elle n'entre donc pas dans ce gabarit -route-par-table. Le contrat détaillé pour le frontend est dans +route-par-table. `GET /sites/{site_id}/current` reste sur le gabarit `sites`, mais +`SiteService` gagne la même seconde dépendance (`ReadingRepository`) pour restituer la +dernière `Reading` du site : un site connu sans lecture rend `200` avec tous les champs de +mesure à `null` et `data_quality="critical"`, seul un `site_id` absent de la base rend `404`. +Le contrat détaillé pour le frontend est dans [31-contrat-authentification.md](31-contrat-authentification.md). ### `/health/ready` From 2f97e4d4344deb8831559e6cf8a0d74e3061911e Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 15:27:05 +0200 Subject: [PATCH 02/87] fix(backend): corrige formatage ruff et typage mypy sur sites/current MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI en échec sur ruff format (ligne trop longue) et mypy (retour Any non annoté, assignation Literal non étroite). Corrige sans changer le comportement. --- apps/backend/app/api/v1/endpoints/sites.py | 4 +--- apps/backend/app/repositories/reading.py | 3 ++- apps/backend/app/services/site.py | 6 +++--- apps/backend/tests/services/test_site.py | 4 +--- 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/apps/backend/app/api/v1/endpoints/sites.py b/apps/backend/app/api/v1/endpoints/sites.py index 93923e9..5687b33 100644 --- a/apps/backend/app/api/v1/endpoints/sites.py +++ b/apps/backend/app/api/v1/endpoints/sites.py @@ -42,9 +42,7 @@ async def get_site(site_id: str, _: LecteurDep, service: SiteServiceDep) -> Site summary="Dernière mesure d'un site", responses=REPONSES_INTROUVABLE, ) -async def get_current( - site_id: str, _: LecteurDep, service: SiteServiceDep -) -> SiteCurrentResponse: +async def get_current(site_id: str, _: LecteurDep, service: SiteServiceDep) -> SiteCurrentResponse: try: actuel = await service.current(site_id) except SiteNotFoundError as erreur: diff --git a/apps/backend/app/repositories/reading.py b/apps/backend/app/repositories/reading.py index c05ae60..7981b9e 100644 --- a/apps/backend/app/repositories/reading.py +++ b/apps/backend/app/repositories/reading.py @@ -27,4 +27,5 @@ class ReadingRepository: .order_by(Reading.timestamp.desc()) .limit(1) ) - return await self._session.scalar(requete) + lecture: Reading | None = await self._session.scalar(requete) + return lecture diff --git a/apps/backend/app/services/site.py b/apps/backend/app/services/site.py index 25a819d..50d2e24 100644 --- a/apps/backend/app/services/site.py +++ b/apps/backend/app/services/site.py @@ -70,9 +70,9 @@ class SiteService: data_quality="critical", ) - qualite: DataQuality = ( - derniere.data_quality if derniere.data_quality in QUALITES_CONNUES else "critical" - ) + qualite: DataQuality = "critical" + if derniere.data_quality in QUALITES_CONNUES: + qualite = derniere.data_quality # type: ignore[assignment] return SiteCurrentReading( timestamp=derniere.timestamp, site_id=site.site_id, diff --git a/apps/backend/tests/services/test_site.py b/apps/backend/tests/services/test_site.py index 7e555e8..76584fb 100644 --- a/apps/backend/tests/services/test_site.py +++ b/apps/backend/tests/services/test_site.py @@ -54,9 +54,7 @@ class FauxDepotLectures: return self._lectures.get(site_id) -def service( - sites: list[Site], lectures: dict[str, FauxLecture] | None = None -) -> SiteService: +def service(sites: list[Site], lectures: dict[str, FauxLecture] | None = None) -> SiteService: return SiteService( sites=FakeRepository(sites), # type: ignore[arg-type] readings=FauxDepotLectures(lectures or {}), # type: ignore[arg-type] From 9161b74874a13b0b17df9eca9679c2120362e683 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 17 Sep 2026 10:53:58 +0200 Subject: [PATCH 03/87] feat(auth): politique de complexite du mot de passe et flux de reinitialisation Remplace la regle de longueur seule (12 caracteres) par une exigence de composition (8 caracteres minimum, majuscule, minuscule, chiffre, caractere special), non documentee dans les exigences officielles du projet, par une regle explicite partagee entre le backend (validateur Pydantic) et le frontend. Ajoute un flux "mot de passe oublie" en libre-service, absent jusqu'ici : jeton a usage unique hache en base (meme principe que les refresh tokens), expirant a 15 minutes, envoye par email via un service SMTP (aiosmtplib, Mailpit en dev), avec limitation de debit dediee et reponse generique pour eviter l'enumeration des comptes. Closes #87 --- apps/backend/.env.example | 10 + apps/backend/README.md | 2 + ...c0adab96238c_jetons_de_reinitialisation.py | 96 ++++++++++ apps/backend/app/api/deps.py | 31 +++- apps/backend/app/api/openapi.py | 13 ++ apps/backend/app/api/v1/endpoints/auth.py | 83 +++++++++ apps/backend/app/cli.py | 31 +++- apps/backend/app/core/config.py | 13 ++ apps/backend/app/core/mailer.py | 48 +++++ apps/backend/app/models/__init__.py | 4 + apps/backend/app/models/audit_log.py | 2 + .../app/models/password_reset_attempt.py | 27 +++ .../app/models/password_reset_token.py | 40 ++++ .../repositories/password_reset_attempt.py | 42 +++++ .../app/repositories/password_reset_token.py | 68 +++++++ apps/backend/app/schemas/auth.py | 45 ++++- apps/backend/app/services/auth.py | 110 +++++++++++ apps/backend/openapi.json | 175 +++++++++++++++++- apps/backend/pyproject.toml | 1 + apps/backend/tests/api/test_auth.py | 100 ++++++++++ .../tests/api/test_route_protection.py | 4 + .../repositories/test_password_reset_token.py | 114 ++++++++++++ apps/backend/tests/schemas/__init__.py | 0 apps/backend/tests/schemas/test_auth.py | 41 ++++ apps/backend/tests/services/test_auth.py | 158 +++++++++++++++- apps/backend/tests/test_cli.py | 19 +- apps/backend/uv.lock | 11 ++ apps/frontend/src/app/app.routes.ts | 2 + .../src/app/core/services/auth.service.ts | 19 +- .../auth/change-password/change-password.html | 2 +- .../change-password/change-password.spec.ts | 17 +- .../auth/change-password/change-password.ts | 6 +- .../auth/forgot-password/forgot-password.html | 37 ++++ .../auth/forgot-password/forgot-password.scss | 104 +++++++++++ .../forgot-password/forgot-password.spec.ts | 75 ++++++++ .../auth/forgot-password/forgot-password.ts | 53 ++++++ .../src/app/features/auth/login/login.html | 2 + .../src/app/features/auth/login/login.scss | 10 + .../src/app/features/auth/login/login.spec.ts | 3 +- .../src/app/features/auth/login/login.ts | 4 +- .../auth/reset-password/reset-password.html | 30 +++ .../auth/reset-password/reset-password.scss | 104 +++++++++++ .../reset-password/reset-password.spec.ts | 74 ++++++++ .../auth/reset-password/reset-password.ts | 52 ++++++ .../src/app/shared/models/auth.model.ts | 9 + .../shared/validators/password.validator.ts | 15 ++ docker-compose.yml | 16 ++ .../31-contrat-authentification.md | 17 +- 48 files changed, 1914 insertions(+), 25 deletions(-) create mode 100644 apps/backend/alembic/versions/c0adab96238c_jetons_de_reinitialisation.py create mode 100644 apps/backend/app/core/mailer.py create mode 100644 apps/backend/app/models/password_reset_attempt.py create mode 100644 apps/backend/app/models/password_reset_token.py create mode 100644 apps/backend/app/repositories/password_reset_attempt.py create mode 100644 apps/backend/app/repositories/password_reset_token.py create mode 100644 apps/backend/tests/repositories/test_password_reset_token.py create mode 100644 apps/backend/tests/schemas/__init__.py create mode 100644 apps/backend/tests/schemas/test_auth.py create mode 100644 apps/frontend/src/app/features/auth/forgot-password/forgot-password.html create mode 100644 apps/frontend/src/app/features/auth/forgot-password/forgot-password.scss create mode 100644 apps/frontend/src/app/features/auth/forgot-password/forgot-password.spec.ts create mode 100644 apps/frontend/src/app/features/auth/forgot-password/forgot-password.ts create mode 100644 apps/frontend/src/app/features/auth/reset-password/reset-password.html create mode 100644 apps/frontend/src/app/features/auth/reset-password/reset-password.scss create mode 100644 apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts create mode 100644 apps/frontend/src/app/features/auth/reset-password/reset-password.ts create mode 100644 apps/frontend/src/app/shared/validators/password.validator.ts diff --git a/apps/backend/.env.example b/apps/backend/.env.example index f36551e..8dff67f 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -8,3 +8,13 @@ APP_SECRET_KEY=change_me APP_CORS_ORIGINS=http://localhost:4200 DATABASE_URL=postgresql+asyncpg://enervision:change_me@localhost:5433/enervision + +# Mot de passe oublié : lien à usage unique valable 15 minutes par défaut. +APP_FRONTEND_RESET_PASSWORD_URL=http://localhost:4200/reset-password + +# SMTP local de dev (Mailpit, cf. docker-compose.yml) : aucune authentification, aucun TLS. +# À remplacer par un vrai relais en staging/prod. +APP_SMTP_HOST=localhost +APP_SMTP_PORT=1025 +APP_SMTP_USE_TLS=false +APP_SMTP_FROM_ADDRESS=no-reply@enervision.fr diff --git a/apps/backend/README.md b/apps/backend/README.md index 91f9608..6c48b3a 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -103,6 +103,8 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie | `/api/v1/auth/logout` | Ferme la session courante | cookie, idempotente | | `/api/v1/auth/logout-all` | Ferme toutes les sessions du compte | jeton | | `/api/v1/auth/password` | Change son propre mot de passe | jeton | +| `/api/v1/auth/forgot-password` | Demande un lien de réinitialisation par email | public | +| `/api/v1/auth/reset-password` | Choisit un nouveau mot de passe depuis ce lien | public | | `/api/v1/auth/me` | Décrit le compte connecté | jeton | | `/api/v1/users` | Liste et crée des comptes | `admin` | | `/api/v1/users/{id}` | Change le rôle ou l'activation | `admin` | diff --git a/apps/backend/alembic/versions/c0adab96238c_jetons_de_reinitialisation.py b/apps/backend/alembic/versions/c0adab96238c_jetons_de_reinitialisation.py new file mode 100644 index 0000000..7f75d21 --- /dev/null +++ b/apps/backend/alembic/versions/c0adab96238c_jetons_de_reinitialisation.py @@ -0,0 +1,96 @@ +"""jetons et tentatives de reinitialisation de mot de passe + +Revision ID: c0adab96238c +Revises: e6d2026091501 +Create Date: 2026-09-17 10:37:12.571314 + +Meme schema que `refresh_token` pour `password_reset_token` : seule l'empreinte SHA-256 du +jeton est stockee, jamais le jeton lui-meme, pour la meme raison (revocation en cascade, +aucune session utilisable dans un pg_dump qui fuiterait). + +`password_reset_attempt` vit hors de `audit_log`, comme `login_attempt`, car son volume est +pilote par l'attaquant : une campagne de demandes y ecrirait des lignes que l'audit, en ajout +seul, ne devrait jamais purger. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "c0adab96238c" +down_revision: str | Sequence[str] | None = "e6d2026091501" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +JETONS_VIVANTS = "consumed_at is null" + + +def upgrade() -> None: + op.create_table( + "password_reset_attempt", + sa.Column("id", sa.BigInteger(), sa.Identity(always=True), nullable=False), + sa.Column( + "occurred_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("email_tried", sa.String(length=320), nullable=False), + sa.Column("client_ip", postgresql.INET(), nullable=True), + sa.PrimaryKeyConstraint("id", name="pk_password_reset_attempt"), + ) + op.create_index( + "ix_password_reset_attempt_email_date", + "password_reset_attempt", + ["email_tried", "occurred_at"], + ) + op.create_index( + "ix_password_reset_attempt_ip_date", "password_reset_attempt", ["client_ip", "occurred_at"] + ) + + op.create_table( + "password_reset_token", + sa.Column("id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("token_hash", sa.LargeBinary(), nullable=False), + sa.Column( + "issued_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("client_ip", postgresql.INET(), nullable=True), + sa.Column("user_agent", sa.Text(), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["app_user.id"], + name="fk_password_reset_token_user", + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name="pk_password_reset_token"), + sa.UniqueConstraint("token_hash", name="uq_password_reset_token_hash"), + ) + op.create_index("ix_password_reset_token_user", "password_reset_token", ["user_id"]) + op.create_index( + "ix_password_reset_token_vivants", + "password_reset_token", + ["user_id"], + postgresql_where=JETONS_VIVANTS, + ) + + +def downgrade() -> None: + op.drop_index( + "ix_password_reset_token_vivants", + table_name="password_reset_token", + postgresql_where=JETONS_VIVANTS, + ) + op.drop_index("ix_password_reset_token_user", table_name="password_reset_token") + op.drop_table("password_reset_token") + op.drop_index("ix_password_reset_attempt_ip_date", table_name="password_reset_attempt") + op.drop_index("ix_password_reset_attempt_email_date", table_name="password_reset_attempt") + op.drop_table("password_reset_attempt") diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index 5b39098..f1aa8ae 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -16,6 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import Settings, get_settings from app.core.hashing import Argon2Hasher, build_hasher +from app.core.mailer import Mailer, SmtpConfig from app.core.principal import Principal from app.core.roles import AccountKind, Role, has_at_least from app.core.security import TokenExpiredError, TokenInvalidError, TokenPolicy @@ -24,13 +25,15 @@ from app.db.session import get_session from app.repositories.alert import AlertRepository from app.repositories.audit_log import AuditLogRepository from app.repositories.login_attempt import LoginAttemptRepository +from app.repositories.password_reset_attempt import PasswordResetAttemptRepository +from app.repositories.password_reset_token import PasswordResetTokenRepository from app.repositories.reading import ReadingRepository from app.repositories.recommendation import RecommendationRepository from app.repositories.refresh_token import RefreshTokenRepository from app.repositories.site import SiteRepository from app.repositories.user import UserRepository from app.services.alert import AlertService -from app.services.auth import AuthService, LoginPolicy +from app.services.auth import AuthService, LoginPolicy, PasswordResetPolicy from app.services.recommendation import RecommendationService from app.services.sensor import SensorService from app.services.site import SiteService @@ -97,11 +100,27 @@ def get_client_ip(request: Request, settings: SettingsDep) -> str | None: return request.client.host if request.client else None +def get_mailer(settings: SettingsDep) -> Mailer: + return Mailer( + SmtpConfig( + host=settings.smtp_host, + port=settings.smtp_port, + username=settings.smtp_username, + password=( + settings.smtp_password.get_secret_value() if settings.smtp_password else None + ), + use_tls=settings.smtp_use_tls, + from_address=settings.smtp_from_address, + ) + ) + + def get_auth_service( session: SessionDep, settings: SettingsDep, hasher: Annotated[Argon2Hasher, Depends(get_hasher)], token_policy: Annotated[TokenPolicy, Depends(get_token_policy)], + mailer: Annotated[Mailer, Depends(get_mailer)], ) -> AuthService: return AuthService( users=UserRepository(session), @@ -118,6 +137,16 @@ def get_auth_service( max_failures_per_identifier=settings.login_max_failures_per_identifier, ), refresh_ttl=timedelta(seconds=settings.refresh_token_ttl_seconds), + reset_tokens=PasswordResetTokenRepository(session), + reset_attempts=PasswordResetAttemptRepository(session), + reset_policy=PasswordResetPolicy( + window_seconds=settings.password_reset_window_seconds, + max_requests_per_identifier=settings.password_reset_max_requests_per_identifier, + max_requests_per_ip=settings.password_reset_max_requests_per_ip, + token_ttl=timedelta(seconds=settings.password_reset_ttl_seconds), + frontend_reset_url=settings.frontend_reset_password_url, + ), + mailer=mailer, ) diff --git a/apps/backend/app/api/openapi.py b/apps/backend/app/api/openapi.py index 85b7775..a649705 100644 --- a/apps/backend/app/api/openapi.py +++ b/apps/backend/app/api/openapi.py @@ -156,3 +156,16 @@ REPONSE_ORIGINE_REFUSEE: Final[Reponses] = { "description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).", }, } + +REPONSE_LIMITE: Final[Reponses] = { + 429: { + "model": ErrorResponse, + "description": "Trop de demandes sur cette fenêtre glissante.", + "headers": { + "Retry-After": { + "description": "Secondes à attendre avant une nouvelle tentative.", + "schema": {"type": "integer"}, + } + }, + }, +} diff --git a/apps/backend/app/api/v1/endpoints/auth.py b/apps/backend/app/api/v1/endpoints/auth.py index 32bf8b2..957775f 100644 --- a/apps/backend/app/api/v1/endpoints/auth.py +++ b/apps/backend/app/api/v1/endpoints/auth.py @@ -12,6 +12,7 @@ from app.api.deps import ( require_trusted_origin, ) from app.api.openapi import ( + REPONSE_LIMITE, REPONSE_ORIGINE_REFUSEE, REPONSE_VALIDATION, REPONSES_AUTHENTIFIEES, @@ -21,15 +22,18 @@ from app.api.openapi import ( from app.core.cookies import RefreshCookie, cookie_name from app.core.logging import get_logger from app.schemas.auth import ( + ForgotPasswordRequest, LoginRequest, PasswordChangeRequest, PrincipalResponse, + ResetPasswordRequest, TokenResponse, ) from app.schemas.errors import ErrorResponse from app.services.auth import ( AuthenticatedSession, InvalidCredentialsError, + InvalidOrExpiredResetTokenError, RateLimitedError, SessionRejectedError, ) @@ -39,6 +43,7 @@ logger = get_logger(__name__) DETAIL_IDENTIFIANTS = "Identifiants invalides" DETAIL_SESSION = "Session invalide" +DETAIL_LIEN_RESET = "Lien invalide ou expiré" REPONSES_LOGIN: Reponses = { **REPONSE_VALIDATION, @@ -85,6 +90,20 @@ REPONSES_MOT_DE_PASSE: Reponses = { }, } +REPONSES_FORGOT_PASSWORD: Reponses = { + **REPONSE_VALIDATION, + **REPONSE_LIMITE, +} + +REPONSES_RESET_PASSWORD: Reponses = { + **REPONSE_VALIDATION, + **REPONSE_ORIGINE_REFUSEE, + 400: { + "model": ErrorResponse, + "description": "Lien invalide, déjà utilisé, ou expiré (durée de vie : 15 minutes).", + }, +} + def repond( response: Response, settings: SettingsDep, session: AuthenticatedSession @@ -267,3 +286,67 @@ async def change_password( logger.info("auth.password_changed user_id=%s", principal.id) return repond(response, settings, session) + + +@router.post( + "/forgot-password", + status_code=status.HTTP_202_ACCEPTED, + summary="Demande un lien de réinitialisation par email", + responses=REPONSES_FORGOT_PASSWORD, +) +async def forgot_password( + payload: ForgotPasswordRequest, + request: Request, + response: Response, + service: AuthServiceDep, + client_ip: str | None = Depends(get_client_ip), +) -> None: + response.headers["Cache-Control"] = "no-store" + + try: + await service.request_password_reset( + email=payload.email, + client_ip=client_ip, + user_agent=request.headers.get("user-agent"), + ) + except RateLimitedError as erreur: + logger.warning("auth.password_reset.rate_limited ip=%s", client_ip) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Trop de demandes, réessayez plus tard", + headers={"Retry-After": str(erreur.retry_after)}, + ) from erreur + + +@router.post( + "/reset-password", + response_model=TokenResponse, + summary="Choisit un nouveau mot de passe depuis un lien reçu par email", + dependencies=[Depends(require_trusted_origin)], + responses=REPONSES_RESET_PASSWORD, +) +async def reset_password( + payload: ResetPasswordRequest, + request: Request, + response: Response, + settings: SettingsDep, + service: AuthServiceDep, + client_ip: str | None = Depends(get_client_ip), +) -> TokenResponse: + response.headers["Cache-Control"] = "no-store" + + try: + session = await service.confirm_password_reset( + token=payload.token, + new_password=payload.new_password, + client_ip=client_ip, + user_agent=request.headers.get("user-agent"), + ) + except InvalidOrExpiredResetTokenError as erreur: + logger.warning("auth.password_reset.invalid_token ip=%s", client_ip) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=DETAIL_LIEN_RESET + ) from erreur + + logger.info("auth.password_reset.success user_id=%s", session.principal.id) + return repond(response, settings, session) diff --git a/apps/backend/app/cli.py b/apps/backend/app/cli.py index 37e94fd..fea510e 100644 --- a/apps/backend/app/cli.py +++ b/apps/backend/app/cli.py @@ -9,6 +9,7 @@ import argparse import asyncio import json import secrets +import string import sys from getpass import getpass from pathlib import Path @@ -22,9 +23,10 @@ from app.core.roles import Role from app.db.session import get_session_factory from app.main import create_app from app.repositories.user import UserRepository +from app.schemas.auth import PASSWORD_MIN_LENGTH, valide_complexite LONGUEUR_MOT_DE_PASSE_GENERE = 24 -LONGUEUR_MINIMALE = 12 +CARACTERES_SPECIAUX = "!@#$%^&*()-_=+[]{};:,.?" CHEMIN_CONTRAT = Path(__file__).resolve().parent.parent / "openapi.json" @@ -111,15 +113,36 @@ def build_parser() -> argparse.ArgumentParser: return parser +def genere_mot_de_passe() -> str: + tirage = secrets.SystemRandom() + classes = [ + string.ascii_uppercase, + string.ascii_lowercase, + string.digits, + CARACTERES_SPECIAUX, + ] + reste = LONGUEUR_MOT_DE_PASSE_GENERE - len(classes) + caracteres = [tirage.choice(classe) for classe in classes] + caracteres += [tirage.choice("".join(classes)) for _ in range(reste)] + tirage.shuffle(caracteres) + return "".join(caracteres) + + def read_password(*, generate: bool) -> str: if generate: - mot_de_passe = secrets.token_urlsafe(LONGUEUR_MOT_DE_PASSE_GENERE) + mot_de_passe = genere_mot_de_passe() print(f"Mot de passe généré, il ne sera plus affiché : {mot_de_passe}") return mot_de_passe mot_de_passe = getpass("Mot de passe : ") - if len(mot_de_passe) < LONGUEUR_MINIMALE: - raise SystemExit(f"Le mot de passe doit faire au moins {LONGUEUR_MINIMALE} caractères") + if len(mot_de_passe) < PASSWORD_MIN_LENGTH: + raise SystemExit( + f"Le mot de passe doit faire au moins {PASSWORD_MIN_LENGTH} caractères" + ) + try: + valide_complexite(mot_de_passe) + except ValueError as erreur: + raise SystemExit(str(erreur)) from erreur if mot_de_passe != getpass("Confirmation : "): raise SystemExit("Les deux saisies diffèrent") return mot_de_passe diff --git a/apps/backend/app/core/config.py b/apps/backend/app/core/config.py index 6733b3a..e374709 100644 --- a/apps/backend/app/core/config.py +++ b/apps/backend/app/core/config.py @@ -54,6 +54,19 @@ class Settings(BaseSettings): login_max_failures_per_ip: int = Field(default=20, ge=1) login_max_failures_per_identifier: int = Field(default=50, ge=1) + password_reset_ttl_seconds: int = Field(default=900, ge=60, le=3600) + password_reset_window_seconds: int = Field(default=900, ge=60) + password_reset_max_requests_per_identifier: int = Field(default=3, ge=1) + password_reset_max_requests_per_ip: int = Field(default=10, ge=1) + + smtp_host: str = "localhost" + smtp_port: int = Field(default=587, ge=1, le=65535) + smtp_username: str | None = None + smtp_password: SecretStr | None = None + smtp_use_tls: bool = False + smtp_from_address: str = "no-reply@enervision.fr" + frontend_reset_password_url: str = "http://localhost:4200/reset-password" # noqa: S105 + trust_proxy_headers: bool = False expose_api_docs: bool | None = None metrics_token: SecretStr | None = None diff --git a/apps/backend/app/core/mailer.py b/apps/backend/app/core/mailer.py new file mode 100644 index 0000000..5c09008 --- /dev/null +++ b/apps/backend/app/core/mailer.py @@ -0,0 +1,48 @@ +# Piège : l'URL de réinitialisation porte le jeton en clair. Ne jamais la journaliser : +# `send_password_reset_email()` ne logue que le destinataire, jamais `reset_url`. + +from dataclasses import dataclass +from email.message import EmailMessage + +import aiosmtplib + +from app.core.logging import get_logger + +logger = get_logger(__name__) + + +@dataclass(frozen=True, slots=True) +class SmtpConfig: + host: str + port: int + username: str | None + password: str | None + use_tls: bool + from_address: str + + +class Mailer: + def __init__(self, config: SmtpConfig) -> None: + self._config = config + + async def send_password_reset_email(self, *, to: str, reset_url: str) -> None: + message = EmailMessage() + message["From"] = self._config.from_address + message["To"] = to + message["Subject"] = "Réinitialisation de votre mot de passe EnerVision" + message.set_content( + "Une réinitialisation de mot de passe a été demandée pour ce compte.\n\n" + f"Ouvrez ce lien dans les 15 minutes pour choisir un nouveau mot de passe : " + f"{reset_url}\n\n" + "Si vous n'êtes pas à l'origine de cette demande, ignorez cet email." + ) + + _, message_recu = await aiosmtplib.send( + message, + hostname=self._config.host, + port=self._config.port, + username=self._config.username, + password=self._config.password, + use_tls=self._config.use_tls, + ) + logger.info("mailer.password_reset_sent to=%s smtp_response=%s", to, message_recu) diff --git a/apps/backend/app/models/__init__.py b/apps/backend/app/models/__init__.py index 10a5ecb..167d7ce 100644 --- a/apps/backend/app/models/__init__.py +++ b/apps/backend/app/models/__init__.py @@ -4,6 +4,8 @@ from app.models.audit_log import AuditLog from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site from app.models.login_attempt import LoginAttempt +from app.models.password_reset_attempt import PasswordResetAttempt +from app.models.password_reset_token import PasswordResetToken from app.models.refresh_token import RefreshToken from app.models.user import AppUser @@ -13,6 +15,8 @@ __all__ = [ "AuditLog", "Dataset", "LoginAttempt", + "PasswordResetAttempt", + "PasswordResetToken", "Prediction", "Reading", "Recommendation", diff --git a/apps/backend/app/models/audit_log.py b/apps/backend/app/models/audit_log.py index 5775f5e..d389880 100644 --- a/apps/backend/app/models/audit_log.py +++ b/apps/backend/app/models/audit_log.py @@ -29,6 +29,8 @@ class AuditAction(StrEnum): COMPTE_ACTIVE = "user.enabled" COMPTE_MOT_DE_PASSE_REINITIALISE = "user.password_reset_by_admin" COMPTE_MOT_DE_PASSE_CHANGE = "user.password_changed" + MOT_DE_PASSE_OUBLIE_DEMANDE = "auth.password_reset_requested" + MOT_DE_PASSE_REINITIALISE_PAR_SOI = "auth.password_reset_self_service" REFRESH_REUTILISE = "auth.refresh_reuse_detected" SESSIONS_REVOQUEES = "auth.all_sessions_revoked" LIMITE_PAR_IDENTIFIANT = "auth.identifier_throttled" diff --git a/apps/backend/app/models/password_reset_attempt.py b/apps/backend/app/models/password_reset_attempt.py new file mode 100644 index 0000000..6d2a607 --- /dev/null +++ b/apps/backend/app/models/password_reset_attempt.py @@ -0,0 +1,27 @@ +# Pourquoi : même séparation que `login_attempt` par rapport à `audit_log` : ce compteur est +# piloté par l'attaquant (une campagne de demandes) et se purge, l'audit log est en ajout seul. +# Piège : la tentative est enregistrée même quand l'email est inconnu, sinon le 429 apprendrait +# qu'un compte existe. + +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, Identity, Index, String, func +from sqlalchemy.dialects.postgresql import INET +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class PasswordResetAttempt(Base): + __tablename__ = "password_reset_attempt" + __table_args__ = ( + Index("ix_password_reset_attempt_email_date", "email_tried", "occurred_at"), + Index("ix_password_reset_attempt_ip_date", "client_ip", "occurred_at"), + ) + + id: Mapped[int] = mapped_column(BigInteger, Identity(always=True), primary_key=True) + occurred_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + email_tried: Mapped[str] = mapped_column(String(320), nullable=False) + client_ip: Mapped[str | None] = mapped_column(INET, nullable=True) diff --git a/apps/backend/app/models/password_reset_token.py b/apps/backend/app/models/password_reset_token.py new file mode 100644 index 0000000..d67d310 --- /dev/null +++ b/apps/backend/app/models/password_reset_token.py @@ -0,0 +1,40 @@ +# Pourquoi : même schéma que `refresh_token` (chaîne opaque, jamais un JWT) pour la même +# raison : un jeton de réinitialisation doit être révocable d'un coup, et un JWT ne figure +# dans aucune ligne à invalider. + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Index, LargeBinary, Text, func +from sqlalchemy.dialects.postgresql import INET +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class PasswordResetToken(Base): + __tablename__ = "password_reset_token" + __table_args__ = ( + Index("ix_password_reset_token_user", "user_id"), + Index( + "ix_password_reset_token_vivants", + "user_id", + postgresql_where="consumed_at is null", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid() + ) + user_id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False + ) + token_hash: Mapped[bytes] = mapped_column(LargeBinary, nullable=False, unique=True) + issued_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + client_ip: Mapped[str | None] = mapped_column(INET, nullable=True) + user_agent: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/apps/backend/app/repositories/password_reset_attempt.py b/apps/backend/app/repositories/password_reset_attempt.py new file mode 100644 index 0000000..ddc2f91 --- /dev/null +++ b/apps/backend/app/repositories/password_reset_attempt.py @@ -0,0 +1,42 @@ +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.password_reset_attempt import PasswordResetAttempt + + +@dataclass(frozen=True, slots=True) +class ResetRequestCounts: + per_identifier: int + per_ip: int + + +class PasswordResetAttemptRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def record(self, *, email: str, client_ip: str | None) -> None: + self._session.add( + PasswordResetAttempt(email_tried=email.strip().lower(), client_ip=client_ip) + ) + + async def count_recent( + self, *, email: str, client_ip: str | None, window_seconds: int + ) -> ResetRequestCounts: + identifiant = email.strip().lower() + meme_email = PasswordResetAttempt.email_tried == identifiant + meme_ip = PasswordResetAttempt.client_ip == client_ip + + requete = select( + func.count().filter(meme_email), + func.count().filter(meme_ip), + ).where( + PasswordResetAttempt.occurred_at + > datetime.now(UTC) - timedelta(seconds=window_seconds), + meme_email | meme_ip, + ) + + par_identifiant, par_ip = (await self._session.execute(requete)).one() + return ResetRequestCounts(per_identifier=par_identifiant, per_ip=par_ip) diff --git a/apps/backend/app/repositories/password_reset_token.py b/apps/backend/app/repositories/password_reset_token.py new file mode 100644 index 0000000..13a660e --- /dev/null +++ b/apps/backend/app/repositories/password_reset_token.py @@ -0,0 +1,68 @@ +# Piège : `consume()` est une seule instruction, sur le modèle de `claim_for_rotation()` du +# jeton de rafraîchissement. Un SELECT puis un UPDATE laisseraient une fenêtre où deux +# soumissions concurrentes du même lien réussiraient toutes les deux. + +from dataclasses import dataclass +from datetime import datetime +from uuid import UUID + +from sqlalchemy import func, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.password_reset_token import PasswordResetToken + + +@dataclass(frozen=True, slots=True) +class ConsumedResetToken: + id: UUID + user_id: UUID + + +class PasswordResetTokenRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def create( + self, + *, + user_id: UUID, + token_hash: bytes, + expires_at: datetime, + client_ip: str | None, + user_agent: str | None, + ) -> PasswordResetToken: + jeton = PasswordResetToken( + user_id=user_id, + token_hash=token_hash, + expires_at=expires_at, + client_ip=client_ip, + user_agent=user_agent, + ) + self._session.add(jeton) + await self._session.flush() + return jeton + + async def consume(self, token_hash: bytes) -> ConsumedResetToken | None: + requete = ( + update(PasswordResetToken) + .where( + PasswordResetToken.token_hash == token_hash, + PasswordResetToken.consumed_at.is_(None), + PasswordResetToken.expires_at > func.clock_timestamp(), + ) + .values(consumed_at=func.clock_timestamp()) + .returning(PasswordResetToken.id, PasswordResetToken.user_id) + ) + ligne = (await self._session.execute(requete)).one_or_none() + if ligne is None: + return None + return ConsumedResetToken(id=ligne.id, user_id=ligne.user_id) + + async def invalidate_all_for_user(self, user_id: UUID) -> int: + resultat = await self._session.execute( + update(PasswordResetToken) + .where(PasswordResetToken.user_id == user_id, PasswordResetToken.consumed_at.is_(None)) + .values(consumed_at=func.clock_timestamp()) + .returning(PasswordResetToken.id) + ) + return len(resultat.all()) diff --git a/apps/backend/app/schemas/auth.py b/apps/backend/app/schemas/auth.py index 522b4c5..e6785be 100644 --- a/apps/backend/app/schemas/auth.py +++ b/apps/backend/app/schemas/auth.py @@ -1,17 +1,39 @@ # Contrainte : le mot de passe est borné à 128 caractères. Sans plafond, une chaîne de dix # mégaoctets ferait travailler Argon2 gratuitement, à la charge du serveur. +import re from typing import Literal, Self from uuid import UUID -from pydantic import BaseModel, ConfigDict, EmailStr, Field +from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator from app.core.principal import Principal from app.core.roles import AccountKind, Role -PASSWORD_MIN_LENGTH = 12 +PASSWORD_MIN_LENGTH = 8 PASSWORD_MAX_LENGTH = 128 +_MAJUSCULE = re.compile(r"[A-ZÀ-Ý]") +_MINUSCULE = re.compile(r"[a-zà-ÿ]") +_CHIFFRE = re.compile(r"\d") +_SPECIAL = re.compile(r"[^\w\s]") + + +def valide_complexite(mot_de_passe: str) -> str: + manquants = [ + nom + for nom, motif in ( + ("une majuscule", _MAJUSCULE), + ("une minuscule", _MINUSCULE), + ("un chiffre", _CHIFFRE), + ("un caractère spécial", _SPECIAL), + ) + if not motif.search(mot_de_passe) + ] + if manquants: + raise ValueError(f"Le mot de passe doit contenir au moins {', '.join(manquants)}") + return mot_de_passe + class LoginRequest(BaseModel): email: EmailStr @@ -22,6 +44,25 @@ class PasswordChangeRequest(BaseModel): current_password: str = Field(min_length=1, max_length=PASSWORD_MAX_LENGTH) new_password: str = Field(min_length=PASSWORD_MIN_LENGTH, max_length=PASSWORD_MAX_LENGTH) + @field_validator("new_password") + @classmethod + def _new_password_est_complexe(cls, valeur: str) -> str: + return valide_complexite(valeur) + + +class ForgotPasswordRequest(BaseModel): + email: EmailStr + + +class ResetPasswordRequest(BaseModel): + token: str = Field(min_length=1) + new_password: str = Field(min_length=PASSWORD_MIN_LENGTH, max_length=PASSWORD_MAX_LENGTH) + + @field_validator("new_password") + @classmethod + def _new_password_est_complexe(cls, valeur: str) -> str: + return valide_complexite(valeur) + class PrincipalResponse(BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/apps/backend/app/services/auth.py b/apps/backend/app/services/auth.py index 8baf857..02ff04b 100644 --- a/apps/backend/app/services/auth.py +++ b/apps/backend/app/services/auth.py @@ -15,6 +15,7 @@ from typing import NoReturn, Protocol from uuid import UUID, uuid4 from app.core.hashing import Argon2Hasher +from app.core.mailer import Mailer from app.core.principal import Principal from app.core.roles import AccountKind, Role from app.core.security import ( @@ -28,6 +29,8 @@ from app.models.login_attempt import LoginOutcome from app.models.refresh_token import RevocationReason from app.repositories.audit_log import AuditLogRepository from app.repositories.login_attempt import LoginAttemptRepository +from app.repositories.password_reset_attempt import PasswordResetAttemptRepository +from app.repositories.password_reset_token import PasswordResetTokenRepository from app.repositories.refresh_token import RefreshTokenRepository from app.repositories.user import UserRepository @@ -54,6 +57,10 @@ class RateLimitedError(AuthError): self.retry_after = retry_after +class InvalidOrExpiredResetTokenError(AuthError): + pass + + @dataclass(frozen=True, slots=True) class LoginPolicy: window_seconds: int @@ -62,6 +69,15 @@ class LoginPolicy: max_failures_per_identifier: int +@dataclass(frozen=True, slots=True) +class PasswordResetPolicy: + window_seconds: int + max_requests_per_identifier: int + max_requests_per_ip: int + token_ttl: timedelta + frontend_reset_url: str + + @dataclass(frozen=True, slots=True) class AuthenticatedSession: principal: Principal @@ -83,6 +99,10 @@ class AuthService: token_policy: TokenPolicy, login_policy: LoginPolicy, refresh_ttl: timedelta, + reset_tokens: PasswordResetTokenRepository, + reset_attempts: PasswordResetAttemptRepository, + reset_policy: PasswordResetPolicy, + mailer: Mailer, ) -> None: self._users = users self._attempts = attempts @@ -93,6 +113,10 @@ class AuthService: self._token_policy = token_policy self._login_policy = login_policy self._refresh_ttl = refresh_ttl + self._reset_tokens = reset_tokens + self._reset_attempts = reset_attempts + self._reset_policy = reset_policy + self._mailer = mailer async def authenticate( self, *, email: str, password: str, client_ip: str | None, user_agent: str | None @@ -200,6 +224,75 @@ class AuthService: rafraichi = await self._users.get_by_id(principal.id) return self._session(self._en_principal(rafraichi or compte), secret) + async def request_password_reset( + self, *, email: str, client_ip: str | None, user_agent: str | None + ) -> None: + await self._refuse_si_limite_reset(email=email, client_ip=client_ip) + + compte = await self._users.get_by_email(email) + # Piège : le hachage factice équilibre le temps de réponse sur un compte inconnu, comme + # `authenticate()`. La réponse et sa forme restent identiques dans tous les cas : compte + # inconnu, compte inactif, ou email envoyé avec succès. + if compte is None or not compte.is_active or compte.kind != AccountKind.HUMAIN.value: + await self._hasher.verify_dummy() + await self._reset_attempts.record(email=email, client_ip=client_ip) + await self._transaction.commit() + return + + await self._reset_tokens.invalidate_all_for_user(compte.id) + secret = generate_refresh_secret() + await self._reset_tokens.create( + user_id=compte.id, + token_hash=fingerprint_refresh(secret), + expires_at=datetime.now(UTC) + self._reset_policy.token_ttl, + client_ip=client_ip, + user_agent=user_agent, + ) + await self._reset_attempts.record(email=email, client_ip=client_ip) + await self._audit.record( + action=AuditAction.MOT_DE_PASSE_OUBLIE_DEMANDE, + actor_label=compte.email, + target_type="app_user", + target_id=str(compte.id), + client_ip=client_ip, + user_agent=user_agent, + ) + await self._transaction.commit() + + lien = f"{self._reset_policy.frontend_reset_url}?token={secret}" + await self._mailer.send_password_reset_email(to=compte.email, reset_url=lien) + + async def confirm_password_reset( + self, *, token: str, new_password: str, client_ip: str | None, user_agent: str | None + ) -> AuthenticatedSession: + revendique = await self._reset_tokens.consume(fingerprint_refresh(token)) + if revendique is None: + raise InvalidOrExpiredResetTokenError("Lien invalide ou expiré") + + await self._users.update_password( + revendique.user_id, await self._hasher.hash(new_password), must_change_password=False + ) + revoquees = await self._refresh.revoke_all_for_user( + revendique.user_id, RevocationReason.CHANGEMENT_MOT_DE_PASSE + ) + secret = await self._ouvre_une_famille( + user_id=revendique.user_id, client_ip=client_ip, user_agent=user_agent + ) + await self._audit.record( + action=AuditAction.MOT_DE_PASSE_REINITIALISE_PAR_SOI, + target_type="app_user", + target_id=str(revendique.user_id), + client_ip=client_ip, + user_agent=user_agent, + detail={"sessions_revoquees": revoquees}, + ) + await self._transaction.commit() + + compte = await self._users.get_by_id(revendique.user_id) + if compte is None: + raise SessionRejectedError("Compte introuvable") + return self._session(self._en_principal(compte), secret) + async def logout_all(self, principal: Principal) -> int: revoquees = await self._refresh.revoke_all_for_user( principal.id, RevocationReason.DECONNEXION @@ -307,6 +400,23 @@ class AuthService: await self._transaction.commit() raise RateLimitedError(politique.window_seconds) + async def _refuse_si_limite_reset(self, *, email: str, client_ip: str | None) -> None: + politique = self._reset_policy + compteurs = await self._reset_attempts.count_recent( + email=email, client_ip=client_ip, window_seconds=politique.window_seconds + ) + + depasse = ( + compteurs.per_identifier >= politique.max_requests_per_identifier + or compteurs.per_ip >= politique.max_requests_per_ip + ) + if not depasse: + return + + await self._reset_attempts.record(email=email, client_ip=client_ip) + await self._transaction.commit() + raise RateLimitedError(politique.window_seconds) + async def _echoue( self, email: str, diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index 84f9c08..f142efd 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -424,6 +424,144 @@ ] } }, + "/api/v1/auth/forgot-password": { + "post": { + "tags": [ + "auth" + ], + "summary": "Demande un lien de réinitialisation par email", + "operationId": "forgot_password_api_v1_auth_forgot_password_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForgotPasswordRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "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" + } + } + } + }, + "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" + } + } + } + }, + "429": { + "description": "Trop de demandes sur cette fenêtre glissante.", + "headers": { + "Retry-After": { + "description": "Secondes à attendre avant une nouvelle tentative.", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/auth/reset-password": { + "post": { + "tags": [ + "auth" + ], + "summary": "Choisit un nouveau mot de passe depuis un lien reçu par email", + "operationId": "reset_password_api_v1_auth_reset_password_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResponse" + } + } + } + }, + "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" + } + } + } + }, + "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" + } + } + } + }, + "403": { + "description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "400": { + "description": "Lien invalide, déjà utilisé, ou expiré (durée de vie : 15 minutes).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/v1/users": { "get": { "tags": [ @@ -1432,6 +1570,20 @@ ], "title": "FieldError" }, + "ForgotPasswordRequest": { + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "Email" + } + }, + "type": "object", + "required": [ + "email" + ], + "title": "ForgotPasswordRequest" + }, "InternalErrorResponse": { "properties": { "detail": { @@ -1511,7 +1663,7 @@ "new_password": { "type": "string", "maxLength": 128, - "minLength": 12, + "minLength": 8, "title": "New Password" } }, @@ -1619,6 +1771,27 @@ ], "title": "RecommendationResponse" }, + "ResetPasswordRequest": { + "properties": { + "token": { + "type": "string", + "minLength": 1, + "title": "Token" + }, + "new_password": { + "type": "string", + "maxLength": 128, + "minLength": 8, + "title": "New Password" + } + }, + "type": "object", + "required": [ + "token", + "new_password" + ], + "title": "ResetPasswordRequest" + }, "Role": { "type": "string", "enum": [ diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index 18bf979..2bfdef3 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "pyjwt>=2.10", "argon2-cffi>=23.1", "anyio>=4.0", + "aiosmtplib>=5.1.3", ] [dependency-groups] diff --git a/apps/backend/tests/api/test_auth.py b/apps/backend/tests/api/test_auth.py index 1d734da..44c25e6 100644 --- a/apps/backend/tests/api/test_auth.py +++ b/apps/backend/tests/api/test_auth.py @@ -11,6 +11,7 @@ from app.core.roles import AccountKind, Role from app.services.auth import ( AuthenticatedSession, InvalidCredentialsError, + InvalidOrExpiredResetTokenError, RateLimitedError, SessionRejectedError, ) @@ -36,6 +37,14 @@ class FauxService: async def logout(self, **_: object) -> None: return None + async def request_password_reset(self, **_: object) -> None: + if self._erreur is not None: + raise self._erreur + return None + + async def confirm_password_reset(self, **_: object) -> AuthenticatedSession: + return await self.authenticate() + async def authenticate(self, **_: object) -> AuthenticatedSession: if self._erreur is not None: raise self._erreur @@ -206,3 +215,94 @@ async def test_a_cookie_bearing_route_accepts_a_request_without_origin( response = await client.post("/api/v1/auth/logout") assert response.status_code != 403 + + +async def test_forgot_password_answers_202_when_the_account_exists( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post( + "/api/v1/auth/forgot-password", json={"email": "operateur@enervision.fr"} + ) + + assert response.status_code == 202 + assert response.headers["cache-control"] == "no-store" + + +async def test_forgot_password_answers_202_identically_when_the_account_is_unknown( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post( + "/api/v1/auth/forgot-password", json={"email": "inconnu@enervision.fr"} + ) + + assert response.status_code == 202 + + +async def test_forgot_password_returns_429_with_a_retry_after_when_the_rate_limit_is_reached( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + fake_auth_service[0] = RateLimitedError(900) + + response = await client.post( + "/api/v1/auth/forgot-password", json={"email": "operateur@enervision.fr"} + ) + + assert response.status_code == 429 + assert response.headers["retry-after"] == "900" + + +async def test_forgot_password_rejects_a_malformed_email( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post("/api/v1/auth/forgot-password", json={"email": "pas-un-email"}) + + assert response.status_code == 422 + + +async def test_reset_password_returns_the_token_and_the_cookie_on_success( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post( + "/api/v1/auth/reset-password", + json={"token": "un-secret-opaque", "new_password": "Un-nouveau-mot-de-passe1!"}, + ) + + assert response.status_code == 200 + assert response.cookies.get("ev_refresh") is not None + assert "refresh_secret" not in response.text + + +async def test_reset_password_rejects_an_invalid_or_expired_token( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + fake_auth_service[0] = InvalidOrExpiredResetTokenError("Lien invalide ou expiré") + + response = await client.post( + "/api/v1/auth/reset-password", + json={"token": "un-secret-perime", "new_password": "Un-nouveau-mot-de-passe1!"}, + ) + + assert response.status_code == 400 + + +async def test_reset_password_rejects_a_weak_password( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post( + "/api/v1/auth/reset-password", + json={"token": "un-secret-opaque", "new_password": "trop-simple"}, + ) + + assert response.status_code == 422 + + +async def test_reset_password_refuses_a_foreign_origin( + fake_auth_service: list[Exception | None], client: AsyncClient +) -> None: + response = await client.post( + "/api/v1/auth/reset-password", + json={"token": "un-secret-opaque", "new_password": "Un-nouveau-mot-de-passe1!"}, + headers={"Origin": "https://malveillant.example"}, + ) + + assert response.status_code == 403 diff --git a/apps/backend/tests/api/test_route_protection.py b/apps/backend/tests/api/test_route_protection.py index 9a04338..1080dce 100644 --- a/apps/backend/tests/api/test_route_protection.py +++ b/apps/backend/tests/api/test_route_protection.py @@ -18,6 +18,10 @@ ROUTES_PUBLIQUES = frozenset( ("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"), ("GET", "/metrics"), } ) diff --git a/apps/backend/tests/repositories/test_password_reset_token.py b/apps/backend/tests/repositories/test_password_reset_token.py new file mode 100644 index 0000000..e25518c --- /dev/null +++ b/apps/backend/tests/repositories/test_password_reset_token.py @@ -0,0 +1,114 @@ +# Le premier test démontre l'atomicité de `consume()` : sur un double, deux soumissions +# concurrentes du même lien réussiraient toutes les deux. + +import uuid +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.roles import Role +from app.core.security import fingerprint_refresh, generate_refresh_secret +from app.repositories.password_reset_token import PasswordResetTokenRepository +from app.repositories.user import UserRepository + +pytestmark = pytest.mark.integration + +DUREE = timedelta(minutes=15) + + +async def un_compte(session: AsyncSession) -> uuid.UUID: + compte = await UserRepository(session).create( + email=f"reset-{uuid.uuid4().hex[:12]}@enervision.fr", + password_hash="$argon2id$x", + role=Role.LECTEUR, + ) + return compte.id + + +async def un_jeton( + depot: PasswordResetTokenRepository, user_id: uuid.UUID, *, duree: timedelta = DUREE +) -> str: + secret = generate_refresh_secret() + await depot.create( + user_id=user_id, + token_hash=fingerprint_refresh(secret), + expires_at=datetime.now(UTC) + duree, + client_ip="203.0.113.10", + user_agent="pytest", + ) + return secret + + +async def test_consume_only_succeeds_once(session: AsyncSession) -> None: + depot = PasswordResetTokenRepository(session) + secret = await un_jeton(depot, await un_compte(session)) + + premier = await depot.consume(fingerprint_refresh(secret)) + second = await depot.consume(fingerprint_refresh(secret)) + await session.rollback() + + assert premier is not None + assert second is None + + +async def test_consume_refuses_an_expired_token(session: AsyncSession) -> None: + depot = PasswordResetTokenRepository(session) + secret = await un_jeton(depot, await un_compte(session), duree=-timedelta(minutes=1)) + + revendique = await depot.consume(fingerprint_refresh(secret)) + await session.rollback() + + assert revendique is None + + +async def test_consume_returns_nothing_for_an_unknown_fingerprint( + session: AsyncSession, +) -> None: + revendique = await PasswordResetTokenRepository(session).consume( + fingerprint_refresh(generate_refresh_secret()) + ) + + assert revendique is None + + +async def test_invalidate_all_for_user_only_touches_living_tokens( + session: AsyncSession, +) -> None: + depot = PasswordResetTokenRepository(session) + compte = await un_compte(session) + await un_jeton(depot, compte) + await un_jeton(depot, compte) + + invalides = await depot.invalidate_all_for_user(compte) + second_passage = await depot.invalidate_all_for_user(compte) + await session.rollback() + + assert invalides == 2 + assert second_passage == 0 + + +async def test_the_database_refuses_two_tokens_sharing_a_fingerprint( + session: AsyncSession, +) -> None: + depot = PasswordResetTokenRepository(session) + compte = await un_compte(session) + secret = generate_refresh_secret() + await depot.create( + user_id=compte, + token_hash=fingerprint_refresh(secret), + expires_at=datetime.now(UTC) + DUREE, + client_ip=None, + user_agent=None, + ) + + with pytest.raises(IntegrityError): + await depot.create( + user_id=compte, + token_hash=fingerprint_refresh(secret), + expires_at=datetime.now(UTC) + DUREE, + client_ip=None, + user_agent=None, + ) + await session.rollback() diff --git a/apps/backend/tests/schemas/__init__.py b/apps/backend/tests/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/tests/schemas/test_auth.py b/apps/backend/tests/schemas/test_auth.py new file mode 100644 index 0000000..7982f56 --- /dev/null +++ b/apps/backend/tests/schemas/test_auth.py @@ -0,0 +1,41 @@ +import pytest +from pydantic import ValidationError + +from app.schemas.auth import PasswordChangeRequest, valide_complexite + +MOT_DE_PASSE_VALIDE = "Un-mot-de-passe1!" + + +def test_password_change_request_accepts_a_password_covering_the_four_classes() -> None: + requete = PasswordChangeRequest( + current_password="peu-importe", new_password=MOT_DE_PASSE_VALIDE + ) + + assert requete.new_password == MOT_DE_PASSE_VALIDE + + +@pytest.mark.parametrize( + "new_password", + [ + "un-mot-de-passe1!", + "UN-MOT-DE-PASSE1!", + "Un-mot-de-passe!", + "Un mot de passe 1", + ], + ids=["sans_majuscule", "sans_minuscule", "sans_chiffre", "sans_caractere_special"], +) +def test_password_change_request_rejects_a_password_missing_a_character_class( + new_password: str, +) -> None: + with pytest.raises(ValidationError): + PasswordChangeRequest(current_password="peu-importe", new_password=new_password) + + +def test_password_change_request_rejects_a_password_below_the_minimum_length() -> None: + with pytest.raises(ValidationError): + PasswordChangeRequest(current_password="peu-importe", new_password="Ab1!") + + +def test_valide_complexite_names_every_missing_class_in_the_error() -> None: + with pytest.raises(ValueError, match=r"majuscule.*chiffre|chiffre.*majuscule"): + valide_complexite("minuscules-seulement") diff --git a/apps/backend/tests/services/test_auth.py b/apps/backend/tests/services/test_auth.py index 9b8c42c..52cde71 100644 --- a/apps/backend/tests/services/test_auth.py +++ b/apps/backend/tests/services/test_auth.py @@ -16,11 +16,15 @@ from app.core.security import ( from app.models.login_attempt import LoginOutcome from app.models.refresh_token import RevocationReason from app.repositories.login_attempt import FailureCounts +from app.repositories.password_reset_attempt import ResetRequestCounts +from app.repositories.password_reset_token import ConsumedResetToken from app.repositories.refresh_token import ClaimedToken from app.services.auth import ( AuthService, InvalidCredentialsError, + InvalidOrExpiredResetTokenError, LoginPolicy, + PasswordResetPolicy, RateLimitedError, SessionRejectedError, ) @@ -37,6 +41,13 @@ POLITIQUE_CONNEXION = LoginPolicy( max_failures_per_ip=20, max_failures_per_identifier=50, ) +POLITIQUE_RESET = PasswordResetPolicy( + window_seconds=900, + max_requests_per_identifier=3, + max_requests_per_ip=10, + token_ttl=timedelta(minutes=15), + frontend_reset_url="http://localhost:4200/reset-password", +) @dataclass @@ -168,6 +179,43 @@ class FausseTransaction: self.validations += 1 +class FauxDepotJetonsReset: + def __init__(self, revendique: ConsumedResetToken | None = None) -> None: + self.revendique = revendique + self.crees: list[UUID] = [] + self.invalidations: list[UUID] = [] + + async def create(self, *, user_id: UUID, **_: object) -> None: + self.crees.append(user_id) + + async def consume(self, token_hash: bytes) -> ConsumedResetToken | None: + return self.revendique + + async def invalidate_all_for_user(self, user_id: UUID) -> int: + self.invalidations.append(user_id) + return len(self.invalidations) + + +class FauxDepotTentativesReset: + def __init__(self, compteurs: ResetRequestCounts | None = None) -> None: + self.compteurs = compteurs or ResetRequestCounts(0, 0) + self.enregistrees: list[str] = [] + + async def count_recent(self, **_: object) -> ResetRequestCounts: + return self.compteurs + + async def record(self, *, email: str, **_: object) -> None: + self.enregistrees.append(email) + + +class FauxMailer: + def __init__(self) -> None: + self.envois: list[tuple[str, str]] = [] + + async def send_password_reset_email(self, *, to: str, reset_url: str) -> None: + self.envois.append((to, reset_url)) + + @dataclass class Attirail: service: AuthService @@ -176,6 +224,9 @@ class Attirail: jetons: FauxDepotJetons audit: FauxDepotAudit hacheur: FauxHacheur + jetons_reset: FauxDepotJetonsReset + tentatives_reset: FauxDepotTentativesReset + mailer: FauxMailer def fabrique_service( @@ -184,12 +235,17 @@ def fabrique_service( compteurs: FailureCounts | None = None, hacheur: FauxHacheur | None = None, jetons: FauxDepotJetons | None = None, + jetons_reset: FauxDepotJetonsReset | None = None, + compteurs_reset: ResetRequestCounts | None = None, ) -> Attirail: comptes = FauxDepotComptes(compte) tentatives = FauxDepotTentatives(compteurs) depot_jetons = jetons or FauxDepotJetons() audit = FauxDepotAudit() hacheur = hacheur or FauxHacheur() + depot_jetons_reset = jetons_reset or FauxDepotJetonsReset() + tentatives_reset = FauxDepotTentativesReset(compteurs_reset) + mailer = FauxMailer() service = AuthService( users=comptes, # type: ignore[arg-type] attempts=tentatives, # type: ignore[arg-type] @@ -200,8 +256,22 @@ def fabrique_service( token_policy=POLITIQUE_JETON, login_policy=POLITIQUE_CONNEXION, refresh_ttl=timedelta(days=7), + reset_tokens=depot_jetons_reset, # type: ignore[arg-type] + reset_attempts=tentatives_reset, # type: ignore[arg-type] + reset_policy=POLITIQUE_RESET, + mailer=mailer, # type: ignore[arg-type] + ) + return Attirail( + service, + comptes, + tentatives, + depot_jetons, + audit, + hacheur, + depot_jetons_reset, + tentatives_reset, + mailer, ) - return Attirail(service, comptes, tentatives, depot_jetons, audit, hacheur) async def connecte(service: AuthService, mot_de_passe: str = "un-mot-de-passe-valide") -> object: @@ -493,3 +563,89 @@ async def test_change_password_refuses_a_wrong_current_password() -> None: assert attirail.jetons.revocations_par_compte == [] assert attirail.jetons.crees == [] + + +async def test_request_password_reset_emails_a_link_when_the_account_exists() -> None: + compte = FauxCompte() + attirail = fabrique_service(compte=compte) + + await attirail.service.request_password_reset( + email=compte.email, client_ip="203.0.113.10", user_agent="pytest" + ) + + assert attirail.jetons_reset.invalidations == [compte.id] + assert attirail.jetons_reset.crees == [compte.id] + assert len(attirail.mailer.envois) == 1 + assert attirail.mailer.envois[0][0] == compte.email + assert "auth.password_reset_requested" in attirail.audit.lignes[0][0] + + +async def test_request_password_reset_stays_silent_when_the_account_is_unknown() -> None: + attirail = fabrique_service(compte=None) + + await attirail.service.request_password_reset( + email="inconnu@enervision.fr", client_ip="203.0.113.10", user_agent="pytest" + ) + + assert attirail.jetons_reset.crees == [] + assert attirail.mailer.envois == [] + assert attirail.hacheur.verifications == 1, "le hachage factice doit tout de même tourner" + + +async def test_request_password_reset_stays_silent_when_the_account_is_inactive() -> None: + compte = FauxCompte(is_active=False) + attirail = fabrique_service(compte=compte) + + await attirail.service.request_password_reset( + email=compte.email, client_ip="203.0.113.10", user_agent="pytest" + ) + + assert attirail.jetons_reset.crees == [] + assert attirail.mailer.envois == [] + + +async def test_request_password_reset_raises_when_the_rate_limit_is_reached() -> None: + attirail = fabrique_service(compteurs_reset=ResetRequestCounts(per_identifier=3, per_ip=0)) + + with pytest.raises(RateLimitedError): + await attirail.service.request_password_reset( + email="operateur@enervision.fr", client_ip="203.0.113.10", user_agent="pytest" + ) + + assert attirail.mailer.envois == [] + + +async def test_confirm_password_reset_revokes_every_session_then_reopens_the_current_one() -> None: + compte = FauxCompte() + jetons_reset = FauxDepotJetonsReset( + revendique=ConsumedResetToken(id=uuid4(), user_id=compte.id) + ) + attirail = fabrique_service(compte=compte, jetons_reset=jetons_reset) + + session = await attirail.service.confirm_password_reset( + token="un-secret-opaque", + new_password="Un-nouveau-mot-de-passe1!", + client_ip="203.0.113.10", + user_agent="pytest", + ) + + assert attirail.jetons.revocations_par_compte == [ + (compte.id, RevocationReason.CHANGEMENT_MOT_DE_PASSE.value) + ] + assert len(attirail.jetons.crees) == 1 + assert session.refresh_secret + assert "auth.password_reset_self_service" in attirail.audit.lignes[0][0] + + +async def test_confirm_password_reset_rejects_an_invalid_or_expired_token() -> None: + attirail = fabrique_service(jetons_reset=FauxDepotJetonsReset(revendique=None)) + + with pytest.raises(InvalidOrExpiredResetTokenError): + await attirail.service.confirm_password_reset( + token="un-secret-invalide", + new_password="Un-nouveau-mot-de-passe1!", + client_ip=None, + user_agent=None, + ) + + assert attirail.jetons.revocations_par_compte == [] diff --git a/apps/backend/tests/test_cli.py b/apps/backend/tests/test_cli.py index 40b8317..7344bf7 100644 --- a/apps/backend/tests/test_cli.py +++ b/apps/backend/tests/test_cli.py @@ -4,6 +4,7 @@ from pathlib import Path import pytest from app import cli +from app.schemas.auth import valide_complexite def test_build_parser_reads_the_create_admin_arguments() -> None: @@ -34,26 +35,36 @@ def test_read_password_generates_a_long_secret_when_asked( assert len(mot_de_passe) >= cli.LONGUEUR_MOT_DE_PASSE_GENERE assert mot_de_passe in capsys.readouterr().out + valide_complexite(mot_de_passe) def test_read_password_accepts_two_matching_entries(monkeypatch: pytest.MonkeyPatch) -> None: - saisies = iter(["un-mot-de-passe-valide", "un-mot-de-passe-valide"]) + saisies = iter(["Un-mot-de-passe-valide1", "Un-mot-de-passe-valide1"]) monkeypatch.setattr(cli, "getpass", lambda _: next(saisies)) - assert cli.read_password(generate=False) == "un-mot-de-passe-valide" + assert cli.read_password(generate=False) == "Un-mot-de-passe-valide1" def test_read_password_refuses_a_password_below_the_minimum_length( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(cli, "getpass", lambda _: "court") + monkeypatch.setattr(cli, "getpass", lambda _: "Court1!") + + with pytest.raises(SystemExit): + cli.read_password(generate=False) + + +def test_read_password_refuses_a_password_missing_a_character_class( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(cli, "getpass", lambda _: "un-mot-de-passe-sans-majuscule-ni-chiffre") with pytest.raises(SystemExit): cli.read_password(generate=False) def test_read_password_refuses_two_different_entries(monkeypatch: pytest.MonkeyPatch) -> None: - saisies = iter(["un-mot-de-passe-valide", "un-autre-mot-de-passe"]) + saisies = iter(["Un-mot-de-passe-valide1", "Un-autre-mot-de-passe2"]) monkeypatch.setattr(cli, "getpass", lambda _: next(saisies)) with pytest.raises(SystemExit): diff --git a/apps/backend/uv.lock b/apps/backend/uv.lock index 7c2b8f4..39ec7ca 100644 --- a/apps/backend/uv.lock +++ b/apps/backend/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = "==3.14.*" +[[package]] +name = "aiosmtplib" +version = "5.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/5c/9cabc5db6d607616e81ba6d8f1f231cd5a75955807a308c1090a59072d6d/aiosmtplib-5.1.3.tar.gz", hash = "sha256:ac2b418d3260ba62d9cfd0fe7359726e9dc009a4e8e8d9909fdfae332f522a7c", size = 77010, upload-time = "2026-09-08T02:11:20.532Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/0a/b56ab8163d54960337fdca475d3dfd56c8badf6172e79cf2ad00d5335dc1/aiosmtplib-5.1.3-py3-none-any.whl", hash = "sha256:f7d76ce3d4995a65a178c1f11e1bd1607706b921d00cb768e7a2c7f7ef5517a8", size = 30116, upload-time = "2026-09-08T02:11:19.352Z" }, +] + [[package]] name = "alembic" version = "1.20.0" @@ -306,6 +315,7 @@ name = "enervision-backend" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "aiosmtplib" }, { name = "alembic" }, { name = "anyio" }, { name = "argon2-cffi" }, @@ -332,6 +342,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "aiosmtplib", specifier = ">=5.1.3" }, { name = "alembic", specifier = ">=1.20.0" }, { name = "anyio", specifier = ">=4.0" }, { name = "argon2-cffi", specifier = ">=23.1" }, diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index b3e97d8..72e20f5 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -5,6 +5,8 @@ export const routes: Routes = [ { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, { path: 'login', loadComponent: () => import('./features/auth/login/login').then(m => m.Login) }, { path: 'change-password', loadComponent: () => import('./features/auth/change-password/change-password').then(m => m.ChangePassword) }, + { path: 'forgot-password', loadComponent: () => import('./features/auth/forgot-password/forgot-password').then(m => m.ForgotPassword) }, + { path: 'reset-password', loadComponent: () => import('./features/auth/reset-password/reset-password').then(m => m.ResetPassword) }, { path: 'dashboard', canActivate: [authGuard], diff --git a/apps/frontend/src/app/core/services/auth.service.ts b/apps/frontend/src/app/core/services/auth.service.ts index d27c1db..9aa477a 100644 --- a/apps/frontend/src/app/core/services/auth.service.ts +++ b/apps/frontend/src/app/core/services/auth.service.ts @@ -1,7 +1,14 @@ import { Service, signal, computed, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable, tap, finalize, shareReplay } from 'rxjs'; -import { LoginRequest, PasswordChangeRequest, Principal, TokenResponse } from '../../shared/models/auth.model'; +import { + ForgotPasswordRequest, + LoginRequest, + PasswordChangeRequest, + Principal, + ResetPasswordRequest, + TokenResponse, +} from '../../shared/models/auth.model'; import { environment } from '../../../environments/environment'; @Service() @@ -66,4 +73,14 @@ export class AuthService { me(): Observable { return this.http.get(`${environment.apiUrl}/auth/me`); } + + forgotPassword(payload: ForgotPasswordRequest): Observable { + return this.http.post(`${environment.apiUrl}/auth/forgot-password`, payload); + } + + resetPassword(payload: ResetPasswordRequest): Observable { + return this.http + .post(`${environment.apiUrl}/auth/reset-password`, payload, { withCredentials: true }) + .pipe(tap((response) => this.setSession(response))); + } } diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.html b/apps/frontend/src/app/features/auth/change-password/change-password.html index edf2146..d7b5039 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.html +++ b/apps/frontend/src/app/features/auth/change-password/change-password.html @@ -18,7 +18,7 @@ formControlName="new_password" autocomplete="new-password" /> - 12 à 128 caractères + {{ passwordHint }} @if (errorMessage()) {

{{ errorMessage() }}

diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts b/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts index 63e1872..0e72843 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts +++ b/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts @@ -32,10 +32,19 @@ describe('ChangePassword', () => { expect(authMock.changePassword).not.toHaveBeenCalled(); }); + it('ne soumet pas si le mot de passe ne couvre pas les 4 classes de caractères', () => { + const fixture = TestBed.createComponent(ChangePassword); + const component = fixture.componentInstance; + component.form.setValue({ current_password: 'old', new_password: 'longueur-suffisante-sans-majuscule-ni-chiffre' }); + + component.onSubmit(); + expect(authMock.changePassword).not.toHaveBeenCalled(); + }); + it('redirige vers /dashboard après un changement réussi', () => { const fixture = TestBed.createComponent(ChangePassword); const component = fixture.componentInstance; - component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' }); + component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' }); authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } })); @@ -46,7 +55,7 @@ describe('ChangePassword', () => { it("affiche un message d'erreur si le mot de passe actuel est incorrect", () => { const fixture = TestBed.createComponent(ChangePassword); const component = fixture.componentInstance; - component.form.setValue({ current_password: 'mauvais-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' }); + component.form.setValue({ current_password: 'mauvais-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' }); authMock.changePassword.mockReturnValue(throwError(() => new Error('401'))); @@ -70,7 +79,7 @@ describe('ChangePassword', () => { it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => { const fixture = TestBed.createComponent(ChangePassword); const component = fixture.componentInstance; - component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' }); + component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' }); fixture.detectChanges(); authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } })); @@ -81,7 +90,7 @@ describe('ChangePassword', () => { expect(authMock.changePassword).toHaveBeenCalledWith({ current_password: 'ancien-mot-de-passe', - new_password: 'un-nouveau-mot-de-passe-valide', + new_password: 'Un-nouveau-mot-de-passe1!', }); }); diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.ts b/apps/frontend/src/app/features/auth/change-password/change-password.ts index 507af14..528aea0 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.ts +++ b/apps/frontend/src/app/features/auth/change-password/change-password.ts @@ -2,6 +2,7 @@ import { Component, inject, signal } from '@angular/core'; import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { AuthService } from '../../../core/services/auth.service'; +import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator'; @Component({ selector: 'app-change-password', @@ -17,10 +18,11 @@ export class ChangePassword { errorMessage = signal(null); isLoading = signal(false); + passwordHint = PASSWORD_HINT; form = this.fb.nonNullable.group({ current_password: ['', Validators.required], - new_password: ['', [Validators.required, Validators.minLength(12), Validators.maxLength(128)]], + new_password: ['', passwordValidators], }); onSubmit(): void { @@ -34,7 +36,7 @@ export class ChangePassword { }, error: () => { this.isLoading.set(false); - this.errorMessage.set('Mot de passe actuel incorrect, ou nouveau mot de passe invalide (12 à 128 caractères).'); + this.errorMessage.set(`Mot de passe actuel incorrect, ou nouveau mot de passe invalide (${this.passwordHint}).`); }, }); } diff --git a/apps/frontend/src/app/features/auth/forgot-password/forgot-password.html b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.html new file mode 100644 index 0000000..2bd7ef9 --- /dev/null +++ b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.html @@ -0,0 +1,37 @@ +
+
+

Mot de passe oublié

+

Recevez un lien de réinitialisation par email

+ + @if (submitted()) { +

+ Si un compte existe pour cet email, un lien de réinitialisation vient d'être envoyé. + Il expire dans 15 minutes. +

+ } @else { + + + + @if (errorMessage()) { +

+ {{ errorMessage() }} + @if (retryAfterSeconds(); as seconds) { + (réessayez dans {{ seconds }}s) + } +

+ } + + + } + + +
+
diff --git a/apps/frontend/src/app/features/auth/forgot-password/forgot-password.scss b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.scss new file mode 100644 index 0000000..31c9efc --- /dev/null +++ b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.scss @@ -0,0 +1,104 @@ +:host { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: #f3f4f6; + font-family: 'Segoe UI', system-ui, sans-serif; +} + +.auth-card { + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 2.5rem; + width: 100%; + max-width: 360px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); + display: flex; + flex-direction: column; + + h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + color: #1f2937; + } + + .auth-subtitle { + margin: 0.25rem 0 1.5rem; + color: #6b7280; + font-size: 0.9rem; + line-height: 1.4; + } + + label { + font-size: 0.85rem; + font-weight: 600; + color: #374151; + margin-bottom: 0.35rem; + margin-top: 1rem; + } + + input { + padding: 0.6rem 0.75rem; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 0.95rem; + + &:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); + } + } + + button { + margin-top: 1.5rem; + padding: 0.7rem; + background: #3b82f6; + color: #fff; + border: none; + border-radius: 8px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + + &:disabled { + background: #9ca3af; + cursor: not-allowed; + } + + &:not(:disabled):hover { + background: #2563eb; + } + } +} + +.auth-hint { + font-size: 0.75rem; + color: #9ca3af; + margin-top: 0.25rem; +} + +.auth-error { + margin: 0.75rem 0 0; + color: #dc2626; + font-size: 0.85rem; +} + +.auth-success { + margin: 0.75rem 0 0; + color: #16a34a; + font-size: 0.85rem; +} + +.auth-link { + margin-top: 1rem; + font-size: 0.85rem; + text-align: center; + + a { + color: #3b82f6; + } +} diff --git a/apps/frontend/src/app/features/auth/forgot-password/forgot-password.spec.ts b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.spec.ts new file mode 100644 index 0000000..56f7764 --- /dev/null +++ b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.spec.ts @@ -0,0 +1,75 @@ +import { TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; +import { of, throwError } from 'rxjs'; +import { vi } from 'vitest'; +import { ForgotPassword } from './forgot-password'; +import { AuthService } from '../../../core/services/auth.service'; + +describe('ForgotPassword', () => { + let authMock: { forgotPassword: ReturnType }; + let routerMock: { navigate: ReturnType }; + + beforeEach(async () => { + authMock = { forgotPassword: vi.fn() }; + routerMock = { navigate: vi.fn() }; + + await TestBed.configureTestingModule({ + imports: [ForgotPassword, ReactiveFormsModule], + providers: [ + { provide: AuthService, useValue: authMock }, + { provide: Router, useValue: routerMock }, + { provide: ActivatedRoute, useValue: {} }, + ], + }).compileComponents(); + }); + + it('ne soumet pas si le formulaire est invalide', () => { + const fixture = TestBed.createComponent(ForgotPassword); + fixture.componentInstance.onSubmit(); + expect(authMock.forgotPassword).not.toHaveBeenCalled(); + }); + + it('affiche le message générique après une soumission réussie', () => { + const fixture = TestBed.createComponent(ForgotPassword); + const component = fixture.componentInstance; + component.form.setValue({ email: 'operateur@enervision.fr' }); + authMock.forgotPassword.mockReturnValue(of(undefined)); + + component.onSubmit(); + + expect(component.submitted()).toBe(true); + }); + + it('affiche le même message générique même quand le serveur répond une erreur autre que 429', () => { + const fixture = TestBed.createComponent(ForgotPassword); + const component = fixture.componentInstance; + component.form.setValue({ email: 'inconnu@enervision.fr' }); + authMock.forgotPassword.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 500 }))); + + component.onSubmit(); + + expect(component.submitted()).toBe(true); + }); + + it('affiche le délai à respecter quand le taux limite est atteint', () => { + const fixture = TestBed.createComponent(ForgotPassword); + const component = fixture.componentInstance; + component.form.setValue({ email: 'operateur@enervision.fr' }); + authMock.forgotPassword.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 429, + headers: new HttpHeaders({ 'Retry-After': '900' }), + }) + ) + ); + + component.onSubmit(); + + expect(component.submitted()).toBe(false); + expect(component.retryAfterSeconds()).toBe(900); + }); +}); diff --git a/apps/frontend/src/app/features/auth/forgot-password/forgot-password.ts b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.ts new file mode 100644 index 0000000..6ceef5c --- /dev/null +++ b/apps/frontend/src/app/features/auth/forgot-password/forgot-password.ts @@ -0,0 +1,53 @@ +import { Component, inject, signal } from '@angular/core'; +import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; +import { RouterLink } from '@angular/router'; +import { HttpErrorResponse } from '@angular/common/http'; +import { AuthService } from '../../../core/services/auth.service'; + +@Component({ + selector: 'app-forgot-password', + standalone: true, + imports: [ReactiveFormsModule, RouterLink], + templateUrl: './forgot-password.html', + styleUrl: './forgot-password.scss', +}) +export class ForgotPassword { + private fb = inject(FormBuilder); + private auth = inject(AuthService); + + errorMessage = signal(null); + retryAfterSeconds = signal(null); + submitted = signal(false); + isLoading = signal(false); + + form = this.fb.nonNullable.group({ + email: ['', [Validators.required, Validators.email]], + }); + + onSubmit(): void { + if (this.form.invalid) return; + + this.isLoading.set(true); + this.errorMessage.set(null); + this.retryAfterSeconds.set(null); + + this.auth.forgotPassword(this.form.getRawValue()).subscribe({ + // Le message affiché ne dépend jamais du fait que le compte existe ou non : la réponse + // du serveur est déjà générique, l'écran doit l'être aussi. + next: () => { + this.isLoading.set(false); + this.submitted.set(true); + }, + error: (error: HttpErrorResponse) => { + this.isLoading.set(false); + if (error.status === 429) { + const retryAfter = error.headers.get('Retry-After'); + this.retryAfterSeconds.set(retryAfter ? Number(retryAfter) : null); + this.errorMessage.set('Trop de demandes, réessayez plus tard.'); + return; + } + this.submitted.set(true); + }, + }); + } +} diff --git a/apps/frontend/src/app/features/auth/login/login.html b/apps/frontend/src/app/features/auth/login/login.html index 0083bd2..3ee100b 100644 --- a/apps/frontend/src/app/features/auth/login/login.html +++ b/apps/frontend/src/app/features/auth/login/login.html @@ -32,5 +32,7 @@ + + diff --git a/apps/frontend/src/app/features/auth/login/login.scss b/apps/frontend/src/app/features/auth/login/login.scss index cc415b8..45b28c0 100644 --- a/apps/frontend/src/app/features/auth/login/login.scss +++ b/apps/frontend/src/app/features/auth/login/login.scss @@ -79,3 +79,13 @@ color: #dc2626; font-size: 0.85rem; } + +.auth-link { + margin-top: 1rem; + font-size: 0.85rem; + text-align: center; + + a { + color: #3b82f6; + } +} diff --git a/apps/frontend/src/app/features/auth/login/login.spec.ts b/apps/frontend/src/app/features/auth/login/login.spec.ts index 3c9bac1..d39298d 100644 --- a/apps/frontend/src/app/features/auth/login/login.spec.ts +++ b/apps/frontend/src/app/features/auth/login/login.spec.ts @@ -1,6 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { ReactiveFormsModule } from '@angular/forms'; -import { Router } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; import { of, throwError } from 'rxjs'; import { vi } from 'vitest'; @@ -20,6 +20,7 @@ describe('Login', () => { providers: [ { provide: AuthService, useValue: authMock }, { provide: Router, useValue: routerMock }, + { provide: ActivatedRoute, useValue: {} }, ], }).compileComponents(); }); diff --git a/apps/frontend/src/app/features/auth/login/login.ts b/apps/frontend/src/app/features/auth/login/login.ts index 34b9ff2..871e7cc 100644 --- a/apps/frontend/src/app/features/auth/login/login.ts +++ b/apps/frontend/src/app/features/auth/login/login.ts @@ -1,13 +1,13 @@ import { Component, inject, signal } from '@angular/core'; import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; -import { Router } from '@angular/router'; +import { Router, RouterLink } from '@angular/router'; import { HttpErrorResponse } from '@angular/common/http'; import { AuthService } from '../../../core/services/auth.service'; @Component({ selector: 'app-login', standalone: true, - imports: [ReactiveFormsModule], + imports: [ReactiveFormsModule, RouterLink], templateUrl: './login.html', styleUrl: './login.scss', }) diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.html b/apps/frontend/src/app/features/auth/reset-password/reset-password.html new file mode 100644 index 0000000..eed77a8 --- /dev/null +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.html @@ -0,0 +1,30 @@ +
+
+

Nouveau mot de passe

+ + @if (!hasToken) { +

Ce lien est incomplet. Redemandez un lien de réinitialisation.

+ } @else { +

Choisissez votre nouveau mot de passe

+ + + + {{ passwordHint }} + + @if (errorMessage()) { +

{{ errorMessage() }}

+ } + + + } + + +
+
diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.scss b/apps/frontend/src/app/features/auth/reset-password/reset-password.scss new file mode 100644 index 0000000..31c9efc --- /dev/null +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.scss @@ -0,0 +1,104 @@ +:host { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: #f3f4f6; + font-family: 'Segoe UI', system-ui, sans-serif; +} + +.auth-card { + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 2.5rem; + width: 100%; + max-width: 360px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); + display: flex; + flex-direction: column; + + h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + color: #1f2937; + } + + .auth-subtitle { + margin: 0.25rem 0 1.5rem; + color: #6b7280; + font-size: 0.9rem; + line-height: 1.4; + } + + label { + font-size: 0.85rem; + font-weight: 600; + color: #374151; + margin-bottom: 0.35rem; + margin-top: 1rem; + } + + input { + padding: 0.6rem 0.75rem; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 0.95rem; + + &:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); + } + } + + button { + margin-top: 1.5rem; + padding: 0.7rem; + background: #3b82f6; + color: #fff; + border: none; + border-radius: 8px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + + &:disabled { + background: #9ca3af; + cursor: not-allowed; + } + + &:not(:disabled):hover { + background: #2563eb; + } + } +} + +.auth-hint { + font-size: 0.75rem; + color: #9ca3af; + margin-top: 0.25rem; +} + +.auth-error { + margin: 0.75rem 0 0; + color: #dc2626; + font-size: 0.85rem; +} + +.auth-success { + margin: 0.75rem 0 0; + color: #16a34a; + font-size: 0.85rem; +} + +.auth-link { + margin-top: 1rem; + font-size: 0.85rem; + text-align: center; + + a { + color: #3b82f6; + } +} diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts b/apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts new file mode 100644 index 0000000..7e212cd --- /dev/null +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.spec.ts @@ -0,0 +1,74 @@ +import { TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; +import { HttpErrorResponse } from '@angular/common/http'; +import { of, throwError } from 'rxjs'; +import { vi } from 'vitest'; +import { ResetPassword } from './reset-password'; +import { AuthService } from '../../../core/services/auth.service'; + +function configure(token: string | null) { + return TestBed.configureTestingModule({ + imports: [ResetPassword, ReactiveFormsModule], + providers: [ + { provide: AuthService, useValue: { resetPassword: vi.fn() } }, + { provide: Router, useValue: { navigate: vi.fn() } }, + { + provide: ActivatedRoute, + useValue: { snapshot: { queryParamMap: convertToParamMap(token ? { token } : {}) } }, + }, + ], + }).compileComponents(); +} + +describe('ResetPassword', () => { + it("signale un lien incomplet quand le jeton est absent de l'URL", async () => { + await configure(null); + const fixture = TestBed.createComponent(ResetPassword); + + expect(fixture.componentInstance.hasToken).toBe(false); + }); + + it('ne soumet pas si le mot de passe ne respecte pas la politique de complexité', async () => { + await configure('un-secret-opaque'); + const fixture = TestBed.createComponent(ResetPassword); + const component = fixture.componentInstance; + const auth = TestBed.inject(AuthService) as unknown as { resetPassword: ReturnType }; + component.form.setValue({ new_password: 'trop-simple' }); + + component.onSubmit(); + + expect(auth.resetPassword).not.toHaveBeenCalled(); + }); + + it('redirige vers /dashboard après une réinitialisation réussie', async () => { + await configure('un-secret-opaque'); + const fixture = TestBed.createComponent(ResetPassword); + const component = fixture.componentInstance; + const auth = TestBed.inject(AuthService) as unknown as { resetPassword: ReturnType }; + const router = TestBed.inject(Router) as unknown as { navigate: ReturnType }; + component.form.setValue({ new_password: 'Un-nouveau-mot-de-passe1!' }); + auth.resetPassword.mockReturnValue(of({ principal: { role: 'operateur' } })); + + component.onSubmit(); + + expect(auth.resetPassword).toHaveBeenCalledWith({ + token: 'un-secret-opaque', + new_password: 'Un-nouveau-mot-de-passe1!', + }); + expect(router.navigate).toHaveBeenCalledWith(['/dashboard']); + }); + + it('affiche un message dédié quand le lien est invalide ou expiré', async () => { + await configure('un-secret-perime'); + const fixture = TestBed.createComponent(ResetPassword); + const component = fixture.componentInstance; + const auth = TestBed.inject(AuthService) as unknown as { resetPassword: ReturnType }; + component.form.setValue({ new_password: 'Un-nouveau-mot-de-passe1!' }); + auth.resetPassword.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 400 }))); + + component.onSubmit(); + + expect(component.errorMessage()).toContain('invalide'); + }); +}); diff --git a/apps/frontend/src/app/features/auth/reset-password/reset-password.ts b/apps/frontend/src/app/features/auth/reset-password/reset-password.ts new file mode 100644 index 0000000..6754fa7 --- /dev/null +++ b/apps/frontend/src/app/features/auth/reset-password/reset-password.ts @@ -0,0 +1,52 @@ +import { Component, inject, signal } from '@angular/core'; +import { ReactiveFormsModule, FormBuilder } from '@angular/forms'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { HttpErrorResponse } from '@angular/common/http'; +import { AuthService } from '../../../core/services/auth.service'; +import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator'; + +@Component({ + selector: 'app-reset-password', + standalone: true, + imports: [ReactiveFormsModule, RouterLink], + templateUrl: './reset-password.html', + styleUrl: './reset-password.scss', +}) +export class ResetPassword { + private fb = inject(FormBuilder); + private auth = inject(AuthService); + private router = inject(Router); + private route = inject(ActivatedRoute); + + private token = this.route.snapshot.queryParamMap.get('token') ?? ''; + + errorMessage = signal(null); + isLoading = signal(false); + passwordHint = PASSWORD_HINT; + hasToken = this.token.length > 0; + + form = this.fb.nonNullable.group({ + new_password: ['', passwordValidators], + }); + + onSubmit(): void { + if (this.form.invalid || !this.hasToken) return; + + this.isLoading.set(true); + this.errorMessage.set(null); + + this.auth.resetPassword({ token: this.token, new_password: this.form.getRawValue().new_password }).subscribe({ + next: () => { + this.router.navigate(['/dashboard']); + }, + error: (error: HttpErrorResponse) => { + this.isLoading.set(false); + if (error.status === 400) { + this.errorMessage.set('Ce lien est invalide, déjà utilisé, ou a expiré. Redemandez-en un.'); + return; + } + this.errorMessage.set(`Nouveau mot de passe invalide (${this.passwordHint}).`); + }, + }); + } +} diff --git a/apps/frontend/src/app/shared/models/auth.model.ts b/apps/frontend/src/app/shared/models/auth.model.ts index 932572f..ebed0d5 100644 --- a/apps/frontend/src/app/shared/models/auth.model.ts +++ b/apps/frontend/src/app/shared/models/auth.model.ts @@ -10,6 +10,15 @@ export interface PasswordChangeRequest { new_password: string; } +export interface ForgotPasswordRequest { + email: string; +} + +export interface ResetPasswordRequest { + token: string; + new_password: string; +} + export interface Principal { id: string; email: string; diff --git a/apps/frontend/src/app/shared/validators/password.validator.ts b/apps/frontend/src/app/shared/validators/password.validator.ts new file mode 100644 index 0000000..fac1359 --- /dev/null +++ b/apps/frontend/src/app/shared/validators/password.validator.ts @@ -0,0 +1,15 @@ +import { Validators } from '@angular/forms'; + +export const PASSWORD_MIN_LENGTH = 8; +export const PASSWORD_MAX_LENGTH = 128; +export const PASSWORD_HINT = + '8 à 128 caractères, avec au moins 1 majuscule, 1 minuscule, 1 chiffre et 1 caractère spécial'; + +const PASSWORD_PATTERN = /^(?=.*[A-ZÀ-Ý])(?=.*[a-zà-ÿ])(?=.*\d)(?=.*[^\w\s]).*$/; + +export const passwordValidators = [ + Validators.required, + Validators.minLength(PASSWORD_MIN_LENGTH), + Validators.maxLength(PASSWORD_MAX_LENGTH), + Validators.pattern(PASSWORD_PATTERN), +]; diff --git a/docker-compose.yml b/docker-compose.yml index 3d0ea63..3f7f9ea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,11 +27,22 @@ services: start_period: 40s restart: unless-stopped + # Piege : Mailpit ne relaie rien vers l'exterieur, il capture tout email envoye par le + # backend. Aucun acces reseau sortant n'est requis ; l'UI web (8025) sert a lire les emails. + mailpit: + image: axllent/mailpit + ports: + - "${MAILPIT_SMTP_PORT:-1025}:1025" + - "${MAILPIT_UI_PORT:-8025}:8025" + restart: unless-stopped + backend: build: ./apps/backend depends_on: db: condition: service_healthy + mailpit: + condition: service_started environment: APP_ENV: ${APP_ENV:-local} APP_DEBUG: ${APP_DEBUG:-false} @@ -39,6 +50,11 @@ services: APP_SECRET_KEY: ${APP_SECRET_KEY:?} APP_CORS_ORIGINS: ${APP_CORS_ORIGINS:-http://localhost:4200} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + APP_FRONTEND_RESET_PASSWORD_URL: ${APP_FRONTEND_RESET_PASSWORD_URL:-http://localhost:4200/reset-password} + APP_SMTP_HOST: mailpit + APP_SMTP_PORT: "1025" + APP_SMTP_USE_TLS: "false" + APP_SMTP_FROM_ADDRESS: ${APP_SMTP_FROM_ADDRESS:-no-reply@enervision.fr} ports: - "${BACKEND_PORT:-8000}:8000" restart: unless-stopped diff --git a/docs/architecture/31-contrat-authentification.md b/docs/architecture/31-contrat-authentification.md index 9c9fe66..981cd85 100644 --- a/docs/architecture/31-contrat-authentification.md +++ b/docs/architecture/31-contrat-authentification.md @@ -20,6 +20,8 @@ gérer : il suffit d'envoyer les requêtes avec `withCredentials`. | POST | `/api/v1/auth/logout` | cookie | `204` | | POST | `/api/v1/auth/logout-all` | jeton d'accès | `204` | | POST | `/api/v1/auth/password` | jeton d'accès | `200` `TokenResponse` | +| POST | `/api/v1/auth/forgot-password` | aucune | `202` (toujours, que le compte existe ou non) | +| POST | `/api/v1/auth/reset-password` | aucune (jeton dans le corps) | `200` `TokenResponse` | | GET | `/api/v1/auth/me` | jeton d'accès | `200` `PrincipalResponse` | | GET | `/api/v1/users` | jeton d'accès, `admin` | `200` `UserResponse[]` | | POST | `/api/v1/users` | jeton d'accès, `admin` | `201` `TemporaryPasswordResponse` | @@ -51,7 +53,17 @@ codes d'erreur ci-dessous reste la référence de comportement, le schéma celle } // POST /auth/password -{ "current_password": "...", "new_password": "..." } // 12 à 128 caractères +{ "current_password": "...", "new_password": "..." } // 8 à 128 caractères, au moins 1 majuscule, 1 minuscule, 1 chiffre, 1 caractère spécial + +// POST /auth/forgot-password +{ "email": "operateur@enervision.fr" } +// Répond toujours 202, sans corps, que le compte existe, soit inactif, ou soit inconnu. + +// POST /auth/reset-password +{ "token": "...", "new_password": "..." } // même règle de complexité que /auth/password +// Le jeton vient du lien reçu par email, valable 15 minutes, à usage unique. Répond +// TokenResponse au succès (l'appareil qui pose le nouveau mot de passe reste connecté), ou 400 +// si le jeton est invalide, déjà utilisé, ou expiré. ``` Le secret de rafraîchissement **n'apparaît jamais** dans le corps de la réponse. @@ -70,6 +82,9 @@ Le secret de rafraîchissement **n'apparaît jamais** dans le corps de la répon | `403` avec `detail: "Droits insuffisants"` | rôle trop bas | masquer ou griser l'action, ne pas déconnecter | | `403` sur `/auth/refresh`, `/logout`, `/logout-all`, `/password` | origine hors liste autorisée (voir « Origines autorisées ») | erreur de configuration réseau, pas un cas à gérer par l'utilisateur | | `422` | corps invalide | le détail donne `champ` et `type`, jamais la valeur envoyée | +| `429` sur `/auth/forgot-password` | trop de demandes | afficher l'attente, l'en-tête `Retry-After` donne les secondes | +| `400` sur `/auth/reset-password` | lien invalide, déjà utilisé, ou expiré | inviter à redemander un lien depuis `/forgot-password` | +| `403` sur `/auth/reset-password` | origine hors liste autorisée | erreur de configuration réseau, pas un cas à gérer par l'utilisateur | ## Les quatre règles qui comptent From 9c78c6dc3882d12c3ce8958a80a8aad1b30ca7b2 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 17 Sep 2026 11:14:02 +0200 Subject: [PATCH 04/87] =?UTF-8?q?feat(frontend):=20design=20syst=C3=A8me?= =?UTF-8?q?=20-=20tokens,=20composants=20ui=20et=20restylage=20des=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Centralise les couleurs/rayons/espacements dispersés en dur dans chaque page (login, change-password, dashboard) en tokens CSS partagés, ajoute un petit set de composants standalone réutilisables (ev-button, ev-card, ev-alert, ev-badge) et intègre le logo EnerVision en en-tête des pages ainsi que dans Swagger/ReDoc côté backend. Refs #91 --- apps/backend/app/main.py | 50 ++++++++- apps/backend/app/static/logo-icon.png | Bin 0 -> 36468 bytes apps/frontend/README.md | 7 ++ apps/frontend/public/logo.png | Bin 0 -> 7152 bytes .../auth/change-password/change-password.html | 53 +++++---- .../auth/change-password/change-password.scss | 104 +++++------------- .../change-password/change-password.spec.ts | 4 +- .../auth/change-password/change-password.ts | 5 +- .../src/app/features/auth/login/login.html | 63 ++++++----- .../src/app/features/auth/login/login.scss | 100 ++++++----------- .../src/app/features/auth/login/login.spec.ts | 6 +- .../src/app/features/auth/login/login.ts | 5 +- .../src/app/features/dashboard/dashboard.html | 27 +++-- .../src/app/features/dashboard/dashboard.scss | 96 +++++----------- .../src/app/features/dashboard/dashboard.ts | 18 ++- .../app/shared/components/ui/alert/alert.html | 1 + .../app/shared/components/ui/alert/alert.scss | 27 +++++ .../shared/components/ui/alert/alert.spec.ts | 30 +++++ .../app/shared/components/ui/alert/alert.ts | 21 ++++ .../app/shared/components/ui/badge/badge.html | 3 + .../app/shared/components/ui/badge/badge.scss | 27 +++++ .../shared/components/ui/badge/badge.spec.ts | 30 +++++ .../app/shared/components/ui/badge/badge.ts | 13 +++ .../shared/components/ui/button/button.html | 3 + .../shared/components/ui/button/button.scss | 51 +++++++++ .../components/ui/button/button.spec.ts | 50 +++++++++ .../app/shared/components/ui/button/button.ts | 15 +++ .../app/shared/components/ui/card/card.html | 1 + .../app/shared/components/ui/card/card.scss | 10 ++ .../shared/components/ui/card/card.spec.ts | 22 ++++ .../src/app/shared/components/ui/card/card.ts | 9 ++ apps/frontend/src/styles.scss | 9 +- apps/frontend/src/styles/_forms.scss | 37 +++++++ apps/frontend/src/styles/_tokens.scss | 39 +++++++ .../32-design-systeme-frontend.md | 76 +++++++++++++ 35 files changed, 724 insertions(+), 288 deletions(-) create mode 100644 apps/backend/app/static/logo-icon.png create mode 100644 apps/frontend/public/logo.png create mode 100644 apps/frontend/src/app/shared/components/ui/alert/alert.html create mode 100644 apps/frontend/src/app/shared/components/ui/alert/alert.scss create mode 100644 apps/frontend/src/app/shared/components/ui/alert/alert.spec.ts create mode 100644 apps/frontend/src/app/shared/components/ui/alert/alert.ts create mode 100644 apps/frontend/src/app/shared/components/ui/badge/badge.html create mode 100644 apps/frontend/src/app/shared/components/ui/badge/badge.scss create mode 100644 apps/frontend/src/app/shared/components/ui/badge/badge.spec.ts create mode 100644 apps/frontend/src/app/shared/components/ui/badge/badge.ts create mode 100644 apps/frontend/src/app/shared/components/ui/button/button.html create mode 100644 apps/frontend/src/app/shared/components/ui/button/button.scss create mode 100644 apps/frontend/src/app/shared/components/ui/button/button.spec.ts create mode 100644 apps/frontend/src/app/shared/components/ui/button/button.ts create mode 100644 apps/frontend/src/app/shared/components/ui/card/card.html create mode 100644 apps/frontend/src/app/shared/components/ui/card/card.scss create mode 100644 apps/frontend/src/app/shared/components/ui/card/card.spec.ts create mode 100644 apps/frontend/src/app/shared/components/ui/card/card.ts create mode 100644 apps/frontend/src/styles/_forms.scss create mode 100644 apps/frontend/src/styles/_tokens.scss create mode 100644 docs/architecture/32-design-systeme-frontend.md diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index de1235e..5a8ec3e 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -1,9 +1,15 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from pathlib import Path from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html +from fastapi.openapi.utils import get_openapi +from fastapi.staticfiles import StaticFiles from prometheus_fastapi_instrumentator import Instrumentator +from starlette.requests import Request +from starlette.responses import HTMLResponse from app.api.errors import register_error_handlers from app.api.middleware import SecurityHeadersMiddleware @@ -18,6 +24,8 @@ logger = get_logger(__name__) METHODES_AUTORISEES = ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"] EN_TETES_AUTORISES = ["Authorization", "Content-Type"] +STATIC_DIR = Path(__file__).parent / "static" +LOGO_URL = "/static/logo-icon.png" @asynccontextmanager @@ -43,11 +51,49 @@ def create_app(settings: Settings | None = None) -> FastAPI: openapi_tags=TAGS, debug=resolved.debug, lifespan=lifespan, - docs_url="/docs" if documentee else None, - redoc_url="/redoc" if documentee else None, + docs_url=None, + redoc_url=None, openapi_url="/openapi.json" if documentee else None, ) + if documentee: + application.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + + # ReDoc supporte nativement `info.x-logo` (extension Redocly) pour afficher un logo + # en en-tête ; Swagger UI n'a pas d'equivalent, il ne reprend que le favicon. + def openapi_avec_logo() -> dict[str, object]: + if application.openapi_schema: + return application.openapi_schema + schema = get_openapi( + title=application.title, + version=application.version, + summary=application.summary, + description=application.description, + routes=application.routes, + tags=application.openapi_tags, + ) + schema["info"]["x-logo"] = {"url": LOGO_URL, "altText": "EnerVision"} + application.openapi_schema = schema + return application.openapi_schema + + application.openapi = openapi_avec_logo # type: ignore[method-assign] + + @application.get("/docs", include_in_schema=False) + async def docs_swagger(_: Request) -> HTMLResponse: + return get_swagger_ui_html( + openapi_url="/openapi.json", + title=f"{application.title} · Swagger UI", + swagger_favicon_url=LOGO_URL, + ) + + @application.get("/redoc", include_in_schema=False) + async def docs_redoc(_: Request) -> HTMLResponse: + return get_redoc_html( + openapi_url="/openapi.json", + title=f"{application.title} · ReDoc", + redoc_favicon_url=LOGO_URL, + ) + application.add_middleware(SecurityHeadersMiddleware) if resolved.allowed_origins: diff --git a/apps/backend/app/static/logo-icon.png b/apps/backend/app/static/logo-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d3bdc53f943206607cea054067c3888aa9056d83 GIT binary patch literal 36468 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 kzNePZU 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 literal 0 HcmV?d00001 diff --git a/apps/frontend/README.md b/apps/frontend/README.md index aeaf788..c5b7484 100644 --- a/apps/frontend/README.md +++ b/apps/frontend/README.md @@ -76,6 +76,13 @@ Points à vérifier après toute regénération : côté backend. Le `docker-compose.yml` n'a aucun service frontend. 4. Ajouter le `Dockerfile` multi-stage (build Angular puis service statique nginx). +## Design système + +Tokens (couleurs, typo, espacements) et composants partagés (`ev-button`, `ev-card`, +`ev-alert`, `ev-badge`) sont documentés dans +[`docs/architecture/32-design-systeme-frontend.md`](../../docs/architecture/32-design-systeme-frontend.md). +Toute nouvelle page doit les réutiliser plutôt que définir ses propres valeurs. + ## Additional Resources For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. diff --git a/apps/frontend/public/logo.png b/apps/frontend/public/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..e5e41b24ad30754b294062a27d7f16819fc51210 GIT binary patch literal 7152 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 -

-

Nouveau mot de passe

-

Votre mot de passe est provisoire, vous devez le modifier avant de continuer

+ + + +

Nouveau mot de passe

+

Votre mot de passe est provisoire, vous devez le modifier avant de continuer

- - + + - - - 12 à 128 caractères + + + 12 à 128 caractères - @if (errorMessage()) { -

{{ errorMessage() }}

- } + @if (errorMessage()) { + {{ errorMessage() }} + } - + + {{ isLoading() ? 'Modification...' : 'Valider' }} + +
diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.scss b/apps/frontend/src/app/features/auth/change-password/change-password.scss index f44fcb8..48051a5 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.scss +++ b/apps/frontend/src/app/features/auth/change-password/change-password.scss @@ -3,86 +3,42 @@ align-items: center; justify-content: center; min-height: 100vh; - background: #f3f4f6; - font-family: 'Segoe UI', system-ui, sans-serif; + background: var(--color-bg); } -.auth-card { - background: #ffffff; - border: 1px solid #e5e7eb; - border-radius: 12px; - padding: 2.5rem; +.auth-card-wrapper { width: 100%; max-width: 360px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); - display: flex; - flex-direction: column; - - h1 { - margin: 0; - font-size: 1.5rem; - font-weight: 700; - color: #1f2937; - } - - .auth-subtitle { - margin: 0.25rem 0 1.5rem; - color: #6b7280; - font-size: 0.9rem; - line-height: 1.4; - } - - label { - font-size: 0.85rem; - font-weight: 600; - color: #374151; - margin-bottom: 0.35rem; - margin-top: 1rem; - } - - input { - padding: 0.6rem 0.75rem; - border: 1px solid #d1d5db; - border-radius: 8px; - font-size: 0.95rem; - - &:focus { - outline: none; - border-color: #3b82f6; - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); - } - } - - button { - margin-top: 1.5rem; - padding: 0.7rem; - background: #3b82f6; - color: #fff; - border: none; - border-radius: 8px; - font-size: 0.95rem; - font-weight: 600; - cursor: pointer; - - &:disabled { - background: #9ca3af; - cursor: not-allowed; - } - - &:not(:disabled):hover { - background: #2563eb; - } - } } -.auth-hint { - font-size: 0.75rem; - color: #9ca3af; - margin-top: 0.25rem; +.auth-logo { + display: block; + height: 48px; + margin: 0 auto 1rem; } -.auth-error { - margin: 0.75rem 0 0; - color: #dc2626; - font-size: 0.85rem; +h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + color: var(--color-text); + text-align: center; +} + +.auth-subtitle { + margin: 0.25rem 0 1.5rem; + color: var(--color-text-muted); + font-size: 0.9rem; + line-height: 1.4; + text-align: center; +} + +ev-alert { + display: block; + margin-top: 0.75rem; +} + +ev-button { + display: block; + margin-top: 1.5rem; } diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts b/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts index 63e1872..6fe09d0 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts +++ b/apps/frontend/src/app/features/auth/change-password/change-password.spec.ts @@ -54,7 +54,7 @@ describe('ChangePassword', () => { fixture.detectChanges(); // rend le bloc @if (errorMessage()) expect(component.errorMessage()).toContain('incorrect'); - const errorEl = fixture.nativeElement.querySelector('.auth-error'); + const errorEl = fixture.nativeElement.querySelector('.ev-alert'); expect(errorEl?.textContent).toContain('incorrect'); }); @@ -64,7 +64,7 @@ describe('ChangePassword', () => { const button = fixture.nativeElement.querySelector('button[type="submit"]'); expect(button.disabled).toBe(true); - expect(fixture.nativeElement.querySelector('.auth-error')).toBeNull(); + expect(fixture.nativeElement.querySelector('.ev-alert')).toBeNull(); }); it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => { diff --git a/apps/frontend/src/app/features/auth/change-password/change-password.ts b/apps/frontend/src/app/features/auth/change-password/change-password.ts index 507af14..1acf568 100644 --- a/apps/frontend/src/app/features/auth/change-password/change-password.ts +++ b/apps/frontend/src/app/features/auth/change-password/change-password.ts @@ -2,11 +2,14 @@ import { Component, inject, signal } from '@angular/core'; import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { AuthService } from '../../../core/services/auth.service'; +import { Button } from '../../../shared/components/ui/button/button'; +import { Card } from '../../../shared/components/ui/card/card'; +import { Alert } from '../../../shared/components/ui/alert/alert'; @Component({ selector: 'app-change-password', standalone: true, - imports: [ReactiveFormsModule], + imports: [ReactiveFormsModule, Button, Card, Alert], templateUrl: './change-password.html', styleUrl: './change-password.scss', }) diff --git a/apps/frontend/src/app/features/auth/login/login.html b/apps/frontend/src/app/features/auth/login/login.html index 0083bd2..89e67fe 100644 --- a/apps/frontend/src/app/features/auth/login/login.html +++ b/apps/frontend/src/app/features/auth/login/login.html @@ -1,36 +1,41 @@
-
-

Connexion

-

Accédez à votre espace EnerVision

+ + + +

Connexion

+

Accédez à votre espace EnerVision

- - + + - - + + - @if (errorMessage()) { -

- {{ errorMessage() }} - @if (retryAfterSeconds(); as seconds) { - (réessayez dans {{ seconds }}s) - } -

- } + @if (errorMessage()) { + + {{ errorMessage() }} + @if (retryAfterSeconds(); as seconds) { + (réessayez dans {{ seconds }}s) + } + + } - + + {{ isLoading() ? 'Connexion...' : 'Se connecter' }} + +
diff --git a/apps/frontend/src/app/features/auth/login/login.scss b/apps/frontend/src/app/features/auth/login/login.scss index cc415b8..46cc393 100644 --- a/apps/frontend/src/app/features/auth/login/login.scss +++ b/apps/frontend/src/app/features/auth/login/login.scss @@ -3,79 +3,41 @@ align-items: center; justify-content: center; min-height: 100vh; - background: #f3f4f6; - font-family: 'Segoe UI', system-ui, sans-serif; + background: var(--color-bg); } -.auth-card { - background: #ffffff; - border: 1px solid #e5e7eb; - border-radius: 12px; - padding: 2.5rem; +.auth-card-wrapper { width: 100%; max-width: 360px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); - display: flex; - flex-direction: column; - - h1 { - margin: 0; - font-size: 1.5rem; - font-weight: 700; - color: #1f2937; - } - - .auth-subtitle { - margin: 0.25rem 0 1.5rem; - color: #6b7280; - font-size: 0.9rem; - } - - label { - font-size: 0.85rem; - font-weight: 600; - color: #374151; - margin-bottom: 0.35rem; - margin-top: 1rem; - } - - input { - padding: 0.6rem 0.75rem; - border: 1px solid #d1d5db; - border-radius: 8px; - font-size: 0.95rem; - - &:focus { - outline: none; - border-color: #3b82f6; - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); - } - } - - button { - margin-top: 1.5rem; - padding: 0.7rem; - background: #3b82f6; - color: #fff; - border: none; - border-radius: 8px; - font-size: 0.95rem; - font-weight: 600; - cursor: pointer; - - &:disabled { - background: #9ca3af; - cursor: not-allowed; - } - - &:not(:disabled):hover { - background: #2563eb; - } - } } -.auth-error { - margin: 0.75rem 0 0; - color: #dc2626; - font-size: 0.85rem; +.auth-logo { + display: block; + height: 48px; + margin: 0 auto 1rem; +} + +h1 { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + color: var(--color-text); + text-align: center; +} + +.auth-subtitle { + margin: 0.25rem 0 1.5rem; + color: var(--color-text-muted); + font-size: 0.9rem; + text-align: center; +} + +ev-alert { + display: block; + margin-top: 0.75rem; +} + +ev-button { + display: block; + margin-top: 1.5rem; } diff --git a/apps/frontend/src/app/features/auth/login/login.spec.ts b/apps/frontend/src/app/features/auth/login/login.spec.ts index 3c9bac1..46afce2 100644 --- a/apps/frontend/src/app/features/auth/login/login.spec.ts +++ b/apps/frontend/src/app/features/auth/login/login.spec.ts @@ -63,7 +63,7 @@ describe('Login', () => { fixture.detectChanges(); // rend le bloc @if (errorMessage()) du template expect(component.errorMessage()).toBe('Email ou mot de passe incorrect.'); - const errorEl = fixture.nativeElement.querySelector('.auth-error'); + const errorEl = fixture.nativeElement.querySelector('.ev-alert'); expect(errorEl?.textContent).toContain('Email ou mot de passe incorrect.'); }); @@ -80,7 +80,7 @@ describe('Login', () => { fixture.detectChanges(); // rend aussi le sous-bloc @if (retryAfterSeconds(); as seconds) expect(component.retryAfterSeconds()).toBe(30); - const errorEl = fixture.nativeElement.querySelector('.auth-error'); + const errorEl = fixture.nativeElement.querySelector('.ev-alert'); expect(errorEl?.textContent).toContain('30s'); }); @@ -90,7 +90,7 @@ describe('Login', () => { const button = fixture.nativeElement.querySelector('button[type="submit"]'); expect(button.disabled).toBe(true); - expect(fixture.nativeElement.querySelector('.auth-error')).toBeNull(); + expect(fixture.nativeElement.querySelector('.ev-alert')).toBeNull(); }); it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => { diff --git a/apps/frontend/src/app/features/auth/login/login.ts b/apps/frontend/src/app/features/auth/login/login.ts index 34b9ff2..ea8f62f 100644 --- a/apps/frontend/src/app/features/auth/login/login.ts +++ b/apps/frontend/src/app/features/auth/login/login.ts @@ -3,11 +3,14 @@ import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { HttpErrorResponse } from '@angular/common/http'; import { AuthService } from '../../../core/services/auth.service'; +import { Button } from '../../../shared/components/ui/button/button'; +import { Card } from '../../../shared/components/ui/card/card'; +import { Alert } from '../../../shared/components/ui/alert/alert'; @Component({ selector: 'app-login', standalone: true, - imports: [ReactiveFormsModule], + imports: [ReactiveFormsModule, Button, Card, Alert], templateUrl: './login.html', styleUrl: './login.scss', }) diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index 684b444..533f0e3 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -1,19 +1,22 @@
-
-

Vue d'ensemble

-

Consommation instantanée du parc

+
+ +
+

Vue d'ensemble

+

Consommation instantanée du parc

+
@if (error(); as message) { - + } @if (stats(); as s) {
-
+ Consommation vs capacité {{ s.total_consumption_kw | number: '1.0-1' }} / {{ s.total_capacity_kw | number }} kW -
+ -
+ Charge moyenne du parc {{ s.average_load_percent }} %
-
+ -
+ Sites suivis {{ s.total_sites }} -
+
@@ -50,8 +53,8 @@

Alertes actives

    @for (alert of alerts(); track alert.alert_id) { -
  • - {{ alert.severity }} +
  • + {{ alert.severity }} {{ alert.message }}
  • } diff --git a/apps/frontend/src/app/features/dashboard/dashboard.scss b/apps/frontend/src/app/features/dashboard/dashboard.scss index 75976e1..53c5364 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.scss +++ b/apps/frontend/src/app/features/dashboard/dashboard.scss @@ -1,23 +1,22 @@ :host { - --color-good: #2e7d32; - --color-partial: #f9a825; - --color-degraded: #ef6c00; - --color-critical: #c62828; - --color-bg-card: #ffffff; - --color-border: #e5e7eb; - --color-text-muted: #6b7280; - --radius: 10px; - display: block; - font-family: 'Segoe UI', system-ui, sans-serif; - color: #1f2937; + color: var(--color-text); padding: 2rem; max-width: 1100px; margin: 0 auto; } .dashboard__header { + display: flex; + align-items: flex-start; + justify-content: space-between; margin-bottom: 2rem; +} + +.dashboard__brand { + display: flex; + align-items: center; + gap: 0.85rem; h1 { margin: 0; @@ -26,6 +25,11 @@ } } +.dashboard__logo { + height: 40px; + width: auto; +} + .dashboard__subtitle { margin: 0.25rem 0 0; color: var(--color-text-muted); @@ -38,13 +42,8 @@ h2 { } .banner-error { + display: block; margin: 0 0 1.5rem; - padding: 0.75rem 1rem; - border: 1px solid var(--color-critical); - border-left-width: 4px; - border-radius: var(--radius); - background: #fdecea; - color: var(--color-critical); } .overview { @@ -55,14 +54,8 @@ h2 { } .card { - background: var(--color-bg-card); - border: 1px solid var(--color-border); - border-radius: var(--radius); padding: 1.25rem; - display: flex; - flex-direction: column; gap: 0.35rem; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); } .card--gauge { @@ -84,16 +77,16 @@ h2 { .progress-bar { height: 6px; - background: #e5e7eb; - border-radius: 999px; + background: var(--color-border-light); + border-radius: var(--radius-pill); overflow: hidden; margin-top: 0.25rem; } .progress-bar__fill { height: 100%; - background: #3b82f6; - border-radius: 999px; + background: var(--color-primary); + border-radius: var(--radius-pill); transition: width 0.3s ease; } @@ -115,59 +108,26 @@ h2 { align-items: center; gap: 0.75rem; padding: 0.7rem 1rem; - border-radius: var(--radius); - background: #fef2f2; - border: 1px solid #fecaca; -} - -.alert-item__badge { - font-size: 0.7rem; - font-weight: 700; - text-transform: uppercase; - padding: 0.2rem 0.55rem; - border-radius: 999px; - color: #fff; - background: var(--color-critical); - flex-shrink: 0; -} - -.alert-item--high .alert-item__badge { - background: var(--color-degraded); -} -.alert-item--medium .alert-item__badge { - background: var(--color-partial); -} -.alert-item--low .alert-item__badge { - background: var(--color-good); + border-radius: var(--radius-md); + background: var(--color-danger-bg); + border: 1px solid var(--color-danger-border); } .alert-item__message { font-size: 0.9rem; } -.dashboard__header { - display: flex; - align-items: flex-start; - justify-content: space-between; - margin-bottom: 2rem; - - h1 { - margin: 0; - font-size: 1.75rem; - font-weight: 700; - } -} .logout-button { padding: 0.5rem 1rem; - background: #ffffff; - border: 1px solid #d1d5db; - border-radius: 8px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); font-size: 0.85rem; font-weight: 600; - color: #374151; + color: var(--color-label); cursor: pointer; &:hover { - background: #f3f4f6; + background: var(--color-bg); } } diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index c6a6a56..28bc37e 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -9,16 +9,26 @@ import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load import { AlertsService } from '../../core/services/alerts.service'; import { AuthService } from '../../core/services/auth.service'; import { StatsSummary } from '../../shared/models/stats.model'; -import { Alert } from '../../shared/models/alert.model'; +import { Alert, AlertSeverity } from '../../shared/models/alert.model'; +import { Card } from '../../shared/components/ui/card/card'; +import { Alert as EvAlert } from '../../shared/components/ui/alert/alert'; +import { Badge, BadgeTone } from '../../shared/components/ui/badge/badge'; const REFRESH_INTERVAL_MS = 10000; const UNAVAILABLE_MESSAGE = 'Données indisponibles, les valeurs affichées datent du dernier relevé.'; +const TON_PAR_SEVERITE: Record = { + low: 'success', + medium: 'warning', + high: 'danger', + critical: 'danger', +}; + @Component({ selector: 'app-dashboard', standalone: true, - imports: [DecimalPipe, ConsumptionGauge, SiteLoadChart], + imports: [DecimalPipe, ConsumptionGauge, SiteLoadChart, Card, EvAlert, Badge], templateUrl: './dashboard.html', styleUrl: './dashboard.scss', }) @@ -54,6 +64,10 @@ export class Dashboard implements OnInit { }); } + badgeToneForSeverity(severity: AlertSeverity): BadgeTone { + return TON_PAR_SEVERITE[severity]; + } + onLogout(): void { this.auth.logout().subscribe({ next: () => this.router.navigate(['/login']), diff --git a/apps/frontend/src/app/shared/components/ui/alert/alert.html b/apps/frontend/src/app/shared/components/ui/alert/alert.html new file mode 100644 index 0000000..6dbc743 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/alert/alert.html @@ -0,0 +1 @@ + diff --git a/apps/frontend/src/app/shared/components/ui/alert/alert.scss b/apps/frontend/src/app/shared/components/ui/alert/alert.scss new file mode 100644 index 0000000..667f779 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/alert/alert.scss @@ -0,0 +1,27 @@ +:host { + display: block; + margin: 0; + padding: 0.75rem 1rem; + border-radius: var(--radius-sm); + border: 1px solid transparent; + font-size: 0.85rem; + line-height: 1.4; +} + +:host.ev-alert--success { + background: var(--color-success-bg); + border-color: var(--color-success); + color: var(--color-success); +} + +:host.ev-alert--warning { + background: var(--color-warning-bg); + border-color: var(--color-warning); + color: #92400e; +} + +:host.ev-alert--danger { + background: var(--color-danger-bg); + border-color: var(--color-danger-border); + color: var(--color-danger); +} diff --git a/apps/frontend/src/app/shared/components/ui/alert/alert.spec.ts b/apps/frontend/src/app/shared/components/ui/alert/alert.spec.ts new file mode 100644 index 0000000..cea6bd7 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/alert/alert.spec.ts @@ -0,0 +1,30 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Alert } from './alert'; + +@Component({ + standalone: true, + imports: [Alert], + template: `C'est fait`, +}) +class AlertHost {} + +describe('Alert', () => { + it('applique la classe danger par défaut', async () => { + await TestBed.configureTestingModule({ imports: [Alert] }).compileComponents(); + const fixture = TestBed.createComponent(Alert); + fixture.detectChanges(); + + expect(fixture.nativeElement.classList).toContain('ev-alert--danger'); + }); + + it('applique la sévérité demandée et projette le contenu', async () => { + await TestBed.configureTestingModule({ imports: [AlertHost] }).compileComponents(); + const fixture = TestBed.createComponent(AlertHost); + fixture.detectChanges(); + + const el = fixture.nativeElement.querySelector('.ev-alert'); + expect(el.classList).toContain('ev-alert--success'); + expect(el.textContent).toContain("C'est fait"); + }); +}); diff --git a/apps/frontend/src/app/shared/components/ui/alert/alert.ts b/apps/frontend/src/app/shared/components/ui/alert/alert.ts new file mode 100644 index 0000000..45e030f --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/alert/alert.ts @@ -0,0 +1,21 @@ +import { Component, HostBinding, input } from '@angular/core'; + +export type AlertSeverity = 'success' | 'warning' | 'danger'; + +@Component({ + selector: 'ev-alert', + standalone: true, + templateUrl: './alert.html', + styleUrl: './alert.scss', +}) +export class Alert { + severity = input('danger'); + + @HostBinding('class') + get hostClass(): string { + return `ev-alert ev-alert--${this.severity()}`; + } + + @HostBinding('attr.role') + readonly role = 'alert'; +} diff --git a/apps/frontend/src/app/shared/components/ui/badge/badge.html b/apps/frontend/src/app/shared/components/ui/badge/badge.html new file mode 100644 index 0000000..9dd0cd5 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/badge/badge.html @@ -0,0 +1,3 @@ + + + diff --git a/apps/frontend/src/app/shared/components/ui/badge/badge.scss b/apps/frontend/src/app/shared/components/ui/badge/badge.scss new file mode 100644 index 0000000..212281f --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/badge/badge.scss @@ -0,0 +1,27 @@ +.ev-badge { + display: inline-block; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.02em; + padding: 0.2rem 0.55rem; + border-radius: var(--radius-pill); + color: #fff; + flex-shrink: 0; +} + +.ev-badge--success { + background: var(--color-success); +} + +.ev-badge--warning { + background: var(--color-warning); +} + +.ev-badge--danger { + background: var(--color-danger); +} + +.ev-badge--neutral { + background: var(--color-text-muted); +} diff --git a/apps/frontend/src/app/shared/components/ui/badge/badge.spec.ts b/apps/frontend/src/app/shared/components/ui/badge/badge.spec.ts new file mode 100644 index 0000000..09e1a25 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/badge/badge.spec.ts @@ -0,0 +1,30 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Badge } from './badge'; + +@Component({ + standalone: true, + imports: [Badge], + template: `critique`, +}) +class BadgeHost {} + +describe('Badge', () => { + it('applique le ton neutral par défaut', async () => { + await TestBed.configureTestingModule({ imports: [Badge] }).compileComponents(); + const fixture = TestBed.createComponent(Badge); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.ev-badge').classList).toContain('ev-badge--neutral'); + }); + + it('applique le ton demandé et projette le contenu', async () => { + await TestBed.configureTestingModule({ imports: [BadgeHost] }).compileComponents(); + const fixture = TestBed.createComponent(BadgeHost); + fixture.detectChanges(); + + const el = fixture.nativeElement.querySelector('.ev-badge'); + expect(el.classList).toContain('ev-badge--danger'); + expect(el.textContent).toContain('critique'); + }); +}); diff --git a/apps/frontend/src/app/shared/components/ui/badge/badge.ts b/apps/frontend/src/app/shared/components/ui/badge/badge.ts new file mode 100644 index 0000000..e9cc0ca --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/badge/badge.ts @@ -0,0 +1,13 @@ +import { Component, input } from '@angular/core'; + +export type BadgeTone = 'success' | 'warning' | 'danger' | 'neutral'; + +@Component({ + selector: 'ev-badge', + standalone: true, + templateUrl: './badge.html', + styleUrl: './badge.scss', +}) +export class Badge { + tone = input('neutral'); +} diff --git a/apps/frontend/src/app/shared/components/ui/button/button.html b/apps/frontend/src/app/shared/components/ui/button/button.html new file mode 100644 index 0000000..08ab099 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/button/button.html @@ -0,0 +1,3 @@ + diff --git a/apps/frontend/src/app/shared/components/ui/button/button.scss b/apps/frontend/src/app/shared/components/ui/button/button.scss new file mode 100644 index 0000000..0f33524 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/button/button.scss @@ -0,0 +1,51 @@ +.ev-button { + width: 100%; + padding: 0.7rem; + border: none; + border-radius: var(--radius-sm); + font-size: 0.95rem; + font-weight: 600; + font-family: var(--font-family); + cursor: pointer; + + &:disabled { + cursor: not-allowed; + opacity: 0.7; + } +} + +.ev-button--primary { + background: var(--color-primary); + color: #fff; + + &:disabled { + background: var(--color-disabled); + } + + &:not(:disabled):hover { + background: var(--color-primary-hover); + } +} + +.ev-button--secondary { + background: var(--color-surface); + border: 1px solid var(--color-border); + color: var(--color-label); + + &:not(:disabled):hover { + background: var(--color-bg); + } +} + +.ev-button--danger { + background: var(--color-danger); + color: #fff; + + &:disabled { + background: var(--color-disabled); + } + + &:not(:disabled):hover { + background: #b91c1c; + } +} diff --git a/apps/frontend/src/app/shared/components/ui/button/button.spec.ts b/apps/frontend/src/app/shared/components/ui/button/button.spec.ts new file mode 100644 index 0000000..8362cdc --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/button/button.spec.ts @@ -0,0 +1,50 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Button } from './button'; + +@Component({ + standalone: true, + imports: [Button], + template: `Valider`, +}) +class ButtonHost {} + +describe('Button', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [Button] }).compileComponents(); + }); + + it('applique la classe de la variante primary par défaut', () => { + const fixture = TestBed.createComponent(Button); + fixture.detectChanges(); + + const button = fixture.nativeElement.querySelector('button'); + expect(button.classList).toContain('ev-button--primary'); + }); + + it('applique la classe de la variante demandée', () => { + const fixture = TestBed.createComponent(Button); + fixture.componentRef.setInput('variant', 'danger'); + fixture.detectChanges(); + + const button = fixture.nativeElement.querySelector('button'); + expect(button.classList).toContain('ev-button--danger'); + }); + + it('désactive le bouton natif quand disabled est vrai', () => { + const fixture = TestBed.createComponent(Button); + fixture.componentRef.setInput('disabled', true); + fixture.detectChanges(); + + const button = fixture.nativeElement.querySelector('button'); + expect(button.disabled).toBe(true); + }); + + it('projette le contenu', async () => { + await TestBed.configureTestingModule({ imports: [ButtonHost] }).compileComponents(); + const fixture = TestBed.createComponent(ButtonHost); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('button').textContent).toContain('Valider'); + }); +}); diff --git a/apps/frontend/src/app/shared/components/ui/button/button.ts b/apps/frontend/src/app/shared/components/ui/button/button.ts new file mode 100644 index 0000000..c9d6b11 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/button/button.ts @@ -0,0 +1,15 @@ +import { Component, input } from '@angular/core'; + +export type ButtonVariant = 'primary' | 'secondary' | 'danger'; + +@Component({ + selector: 'ev-button', + standalone: true, + templateUrl: './button.html', + styleUrl: './button.scss', +}) +export class Button { + variant = input('primary'); + type = input<'button' | 'submit'>('button'); + disabled = input(false); +} diff --git a/apps/frontend/src/app/shared/components/ui/card/card.html b/apps/frontend/src/app/shared/components/ui/card/card.html new file mode 100644 index 0000000..6dbc743 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/card/card.html @@ -0,0 +1 @@ + diff --git a/apps/frontend/src/app/shared/components/ui/card/card.scss b/apps/frontend/src/app/shared/components/ui/card/card.scss new file mode 100644 index 0000000..09307fb --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/card/card.scss @@ -0,0 +1,10 @@ +:host { + background: var(--color-surface); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); + padding: var(--space-5); + box-shadow: var(--shadow-card); + display: flex; + flex-direction: column; + box-sizing: border-box; +} diff --git a/apps/frontend/src/app/shared/components/ui/card/card.spec.ts b/apps/frontend/src/app/shared/components/ui/card/card.spec.ts new file mode 100644 index 0000000..cea71d9 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/card/card.spec.ts @@ -0,0 +1,22 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Card } from './card'; + +@Component({ + standalone: true, + imports: [Card], + template: `

    Contenu

    `, +}) +class CardHost {} + +describe('Card', () => { + it('projette son contenu', async () => { + await TestBed.configureTestingModule({ imports: [CardHost] }).compileComponents(); + const fixture = TestBed.createComponent(CardHost); + fixture.detectChanges(); + + const card = fixture.nativeElement.querySelector('ev-card'); + expect(card).toBeTruthy(); + expect(card.textContent).toContain('Contenu'); + }); +}); diff --git a/apps/frontend/src/app/shared/components/ui/card/card.ts b/apps/frontend/src/app/shared/components/ui/card/card.ts new file mode 100644 index 0000000..50c496c --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/card/card.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ev-card', + standalone: true, + templateUrl: './card.html', + styleUrl: './card.scss', +}) +export class Card {} diff --git a/apps/frontend/src/styles.scss b/apps/frontend/src/styles.scss index 90d4ee0..0d45801 100644 --- a/apps/frontend/src/styles.scss +++ b/apps/frontend/src/styles.scss @@ -1 +1,8 @@ -/* You can add global styles to this file, and also import other style files */ +@use 'styles/tokens'; +@use 'styles/forms'; + +body { + margin: 0; + font-family: var(--font-family); + color: var(--color-text); +} diff --git a/apps/frontend/src/styles/_forms.scss b/apps/frontend/src/styles/_forms.scss new file mode 100644 index 0000000..fc35ad4 --- /dev/null +++ b/apps/frontend/src/styles/_forms.scss @@ -0,0 +1,37 @@ +.form-label { + display: block; + font-size: 0.85rem; + font-weight: 600; + color: var(--color-label); + margin-bottom: var(--space-1); + margin-top: var(--space-3); +} + +.form-input { + width: 100%; + padding: var(--space-2) 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: 0.95rem; + font-family: var(--font-family); + box-sizing: border-box; + + &:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.15); + } +} + +.form-hint { + display: block; + font-size: 0.75rem; + color: var(--color-disabled); + margin-top: 0.25rem; +} + +.form-error { + margin: var(--space-2) 0 0; + color: var(--color-danger); + font-size: 0.85rem; +} diff --git a/apps/frontend/src/styles/_tokens.scss b/apps/frontend/src/styles/_tokens.scss new file mode 100644 index 0000000..4b85e61 --- /dev/null +++ b/apps/frontend/src/styles/_tokens.scss @@ -0,0 +1,39 @@ +:root { + // Marque (dérivé du logo : vert feuille/éclair, halo) + --color-primary: #16a34a; + --color-primary-hover: #15803d; + --color-primary-light: #dcfce7; + + // Neutres (texte, bordures, fonds) + --color-text: #1f2937; + --color-text-muted: #6b7280; + --color-label: #374151; + --color-border: #d1d5db; + --color-border-light: #e5e7eb; + --color-bg: #f3f4f6; + --color-surface: #ffffff; + --color-disabled: #9ca3af; + + // Sémantique (statuts, alertes) + --color-success: #16a34a; + --color-success-bg: #dcfce7; + --color-warning: #f9a825; + --color-warning-bg: #fef9e7; + --color-danger: #dc2626; + --color-danger-bg: #fef2f2; + --color-danger-border: #fecaca; + + // Typo, rayons, ombre + --font-family: 'Segoe UI', system-ui, sans-serif; + --radius-sm: 8px; + --radius-md: 12px; + --radius-pill: 999px; + --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06); + + // Espacements + --space-1: 0.35rem; + --space-2: 0.6rem; + --space-3: 1rem; + --space-4: 1.5rem; + --space-5: 2.5rem; +} diff --git a/docs/architecture/32-design-systeme-frontend.md b/docs/architecture/32-design-systeme-frontend.md new file mode 100644 index 0000000..d2324e9 --- /dev/null +++ b/docs/architecture/32-design-systeme-frontend.md @@ -0,0 +1,76 @@ +# Design système frontend + +Ce que toute nouvelle page ou tout nouveau composant Angular doit réutiliser, plutôt que +redéfinir ses propres couleurs, rayons ou espacements en dur. Contexte : issue +[#91](https://github.com/ineszang/ProjetPiscine_EnerVision/issues/91), née d'une incohérence +visuelle accumulée page après page (aucun jeton partagé n'existait avant ce chantier). + +## Tokens + +Déclarés en CSS custom properties dans `apps/frontend/src/styles/_tokens.scss`, importés une +seule fois dans `src/styles.scss`. Disponibles partout sans import supplémentaire. + +| Variable | Rôle | +|---|---| +| `--color-primary`, `--color-primary-hover`, `--color-primary-light` | Couleur de marque (vert, dérivé du logo), actions principales | +| `--color-text`, `--color-text-muted`, `--color-label` | Hiérarchie de texte (titres, texte secondaire, labels de formulaire) | +| `--color-border`, `--color-border-light` | Bordures d'inputs et de cartes | +| `--color-bg`, `--color-surface` | Fond de page vs fond des cartes/panneaux | +| `--color-disabled` | Éléments désactivés | +| `--color-success` / `-bg`, `--color-warning` / `-bg`, `--color-danger` / `-bg` / `-border` | États sémantiques (alertes, badges) | +| `--font-family` | Police unique de l'application | +| `--radius-sm`, `--radius-md`, `--radius-pill` | Rayons de bordure (input/bouton, carte, pastille) | +| `--shadow-card` | Ombre portée des cartes | +| `--space-1` à `--space-5` | Échelle d'espacement (0.35rem à 2.5rem) | + +Les classes de formulaire partagées (`.form-label`, `.form-input`, `.form-hint`, `.form-error`) +sont dans `apps/frontend/src/styles/_forms.scss`, importées globalement de la même façon. Elles +s'appliquent directement à des `
} + + @if (predictions().length > 0) { +
+

Prévisions de consommation

+
    + @for (site of predictions(); track site.site_id) { +
  • + {{ site.site_name }} + @if (site.prediction; as prediction) { + @if (prediction.status === 'available') { + + {{ prediction.predicted_value | number: '1.0-1' }} kWh + à {{ prediction.target_at | date: 'HH:mm' }} + + } @else { + {{ + prediction.status === 'insufficient_data' ? 'Historique insuffisant' : 'Erreur' + }} + } + } @else { + Pas encore de prévision + } +
  • + } +
+
+ }
diff --git a/apps/frontend/src/app/features/dashboard/dashboard.scss b/apps/frontend/src/app/features/dashboard/dashboard.scss index 06adf01..8112b51 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.scss +++ b/apps/frontend/src/app/features/dashboard/dashboard.scss @@ -121,3 +121,40 @@ h2 { .alert-item__message { font-size: 0.9rem; } + +.predictions-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.prediction-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.7rem 1rem; + border-radius: var(--radius-md); + background: var(--color-surface); + border: 1px solid var(--color-border-light); +} + +.prediction-item__site { + font-size: 0.9rem; + font-weight: 600; +} + +.prediction-item__value { + font-size: 0.9rem; + font-weight: 600; +} + +.prediction-item__target { + margin-left: 0.35rem; + font-size: 0.8rem; + font-weight: 400; + color: var(--color-text-muted); +} diff --git a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts index 1f285b3..120ff25 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts @@ -4,6 +4,7 @@ import { of, throwError } from 'rxjs'; import { Dashboard } from './dashboard'; import { StatsService } from '../../core/services/stats.service'; import { AlertsService } from '../../core/services/alerts.service'; +import { PredictionsService } from '../../core/services/predictions.service'; import {AuthService} from '../../core/services/auth.service'; import {Router, provideRouter} from '@angular/router'; @@ -17,18 +18,24 @@ vi.mock('chart.js', () => { return { Chart: ChartMock, registerables: [] }; }); +function predictionsMock(sites: unknown[] = []) { + return { getPredictions: vi.fn().mockReturnValue(of({ timestamp: '2026-09-18T09:00:00Z', sites })) }; +} + describe('Dashboard', () => { afterEach(() => vi.useRealTimers()); - it('charge les stats et les alertes au démarrage', async () => { + it('charge les stats, les alertes et les prévisions au démarrage', async () => { const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([{ alert_id: 'A1' }])) }; + const predictions = predictionsMock([{ site_id: 'SITE001', site_name: 'Test', prediction: null }]); TestBed.configureTestingModule({ imports: [Dashboard], providers: [ { provide: StatsService, useValue: statsMock }, { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictions }, provideRouter([]), ], }); @@ -42,7 +49,9 @@ describe('Dashboard', () => { expect(statsMock.getSummary).toHaveBeenCalled(); expect(alertsMock.getAlerts).toHaveBeenCalled(); + expect(predictions.getPredictions).toHaveBeenCalled(); expect(fixture.componentInstance.alerts().length).toBe(1); + expect(fixture.componentInstance.predictions().length).toBe(1); expect(fixture.componentInstance.error()).toBeNull(); }); @@ -61,6 +70,7 @@ describe('Dashboard', () => { providers: [ { provide: StatsService, useValue: statsMock }, { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, provideRouter([]), ], }); @@ -88,6 +98,7 @@ describe('Dashboard', () => { providers: [ { provide: StatsService, useValue: statsMock }, { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, provideRouter([]), ], }); @@ -98,6 +109,30 @@ describe('Dashboard', () => { expect(fixture.componentInstance.alerts().length).toBe(0); }); + it("n'interrompt pas la page quand le chargement des prévisions échoue", () => { + const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; + const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; + const predictions = { + getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))), + }; + + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictions }, + provideRouter([]), + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + fixture.detectChanges(); + + expect(fixture.componentInstance.predictions().length).toBe(0); + expect(fixture.componentInstance.error()).not.toBeNull(); + }); + it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => { const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; @@ -108,6 +143,7 @@ describe('Dashboard', () => { providers: [ { provide: StatsService, useValue: statsMock }, { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, { provide: AuthService, useValue: authMock }, provideRouter([]), ], @@ -137,6 +173,7 @@ describe('Dashboard', () => { providers: [ { provide: StatsService, useValue: statsMock }, { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, { provide: AuthService, useValue: authMock }, provideRouter([]), ], @@ -164,6 +201,7 @@ describe('Dashboard', () => { providers: [ { provide: StatsService, useValue: statsMock }, { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, provideRouter([]), ], }); @@ -179,4 +217,26 @@ describe('Dashboard', () => { dashboard.badgeToneForSeverity('critical'), ); }); + + it('distingue le ton des statuts de prévision', () => { + const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; + const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; + + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, + provideRouter([]), + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + const dashboard = fixture.componentInstance; + + expect(dashboard.badgeToneForPredictionStatus('available')).toBe('success'); + expect(dashboard.badgeToneForPredictionStatus('insufficient_data')).toBe('warning'); + expect(dashboard.badgeToneForPredictionStatus('error')).toBe('danger'); + }); }); diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index e9f9792..d41d258 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -1,15 +1,17 @@ import { Component, OnInit, inject, signal, DestroyRef } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { timer, switchMap, catchError, EMPTY, Observable } from 'rxjs'; -import { DecimalPipe } from '@angular/common'; +import { DecimalPipe, DatePipe } from '@angular/common'; import { Router, RouterLink } from '@angular/router'; import { StatsService } from '../../core/services/stats.service'; import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge'; import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart'; import { AlertsService } from '../../core/services/alerts.service'; +import { PredictionsService } from '../../core/services/predictions.service'; import { AuthService } from '../../core/services/auth.service'; import { StatsSummary } from '../../shared/models/stats.model'; import { Alert, AlertSeverity } from '../../shared/models/alert.model'; +import { PredictionStatus, SitePredictionSummary } from '../../shared/models/prediction.model'; import { Card } from '../../shared/components/ui/card/card'; import { Alert as EvAlert } from '../../shared/components/ui/alert/alert'; import { Badge, BadgeTone } from '../../shared/components/ui/badge/badge'; @@ -27,11 +29,21 @@ const TON_PAR_SEVERITE: Record = { critical: 'critical', }; +// `error` n'a pas de précédent dans les fixtures ou l'API à ce jour, mais figure dans le +// domaine du schéma backend (`ck_prediction_status`) : mieux vaut une couleur définie que +// tomber sur `undefined` si ce statut apparaît un jour. +const TON_PAR_STATUT_PREDICTION: Record = { + available: 'success', + insufficient_data: 'warning', + error: 'danger', +}; + @Component({ selector: 'app-dashboard', standalone: true, imports: [ DecimalPipe, + DatePipe, RouterLink, ConsumptionGauge, SiteLoadChart, @@ -47,12 +59,14 @@ const TON_PAR_SEVERITE: Record = { export class Dashboard implements OnInit { private statsService = inject(StatsService); private alertsService = inject(AlertsService); + private predictionsService = inject(PredictionsService); private auth = inject(AuthService); private router = inject(Router); private destroyRef = inject(DestroyRef); stats = signal(null); alerts = signal([]); + predictions = signal([]); error = signal(null); ngOnInit(): void { @@ -61,6 +75,13 @@ export class Dashboard implements OnInit { .pipe(catchError(() => this.reportUnavailable())) .subscribe((alerts) => this.alerts.set(alerts)); + // Les prévisions viennent d'un scoring hors ligne, pas d'un calcul à la demande : un seul + // chargement au démarrage suffit, pas besoin du rafraîchissement périodique de `stats`. + this.predictionsService + .getPredictions() + .pipe(catchError(() => this.reportUnavailable())) + .subscribe((summary) => this.predictions.set(summary.sites)); + // Piège : le catchError porte sur l'observable interne. Sur le flux externe il // terminerait le timer, et le rafraîchissement ne repartirait jamais. timer(0, REFRESH_INTERVAL_MS) @@ -80,6 +101,10 @@ export class Dashboard implements OnInit { return TON_PAR_SEVERITE[severity]; } + badgeToneForPredictionStatus(status: PredictionStatus): BadgeTone { + return TON_PAR_STATUT_PREDICTION[status]; + } + onLogout(): void { this.auth.logout().subscribe({ next: () => this.router.navigate(['/login']), diff --git a/apps/frontend/src/app/shared/models/prediction.model.ts b/apps/frontend/src/app/shared/models/prediction.model.ts new file mode 100644 index 0000000..50d726a --- /dev/null +++ b/apps/frontend/src/app/shared/models/prediction.model.ts @@ -0,0 +1,24 @@ +export type PredictionStatus = 'available' | 'insufficient_data' | 'error'; +export type PredictionTargetMetric = 'consumption_kwh' | 'consumption_kw'; + +export interface SitePrediction { + target_at: string; + target_metric: PredictionTargetMetric; + period_minutes: number | null; + predicted_value: number | null; + status: PredictionStatus; + failure_reason: string | null; + model_reference: string; + created_at: string; +} + +export interface SitePredictionSummary { + site_id: string; + site_name: string; + prediction: SitePrediction | null; +} + +export interface PredictionSummary { + timestamp: string; + sites: SitePredictionSummary[]; +} diff --git a/docs/architecture/30-frontend.md b/docs/architecture/30-frontend.md index de73599..b7f6d10 100644 --- a/docs/architecture/30-frontend.md +++ b/docs/architecture/30-frontend.md @@ -13,24 +13,28 @@ Ce qui est en place : - `app.config.ts` fournit `provideBrowserGlobalErrorListeners()`, `provideRouter(routes)` et `provideHttpClient(withInterceptors([mockApiInterceptor]))`. - Une route `/dashboard` en composant différé, et une redirection depuis la racine. -- `core/services` porte `StatsService` et `AlertsService`, `core/interceptors` l'intercepteur de - fixtures, `features/dashboard` la page, `shared/components` la jauge de consommation et le +- `core/services` porte `StatsService`, `AlertsService`, `PredictionsService`, `SitesService` et + `AuthService`, `core/interceptors` l'intercepteur de fixtures et l'intercepteur d'authentification + (jeton porteur, rafraîchissement sur 401), `core/guards` la garde de route `authGuard`, + `features/dashboard` la page principale, `shared/components` la jauge de consommation et le graphique de charge par site, tous deux construits sur Chart.js. +- Une authentification complète côté interface : connexion, mot de passe oublié/réinitialisation, + changement de mot de passe, garde de route sur `/dashboard` et `/sites`. Détail : + [31-contrat-authentification.md](31-contrat-authentification.md). - Un système de design partagé (`shared/components/ui/` : `ev-button`, `ev-card`, `ev-alert`, `ev-badge`, `ev-brand`, tokens CSS dans `styles/_tokens.scss`) que toute nouvelle page doit réutiliser plutôt que redéfinir ses propres styles. Détail : [32-design-systeme-frontend.md](32-design-systeme-frontend.md). - L'état vit dans des signaux, sans bibliothèque dédiée. -- Vitest via le builder `@angular/build:unit-test`, couverture activée, sept fichiers de test. +- Vitest via le builder `@angular/build:unit-test`, couverture activée. - Prettier configuré, parser `angular` pour les gabarits HTML. Ce qui n'existe pas encore : -- **Aucun endpoint réel derrière l'écran.** `GET /api/v1/stats/summary` et `GET /api/v1/alerts` - sont servis par l'intercepteur ; l'API expose `/health`, `/auth` et `/users`, rien d'autre. -- Aucune authentification côté interface : ni garde de route, ni intercepteur de jeton, alors que - les routes métier de l'API en exigent un. Voir - [31-contrat-authentification.md](31-contrat-authentification.md). +- **Aucun endpoint métier réel derrière l'écran du tableau de bord.** `GET /api/v1/stats/summary`, + `GET /api/v1/alerts` et `GET /api/v1/predictions` sont servis par l'intercepteur de fixtures ; + l'API expose bien ces routes désormais, mais rien ne bascule `useMockFixtures` à `false` en + développement pour les consommer réellement. - Aucun état de chargement : tant que la première réponse n'est pas arrivée, la page reste vide. - Aucun lint : ESLint n'est pas installé. @@ -81,22 +85,20 @@ sequenceDiagram S-->>C: modèle typé ``` -`mockApiInterceptor` n'intercepte que `/stats/summary` et `/alerts`, et seulement si -`environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en -production : toute autre requête, et toutes les requêtes en production, suivent le chemin réel. +`mockApiInterceptor` n'intercepte que `/stats/summary`, `/alerts` et `/predictions`, et seulement +si `environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en +production : toute autre requête (dont tout ce qui touche `/auth`), et toutes les requêtes en +production, suivent le chemin réel. En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un `apiUrl` relatif, `/api/v1`. -En production, il n'y a pas de proxy : `environment.ts` porte une URL absolue. Angular substitue -le fichier via `fileReplacements`, et la configuration `production` est celle par défaut. - -**Dette connue.** `src/environments/environment.ts`, qui est la configuration de production, -pointe `http://localhost:8000/api/v1` en dur. La valeur est celle du poste de développement : -telle quelle, un build de production ne joindra jamais l'API. À corriger avant le premier -déploiement, en même temps que sera tranchée la question de l'ingress dans -[10-infra.md](10-infra.md). +En production, il n'y a pas de proxy, mais `environment.ts` porte lui aussi un `apiUrl` relatif +(`/api/v1`) plutôt qu'une URL absolue : la dette qui pointait en dur sur +`http://localhost:8000/api/v1` a été corrigée. Un build de production sert donc l'appel `/api/v1/...` +sur son propre origin, ce qui suppose qu'un ingress ou un reverse proxy route `/api` vers le +backend une fois déployé — question toujours ouverte dans [10-infra.md](10-infra.md). ## Exécution @@ -124,9 +126,10 @@ avec un service statique, il reste à écrire. ## Sécurité - Le frontend ne détient aucun secret : `environment.ts` ne porte qu'une URL. -- L'authentification existe côté API mais pas côté interface : aucune garde de route, aucun - intercepteur de jeton. `core/guards` reste à créer, `core/interceptors` n'héberge aujourd'hui - que les fixtures. +- L'authentification existe des deux côtés désormais : `authGuard` protège `/dashboard` et + `/sites`, `authInterceptor` pose le jeton porteur sur les requêtes sortantes et déclenche le + rafraîchissement sur 401. Détail complet dans + [31-contrat-authentification.md](31-contrat-authentification.md). ## Tests From 12fb8860d1cd82a6925f0cb7217eb3e3fa7089f2 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Fri, 18 Sep 2026 11:26:45 +0200 Subject: [PATCH 58/87] 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 59/87] =?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 60/87] =?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 61/87] =?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 62/87] =?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 63/87] 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 64/87] =?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 65/87] =?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 66/87] 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 67/87] 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 68/87] 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 69/87] 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 70/87] 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 71/87] 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 72/87] =?UTF-8?q?fix(frontend):=20traite=20la=20revue=20de?= =?UTF-8?q?=20phyri0s=20sur=20la=20vue=20d=C3=A9tail=20d'un=20site?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quatre points portant sur le code de cette PR : - L'échec de chargement laissait à l'écran le site précédemment affiché sous le bandeau d'erreur : `reportUnavailable()` vide désormais site, mesure et historique, pour qu'on ne lise pas les chiffres de A en croyant regarder B. - L'historique était tracé à rebours : l'API trie en timestamp décroissant (`ReadingRepository.list_history`), le graphique rétablit la chronologie. - Une consommation `null` (panne capteur) alimentait la jauge avec un 0, indiscernable d'un site qui ne consomme rien : la jauge n'est plus montée dans ce cas, la raison de l'absence est affichée à la place. Une consommation réellement mesurée à 0 continue d'afficher la jauge. - `getSite` et `getCurrent` ne dépendent pas l'un de l'autre : `forkJoin` économise un aller-retour en série à chaque ouverture de la page. Co-Authored-By: Claude Opus 5 (1M context) --- .../sites/site-detail/site-detail.html | 19 +++-- .../sites/site-detail/site-detail.scss | 5 ++ .../sites/site-detail/site-detail.spec.ts | 80 +++++++++++++++++++ .../features/sites/site-detail/site-detail.ts | 32 ++++++-- .../reading-history-chart.spec.ts | 27 ++++++- .../reading-history-chart.ts | 35 +++++--- 6 files changed, 172 insertions(+), 26 deletions(-) diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.html b/apps/frontend/src/app/features/sites/site-detail/site-detail.html index e95ac75..9c4a4bc 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.html +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.html @@ -36,13 +36,18 @@
Consommation vs capacité - - - {{ current()?.consumption_kw ?? '-' }} / {{ s.capacity_kw ?? '-' }} kW - + @let consumption = consumptionKw(); + @if (consumption !== null) { + + + {{ consumptionLabel() }} / {{ s.capacity_kw ?? '-' }} kW + + } @else { +

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

+ }
diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.scss b/apps/frontend/src/app/features/sites/site-detail/site-detail.scss index 78dd9ed..a0032be 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.scss +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.scss @@ -74,6 +74,11 @@ font-weight: 700; } +.card__unavailable { + color: var(--color-text-muted); + margin: 0; +} + .metrics-grid { display: grid; grid-template-columns: repeat(2, 1fr); diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts b/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts index 3fdc269..5e3cf46 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts @@ -116,6 +116,86 @@ describe('SiteDetail', () => { expect(fixture.componentInstance.site()).toBeNull(); }); + it('efface les données du site précédent quand le chargement du suivant échoue', () => { + const getSite = vi + .fn() + .mockReturnValueOnce(of(SITE)) + .mockReturnValueOnce(throwError(() => new Error('404'))); + const { fixture, paramMap } = setup( + 'SITE001', + { getSite, getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)) }, + { getHistory: vi.fn().mockReturnValue(of([LECTURE])) }, + ); + + fixture.detectChanges(); + expect(fixture.componentInstance.site()?.site_id).toBe('SITE001'); + + paramMap.next(convertToParamMap({ siteId: 'SITE002' })); + fixture.detectChanges(); + + expect(fixture.componentInstance.error()).not.toBeNull(); + expect(fixture.componentInstance.site()).toBeNull(); + expect(fixture.componentInstance.current()).toBeNull(); + expect(fixture.componentInstance.history()).toEqual([]); + expect(fixture.nativeElement.textContent).not.toContain('Site 1'); + }); + + it('interroge le site et sa mesure courante en parallèle', () => { + const getSite = vi.fn().mockReturnValue(of(SITE)); + const getCurrent = vi.fn().mockReturnValue(of(CURRENT_COMPLET)); + const { fixture } = setup( + 'SITE001', + { getSite, getCurrent }, + { getHistory: vi.fn().mockReturnValue(of([])) }, + ); + + fixture.detectChanges(); + + expect(getSite).toHaveBeenCalledWith('SITE001'); + expect(getCurrent).toHaveBeenCalledWith('SITE001'); + }); + + it('signale la panne du capteur de consommation au lieu de tracer une jauge à zéro', () => { + const sansConsommation = { + ...CURRENT_COMPLET, + consumption_kw: null, + null_reasons: ['consumption_sensor_failure'], + data_quality: 'partial' as const, + }; + const { fixture } = setup( + 'SITE001', + { + getSite: vi.fn().mockReturnValue(of(SITE)), + getCurrent: vi.fn().mockReturnValue(of(sansConsommation)), + }, + { getHistory: vi.fn().mockReturnValue(of([LECTURE])) }, + ); + + fixture.detectChanges(); + + expect(fixture.componentInstance.consumptionKw()).toBeNull(); + expect(fixture.componentInstance.consumptionReason()).toBe('capteur de consommation en panne'); + expect(fixture.nativeElement.querySelector('app-consumption-gauge')).toBeNull(); + expect(fixture.nativeElement.textContent).toContain('Consommation indisponible'); + }); + + it('trace la jauge pour une consommation nulle réellement mesurée', () => { + const { fixture } = setup( + 'SITE001', + { + getSite: vi.fn().mockReturnValue(of(SITE)), + getCurrent: vi.fn().mockReturnValue(of({ ...CURRENT_COMPLET, consumption_kw: 0 })), + }, + { getHistory: vi.fn().mockReturnValue(of([LECTURE])) }, + ); + + fixture.detectChanges(); + + expect(fixture.componentInstance.consumptionLabel()).toBe('0.0 kW'); + expect(fixture.nativeElement.querySelector('app-consumption-gauge')).not.toBeNull(); + expect(fixture.nativeElement.textContent).not.toContain('Consommation indisponible'); + }); + it('affiche explicitement les champs null avec leur raison plutôt que de les masquer', () => { const partielle = { ...CURRENT_COMPLET, diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.ts b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts index 047614a..21e716d 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.ts +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts @@ -1,7 +1,7 @@ import { Component, DestroyRef, computed, inject, signal } from '@angular/core'; import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, RouterLink } from '@angular/router'; -import { catchError, EMPTY, filter, map, Observable, of, switchMap } from 'rxjs'; +import { catchError, EMPTY, filter, forkJoin, map, Observable, of, switchMap } from 'rxjs'; import { SitesService } from '../../../core/services/sites.service'; import { ReadingsService } from '../../../core/services/readings.service'; import { Site } from '../../../shared/models/site.model'; @@ -52,8 +52,14 @@ interface MetricDef { format: (value: number) => string; } +const CONSUMPTION_DEF: MetricDef = { + key: 'consumption_kw', + label: 'Consommation', + format: (v) => `${v.toFixed(1)} kW`, +}; + const METRIC_DEFS: MetricDef[] = [ - { key: 'consumption_kw', label: 'Consommation', format: (v) => `${v.toFixed(1)} kW` }, + CONSUMPTION_DEF, { key: 'voltage_v', label: 'Tension', format: (v) => `${v.toFixed(1)} V` }, { key: 'current_a', label: 'Courant', format: (v) => `${v.toFixed(1)} A` }, { key: 'power_factor', label: 'Cos φ', format: (v) => v.toFixed(2) }, @@ -111,6 +117,15 @@ export class SiteDetail { hasMeasurement = computed(() => this.current()?.timestamp != null); + consumptionKw = computed(() => this.current()?.consumption_kw ?? null); + + consumptionLabel = computed(() => { + const kw = this.consumptionKw(); + return kw != null ? CONSUMPTION_DEF.format(kw) : null; + }); + + consumptionReason = computed(() => this.reasonFor('consumption_kw', this.current())); + qualityLabel = computed(() => { const quality = this.current()?.data_quality; return quality ? LIBELLE_PAR_QUALITE[quality] : null; @@ -156,10 +171,10 @@ export class SiteDetail { } private load(siteId: string) { - return this.sitesService.getSite(siteId).pipe( - switchMap((site) => - this.sitesService.getCurrent(siteId).pipe(map((current) => ({ site, current }))), - ), + return forkJoin({ + site: this.sitesService.getSite(siteId), + current: this.sitesService.getCurrent(siteId), + }).pipe( switchMap(({ site, current }) => this.loadHistory(siteId, current).pipe(map((history) => ({ site, current, history }))), ), @@ -186,8 +201,13 @@ export class SiteDetail { return trouvees.length > 0 ? trouvees.join(', ') : 'cause inconnue'; } + // Piège : vider les signaux avec l'erreur, sinon la page garde le site précédemment chargé + // sous le bandeau et laisse lire les chiffres de A en croyant regarder B. private reportUnavailable(): Observable { this.error.set(UNAVAILABLE_MESSAGE); + this.site.set(null); + this.current.set(null); + this.history.set([]); return EMPTY; } } diff --git a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.spec.ts b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.spec.ts index bb6232f..63be883 100644 --- a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.spec.ts +++ b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.spec.ts @@ -9,15 +9,21 @@ vi.mock('chart.js', () => { static register = vi.fn(); update = vi.fn(); destroy = vi.fn(); - data = { datasets: [{}] }; - constructor() { + data: { labels?: unknown[]; datasets: Record[] } = { datasets: [{}] }; + constructor(_canvas: unknown, config?: { data?: ChartMock['data'] }) { + if (config?.data) { + this.data = config.data; + } ChartMock.instances.push(this); } } return { Chart: ChartMock, registerables: [] }; }); -type ChartDouble = { destroy: ReturnType }; +type ChartDouble = { + destroy: ReturnType; + data: { labels?: unknown[]; datasets: Record[] }; +}; function lastChart(): ChartDouble | undefined { return (Chart as unknown as { instances: ChartDouble[] }).instances.at(-1); @@ -66,6 +72,21 @@ describe('ReadingHistoryChart', () => { expect(() => fixture.detectChanges()).not.toThrow(); }); + it("trace du plus ancien au plus récent, quel que soit l'ordre reçu de l'API", () => { + TestBed.configureTestingModule({ imports: [ReadingHistoryChart] }); + const fixture = TestBed.createComponent(ReadingHistoryChart); + // L'API trie en timestamp décroissant : le composant doit rétablir la chronologie. + fixture.componentRef.setInput('readings', [ + { ...READING, reading_id: 2, timestamp: '2026-09-17T11:00:00Z', consumption_kw: 60 }, + { ...READING, reading_id: 1, timestamp: '2026-09-17T10:00:00Z', consumption_kw: 42 }, + ]); + fixture.detectChanges(); + + const chart = lastChart(); + expect(chart?.data.labels).toEqual(['2026-09-17T10:00:00Z', '2026-09-17T11:00:00Z']); + expect(chart?.data.datasets[0]['data']).toEqual([42, 60]); + }); + it('détruit le graphique quand le composant est détruit', () => { TestBed.configureTestingModule({ imports: [ReadingHistoryChart] }); const fixture = TestBed.createComponent(ReadingHistoryChart); diff --git a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.ts b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.ts index 922ca01..17d1e06 100644 --- a/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.ts +++ b/apps/frontend/src/app/shared/components/reading-history-chart/reading-history-chart.ts @@ -20,8 +20,23 @@ const QUALITY_COLORS: Record = { }; const UNKNOWN_QUALITY_COLOR = '#9ca3af'; -function pointColors(readings: Reading[]): string[] { - return readings.map((r) => (r.data_quality ? QUALITY_COLORS[r.data_quality] : UNKNOWN_QUALITY_COLOR)); +interface ChartSeries { + labels: string[]; + values: number[]; + colors: string[]; +} + +// Piège : l'API renvoie les lectures du plus récent au plus ancien (ReadingRepository.list_history +// trie en timestamp desc) ; sans ce tri l'axe des abscisses se lirait à rebours. +function toSeries(readings: Reading[]): ChartSeries { + const ordered = [...readings].sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); + return { + labels: ordered.map((r) => r.timestamp), + values: ordered.map((r) => r.consumption_kw ?? 0), + colors: ordered.map((r) => + r.data_quality ? QUALITY_COLORS[r.data_quality] : UNKNOWN_QUALITY_COLOR, + ), + }; } @Component({ @@ -38,27 +53,27 @@ export class ReadingHistoryChart implements AfterViewInit, OnDestroy { constructor() { effect(() => { - const readings = this.readings(); + const series = toSeries(this.readings()); if (this.chart) { - this.chart.data.labels = readings.map((r) => r.timestamp); - this.chart.data.datasets[0].data = readings.map((r) => r.consumption_kw ?? 0); - this.chart.data.datasets[0].pointBackgroundColor = pointColors(readings); + this.chart.data.labels = series.labels; + this.chart.data.datasets[0].data = series.values; + this.chart.data.datasets[0].pointBackgroundColor = series.colors; this.chart.update('none'); } }); } ngAfterViewInit(): void { - const readings = this.readings(); + const series = toSeries(this.readings()); this.chart = new Chart(this.canvasRef.nativeElement, { type: 'line', data: { - labels: readings.map((r) => r.timestamp), + labels: series.labels, datasets: [ { - data: readings.map((r) => r.consumption_kw ?? 0), + data: series.values, borderColor: '#3b82f6', - pointBackgroundColor: pointColors(readings), + pointBackgroundColor: series.colors, tension: 0.25, }, ], From eb4291b10a7da75efd0d9fd9a2c6198ab5d9be05 Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 18 Sep 2026 14:58:39 +0200 Subject: [PATCH 73/87] fix(ml,backend,frontend): borne la peremption des predictions et isole les erreurs par flux --- apps/backend/tests/api/test_predictions.py | 11 +- .../src/app/core/mocks/predictions.fixture.ts | 99 ----------------- .../src/app/features/dashboard/dashboard.html | 10 +- .../app/features/dashboard/dashboard.spec.ts | 42 ++++++- .../src/app/features/dashboard/dashboard.ts | 32 ++++-- docs/architecture/00-vue-ensemble.md | 2 +- docs/architecture/20-backend.md | 13 --- docs/architecture/30-frontend.md | 19 ++-- ml/enervision_ml/data.py | 5 + ml/enervision_ml/score.py | 105 ++++++++++++------ ml/tests/test_data.py | 71 +++++++----- ml/tests/test_score.py | 51 ++++++++- 12 files changed, 258 insertions(+), 202 deletions(-) delete mode 100644 apps/frontend/src/app/core/mocks/predictions.fixture.ts diff --git a/apps/backend/tests/api/test_predictions.py b/apps/backend/tests/api/test_predictions.py index cbc1cb6..184afc6 100644 --- a/apps/backend/tests/api/test_predictions.py +++ b/apps/backend/tests/api/test_predictions.py @@ -15,11 +15,14 @@ TARGET_AT = datetime(2026, 9, 16, 13, 0, tzinfo=UTC) CREATED_AT = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) -def principal(role: Role = Role.LECTEUR) -> Principal: +def lecteur() -> Principal: + # Le garde-fou de rôle (`lecteur` minimum) est déjà couvert par l'ensemble `ROUTES_A_ROLE` + # de `tests/api/test_openapi.py` : pas besoin ici d'un paramètre de rôle jamais appelé avec + # autre chose que sa valeur par défaut. return Principal( id=uuid4(), - email=f"{role.value}@enervision.fr", - role=role, + email="lecteur@enervision.fr", + role=Role.LECTEUR, kind=AccountKind.HUMAIN, must_change_password=False, ) @@ -57,7 +60,7 @@ def servi(app: FastAPI) -> Iterator[Callable[[], FauxService]]: def installe() -> FauxService: service = FauxService() app.dependency_overrides[get_prediction_service] = lambda: service - app.dependency_overrides[get_current_principal] = lambda: principal() + app.dependency_overrides[get_current_principal] = lambda: lecteur() return service yield installe diff --git a/apps/frontend/src/app/core/mocks/predictions.fixture.ts b/apps/frontend/src/app/core/mocks/predictions.fixture.ts deleted file mode 100644 index 5397e27..0000000 --- a/apps/frontend/src/app/core/mocks/predictions.fixture.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { PredictionSummary } from '../../shared/models/prediction.model'; - -export const PREDICTIONS_FIXTURE: PredictionSummary = { - timestamp: '2026-09-18T09:00:00Z', - sites: [ - { - site_id: 'SITE001', - site_name: 'Bureau Paris La Défense', - prediction: { - target_at: '2026-09-18T10:00:00Z', - target_metric: 'consumption_kwh', - period_minutes: 60, - predicted_value: 89.2, - status: 'available', - failure_reason: null, - model_reference: 'lightgbm-16b431449a50', - created_at: '2026-09-18T09:00:00Z', - }, - }, - { - site_id: 'SITE002', - site_name: 'Usine Lyon Vénissieux', - prediction: { - target_at: '2026-09-18T10:00:00Z', - target_metric: 'consumption_kwh', - period_minutes: 60, - predicted_value: 561.4, - status: 'available', - failure_reason: null, - model_reference: 'lightgbm-16b431449a50', - created_at: '2026-09-18T09:00:00Z', - }, - }, - { - site_id: 'SITE003', - site_name: 'Data Center Marseille', - prediction: { - target_at: '2026-09-18T10:00:00Z', - target_metric: 'consumption_kwh', - period_minutes: 60, - predicted_value: null, - status: 'insufficient_data', - failure_reason: - "Historique insuffisant : moins de 168h de consumption_kwh disponibles pour ce site.", - model_reference: 'lightgbm-16b431449a50', - created_at: '2026-09-18T09:00:00Z', - }, - }, - { - site_id: 'SITE004', - site_name: 'Bureau Bordeaux', - prediction: { - target_at: '2026-09-18T10:00:00Z', - target_metric: 'consumption_kwh', - period_minutes: 60, - predicted_value: 58.9, - status: 'available', - failure_reason: null, - model_reference: 'lightgbm-16b431449a50', - created_at: '2026-09-18T09:00:00Z', - }, - }, - { - site_id: 'SITE005', - site_name: 'Usine Toulouse', - prediction: { - target_at: '2026-09-18T10:00:00Z', - target_metric: 'consumption_kwh', - period_minutes: 60, - predicted_value: 402.7, - status: 'available', - failure_reason: null, - model_reference: 'lightgbm-16b431449a50', - created_at: '2026-09-18T09:00:00Z', - }, - }, - { - site_id: 'SITE006', - site_name: 'Bureau Lille', - prediction: { - target_at: '2026-09-18T10:00:00Z', - target_metric: 'consumption_kwh', - period_minutes: 60, - predicted_value: 91.3, - status: 'available', - failure_reason: null, - model_reference: 'lightgbm-16b431449a50', - created_at: '2026-09-18T09:00:00Z', - }, - }, - { - // Illustre le cas d'un site jamais scoré : `prediction` reste `null`, pas un statut inventé - // (même contrat que `PredictionService.summary()` côté backend). - site_id: 'SITE007', - site_name: 'Data Center Nantes', - prediction: null, - }, - ], -}; diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index d3c5a87..bd5f927 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -21,7 +21,13 @@ - @if (error(); as message) { + @if (statsError(); as message) { + + } + @if (alertsError(); as message) { + + } + @if (predictionsError(); as message) { } @@ -85,7 +91,7 @@ {{ prediction.predicted_value | number: '1.0-1' }} kWh à {{ prediction.target_at | date: 'HH:mm' }}{{ prediction.target_at | date: "dd/MM 'à' HH:mm" }} } @else { diff --git a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts index 120ff25..5d716ae 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts @@ -52,7 +52,9 @@ describe('Dashboard', () => { expect(predictions.getPredictions).toHaveBeenCalled(); expect(fixture.componentInstance.alerts().length).toBe(1); expect(fixture.componentInstance.predictions().length).toBe(1); - expect(fixture.componentInstance.error()).toBeNull(); + expect(fixture.componentInstance.statsError()).toBeNull(); + expect(fixture.componentInstance.alertsError()).toBeNull(); + expect(fixture.componentInstance.predictionsError()).toBeNull(); }); it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => { @@ -80,13 +82,13 @@ describe('Dashboard', () => { vi.advanceTimersByTime(1); expect(statsMock.getSummary).toHaveBeenCalledTimes(1); - expect(fixture.componentInstance.error()).not.toBeNull(); + expect(fixture.componentInstance.statsError()).not.toBeNull(); expect(fixture.componentInstance.stats()).toBeNull(); vi.advanceTimersByTime(10000); expect(statsMock.getSummary).toHaveBeenCalledTimes(2); expect(fixture.componentInstance.stats()).not.toBeNull(); - expect(fixture.componentInstance.error()).toBeNull(); + expect(fixture.componentInstance.statsError()).toBeNull(); }); it("n'interrompt pas la page quand le chargement des alertes échoue", () => { @@ -107,6 +109,7 @@ describe('Dashboard', () => { fixture.detectChanges(); expect(fixture.componentInstance.alerts().length).toBe(0); + expect(fixture.componentInstance.alertsError()).not.toBeNull(); }); it("n'interrompt pas la page quand le chargement des prévisions échoue", () => { @@ -130,7 +133,38 @@ describe('Dashboard', () => { fixture.detectChanges(); expect(fixture.componentInstance.predictions().length).toBe(0); - expect(fixture.componentInstance.error()).not.toBeNull(); + expect(fixture.componentInstance.predictionsError()).not.toBeNull(); + }); + + it("un rafraîchissement de stats n'efface pas une erreur de prévisions en attente", () => { + vi.useFakeTimers(); + const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; + const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; + const predictions = { + getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))), + }; + + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictions }, + provideRouter([]), + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + fixture.detectChanges(); + + expect(fixture.componentInstance.predictionsError()).not.toBeNull(); + + // Plusieurs cycles de `timer(0, 10_000)` (stats) plus tard, l'erreur des prévisions doit + // toujours être visible : rien ne vient la rafraîchir tant que la section n'est pas rechargée. + vi.advanceTimersByTime(30000); + + expect(fixture.componentInstance.predictionsError()).not.toBeNull(); + expect(fixture.componentInstance.statsError()).toBeNull(); }); it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => { diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index d41d258..3671919 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, inject, signal, DestroyRef } from '@angular/core'; +import { Component, OnInit, inject, signal, DestroyRef, WritableSignal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { timer, switchMap, catchError, EMPTY, Observable } from 'rxjs'; import { DecimalPipe, DatePipe } from '@angular/common'; @@ -67,32 +67,44 @@ export class Dashboard implements OnInit { stats = signal(null); alerts = signal([]); predictions = signal([]); - error = signal(null); + + // Un signal par flux, pas un seul `error` partagé : sinon le tick suivant de `timer` (stats) + // efface silencieusement un message d'échec des prévisions ou des alertes après 10s au plus, + // sans retry ni indication pour l'utilisateur que la section correspondante est restée vide. + statsError = signal(null); + alertsError = signal(null); + predictionsError = signal(null); ngOnInit(): void { this.alertsService .getAlerts() - .pipe(catchError(() => this.reportUnavailable())) - .subscribe((alerts) => this.alerts.set(alerts)); + .pipe(catchError(() => this.reportUnavailable(this.alertsError))) + .subscribe((alerts) => { + this.alertsError.set(null); + this.alerts.set(alerts); + }); // Les prévisions viennent d'un scoring hors ligne, pas d'un calcul à la demande : un seul // chargement au démarrage suffit, pas besoin du rafraîchissement périodique de `stats`. this.predictionsService .getPredictions() - .pipe(catchError(() => this.reportUnavailable())) - .subscribe((summary) => this.predictions.set(summary.sites)); + .pipe(catchError(() => this.reportUnavailable(this.predictionsError))) + .subscribe((summary) => { + this.predictionsError.set(null); + this.predictions.set(summary.sites); + }); // Piège : le catchError porte sur l'observable interne. Sur le flux externe il // terminerait le timer, et le rafraîchissement ne repartirait jamais. timer(0, REFRESH_INTERVAL_MS) .pipe( switchMap(() => - this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable())), + this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable(this.statsError))), ), takeUntilDestroyed(this.destroyRef), ) .subscribe((stats) => { - this.error.set(null); + this.statsError.set(null); this.stats.set(stats); }); } @@ -116,8 +128,8 @@ export class Dashboard implements OnInit { }); } - private reportUnavailable(): Observable { - this.error.set(UNAVAILABLE_MESSAGE); + private reportUnavailable(target: WritableSignal): Observable { + target.set(UNAVAILABLE_MESSAGE); return EMPTY; } } diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index 26b890e..a650a1e 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -75,7 +75,7 @@ collecteur ne vient le lire. | Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement | |---|---|---|---|---| | Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary`, `readings`, `sensors/status` et `predictions` en lecture (endpoints → services → repositories → models) | -| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, deux services HTTP, graphiques Chart.js, données servies par des fixtures | +| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, authentification complète (garde de route, intercepteur de jeton), cinq services HTTP, graphiques Chart.js. `stats`/`alerts` sur fixtures, `predictions` branché sur l'API réelle | | Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) | | ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`. Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../../ML-START.md). Automatisation (Airflow) et surveillance de dérive (EC06, #44/#45) pas encore construites | | Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée | diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index 308a8bf..f60397d 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -205,19 +205,6 @@ par exemple `limit` hors bornes). Un datetime sans fuseau dans `start`/`end` est l'UTC plutôt que rejeté : le comparer tel quel à `reading.timestamp` (`timestamptz`) échouerait côté pilote, en `500` plutôt qu'un refus propre. -`GET /readings` reprend le même gabarit mais s'en écarte sur un point : `reading` est l'hypertable, -donc la seule table métier pouvant porter des années d'historique, ce que `docs/architecture/ -owasp-traceabilite.md` documentait comme un risque ouvert (API4, aucune pagination plafonnée ni -fenêtre temporelle maximale). `ReadingService` porte donc une couche de validation absente des -autres routes de lecture : `start`/`end` sont optionnels (24 dernières heures par défaut si les -deux sont omis, l'un défaut par rapport à l'autre sinon), l'écart entre les deux est plafonné à 90 -jours (`FENETRE_MAXIMALE`), et `limit`/`offset` (défaut 500, plafond 2000) empêchent qu'une fenêtre -large mais peu dense reste malgré tout coûteuse. Un dépassement de plafond répond `400` (règle -métier, portée par le service) plutôt que `422` (réservé à la validation structurelle de FastAPI, -par exemple `limit` hors bornes). Un datetime sans fuseau dans `start`/`end` est traité comme de -l'UTC plutôt que rejeté : le comparer tel quel à `reading.timestamp` (`timestamptz`) échouerait -côté pilote, en `500` plutôt qu'un refus propre. - ### `/health/ready` Cette sonde porte une garde décrite dans l'[ADR 0001](../adr/0001-postgresql-timescaledb.md) : un diff --git a/docs/architecture/30-frontend.md b/docs/architecture/30-frontend.md index b7f6d10..fb10f92 100644 --- a/docs/architecture/30-frontend.md +++ b/docs/architecture/30-frontend.md @@ -31,10 +31,11 @@ Ce qui est en place : Ce qui n'existe pas encore : -- **Aucun endpoint métier réel derrière l'écran du tableau de bord.** `GET /api/v1/stats/summary`, - `GET /api/v1/alerts` et `GET /api/v1/predictions` sont servis par l'intercepteur de fixtures ; - l'API expose bien ces routes désormais, mais rien ne bascule `useMockFixtures` à `false` en - développement pour les consommer réellement. +- **`stats`/`alerts` restent sur fixtures.** `GET /api/v1/stats/summary` et `GET /api/v1/alerts` + sont servis par l'intercepteur de fixtures ; l'API expose bien ces routes désormais, mais rien + ne bascule `useMockFixtures` à `false` en développement pour les consommer réellement. + `GET /api/v1/predictions` fait exception : jamais mocké, branché sur l'API réelle depuis cette + PR (voir plus bas). - Aucun état de chargement : tant que la première réponse n'est pas arrivée, la page reste vide. - Aucun lint : ESLint n'est pas installé. @@ -85,10 +86,12 @@ sequenceDiagram S-->>C: modèle typé ``` -`mockApiInterceptor` n'intercepte que `/stats/summary`, `/alerts` et `/predictions`, et seulement -si `environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en -production : toute autre requête (dont tout ce qui touche `/auth`), et toutes les requêtes en -production, suivent le chemin réel. +`mockApiInterceptor` n'intercepte que `/stats/summary` et `/alerts`, et seulement si +`environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en +production : toute autre requête, et toutes les requêtes en production, suivent le chemin réel. +`/predictions` est volontairement exclu de cette liste (contrairement à `stats`/`alerts`) : il +suit toujours le chemin réel, comme `/auth/*` - en développement, ça veut dire qu'un jeton valide +et un backend joignable sont nécessaires pour que la section prévisions du dashboard s'affiche. En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un diff --git a/ml/enervision_ml/data.py b/ml/enervision_ml/data.py index 6e08b54..f8e7bf9 100644 --- a/ml/enervision_ml/data.py +++ b/ml/enervision_ml/data.py @@ -119,6 +119,11 @@ def _typer(frame: pd.DataFrame) -> pd.DataFrame: LightGBM refuse ("pandas dtypes must be int, float or bool"). `pd.to_numeric` corrige aussi n'importe quelle autre colonne mesuree entierement absente sur une fenetre de scoring, pas seulement `capacity_kw`. + + Piege additionnel : `NUMERIC_COLUMNS` inclut `consumption_kwh`, la cible du modele, pas + seulement des variables explicatives. Une valeur non numerique y devient donc silencieusement + `NaN` aussi bien a l'entrainement (ou `train.py` l'exclura ensuite via son `dropna`) qu'au + scoring -- ce n'est pas un effet de bord limite aux colonnes mesurees. """ typee = frame.copy() for colonne in NUMERIC_COLUMNS: diff --git a/ml/enervision_ml/score.py b/ml/enervision_ml/score.py index adf9634..c0b7e3f 100644 --- a/ml/enervision_ml/score.py +++ b/ml/enervision_ml/score.py @@ -30,6 +30,13 @@ from enervision_ml.features import TARGET_COLUMN, WEATHER_COLUMNS, build_feature # Marge au-dessus des 168h necessaires au lag hebdomadaire, pour absorber les trous de mesure. LOOKBACK = timedelta(days=21) +# Au-dela de ce seuil, la derniere lecture d'un site est trop vieille pour que "l'heure +# suivante" ait un sens operationnel : ce n'est plus une prevision a un pas, c'est un site dont +# l'ingestion s'est probablement arretee. Sans cette borne, `build_scoring_frame` produirait +# quand meme un `target_at` (derniere lecture + 1h), et rien en aval (ni l'API, ni le dashboard) +# ne distingue une prevision fraiche d'une prevision vieille de plusieurs jours. +MAX_STALENESS = timedelta(hours=24) + TARGET_METRIC = "consumption_kwh" PERIOD_MINUTES = 60 LAG_168H_COLUMN = f"{TARGET_COLUMN}_lag_168h" @@ -38,6 +45,14 @@ INSUFFICIENT_DATA_REASON = ( ) +def _stale_reason(age: pd.Timedelta) -> str: + return ( + f"Dernière lecture vieille de {age.total_seconds() / 3600:.0f}h " + f"(seuil {MAX_STALENESS.total_seconds() / 3600:.0f}h) : ingestion probablement " + "arrêtée pour ce site." + ) + + @dataclass(frozen=True, slots=True) class ScoredSite: site_id: str @@ -86,10 +101,31 @@ def build_scoring_frame(recent: pd.DataFrame, *, site_id: str | None = None) -> return features.groupby("site_id", as_index=False, sort=False).tail(1).reset_index(drop=True) -def score(booster: lgb.Booster, scoring_frame: pd.DataFrame) -> list[ScoredSite]: +def score( + booster: lgb.Booster, scoring_frame: pd.DataFrame, *, instant: datetime +) -> list[ScoredSite]: resultats: list[ScoredSite] = [] - insuffisants = scoring_frame[scoring_frame[LAG_168H_COLUMN].isna()] + # `timestamp` de la ligne de scoring vaut derniere lecture + 1h (cf. `build_scoring_frame`) : + # on en deduit l'age de cette derniere lecture par rapport a `instant`. + travail = scoring_frame.copy() + travail["_age"] = instant - (travail["timestamp"] - pd.Timedelta(hours=1)) + + perimes = travail[travail["_age"] > MAX_STALENESS] + for enregistrement in _records(perimes): + resultats.append( + ScoredSite( + site_id=enregistrement["site_id"], + target_at=enregistrement["timestamp"].to_pydatetime(), + status="insufficient_data", + predicted_value=None, + failure_reason=_stale_reason(enregistrement["_age"]), + ) + ) + + a_jour = travail[travail["_age"] <= MAX_STALENESS] + + insuffisants = a_jour[a_jour[LAG_168H_COLUMN].isna()] for enregistrement in _records(insuffisants): resultats.append( ScoredSite( @@ -101,7 +137,7 @@ def score(booster: lgb.Booster, scoring_frame: pd.DataFrame) -> list[ScoredSite] ) ) - suffisants = scoring_frame[scoring_frame[LAG_168H_COLUMN].notna()] + suffisants = a_jour[a_jour[LAG_168H_COLUMN].notna()] if not suffisants.empty: typee = suffisants.copy() typee["site_type"] = typee["site_type"].astype("category") @@ -163,20 +199,23 @@ def write_predictions( connection.execute(_INSERT_PREDICTION, lignes) -def _load_recent(*, csv_path: Path | None, now: datetime | None) -> tuple[pd.DataFrame, datetime]: - if csv_path is not None: - brute = load_from_csv(csv_path) - instant = now or ( - brute["timestamp"].max().to_pydatetime() if not brute.empty else datetime.now(UTC) - ) - return brute[brute["timestamp"] >= instant - LOOKBACK], instant +def _load_recent_from_csv(csv_path: Path, *, now: datetime | None) -> tuple[pd.DataFrame, datetime]: + brute = load_from_csv(csv_path) + instant = now or ( + brute["timestamp"].max().to_pydatetime() if not brute.empty else datetime.now(UTC) + ) + return brute[brute["timestamp"] >= instant - LOOKBACK], instant - instant = now or datetime.now(UTC) - engine = create_engine(config.database_url()) - try: - return load_recent_from_database(engine, since=instant - LOOKBACK), instant - finally: - engine.dispose() + +def _score_frame( + recent: pd.DataFrame, *, model_path: Path, site_id: str | None, instant: datetime +) -> list[ScoredSite]: + scoring_frame = build_scoring_frame(recent, site_id=site_id) + if scoring_frame.empty: + return [] + + booster = lgb.Booster(model_file=str(model_path)) + return score(booster, scoring_frame, instant=instant) def run_scoring( @@ -190,29 +229,27 @@ def run_scoring( En mode `--csv`, rien n'est ecrit : c'est un instantane historique fige (l'heure "future" calculee n'existe dans aucune base reelle), utile pour valider le pipeline sans base - joignable, cf. `ml/README.md`. + joignable, cf. `ml/README.md`. `site_id` n'est filtre qu'une fois, dans + `build_scoring_frame` : le filtrer aussi ici serait redondant. """ - recent, _instant = _load_recent(csv_path=csv_path, now=now) - if site_id is not None: - recent = recent[recent["site_id"] == site_id] + if csv_path is not None: + recent, instant = _load_recent_from_csv(csv_path, now=now) + return _score_frame(recent, model_path=model_path, site_id=site_id, instant=instant) - scoring_frame = build_scoring_frame(recent, site_id=site_id) - if scoring_frame.empty: - return [] + # Un seul engine pour la lecture et l'ecriture de ce run, plutot qu'un par etape. + engine = create_engine(config.database_url()) + try: + instant = now or datetime.now(UTC) + recent = load_recent_from_database(engine, since=instant - LOOKBACK) + resultats = _score_frame(recent, model_path=model_path, site_id=site_id, instant=instant) - booster = lgb.Booster(model_file=str(model_path)) - resultats = score(booster, scoring_frame) - - if csv_path is None: reference = model_reference(model_path) - engine = create_engine(config.database_url()) - try: - with engine.begin() as connection: - write_predictions(connection, resultats, reference=reference) - finally: - engine.dispose() + with engine.begin() as connection: + write_predictions(connection, resultats, reference=reference) - return resultats + return resultats + finally: + engine.dispose() def parse_args() -> argparse.Namespace: diff --git a/ml/tests/test_data.py b/ml/tests/test_data.py index 0a1f022..0aa42a1 100644 --- a/ml/tests/test_data.py +++ b/ml/tests/test_data.py @@ -1,34 +1,55 @@ +from pathlib import Path + import pandas as pd -from enervision_ml.data import NUMERIC_COLUMNS, OUTPUT_COLUMNS, _typer +from enervision_ml.data import NUMERIC_COLUMNS, load_from_csv + +_CSV_HEADER = ( + "site_id,timestamp,consumption_kwh,temperature_celsius,humidity_percent," + "solar_irradiance_wm2,is_working_hours,site_type" +) -def make_frame_with_object_dtype_capacity() -> pd.DataFrame: - # Reproduit ce que `pd.read_sql` renvoie pour une colonne entierement `NULL` en base : - # dtype `object` rempli de `None`, pas `float64` rempli de `NaN`. - frame = pd.DataFrame( - {colonne: [1.0, 2.0] for colonne in OUTPUT_COLUMNS if colonne not in NUMERIC_COLUMNS} +def write_csv(tmp_path: Path, *lignes: str) -> Path: + csv_path = tmp_path / "recent.csv" + csv_path.write_text("\n".join([_CSV_HEADER, *lignes]) + "\n") + return csv_path + + +def test_load_from_csv_types_every_numeric_column_as_float(tmp_path: Path) -> None: + csv_path = write_csv(tmp_path, "SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,True,office") + + frame = load_from_csv(csv_path) + + for colonne in NUMERIC_COLUMNS: + assert frame[colonne].dtype == "float64" + + +def test_load_from_csv_coerces_a_corrupted_measurement_to_nan(tmp_path: Path) -> None: + # Reproduit une valeur de capteur corrompue plutot que vraiment manquante : `pandas` type + # alors la colonne entiere en `object`, pas en `float64` rempli de `NaN` -- le meme genre de + # divergence de typage que celle que `pd.read_sql` produit sur une colonne SQL entierement + # `NULL` (cf. `site.capacity_kw`, jamais peuplee par aucun pipeline d'ingestion aujourd'hui). + csv_path = write_csv( + tmp_path, + "SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,True,office", + "SITE001,2026-01-01T01:00:00,capteur_hs,15.2,50.5,0.0,True,office", ) - for colonne in NUMERIC_COLUMNS: - frame[colonne] = pd.Series([None, None], dtype="object") - return frame + + frame = load_from_csv(csv_path) + + assert frame["consumption_kwh"].dtype == "float64" + assert frame["consumption_kwh"].iloc[0] == 10.5 + assert pd.isna(frame["consumption_kwh"].iloc[1]) -def test_typer_coerces_an_all_null_object_column_to_float() -> None: - frame = make_frame_with_object_dtype_capacity() +def test_load_from_csv_always_types_capacity_kw_as_float(tmp_path: Path) -> None: + # `capacity_kw` n'existe pas dans ce CSV : `load_from_csv` la pose elle-meme a `NaN`. Cette + # affectation directe est deja un `float`, contrairement au cas `pd.read_sql` -- ce test + # garde le contrat visible malgre tout, au cas ou l'implementation changerait. + csv_path = write_csv(tmp_path, "SITE001,2026-01-01T00:00:00,10.5,15.0,50.0,0.0,True,office") - typee = _typer(frame) + frame = load_from_csv(csv_path) - for colonne in NUMERIC_COLUMNS: - assert typee[colonne].dtype == "float64" - assert typee[colonne].isna().all() - - -def test_typer_preserves_real_numeric_values() -> None: - frame = make_frame_with_object_dtype_capacity() - frame["capacity_kw"] = pd.Series([100.0, None], dtype="object") - - typee = _typer(frame) - - assert typee["capacity_kw"].tolist()[0] == 100.0 - assert pd.isna(typee["capacity_kw"].tolist()[1]) + assert frame["capacity_kw"].dtype == "float64" + assert pd.isna(frame["capacity_kw"].iloc[0]) diff --git a/ml/tests/test_score.py b/ml/tests/test_score.py index 28b0af5..fb3e015 100644 --- a/ml/tests/test_score.py +++ b/ml/tests/test_score.py @@ -8,6 +8,7 @@ import pytest from enervision_ml.features import TARGET_COLUMN from enervision_ml.score import ( LAG_168H_COLUMN, + MAX_STALENESS, ScoredSite, build_scoring_frame, model_reference, @@ -123,12 +124,23 @@ def test_build_scoring_frame_returns_empty_when_there_is_no_recent_reading() -> assert scoring_frame.empty +def target_at_for(depart: datetime, heures: int) -> datetime: + """`target_at` que produira `build_scoring_frame` pour ce jeu synthetique (derniere lecture + + 1h) : l'utiliser comme `instant` donne un age d'1h, largement sous le seuil de peremption, + pour les tests qui ne visent pas ce filtre.""" + return depart + timedelta(hours=heures) + + def test_score_marks_insufficient_history_without_calling_the_model() -> None: depart = datetime(2026, 1, 1, tzinfo=UTC) scoring_frame = build_scoring_frame(make_recent("site-a", heures=100, depart=depart)) booster = FakeBooster() - resultats = score(booster, scoring_frame) # type: ignore[arg-type] + resultats = score( + booster, # type: ignore[arg-type] + scoring_frame, + instant=target_at_for(depart, 100), + ) assert resultats == [ ScoredSite( @@ -147,7 +159,11 @@ def test_score_predicts_when_history_is_sufficient() -> None: scoring_frame = build_scoring_frame(make_recent("site-a", heures=200, depart=depart)) booster = FakeBooster(valeur=99.5) - resultats = score(booster, scoring_frame) # type: ignore[arg-type] + resultats = score( + booster, # type: ignore[arg-type] + scoring_frame, + instant=target_at_for(depart, 200), + ) assert len(resultats) == 1 assert resultats[0].status == "available" @@ -156,6 +172,37 @@ def test_score_predicts_when_history_is_sufficient() -> None: assert booster.appels == [1] +def test_score_marks_a_stale_site_as_insufficient_data_without_calling_the_model() -> None: + depart = datetime(2026, 1, 1, tzinfo=UTC) + # Historique largement suffisant (168h+), mais l'instant de reference est loin apres la + # derniere lecture : la fraicheur doit primer sur la disponibilite de l'historique. + scoring_frame = build_scoring_frame(make_recent("site-a", heures=200, depart=depart)) + instant = target_at_for(depart, 200) + MAX_STALENESS + timedelta(hours=1) + booster = FakeBooster() + + resultats = score(booster, scoring_frame, instant=instant) # type: ignore[arg-type] + + assert len(resultats) == 1 + assert resultats[0].status == "insufficient_data" + assert resultats[0].predicted_value is None + assert "vieille" in (resultats[0].failure_reason or "") + assert booster.appels == [] + + +def test_score_accepts_a_reading_exactly_at_the_staleness_threshold() -> None: + depart = datetime(2026, 1, 1, tzinfo=UTC) + scoring_frame = build_scoring_frame(make_recent("site-a", heures=200, depart=depart)) + # `target_at_for(...)` donne deja un age d'1h (cf. sa docstring) : retrancher cette heure + # pour retomber exactement sur le seuil, ni en dessous ni au dessus. + instant = target_at_for(depart, 200) + MAX_STALENESS - timedelta(hours=1) + booster = FakeBooster(valeur=12.0) + + resultats = score(booster, scoring_frame, instant=instant) # type: ignore[arg-type] + + assert resultats[0].status == "available" + assert booster.appels == [1] + + def test_write_predictions_does_nothing_when_there_is_nothing_to_write() -> None: connection = FakeConnection() From aeb07e14db25ded4fbe5b272c8516d29be6aa77c Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Fri, 18 Sep 2026 15:49:54 +0200 Subject: [PATCH 74/87] =?UTF-8?q?feat(backend):=20moteur=20de=20r=C3=A8gle?= =?UTF-8?q?s=20de=20recommandations=20et=20route=20de=20g=C3=A9n=C3=A9rati?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `recommendation` n'avait aucun écrivain : les quatre couches de lecture étaient livrées, mais rien ne produisait de ligne. Le moteur comble ce trou. Le catalogue `REGLES` vit dans `app/services/`, pas dans `ml/` : il lit `alert.type`, `alert.severity`, `alert.value` et `alert.threshold`, sans modèle ni feature, et s'appuie sur deux repositories existants. L'arbitrage avec l'ADR 0005, qui annonçait #38 du côté ML, est tranché par l'ADR 0006. Sept règles, cinq par type d'alerte et deux transverses (sévérité critique, dépassement d'au moins 20 % du seuil), donc une à trois recommandations par alerte. L'idempotence est portée par la base : `create_missing()` insère en `ON CONFLICT DO NOTHING` sur `uq_recommendation_alert_rule`, ce qui supprime la fenêtre entre un contrôle préalable et l'insertion. `rule_reference` devient de ce fait une clé fonctionnelle, d'où le suffixe de version sur chaque référence. Deux déclencheurs : `POST /api/v1/recommendations/generate` réservé `admin`, et `python -m app.cli generate-recommendations` (cible `make recommendations`). Limite connue : aucune source n'alimente `alert` aujourd'hui, ni détection interne (#104) ni ingestion de l'API Mock. La route répond, le rapport reste à zéro, et la chaîne s'allume sans retoucher le moteur le jour où les alertes existent. Tests : 80 unitaires et API verts, plus 6 d'intégration dont l'idempotence jouée contre PostgreSQL. Closes #38 --- Makefile | 5 +- apps/backend/README.md | 1 + apps/backend/app/api/deps.py | 6 +- apps/backend/app/api/openapi.py | 2 +- .../app/api/v1/endpoints/recommendations.py | 30 +++- apps/backend/app/cli.py | 31 ++++ .../app/repositories/recommendation.py | 25 +++ apps/backend/app/schemas/recommendation.py | 6 + apps/backend/app/services/recommendation.py | 38 ++++- .../app/services/recommendation_rules.py | 117 +++++++++++++++ apps/backend/openapi.json | 109 +++++++++++++- apps/backend/tests/api/acces.py | 1 + .../backend/tests/api/test_recommendations.py | 60 +++++++- .../tests/repositories/test_recommendation.py | 42 +++++- .../tests/services/test_recommendation.py | 134 +++++++++++++++-- .../services/test_recommendation_rules.py | 142 ++++++++++++++++++ apps/backend/tests/test_cli.py | 27 ++++ .../0006-moteur-de-regles-dans-le-backend.md | 77 ++++++++++ docs/architecture/00-vue-ensemble.md | 5 + docs/architecture/20-backend.md | 14 ++ docs/architecture/40-data.md | 5 + 21 files changed, 854 insertions(+), 23 deletions(-) create mode 100644 apps/backend/app/services/recommendation_rules.py create mode 100644 apps/backend/tests/services/test_recommendation_rules.py create mode 100644 docs/adr/0006-moteur-de-regles-dans-le-backend.md diff --git a/Makefile b/Makefile index 2a4b3d2..a2a171f 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ ML := ml .PHONY: help install install-backend install-frontend install-ml dev dev-backend dev-frontend \ lint format typecheck test test-cov test-integration check \ openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin \ - ml-lint ml-typecheck ml-test ml-check ml-train ml-score + ml-lint ml-typecheck ml-test ml-check ml-train ml-score recommendations help: ## Liste les cibles disponibles @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' @@ -77,6 +77,9 @@ ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DAT ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV=chemin optionnel cd $(ML) && uv run python -m enervision_ml.score $(if $(CSV),--csv $(CSV),) +recommendations: ## Genere les recommandations depuis les alertes en base. SITE=identifiant optionnel + cd $(BACKEND) && uv run python -m app.cli generate-recommendations $(if $(SITE),--site-id $(SITE),) + docker-build: ## Construit l'image du backend docker build -t enervision-backend:local $(BACKEND) diff --git a/apps/backend/README.md b/apps/backend/README.md index 6c48b3a..2aebb52 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -113,6 +113,7 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie | `/api/v1/sites/{site_id}` | Décrit un site | `lecteur` | | `/api/v1/recommendations` | Liste les recommandations | `lecteur` | | `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation | `lecteur` | +| `/api/v1/recommendations/generate` | Génère les recommandations depuis les alertes (POST) | `admin` | | `/metrics` | Métriques au format Prometheus | jeton si `APP_METRICS_TOKEN` | | `/docs`, `/openapi.json` | Documentation, fermée en `staging` et `prod` | public sinon | diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index c247f4c..abf6293 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -187,7 +187,11 @@ AlertServiceDep = Annotated[AlertService, Depends(get_alert_service)] def get_recommendation_service(session: SessionDep) -> RecommendationService: - return RecommendationService(recommendations=RecommendationRepository(session)) + return RecommendationService( + recommendations=RecommendationRepository(session), + alerts=AlertRepository(session), + transaction=session, + ) RecommendationServiceDep = Annotated[RecommendationService, Depends(get_recommendation_service)] diff --git a/apps/backend/app/api/openapi.py b/apps/backend/app/api/openapi.py index 11b2604..8ca8c08 100644 --- a/apps/backend/app/api/openapi.py +++ b/apps/backend/app/api/openapi.py @@ -63,7 +63,7 @@ TAGS: Final[list[dict[str, Any]]] = [ "name": "recommendations", "description": ( "Consultation des recommandations issues des alertes. Accessible à partir du rôle " - "`lecteur`." + "`lecteur`. Leur génération par le moteur de règles est réservée au rôle `admin`." ), }, { diff --git a/apps/backend/app/api/v1/endpoints/recommendations.py b/apps/backend/app/api/v1/endpoints/recommendations.py index 87e8be1..180808a 100644 --- a/apps/backend/app/api/v1/endpoints/recommendations.py +++ b/apps/backend/app/api/v1/endpoints/recommendations.py @@ -1,13 +1,18 @@ from fastapi import APIRouter, HTTPException, status -from app.api.deps import LecteurDep, RecommendationServiceDep -from app.api.openapi import REPONSE_VALIDATION, Reponses +from app.api.deps import AdminDep, LecteurDep, RecommendationServiceDep +from app.api.openapi import REPONSE_VALIDATION, REPONSES_ADMIN, Reponses from app.schemas.errors import ErrorResponse -from app.schemas.recommendation import RecommendationResponse +from app.schemas.recommendation import ( + RecommendationGenerationResponse, + RecommendationResponse, +) from app.services.recommendation import RecommendationNotFoundError router = APIRouter() +REPONSES_GENERATION: Reponses = {**REPONSES_ADMIN, **REPONSE_VALIDATION} + REPONSES_INTROUVABLE: Reponses = { **REPONSE_VALIDATION, 404: {"model": ErrorResponse, "description": "Aucune recommandation ne porte cet identifiant."}, @@ -38,3 +43,22 @@ async def get_recommendation( status_code=status.HTTP_404_NOT_FOUND, detail="Recommandation introuvable" ) from erreur return RecommendationResponse.model_validate(recommendation) + + +@router.post( + "/generate", + response_model=RecommendationGenerationResponse, + summary="Génère les recommandations à partir des alertes", + responses=REPONSES_GENERATION, +) +async def generate_recommendations( + _: AdminDep, + service: RecommendationServiceDep, + site_id: str | None = None, +) -> RecommendationGenerationResponse: + rapport = await service.generate(site_id=site_id) + return RecommendationGenerationResponse( + alerts_examined=rapport.alertes_examinees, + recommendations_created=rapport.recommandations_creees, + already_present=rapport.deja_presentes, + ) diff --git a/apps/backend/app/cli.py b/apps/backend/app/cli.py index f713fa5..5822e08 100644 --- a/apps/backend/app/cli.py +++ b/apps/backend/app/cli.py @@ -22,8 +22,11 @@ from app.core.hashing import build_hasher from app.core.roles import Role from app.db.session import get_session_factory from app.main import create_app +from app.repositories.alert import AlertRepository +from app.repositories.recommendation import RecommendationRepository from app.repositories.user import UserRepository from app.schemas.auth import PASSWORD_MIN_LENGTH, SPECIAL_CHARACTERS, valide_complexite +from app.services.recommendation import RecommendationService LONGUEUR_MOT_DE_PASSE_GENERE = 24 CHEMIN_CONTRAT = Path(__file__).resolve().parent.parent / "openapi.json" @@ -63,6 +66,22 @@ async def create_admin( ) +async def generate_recommendations(*, site_id: str | None) -> str: + async with get_session_factory()() as session: + service = RecommendationService( + recommendations=RecommendationRepository(session), + alerts=AlertRepository(session), + transaction=session, + ) + rapport = await service.generate(site_id=site_id) + + return ( + f"{rapport.alertes_examinees} alerte(s) examinée(s), " + f"{rapport.recommandations_creees} recommandation(s) créée(s), " + f"{rapport.deja_presentes} déjà présente(s)" + ) + + # Piège : le schéma ne doit dépendre ni du `.env` du poste ni des variables `APP_*`, sinon le # fichier versionné changerait de machine en machine et le test de dérive deviendrait un oracle # de configuration locale. Tout ce qui atteint le schéma est donc posé ici, `_env_file` compris. @@ -109,6 +128,14 @@ def build_parser() -> argparse.ArgumentParser: "export-openapi", help="Écrit le contrat OpenAPI sur disque" ) contrat.add_argument("--output", default=str(CHEMIN_CONTRAT)) + + recommandations = sous_commandes.add_parser( + "generate-recommendations", + help="Applique le moteur de règles aux alertes en base", + ) + recommandations.add_argument( + "--site-id", default=None, help="Limite le traitement aux alertes d'un site" + ) return parser @@ -152,6 +179,10 @@ def main(argv: list[str] | None = None) -> int: print(export_openapi(Path(arguments.output))) return 0 + if arguments.commande == "generate-recommendations": + print(asyncio.run(generate_recommendations(site_id=arguments.site_id))) + return 0 + mot_de_passe = read_password(generate=arguments.generate) succes, message = asyncio.run( diff --git a/apps/backend/app/repositories/recommendation.py b/apps/backend/app/repositories/recommendation.py index 7870131..8b07349 100644 --- a/apps/backend/app/repositories/recommendation.py +++ b/apps/backend/app/repositories/recommendation.py @@ -1,11 +1,21 @@ from collections.abc import Sequence +from dataclasses import asdict, dataclass from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert from sqlalchemy.ext.asyncio import AsyncSession from app.models.energy import Recommendation +@dataclass(frozen=True, slots=True) +class NouvelleRecommandation: + alert_id: int + action: str + explanation: str + rule_reference: str + + class RecommendationRepository: def __init__(self, session: AsyncSession) -> None: self._session = session @@ -20,3 +30,18 @@ class RecommendationRepository: ) recommendation: Recommendation | None = await self._session.scalar(requete) return recommendation + + # Pourquoi : l'idempotence est déléguée à `uq_recommendation_alert_rule` plutôt qu'à une + # lecture préalable, qui laisserait une fenêtre entre le contrôle et l'insertion. + async def create_missing(self, nouvelles: Sequence[NouvelleRecommandation]) -> int: + if not nouvelles: + return 0 + + requete = ( + insert(Recommendation) + .values([asdict(nouvelle) for nouvelle in nouvelles]) + .on_conflict_do_nothing(constraint="uq_recommendation_alert_rule") + .returning(Recommendation.recommendation_id) + ) + creees = (await self._session.scalars(requete)).all() + return len(creees) diff --git a/apps/backend/app/schemas/recommendation.py b/apps/backend/app/schemas/recommendation.py index 8764615..bb22d02 100644 --- a/apps/backend/app/schemas/recommendation.py +++ b/apps/backend/app/schemas/recommendation.py @@ -12,3 +12,9 @@ class RecommendationResponse(BaseModel): explanation: str rule_reference: str created_at: datetime + + +class RecommendationGenerationResponse(BaseModel): + alerts_examined: int + recommendations_created: int + already_present: int diff --git a/apps/backend/app/services/recommendation.py b/apps/backend/app/services/recommendation.py index 31115ae..6b0ceb6 100644 --- a/apps/backend/app/services/recommendation.py +++ b/apps/backend/app/services/recommendation.py @@ -1,7 +1,15 @@ from collections.abc import Sequence +from dataclasses import dataclass +from typing import Protocol from app.models.energy import Recommendation +from app.repositories.alert import AlertRepository from app.repositories.recommendation import RecommendationRepository +from app.services.recommendation_rules import applique_les_regles + + +class Transaction(Protocol): + async def commit(self) -> None: ... class RecommendationError(Exception): @@ -12,9 +20,24 @@ class RecommendationNotFoundError(RecommendationError): pass +@dataclass(frozen=True, slots=True) +class RapportGeneration: + alertes_examinees: int + recommandations_creees: int + deja_presentes: int + + class RecommendationService: - def __init__(self, *, recommendations: RecommendationRepository) -> None: + def __init__( + self, + *, + recommendations: RecommendationRepository, + alerts: AlertRepository, + transaction: Transaction, + ) -> None: self._recommendations = recommendations + self._alerts = alerts + self._transaction = transaction async def list_all(self) -> Sequence[Recommendation]: return await self._recommendations.list_all() @@ -24,3 +47,16 @@ class RecommendationService: if recommendation is None: raise RecommendationNotFoundError(recommendation_id) return recommendation + + async def generate(self, *, site_id: str | None = None) -> RapportGeneration: + alertes = await self._alerts.list_all(site_id=site_id) + nouvelles = [nouvelle for alerte in alertes for nouvelle in applique_les_regles(alerte)] + + creees = await self._recommendations.create_missing(nouvelles) + await self._transaction.commit() + + return RapportGeneration( + alertes_examinees=len(alertes), + recommandations_creees=creees, + deja_presentes=len(nouvelles) - creees, + ) diff --git a/apps/backend/app/services/recommendation_rules.py b/apps/backend/app/services/recommendation_rules.py new file mode 100644 index 0000000..72fa185 --- /dev/null +++ b/apps/backend/app/services/recommendation_rules.py @@ -0,0 +1,117 @@ +# Piège : `rule_reference` est la clé d'idempotence en base, portée par la contrainte +# `uq_recommendation_alert_rule`. Renommer une référence déjà livrée ne remplace pas les +# recommandations existantes, il en crée de nouvelles à côté. Une règle qui change de sens +# prend donc une référence suffixée `-v2` - REGLES. + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Final + +from app.models.energy import Alert +from app.repositories.recommendation import NouvelleRecommandation +from app.schemas.alert import AlertSeverity, AlertType + +FACTEUR_DEPASSEMENT_MAJEUR: Final = 1.2 +POURCENTAGE_DEPASSEMENT_MAJEUR: Final = round((FACTEUR_DEPASSEMENT_MAJEUR - 1) * 100) + + +@dataclass(frozen=True, slots=True) +class Regle: + reference: str + action: str + declencheur: Callable[[Alert], bool] + motif: Callable[[Alert], str] + + +def _du_type(attendu: AlertType) -> Callable[[Alert], bool]: + return lambda alerte: alerte.type == attendu + + +def _de_severite(attendue: AlertSeverity) -> Callable[[Alert], bool]: + return lambda alerte: alerte.severity == attendue + + +# Un seuil nul ou négatif rendrait le rapport `value / threshold` arbitraire : l'alerte ne +# renseigne alors aucun dépassement exploitable, et la règle ne se déclenche pas. +def _depasse_largement_le_seuil(alerte: Alert) -> bool: + if alerte.value is None or alerte.threshold is None or alerte.threshold <= 0: + return False + return alerte.value >= alerte.threshold * FACTEUR_DEPASSEMENT_MAJEUR + + +REGLES: Final[tuple[Regle, ...]] = ( + Regle( + reference="spike-delestage-v1", + action="Délester les équipements non prioritaires sur le créneau du pic", + declencheur=_du_type(AlertType.SPIKE), + motif=lambda alerte: f"Pic de consommation signalé sur le site {alerte.site_id}", + ), + Regle( + reference="threshold-reduction-v1", + action="Ramener la puissance appelée sous le seuil contractuel", + declencheur=_du_type(AlertType.THRESHOLD), + motif=lambda alerte: f"Seuil de consommation dépassé sur le site {alerte.site_id}", + ), + Regle( + reference="outage-secours-v1", + action="Basculer sur l'alimentation de secours et prévenir l'exploitant", + declencheur=_du_type(AlertType.OUTAGE), + motif=lambda alerte: ( + f"Risque de surcharge ou de coupure imminente sur le site {alerte.site_id}" + ), + ), + Regle( + reference="sensor-maintenance-v1", + action="Planifier une intervention de maintenance sur le capteur", + declencheur=_du_type(AlertType.SENSOR), + motif=lambda alerte: ( + f"Capteur défaillant sur le site {alerte.site_id}, les mesures ne sont plus fiables" + ), + ), + Regle( + reference="anomaly-verification-v1", + action="Confronter la mesure à la prévision et vérifier le paramétrage du site", + declencheur=_du_type(AlertType.ANOMALY), + motif=lambda alerte: ( + f"Écart anormal entre la mesure et le comportement attendu du site {alerte.site_id}" + ), + ), + Regle( + reference="escalade-astreinte-v1", + action="Escalader à l'astreinte sous une heure", + declencheur=_de_severite(AlertSeverity.CRITICAL), + motif=lambda alerte: f"Alerte de sévérité critique sur le site {alerte.site_id}", + ), + Regle( + reference="contrat-puissance-v1", + action="Réévaluer la puissance souscrite au contrat", + declencheur=_depasse_largement_le_seuil, + motif=lambda alerte: ( + f"Dépassement d'au moins {POURCENTAGE_DEPASSEMENT_MAJEUR} % du seuil " + f"sur le site {alerte.site_id}" + ), + ), +) + + +def applique_les_regles(alerte: Alert) -> list[NouvelleRecommandation]: + contexte = _contexte_de_mesure(alerte) + return [ + NouvelleRecommandation( + alert_id=alerte.alert_id, + action=regle.action, + explanation=f"{regle.motif(alerte)}{contexte}.", + rule_reference=regle.reference, + ) + for regle in REGLES + if regle.declencheur(alerte) + ] + + +def _contexte_de_mesure(alerte: Alert) -> str: + if alerte.value is None: + return "" + grandeur = alerte.metric or "valeur" + if alerte.threshold is None: + return f" ({grandeur} mesurée à {alerte.value})" + return f" ({grandeur} mesurée à {alerte.value}, seuil {alerte.threshold})" diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index f7c445c..67b3877 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -1453,6 +1453,90 @@ } } }, + "/api/v1/recommendations/generate": { + "post": { + "tags": [ + "recommendations" + ], + "summary": "Génère les recommandations à partir des alertes", + "operationId": "generate_recommendations_api_v1_recommendations_generate_post", + "security": [ + { + "Jeton d'accès": [] + } + ], + "parameters": [ + { + "name": "site_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Site Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecommendationGenerationResponse" + } + } + } + }, + "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": "Droits insuffisants, ou mot de passe provisoire à changer quand `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" + } + } + } + } + } + } + }, "/api/v1/stats/summary": { "get": { "tags": [ @@ -2344,6 +2428,29 @@ ], "title": "ReadingSource" }, + "RecommendationGenerationResponse": { + "properties": { + "alerts_examined": { + "type": "integer", + "title": "Alerts Examined" + }, + "recommendations_created": { + "type": "integer", + "title": "Recommendations Created" + }, + "already_present": { + "type": "integer", + "title": "Already Present" + } + }, + "type": "object", + "required": [ + "alerts_examined", + "recommendations_created", + "already_present" + ], + "title": "RecommendationGenerationResponse" + }, "RecommendationResponse": { "properties": { "recommendation_id": { @@ -3153,7 +3260,7 @@ }, { "name": "recommendations", - "description": "Consultation des recommandations issues des alertes. Accessible à partir du rôle `lecteur`." + "description": "Consultation des recommandations issues des alertes. Accessible à partir du rôle `lecteur`. Leur génération par le moteur de règles est réservée au rôle `admin`." }, { "name": "stats", diff --git a/apps/backend/tests/api/acces.py b/apps/backend/tests/api/acces.py index e3b641b..2b38374 100644 --- a/apps/backend/tests/api/acces.py +++ b/apps/backend/tests/api/acces.py @@ -51,6 +51,7 @@ ROLE_MINIMUM: Final[dict[Route, Role]] = { ("GET", "/api/v1/alerts"): Role.LECTEUR, ("GET", "/api/v1/recommendations"): Role.LECTEUR, ("GET", "/api/v1/recommendations/{recommendation_id}"): Role.LECTEUR, + ("POST", "/api/v1/recommendations/generate"): Role.ADMIN, ("GET", "/api/v1/stats/summary"): Role.LECTEUR, ("GET", "/api/v1/readings"): Role.LECTEUR, ("GET", "/api/v1/predictions"): Role.LECTEUR, diff --git a/apps/backend/tests/api/test_recommendations.py b/apps/backend/tests/api/test_recommendations.py index d01db09..6e854bd 100644 --- a/apps/backend/tests/api/test_recommendations.py +++ b/apps/backend/tests/api/test_recommendations.py @@ -10,7 +10,7 @@ from app.api.deps import get_current_principal, get_recommendation_service from app.core.principal import Principal from app.core.roles import AccountKind, Role from app.models.energy import Recommendation -from app.services.recommendation import RecommendationNotFoundError +from app.services.recommendation import RapportGeneration, RecommendationNotFoundError MOMENT = datetime(2024, 1, 1, tzinfo=UTC) @@ -40,6 +40,7 @@ class FauxService: def __init__(self, erreur: Exception | None = None) -> None: self._erreur = erreur self.recommendation = recommendation() + self.site_demande: str | None = None async def list_all(self) -> list[Recommendation]: return [self.recommendation] @@ -49,6 +50,10 @@ class FauxService: raise self._erreur return self.recommendation + async def generate(self, *, site_id: str | None = None) -> RapportGeneration: + self.site_demande = site_id + return RapportGeneration(alertes_examinees=2, recommandations_creees=3, deja_presentes=1) + @pytest.fixture def lecteur_connecte(app: FastAPI) -> Iterator[None]: @@ -142,3 +147,56 @@ async def test_get_recommendation_returns_404_when_the_session_finds_nothing( response = await client.get("/api/v1/recommendations/404") assert response.status_code == 404 + + +@pytest.fixture +def admin_connecte(app: FastAPI) -> Iterator[None]: + app.dependency_overrides[get_current_principal] = lambda: principal(Role.ADMIN) + yield + app.dependency_overrides.pop(get_current_principal, None) + + +@pytest.fixture +def servi_en_admin(app: FastAPI, admin_connecte: None) -> Iterator[Callable[[], FauxService]]: + def installe() -> FauxService: + service = FauxService() + app.dependency_overrides[get_recommendation_service] = lambda: service + return service + + yield installe + app.dependency_overrides.pop(get_recommendation_service, None) + + +async def test_generate_recommendations_returns_the_generation_report( + servi_en_admin: Callable[[], FauxService], client: AsyncClient +) -> None: + servi_en_admin() + + response = await client.post("/api/v1/recommendations/generate") + + assert response.status_code == 200 + assert response.json() == { + "alerts_examined": 2, + "recommendations_created": 3, + "already_present": 1, + } + + +async def test_generate_recommendations_forwards_the_requested_site( + servi_en_admin: Callable[[], FauxService], client: AsyncClient +) -> None: + service = servi_en_admin() + + await client.post("/api/v1/recommendations/generate", params={"site_id": "SITE002"}) + + assert service.site_demande == "SITE002" + + +async def test_generate_recommendations_refuses_a_reader( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.post("/api/v1/recommendations/generate") + + assert response.status_code == 403 diff --git a/apps/backend/tests/repositories/test_recommendation.py b/apps/backend/tests/repositories/test_recommendation.py index 075c9eb..64b3b5f 100644 --- a/apps/backend/tests/repositories/test_recommendation.py +++ b/apps/backend/tests/repositories/test_recommendation.py @@ -5,7 +5,7 @@ import pytest from sqlalchemy.ext.asyncio import AsyncSession from app.models.energy import Alert, Recommendation, Site -from app.repositories.recommendation import RecommendationRepository +from app.repositories.recommendation import NouvelleRecommandation, RecommendationRepository pytestmark = pytest.mark.integration @@ -83,3 +83,43 @@ async def test_list_all_returns_the_recommendations_sorted_by_identifier( await session.rollback() assert identifiants == sorted(identifiants) + + +def nouvelle(alert_id: int, reference: str = "spike-delestage-v1") -> NouvelleRecommandation: + return NouvelleRecommandation( + alert_id=alert_id, + action="Délester les équipements non prioritaires", + explanation="Pic de consommation signalé.", + rule_reference=reference, + ) + + +async def test_create_missing_inserts_the_proposals(session: AsyncSession) -> None: + depot = RecommendationRepository(session) + alert_id = await creer_alerte(session) + + creees = await depot.create_missing( + [nouvelle(alert_id), nouvelle(alert_id, "escalade-astreinte-v1")] + ) + await session.rollback() + + assert creees == 2 + + +async def test_create_missing_ignores_a_rule_already_held_for_the_alert( + session: AsyncSession, +) -> None: + depot = RecommendationRepository(session) + alert_id = await creer_alerte(session) + await depot.create_missing([nouvelle(alert_id)]) + + creees = await depot.create_missing([nouvelle(alert_id)]) + await session.rollback() + + assert creees == 0 + + +async def test_create_missing_returns_zero_without_any_proposal(session: AsyncSession) -> None: + creees = await RecommendationRepository(session).create_missing([]) + + assert creees == 0 diff --git a/apps/backend/tests/services/test_recommendation.py b/apps/backend/tests/services/test_recommendation.py index e8ed2b2..725df25 100644 --- a/apps/backend/tests/services/test_recommendation.py +++ b/apps/backend/tests/services/test_recommendation.py @@ -1,10 +1,14 @@ +from collections.abc import Sequence from datetime import UTC, datetime import pytest -from app.models.energy import Recommendation +from app.models.energy import Alert, Recommendation +from app.repositories.recommendation import NouvelleRecommandation from app.services.recommendation import RecommendationNotFoundError, RecommendationService +MOMENT = datetime(2024, 1, 1, tzinfo=UTC) + def recommendation(recommendation_id: int = 1) -> Recommendation: return Recommendation( @@ -13,13 +17,33 @@ def recommendation(recommendation_id: int = 1) -> Recommendation: action="Vérifier la consommation", explanation="Pic détecté", rule_reference="spike-v1", - created_at=datetime(2024, 1, 1, tzinfo=UTC), + created_at=MOMENT, + ) + + +def alerte(alert_id: int = 1, site_id: str = "SITE001", severity: str = "high") -> Alert: + return Alert( + alert_id=alert_id, + source_alert_id=f"ALR-{alert_id}", + site_id=site_id, + source="api_mock", + timestamp=MOMENT, + type="spike", + severity=severity, + message="Pic de consommation", + value=None, + threshold=None, + metric=None, + prediction_id=None, + raw_data={}, ) class FakeRepository: - def __init__(self, recommendations: list[Recommendation]) -> None: + def __init__(self, recommendations: list[Recommendation], creees: int | None = None) -> None: self._recommendations = recommendations + self._creees = creees + self.recues: list[NouvelleRecommandation] = [] async def list_all(self) -> list[Recommendation]: return self._recommendations @@ -29,27 +53,111 @@ class FakeRepository: (r for r in self._recommendations if r.recommendation_id == recommendation_id), None ) + async def create_missing(self, nouvelles: Sequence[NouvelleRecommandation]) -> int: + self.recues = list(nouvelles) + return len(self.recues) if self._creees is None else self._creees -async def test_list_all_returns_the_repository_recommendations() -> None: - service = RecommendationService( - recommendations=FakeRepository([recommendation(1), recommendation(2)]) + +class FakeAlertRepository: + def __init__(self, alertes: list[Alert]) -> None: + self._alertes = alertes + self.site_demande: str | None = None + + async def list_all( + self, *, site_id: str | None = None, severity: str | None = None + ) -> list[Alert]: + self.site_demande = site_id + if site_id is None: + return self._alertes + return [a for a in self._alertes if a.site_id == site_id] + + +class FakeTransaction: + def __init__(self) -> None: + self.commits = 0 + + async def commit(self) -> None: + self.commits += 1 + + +def service( + recommendations: FakeRepository | None = None, + alerts: FakeAlertRepository | None = None, + transaction: FakeTransaction | None = None, +) -> RecommendationService: + return RecommendationService( + recommendations=recommendations or FakeRepository([]), + alerts=alerts or FakeAlertRepository([]), + transaction=transaction or FakeTransaction(), ) - recommendations = await service.list_all() + +async def test_list_all_returns_the_repository_recommendations() -> None: + depot = FakeRepository([recommendation(1), recommendation(2)]) + + recommendations = await service(recommendations=depot).list_all() assert [r.recommendation_id for r in recommendations] == [1, 2] async def test_get_by_id_returns_the_matching_recommendation() -> None: - service = RecommendationService(recommendations=FakeRepository([recommendation(1)])) - - trouve = await service.get_by_id(1) + trouve = await service(recommendations=FakeRepository([recommendation(1)])).get_by_id(1) assert trouve.recommendation_id == 1 async def test_get_by_id_raises_when_the_recommendation_is_unknown() -> None: - service = RecommendationService(recommendations=FakeRepository([])) - with pytest.raises(RecommendationNotFoundError): - await service.get_by_id(404) + await service().get_by_id(404) + + +async def test_generate_persists_one_proposal_per_triggered_rule() -> None: + depot = FakeRepository([]) + + rapport = await service( + recommendations=depot, alerts=FakeAlertRepository([alerte(severity="critical")]) + ).generate() + + assert {n.rule_reference for n in depot.recues} == { + "spike-delestage-v1", + "escalade-astreinte-v1", + } + assert rapport.recommandations_creees == 2 + + +async def test_generate_commits_once() -> None: + transaction = FakeTransaction() + + await service(alerts=FakeAlertRepository([alerte()]), transaction=transaction).generate() + + assert transaction.commits == 1 + + +async def test_generate_restricts_the_alerts_to_the_requested_site() -> None: + alertes = FakeAlertRepository([alerte(1, site_id="SITE001"), alerte(2, site_id="SITE002")]) + depot = FakeRepository([]) + + rapport = await service(recommendations=depot, alerts=alertes).generate(site_id="SITE002") + + assert alertes.site_demande == "SITE002" + assert rapport.alertes_examinees == 1 + assert {n.alert_id for n in depot.recues} == {2} + + +async def test_generate_reports_nothing_when_no_alert_matches() -> None: + rapport = await service().generate() + + assert rapport.alertes_examinees == 0 + assert rapport.recommandations_creees == 0 + assert rapport.deja_presentes == 0 + + +async def test_generate_counts_the_proposals_the_database_already_held() -> None: + depot = FakeRepository([], creees=0) + + rapport = await service( + recommendations=depot, alerts=FakeAlertRepository([alerte()]) + ).generate() + + assert rapport.recommandations_creees == 0 + assert rapport.deja_presentes == 1 diff --git a/apps/backend/tests/services/test_recommendation_rules.py b/apps/backend/tests/services/test_recommendation_rules.py new file mode 100644 index 0000000..28a58fa --- /dev/null +++ b/apps/backend/tests/services/test_recommendation_rules.py @@ -0,0 +1,142 @@ +from datetime import UTC, datetime + +import pytest + +from app.models.energy import Alert +from app.services.recommendation_rules import FACTEUR_DEPASSEMENT_MAJEUR, applique_les_regles + +MOMENT = datetime(2024, 1, 1, tzinfo=UTC) + + +def alerte( + *, + alert_id: int = 1, + type_alerte: str = "spike", + severity: str = "high", + value: float | None = None, + threshold: float | None = None, + metric: str | None = None, + site_id: str = "SITE001", +) -> Alert: + return Alert( + alert_id=alert_id, + source_alert_id=f"ALR-{alert_id}", + site_id=site_id, + source="api_mock", + timestamp=MOMENT, + type=type_alerte, + severity=severity, + message="Alerte de test", + value=value, + threshold=threshold, + metric=metric, + prediction_id=None, + raw_data={}, + ) + + +@pytest.mark.parametrize( + ("type_alerte", "attendue"), + [ + ("spike", "spike-delestage-v1"), + ("threshold", "threshold-reduction-v1"), + ("outage", "outage-secours-v1"), + ("sensor", "sensor-maintenance-v1"), + ("anomaly", "anomaly-verification-v1"), + ], + ids=["pic", "seuil", "coupure", "capteur", "anomalie"], +) +def test_each_alert_type_yields_its_own_rule(type_alerte: str, attendue: str) -> None: + proposees = applique_les_regles(alerte(type_alerte=type_alerte)) + + assert [p.rule_reference for p in proposees] == [attendue] + + +def test_a_critical_alert_adds_the_escalation_rule() -> None: + proposees = applique_les_regles(alerte(severity="critical")) + + assert "escalade-astreinte-v1" in {p.rule_reference for p in proposees} + + +@pytest.mark.parametrize("severity", ["low", "medium", "high"], ids=["faible", "moyenne", "haute"]) +def test_a_non_critical_alert_does_not_escalate(severity: str) -> None: + proposees = applique_les_regles(alerte(severity=severity)) + + assert "escalade-astreinte-v1" not in {p.rule_reference for p in proposees} + + +def test_a_large_overshoot_adds_the_contract_rule() -> None: + proposees = applique_les_regles( + alerte(value=720.0 * FACTEUR_DEPASSEMENT_MAJEUR, threshold=720.0) + ) + + assert "contrat-puissance-v1" in {p.rule_reference for p in proposees} + + +def test_an_overshoot_below_the_factor_does_not_add_the_contract_rule() -> None: + proposees = applique_les_regles(alerte(value=800.0, threshold=720.0)) + + assert "contrat-puissance-v1" not in {p.rule_reference for p in proposees} + + +@pytest.mark.parametrize( + ("value", "threshold"), + [(None, 720.0), (900.0, None), (900.0, 0.0), (900.0, -10.0)], + ids=["sans mesure", "sans seuil", "seuil nul", "seuil negatif"], +) +def test_the_contract_rule_stays_silent_without_an_exploitable_threshold( + value: float | None, threshold: float | None +) -> None: + proposees = applique_les_regles(alerte(value=value, threshold=threshold)) + + assert "contrat-puissance-v1" not in {p.rule_reference for p in proposees} + + +def test_the_explanation_quotes_the_measure_and_the_threshold() -> None: + proposees = applique_les_regles(alerte(value=812.5, threshold=720.0, metric="consumption_kw")) + + assert "(consumption_kw mesurée à 812.5, seuil 720.0)" in proposees[0].explanation + + +def test_the_explanation_quotes_the_measure_alone_when_no_threshold_is_known() -> None: + proposees = applique_les_regles(alerte(value=812.5, metric="consumption_kw")) + + assert "(consumption_kw mesurée à 812.5)" in proposees[0].explanation + + +def test_the_explanation_omits_the_measure_when_the_alert_carries_none() -> None: + proposees = applique_les_regles(alerte()) + + assert "(" not in proposees[0].explanation + + +def test_the_explanation_names_the_site() -> None: + proposees = applique_les_regles(alerte(site_id="SITE042")) + + assert "SITE042" in proposees[0].explanation + + +def test_every_proposal_carries_the_alert_identifier() -> None: + proposees = applique_les_regles(alerte(alert_id=77, severity="critical")) + + assert {p.alert_id for p in proposees} == {77} + + +def test_an_alert_never_yields_the_same_rule_twice() -> None: + proposees = applique_les_regles( + alerte(severity="critical", value=900.0, threshold=720.0, metric="consumption_kw") + ) + + assert len(proposees) == len({p.rule_reference for p in proposees}) + + +def test_a_critical_alert_over_the_threshold_yields_the_three_rules() -> None: + proposees = applique_les_regles( + alerte(severity="critical", value=900.0, threshold=720.0, metric="consumption_kw") + ) + + assert {p.rule_reference for p in proposees} == { + "spike-delestage-v1", + "escalade-astreinte-v1", + "contrat-puissance-v1", + } diff --git a/apps/backend/tests/test_cli.py b/apps/backend/tests/test_cli.py index 7344bf7..2edf814 100644 --- a/apps/backend/tests/test_cli.py +++ b/apps/backend/tests/test_cli.py @@ -118,3 +118,30 @@ def test_main_exports_the_contract_without_asking_for_a_password( assert code == 0 assert destination.exists() assert str(destination) in capsys.readouterr().out + + +def test_build_parser_reads_the_generate_recommendations_arguments() -> None: + arguments = cli.build_parser().parse_args(["generate-recommendations", "--site-id", "SITE002"]) + + assert arguments.commande == "generate-recommendations" + assert arguments.site_id == "SITE002" + + +def test_build_parser_defaults_the_generation_to_every_site() -> None: + arguments = cli.build_parser().parse_args(["generate-recommendations"]) + + assert arguments.site_id is None + + +def test_main_generates_the_recommendations_without_asking_for_a_password( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + async def fausse_generation(*, site_id: str | None) -> str: + return f"génération lancée pour {site_id}" + + monkeypatch.setattr(cli, "generate_recommendations", fausse_generation) + + code = cli.main(["generate-recommendations", "--site-id", "SITE002"]) + + assert code == 0 + assert "SITE002" in capsys.readouterr().out diff --git a/docs/adr/0006-moteur-de-regles-dans-le-backend.md b/docs/adr/0006-moteur-de-regles-dans-le-backend.md new file mode 100644 index 0000000..794825e --- /dev/null +++ b/docs/adr/0006-moteur-de-regles-dans-le-backend.md @@ -0,0 +1,77 @@ +# 0006 - Le moteur de règles de recommandation vit dans le backend + +- Statut : accepté +- Date : 2026-09-18 + +## Contexte + +L'issue #38 demande un « moteur de règles pour recommandations », portée par le label `ml`. Le +schéma tranche déjà la forme du résultat : `recommendation(alert_id, action, explanation, +rule_reference)`, avec `alert_id` en clé étrangère `NOT NULL` et une contrainte d'unicité +`uq_recommendation_alert_rule` sur `(alert_id, rule_reference)`. Une recommandation est donc +**dérivée d'une alerte**, jamais d'une mesure brute ni d'une prévision. + +Deux emplacements se disputaient le code : + +1. `ml/enervision_ml/`, sur le patron de `enervision_ml.score` livré par #37 : un script autonome + qui se connecte par `ML_DATABASE_URL`, écrit une table, et que l'API se contente de lire. + L'[ADR 0005](0005-modele-prediction-lightgbm.md) annonce d'ailleurs #38 de ce côté, en écrivant + que le scoring, le moteur de recommandations et les tests de dérive « consommeront le même + module `enervision_ml.features` ». +2. `apps/backend/app/services/`, où `apps/backend/README.md` place les « regles metier ». + +## Décision + +**Le moteur vit dans `apps/backend/app/services/`**, sous la forme d'un module pur +`recommendation_rules.py` (le catalogue `REGLES`) et d'une méthode `RecommendationService.generate()` +qui l'applique, persiste et valide la transaction. + +Trois raisons : + +- **Il n'utilise rien du ML.** Le catalogue lit `alert.type`, `alert.severity`, `alert.value` et + `alert.threshold`. Aucun modèle, aucune feature, aucun `enervision_ml.features` : la phrase de + l'ADR 0005 vaut pour le scoring (#37) et les tests de dérive (#44/#45), qui manipulent bien des + features, pas pour des règles sur alertes. Le label `ml` de #38 désigne le lot fonctionnel + « prédiction et recommandation », pas l'emplacement du code. +- **Il lit et écrit deux tables déjà couvertes par des repositories.** `AlertRepository` sait déjà + filtrer par site. Le placer dans `ml/` obligerait à réécrire ces accès en SQL brut, et à + maintenir deux représentations du même domaine. +- **Le déclencheur HTTP n'a de sens que dans l'API.** `POST /recommendations/generate` doit passer + par `require_role(Role.ADMIN)` et par la session injectée : cela suppose d'être dans + l'application FastAPI. + +Le moteur reste néanmoins **déclenchable hors HTTP**, par `python -m app.cli +generate-recommendations` (cible `make recommendations`), sur le patron de `make ml-score` : rien +n'oblige à exposer un port pour régénérer des recommandations. + +## Conséquences + +- L'API gagne sa première route d'écriture métier. La checklist de `20-backend.md` s'applique : + entrée dans `ROLE_MINIMUM` de `tests/api/acces.py`, et `openapi.json` régénéré dans le même + commit. +- `RecommendationService` n'est plus en lecture seule : il reçoit le `Transaction` Protocol déjà + utilisé par `AuthService` et `UserService`, et commite lui-même. Les repositories continuent de + ne pas commiter. +- **L'idempotence est déléguée à la base.** `create_missing()` insère en `ON CONFLICT DO NOTHING` + sur `uq_recommendation_alert_rule` plutôt que de relire avant d'écrire, ce qui supprime la + fenêtre entre le contrôle et l'insertion. Corollaire : `rule_reference` est une clé fonctionnelle. + Une règle dont le sens change prend une référence `-v2` ; renommer une référence livrée + ferait réapparaître ses recommandations à côté des anciennes. +- **Le moteur ne produira rien tant que `alert` restera vide.** Aucun code ne produit aujourd'hui + de ligne d'alerte : ni détection interne (#104), ni ingestion de l'API Mock `/alerts`. La chaîne + s'allume d'elle-même le jour où l'une des deux existe, sans retoucher le moteur. +- Si le projet devait un jour pondérer les recommandations par un score appris, la décision serait + à rouvrir : le moteur redeviendrait consommateur du pipeline ML. + +## Alternatives écartées + +- **Module et CLI dans `ml/enervision_ml/`** : cohérent avec le label `ml` et avec la lettre de + l'ADR 0005, mais impose du SQL brut là où deux repositories existent, et laisse la génération + hors de portée de l'API. Redeviendrait le bon choix si les règles se mettaient à consommer des + features ou un modèle. +- **Génération à la volée, sans persistance**, calculée à chaque `GET /recommendations` : supprime + le besoin d'écriture, mais rend la table `recommendation` et sa contrainte d'unicité inutiles, + et interdit toute trace de ce qui a été proposé et quand. +- **Table de configuration des règles en base**, plutôt qu'un catalogue en Python : plus souple, + mais déplace la logique métier hors de la revue de code et hors des tests, pour un besoin que + rien n'exprime à ce stade. diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index a650a1e..2f54762 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -165,3 +165,8 @@ Elles vivent dans `../adr/`, pas ici. | ADR | Objet | |---|---| | [0001](../adr/0001-postgresql-timescaledb.md) | PostgreSQL 17 avec l'extension TimescaleDB, et la frontière `db/` vs `alembic/` | +| [0002](../adr/0002-authentification-jwt-et-refresh-opaque.md) | Authentification par JWT d'accès et jeton de rafraîchissement opaque | +| [0003](../adr/0003-autorisation-rbac-a-trois-roles.md) | Autorisation RBAC à trois rôles, avec relecture du compte à chaque requête | +| [0004](../adr/0004-journal-d-audit-en-ajout-seul.md) | Journal d'audit en ajout seul, garanti par PostgreSQL | +| [0005](../adr/0005-modele-prediction-lightgbm.md) | Modèle de prédiction de consommation : LightGBM | +| [0006](../adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` | diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index 09d3b3d..44d50be 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -146,6 +146,7 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke | GET | `/api/v1/alerts` | Liste les alertes, filtrable par `site_id` et `severity`. `lecteur` | 401, 403, 422, 500 | | GET | `/api/v1/recommendations` | Liste les recommandations. `lecteur` | 401, 403, 500 | | GET | `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation. `lecteur` | 401, 403, 404, 422, 500 | +| POST | `/api/v1/recommendations/generate` | Applique le moteur de règles aux alertes, filtrable par `site_id`. `admin` | 401, 403, 422, 500 | | GET | `/api/v1/stats/summary` | Résume la consommation instantanée du parc. `lecteur` | 401, 403, 500 | | GET | `/api/v1/readings` | Historique des lectures, filtrable par `site_id`, fenêtre `start`/`end` (24h par défaut, 90 jours maximum) et paginé par `limit`/`offset`. `lecteur` | 400, 401, 403, 422, 500 | | GET | `/api/v1/sensors/status` | État de santé des capteurs par site, dérivé de la dernière lecture. `admin` | 401, 403, 500 | @@ -194,6 +195,19 @@ plutôt qu'un statut inventé : le domaine `available`/`insufficient_data`/`erro LightGBM elle-même ; elle lit ce que le pipeline de scoring a déjà écrit, cf. [ML-START.md](../../ML-START.md) section 3. +`POST /recommendations/generate` est la seule route d'écriture métier du contrat. Elle applique +le moteur de règles d'`app/services/recommendation_rules.py` aux lignes d'`alert`, sans modèle ni +feature ML : le catalogue `REGLES` associe à chaque type et à chaque gravité d'alerte une action et +son explication, et une même alerte peut en déclencher plusieurs, comme le prévoit +[40-data.md](40-data.md). L'idempotence est portée par la base, pas par le service : +`RecommendationRepository.create_missing()` insère en `ON CONFLICT DO NOTHING` sur +`uq_recommendation_alert_rule`, donc rejouer la génération sur les mêmes alertes ne crée rien et +le rapport rendu distingue `recommendations_created` de `already_present`. Le même traitement est +disponible hors HTTP par `python -m app.cli generate-recommendations` (cible `make +recommendations`), sur le patron de `make ml-score`. Le choix de loger le moteur dans le backend +plutôt que dans `ml/` est justifié par l'[ADR 0006](../adr/0006-moteur-de-regles-dans-le-backend.md). +Tant qu'aucune source n'alimente `alert`, la route est fonctionnelle mais rend un rapport à zéro. + `GET /readings` reprend le même gabarit mais s'en écarte sur un point : `reading` est l'hypertable, donc la seule table métier pouvant porter des années d'historique, ce que `docs/architecture/ owasp-traceabilite.md` documentait comme un risque ouvert (API4, aucune pagination plafonnée ni diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md index ffd5e6d..b02fd13 100644 --- a/docs/architecture/40-data.md +++ b/docs/architecture/40-data.md @@ -224,6 +224,11 @@ Les anomalies historiques décrites dans les JSON sont conservées dans `dataset.metadata`. Elles servent à l’analyse des données et ne sont pas considérées comme des alertes actuelles. +Les lignes de `recommendation` sont écrites par le moteur de règles du backend +(`app/services/recommendation_rules.py`), déclenché par `POST /api/v1/recommendations/generate` +ou par `make recommendations`. Le couple `(alert_id, rule_reference)` est unique : rejouer le +moteur sur les mêmes alertes n'ajoute aucune ligne. + ### Relations entre les tables - Un site possède plusieurs mesures, prévisions et alertes. From edd5e82d29633cc010e9a2c7dbd785f32aee1ff7 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Fri, 18 Sep 2026 16:04:10 +0200 Subject: [PATCH 75/87] fix --- sonar-project.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sonar-project.properties b/sonar-project.properties index 43c28fd..49c6abe 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -9,7 +9,7 @@ sonar.tests=apps/frontend/src,apps/backend/tests sonar.test.inclusions=**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py # Liste des fichiers et dossiers à exclure de l'analyse -sonar.exclusions=.pytest_cache,.venv,alembic,tests,**/*/node_modules/**,**/*/dist/**,**/*/build/**,**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py +sonar.exclusions=.pytest_cache,.venv,alembic,tests,**/*/node_modules/**,**/*/dist/**,**/*/build/**,**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py,**/*.spec.ts # Chemin vers le rapport de couverture de code # Fichier généré par Pytest From a9e124a97d55445c72a1a8632f05515c19533873 Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 18 Sep 2026 16:10:06 +0200 Subject: [PATCH 76/87] feat(backend): detecte les alertes internes a partir des lectures et previsions --- apps/backend/app/api/deps.py | 7 +- apps/backend/app/detection/__init__.py | 0 apps/backend/app/detection/internal_alerts.py | 68 ++++ apps/backend/app/repositories/alert.py | 34 ++ apps/backend/app/repositories/prediction.py | 15 + apps/backend/app/repositories/reading.py | 12 + apps/backend/app/services/alert.py | 291 ++++++++++++++- apps/backend/tests/repositories/test_alert.py | 57 +++ .../tests/repositories/test_prediction.py | 55 +++ .../tests/repositories/test_reading.py | 50 +++ apps/backend/tests/services/test_alert.py | 347 +++++++++++++++++- apps/backend/tests/test_internal_alerts.py | 65 ++++ docs/architecture/20-backend.md | 29 ++ 13 files changed, 1021 insertions(+), 9 deletions(-) create mode 100644 apps/backend/app/detection/__init__.py create mode 100644 apps/backend/app/detection/internal_alerts.py create mode 100644 apps/backend/tests/test_internal_alerts.py diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index c247f4c..6098403 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -180,7 +180,12 @@ SiteServiceDep = Annotated[SiteService, Depends(get_site_service)] def get_alert_service(session: SessionDep) -> AlertService: - return AlertService(alerts=AlertRepository(session)) + return AlertService( + alerts=AlertRepository(session), + readings=ReadingRepository(session), + predictions=PredictionRepository(session), + sites=SiteRepository(session), + ) AlertServiceDep = Annotated[AlertService, Depends(get_alert_service)] diff --git a/apps/backend/app/detection/__init__.py b/apps/backend/app/detection/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/app/detection/internal_alerts.py b/apps/backend/app/detection/internal_alerts.py new file mode 100644 index 0000000..d7e0785 --- /dev/null +++ b/apps/backend/app/detection/internal_alerts.py @@ -0,0 +1,68 @@ +# Détection d'alertes internes EnerVision (issue #104) : script lancé à la main pour l'instant, +# comme `enervision_ml.score` côté ML, sans automatisation Airflow pour l'ordonnancer. + +from __future__ import annotations + +import argparse +import asyncio +import sys +from datetime import UTC, datetime + +from app.core.config import get_settings +from app.db.session import get_session_factory +from app.repositories.alert import AlertRepository +from app.repositories.prediction import PredictionRepository +from app.repositories.reading import ReadingRepository +from app.repositories.site import SiteRepository +from app.services.alert import AlertService + + +async def run_detection(*, now: datetime | None = None, site_id: str | None = None) -> int: + """Exécute les cinq règles de détection et enregistre les nouvelles alertes. Rend le nombre de + lignes effectivement insérées (les doublons de `source_alert_id` sont silencieusement + ignorés).""" + async with get_session_factory()() as session: + service = AlertService( + alerts=AlertRepository(session), + readings=ReadingRepository(session), + predictions=PredictionRepository(session), + sites=SiteRepository(session), + ) + nouvelles = await service.detect(now=now, site_id=site_id) + await session.commit() + return len(nouvelles) + + +def _parse_instant(valeur: str) -> datetime: + instant = datetime.fromisoformat(valeur) + return instant if instant.tzinfo is not None else instant.replace(tzinfo=UTC) + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="python -m app.detection.internal_alerts", + description="Détection d'alertes internes EnerVision", + ) + parser.add_argument("--site-id", default=None, help="Limite la détection à un seul site.") + parser.add_argument( + "--now", + type=_parse_instant, + default=None, + help=( + "Instant de référence (ISO 8601, UTC si le fuseau est omis). Défaut : l'heure courante." + ), + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + # Échoue tôt si `APP_SECRET_KEY`/`DATABASE_URL` manquent, avant toute requête à la base. + get_settings() + nombre = asyncio.run(run_detection(now=args.now, site_id=args.site_id)) + print(f"{nombre} nouvelle(s) alerte(s) enregistrée(s).") + return 0 + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/apps/backend/app/repositories/alert.py b/apps/backend/app/repositories/alert.py index 4b0766f..f495a3b 100644 --- a/apps/backend/app/repositories/alert.py +++ b/apps/backend/app/repositories/alert.py @@ -1,6 +1,7 @@ from collections.abc import Sequence from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert from sqlalchemy.ext.asyncio import AsyncSession from app.models.energy import Alert @@ -19,3 +20,36 @@ class AlertRepository: if severity is not None: requete = requete.where(Alert.severity == severity) return (await self._session.scalars(requete)).all() + + async def create_many(self, alerts: Sequence[Alert]) -> Sequence[Alert]: + # `ON CONFLICT DO NOTHING` sur `uq_alert_source_reference` : rejouer la détection sur une + # fenêtre qui recouvre une exécution précédente ne doit pas dupliquer une alerte déjà + # enregistrée. `RETURNING` ne renvoie donc que les lignes effectivement insérées. + if not alerts: + return [] + valeurs = [ + { + "source_alert_id": alerte.source_alert_id, + "site_id": alerte.site_id, + "source": alerte.source, + "timestamp": alerte.timestamp, + "type": alerte.type, + "severity": alerte.severity, + "message": alerte.message, + "value": alerte.value, + "threshold": alerte.threshold, + "metric": alerte.metric, + "prediction_id": alerte.prediction_id, + "raw_data": alerte.raw_data, + } + for alerte in alerts + ] + requete = ( + insert(Alert) + .values(valeurs) + .on_conflict_do_nothing(constraint="uq_alert_source_reference") + .returning(Alert) + ) + resultat = await self._session.execute(requete) + await self._session.flush() + return resultat.scalars().all() diff --git a/apps/backend/app/repositories/prediction.py b/apps/backend/app/repositories/prediction.py index f79311a..dd3dc28 100644 --- a/apps/backend/app/repositories/prediction.py +++ b/apps/backend/app/repositories/prediction.py @@ -1,4 +1,5 @@ from collections.abc import Sequence +from datetime import datetime from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -10,6 +11,20 @@ class PredictionRepository: def __init__(self, session: AsyncSession) -> None: self._session = session + async def list_since( + self, *, since: datetime, site_id: str | None = None + ) -> Sequence[Prediction]: + # Restreint à `available` : une prévision `insufficient_data`/`error` n'a pas de + # `predicted_value` à comparer à une lecture réelle (détection d'anomalie). + requete = ( + select(Prediction) + .where(Prediction.target_at >= since, Prediction.status == "available") + .order_by(Prediction.site_id, Prediction.target_at) + ) + if site_id is not None: + requete = requete.where(Prediction.site_id == site_id) + return (await self._session.scalars(requete)).all() + async def latest_by_site(self) -> Sequence[Prediction]: # `.distinct(site_id)` compile en `DISTINCT ON (site_id)` sous PostgreSQL : une seule # ligne par site, la plus récente grâce à l'ordre composite qui suit. Même mécanisme que diff --git a/apps/backend/app/repositories/reading.py b/apps/backend/app/repositories/reading.py index d005d16..3e9cec5 100644 --- a/apps/backend/app/repositories/reading.py +++ b/apps/backend/app/repositories/reading.py @@ -34,6 +34,18 @@ class ReadingRepository: lecture: Reading | None = await self._session.scalar(requete) return lecture + async def list_since(self, *, since: datetime, site_id: str | None = None) -> Sequence[Reading]: + # Trié par site puis par heure croissante : la détection d'alertes (spike) a besoin de + # comparer chaque lecture à celle qui la précède immédiatement pour le même site. + requete = ( + select(Reading) + .where(Reading.timestamp >= since) + .order_by(Reading.site_id, Reading.timestamp) + ) + if site_id is not None: + requete = requete.where(Reading.site_id == site_id) + return (await self._session.scalars(requete)).all() + async def list_history( self, *, diff --git a/apps/backend/app/services/alert.py b/apps/backend/app/services/alert.py index a3ad16e..acae8cf 100644 --- a/apps/backend/app/services/alert.py +++ b/apps/backend/app/services/alert.py @@ -1,14 +1,301 @@ from collections.abc import Sequence +from datetime import UTC, datetime, timedelta -from app.models.energy import Alert +from app.models.energy import Alert, Prediction, Reading, Site from app.repositories.alert import AlertRepository +from app.repositories.prediction import PredictionRepository +from app.repositories.reading import ReadingRepository +from app.repositories.site import SiteRepository + +# Fenêtre de lectures/prédictions analysée à chaque exécution : assez large pour couvrir une paire +# de lectures consécutives (spike) et une coupure prolongée (outage), sans réanalyser tout +# l'historique à chaque lancement manuel du script de détection. +LOOKBACK = timedelta(hours=48) + +# Cadence nominale d'une lecture : le CSV historique comme l'API Mock livrent un pas horaire. +EXPECTED_INTERVAL = timedelta(hours=1) +# Au-delà de trois pas manqués, on parle de coupure plutôt que d'un simple retard d'ingestion. +OUTAGE_THRESHOLD = EXPECTED_INTERVAL * 3 + +# +/-50% entre deux lectures consécutives du même site. +SPIKE_RELATIVE_THRESHOLD = 0.5 +# 30% d'écart entre la consommation réelle et la prévision du même site/instant. +ANOMALY_RELATIVE_THRESHOLD = 0.3 +# Une prévision quasi nulle rend l'écart relatif ininterprétable ; on l'ignore plutôt. +ANOMALY_MINIMUM_PREDICTED_VALUE = 1e-6 + +THRESHOLD_METRIC = "consumption_kw" +ANOMALY_METRIC = "consumption_kwh" +# `data_quality` -> sévérité du capteur défaillant. `good` est volontairement absent : il ne +# déclenche jamais d'alerte. +QUALITE_VERS_SEVERITE: dict[str, str] = { + "partial": "low", + "degraded": "medium", + "critical": "critical", +} class AlertService: - def __init__(self, *, alerts: AlertRepository) -> None: + def __init__( + self, + *, + alerts: AlertRepository, + readings: ReadingRepository, + predictions: PredictionRepository, + sites: SiteRepository, + ) -> None: self._alerts = alerts + self._readings = readings + self._predictions = predictions + self._sites = sites async def list_all( self, *, site_id: str | None = None, severity: str | None = None ) -> Sequence[Alert]: return await self._alerts.list_all(site_id=site_id, severity=severity) + + async def detect( + self, *, now: datetime | None = None, site_id: str | None = None + ) -> Sequence[Alert]: + """Compare les lectures/prévisions récentes aux cinq règles internes et enregistre les + alertes déclenchées (`source='enervision'`). Idempotent grâce à `source_alert_id` : + rejouer sur une fenêtre déjà analysée ne recrée pas les mêmes lignes.""" + instant = now or datetime.now(UTC) + depuis = instant - LOOKBACK + + sites = await self._sites.list_all() + if site_id is not None: + sites = [site for site in sites if site.site_id == site_id] + sites_par_id = {site.site_id: site for site in sites} + if not sites_par_id: + return [] + + lectures = [ + lecture + for lecture in await self._readings.list_since(since=depuis, site_id=site_id) + if lecture.site_id in sites_par_id + ] + predictions = [ + prediction + for prediction in await self._predictions.list_since(since=depuis, site_id=site_id) + if prediction.site_id in sites_par_id + ] + dernieres_lectures = { + lecture.site_id: lecture + for lecture in await self._readings.latest_by_site() + if lecture.site_id in sites_par_id + } + + candidates = [ + *_detect_threshold(lectures, sites_par_id), + *_detect_spike(lectures), + *_detect_anomaly(lectures, predictions), + *_detect_outage(sites, dernieres_lectures, instant), + *_detect_sensor(lectures), + ] + if not candidates: + return [] + return await self._alerts.create_many(candidates) + + +def _severity_from_ratio(ratio: float) -> str: + if ratio >= 2.0: + return "critical" + if ratio >= 1.5: + return "high" + if ratio >= 1.2: + return "medium" + return "low" + + +def _detect_threshold(lectures: Sequence[Reading], sites_par_id: dict[str, Site]) -> list[Alert]: + # Seuil fixe = la capacité déclarée du site : dépasser `capacity_kw` est un dépassement + # matériel, pas une simple variation, et évite un seuil arbitraire non fourni par le domaine. + alertes = [] + for lecture in lectures: + site = sites_par_id[lecture.site_id] + valeur = lecture.consumption_kw + if site.capacity_kw is None or site.capacity_kw <= 0 or valeur is None: + continue + if valeur <= site.capacity_kw: + continue + alertes.append( + Alert( + source_alert_id=f"threshold:{THRESHOLD_METRIC}:{lecture.timestamp.isoformat()}", + site_id=lecture.site_id, + source="enervision", + timestamp=lecture.timestamp, + type="threshold", + severity=_severity_from_ratio(valeur / site.capacity_kw), + message=( + f"Puissance appelée {valeur:.1f} kW au-dessus de la capacité du site " + f"({site.capacity_kw:.1f} kW)" + ), + value=valeur, + threshold=site.capacity_kw, + metric=THRESHOLD_METRIC, + prediction_id=None, + raw_data={}, + ) + ) + return alertes + + +def _detect_spike(lectures: Sequence[Reading]) -> list[Alert]: + # `lectures` est triée par site puis par heure (cf. `ReadingRepository.list_since`) : deux + # lignes consécutives du même site sont donc deux mesures consécutives dans le temps. + alertes = [] + precedente: Reading | None = None + for lecture in lectures: + if precedente is None or precedente.site_id != lecture.site_id: + precedente = lecture + continue + avant, apres = precedente.consumption_kw, lecture.consumption_kw + precedente = lecture + if avant is None or apres is None or avant == 0: + continue + variation = abs(apres - avant) / abs(avant) + if variation < SPIKE_RELATIVE_THRESHOLD: + continue + alertes.append( + Alert( + source_alert_id=f"spike:{THRESHOLD_METRIC}:{lecture.timestamp.isoformat()}", + site_id=lecture.site_id, + source="enervision", + timestamp=lecture.timestamp, + type="spike", + severity=_severity_from_ratio(variation / SPIKE_RELATIVE_THRESHOLD), + message=( + f"Variation brutale de {variation * 100:.0f}% entre deux lectures " + f"consécutives ({avant:.1f} kW -> {apres:.1f} kW)" + ), + value=apres, + threshold=avant, + metric=THRESHOLD_METRIC, + prediction_id=None, + raw_data={}, + ) + ) + return alertes + + +def _detect_anomaly(lectures: Sequence[Reading], predictions: Sequence[Prediction]) -> list[Alert]: + # Alignement strict (site_id, target_at == timestamp) : `enervision_ml.score` produit une + # cible à l'heure pile suivant la dernière lecture, sur la même grille horaire que `reading`. + predictions_par_cle = { + (prediction.site_id, prediction.target_at): prediction + for prediction in predictions + if prediction.target_metric == ANOMALY_METRIC + } + alertes = [] + for lecture in lectures: + prediction = predictions_par_cle.get((lecture.site_id, lecture.timestamp)) + reel = lecture.consumption_kwh + if prediction is None or reel is None or prediction.predicted_value is None: + continue + predite = prediction.predicted_value + if abs(predite) < ANOMALY_MINIMUM_PREDICTED_VALUE: + continue + ecart = abs(reel - predite) / abs(predite) + if ecart < ANOMALY_RELATIVE_THRESHOLD: + continue + alertes.append( + Alert( + source_alert_id=f"anomaly:{ANOMALY_METRIC}:{lecture.timestamp.isoformat()}", + site_id=lecture.site_id, + source="enervision", + timestamp=lecture.timestamp, + type="anomaly", + severity=_severity_from_ratio(ecart / ANOMALY_RELATIVE_THRESHOLD), + message=( + f"Écart de {ecart * 100:.0f}% entre la consommation mesurée ({reel:.1f} kWh) " + f"et la prévision ({predite:.1f} kWh)" + ), + value=reel, + threshold=predite, + metric=ANOMALY_METRIC, + prediction_id=prediction.prediction_id, + raw_data={}, + ) + ) + return alertes + + +def _detect_outage( + sites: Sequence[Site], dernieres_lectures: dict[str, Reading], now: datetime +) -> list[Alert]: + alertes = [] + for site in sites: + derniere = dernieres_lectures.get(site.site_id) + if derniere is None: + alertes.append( + _outage_alert( + site.site_id, + now, + reference=None, + message="Aucune lecture n'a jamais été reçue pour ce site", + severity="critical", + ) + ) + continue + absence = now - derniere.timestamp + if absence < OUTAGE_THRESHOLD: + continue + alertes.append( + _outage_alert( + site.site_id, + now, + reference=derniere.timestamp, + message=( + f"Aucune lecture depuis {absence} (dernière lecture : " + f"{derniere.timestamp.isoformat()})" + ), + severity=_severity_from_ratio(absence / OUTAGE_THRESHOLD), + ) + ) + return alertes + + +def _outage_alert( + site_id: str, now: datetime, *, reference: datetime | None, message: str, severity: str +) -> Alert: + return Alert( + source_alert_id=f"outage:{reference.isoformat() if reference is not None else 'jamais'}", + site_id=site_id, + source="enervision", + timestamp=now, + type="outage", + severity=severity, + message=message, + value=None, + threshold=None, + metric=None, + prediction_id=None, + raw_data={}, + ) + + +def _detect_sensor(lectures: Sequence[Reading]) -> list[Alert]: + alertes = [] + for lecture in lectures: + severite = QUALITE_VERS_SEVERITE.get(lecture.data_quality or "") + if severite is None: + continue + raisons = ", ".join(lecture.null_reasons or []) or "raison non précisée" + alertes.append( + Alert( + source_alert_id=f"sensor:{lecture.timestamp.isoformat()}", + site_id=lecture.site_id, + source="enervision", + timestamp=lecture.timestamp, + type="sensor", + severity=severite, + message=f"Qualité de mesure {lecture.data_quality} ({raisons})", + value=None, + threshold=None, + metric=None, + prediction_id=None, + raw_data={}, + ) + ) + return alertes diff --git a/apps/backend/tests/repositories/test_alert.py b/apps/backend/tests/repositories/test_alert.py index d2a78d0..16c5a9a 100644 --- a/apps/backend/tests/repositories/test_alert.py +++ b/apps/backend/tests/repositories/test_alert.py @@ -89,3 +89,60 @@ async def test_list_all_returns_an_empty_list_when_there_is_nothing( alertes = await depot.list_all(site_id=identifiant_site()) assert list(alertes) == [] + + +def _alerte_a_inserer(*, site_id: str, source_alert_id: str) -> Alert: + return Alert( + source_alert_id=source_alert_id, + site_id=site_id, + source="enervision", + timestamp=datetime(2026, 9, 16, tzinfo=UTC), + type="threshold", + severity="high", + message="Dépassement du seuil configuré", + value=812.5, + threshold=720.0, + metric="consumption_kw", + prediction_id=None, + raw_data={}, + ) + + +async def test_create_many_inserts_every_alert(session: AsyncSession) -> None: + site = await creer_site(session) + depot = AlertRepository(session) + + creees = await depot.create_many( + [ + _alerte_a_inserer(site_id=site.site_id, source_alert_id="threshold:a"), + _alerte_a_inserer(site_id=site.site_id, source_alert_id="threshold:b"), + ] + ) + identifiants = [a.alert_id for a in creees] + await session.rollback() + + assert len(identifiants) == 2 + assert all(identifiant is not None for identifiant in identifiants) + + +async def test_create_many_skips_a_duplicate_source_alert_id(session: AsyncSession) -> None: + site = await creer_site(session) + depot = AlertRepository(session) + await depot.create_many( + [_alerte_a_inserer(site_id=site.site_id, source_alert_id="threshold:rejouee")] + ) + + rejouees = await depot.create_many( + [_alerte_a_inserer(site_id=site.site_id, source_alert_id="threshold:rejouee")] + ) + await session.rollback() + + assert rejouees == [] + + +async def test_create_many_does_nothing_for_an_empty_list(session: AsyncSession) -> None: + depot = AlertRepository(session) + + creees = await depot.create_many([]) + + assert creees == [] diff --git a/apps/backend/tests/repositories/test_prediction.py b/apps/backend/tests/repositories/test_prediction.py index 2be7ddd..708a469 100644 --- a/apps/backend/tests/repositories/test_prediction.py +++ b/apps/backend/tests/repositories/test_prediction.py @@ -29,6 +29,61 @@ async def creer_prediction( return prediction +async def test_list_since_excludes_predictions_before_the_cutoff(session: AsyncSession) -> None: + site = await creer_site(session) + depot = PredictionRepository(session) + dedans = await creer_prediction( + session, site_id=site.site_id, target_at=datetime(2026, 9, 16, tzinfo=UTC) + ) + await creer_prediction( + session, site_id=site.site_id, target_at=datetime(2026, 9, 1, tzinfo=UTC) + ) + + resultats = await depot.list_since( + since=datetime(2026, 9, 10, tzinfo=UTC), site_id=site.site_id + ) + identifiants = [p.prediction_id for p in resultats] + await session.rollback() + + assert identifiants == [dedans.prediction_id] + + +async def test_list_since_excludes_predictions_that_are_not_available( + session: AsyncSession, +) -> None: + site = await creer_site(session) + depot = PredictionRepository(session) + await creer_prediction( + session, + site_id=site.site_id, + target_at=datetime(2026, 9, 16, tzinfo=UTC), + status="insufficient_data", + predicted_value=None, + failure_reason="pas assez d'historique", + ) + + resultats = await depot.list_since(since=datetime(2026, 9, 1, tzinfo=UTC), site_id=site.site_id) + await session.rollback() + + assert list(resultats) == [] + + +async def test_list_since_filters_by_site_id(session: AsyncSession) -> None: + premier = await creer_site(session) + second = await creer_site(session) + depot = PredictionRepository(session) + voulue = await creer_prediction(session, site_id=premier.site_id) + await creer_prediction(session, site_id=second.site_id) + + resultats = await depot.list_since( + since=datetime(2026, 8, 1, tzinfo=UTC), site_id=premier.site_id + ) + identifiants = [p.prediction_id for p in resultats] + await session.rollback() + + assert identifiants == [voulue.prediction_id] + + async def test_latest_by_site_keeps_only_the_most_recent_target(session: AsyncSession) -> None: site = await creer_site(session) depot = PredictionRepository(session) diff --git a/apps/backend/tests/repositories/test_reading.py b/apps/backend/tests/repositories/test_reading.py index 4f12df0..aaff856 100644 --- a/apps/backend/tests/repositories/test_reading.py +++ b/apps/backend/tests/repositories/test_reading.py @@ -155,6 +155,56 @@ async def test_latest_for_site_ignores_the_readings_of_the_other_sites( assert trouvee is None +async def test_list_since_orders_by_site_then_by_time_ascending(session: AsyncSession) -> None: + site = await creer_site(session) + depot = ReadingRepository(session) + plus_recente = await creer_lecture( + session, site_id=site.site_id, timestamp=datetime(2026, 9, 16, tzinfo=UTC) + ) + plus_ancienne = await creer_lecture( + session, site_id=site.site_id, timestamp=datetime(2026, 9, 15, tzinfo=UTC) + ) + + resultats = await depot.list_since(since=datetime(2026, 9, 1, tzinfo=UTC), site_id=site.site_id) + identifiants = [r.reading_id for r in resultats] + await session.rollback() + + assert identifiants == [plus_ancienne.reading_id, plus_recente.reading_id] + + +async def test_list_since_excludes_readings_before_the_cutoff(session: AsyncSession) -> None: + site = await creer_site(session) + depot = ReadingRepository(session) + dedans = await creer_lecture( + session, site_id=site.site_id, timestamp=datetime(2026, 9, 16, tzinfo=UTC) + ) + await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2026, 9, 1, tzinfo=UTC)) + + resultats = await depot.list_since( + since=datetime(2026, 9, 10, tzinfo=UTC), site_id=site.site_id + ) + identifiants = [r.reading_id for r in resultats] + await session.rollback() + + assert identifiants == [dedans.reading_id] + + +async def test_list_since_filters_by_site_id(session: AsyncSession) -> None: + premier = await creer_site(session) + second = await creer_site(session) + depot = ReadingRepository(session) + voulue = await creer_lecture(session, site_id=premier.site_id) + await creer_lecture(session, site_id=second.site_id) + + resultats = await depot.list_since( + since=datetime(2026, 8, 1, tzinfo=UTC), site_id=premier.site_id + ) + identifiants = [r.reading_id for r in resultats] + await session.rollback() + + assert identifiants == [voulue.reading_id] + + async def test_list_history_orders_the_readings_by_timestamp_descending( session: AsyncSession, ) -> None: diff --git a/apps/backend/tests/services/test_alert.py b/apps/backend/tests/services/test_alert.py index 4a88802..c99fd6f 100644 --- a/apps/backend/tests/services/test_alert.py +++ b/apps/backend/tests/services/test_alert.py @@ -1,7 +1,10 @@ -from datetime import UTC, datetime +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta from app.models.energy import Alert -from app.services.alert import AlertService +from app.services.alert import OUTAGE_THRESHOLD, AlertService, _severity_from_ratio + +NOW = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) def alert( @@ -26,10 +29,36 @@ def alert( ) +@dataclass +class FauxSite: + site_id: str + capacity_kw: float | None = None + + +@dataclass +class FauxLecture: + site_id: str + timestamp: datetime + consumption_kw: float | None = None + consumption_kwh: float | None = None + data_quality: str | None = None + null_reasons: list[str] | None = None + + +@dataclass +class FauxPrediction: + site_id: str + target_at: datetime + predicted_value: float | None + target_metric: str = "consumption_kwh" + prediction_id: int = 1 + + class FakeRepository: def __init__(self, alerts: list[Alert]) -> None: self._alerts = alerts self.appels: list[tuple[str | None, str | None]] = [] + self.crees: list[Alert] = [] async def list_all( self, *, site_id: str | None = None, severity: str | None = None @@ -37,19 +66,325 @@ class FakeRepository: self.appels.append((site_id, severity)) return self._alerts + async def create_many(self, alerts: list[Alert]) -> list[Alert]: + self.crees = list(alerts) + return self.crees + + +@dataclass +class FauxDepotLectures: + depuis: list[FauxLecture] = field(default_factory=list) + dernieres: list[FauxLecture] = field(default_factory=list) + + async def list_since(self, *, since: datetime, site_id: str | None = None) -> list[FauxLecture]: + return [lecture for lecture in self.depuis if site_id is None or lecture.site_id == site_id] + + async def latest_by_site(self) -> list[FauxLecture]: + return self.dernieres + + +@dataclass +class FauxDepotPredictions: + predictions: list[FauxPrediction] = field(default_factory=list) + + async def list_since( + self, *, since: datetime, site_id: str | None = None + ) -> list[FauxPrediction]: + return [p for p in self.predictions if site_id is None or p.site_id == site_id] + + +@dataclass +class FauxDepotSites: + sites: list[FauxSite] + + async def list_all(self) -> list[FauxSite]: + return self.sites + + +def service( + *, + sites: list[FauxSite], + lectures: list[FauxLecture] | None = None, + dernieres: list[FauxLecture] | None = None, + predictions: list[FauxPrediction] | None = None, + alerts: FakeRepository | None = None, +) -> tuple[AlertService, FakeRepository]: + depot_alertes = alerts or FakeRepository([]) + dernieres_lectures = dernieres if dernieres is not None else (lectures or []) + return ( + AlertService( + alerts=depot_alertes, # type: ignore[arg-type] + readings=FauxDepotLectures(depuis=lectures or [], dernieres=dernieres_lectures), # type: ignore[arg-type] + predictions=FauxDepotPredictions(predictions or []), # type: ignore[arg-type] + sites=FauxDepotSites(sites), # type: ignore[arg-type] + ), + depot_alertes, + ) + async def test_list_all_returns_the_repository_alerts() -> None: - service = AlertService(alerts=FakeRepository([alert(1), alert(2)])) + svc, _ = service(sites=[], alerts=FakeRepository([alert(1), alert(2)])) - alertes = await service.list_all() + alertes = await svc.list_all() assert [a.alert_id for a in alertes] == [1, 2] async def test_list_all_relays_the_filters_to_the_repository() -> None: depot = FakeRepository([]) - service = AlertService(alerts=depot) + svc, _ = service(sites=[], alerts=depot) - await service.list_all(site_id="site-1", severity="critical") + await svc.list_all(site_id="site-1", severity="critical") assert depot.appels == [("site-1", "critical")] + + +async def test_detect_raises_a_threshold_alert_above_site_capacity() -> None: + svc, depot = service( + sites=[FauxSite("A", capacity_kw=100.0)], + lectures=[FauxLecture("A", NOW, consumption_kw=150.0)], + ) + + await svc.detect(now=NOW) + + (candidate,) = depot.crees + assert candidate.type == "threshold" + assert candidate.severity == "high" + assert candidate.value == 150.0 + assert candidate.threshold == 100.0 + assert candidate.metric == "consumption_kw" + + +async def test_detect_ignores_a_reading_within_capacity() -> None: + svc, depot = service( + sites=[FauxSite("A", capacity_kw=100.0)], + lectures=[FauxLecture("A", NOW, consumption_kw=80.0)], + ) + + await svc.detect(now=NOW) + + assert depot.crees == [] + + +async def test_detect_ignores_threshold_when_the_site_has_no_declared_capacity() -> None: + svc, depot = service( + sites=[FauxSite("A", capacity_kw=None)], + lectures=[FauxLecture("A", NOW, consumption_kw=9999.0)], + ) + + await svc.detect(now=NOW) + + assert depot.crees == [] + + +async def test_detect_raises_a_spike_alert_on_a_brutal_consecutive_variation() -> None: + svc, depot = service( + sites=[FauxSite("A")], + lectures=[ + FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=100.0), + FauxLecture("A", NOW, consumption_kw=160.0), + ], + ) + + await svc.detect(now=NOW) + + (candidate,) = [a for a in depot.crees if a.type == "spike"] + assert candidate.value == 160.0 + assert candidate.threshold == 100.0 + assert candidate.timestamp == NOW + + +async def test_detect_ignores_a_moderate_consecutive_variation() -> None: + svc, depot = service( + sites=[FauxSite("A")], + lectures=[ + FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=100.0), + FauxLecture("A", NOW, consumption_kw=110.0), + ], + ) + + await svc.detect(now=NOW) + + assert [a for a in depot.crees if a.type == "spike"] == [] + + +async def test_detect_never_compares_consecutive_readings_across_two_sites() -> None: + svc, depot = service( + sites=[FauxSite("A"), FauxSite("B")], + lectures=[ + FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=10.0), + FauxLecture("B", NOW, consumption_kw=1000.0), + ], + ) + + await svc.detect(now=NOW) + + assert [a for a in depot.crees if a.type == "spike"] == [] + + +async def test_detect_raises_an_anomaly_alert_far_from_the_matching_prediction() -> None: + svc, depot = service( + sites=[FauxSite("A")], + lectures=[FauxLecture("A", NOW, consumption_kwh=100.0)], + predictions=[FauxPrediction("A", target_at=NOW, predicted_value=70.0)], + ) + + await svc.detect(now=NOW) + + (candidate,) = [a for a in depot.crees if a.type == "anomaly"] + assert candidate.value == 100.0 + assert candidate.threshold == 70.0 + assert candidate.metric == "consumption_kwh" + assert candidate.prediction_id == 1 + + +async def test_detect_ignores_a_reading_close_to_its_prediction() -> None: + svc, depot = service( + sites=[FauxSite("A")], + lectures=[FauxLecture("A", NOW, consumption_kwh=100.0)], + predictions=[FauxPrediction("A", target_at=NOW, predicted_value=95.0)], + ) + + await svc.detect(now=NOW) + + assert [a for a in depot.crees if a.type == "anomaly"] == [] + + +async def test_detect_ignores_a_prediction_whose_target_at_does_not_match_the_reading() -> None: + svc, depot = service( + sites=[FauxSite("A")], + lectures=[FauxLecture("A", NOW, consumption_kwh=100.0)], + predictions=[FauxPrediction("A", target_at=NOW - timedelta(hours=1), predicted_value=1.0)], + ) + + await svc.detect(now=NOW) + + assert [a for a in depot.crees if a.type == "anomaly"] == [] + + +async def test_detect_raises_an_outage_alert_past_the_threshold() -> None: + derniere = NOW - OUTAGE_THRESHOLD - timedelta(minutes=1) + svc, depot = service( + sites=[FauxSite("A")], + lectures=[], + dernieres=[FauxLecture("A", derniere)], + ) + + await svc.detect(now=NOW) + + (candidate,) = [a for a in depot.crees if a.type == "outage"] + assert candidate.severity in {"low", "medium", "high", "critical"} + + +async def test_detect_ignores_a_site_still_within_the_outage_threshold() -> None: + derniere = NOW - OUTAGE_THRESHOLD + timedelta(minutes=1) + svc, depot = service( + sites=[FauxSite("A")], + lectures=[], + dernieres=[FauxLecture("A", derniere)], + ) + + await svc.detect(now=NOW) + + assert [a for a in depot.crees if a.type == "outage"] == [] + + +async def test_detect_raises_a_critical_outage_alert_for_a_site_never_read() -> None: + svc, depot = service(sites=[FauxSite("A")], lectures=[], dernieres=[]) + + await svc.detect(now=NOW) + + (candidate,) = [a for a in depot.crees if a.type == "outage"] + assert candidate.severity == "critical" + assert candidate.source_alert_id == "outage:jamais" + + +async def test_detect_raises_a_sensor_alert_on_a_degraded_reading() -> None: + svc, depot = service( + sites=[FauxSite("A")], + lectures=[FauxLecture("A", NOW, data_quality="critical", null_reasons=["missing:x"])], + ) + + await svc.detect(now=NOW) + + (candidate,) = [a for a in depot.crees if a.type == "sensor"] + assert candidate.severity == "critical" + + +async def test_detect_ignores_a_good_quality_reading_for_the_sensor_rule() -> None: + svc, depot = service( + sites=[FauxSite("A")], + lectures=[FauxLecture("A", NOW, data_quality="good")], + ) + + await svc.detect(now=NOW) + + assert [a for a in depot.crees if a.type == "sensor"] == [] + + +async def test_detect_scopes_to_a_single_site_when_asked() -> None: + svc, depot = service( + sites=[FauxSite("A", capacity_kw=100.0), FauxSite("B", capacity_kw=100.0)], + lectures=[ + FauxLecture("A", NOW, consumption_kw=150.0), + FauxLecture("B", NOW, consumption_kw=150.0), + ], + ) + + await svc.detect(now=NOW, site_id="A") + + assert {a.site_id for a in depot.crees} == {"A"} + + +async def test_detect_returns_early_when_there_is_no_site() -> None: + svc, depot = service(sites=[]) + + resultat = await svc.detect(now=NOW) + + assert resultat == [] + assert depot.crees == [] + + +async def test_detect_ignores_a_spike_when_the_previous_reading_is_zero() -> None: + svc, depot = service( + sites=[FauxSite("A")], + lectures=[ + FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=0.0), + FauxLecture("A", NOW, consumption_kw=50.0), + ], + ) + + await svc.detect(now=NOW) + + assert [a for a in depot.crees if a.type == "spike"] == [] + + +async def test_detect_ignores_an_anomaly_when_the_prediction_is_near_zero() -> None: + svc, depot = service( + sites=[FauxSite("A")], + lectures=[FauxLecture("A", NOW, consumption_kwh=5.0)], + predictions=[FauxPrediction("A", target_at=NOW, predicted_value=0.0)], + ) + + await svc.detect(now=NOW) + + assert [a for a in depot.crees if a.type == "anomaly"] == [] + + +def test_severity_from_ratio_covers_every_band() -> None: + assert _severity_from_ratio(1.0) == "low" + assert _severity_from_ratio(1.2) == "medium" + assert _severity_from_ratio(1.5) == "high" + assert _severity_from_ratio(2.0) == "critical" + + +async def test_detect_does_not_call_create_many_when_nothing_triggers() -> None: + svc, depot = service( + sites=[FauxSite("A", capacity_kw=100.0)], + lectures=[FauxLecture("A", NOW, consumption_kw=10.0, data_quality="good")], + ) + + resultat = await svc.detect(now=NOW) + + assert resultat == [] + assert depot.crees == [] diff --git a/apps/backend/tests/test_internal_alerts.py b/apps/backend/tests/test_internal_alerts.py new file mode 100644 index 0000000..4740150 --- /dev/null +++ b/apps/backend/tests/test_internal_alerts.py @@ -0,0 +1,65 @@ +from datetime import UTC, datetime + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.detection import internal_alerts +from app.repositories.alert import AlertRepository +from tests.repositories.test_reading import creer_lecture +from tests.repositories.test_site import creer as creer_site + + +def test_parse_args_defaults_to_no_site_and_no_instant() -> None: + arguments = internal_alerts.parse_args([]) + + assert arguments.site_id is None + assert arguments.now is None + + +def test_parse_args_reads_the_site_id() -> None: + arguments = internal_alerts.parse_args(["--site-id", "site-1"]) + + assert arguments.site_id == "site-1" + + +def test_parse_args_parses_the_instant_option() -> None: + arguments = internal_alerts.parse_args(["--now", "2026-09-16T12:00:00+00:00"]) + + assert arguments.now == datetime(2026, 9, 16, 12, tzinfo=UTC) + + +def test_parse_instant_treats_a_naive_datetime_as_utc() -> None: + assert internal_alerts._parse_instant("2026-09-16T12:00:00") == datetime( + 2026, 9, 16, 12, tzinfo=UTC + ) + + +def test_main_prints_how_many_alerts_were_recorded( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + async def fausse_execution(*, now: datetime | None, site_id: str | None) -> int: + return 3 + + monkeypatch.setattr(internal_alerts, "run_detection", fausse_execution) + + code = internal_alerts.main([]) + + assert code == 0 + assert "3 nouvelle" in capsys.readouterr().out + + +@pytest.mark.integration +async def test_run_detection_writes_a_threshold_alert_end_to_end(session: AsyncSession) -> None: + site = await creer_site(session, capacity_kw=100.0) + instant = datetime(2026, 9, 16, 12, tzinfo=UTC) + await creer_lecture(session, site_id=site.site_id, timestamp=instant, consumption_kw=150.0) + await session.commit() + + nombre = await internal_alerts.run_detection(now=instant, site_id=site.site_id) + + alertes = await AlertRepository(session).list_all(site_id=site.site_id) + types = [a.type for a in alertes] + await session.rollback() + + assert nombre == 1 + assert types == ["threshold"] diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index 09d3b3d..8c3641d 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -207,6 +207,35 @@ par exemple `limit` hors bornes). Un datetime sans fuseau dans `start`/`end` est l'UTC plutôt que rejeté : le comparer tel quel à `reading.timestamp` (`timestamptz`) échouerait côté pilote, en `500` plutôt qu'un refus propre. +### Détection d'alertes internes + +`AlertService` n'est plus lecture seule : `AlertService.detect()` compare les `reading` (et, pour +le type `anomaly`, les `prediction`) des dernières 48h (`LOOKBACK`) à cinq règles et enregistre une +ligne `alert` par déclenchement, avec `source="enervision"`. `metric`/`value`/`threshold` gardent +leur sens dans chaque règle plutôt que d'être laissés à `null` par commodité : + +| `type` | Règle | `value` / `threshold` | +|---|---|---| +| `threshold` | `reading.consumption_kw` dépasse `site.capacity_kw` (site sans capacité déclarée : ignoré) | mesure / capacité du site | +| `spike` | Variation relative ≥ 50% (`SPIKE_RELATIVE_THRESHOLD`) entre deux lectures consécutives du même site | mesure actuelle / mesure précédente | +| `anomaly` | Écart relatif ≥ 30% (`ANOMALY_RELATIVE_THRESHOLD`) entre `reading.consumption_kwh` et la `prediction` du même site dont `target_at == timestamp` | mesure réelle / valeur prédite | +| `outage` | Aucune lecture depuis plus de 3h (`OUTAGE_THRESHOLD`, 3x la cadence horaire nominale), ou site jamais lu | `null` / `null` | +| `sensor` | `reading.data_quality` ∈ `partial`/`degraded`/`critical` | `null` / `null` | + +La sévérité de chaque alerte (hors `sensor`, dérivée directement de `data_quality`) suit le même +barème par ratio observé/seuil : `low` sous 1.2, `medium` sous 1.5, `high` sous 2.0, `critical` +au-delà. `AlertRepository.create_many()` insère par lot avec `ON CONFLICT DO NOTHING` sur +`uq_alert_source_reference`, et `source_alert_id` est construit de façon déterministe (règle + +horodatage) : rejouer la détection sur une fenêtre déjà analysée ne duplique donc jamais une +alerte. + +Comme `enervision_ml.score`, la détection est un script lancé à la main, pas encore ordonnancé par +Airflow : `uv run python -m app.detection.internal_alerts [--site-id ...] [--now ...]`, dans +`apps/backend` puisque les règles s'appuient sur les repositories ORM de l'API plutôt que sur une +connexion SQL directe (contrairement à `app/etl/historical_import.py`). Cette issue (#104) +débloquait #38 (moteur de règles pour recommandations), dont la FK `alert_id` `NOT NULL` n'avait +jusqu'ici rien à référencer côté `source="enervision"`. + ### `/health/ready` Cette sonde porte une garde décrite dans l'[ADR 0001](../adr/0001-postgresql-timescaledb.md) : un From c059f838bbbc45c440bde45e498a94c1f42a365f Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 18 Sep 2026 16:58:03 +0200 Subject: [PATCH 77/87] fix(backend): fiabilise le tri des lectures/predictions et la detection de redemarrage a zero --- apps/backend/app/repositories/prediction.py | 8 ++- apps/backend/app/repositories/reading.py | 5 +- apps/backend/app/services/alert.py | 60 +++++++++++----- .../tests/repositories/test_prediction.py | 24 +++++++ .../tests/repositories/test_reading.py | 23 +++++++ apps/backend/tests/services/test_alert.py | 69 ++++++++++++++++++- apps/backend/tests/test_internal_alerts.py | 36 ++++++++-- docs/architecture/20-backend.md | 13 +++- 8 files changed, 208 insertions(+), 30 deletions(-) diff --git a/apps/backend/app/repositories/prediction.py b/apps/backend/app/repositories/prediction.py index dd3dc28..5939899 100644 --- a/apps/backend/app/repositories/prediction.py +++ b/apps/backend/app/repositories/prediction.py @@ -16,10 +16,16 @@ class PredictionRepository: ) -> Sequence[Prediction]: # Restreint à `available` : une prévision `insufficient_data`/`error` n'a pas de # `predicted_value` à comparer à une lecture réelle (détection d'anomalie). + # Piège : `prediction` n'a pas d'unicité sur `(site_id, target_at)` (cf. + # `enervision_ml.score`, qui insère toujours une nouvelle ligne plutôt que d'écraser la + # précédente). `prediction_id` en dernier départage donc les égalités de `target_at` par + # ordre croissant : `_detect_anomaly` construit un dict qui garde le dernier rencontré, + # c'est-à-dire le run le plus récent plutôt qu'une ligne choisie au hasard par le plan + # d'exécution. requete = ( select(Prediction) .where(Prediction.target_at >= since, Prediction.status == "available") - .order_by(Prediction.site_id, Prediction.target_at) + .order_by(Prediction.site_id, Prediction.target_at, Prediction.prediction_id) ) if site_id is not None: requete = requete.where(Prediction.site_id == site_id) diff --git a/apps/backend/app/repositories/reading.py b/apps/backend/app/repositories/reading.py index 3e9cec5..82a8565 100644 --- a/apps/backend/app/repositories/reading.py +++ b/apps/backend/app/repositories/reading.py @@ -37,10 +37,13 @@ class ReadingRepository: async def list_since(self, *, since: datetime, site_id: str | None = None) -> Sequence[Reading]: # Trié par site puis par heure croissante : la détection d'alertes (spike) a besoin de # comparer chaque lecture à celle qui la précède immédiatement pour le même site. + # `reading_id` en dernier départage : `uq_reading_source` autorise deux lignes au même + # `site_id`+`timestamp` quand la `source` diffère (même piège que `latest_for_site`), sans + # quoi l'ordre entre elles ne serait pas garanti d'un appel à l'autre. requete = ( select(Reading) .where(Reading.timestamp >= since) - .order_by(Reading.site_id, Reading.timestamp) + .order_by(Reading.site_id, Reading.timestamp, Reading.reading_id) ) if site_id is not None: requete = requete.where(Reading.site_id == site_id) diff --git a/apps/backend/app/services/alert.py b/apps/backend/app/services/alert.py index acae8cf..44a1db7 100644 --- a/apps/backend/app/services/alert.py +++ b/apps/backend/app/services/alert.py @@ -142,43 +142,65 @@ def _detect_threshold(lectures: Sequence[Reading], sites_par_id: dict[str, Site] def _detect_spike(lectures: Sequence[Reading]) -> list[Alert]: - # `lectures` est triée par site puis par heure (cf. `ReadingRepository.list_since`) : deux - # lignes consécutives du même site sont donc deux mesures consécutives dans le temps. + # `lectures` est triée par site, heure puis `reading_id` (cf. `ReadingRepository.list_since`) : + # deux lignes consécutives du même site sont donc deux mesures consécutives dans le temps, + # sauf lorsqu'elles partagent le même horodatage (deux `source` différentes pour le même + # instant, permises par `uq_reading_source`) : ce n'est alors pas une variation réelle, on + # l'ignore plutôt que de générer une fausse alerte figée par son `source_alert_id`. alertes = [] precedente: Reading | None = None for lecture in lectures: - if precedente is None or precedente.site_id != lecture.site_id: + if ( + precedente is None + or precedente.site_id != lecture.site_id + or precedente.timestamp == lecture.timestamp + ): precedente = lecture continue avant, apres = precedente.consumption_kw, lecture.consumption_kw precedente = lecture - if avant is None or apres is None or avant == 0: + if avant is None or apres is None: + continue + if avant == 0: + # Une variation relative n'a pas de sens depuis zéro, mais un redémarrage direct à + # une consommation positive reste le signal le plus alarmant du lot : `critical` + # plutôt qu'un ratio indéfini. + if apres > 0: + alertes.append(_spike_alert(lecture, avant, apres, severity="critical")) continue variation = abs(apres - avant) / abs(avant) if variation < SPIKE_RELATIVE_THRESHOLD: continue alertes.append( - Alert( - source_alert_id=f"spike:{THRESHOLD_METRIC}:{lecture.timestamp.isoformat()}", - site_id=lecture.site_id, - source="enervision", - timestamp=lecture.timestamp, - type="spike", + _spike_alert( + lecture, + avant, + apres, severity=_severity_from_ratio(variation / SPIKE_RELATIVE_THRESHOLD), - message=( - f"Variation brutale de {variation * 100:.0f}% entre deux lectures " - f"consécutives ({avant:.1f} kW -> {apres:.1f} kW)" - ), - value=apres, - threshold=avant, - metric=THRESHOLD_METRIC, - prediction_id=None, - raw_data={}, ) ) return alertes +def _spike_alert(lecture: Reading, avant: float, apres: float, *, severity: str) -> Alert: + return Alert( + source_alert_id=f"spike:{THRESHOLD_METRIC}:{lecture.timestamp.isoformat()}", + site_id=lecture.site_id, + source="enervision", + timestamp=lecture.timestamp, + type="spike", + severity=severity, + message=( + f"Variation brutale entre deux lectures consécutives ({avant:.1f} kW -> {apres:.1f} kW)" + ), + value=apres, + threshold=avant, + metric=THRESHOLD_METRIC, + prediction_id=None, + raw_data={}, + ) + + def _detect_anomaly(lectures: Sequence[Reading], predictions: Sequence[Prediction]) -> list[Alert]: # Alignement strict (site_id, target_at == timestamp) : `enervision_ml.score` produit une # cible à l'heure pile suivant la dernière lecture, sur la même grille horaire que `reading`. diff --git a/apps/backend/tests/repositories/test_prediction.py b/apps/backend/tests/repositories/test_prediction.py index 708a469..da71aea 100644 --- a/apps/backend/tests/repositories/test_prediction.py +++ b/apps/backend/tests/repositories/test_prediction.py @@ -68,6 +68,30 @@ async def test_list_since_excludes_predictions_that_are_not_available( assert list(resultats) == [] +async def test_list_since_breaks_a_target_at_tie_by_ascending_prediction_id( + session: AsyncSession, +) -> None: + # `prediction` n'a pas d'unicité sur `(site_id, target_at)` : deux runs de scoring sans + # nouvelle lecture entre-temps produisent deux lignes `available` à la même cible. Sans ce + # départage, `_detect_anomaly` retiendrait une ligne au hasard plutôt que le run le plus + # récent. + site = await creer_site(session) + depot = PredictionRepository(session) + cible = datetime(2026, 9, 16, tzinfo=UTC) + premier_run = await creer_prediction( + session, site_id=site.site_id, target_at=cible, predicted_value=10.0 + ) + second_run = await creer_prediction( + session, site_id=site.site_id, target_at=cible, predicted_value=20.0 + ) + + resultats = await depot.list_since(since=datetime(2026, 9, 1, tzinfo=UTC), site_id=site.site_id) + identifiants = [p.prediction_id for p in resultats] + await session.rollback() + + assert identifiants == [premier_run.prediction_id, second_run.prediction_id] + + async def test_list_since_filters_by_site_id(session: AsyncSession) -> None: premier = await creer_site(session) second = await creer_site(session) diff --git a/apps/backend/tests/repositories/test_reading.py b/apps/backend/tests/repositories/test_reading.py index aaff856..ac3f854 100644 --- a/apps/backend/tests/repositories/test_reading.py +++ b/apps/backend/tests/repositories/test_reading.py @@ -189,6 +189,29 @@ async def test_list_since_excludes_readings_before_the_cutoff(session: AsyncSess assert identifiants == [dedans.reading_id] +async def test_list_since_breaks_a_timestamp_tie_by_ascending_reading_id( + session: AsyncSession, +) -> None: + # `uq_reading_source` autorise deux lignes au même `site_id`+`timestamp` quand la `source` + # diffère (même piège que `latest_for_site`). Sans ce départage, `_detect_spike` traiterait + # cette paire comme une variation réelle selon un ordre non garanti par le plan d'exécution. + site = await creer_site(session) + depot = ReadingRepository(session) + horodatage = datetime(2026, 9, 16, tzinfo=UTC) + premiere = await creer_lecture( + session, site_id=site.site_id, timestamp=horodatage, source="api_history", consumption_kw=10 + ) + seconde = await creer_lecture( + session, site_id=site.site_id, timestamp=horodatage, source="api_current", consumption_kw=42 + ) + + resultats = await depot.list_since(since=datetime(2026, 9, 1, tzinfo=UTC), site_id=site.site_id) + identifiants = [r.reading_id for r in resultats] + await session.rollback() + + assert identifiants == [premiere.reading_id, seconde.reading_id] + + async def test_list_since_filters_by_site_id(session: AsyncSession) -> None: premier = await creer_site(session) second = await creer_site(session) diff --git a/apps/backend/tests/services/test_alert.py b/apps/backend/tests/services/test_alert.py index c99fd6f..97b2b0a 100644 --- a/apps/backend/tests/services/test_alert.py +++ b/apps/backend/tests/services/test_alert.py @@ -262,6 +262,28 @@ async def test_detect_ignores_a_prediction_whose_target_at_does_not_match_the_re assert [a for a in depot.crees if a.type == "anomaly"] == [] +async def test_detect_keeps_the_most_recent_run_when_two_predictions_share_the_same_target() -> ( + None +): + # `PredictionRepository.list_since` départage les égalités de `target_at` par `prediction_id` + # croissant : le repository fait donc déjà passer le run le plus récent en dernier dans la + # liste, et c'est ce dernier que le dict de `_detect_anomaly` doit retenir. + svc, depot = service( + sites=[FauxSite("A")], + lectures=[FauxLecture("A", NOW, consumption_kwh=100.0)], + predictions=[ + FauxPrediction("A", target_at=NOW, predicted_value=100.0, prediction_id=1), + FauxPrediction("A", target_at=NOW, predicted_value=70.0, prediction_id=2), + ], + ) + + await svc.detect(now=NOW) + + (candidate,) = [a for a in depot.crees if a.type == "anomaly"] + assert candidate.threshold == 70.0 + assert candidate.prediction_id == 2 + + async def test_detect_raises_an_outage_alert_past_the_threshold() -> None: derniere = NOW - OUTAGE_THRESHOLD - timedelta(minutes=1) svc, depot = service( @@ -345,7 +367,35 @@ async def test_detect_returns_early_when_there_is_no_site() -> None: assert depot.crees == [] -async def test_detect_ignores_a_spike_when_the_previous_reading_is_zero() -> None: +async def test_detect_ignores_a_spike_pair_with_a_missing_measurement() -> None: + svc, depot = service( + sites=[FauxSite("A")], + lectures=[ + FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=None), + FauxLecture("A", NOW, consumption_kw=160.0), + ], + ) + + await svc.detect(now=NOW) + + assert [a for a in depot.crees if a.type == "spike"] == [] + + +async def test_detect_ignores_a_reading_still_at_zero_after_a_previous_zero() -> None: + svc, depot = service( + sites=[FauxSite("A")], + lectures=[ + FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=0.0), + FauxLecture("A", NOW, consumption_kw=0.0), + ], + ) + + await svc.detect(now=NOW) + + assert [a for a in depot.crees if a.type == "spike"] == [] + + +async def test_detect_raises_a_critical_spike_when_a_site_restarts_from_zero() -> None: svc, depot = service( sites=[FauxSite("A")], lectures=[ @@ -356,6 +406,23 @@ async def test_detect_ignores_a_spike_when_the_previous_reading_is_zero() -> Non await svc.detect(now=NOW) + (candidate,) = [a for a in depot.crees if a.type == "spike"] + assert candidate.severity == "critical" + assert candidate.value == 50.0 + assert candidate.threshold == 0.0 + + +async def test_detect_ignores_a_spike_pair_sharing_the_same_timestamp() -> None: + svc, depot = service( + sites=[FauxSite("A")], + lectures=[ + FauxLecture("A", NOW, consumption_kw=100.0), + FauxLecture("A", NOW, consumption_kw=160.0), + ], + ) + + await svc.detect(now=NOW) + assert [a for a in depot.crees if a.type == "spike"] == [] diff --git a/apps/backend/tests/test_internal_alerts.py b/apps/backend/tests/test_internal_alerts.py index 4740150..690ac00 100644 --- a/apps/backend/tests/test_internal_alerts.py +++ b/apps/backend/tests/test_internal_alerts.py @@ -1,8 +1,10 @@ from datetime import UTC, datetime import pytest +from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession +from app.db.session import get_session_factory from app.detection import internal_alerts from app.repositories.alert import AlertRepository from tests.repositories.test_reading import creer_lecture @@ -50,16 +52,36 @@ def test_main_prints_how_many_alerts_were_recorded( @pytest.mark.integration async def test_run_detection_writes_a_threshold_alert_end_to_end(session: AsyncSession) -> None: + # `run_detection` ouvre sa propre session et commite : `session.rollback()` seul ne défait + # rien ici (contrairement au reste de la suite), d'où le nettoyage explicite ci-dessous, sur + # le modèle de `tests/api/test_matrice_acces.py`. site = await creer_site(session, capacity_kw=100.0) + site_id = site.site_id instant = datetime(2026, 9, 16, 12, tzinfo=UTC) - await creer_lecture(session, site_id=site.site_id, timestamp=instant, consumption_kw=150.0) + await creer_lecture(session, site_id=site_id, timestamp=instant, consumption_kw=150.0) await session.commit() - nombre = await internal_alerts.run_detection(now=instant, site_id=site.site_id) + try: + nombre = await internal_alerts.run_detection(now=instant, site_id=site_id) - alertes = await AlertRepository(session).list_all(site_id=site.site_id) - types = [a.type for a in alertes] - await session.rollback() + alertes = await AlertRepository(session).list_all(site_id=site_id) + types = [a.type for a in alertes] + await session.rollback() - assert nombre == 1 - assert types == ["threshold"] + assert nombre == 1 + assert types == ["threshold"] + finally: + # `site.site_id` n'est plus sûr après `session.rollback()` : le rollback expire tous les + # objets de la session (indépendamment d'`expire_on_commit`), et y accéder ici relance une + # requête hors contexte async. D'où `site_id`, capturé avant. + async with get_session_factory()() as nettoyage: + await nettoyage.execute( + text("delete from alert where site_id = :site_id"), {"site_id": site_id} + ) + await nettoyage.execute( + text("delete from reading where site_id = :site_id"), {"site_id": site_id} + ) + await nettoyage.execute( + text("delete from site where site_id = :site_id"), {"site_id": site_id} + ) + await nettoyage.commit() diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index 8c3641d..731ff21 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -217,7 +217,7 @@ leur sens dans chaque règle plutôt que d'être laissés à `null` par commodit | `type` | Règle | `value` / `threshold` | |---|---|---| | `threshold` | `reading.consumption_kw` dépasse `site.capacity_kw` (site sans capacité déclarée : ignoré) | mesure / capacité du site | -| `spike` | Variation relative ≥ 50% (`SPIKE_RELATIVE_THRESHOLD`) entre deux lectures consécutives du même site | mesure actuelle / mesure précédente | +| `spike` | Variation relative ≥ 50% (`SPIKE_RELATIVE_THRESHOLD`) entre deux lectures consécutives du même site, ou redémarrage direct à une valeur positive depuis zéro (`critical`) | mesure actuelle / mesure précédente | | `anomaly` | Écart relatif ≥ 30% (`ANOMALY_RELATIVE_THRESHOLD`) entre `reading.consumption_kwh` et la `prediction` du même site dont `target_at == timestamp` | mesure réelle / valeur prédite | | `outage` | Aucune lecture depuis plus de 3h (`OUTAGE_THRESHOLD`, 3x la cadence horaire nominale), ou site jamais lu | `null` / `null` | | `sensor` | `reading.data_quality` ∈ `partial`/`degraded`/`critical` | `null` / `null` | @@ -229,6 +229,17 @@ au-delà. `AlertRepository.create_many()` insère par lot avec `ON CONFLICT DO N horodatage) : rejouer la détection sur une fenêtre déjà analysée ne duplique donc jamais une alerte. +**Pièges de tri corrigés en revue** : `reading`/`prediction` n'ont pas d'unicité sur leur couple +métier (`uq_reading_source` autorise deux `source` différentes au même `site_id`+`timestamp`, +`prediction` n'a aucune contrainte sur `(site_id, target_at)`, chaque run de scoring gardant sa +propre ligne). `ReadingRepository.list_since()`/`PredictionRepository.list_since()` départagent +donc les égalités par `reading_id`/`prediction_id` croissant, comme le font déjà +`latest_by_site()`/`latest_for_site()` sur les mêmes tables ; sans ce départage, l'ordre entre +lignes à égalité n'est pas garanti d'un appel à l'autre, et `_detect_spike`/`_detect_anomaly` +auraient pu comparer des lectures/choisir une prévision au hasard. `_detect_spike` ignore en plus +explicitement les paires de lectures qui partagent le même horodatage (deux `source` pour un seul +instant réel, pas une variation). + Comme `enervision_ml.score`, la détection est un script lancé à la main, pas encore ordonnancé par Airflow : `uv run python -m app.detection.internal_alerts [--site-id ...] [--now ...]`, dans `apps/backend` puisque les règles s'appuient sur les repositories ORM de l'API plutôt que sur une From 0ddfb1997d5cb37329a3c76ad55a569d4fabf268 Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Fri, 18 Sep 2026 14:43:04 +0200 Subject: [PATCH 78/87] =?UTF-8?q?feat(apps):=20configure=20la=20connexion?= =?UTF-8?q?=20=C3=A0=20l'API=20Mock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 ++++++ apps/backend/.env.example | 4 ++++ apps/backend/app/core/config.py | 5 +++++ apps/backend/pyproject.toml | 2 +- apps/backend/uv.lock | 4 ++-- docker-compose.yml | 6 ++++++ 6 files changed, 24 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 54dc3d8..250573f 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,9 @@ APP_LOG_LEVEL=INFO APP_SECRET_KEY=change_me APP_CORS_ORIGINS=http://localhost:4200 BACKEND_PORT=8000 + +# API Mock EnerVision +APP_MOCK_API_BASE_URL=https://api-mock.charlieandre.fr +APP_MOCK_API_USERNAME=change_me +APP_MOCK_API_PASSWORD=change_me +APP_MOCK_API_TIMEOUT_SECONDS=10 diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 8dff67f..da73dbb 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -18,3 +18,7 @@ APP_SMTP_HOST=localhost APP_SMTP_PORT=1025 APP_SMTP_USE_TLS=false APP_SMTP_FROM_ADDRESS=no-reply@enervision.fr +APP_MOCK_API_BASE_URL=https://api-mock.charlieandre.fr +APP_MOCK_API_USERNAME=change_me +APP_MOCK_API_PASSWORD=change_me +APP_MOCK_API_TIMEOUT_SECONDS=10 diff --git a/apps/backend/app/core/config.py b/apps/backend/app/core/config.py index e374709..e622ea7 100644 --- a/apps/backend/app/core/config.py +++ b/apps/backend/app/core/config.py @@ -34,6 +34,11 @@ class Settings(BaseSettings): database_pool_size: int = 5 database_max_overflow: int = 10 + mock_api_base_url: str = "https://api-mock.charlieandre.fr" + mock_api_username: str | None = None + mock_api_password: SecretStr | None = None + mock_api_timeout_seconds: float = Field(default=10.0, gt=0) + jwt_issuer: str = "enervision-api" jwt_audience: str = "enervision-web" access_token_ttl_seconds: int = Field(default=900, ge=60, le=3600) diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index 5336ba6..cfe6481 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "argon2-cffi>=23.1", "anyio>=4.0", "aiosmtplib>=5.1.3", + "httpx>=0.28.1", "pandas>=3.0.5", ] @@ -27,7 +28,6 @@ dev = [ "pytest>=9.1.1", "pytest-asyncio>=1.4.0", "pytest-cov>=7.1.0", - "httpx>=0.28.1", "pandas-stubs>=3.0.5.260914", ] diff --git a/apps/backend/uv.lock b/apps/backend/uv.lock index 59ff61b..a8b434f 100644 --- a/apps/backend/uv.lock +++ b/apps/backend/uv.lock @@ -326,6 +326,7 @@ dependencies = [ { name = "argon2-cffi" }, { name = "asyncpg" }, { name = "fastapi" }, + { name = "httpx" }, { name = "pandas" }, { name = "prometheus-fastapi-instrumentator" }, { name = "pydantic", extra = ["email"] }, @@ -338,7 +339,6 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "httpx" }, { name = "mypy" }, { name = "pandas-stubs" }, { name = "pytest" }, @@ -355,6 +355,7 @@ requires-dist = [ { name = "argon2-cffi", specifier = ">=23.1" }, { name = "asyncpg", specifier = ">=0.31.0" }, { name = "fastapi", specifier = ">=0.141.1" }, + { name = "httpx", specifier = ">=0.28.1" }, { name = "pandas", specifier = ">=3.0.5" }, { name = "prometheus-fastapi-instrumentator", specifier = ">=8.1.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.13.5" }, @@ -367,7 +368,6 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "httpx", specifier = ">=0.28.1" }, { name = "mypy", specifier = ">=2.3.1" }, { name = "pandas-stubs", specifier = ">=3.0.5.260914" }, { name = "pytest", specifier = ">=9.1.1" }, diff --git a/docker-compose.yml b/docker-compose.yml index 3f7f9ea..91e315e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,6 +50,12 @@ services: APP_SECRET_KEY: ${APP_SECRET_KEY:?} APP_CORS_ORIGINS: ${APP_CORS_ORIGINS:-http://localhost:4200} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + + APP_MOCK_API_BASE_URL: ${APP_MOCK_API_BASE_URL:?} + APP_MOCK_API_USERNAME: ${APP_MOCK_API_USERNAME:?} + APP_MOCK_API_PASSWORD: ${APP_MOCK_API_PASSWORD:?} + APP_MOCK_API_TIMEOUT_SECONDS: ${APP_MOCK_API_TIMEOUT_SECONDS:-10} + APP_FRONTEND_RESET_PASSWORD_URL: ${APP_FRONTEND_RESET_PASSWORD_URL:-http://localhost:4200/reset-password} APP_SMTP_HOST: mailpit APP_SMTP_PORT: "1025" From 0318ee6cc5881177d0b0ab65717e4f8b0ff0a8b7 Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Fri, 18 Sep 2026 14:43:29 +0200 Subject: [PATCH 79/87] feat(apps): ajoute l'import depuis l'API Mock --- apps/backend/app/etl/mock_api_import.py | 315 ++++++++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 apps/backend/app/etl/mock_api_import.py diff --git a/apps/backend/app/etl/mock_api_import.py b/apps/backend/app/etl/mock_api_import.py new file mode 100644 index 0000000..f143f16 --- /dev/null +++ b/apps/backend/app/etl/mock_api_import.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +import argparse +import asyncio +import json +from datetime import datetime +from typing import Any + +import httpx +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine + +from app.core.config import get_settings + +SOURCE_HISTORY = "api_history" + + +def create_mock_api_client() -> httpx.AsyncClient: + settings = get_settings() + + if settings.mock_api_username is None or settings.mock_api_password is None: + raise ValueError("Les identifiants de l'API Mock ne sont pas configurés.") + + return httpx.AsyncClient( + base_url=settings.mock_api_base_url.rstrip("/"), + auth=( + settings.mock_api_username, + settings.mock_api_password.get_secret_value(), + ), + timeout=settings.mock_api_timeout_seconds, + ) + + +async def fetch_sites( + client: httpx.AsyncClient, +) -> list[dict[str, Any]]: + response = await client.get("/api/v1/sites") + + response.raise_for_status() + + payload = response.json() + + if not isinstance(payload, list): + raise ValueError("La réponse /api/v1/sites doit être une liste.") + + return payload + + +async def upsert_sites( + connection: AsyncConnection, + sites: list[dict[str, Any]], +) -> None: + if not sites: + return + + await connection.execute( + text( + """ + INSERT INTO site ( + site_id, + site_type, + site_name, + location, + capacity_kw, + status + ) + VALUES ( + :site_id, + :site_type, + :site_name, + :location, + :capacity_kw, + :status + ) + ON CONFLICT (site_id) + DO UPDATE SET + site_type = EXCLUDED.site_type, + site_name = EXCLUDED.site_name, + location = EXCLUDED.location, + capacity_kw = EXCLUDED.capacity_kw, + status = EXCLUDED.status + """ + ), + sites, + ) + + +async def fetch_readings( + client: httpx.AsyncClient, + site_id: str, + start_time: datetime, + end_time: datetime, + limit: int = 1000, +) -> list[dict[str, Any]]: + response = await client.get( + "/api/v1/readings", + params={ + "site_id": site_id, + "start_time": start_time.isoformat(), + "end_time": end_time.isoformat(), + "limit": limit, + }, + ) + + response.raise_for_status() + + payload = response.json() + + if not isinstance(payload, list): + raise ValueError("La réponse /api/v1/readings doit être une liste.") + + return payload + + +def build_reading_row( + reading: dict[str, Any], +) -> dict[str, Any]: + timestamp = datetime.fromisoformat(reading["timestamp"].replace("Z", "+00:00")) + return { + "site_id": reading["site_id"], + "timestamp": timestamp, + "source": SOURCE_HISTORY, + "dataset_id": None, + "consumption_kw": reading.get("consumption_kw"), + "consumption_kwh": reading.get("consumption_kwh"), + "consumption_euros": None, + "voltage_v": reading.get("voltage_v"), + "current_a": reading.get("current_a"), + "power_factor": reading.get("power_factor"), + "temperature_celsius": reading.get("temperature_celsius"), + "humidity_percent": reading.get("humidity_percent"), + "solar_irradiance_wm2": None, + "is_working_hours": None, + "data_quality": reading.get("data_quality"), + "null_reasons": reading.get("null_reasons"), + "imputed_values": None, + "imputation_method": None, + "raw_data": json.dumps( + reading, + ensure_ascii=False, + ), + } + + +READING_INSERT = text( + """ + INSERT INTO reading ( + site_id, + timestamp, + source, + dataset_id, + consumption_kw, + consumption_kwh, + consumption_euros, + voltage_v, + current_a, + power_factor, + temperature_celsius, + humidity_percent, + solar_irradiance_wm2, + is_working_hours, + data_quality, + null_reasons, + imputed_values, + imputation_method, + raw_data + ) + VALUES ( + :site_id, + :timestamp, + :source, + :dataset_id, + :consumption_kw, + :consumption_kwh, + :consumption_euros, + :voltage_v, + :current_a, + :power_factor, + :temperature_celsius, + :humidity_percent, + :solar_irradiance_wm2, + :is_working_hours, + :data_quality, + :null_reasons, + CAST(:imputed_values AS jsonb), + :imputation_method, + CAST(:raw_data AS jsonb) + ) + ON CONFLICT DO NOTHING + """ +) + + +def build_reading_batch( + readings: list[dict[str, Any]], +) -> list[dict[str, Any]]: + return [build_reading_row(reading) for reading in readings] + + +async def import_mock_api_history( + start_time: datetime, + end_time: datetime, + limit: int, + dry_run: bool, +) -> None: + settings = get_settings() + + async with create_mock_api_client() as client: + sites = await fetch_sites(client) + + print(f"Sites récupérés : {len(sites)}") + + all_readings: list[dict[str, Any]] = [] + + for site in sites: + site_id = site["site_id"] + + readings = await fetch_readings( + client=client, + site_id=site_id, + start_time=start_time, + end_time=end_time, + limit=limit, + ) + + print(f"{site_id}: {len(readings)} lectures") + + all_readings.extend(readings) + + print(f"Lectures récupérées : {len(all_readings)}") + + if dry_run: + print("Dry-run terminé : aucune donnée écrite.") + return + + engine = create_async_engine( + str(settings.database_url), + pool_pre_ping=True, + ) + + try: + async with engine.begin() as connection: + await upsert_sites( + connection, + sites, + ) + + rows = build_reading_batch(all_readings) + + if rows: + await connection.execute( + READING_INSERT, + rows, + ) + + finally: + await engine.dispose() + + print("Import API Mock terminé.") + + +def parse_datetime(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=("Import historique depuis l'API Mock EnerVision")) + + parser.add_argument( + "--start-time", + required=True, + type=parse_datetime, + ) + + parser.add_argument( + "--end-time", + required=True, + type=parse_datetime, + ) + + parser.add_argument( + "--limit", + type=int, + default=1000, + ) + + parser.add_argument( + "--dry-run", + action="store_true", + ) + + return parser.parse_args() + + +def main() -> None: + args = parse_args() + + if args.limit < 1 or args.limit > 1000: + raise ValueError("--limit doit être compris entre 1 et 1000.") + + if args.start_time >= args.end_time: + raise ValueError("--start-time doit être antérieur à --end-time.") + + asyncio.run( + import_mock_api_history( + start_time=args.start_time, + end_time=args.end_time, + limit=args.limit, + dry_run=args.dry_run, + ) + ) + + +if __name__ == "__main__": + main() From e66ef867297c7faae2e5d445be6a883c0f804232 Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Fri, 18 Sep 2026 14:43:49 +0200 Subject: [PATCH 80/87] test(apps): couvre l'import depuis l'API Mock --- .../backend/tests/etl/test_mock_api_import.py | 304 ++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 apps/backend/tests/etl/test_mock_api_import.py diff --git a/apps/backend/tests/etl/test_mock_api_import.py b/apps/backend/tests/etl/test_mock_api_import.py new file mode 100644 index 0000000..c51a153 --- /dev/null +++ b/apps/backend/tests/etl/test_mock_api_import.py @@ -0,0 +1,304 @@ +import json +from datetime import datetime +from typing import Any + +import httpx +import pytest +from httpx import AsyncClient, MockTransport, Request, Response +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.etl.mock_api_import import ( + READING_INSERT, + SOURCE_HISTORY, + build_reading_batch, + build_reading_row, + fetch_readings, + fetch_sites, +) + + +def make_site() -> dict[str, Any]: + return { + "site_id": "SITE001", + "site_type": "office", + "site_name": "Bureau Paris La Défense", + "location": "Paris, France", + "capacity_kw": 200, + "status": "active", + } + + +def make_reading() -> dict[str, Any]: + return { + "timestamp": "2024-06-15T12:00:00Z", + "site_id": "SITE001", + "site_type": "office", + "consumption_kw": 87.34, + "consumption_kwh": 87.34, + "voltage_v": 401.2, + "current_a": 132.5, + "power_factor": 0.923, + "temperature_celsius": 22.1, + "humidity_percent": 58.4, + "null_reasons": [], + "data_quality": "good", + } + + +async def test_fetch_sites_returns_sites() -> None: + def handler(request: Request) -> Response: + assert request.url.path == "/api/v1/sites" + return Response( + status_code=200, + json=[make_site()], + ) + + transport = MockTransport(handler) + + async with AsyncClient( + transport=transport, + base_url="https://mock.test", + ) as client: + sites = await fetch_sites(client) + + assert len(sites) == 1 + assert sites[0]["site_id"] == "SITE001" + assert sites[0]["site_type"] == "office" + + +async def test_fetch_readings_sends_expected_query_parameters() -> None: + captured_params: dict[str, str] = {} + + def handler(request: Request) -> Response: + nonlocal captured_params + + captured_params = dict(request.url.params) + + return Response( + status_code=200, + json=[make_reading()], + ) + + transport = MockTransport(handler) + + start_time = datetime.fromisoformat("2024-06-15T12:00:00") + end_time = datetime.fromisoformat("2024-06-15T13:00:00") + + async with AsyncClient( + transport=transport, + base_url="https://mock.test", + ) as client: + readings = await fetch_readings( + client=client, + site_id="SITE001", + start_time=start_time, + end_time=end_time, + limit=60, + ) + + assert len(readings) == 1 + assert captured_params["site_id"] == "SITE001" + assert captured_params["start_time"] == "2024-06-15T12:00:00" + assert captured_params["end_time"] == "2024-06-15T13:00:00" + assert captured_params["limit"] == "60" + + +async def test_fetch_readings_rejects_non_list_response() -> None: + def handler(request: Request) -> Response: + return Response( + status_code=200, + json={"unexpected": "payload"}, + ) + + transport = MockTransport(handler) + + async with AsyncClient( + transport=transport, + base_url="https://mock.test", + ) as client: + with pytest.raises( + ValueError, + match="La réponse /api/v1/readings doit être une liste", + ): + await fetch_readings( + client=client, + site_id="SITE001", + start_time=datetime.fromisoformat("2024-06-15T12:00:00"), + end_time=datetime.fromisoformat("2024-06-15T13:00:00"), + limit=60, + ) + + +async def test_fetch_readings_raises_on_http_error() -> None: + def handler(request: Request) -> Response: + return Response( + status_code=404, + json={"detail": "Site non trouvé"}, + ) + + transport = MockTransport(handler) + + async with AsyncClient( + transport=transport, + base_url="https://mock.test", + ) as client: + with pytest.raises(httpx.HTTPStatusError): + await fetch_readings( + client=client, + site_id="SITE999", + start_time=datetime.fromisoformat("2024-06-15T12:00:00"), + end_time=datetime.fromisoformat("2024-06-15T13:00:00"), + limit=60, + ) + + +def test_build_reading_row_respects_database_contract() -> None: + reading = make_reading() + + row = build_reading_row(reading) + + assert row["site_id"] == "SITE001" + assert row["source"] == SOURCE_HISTORY + assert row["source"] == "api_history" + assert row["dataset_id"] is None + + assert row["timestamp"] == datetime.fromisoformat("2024-06-15T12:00:00+00:00") + + assert row["consumption_kw"] == 87.34 + assert row["consumption_kwh"] == 87.34 + assert row["data_quality"] == "good" + assert row["null_reasons"] == [] + + assert row["imputed_values"] is None + assert row["imputation_method"] is None + + +def test_build_reading_row_keeps_null_values_and_quality() -> None: + reading = make_reading() + + reading["consumption_kw"] = None + reading["consumption_kwh"] = None + reading["voltage_v"] = None + reading["current_a"] = None + reading["power_factor"] = None + reading["data_quality"] = "degraded" + reading["null_reasons"] = [ + "consumption_sensor_failure", + "electrical_sensor_failure", + ] + + row = build_reading_row(reading) + + assert row["consumption_kw"] is None + assert row["consumption_kwh"] is None + assert row["voltage_v"] is None + assert row["current_a"] is None + assert row["power_factor"] is None + + assert row["data_quality"] == "degraded" + assert row["null_reasons"] == [ + "consumption_sensor_failure", + "electrical_sensor_failure", + ] + + assert row["imputed_values"] is None + assert row["imputation_method"] is None + + +def test_build_reading_row_keeps_raw_source_data() -> None: + reading = make_reading() + + row = build_reading_row(reading) + + raw_data = json.loads(row["raw_data"]) + + assert raw_data == reading + + +def test_build_reading_batch_transforms_all_readings() -> None: + first = make_reading() + + second = make_reading() + second["timestamp"] = "2024-06-15T12:01:00Z" + second["consumption_kw"] = 90.5 + + rows = build_reading_batch([first, second]) + + assert len(rows) == 2 + + assert rows[0]["site_id"] == "SITE001" + assert rows[0]["consumption_kw"] == 87.34 + + assert rows[1]["site_id"] == "SITE001" + assert rows[1]["consumption_kw"] == 90.5 + + +@pytest.mark.integration +async def test_reading_insert_is_idempotent( + session: AsyncSession, +) -> None: + reading = make_reading() + row = build_reading_row(reading) + + await session.execute( + text( + """ + INSERT INTO site ( + site_id, + site_type, + site_name, + location, + capacity_kw, + status + ) + VALUES ( + :site_id, + :site_type, + :site_name, + :location, + :capacity_kw, + :status + ) + ON CONFLICT (site_id) + DO UPDATE SET + site_type = EXCLUDED.site_type, + site_name = EXCLUDED.site_name, + location = EXCLUDED.location, + capacity_kw = EXCLUDED.capacity_kw, + status = EXCLUDED.status + """ + ), + make_site(), + ) + + await session.execute( + READING_INSERT, + [row], + ) + + await session.execute( + READING_INSERT, + [row], + ) + + result = await session.execute( + text( + """ + SELECT COUNT(*) + FROM reading + WHERE site_id = :site_id + AND timestamp = :timestamp + AND source = :source + """ + ), + { + "site_id": row["site_id"], + "timestamp": row["timestamp"], + "source": row["source"], + }, + ) + + assert result.scalar_one() == 1 + + await session.rollback() From 96dd1f834c66041b4c8d849cc99b4b5b4f6c1873 Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Fri, 18 Sep 2026 14:44:25 +0200 Subject: [PATCH 81/87] docs: documente l'ingestion depuis l'API Mock --- docs/architecture/40-data.md | 401 +++++++++++++++++++++++++++++------ etl/README.md | 329 ++++++++++++++++++++++++++-- 2 files changed, 639 insertions(+), 91 deletions(-) diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md index ffd5e6d..65a4d62 100644 --- a/docs/architecture/40-data.md +++ b/docs/architecture/40-data.md @@ -6,10 +6,14 @@ système qui en découle. ## Ce que couvre ce document -**Dix tables applicatives existent** : quatre pour l'authentification, six pour les données -d'énergie, dont l'hypertable `reading`. Les sections marquées `Fait` relèvent le code. Celles -marquées `Cible` décrivent ce qui n'est pas écrit, au premier rang desquelles la chaîne -d'ingestion, les agrégats continus, la compression et la rétention. +**Douze tables applicatives existent** : six pour l'authentification et six pour les données +d'énergie, dont l'hypertable `reading`. + +Les sections marquées `Fait` relèvent du code déjà implémenté. Les sections marquées `Cible` +décrivent les éléments prévus mais pas encore réalisés. + +L'ingestion des deux sources de données du MVP est maintenant implémentée. L'orchestration +Airflow, les agrégats continus, la compression et la rétention restent des cibles. ## Trois emplacements, trois rôles @@ -35,8 +39,16 @@ Statut : `Fait`. - `db/init/100-extensions.sql` crée l'extension `timescaledb`. - `db/init/110-test-database.sql` crée `enervision_test`, dont le nom est attendu en dur par `apps/backend/tests/conftest.py`. -- Cinq révisions Alembic. La première, `5353c0e4f094`, **ne crée aucune table** : elle - établit `alembic_version` et refuse de s'appliquer si l'extension manque : +- Six révisions Alembic sont actuellement appliquées. +- La première, `5353c0e4f094`, **ne crée aucune table** : elle établit `alembic_version` + et refuse de s'appliquer si l'extension TimescaleDB manque. +- Les révisions suivantes créent les tables liées à l'authentification : + `app_user`, `login_attempt`, `audit_log` et `refresh_token`. +- La révision `e6d2026091501` crée les six tables Data et déclare l'hypertable `reading`. +- La révision `c0adab96238c` ajoute les tables `password_reset_attempt` + et `password_reset_token`. + +La garde de la première migration est : ```sql IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') THEN @@ -47,37 +59,58 @@ END IF; Cette garde forme paire avec le 503 de `/api/v1/health/ready`. Un bootstrap sauté ne se voit pas au démarrage de l'API : ces deux gardes le rendent visible tôt, des deux côtés. -Les trois suivantes créent les tables de l'authentification, décrites plus bas : `app_user`, -puis `login_attempt` et `audit_log`, puis `refresh_token`. La cinquième, `e6d2026091501`, crée -les six tables de données décrites en fin de document et déclare l'hypertable `reading`. - ## Cycle de vie d'une mesure -Statut : `Cible`, sauf l'hypertable `reading` qui existe. Ni l'ingestion, ni les agrégats -continus, ni la compression, ni la rétention ne sont écrits. +Statut : `Partiellement fait`. + +Les mécanismes d'ingestion sont maintenant implémentés pour les deux sources de données du MVP : + +- le dataset historique CSV/JSON avec `historical_import.py` ; +- l'API Mock avec `mock_api_import.py`. + +Les traitements sont actuellement exécutables directement depuis le backend. + +L'orchestration avec Apache Airflow reste une cible, tout comme les agrégats continus, +la compression et les politiques de rétention. ```mermaid flowchart LR - src["Source de mesures"] -.-> ing["Ingestion Airflow"] - ing -.-> hy[("Hypertable reading")] + csv["CSV + JSON"] --> hist["historical_import.py"] + mock["API Mock"] --> api["mock_api_import.py"] + + hist --> hy[("Hypertable reading")] + api --> hy + + airflow["Airflow"] -.-> hist + airflow -.-> api + hy -.-> agg[("Agrégat continu")] hy -.-> comp["Compression"] hy -.-> ret["Rétention"] - agg -.-> api["API FastAPI"] + + agg -.-> backend["API FastAPI"] agg -.-> graf["Grafana"] ``` -Les lectures de l'API et de Grafana visent l'agrégat continu, pas la table brute : c'est tout -l'intérêt de TimescaleDB, et cela doit rester vrai quand les volumes augmenteront. +Les flèches pleines représentent les traitements actuellement implémentés. + +Les flèches pointillées représentent les éléments encore prévus comme cibles. + +Les lectures futures de l'API et de Grafana visent l'agrégat continu plutôt que la table brute +lorsque cette partie TimescaleDB sera mise en place. ## Tables d'authentification -Statut : `Fait`. Elles ne sont pas des séries temporelles et n'ont donc rien à voir avec les -hypertables ; elles vivent dans `apps/backend/alembic/`, qui porte le schéma exposé par l'API. +Statut : `Fait`. + +Elles ne sont pas des séries temporelles et n'ont donc rien à voir avec les hypertables ; +elles vivent dans `apps/backend/alembic/`, qui porte le schéma exposé par l'API. ```mermaid erDiagram APP_USER ||--o{ REFRESH_TOKEN : ouvre + APP_USER ||--o{ PASSWORD_RESET_TOKEN : recoit + APP_USER { uuid id PK string email UK @@ -88,6 +121,7 @@ erDiagram bool must_change_password timestamptz credentials_changed_at } + REFRESH_TOKEN { uuid id PK uuid family_id @@ -99,6 +133,7 @@ erDiagram text revoked_reason uuid replaced_by } + LOGIN_ATTEMPT { bigint id PK timestamptz occurred_at @@ -106,6 +141,7 @@ erDiagram inet client_ip text outcome } + AUDIT_LOG { bigint id PK timestamptz occurred_at @@ -114,31 +150,47 @@ erDiagram text action jsonb detail } + + PASSWORD_RESET_ATTEMPT { + bigint id PK + timestamptz occurred_at + string email_tried + inet client_ip + } + + PASSWORD_RESET_TOKEN { + uuid id PK + uuid user_id FK + bytea token_hash UK + timestamptz issued_at + timestamptz expires_at + timestamptz consumed_at + inet client_ip + text user_agent + } ``` -Quatre choix de modélisation portent une intention et se défendent seuls : +Plusieurs choix de modélisation portent une intention précise : - **`app_user` et non `user`** : `user` est un mot réservé PostgreSQL, raccourci de - `CURRENT_USER`. Le nom rappelle en prime qu'il s'agit d'un compte applicatif, par opposition - au rôle PostgreSQL qui portera le cantonnement de l'ETL. -- **`credentials_changed_at`, une seule colonne**, couvre le changement de mot de passe, le - changement de rôle et la désactivation. Un compteur de version ne dirait rien à un humain qui - lit un audit. -- **`refresh_token.expires_at` est absolu et hérité** du prédécesseur à chaque rotation. S'il - glissait, la promesse de sept jours serait fictive et une session active ne finirait jamais. -- **`audit_log.actor_id` n'a aucune clé étrangère**, et `actor_email` comme `actor_role` sont - dénormalisés. Une contrainte `ON DELETE SET NULL` déclencherait un `UPDATE` que le déclencheur - d'ajout seul refuserait. Voir l'[ADR 0004](../adr/0004-journal-d-audit-en-ajout-seul.md). + `CURRENT_USER`. Le nom rappelle aussi qu'il s'agit d'un compte applicatif. +- **`credentials_changed_at`, une seule colonne**, couvre notamment le changement de mot de passe, + le changement de rôle et la désactivation. +- **`refresh_token.expires_at` est absolu et hérité** du prédécesseur à chaque rotation. +- **`audit_log.actor_id` n'a aucune clé étrangère** afin de conserver les informations d'audit + même si l'entité d'origine évolue. +- `password_reset_token` ne stocke que l'empreinte du jeton et jamais sa valeur directement. +- `password_reset_attempt` est séparée de `audit_log`, car son volume peut être piloté + par des demandes externes répétées. -`audit_log` porte deux déclencheurs qui refusent `UPDATE`, `DELETE` et `TRUNCATE`. Elle n'est -donc **pas** une hypertable : une politique de rétention émettrait des `DELETE` qu'ils -refuseraient. `login_attempt`, à l'inverse, est faite pour se purger, puisque son volume est -piloté par l'attaquant. +`audit_log` porte des déclencheurs qui refusent `UPDATE`, `DELETE` et `TRUNCATE`. +Elle n'est donc **pas** une hypertable. ## Gabarit de révision créant une hypertable -Conforme à la règle de l'ADR 0001 : table et hypertable dans la même révision. La révision -`e6d2026091501` en est l'exemple réel, réduit ici à l'essentiel. +Conforme à la règle de l'ADR 0001 : table et hypertable dans la même révision. + +La révision `e6d2026091501` en est l'exemple réel, réduit ici à l'essentiel. ```python def upgrade() -> None: @@ -182,24 +234,28 @@ colonne de temps : les index déclarés dans la révision le couvrent déjà. ## Questions ouvertes -Elles relèvent du jalon J2, « valider le périmètre retenu ». Le schéma est livré : ce qui suit -porte sur son exploitation, plus sur sa forme. +Elles portent maintenant principalement sur l'exploitation du schéma : -- **Quelle granularité** à l'ingestion : la seconde, la minute, le quart d'heure. -- **Quels agrégats continus**, et sur quelles fenêtres. -- **Quelle profondeur de rétention** en données brutes, et à partir de quand on compresse. -- **Multi-tenant ou non** : un site appartient-il à un client, et faut-il cloisonner les lectures. +- **Quelle granularité** conserver à long terme à l'ingestion : seconde, minute ou quart d'heure. +- **Quels agrégats continus** créer et sur quelles fenêtres. +- **Quelle profondeur de rétention** conserver en données brutes et à partir de quand compresser. +- **Multi-tenant ou non** : un site appartient-il à un client et faut-il cloisonner les lectures. ## Modélisation détaillée des données -Cette modélisation prend en compte les fichiers CSV historiques, -leurs métadonnées JSON et les données de l’API Mock. -Elle comprend six tables, depuis le stockage des mesures -jusqu’aux recommandations proposées à l’utilisateur. +Cette modélisation prend en compte : + +- les fichiers CSV historiques ; +- leurs métadonnées JSON ; +- les données de l'API Mock. + +Elle comprend six tables Data, depuis le stockage des mesures jusqu'aux recommandations proposées +à l'utilisateur. ### Schéma de données Le diagramme ci-dessous présente les tables et leurs relations. + La révision `e6d2026091501` les crée. ![Schéma de données EnerVision](images/EnerVision-schema-donnees.png) @@ -208,21 +264,20 @@ La révision `e6d2026091501` les crée. ### Description des tables -Chaque table remplit un rôle précis dans le traitement et l’exploitation -des données. +Chaque table remplit un rôle précis dans le traitement et l'exploitation des données. | Table | Rôle | Origine des informations | |---|---|---| -| `dataset` | Identifier les jeux historiques, retrouver leurs fichiers et conserver leurs métadonnées | Archive CSV/JSON et informations ajoutées lors de l’import | +| `dataset` | Identifier les jeux historiques, retrouver leurs fichiers et conserver leurs métadonnées | Archive CSV/JSON et informations ajoutées lors de l'import | | `site` | Regrouper les informations des sites : identifiant, nom, type et caractéristiques disponibles | CSV et API Mock `/api/v1/sites` | | `reading` | Stocker les mesures, leur provenance, leur qualité et les éventuelles valeurs imputées | CSV et API Mock `/current` et `/readings` | -| `prediction` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d’EnerVision | +| `prediction` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d'EnerVision | | `alert` | Enregistrer les alertes, leur type, leur gravité et leur message | API Mock `/alerts` et détections EnerVision | -| `recommendation` | Proposer des actions et expliquer la règle qui les motive | Règles métier d’EnerVision | +| `recommendation` | Proposer des actions et expliquer la règle qui les motive | Règles métier d'EnerVision | -Les anomalies historiques décrites dans les JSON sont conservées -dans `dataset.metadata`. Elles servent à l’analyse des données -et ne sont pas considérées comme des alertes actuelles. +Les anomalies historiques décrites dans les JSON sont conservées dans `dataset.metadata`. + +Elles servent à l'analyse des données et ne sont pas considérées comme des alertes actuelles. ### Relations entre les tables @@ -232,15 +287,21 @@ et ne sont pas considérées comme des alertes actuelles. - Une alerte peut être associée à une prévision du même site. - Une alerte peut donner lieu à plusieurs recommandations. -## Ingestion des données historiques +# Ingestion des données historiques -Le MVP EnerVision initialise les données énergétiques à partir du dataset fourni dans le cadre du projet. +Statut : `Fait`. -Le dataset de référence contient 122 647 mesures issues de 7 sites et couvre la période du 1er janvier 2023 au 31 décembre 2024. +Le MVP EnerVision initialise les données énergétiques à partir du dataset fourni dans le cadre +du projet. -Les fichiers sources CSV et JSON sont nécessaires uniquement pour l'initialisation des données. Ils ne sont pas versionnés dans Git et sont placés localement dans `data/raw/`. +Le dataset de référence contient 122 647 mesures issues de 7 sites et couvre la période +du 1er janvier 2023 au 31 décembre 2024. -### Architecture du flux +Les fichiers sources CSV et JSON sont nécessaires uniquement pour l'initialisation des données. + +Ils ne sont pas versionnés dans Git et sont placés localement dans `data/raw/`. + +## Architecture du flux historique ```text Dataset CSV + métadonnées JSON @@ -271,17 +332,26 @@ Dataset CSV + métadonnées JSON Le pipeline est développé en Python. -Pandas est utilisé pour l'extraction, la validation et la préparation des données. SQLAlchemy Async assure le chargement transactionnel dans PostgreSQL/TimescaleDB. +Pandas est utilisé pour l'extraction, la validation et la préparation des données. + +SQLAlchemy Async assure le chargement transactionnel dans PostgreSQL/TimescaleDB. Une empreinte SHA-256 permet d'identifier le dataset utilisé et d'assurer sa traçabilité. -Les valeurs manquantes sont conservées pendant l'ingestion afin de préserver les données sources. Aucune imputation n'est réalisée à cette étape. +Les valeurs manquantes sont conservées pendant l'ingestion afin de préserver les données sources. + +Aucune imputation n'est réalisée à cette étape. Le chargement des mesures est effectué par batches de 1 000 lignes. -Les données provenant du dataset CSV sont identifiées par `source = "csv"` et associées à leur `dataset_id`. +Les données provenant du dataset CSV sont identifiées par : -### Résultats validés +```text +source = "csv" +dataset_id = identifiant du dataset +``` + +## Résultats validés pour l'historique Le chargement de référence a permis d'obtenir : @@ -290,14 +360,207 @@ Le chargement de référence a permis d'obtenir : - 122 647 mesures ; - 0 doublon détecté dans le dataset source. -L'idempotence a également été vérifiée par une deuxième exécution du pipeline : aucune nouvelle mesure n'a été créée et le nombre de `reading` est resté à 122 647. +L'idempotence a également été vérifiée par une deuxième exécution du pipeline : +aucune nouvelle mesure n'a été créée et le nombre de `reading` est resté à 122 647. -La procédure détaillée d'installation, d'exécution, de validation et de contrôle du pipeline est disponible dans `etl/README.md`. +La procédure détaillée d'installation, d'exécution, de validation et de contrôle du pipeline +est disponible dans `etl/README.md`. -### Évolution prévue +# Ingestion depuis l'API Mock -L'étape suivante consiste à orchestrer les traitements Data avec Apache Airflow. +Statut : `Fait`. -L'orchestration réutilisera la logique ETL existante afin de séparer la logique de traitement de la planification, du suivi des exécutions et de la gestion des erreurs. +La deuxième source du pipeline Data est l'API Mock EnerVision. -Le pipeline servira ensuite de base à la préparation des données nécessaires au modèle de Machine Learning. +Le traitement est implémenté dans : + +```text +apps/backend/app/etl/mock_api_import.py +``` + +## Endpoints utilisés + +Le pipeline récupère les informations des sites depuis : + +```text +GET /api/v1/sites +``` + +puis les mesures historiques simulées depuis : + +```text +GET /api/v1/readings +``` + +Pour `/api/v1/readings`, les informations suivantes sont envoyées : + +```text +site_id +start_time +end_time +limit +``` + +Les paramètres de ligne de commande disponibles pour l'import sont : + +```text +--start-time +--end-time +--limit +--dry-run +``` + +## Flux d'ingestion API Mock + +```text + API Mock + | + +-----+------+ + | | + v v + /sites /readings + | | + +-----+------+ + | + v + mock_api_import.py + | + v + Transformation + + qualité data + | + v +PostgreSQL / TimescaleDB + | | + v v + site reading +``` + +Les informations des sites sont insérées ou mises à jour dans `site`. + +Les mesures sont enregistrées dans l'hypertable `reading` avec : + +```text +source = "api_history" +dataset_id = NULL +``` + +Les données provenant de l'API Mock ne sont donc pas associées à un enregistrement de la table +`dataset`. + +La réponse source reçue depuis l'API est conservée dans : + +```text +raw_data +``` + +## Qualité des données de l'API Mock + +Les valeurs `NULL` ne sont pas remplacées pendant l'ingestion. + +Les informations suivantes fournies par l'API sont conservées : + +```text +data_quality +null_reasons +``` + +Cette conservation permet de distinguer une valeur manquante d'une valeur réelle égale à zéro +et de garder les informations liées aux éventuelles défaillances de capteurs. + +Aucune imputation n'est réalisée pendant cette phase : + +```text +imputed_values = NULL +imputation_method = NULL +``` + +## Validation de l'import API Mock + +Un scénario de validation a été exécuté pour les 7 sites sur la période : + +```text +15/06/2024 12:00 UTC +à +15/06/2024 13:00 UTC +``` + +avec : + +```text +limit = 60 +``` + +Résultat : + +```text +7 sites +60 lectures par site +420 lectures récupérées +``` + +Les données ont été chargées dans PostgreSQL/TimescaleDB puis contrôlées directement en base. + +Les contrôles ont confirmé : + +- `source = "api_history"` ; +- `dataset_id = NULL` ; +- la conservation des valeurs `NULL` ; +- la conservation de `data_quality` ; +- la conservation de `null_reasons` ; +- la conservation de `raw_data`. + +L'idempotence a été vérifiée en rejouant le même import. + +Une mesure déjà présente n'est pas ajoutée une seconde fois. + +Les tests automatisés couvrent également : + +- la récupération des sites ; +- les paramètres envoyés à `/api/v1/readings` ; +- les réponses HTTP en erreur ; +- le format de la réponse ; +- la transformation des mesures ; +- les valeurs manquantes ; +- la qualité des données ; +- la conservation des données sources ; +- l'idempotence en base. + +# Évolution prévue + +La prochaine étape consiste à orchestrer les deux mécanismes d'ingestion avec Apache Airflow. + +```text +CSV / JSON ----------------+ + | + v + +------------------+ + | Airflow | + +------------------+ + | + +----------------+----------------+ + | | + v v +historical_import.py mock_api_import.py + | | + +----------------+----------------+ + | + v + PostgreSQL / TimescaleDB +``` + +Airflow servira à : + +- planifier les traitements ; +- définir leur ordre d'exécution ; +- suivre leur état ; +- gérer et remonter les erreurs ; +- faciliter les exécutions récurrentes. + +Airflow ne remplacera pas la logique ETL déjà implémentée. + +Les scripts Python resteront responsables de l'extraction, de la validation, de la transformation +et du chargement des données. + +Le pipeline servira ensuite de base à la préparation des données nécessaires au modèle +de Machine Learning. \ No newline at end of file diff --git a/etl/README.md b/etl/README.md index b835311..15c376b 100644 --- a/etl/README.md +++ b/etl/README.md @@ -2,9 +2,10 @@ ## Objectif -Le pipeline ETL EnerVision permet d'intégrer les données énergétiques historiques dans PostgreSQL/TimescaleDB. +Le pipeline ETL EnerVision permet d'intégrer les données énergétiques dans PostgreSQL/TimescaleDB à partir de deux sources : -Cette première étape du pipeline Data permet de charger le dataset fourni dans le cadre du projet, contenant les mesures énergétiques de 7 sites sur la période du 1er janvier 2023 au 31 décembre 2024. +- le dataset historique CSV/JSON fourni dans le cadre du projet ; +- l'API Mock EnerVision. Le pipeline assure : @@ -12,12 +13,15 @@ Le pipeline assure : - la validation de leur structure et de leur cohérence ; - la normalisation des données nécessaires au stockage ; - le suivi de la qualité des données ; -- la traçabilité du dataset importé ; +- la traçabilité des données importées ; - le chargement des données dans PostgreSQL/TimescaleDB ; +- la conservation des valeurs manquantes et des informations de qualité ; - l'idempotence du chargement afin d'éviter la création de doublons. ## Données sources +### Dataset historique + Le dataset est fourni par le formateur dans le cadre du projet EnerVision. Il contient les deux fichiers suivants : @@ -29,7 +33,7 @@ dataset_metadata.json Ces fichiers sont nécessaires une seule fois pour initialiser les données historiques de l'environnement. -Ils ne sont pas versionnés dans Git. Chaque membre de l'équipe récupère manuellement une fois les fichiers fournis par le formateur et les place dans : +Ils ne sont pas versionnés dans Git. Chaque membre de l'équipe récupère manuellement les fichiers fournis par le formateur et les place dans : ```text data/raw/ @@ -47,14 +51,26 @@ data/ Le fichier `.gitkeep` est versionné afin de conserver le répertoire `data/raw/` dans Git. Les fichiers CSV et JSON sont ignorés par Git. +### API Mock + +La deuxième source est l'API Mock EnerVision. + +Elle permet de récupérer : + +- les informations des sites avec `GET /api/v1/sites` ; +- les mesures simulées avec `GET /api/v1/readings`. + +L'API Mock est utilisée pour compléter les données historiques avec des mesures simulées récupérées sur une période donnée. + ## Technologies utilisées | Technologie | Utilisation | |---|---| | Python | Développement du pipeline ETL | -| Pandas | Lecture, validation et transformation des données | -| JSON | Lecture des métadonnées du dataset | -| hashlib / SHA-256 | Identification, intégrité et traçabilité du dataset | +| Pandas | Lecture, validation et transformation du dataset historique | +| JSON | Lecture des métadonnées et conservation des données sources | +| HTTPX | Appels HTTP asynchrones vers l'API Mock | +| hashlib / SHA-256 | Identification, intégrité et traçabilité du dataset historique | | SQLAlchemy Async | Connexion et chargement asynchrone en base | | PostgreSQL | Stockage relationnel | | TimescaleDB | Stockage des séries temporelles énergétiques | @@ -62,11 +78,14 @@ Le fichier `.gitkeep` est versionné afin de conserver le répertoire `data/raw/ | Alembic | Gestion des migrations du schéma | | uv | Gestion et exécution de l'environnement Python | | Ruff | Contrôle de la qualité du code | +| mypy | Vérification du typage | | Pytest | Tests automatisés | -## Fonctionnement du pipeline +# Import du dataset historique -Le script principal d'import se trouve dans : +## Fonctionnement du pipeline historique + +Le script d'import se trouve dans : ```text apps/backend/app/etl/historical_import.py @@ -207,7 +226,7 @@ Valeurs manquantes identifiées : | `humidity_percent` | 3 423 | | `solar_irradiance_wm2` | 3 964 | -## Exécution en dry-run +## Exécution historique en dry-run Depuis le dossier : @@ -227,7 +246,7 @@ uv run python -m app.etl.historical_import ` Aucune donnée n'est écrite dans la base pendant cette exécution. -## Chargement réel +## Chargement historique réel Depuis `apps/backend/` : @@ -249,7 +268,7 @@ Chargement : 2000/122647 Chargement : 122647/122647 ``` -## Résultats obtenus +## Résultats obtenus pour le dataset historique Après le chargement initial, les contrôles en base ont confirmé : @@ -266,7 +285,7 @@ Le premier import a créé : nouvelles lectures : 122647 ``` -## Idempotence +## Idempotence du dataset historique Le pipeline a été exécuté une deuxième fois avec exactement le même dataset afin de vérifier son idempotence. @@ -280,7 +299,7 @@ nouvelles lectures : 0 Une nouvelle exécution du même import ne crée donc pas de mesures supplémentaires pour le dataset testé. -## Vérifications SQL +## Vérifications SQL du dataset historique Depuis la racine du projet, vérifier le nombre d'enregistrements avec : @@ -302,21 +321,213 @@ Vérifier la source des mesures avec : docker compose exec db psql -U enervision -d enervision -c "SELECT source, COUNT(*) FROM reading GROUP BY source ORDER BY source;" ``` -Résultat attendu : +Résultat attendu pour le dataset historique : ```text csv | 122647 ``` -## Tests et qualité +# Import depuis l'API Mock -Les tests automatisés du pipeline sont situés dans : +## Fonctionnement + +Le script d'import de l'API Mock se trouve dans : + +```text +apps/backend/app/etl/mock_api_import.py +``` + +Le flux est le suivant : + +```text + API Mock + | + +-----+------+ + | | + v v + /sites /readings + | | + +-----+------+ + | + v + mock_api_import.py + | + v + Transformation + + qualité data + | + v +PostgreSQL / TimescaleDB + | | + v v + site reading +``` + +Le pipeline commence par récupérer les sites avec : + +```text +GET /api/v1/sites +``` + +Il récupère ensuite les mesures de chaque site avec : + +```text +GET /api/v1/readings +``` + +Les paramètres envoyés à `/api/v1/readings` sont : + +```text +site_id +start_time +end_time +limit +``` + +Le paramètre `limit` doit être compris entre 1 et 1000. + +## Configuration de l'API Mock + +La connexion à l'API Mock est configurée avec les variables d'environnement suivantes : + +```text +APP_MOCK_API_BASE_URL +APP_MOCK_API_USERNAME +APP_MOCK_API_PASSWORD +APP_MOCK_API_TIMEOUT_SECONDS +``` + +Les identifiants réels ne sont pas versionnés dans Git. + +Les fichiers `.env.example` indiquent uniquement les variables nécessaires à l'exécution. + +## Transformation des mesures API + +Les mesures provenant de l'API Mock sont enregistrées dans `reading` avec : + +```text +source = "api_history" +dataset_id = NULL +``` + +Les mesures provenant de l'API ne sont donc pas rattachées à un dataset historique. + +Le timestamp reçu depuis l'API est converti en `datetime` avec timezone avant le chargement. + +La réponse source est conservée dans : + +```text +raw_data +``` + +afin de préserver la donnée reçue et faciliter la traçabilité. + +## Qualité des données API + +Les valeurs `NULL` fournies par l'API sont conservées telles quelles. + +Une valeur manquante n'est pas transformée en zéro et la mesure n'est pas supprimée. + +Le pipeline conserve également : + +```text +data_quality +null_reasons +``` + +Les niveaux de qualité possibles sont : + +```text +good +partial +degraded +critical +``` + +Aucune imputation n'est réalisée pendant l'ingestion : + +```text +imputed_values = NULL +imputation_method = NULL +``` + +Cette stratégie permet de distinguer une véritable valeur nulle ou manquante d'une consommation égale à zéro et de conserver les informations liées aux défaillances de capteurs. + +## Dry-run de l'API Mock + +Le mode `--dry-run` permet de tester la connexion, la récupération des sites et la récupération des mesures sans écrire dans PostgreSQL. + +Depuis `apps/backend/` : + +```powershell +uv run python -m app.etl.mock_api_import ` + --start-time "2024-06-15T12:00:00" ` + --end-time "2024-06-15T13:00:00" ` + --limit 60 ` + --dry-run +``` + +## Chargement réel depuis l'API Mock + +Depuis `apps/backend/` : + +```powershell +uv run python -m app.etl.mock_api_import ` + --start-time "2024-06-15T12:00:00" ` + --end-time "2024-06-15T13:00:00" ` + --limit 60 +``` + +## Résultat validé pour l'API Mock + +Le scénario de validation utilisé couvre la période : + +```text +15/06/2024 12:00 UTC +à +15/06/2024 13:00 UTC +``` + +avec une limite de 60 lectures par site. + +Résultat obtenu : + +```text +sites récupérés : 7 +lectures par site : 60 +lectures récupérées : 420 +source : api_history +dataset_id : NULL +``` + +Les contrôles effectués directement dans PostgreSQL/TimescaleDB ont confirmé : + +- l'enregistrement des mesures dans `reading` ; +- la présence des 7 sites ; +- `source = "api_history"` ; +- `dataset_id = NULL` ; +- la conservation des valeurs `NULL` ; +- la conservation de `data_quality` ; +- la conservation de `null_reasons` ; +- la conservation de la donnée source dans `raw_data`. + +## Idempotence de l'import API Mock + +Le même import a été exécuté plusieurs fois afin de vérifier qu'une mesure déjà présente n'est pas créée une seconde fois. + +L'idempotence repose sur la contrainte d'unicité de la table `reading` et sur la gestion des conflits lors de l'insertion. + +Un test d'intégration automatisé vérifie également ce comportement. + +# Tests et qualité + +Les tests automatisés des pipelines ETL sont situés dans : ```text apps/backend/tests/etl/ ``` -Ils couvrent notamment : +Les tests de l'import historique couvrent notamment : - la validation du dataset ; - les colonnes obligatoires ; @@ -328,22 +539,96 @@ Ils couvrent notamment : - la construction des mesures destinées à la BDD ; - le respect des contraintes du modèle de données. +Les tests de l'import API Mock couvrent notamment : + +- la récupération des sites ; +- l'appel à `/api/v1/readings` ; +- les paramètres `site_id`, `start_time`, `end_time` et `limit` ; +- la gestion des erreurs HTTP ; +- la validation du format de la réponse ; +- la transformation des mesures ; +- la conservation des valeurs `NULL` ; +- la conservation de `data_quality` et `null_reasons` ; +- `source = "api_history"` ; +- `dataset_id = NULL` ; +- la conservation de `raw_data` ; +- l'idempotence du chargement. + Exécuter les tests ETL : ```powershell uv run pytest tests\etl -v ``` +Exécuter les tests unitaires de l'import API Mock : + +```powershell +uv run pytest tests\etl\test_mock_api_import.py -v +``` + +Exécuter le test d'intégration de l'import API Mock : + +```powershell +uv run pytest tests\etl\test_mock_api_import.py -m integration -v +``` + Contrôler la qualité du code : ```powershell uv run ruff check app\etl tests\etl ``` -## Suite du pipeline Data +Contrôler le typage : -L'import historique constitue la première brique du pipeline Data EnerVision. +```powershell +uv run mypy app +``` -La prochaine étape consiste à orchestrer les traitements ETL avec Apache Airflow, puis à préparer les données nécessaires à l'entraînement du modèle de Machine Learning. +Exécuter la suite complète avec le seuil de couverture : -Airflow sera utilisé comme orchestrateur des traitements existants et ne remplacera pas la logique métier déjà implémentée dans le pipeline ETL. \ No newline at end of file +```powershell +uv run pytest --cov-fail-under=85 +``` + +Lors de la validation de l'import API Mock : + +```text +8 tests unitaires passés +1 test d'intégration passé +``` + +La suite backend complète a également été validée avec une couverture supérieure au seuil de 85 %. + +# Suite du pipeline Data + +Deux sources de données sont maintenant prises en charge : + +```text +Dataset CSV/JSON + | + v +historical_import.py + | + +-----------------+ + | + v + PostgreSQL / TimescaleDB + ^ + | + +-----------------+ + | +mock_api_import.py + ^ + | + API Mock +``` + +La logique d'extraction, de transformation et de chargement est donc disponible pour les deux sources de données du MVP. + +La prochaine étape consiste à orchestrer ces traitements avec Apache Airflow. + +Airflow permettra de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs. + +Airflow ne remplacera pas la logique ETL Python existante. Les scripts actuels resteront responsables de l'extraction, de la validation, de la transformation et du chargement. + +Le pipeline Data servira ensuite à préparer les données nécessaires au modèle de Machine Learning. \ No newline at end of file From 452cfdef85341005f1614e5cdafbe941e2cfbae1 Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Mon, 21 Sep 2026 09:36:10 +0200 Subject: [PATCH 82/87] test(etl): corrige les points bloquants de la revue API Mock --- .../backend/tests/etl/test_mock_api_import.py | 380 ++++++++++++++++-- docker-compose.yml | 8 +- 2 files changed, 354 insertions(+), 34 deletions(-) diff --git a/apps/backend/tests/etl/test_mock_api_import.py b/apps/backend/tests/etl/test_mock_api_import.py index c51a153..2b4c07c 100644 --- a/apps/backend/tests/etl/test_mock_api_import.py +++ b/apps/backend/tests/etl/test_mock_api_import.py @@ -1,6 +1,9 @@ import json +import sys from datetime import datetime +from types import SimpleNamespace from typing import Any +from unittest.mock import AsyncMock, MagicMock import httpx import pytest @@ -8,6 +11,7 @@ from httpx import AsyncClient, MockTransport, Request, Response from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession +import app.etl.mock_api_import as mock_api_import from app.etl.mock_api_import import ( READING_INSERT, SOURCE_HISTORY, @@ -15,6 +19,7 @@ from app.etl.mock_api_import import ( build_reading_row, fetch_readings, fetch_sites, + upsert_sites, ) @@ -49,6 +54,7 @@ def make_reading() -> dict[str, Any]: async def test_fetch_sites_returns_sites() -> None: def handler(request: Request) -> Response: assert request.url.path == "/api/v1/sites" + return Response( status_code=200, json=[make_site()], @@ -163,7 +169,9 @@ def test_build_reading_row_respects_database_contract() -> None: assert row["source"] == "api_history" assert row["dataset_id"] is None - assert row["timestamp"] == datetime.fromisoformat("2024-06-15T12:00:00+00:00") + assert row["timestamp"] == datetime.fromisoformat( + "2024-06-15T12:00:00+00:00" + ) assert row["consumption_kw"] == 87.34 assert row["consumption_kwh"] == 87.34 @@ -234,6 +242,342 @@ def test_build_reading_batch_transforms_all_readings() -> None: assert rows[1]["consumption_kw"] == 90.5 +def test_create_mock_api_client_requires_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings = SimpleNamespace( + mock_api_username=None, + mock_api_password=None, + ) + + monkeypatch.setattr( + mock_api_import, + "get_settings", + lambda: settings, + ) + + with pytest.raises( + ValueError, + match="Les identifiants de l'API Mock ne sont pas configurés", + ): + mock_api_import.create_mock_api_client() + + +async def test_create_mock_api_client_uses_configuration( + monkeypatch: pytest.MonkeyPatch, +) -> None: + password = MagicMock() + password.get_secret_value.return_value = "test-password" + + settings = SimpleNamespace( + mock_api_base_url="https://mock.test/", + mock_api_username="test-user", + mock_api_password=password, + mock_api_timeout_seconds=10.0, + ) + + monkeypatch.setattr( + mock_api_import, + "get_settings", + lambda: settings, + ) + + client = mock_api_import.create_mock_api_client() + + try: + assert str(client.base_url) == "https://mock.test" + assert client.timeout.connect == 10.0 + finally: + await client.aclose() + + +async def test_upsert_sites_with_empty_list_does_nothing() -> None: + connection = AsyncMock() + + await upsert_sites( + connection, + [], + ) + + connection.execute.assert_not_awaited() + + +async def test_import_mock_api_history_dry_run_does_not_write( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def handler(request: Request) -> Response: + if request.url.path == "/api/v1/sites": + return Response( + status_code=200, + json=[make_site()], + ) + + if request.url.path == "/api/v1/readings": + return Response( + status_code=200, + json=[make_reading()], + ) + + return Response(status_code=404) + + transport = MockTransport(handler) + + client = AsyncClient( + transport=transport, + base_url="https://mock.test", + ) + + monkeypatch.setattr( + mock_api_import, + "create_mock_api_client", + lambda: client, + ) + + monkeypatch.setattr( + mock_api_import, + "get_settings", + lambda: SimpleNamespace( + database_url="postgresql+asyncpg://unused", + ), + ) + + create_engine_mock = MagicMock() + + monkeypatch.setattr( + mock_api_import, + "create_async_engine", + create_engine_mock, + ) + + await mock_api_import.import_mock_api_history( + start_time=datetime.fromisoformat("2024-06-15T12:00:00"), + end_time=datetime.fromisoformat("2024-06-15T13:00:00"), + limit=60, + dry_run=True, + ) + + create_engine_mock.assert_not_called() + + +async def test_import_mock_api_history_loads_data( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def handler(request: Request) -> Response: + if request.url.path == "/api/v1/sites": + return Response( + status_code=200, + json=[make_site()], + ) + + if request.url.path == "/api/v1/readings": + return Response( + status_code=200, + json=[make_reading()], + ) + + return Response(status_code=404) + + transport = MockTransport(handler) + + client = AsyncClient( + transport=transport, + base_url="https://mock.test", + ) + + monkeypatch.setattr( + mock_api_import, + "create_mock_api_client", + lambda: client, + ) + + monkeypatch.setattr( + mock_api_import, + "get_settings", + lambda: SimpleNamespace( + database_url="postgresql+asyncpg://test:test@localhost/test", + ), + ) + + connection = AsyncMock() + + transaction_context = MagicMock() + transaction_context.__aenter__ = AsyncMock( + return_value=connection, + ) + transaction_context.__aexit__ = AsyncMock( + return_value=None, + ) + + engine = MagicMock() + engine.begin.return_value = transaction_context + engine.dispose = AsyncMock() + + create_engine_mock = MagicMock( + return_value=engine, + ) + + upsert_sites_mock = AsyncMock() + + monkeypatch.setattr( + mock_api_import, + "create_async_engine", + create_engine_mock, + ) + + monkeypatch.setattr( + mock_api_import, + "upsert_sites", + upsert_sites_mock, + ) + + await mock_api_import.import_mock_api_history( + start_time=datetime.fromisoformat("2024-06-15T12:00:00"), + end_time=datetime.fromisoformat("2024-06-15T13:00:00"), + limit=60, + dry_run=False, + ) + + create_engine_mock.assert_called_once_with( + "postgresql+asyncpg://test:test@localhost/test", + pool_pre_ping=True, + ) + + upsert_sites_mock.assert_awaited_once_with( + connection, + [make_site()], + ) + + connection.execute.assert_awaited_once() + engine.dispose.assert_awaited_once() + + +def test_parse_datetime_accepts_z_suffix() -> None: + result = mock_api_import.parse_datetime( + "2024-06-15T12:00:00Z", + ) + + assert result == datetime.fromisoformat( + "2024-06-15T12:00:00+00:00", + ) + + +def test_parse_args_reads_cli_parameters( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + sys, + "argv", + [ + "mock_api_import", + "--start-time", + "2024-06-15T12:00:00Z", + "--end-time", + "2024-06-15T13:00:00Z", + "--limit", + "60", + "--dry-run", + ], + ) + + args = mock_api_import.parse_args() + + assert args.start_time == datetime.fromisoformat( + "2024-06-15T12:00:00+00:00", + ) + assert args.end_time == datetime.fromisoformat( + "2024-06-15T13:00:00+00:00", + ) + assert args.limit == 60 + assert args.dry_run is True + + +def test_main_rejects_limit_out_of_bounds( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + sys, + "argv", + [ + "mock_api_import", + "--start-time", + "2024-06-15T12:00:00Z", + "--end-time", + "2024-06-15T13:00:00Z", + "--limit", + "0", + ], + ) + + with pytest.raises( + ValueError, + match="--limit doit être compris entre 1 et 1000", + ): + mock_api_import.main() + + +def test_main_rejects_invalid_period( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + sys, + "argv", + [ + "mock_api_import", + "--start-time", + "2024-06-15T14:00:00Z", + "--end-time", + "2024-06-15T13:00:00Z", + "--limit", + "60", + ], + ) + + with pytest.raises( + ValueError, + match="--start-time doit être antérieur à --end-time", + ): + mock_api_import.main() + + +def test_main_runs_import( + monkeypatch: pytest.MonkeyPatch, +) -> None: + start_time = datetime.fromisoformat( + "2024-06-15T12:00:00+00:00", + ) + end_time = datetime.fromisoformat( + "2024-06-15T13:00:00+00:00", + ) + + import_mock = AsyncMock() + + monkeypatch.setattr( + mock_api_import, + "parse_args", + lambda: SimpleNamespace( + start_time=start_time, + end_time=end_time, + limit=60, + dry_run=True, + ), + ) + + monkeypatch.setattr( + mock_api_import, + "import_mock_api_history", + import_mock, + ) + + mock_api_import.main() + + import_mock.assert_awaited_once_with( + start_time=start_time, + end_time=end_time, + limit=60, + dry_run=True, + ) + + @pytest.mark.integration async def test_reading_insert_is_idempotent( session: AsyncSession, @@ -241,35 +585,11 @@ async def test_reading_insert_is_idempotent( reading = make_reading() row = build_reading_row(reading) - await session.execute( - text( - """ - INSERT INTO site ( - site_id, - site_type, - site_name, - location, - capacity_kw, - status - ) - VALUES ( - :site_id, - :site_type, - :site_name, - :location, - :capacity_kw, - :status - ) - ON CONFLICT (site_id) - DO UPDATE SET - site_type = EXCLUDED.site_type, - site_name = EXCLUDED.site_name, - location = EXCLUDED.location, - capacity_kw = EXCLUDED.capacity_kw, - status = EXCLUDED.status - """ - ), - make_site(), + connection = await session.connection() + + await upsert_sites( + connection, + [make_site()], ) await session.execute( diff --git a/docker-compose.yml b/docker-compose.yml index 91e315e..12b5c96 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,10 +50,10 @@ services: APP_SECRET_KEY: ${APP_SECRET_KEY:?} APP_CORS_ORIGINS: ${APP_CORS_ORIGINS:-http://localhost:4200} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} - - APP_MOCK_API_BASE_URL: ${APP_MOCK_API_BASE_URL:?} - APP_MOCK_API_USERNAME: ${APP_MOCK_API_USERNAME:?} - APP_MOCK_API_PASSWORD: ${APP_MOCK_API_PASSWORD:?} + + APP_MOCK_API_BASE_URL: ${APP_MOCK_API_BASE_URL:-https://api-mock.charlieandre.fr} + APP_MOCK_API_USERNAME: ${APP_MOCK_API_USERNAME:-} + APP_MOCK_API_PASSWORD: ${APP_MOCK_API_PASSWORD:-} APP_MOCK_API_TIMEOUT_SECONDS: ${APP_MOCK_API_TIMEOUT_SECONDS:-10} APP_FRONTEND_RESET_PASSWORD_URL: ${APP_FRONTEND_RESET_PASSWORD_URL:-http://localhost:4200/reset-password} From 19c38fe571f19d6393efe9bd868694c4d07aef1f Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 09:45:25 +0200 Subject: [PATCH 83/87] fix(backend): decoupe l'insertion des recommandations en lots et remet les docs a jour `create_missing()` construisait un seul `INSERT ... VALUES` pour la totalite des propositions. Avec quatre colonnes par ligne et le plafond asyncpg de 32 767 parametres, la route echouait au-dela de 8 191 recommandations par appel, cas devenu realiste maintenant que la detection interne (#104) alimente `alert` en continu. L'insertion passe par des lots de `TAILLE_DE_LOT` lignes, sur le patron de `app/etl/historical_import.py`. L'ADR 0006, `20-backend.md` et la description de la PR annoncaient qu'aucune source n'alimentait `alert` et que #104 n'etait pas commencee. #104 est livree sur `dev` depuis la #113 : les phrases sont corrigees plutot que laissees a vieillir dans un ADR. --- .../app/repositories/recommendation.py | 26 +++++++++++-------- .../tests/repositories/test_recommendation.py | 17 ++++++++++++ .../0006-moteur-de-regles-dans-le-backend.md | 10 ++++--- docs/architecture/20-backend.md | 4 ++- docs/architecture/40-data.md | 5 ++-- 5 files changed, 45 insertions(+), 17 deletions(-) diff --git a/apps/backend/app/repositories/recommendation.py b/apps/backend/app/repositories/recommendation.py index 8b07349..144957b 100644 --- a/apps/backend/app/repositories/recommendation.py +++ b/apps/backend/app/repositories/recommendation.py @@ -16,6 +16,9 @@ class NouvelleRecommandation: rule_reference: str +TAILLE_DE_LOT = 1000 + + class RecommendationRepository: def __init__(self, session: AsyncSession) -> None: self._session = session @@ -34,14 +37,15 @@ class RecommendationRepository: # Pourquoi : l'idempotence est déléguée à `uq_recommendation_alert_rule` plutôt qu'à une # lecture préalable, qui laisserait une fenêtre entre le contrôle et l'insertion. async def create_missing(self, nouvelles: Sequence[NouvelleRecommandation]) -> int: - if not nouvelles: - return 0 - - requete = ( - insert(Recommendation) - .values([asdict(nouvelle) for nouvelle in nouvelles]) - .on_conflict_do_nothing(constraint="uq_recommendation_alert_rule") - .returning(Recommendation.recommendation_id) - ) - creees = (await self._session.scalars(requete)).all() - return len(creees) + creees = 0 + # Piège : asyncpg plafonne une requête à 32 767 paramètres, soit 8 191 lignes de quatre + # colonnes. Au-delà de ce seuil un `INSERT` d'un seul tenant échouerait. + for debut in range(0, len(nouvelles), TAILLE_DE_LOT): + requete = ( + insert(Recommendation) + .values([asdict(nouvelle) for nouvelle in nouvelles[debut : debut + TAILLE_DE_LOT]]) + .on_conflict_do_nothing(constraint="uq_recommendation_alert_rule") + .returning(Recommendation.recommendation_id) + ) + creees += len((await self._session.scalars(requete)).all()) + return creees diff --git a/apps/backend/tests/repositories/test_recommendation.py b/apps/backend/tests/repositories/test_recommendation.py index 64b3b5f..6585878 100644 --- a/apps/backend/tests/repositories/test_recommendation.py +++ b/apps/backend/tests/repositories/test_recommendation.py @@ -5,6 +5,7 @@ import pytest from sqlalchemy.ext.asyncio import AsyncSession from app.models.energy import Alert, Recommendation, Site +from app.repositories import recommendation as module_recommendation from app.repositories.recommendation import NouvelleRecommandation, RecommendationRepository pytestmark = pytest.mark.integration @@ -123,3 +124,19 @@ async def test_create_missing_returns_zero_without_any_proposal(session: AsyncSe creees = await RecommendationRepository(session).create_missing([]) assert creees == 0 + + +async def test_create_missing_inserts_every_proposal_across_several_batches( + session: AsyncSession, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(module_recommendation, "TAILLE_DE_LOT", 2) + depot = RecommendationRepository(session) + alert_id = await creer_alerte(session) + propositions = [nouvelle(alert_id, f"regle-{index}-v1") for index in range(5)] + + creees = await depot.create_missing(propositions) + enregistrees = [r for r in await depot.list_all() if r.alert_id == alert_id] + await session.rollback() + + assert creees == 5 + assert len(enregistrees) == 5 diff --git a/docs/adr/0006-moteur-de-regles-dans-le-backend.md b/docs/adr/0006-moteur-de-regles-dans-le-backend.md index 794825e..4f23dce 100644 --- a/docs/adr/0006-moteur-de-regles-dans-le-backend.md +++ b/docs/adr/0006-moteur-de-regles-dans-le-backend.md @@ -57,9 +57,13 @@ n'oblige à exposer un port pour régénérer des recommandations. fenêtre entre le contrôle et l'insertion. Corollaire : `rule_reference` est une clé fonctionnelle. Une règle dont le sens change prend une référence `-v2` ; renommer une référence livrée ferait réapparaître ses recommandations à côté des anciennes. -- **Le moteur ne produira rien tant que `alert` restera vide.** Aucun code ne produit aujourd'hui - de ligne d'alerte : ni détection interne (#104), ni ingestion de l'API Mock `/alerts`. La chaîne - s'allume d'elle-même le jour où l'une des deux existe, sans retoucher le moteur. +- **Le moteur est branché sur la détection interne, et sur elle seule.** `alert` est alimentée + par `app/detection/internal_alerts.py` (#104), lancée à la main comme `enervision_ml.score` ; + l'ingestion de l'API Mock `/alerts` reste à faire. Le rapport de génération est donc à zéro tant + que la détection n'a pas tourné, sans que le moteur soit à retoucher. +- **L'insertion est découpée en lots.** `create_missing()` écrit par paquets de `TAILLE_DE_LOT` + lignes : asyncpg plafonne une requête à 32 767 paramètres, soit 8 191 lignes de quatre colonnes, + et la détection interne peut alimenter `alert` au fil de l'eau. - Si le projet devait un jour pondérer les recommandations par un score appris, la décision serait à rouvrir : le moteur redeviendrait consommateur du pipeline ML. diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index c45b134..c6189a6 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -206,7 +206,9 @@ le rapport rendu distingue `recommendations_created` de `already_present`. Le m disponible hors HTTP par `python -m app.cli generate-recommendations` (cible `make recommendations`), sur le patron de `make ml-score`. Le choix de loger le moteur dans le backend plutôt que dans `ml/` est justifié par l'[ADR 0006](../adr/0006-moteur-de-regles-dans-le-backend.md). -Tant qu'aucune source n'alimente `alert`, la route est fonctionnelle mais rend un rapport à zéro. +Les alertes traitées sont celles qu'écrit la détection interne (#104, section ci-dessous) : la +génération ne rend donc de recommandations qu'une fois la détection passée. L'insertion est +découpée en lots de `TAILLE_DE_LOT` lignes, asyncpg plafonnant une requête à 32 767 paramètres. `GET /readings` reprend le même gabarit mais s'en écarte sur un point : `reading` est l'hypertable, donc la seule table métier pouvant porter des années d'historique, ce que `docs/architecture/ diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md index b02fd13..88724e9 100644 --- a/docs/architecture/40-data.md +++ b/docs/architecture/40-data.md @@ -226,8 +226,9 @@ et ne sont pas considérées comme des alertes actuelles. Les lignes de `recommendation` sont écrites par le moteur de règles du backend (`app/services/recommendation_rules.py`), déclenché par `POST /api/v1/recommendations/generate` -ou par `make recommendations`. Le couple `(alert_id, rule_reference)` est unique : rejouer le -moteur sur les mêmes alertes n'ajoute aucune ligne. +ou par `make recommendations`, à partir des alertes déjà en base. Le couple +`(alert_id, rule_reference)` est unique : rejouer le moteur sur les mêmes alertes n'ajoute aucune +ligne. ### Relations entre les tables From 56c6b79a5ae4b409f64b7e93238a8943ee75d87b Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Mon, 21 Sep 2026 09:47:07 +0200 Subject: [PATCH 84/87] style(etl): applique le formatage Ruff aux tests API Mock --- apps/backend/tests/etl/test_mock_api_import.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/backend/tests/etl/test_mock_api_import.py b/apps/backend/tests/etl/test_mock_api_import.py index 2b4c07c..36ad2d8 100644 --- a/apps/backend/tests/etl/test_mock_api_import.py +++ b/apps/backend/tests/etl/test_mock_api_import.py @@ -169,9 +169,7 @@ def test_build_reading_row_respects_database_contract() -> None: assert row["source"] == "api_history" assert row["dataset_id"] is None - assert row["timestamp"] == datetime.fromisoformat( - "2024-06-15T12:00:00+00:00" - ) + assert row["timestamp"] == datetime.fromisoformat("2024-06-15T12:00:00+00:00") assert row["consumption_kw"] == 87.34 assert row["consumption_kwh"] == 87.34 From f23894086780e59fae4175b6d2ad3c1c88a94f62 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 10:21:10 +0200 Subject: [PATCH 85/87] =?UTF-8?q?fix(etl):=20borne=20la=20r=C3=A9ponse=20d?= =?UTF-8?q?e=20l'API=20Mock=20avant=20=C3=A9criture=20en=20base?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'API Mock est le seul item OWASP API10 du projet, et ce script en est le premier consommateur. Des quatre garde-fous exigés par la traçabilité OWASP, seul le timeout était en place. - plafonne la taille des réponses : MAX_SITES sites, au plus --limit mesures ; - borne chaque grandeur physique par PHYSICAL_BOUNDS, une valeur hors plage, d'un type inattendu, NaN ou infinie devenant NULL avec sa raison dans null_reasons et data_quality à degraded ; - ne recopie vers la base que les champs attendus, via build_site_row() et build_reading_row(), au lieu de passer les dictionnaires de l'API en paramètres SQL ; - écarte une data_quality que ck_reading_quality refuserait, plutôt que de faire échouer le lot entier ; - nomme la cible du ON CONFLICT, qui avalait jusqu'ici toute violation d'unicité, y compris celle de la clé primaire. raw_data conserve la réponse d'origine intacte : rien n'est perdu, seule son exploitation est bornée. --- apps/backend/app/etl/mock_api_import.py | 731 +++++---- .../backend/tests/etl/test_mock_api_import.py | 1456 ++++++++++------- docs/architecture/owasp-traceabilite.md | 3 +- 3 files changed, 1252 insertions(+), 938 deletions(-) diff --git a/apps/backend/app/etl/mock_api_import.py b/apps/backend/app/etl/mock_api_import.py index f143f16..0d5d6be 100644 --- a/apps/backend/app/etl/mock_api_import.py +++ b/apps/backend/app/etl/mock_api_import.py @@ -1,315 +1,416 @@ -from __future__ import annotations - -import argparse -import asyncio -import json -from datetime import datetime -from typing import Any - -import httpx -from sqlalchemy import text -from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine - -from app.core.config import get_settings - -SOURCE_HISTORY = "api_history" - - -def create_mock_api_client() -> httpx.AsyncClient: - settings = get_settings() - - if settings.mock_api_username is None or settings.mock_api_password is None: - raise ValueError("Les identifiants de l'API Mock ne sont pas configurés.") - - return httpx.AsyncClient( - base_url=settings.mock_api_base_url.rstrip("/"), - auth=( - settings.mock_api_username, - settings.mock_api_password.get_secret_value(), - ), - timeout=settings.mock_api_timeout_seconds, - ) - - -async def fetch_sites( - client: httpx.AsyncClient, -) -> list[dict[str, Any]]: - response = await client.get("/api/v1/sites") - - response.raise_for_status() - - payload = response.json() - - if not isinstance(payload, list): - raise ValueError("La réponse /api/v1/sites doit être une liste.") - - return payload - - -async def upsert_sites( - connection: AsyncConnection, - sites: list[dict[str, Any]], -) -> None: - if not sites: - return - - await connection.execute( - text( - """ - INSERT INTO site ( - site_id, - site_type, - site_name, - location, - capacity_kw, - status - ) - VALUES ( - :site_id, - :site_type, - :site_name, - :location, - :capacity_kw, - :status - ) - ON CONFLICT (site_id) - DO UPDATE SET - site_type = EXCLUDED.site_type, - site_name = EXCLUDED.site_name, - location = EXCLUDED.location, - capacity_kw = EXCLUDED.capacity_kw, - status = EXCLUDED.status - """ - ), - sites, - ) - - -async def fetch_readings( - client: httpx.AsyncClient, - site_id: str, - start_time: datetime, - end_time: datetime, - limit: int = 1000, -) -> list[dict[str, Any]]: - response = await client.get( - "/api/v1/readings", - params={ - "site_id": site_id, - "start_time": start_time.isoformat(), - "end_time": end_time.isoformat(), - "limit": limit, - }, - ) - - response.raise_for_status() - - payload = response.json() - - if not isinstance(payload, list): - raise ValueError("La réponse /api/v1/readings doit être une liste.") - - return payload - - -def build_reading_row( - reading: dict[str, Any], -) -> dict[str, Any]: - timestamp = datetime.fromisoformat(reading["timestamp"].replace("Z", "+00:00")) - return { - "site_id": reading["site_id"], - "timestamp": timestamp, - "source": SOURCE_HISTORY, - "dataset_id": None, - "consumption_kw": reading.get("consumption_kw"), - "consumption_kwh": reading.get("consumption_kwh"), - "consumption_euros": None, - "voltage_v": reading.get("voltage_v"), - "current_a": reading.get("current_a"), - "power_factor": reading.get("power_factor"), - "temperature_celsius": reading.get("temperature_celsius"), - "humidity_percent": reading.get("humidity_percent"), - "solar_irradiance_wm2": None, - "is_working_hours": None, - "data_quality": reading.get("data_quality"), - "null_reasons": reading.get("null_reasons"), - "imputed_values": None, - "imputation_method": None, - "raw_data": json.dumps( - reading, - ensure_ascii=False, - ), - } - - -READING_INSERT = text( - """ - INSERT INTO reading ( - site_id, - timestamp, - source, - dataset_id, - consumption_kw, - consumption_kwh, - consumption_euros, - voltage_v, - current_a, - power_factor, - temperature_celsius, - humidity_percent, - solar_irradiance_wm2, - is_working_hours, - data_quality, - null_reasons, - imputed_values, - imputation_method, - raw_data - ) - VALUES ( - :site_id, - :timestamp, - :source, - :dataset_id, - :consumption_kw, - :consumption_kwh, - :consumption_euros, - :voltage_v, - :current_a, - :power_factor, - :temperature_celsius, - :humidity_percent, - :solar_irradiance_wm2, - :is_working_hours, - :data_quality, - :null_reasons, - CAST(:imputed_values AS jsonb), - :imputation_method, - CAST(:raw_data AS jsonb) - ) - ON CONFLICT DO NOTHING - """ -) - - -def build_reading_batch( - readings: list[dict[str, Any]], -) -> list[dict[str, Any]]: - return [build_reading_row(reading) for reading in readings] - - -async def import_mock_api_history( - start_time: datetime, - end_time: datetime, - limit: int, - dry_run: bool, -) -> None: - settings = get_settings() - - async with create_mock_api_client() as client: - sites = await fetch_sites(client) - - print(f"Sites récupérés : {len(sites)}") - - all_readings: list[dict[str, Any]] = [] - - for site in sites: - site_id = site["site_id"] - - readings = await fetch_readings( - client=client, - site_id=site_id, - start_time=start_time, - end_time=end_time, - limit=limit, - ) - - print(f"{site_id}: {len(readings)} lectures") - - all_readings.extend(readings) - - print(f"Lectures récupérées : {len(all_readings)}") - - if dry_run: - print("Dry-run terminé : aucune donnée écrite.") - return - - engine = create_async_engine( - str(settings.database_url), - pool_pre_ping=True, - ) - - try: - async with engine.begin() as connection: - await upsert_sites( - connection, - sites, - ) - - rows = build_reading_batch(all_readings) - - if rows: - await connection.execute( - READING_INSERT, - rows, - ) - - finally: - await engine.dispose() - - print("Import API Mock terminé.") - - -def parse_datetime(value: str) -> datetime: - return datetime.fromisoformat(value.replace("Z", "+00:00")) - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description=("Import historique depuis l'API Mock EnerVision")) - - parser.add_argument( - "--start-time", - required=True, - type=parse_datetime, - ) - - parser.add_argument( - "--end-time", - required=True, - type=parse_datetime, - ) - - parser.add_argument( - "--limit", - type=int, - default=1000, - ) - - parser.add_argument( - "--dry-run", - action="store_true", - ) - - return parser.parse_args() - - -def main() -> None: - args = parse_args() - - if args.limit < 1 or args.limit > 1000: - raise ValueError("--limit doit être compris entre 1 et 1000.") - - if args.start_time >= args.end_time: - raise ValueError("--start-time doit être antérieur à --end-time.") - - asyncio.run( - import_mock_api_history( - start_time=args.start_time, - end_time=args.end_time, - limit=args.limit, - dry_run=args.dry_run, - ) - ) - - -if __name__ == "__main__": - main() +# Contrainte : la réponse de l'API Mock est une entrée hostile, pas une source de confiance. +# Voir OWASP API10 dans docs/architecture/owasp-traceabilite.md. Rien de ce qu'elle renvoie +# n'atteint la base sans passer par build_site_row() ou build_reading_row() : seuls les champs +# attendus sont recopiés, les grandeurs physiques sont bornées par PHYSICAL_BOUNDS et la taille +# des tableaux est plafonnée par MAX_SITES et par --limit. Une valeur hors bornes devient NULL +# et laisse sa trace dans null_reasons plutôt que de lever : le mock émet des anomalies par +# construction, et raw_data conserve de toute façon la réponse d'origine intacte. + +from __future__ import annotations + +import argparse +import asyncio +import json +from datetime import datetime +from typing import Any + +import httpx +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine + +from app.core.config import get_settings + +SOURCE_HISTORY = "api_history" + +MAX_SITES = 100 + +MAX_LIMIT = 1000 + +# Les quatre seules valeurs que la contrainte ck_reading_quality accepte. +ACCEPTED_QUALITIES = frozenset({"good", "partial", "degraded", "critical"}) + +PHYSICAL_BOUNDS: dict[str, tuple[float, float]] = { + "consumption_kw": (0.0, 100_000.0), + "consumption_kwh": (0.0, 100_000.0), + "voltage_v": (0.0, 1_000.0), + "current_a": (0.0, 10_000.0), + "power_factor": (0.0, 1.0), + "temperature_celsius": (-90.0, 60.0), + "humidity_percent": (0.0, 100.0), +} + +CAPACITY_BOUNDS = (0.0, 100_000.0) + + +def create_mock_api_client() -> httpx.AsyncClient: + settings = get_settings() + + if settings.mock_api_username is None or settings.mock_api_password is None: + raise ValueError("Les identifiants de l'API Mock ne sont pas configurés.") + + return httpx.AsyncClient( + base_url=settings.mock_api_base_url.rstrip("/"), + auth=( + settings.mock_api_username, + settings.mock_api_password.get_secret_value(), + ), + timeout=settings.mock_api_timeout_seconds, + ) + + +def read_text(payload: dict[str, Any], key: str) -> str: + value = payload.get(key) + + if not isinstance(value, str) or not value: + raise ValueError(f"Champ {key} absent ou invalide dans la réponse de l'API Mock.") + + return value + + +def optional_text(value: Any) -> str | None: + return value if isinstance(value, str) else None + + +def coerce_measure( + value: Any, + bounds: tuple[float, float], +) -> float | None: + if isinstance(value, bool) or not isinstance(value, int | float): + return None + + lower, upper = bounds + + # Écarte aussi NaN et les infinis, qu'aucune comparaison de bornes ne retient. + return float(value) if lower <= value <= upper else None + + +def resolve_quality( + value: Any, + rejected: list[str], +) -> str | None: + quality = value if isinstance(value, str) and value in ACCEPTED_QUALITIES else None + + if rejected: + return "critical" if quality == "critical" else "degraded" + + return quality + + +def resolve_null_reasons( + value: Any, + rejected: list[str], +) -> list[str]: + reported = [str(reason) for reason in value] if isinstance(value, list) else [] + + return reported + rejected + + +async def fetch_sites( + client: httpx.AsyncClient, +) -> list[dict[str, Any]]: + response = await client.get("/api/v1/sites") + + response.raise_for_status() + + payload = response.json() + + if not isinstance(payload, list): + raise ValueError("La réponse /api/v1/sites doit être une liste.") + + if len(payload) > MAX_SITES: + raise ValueError(f"La réponse /api/v1/sites dépasse le plafond de {MAX_SITES} sites.") + + return payload + + +def build_site_row( + site: dict[str, Any], +) -> dict[str, Any]: + return { + "site_id": read_text(site, "site_id"), + "site_type": read_text(site, "site_type"), + "site_name": read_text(site, "site_name"), + "location": optional_text(site.get("location")), + "capacity_kw": coerce_measure(site.get("capacity_kw"), CAPACITY_BOUNDS), + "status": optional_text(site.get("status")), + } + + +async def upsert_sites( + connection: AsyncConnection, + sites: list[dict[str, Any]], +) -> None: + rows = [build_site_row(site) for site in sites] + + if not rows: + return + + await connection.execute( + text( + """ + INSERT INTO site ( + site_id, + site_type, + site_name, + location, + capacity_kw, + status + ) + VALUES ( + :site_id, + :site_type, + :site_name, + :location, + :capacity_kw, + :status + ) + ON CONFLICT (site_id) + DO UPDATE SET + site_type = EXCLUDED.site_type, + site_name = EXCLUDED.site_name, + location = EXCLUDED.location, + capacity_kw = EXCLUDED.capacity_kw, + status = EXCLUDED.status + """ + ), + rows, + ) + + +async def fetch_readings( + client: httpx.AsyncClient, + site_id: str, + start_time: datetime, + end_time: datetime, + limit: int = MAX_LIMIT, +) -> list[dict[str, Any]]: + response = await client.get( + "/api/v1/readings", + params={ + "site_id": site_id, + "start_time": start_time.isoformat(), + "end_time": end_time.isoformat(), + "limit": limit, + }, + ) + + response.raise_for_status() + + payload = response.json() + + if not isinstance(payload, list): + raise ValueError("La réponse /api/v1/readings doit être une liste.") + + if len(payload) > limit: + raise ValueError(f"La réponse /api/v1/readings dépasse la limite demandée de {limit}.") + + return payload + + +def build_reading_row( + reading: dict[str, Any], +) -> dict[str, Any]: + measures: dict[str, float | None] = {} + rejected: list[str] = [] + + for name, bounds in PHYSICAL_BOUNDS.items(): + received = reading.get(name) + measures[name] = coerce_measure(received, bounds) + + if received is not None and measures[name] is None: + rejected.append(f"out_of_physical_bounds:{name}") + + return { + "site_id": read_text(reading, "site_id"), + "timestamp": parse_datetime(read_text(reading, "timestamp")), + "source": SOURCE_HISTORY, + "dataset_id": None, + **measures, + "consumption_euros": None, + "solar_irradiance_wm2": None, + "is_working_hours": None, + "data_quality": resolve_quality(reading.get("data_quality"), rejected), + "null_reasons": resolve_null_reasons(reading.get("null_reasons"), rejected), + "imputed_values": None, + "imputation_method": None, + "raw_data": json.dumps( + reading, + ensure_ascii=False, + ), + } + + +# Le conflit vise l'index unique uq_reading_source plutôt que la table entière : sans cible +# nommée, DO NOTHING avalerait aussi une violation de clé primaire. +READING_INSERT = text( + """ + INSERT INTO reading ( + site_id, + timestamp, + source, + dataset_id, + consumption_kw, + consumption_kwh, + consumption_euros, + voltage_v, + current_a, + power_factor, + temperature_celsius, + humidity_percent, + solar_irradiance_wm2, + is_working_hours, + data_quality, + null_reasons, + imputed_values, + imputation_method, + raw_data + ) + VALUES ( + :site_id, + :timestamp, + :source, + :dataset_id, + :consumption_kw, + :consumption_kwh, + :consumption_euros, + :voltage_v, + :current_a, + :power_factor, + :temperature_celsius, + :humidity_percent, + :solar_irradiance_wm2, + :is_working_hours, + :data_quality, + :null_reasons, + CAST(:imputed_values AS jsonb), + :imputation_method, + CAST(:raw_data AS jsonb) + ) + ON CONFLICT (site_id, timestamp, source, (coalesce(dataset_id, 0))) + DO NOTHING + """ +) + + +def build_reading_batch( + readings: list[dict[str, Any]], +) -> list[dict[str, Any]]: + return [build_reading_row(reading) for reading in readings] + + +async def import_mock_api_history( + start_time: datetime, + end_time: datetime, + limit: int, + dry_run: bool, +) -> None: + settings = get_settings() + + async with create_mock_api_client() as client: + sites = await fetch_sites(client) + + print(f"Sites récupérés : {len(sites)}") + + all_readings: list[dict[str, Any]] = [] + + for site in sites: + site_id = read_text(site, "site_id") + + readings = await fetch_readings( + client=client, + site_id=site_id, + start_time=start_time, + end_time=end_time, + limit=limit, + ) + + print(f"{site_id}: {len(readings)} lectures") + + all_readings.extend(readings) + + print(f"Lectures récupérées : {len(all_readings)}") + + if dry_run: + print("Dry-run terminé : aucune donnée écrite.") + return + + engine = create_async_engine( + str(settings.database_url), + pool_pre_ping=True, + ) + + try: + async with engine.begin() as connection: + await upsert_sites( + connection, + sites, + ) + + rows = build_reading_batch(all_readings) + + if rows: + await connection.execute( + READING_INSERT, + rows, + ) + + finally: + await engine.dispose() + + print("Import API Mock terminé.") + + +def parse_datetime(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=("Import historique depuis l'API Mock EnerVision")) + + parser.add_argument( + "--start-time", + required=True, + type=parse_datetime, + ) + + parser.add_argument( + "--end-time", + required=True, + type=parse_datetime, + ) + + parser.add_argument( + "--limit", + type=int, + default=MAX_LIMIT, + ) + + parser.add_argument( + "--dry-run", + action="store_true", + ) + + return parser.parse_args() + + +def main() -> None: + args = parse_args() + + if args.limit < 1 or args.limit > MAX_LIMIT: + raise ValueError(f"--limit doit être compris entre 1 et {MAX_LIMIT}.") + + if args.start_time >= args.end_time: + raise ValueError("--start-time doit être antérieur à --end-time.") + + asyncio.run( + import_mock_api_history( + start_time=args.start_time, + end_time=args.end_time, + limit=args.limit, + dry_run=args.dry_run, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/apps/backend/tests/etl/test_mock_api_import.py b/apps/backend/tests/etl/test_mock_api_import.py index 36ad2d8..cdcff55 100644 --- a/apps/backend/tests/etl/test_mock_api_import.py +++ b/apps/backend/tests/etl/test_mock_api_import.py @@ -1,622 +1,834 @@ -import json -import sys -from datetime import datetime -from types import SimpleNamespace -from typing import Any -from unittest.mock import AsyncMock, MagicMock - -import httpx -import pytest -from httpx import AsyncClient, MockTransport, Request, Response -from sqlalchemy import text -from sqlalchemy.ext.asyncio import AsyncSession - -import app.etl.mock_api_import as mock_api_import -from app.etl.mock_api_import import ( - READING_INSERT, - SOURCE_HISTORY, - build_reading_batch, - build_reading_row, - fetch_readings, - fetch_sites, - upsert_sites, -) - - -def make_site() -> dict[str, Any]: - return { - "site_id": "SITE001", - "site_type": "office", - "site_name": "Bureau Paris La Défense", - "location": "Paris, France", - "capacity_kw": 200, - "status": "active", - } - - -def make_reading() -> dict[str, Any]: - return { - "timestamp": "2024-06-15T12:00:00Z", - "site_id": "SITE001", - "site_type": "office", - "consumption_kw": 87.34, - "consumption_kwh": 87.34, - "voltage_v": 401.2, - "current_a": 132.5, - "power_factor": 0.923, - "temperature_celsius": 22.1, - "humidity_percent": 58.4, - "null_reasons": [], - "data_quality": "good", - } - - -async def test_fetch_sites_returns_sites() -> None: - def handler(request: Request) -> Response: - assert request.url.path == "/api/v1/sites" - - return Response( - status_code=200, - json=[make_site()], - ) - - transport = MockTransport(handler) - - async with AsyncClient( - transport=transport, - base_url="https://mock.test", - ) as client: - sites = await fetch_sites(client) - - assert len(sites) == 1 - assert sites[0]["site_id"] == "SITE001" - assert sites[0]["site_type"] == "office" - - -async def test_fetch_readings_sends_expected_query_parameters() -> None: - captured_params: dict[str, str] = {} - - def handler(request: Request) -> Response: - nonlocal captured_params - - captured_params = dict(request.url.params) - - return Response( - status_code=200, - json=[make_reading()], - ) - - transport = MockTransport(handler) - - start_time = datetime.fromisoformat("2024-06-15T12:00:00") - end_time = datetime.fromisoformat("2024-06-15T13:00:00") - - async with AsyncClient( - transport=transport, - base_url="https://mock.test", - ) as client: - readings = await fetch_readings( - client=client, - site_id="SITE001", - start_time=start_time, - end_time=end_time, - limit=60, - ) - - assert len(readings) == 1 - assert captured_params["site_id"] == "SITE001" - assert captured_params["start_time"] == "2024-06-15T12:00:00" - assert captured_params["end_time"] == "2024-06-15T13:00:00" - assert captured_params["limit"] == "60" - - -async def test_fetch_readings_rejects_non_list_response() -> None: - def handler(request: Request) -> Response: - return Response( - status_code=200, - json={"unexpected": "payload"}, - ) - - transport = MockTransport(handler) - - async with AsyncClient( - transport=transport, - base_url="https://mock.test", - ) as client: - with pytest.raises( - ValueError, - match="La réponse /api/v1/readings doit être une liste", - ): - await fetch_readings( - client=client, - site_id="SITE001", - start_time=datetime.fromisoformat("2024-06-15T12:00:00"), - end_time=datetime.fromisoformat("2024-06-15T13:00:00"), - limit=60, - ) - - -async def test_fetch_readings_raises_on_http_error() -> None: - def handler(request: Request) -> Response: - return Response( - status_code=404, - json={"detail": "Site non trouvé"}, - ) - - transport = MockTransport(handler) - - async with AsyncClient( - transport=transport, - base_url="https://mock.test", - ) as client: - with pytest.raises(httpx.HTTPStatusError): - await fetch_readings( - client=client, - site_id="SITE999", - start_time=datetime.fromisoformat("2024-06-15T12:00:00"), - end_time=datetime.fromisoformat("2024-06-15T13:00:00"), - limit=60, - ) - - -def test_build_reading_row_respects_database_contract() -> None: - reading = make_reading() - - row = build_reading_row(reading) - - assert row["site_id"] == "SITE001" - assert row["source"] == SOURCE_HISTORY - assert row["source"] == "api_history" - assert row["dataset_id"] is None - - assert row["timestamp"] == datetime.fromisoformat("2024-06-15T12:00:00+00:00") - - assert row["consumption_kw"] == 87.34 - assert row["consumption_kwh"] == 87.34 - assert row["data_quality"] == "good" - assert row["null_reasons"] == [] - - assert row["imputed_values"] is None - assert row["imputation_method"] is None - - -def test_build_reading_row_keeps_null_values_and_quality() -> None: - reading = make_reading() - - reading["consumption_kw"] = None - reading["consumption_kwh"] = None - reading["voltage_v"] = None - reading["current_a"] = None - reading["power_factor"] = None - reading["data_quality"] = "degraded" - reading["null_reasons"] = [ - "consumption_sensor_failure", - "electrical_sensor_failure", - ] - - row = build_reading_row(reading) - - assert row["consumption_kw"] is None - assert row["consumption_kwh"] is None - assert row["voltage_v"] is None - assert row["current_a"] is None - assert row["power_factor"] is None - - assert row["data_quality"] == "degraded" - assert row["null_reasons"] == [ - "consumption_sensor_failure", - "electrical_sensor_failure", - ] - - assert row["imputed_values"] is None - assert row["imputation_method"] is None - - -def test_build_reading_row_keeps_raw_source_data() -> None: - reading = make_reading() - - row = build_reading_row(reading) - - raw_data = json.loads(row["raw_data"]) - - assert raw_data == reading - - -def test_build_reading_batch_transforms_all_readings() -> None: - first = make_reading() - - second = make_reading() - second["timestamp"] = "2024-06-15T12:01:00Z" - second["consumption_kw"] = 90.5 - - rows = build_reading_batch([first, second]) - - assert len(rows) == 2 - - assert rows[0]["site_id"] == "SITE001" - assert rows[0]["consumption_kw"] == 87.34 - - assert rows[1]["site_id"] == "SITE001" - assert rows[1]["consumption_kw"] == 90.5 - - -def test_create_mock_api_client_requires_credentials( - monkeypatch: pytest.MonkeyPatch, -) -> None: - settings = SimpleNamespace( - mock_api_username=None, - mock_api_password=None, - ) - - monkeypatch.setattr( - mock_api_import, - "get_settings", - lambda: settings, - ) - - with pytest.raises( - ValueError, - match="Les identifiants de l'API Mock ne sont pas configurés", - ): - mock_api_import.create_mock_api_client() - - -async def test_create_mock_api_client_uses_configuration( - monkeypatch: pytest.MonkeyPatch, -) -> None: - password = MagicMock() - password.get_secret_value.return_value = "test-password" - - settings = SimpleNamespace( - mock_api_base_url="https://mock.test/", - mock_api_username="test-user", - mock_api_password=password, - mock_api_timeout_seconds=10.0, - ) - - monkeypatch.setattr( - mock_api_import, - "get_settings", - lambda: settings, - ) - - client = mock_api_import.create_mock_api_client() - - try: - assert str(client.base_url) == "https://mock.test" - assert client.timeout.connect == 10.0 - finally: - await client.aclose() - - -async def test_upsert_sites_with_empty_list_does_nothing() -> None: - connection = AsyncMock() - - await upsert_sites( - connection, - [], - ) - - connection.execute.assert_not_awaited() - - -async def test_import_mock_api_history_dry_run_does_not_write( - monkeypatch: pytest.MonkeyPatch, -) -> None: - def handler(request: Request) -> Response: - if request.url.path == "/api/v1/sites": - return Response( - status_code=200, - json=[make_site()], - ) - - if request.url.path == "/api/v1/readings": - return Response( - status_code=200, - json=[make_reading()], - ) - - return Response(status_code=404) - - transport = MockTransport(handler) - - client = AsyncClient( - transport=transport, - base_url="https://mock.test", - ) - - monkeypatch.setattr( - mock_api_import, - "create_mock_api_client", - lambda: client, - ) - - monkeypatch.setattr( - mock_api_import, - "get_settings", - lambda: SimpleNamespace( - database_url="postgresql+asyncpg://unused", - ), - ) - - create_engine_mock = MagicMock() - - monkeypatch.setattr( - mock_api_import, - "create_async_engine", - create_engine_mock, - ) - - await mock_api_import.import_mock_api_history( - start_time=datetime.fromisoformat("2024-06-15T12:00:00"), - end_time=datetime.fromisoformat("2024-06-15T13:00:00"), - limit=60, - dry_run=True, - ) - - create_engine_mock.assert_not_called() - - -async def test_import_mock_api_history_loads_data( - monkeypatch: pytest.MonkeyPatch, -) -> None: - def handler(request: Request) -> Response: - if request.url.path == "/api/v1/sites": - return Response( - status_code=200, - json=[make_site()], - ) - - if request.url.path == "/api/v1/readings": - return Response( - status_code=200, - json=[make_reading()], - ) - - return Response(status_code=404) - - transport = MockTransport(handler) - - client = AsyncClient( - transport=transport, - base_url="https://mock.test", - ) - - monkeypatch.setattr( - mock_api_import, - "create_mock_api_client", - lambda: client, - ) - - monkeypatch.setattr( - mock_api_import, - "get_settings", - lambda: SimpleNamespace( - database_url="postgresql+asyncpg://test:test@localhost/test", - ), - ) - - connection = AsyncMock() - - transaction_context = MagicMock() - transaction_context.__aenter__ = AsyncMock( - return_value=connection, - ) - transaction_context.__aexit__ = AsyncMock( - return_value=None, - ) - - engine = MagicMock() - engine.begin.return_value = transaction_context - engine.dispose = AsyncMock() - - create_engine_mock = MagicMock( - return_value=engine, - ) - - upsert_sites_mock = AsyncMock() - - monkeypatch.setattr( - mock_api_import, - "create_async_engine", - create_engine_mock, - ) - - monkeypatch.setattr( - mock_api_import, - "upsert_sites", - upsert_sites_mock, - ) - - await mock_api_import.import_mock_api_history( - start_time=datetime.fromisoformat("2024-06-15T12:00:00"), - end_time=datetime.fromisoformat("2024-06-15T13:00:00"), - limit=60, - dry_run=False, - ) - - create_engine_mock.assert_called_once_with( - "postgresql+asyncpg://test:test@localhost/test", - pool_pre_ping=True, - ) - - upsert_sites_mock.assert_awaited_once_with( - connection, - [make_site()], - ) - - connection.execute.assert_awaited_once() - engine.dispose.assert_awaited_once() - - -def test_parse_datetime_accepts_z_suffix() -> None: - result = mock_api_import.parse_datetime( - "2024-06-15T12:00:00Z", - ) - - assert result == datetime.fromisoformat( - "2024-06-15T12:00:00+00:00", - ) - - -def test_parse_args_reads_cli_parameters( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr( - sys, - "argv", - [ - "mock_api_import", - "--start-time", - "2024-06-15T12:00:00Z", - "--end-time", - "2024-06-15T13:00:00Z", - "--limit", - "60", - "--dry-run", - ], - ) - - args = mock_api_import.parse_args() - - assert args.start_time == datetime.fromisoformat( - "2024-06-15T12:00:00+00:00", - ) - assert args.end_time == datetime.fromisoformat( - "2024-06-15T13:00:00+00:00", - ) - assert args.limit == 60 - assert args.dry_run is True - - -def test_main_rejects_limit_out_of_bounds( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr( - sys, - "argv", - [ - "mock_api_import", - "--start-time", - "2024-06-15T12:00:00Z", - "--end-time", - "2024-06-15T13:00:00Z", - "--limit", - "0", - ], - ) - - with pytest.raises( - ValueError, - match="--limit doit être compris entre 1 et 1000", - ): - mock_api_import.main() - - -def test_main_rejects_invalid_period( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr( - sys, - "argv", - [ - "mock_api_import", - "--start-time", - "2024-06-15T14:00:00Z", - "--end-time", - "2024-06-15T13:00:00Z", - "--limit", - "60", - ], - ) - - with pytest.raises( - ValueError, - match="--start-time doit être antérieur à --end-time", - ): - mock_api_import.main() - - -def test_main_runs_import( - monkeypatch: pytest.MonkeyPatch, -) -> None: - start_time = datetime.fromisoformat( - "2024-06-15T12:00:00+00:00", - ) - end_time = datetime.fromisoformat( - "2024-06-15T13:00:00+00:00", - ) - - import_mock = AsyncMock() - - monkeypatch.setattr( - mock_api_import, - "parse_args", - lambda: SimpleNamespace( - start_time=start_time, - end_time=end_time, - limit=60, - dry_run=True, - ), - ) - - monkeypatch.setattr( - mock_api_import, - "import_mock_api_history", - import_mock, - ) - - mock_api_import.main() - - import_mock.assert_awaited_once_with( - start_time=start_time, - end_time=end_time, - limit=60, - dry_run=True, - ) - - -@pytest.mark.integration -async def test_reading_insert_is_idempotent( - session: AsyncSession, -) -> None: - reading = make_reading() - row = build_reading_row(reading) - - connection = await session.connection() - - await upsert_sites( - connection, - [make_site()], - ) - - await session.execute( - READING_INSERT, - [row], - ) - - await session.execute( - READING_INSERT, - [row], - ) - - result = await session.execute( - text( - """ - SELECT COUNT(*) - FROM reading - WHERE site_id = :site_id - AND timestamp = :timestamp - AND source = :source - """ - ), - { - "site_id": row["site_id"], - "timestamp": row["timestamp"], - "source": row["source"], - }, - ) - - assert result.scalar_one() == 1 - - await session.rollback() +import json +import sys +from datetime import datetime +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest +from httpx import AsyncClient, MockTransport, Request, Response +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +import app.etl.mock_api_import as mock_api_import +from app.etl.mock_api_import import ( + MAX_SITES, + READING_INSERT, + SOURCE_HISTORY, + build_reading_batch, + build_reading_row, + build_site_row, + fetch_readings, + fetch_sites, + upsert_sites, +) + + +def make_site() -> dict[str, Any]: + return { + "site_id": "SITE001", + "site_type": "office", + "site_name": "Bureau Paris La Défense", + "location": "Paris, France", + "capacity_kw": 200, + "status": "active", + } + + +def make_reading() -> dict[str, Any]: + return { + "timestamp": "2024-06-15T12:00:00Z", + "site_id": "SITE001", + "site_type": "office", + "consumption_kw": 87.34, + "consumption_kwh": 87.34, + "voltage_v": 401.2, + "current_a": 132.5, + "power_factor": 0.923, + "temperature_celsius": 22.1, + "humidity_percent": 58.4, + "null_reasons": [], + "data_quality": "good", + } + + +async def test_fetch_sites_returns_sites() -> None: + def handler(request: Request) -> Response: + assert request.url.path == "/api/v1/sites" + + return Response( + status_code=200, + json=[make_site()], + ) + + transport = MockTransport(handler) + + async with AsyncClient( + transport=transport, + base_url="https://mock.test", + ) as client: + sites = await fetch_sites(client) + + assert len(sites) == 1 + assert sites[0]["site_id"] == "SITE001" + assert sites[0]["site_type"] == "office" + + +async def test_fetch_sites_rejects_non_list_response() -> None: + def handler(request: Request) -> Response: + return Response( + status_code=200, + json={"unexpected": "payload"}, + ) + + transport = MockTransport(handler) + + async with AsyncClient( + transport=transport, + base_url="https://mock.test", + ) as client: + with pytest.raises( + ValueError, + match="La réponse /api/v1/sites doit être une liste", + ): + await fetch_sites(client) + + +async def test_fetch_readings_sends_expected_query_parameters() -> None: + captured_params: dict[str, str] = {} + + def handler(request: Request) -> Response: + nonlocal captured_params + + captured_params = dict(request.url.params) + + return Response( + status_code=200, + json=[make_reading()], + ) + + transport = MockTransport(handler) + + start_time = datetime.fromisoformat("2024-06-15T12:00:00") + end_time = datetime.fromisoformat("2024-06-15T13:00:00") + + async with AsyncClient( + transport=transport, + base_url="https://mock.test", + ) as client: + readings = await fetch_readings( + client=client, + site_id="SITE001", + start_time=start_time, + end_time=end_time, + limit=60, + ) + + assert len(readings) == 1 + assert captured_params["site_id"] == "SITE001" + assert captured_params["start_time"] == "2024-06-15T12:00:00" + assert captured_params["end_time"] == "2024-06-15T13:00:00" + assert captured_params["limit"] == "60" + + +async def test_fetch_readings_rejects_non_list_response() -> None: + def handler(request: Request) -> Response: + return Response( + status_code=200, + json={"unexpected": "payload"}, + ) + + transport = MockTransport(handler) + + async with AsyncClient( + transport=transport, + base_url="https://mock.test", + ) as client: + with pytest.raises( + ValueError, + match="La réponse /api/v1/readings doit être une liste", + ): + await fetch_readings( + client=client, + site_id="SITE001", + start_time=datetime.fromisoformat("2024-06-15T12:00:00"), + end_time=datetime.fromisoformat("2024-06-15T13:00:00"), + limit=60, + ) + + +async def test_fetch_readings_raises_on_http_error() -> None: + def handler(request: Request) -> Response: + return Response( + status_code=404, + json={"detail": "Site non trouvé"}, + ) + + transport = MockTransport(handler) + + async with AsyncClient( + transport=transport, + base_url="https://mock.test", + ) as client: + with pytest.raises(httpx.HTTPStatusError): + await fetch_readings( + client=client, + site_id="SITE999", + start_time=datetime.fromisoformat("2024-06-15T12:00:00"), + end_time=datetime.fromisoformat("2024-06-15T13:00:00"), + limit=60, + ) + + +def test_build_reading_row_respects_database_contract() -> None: + reading = make_reading() + + row = build_reading_row(reading) + + assert row["site_id"] == "SITE001" + assert row["source"] == SOURCE_HISTORY + assert row["source"] == "api_history" + assert row["dataset_id"] is None + + assert row["timestamp"] == datetime.fromisoformat("2024-06-15T12:00:00+00:00") + + assert row["consumption_kw"] == 87.34 + assert row["consumption_kwh"] == 87.34 + assert row["data_quality"] == "good" + assert row["null_reasons"] == [] + + assert row["imputed_values"] is None + assert row["imputation_method"] is None + + +def test_build_reading_row_keeps_null_values_and_quality() -> None: + reading = make_reading() + + reading["consumption_kw"] = None + reading["consumption_kwh"] = None + reading["voltage_v"] = None + reading["current_a"] = None + reading["power_factor"] = None + reading["data_quality"] = "degraded" + reading["null_reasons"] = [ + "consumption_sensor_failure", + "electrical_sensor_failure", + ] + + row = build_reading_row(reading) + + assert row["consumption_kw"] is None + assert row["consumption_kwh"] is None + assert row["voltage_v"] is None + assert row["current_a"] is None + assert row["power_factor"] is None + + assert row["data_quality"] == "degraded" + assert row["null_reasons"] == [ + "consumption_sensor_failure", + "electrical_sensor_failure", + ] + + assert row["imputed_values"] is None + assert row["imputation_method"] is None + + +def test_build_reading_row_keeps_raw_source_data() -> None: + reading = make_reading() + + row = build_reading_row(reading) + + raw_data = json.loads(row["raw_data"]) + + assert raw_data == reading + + +def test_build_reading_batch_transforms_all_readings() -> None: + first = make_reading() + + second = make_reading() + second["timestamp"] = "2024-06-15T12:01:00Z" + second["consumption_kw"] = 90.5 + + rows = build_reading_batch([first, second]) + + assert len(rows) == 2 + + assert rows[0]["site_id"] == "SITE001" + assert rows[0]["consumption_kw"] == 87.34 + + assert rows[1]["site_id"] == "SITE001" + assert rows[1]["consumption_kw"] == 90.5 + + +def test_create_mock_api_client_requires_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings = SimpleNamespace( + mock_api_username=None, + mock_api_password=None, + ) + + monkeypatch.setattr( + mock_api_import, + "get_settings", + lambda: settings, + ) + + with pytest.raises( + ValueError, + match="Les identifiants de l'API Mock ne sont pas configurés", + ): + mock_api_import.create_mock_api_client() + + +async def test_create_mock_api_client_uses_configuration( + monkeypatch: pytest.MonkeyPatch, +) -> None: + password = MagicMock() + password.get_secret_value.return_value = "test-password" + + settings = SimpleNamespace( + mock_api_base_url="https://mock.test/", + mock_api_username="test-user", + mock_api_password=password, + mock_api_timeout_seconds=10.0, + ) + + monkeypatch.setattr( + mock_api_import, + "get_settings", + lambda: settings, + ) + + client = mock_api_import.create_mock_api_client() + + try: + assert str(client.base_url) == "https://mock.test" + assert client.timeout.connect == 10.0 + finally: + await client.aclose() + + +async def test_upsert_sites_with_empty_list_does_nothing() -> None: + connection = AsyncMock() + + await upsert_sites( + connection, + [], + ) + + connection.execute.assert_not_awaited() + + +async def test_import_mock_api_history_dry_run_does_not_write( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def handler(request: Request) -> Response: + if request.url.path == "/api/v1/sites": + return Response( + status_code=200, + json=[make_site()], + ) + + if request.url.path == "/api/v1/readings": + return Response( + status_code=200, + json=[make_reading()], + ) + + return Response(status_code=404) + + transport = MockTransport(handler) + + client = AsyncClient( + transport=transport, + base_url="https://mock.test", + ) + + monkeypatch.setattr( + mock_api_import, + "create_mock_api_client", + lambda: client, + ) + + monkeypatch.setattr( + mock_api_import, + "get_settings", + lambda: SimpleNamespace( + database_url="postgresql+asyncpg://unused", + ), + ) + + create_engine_mock = MagicMock() + + monkeypatch.setattr( + mock_api_import, + "create_async_engine", + create_engine_mock, + ) + + await mock_api_import.import_mock_api_history( + start_time=datetime.fromisoformat("2024-06-15T12:00:00"), + end_time=datetime.fromisoformat("2024-06-15T13:00:00"), + limit=60, + dry_run=True, + ) + + create_engine_mock.assert_not_called() + + +async def test_import_mock_api_history_loads_data( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def handler(request: Request) -> Response: + if request.url.path == "/api/v1/sites": + return Response( + status_code=200, + json=[make_site()], + ) + + if request.url.path == "/api/v1/readings": + return Response( + status_code=200, + json=[make_reading()], + ) + + return Response(status_code=404) + + transport = MockTransport(handler) + + client = AsyncClient( + transport=transport, + base_url="https://mock.test", + ) + + monkeypatch.setattr( + mock_api_import, + "create_mock_api_client", + lambda: client, + ) + + monkeypatch.setattr( + mock_api_import, + "get_settings", + lambda: SimpleNamespace( + database_url="postgresql+asyncpg://test:test@localhost/test", + ), + ) + + connection = AsyncMock() + + transaction_context = MagicMock() + transaction_context.__aenter__ = AsyncMock( + return_value=connection, + ) + transaction_context.__aexit__ = AsyncMock( + return_value=None, + ) + + engine = MagicMock() + engine.begin.return_value = transaction_context + engine.dispose = AsyncMock() + + create_engine_mock = MagicMock( + return_value=engine, + ) + + upsert_sites_mock = AsyncMock() + + monkeypatch.setattr( + mock_api_import, + "create_async_engine", + create_engine_mock, + ) + + monkeypatch.setattr( + mock_api_import, + "upsert_sites", + upsert_sites_mock, + ) + + await mock_api_import.import_mock_api_history( + start_time=datetime.fromisoformat("2024-06-15T12:00:00"), + end_time=datetime.fromisoformat("2024-06-15T13:00:00"), + limit=60, + dry_run=False, + ) + + create_engine_mock.assert_called_once_with( + "postgresql+asyncpg://test:test@localhost/test", + pool_pre_ping=True, + ) + + upsert_sites_mock.assert_awaited_once_with( + connection, + [make_site()], + ) + + connection.execute.assert_awaited_once() + engine.dispose.assert_awaited_once() + + +def test_parse_datetime_accepts_z_suffix() -> None: + result = mock_api_import.parse_datetime( + "2024-06-15T12:00:00Z", + ) + + assert result == datetime.fromisoformat( + "2024-06-15T12:00:00+00:00", + ) + + +def test_parse_args_reads_cli_parameters( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + sys, + "argv", + [ + "mock_api_import", + "--start-time", + "2024-06-15T12:00:00Z", + "--end-time", + "2024-06-15T13:00:00Z", + "--limit", + "60", + "--dry-run", + ], + ) + + args = mock_api_import.parse_args() + + assert args.start_time == datetime.fromisoformat( + "2024-06-15T12:00:00+00:00", + ) + assert args.end_time == datetime.fromisoformat( + "2024-06-15T13:00:00+00:00", + ) + assert args.limit == 60 + assert args.dry_run is True + + +def test_main_rejects_limit_out_of_bounds( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + sys, + "argv", + [ + "mock_api_import", + "--start-time", + "2024-06-15T12:00:00Z", + "--end-time", + "2024-06-15T13:00:00Z", + "--limit", + "0", + ], + ) + + with pytest.raises( + ValueError, + match="--limit doit être compris entre 1 et 1000", + ): + mock_api_import.main() + + +def test_main_rejects_invalid_period( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + sys, + "argv", + [ + "mock_api_import", + "--start-time", + "2024-06-15T14:00:00Z", + "--end-time", + "2024-06-15T13:00:00Z", + "--limit", + "60", + ], + ) + + with pytest.raises( + ValueError, + match="--start-time doit être antérieur à --end-time", + ): + mock_api_import.main() + + +def test_main_runs_import( + monkeypatch: pytest.MonkeyPatch, +) -> None: + start_time = datetime.fromisoformat( + "2024-06-15T12:00:00+00:00", + ) + end_time = datetime.fromisoformat( + "2024-06-15T13:00:00+00:00", + ) + + import_mock = AsyncMock() + + monkeypatch.setattr( + mock_api_import, + "parse_args", + lambda: SimpleNamespace( + start_time=start_time, + end_time=end_time, + limit=60, + dry_run=True, + ), + ) + + monkeypatch.setattr( + mock_api_import, + "import_mock_api_history", + import_mock, + ) + + mock_api_import.main() + + import_mock.assert_awaited_once_with( + start_time=start_time, + end_time=end_time, + limit=60, + dry_run=True, + ) + + +async def test_fetch_sites_rejects_a_response_above_the_cap() -> None: + def handler(request: Request) -> Response: + return Response( + status_code=200, + json=[make_site() for _ in range(MAX_SITES + 1)], + ) + + transport = MockTransport(handler) + + async with AsyncClient( + transport=transport, + base_url="https://mock.test", + ) as client: + with pytest.raises( + ValueError, + match=f"dépasse le plafond de {MAX_SITES} sites", + ): + await fetch_sites(client) + + +async def test_fetch_readings_rejects_a_response_above_the_requested_limit() -> None: + def handler(request: Request) -> Response: + return Response( + status_code=200, + json=[make_reading(), make_reading(), make_reading()], + ) + + transport = MockTransport(handler) + + async with AsyncClient( + transport=transport, + base_url="https://mock.test", + ) as client: + with pytest.raises( + ValueError, + match="dépasse la limite demandée de 2", + ): + await fetch_readings( + client=client, + site_id="SITE001", + start_time=datetime.fromisoformat("2024-06-15T12:00:00"), + end_time=datetime.fromisoformat("2024-06-15T13:00:00"), + limit=2, + ) + + +def test_build_reading_row_neutralises_values_outside_physical_bounds() -> None: + reading = make_reading() + + reading["power_factor"] = 42.0 + reading["temperature_celsius"] = 1e30 + reading["humidity_percent"] = -1.0 + + row = build_reading_row(reading) + + assert row["power_factor"] is None + assert row["temperature_celsius"] is None + assert row["humidity_percent"] is None + + assert row["null_reasons"] == [ + "out_of_physical_bounds:power_factor", + "out_of_physical_bounds:temperature_celsius", + "out_of_physical_bounds:humidity_percent", + ] + + assert row["data_quality"] == "degraded" + + assert json.loads(row["raw_data"])["power_factor"] == 42.0 + + +def test_build_reading_row_rejects_a_measure_that_is_not_a_number() -> None: + reading = make_reading() + + reading["consumption_kw"] = "87.34" + + row = build_reading_row(reading) + + assert row["consumption_kw"] is None + assert "out_of_physical_bounds:consumption_kw" in row["null_reasons"] + + +def test_build_reading_row_drops_a_quality_the_database_refuses() -> None: + reading = make_reading() + + reading["data_quality"] = "unknown" + + row = build_reading_row(reading) + + assert row["data_quality"] is None + + +def test_build_reading_row_requires_an_identifier() -> None: + reading = make_reading() + + del reading["site_id"] + + with pytest.raises( + ValueError, + match="Champ site_id absent ou invalide", + ): + build_reading_row(reading) + + +def test_build_site_row_keeps_only_the_expected_columns() -> None: + site = make_site() + + site["unexpected"] = "valeur hostile" + site["capacity_kw"] = -5.0 + site["status"] = 12 + + row = build_site_row(site) + + assert set(row) == { + "site_id", + "site_type", + "site_name", + "location", + "capacity_kw", + "status", + } + + assert row["capacity_kw"] is None + assert row["status"] is None + + +async def test_upsert_sites_sends_only_the_expected_columns() -> None: + connection = AsyncMock() + + site = make_site() + site["unexpected"] = "valeur hostile" + + await upsert_sites( + connection, + [site], + ) + + rows = connection.execute.await_args.args[1] + + assert "unexpected" not in rows[0] + assert rows[0]["site_id"] == "SITE001" + + +@pytest.mark.integration +async def test_reading_insert_is_idempotent( + session: AsyncSession, +) -> None: + reading = make_reading() + row = build_reading_row(reading) + + connection = await session.connection() + + await upsert_sites( + connection, + [make_site()], + ) + + await session.execute( + READING_INSERT, + [row], + ) + + await session.execute( + READING_INSERT, + [row], + ) + + result = await session.execute( + text( + """ + SELECT COUNT(*) + FROM reading + WHERE site_id = :site_id + AND timestamp = :timestamp + AND source = :source + """ + ), + { + "site_id": row["site_id"], + "timestamp": row["timestamp"], + "source": row["source"], + }, + ) + + assert result.scalar_one() == 1 + + await session.rollback() + + +@pytest.mark.integration +async def test_out_of_bounds_reading_is_stored_neutralised( + session: AsyncSession, +) -> None: + reading = make_reading() + reading["power_factor"] = 42.0 + + row = build_reading_row(reading) + + connection = await session.connection() + + await upsert_sites( + connection, + [make_site()], + ) + + await session.execute( + READING_INSERT, + [row], + ) + + result = await session.execute( + text( + """ + SELECT power_factor, data_quality, null_reasons, raw_data ->> 'power_factor' + FROM reading + WHERE site_id = :site_id + AND timestamp = :timestamp + AND source = :source + """ + ), + { + "site_id": row["site_id"], + "timestamp": row["timestamp"], + "source": row["source"], + }, + ) + + stored = result.one() + + await session.rollback() + + assert stored[0] is None + assert stored[1] == "degraded" + assert stored[2] == ["out_of_physical_bounds:power_factor"] + assert stored[3] == "42.0" diff --git a/docs/architecture/owasp-traceabilite.md b/docs/architecture/owasp-traceabilite.md index 34e6853..7e9c19a 100644 --- a/docs/architecture/owasp-traceabilite.md +++ b/docs/architecture/owasp-traceabilite.md @@ -41,6 +41,7 @@ lecture seule ; plusieurs lignes resteront à compléter une fois les endpoints | En-têtes `nosniff`, `DENY`, `no-referrer`, et `no-store` sur les routes d'authentification | `app/api/middleware.py` | A05 | | Refus de rétrograder ou désactiver le dernier administrateur actif | `app/services/user.py` | A04 Insecure Design | | Amorçage du premier administrateur hors dépôt, mot de passe jamais dans `argv` ni dans Git | `app/cli.py` | A02, A05 | +| Réponse de l'API Mock bornée avant écriture : timeout, plafond de sites et de mesures, bornes physiques par grandeur, recopie des seuls champs attendus | `app/etl/mock_api_import.py` | API10 Unsafe Consumption of APIs | | CI bloquante : format, lint avec règles Bandit, typage strict, tests avec seuil de couverture | `.github/workflows/backend.yml` | A06 Vulnerable and Outdated Components | Note sur A06 : le jeu de règles `S` de ruff, déjà actif dans `pyproject.toml`, est le portage des @@ -53,7 +54,7 @@ règles Bandit. Ajouter Bandit à la CI serait redondant, contrairement à ce qu | **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site : `GET /sites/{site_id}` et `GET /recommendations/{recommendation_id}` répondent à tout compte `lecteur` pour n'importe quel site ou recommandation, sans vérifier une affectation compte-site qui n'existe pas encore. Un opérateur du site A pourra agir sur le site B dès que les endpoints d'écriture métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. | | **API4, lectures de séries temporelles** | **partiel** | `GET /readings` plafonne la fenêtre temporelle (90 jours) et la pagination (`limit` ≤ 2000), voir plus haut. Reste ouvert : pagination en `limit`/`offset` simple plutôt qu'en curseur (un `offset` élevé sur une fenêtre dense reste coûteux), et aucun `statement_timeout` au niveau de la connexion pour borner une requête individuelle si les plafonds au-dessus s'avéraient insuffisants. | | **API8 Security Misconfiguration, transport** | **ouvert** | Pas de TLS, donc ni HSTS, ni cookie `Secure` réellement posé en production. Ils appartiennent au terminateur TLS, qui n'existe pas. | -| **API10 Unsafe Consumption of APIs** | **ouvert, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse doit être traitée comme une entrée hostile : bornes physiques, taille de tableau plafonnée, timeout, et frontière d'anti-corruption. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. | +| **API10 Unsafe Consumption of APIs** | **partiel, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse est traitée comme une entrée hostile par `app/etl/mock_api_import.py`, son seul consommateur à ce jour : les quatre garde-fous attendus sont en place, voir la ligne correspondante plus haut. Reste ouvert : le plafond de taille s'applique après désérialisation de la réponse, borner le corps HTTP lui-même demanderait une lecture en flux ; et `APP_MOCK_API_BASE_URL` n'impose pas `https`, donc les identifiants Basic partiraient en clair sur une URL en `http`. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. | | **A08 Software and Data Integrity Failures** | **partiel** | La CI vérifie le code mais n'analyse ni les dépendances ni les images. `.terraform.lock.hcl` reste ignoré par git, ce qui contredit une chaîne d'approvisionnement maîtrisée. | | **A10 Server-Side Request Forgery** | **sans objet aujourd'hui** | Aucune URL sortante n'est pilotée par une donnée utilisateur. Le jour où l'adresse d'une source devient un champ de configuration, il faudra une liste blanche de schémas et d'hôtes, sans suivi de redirection. | | **Cantonnement des accès ETL et ML** | **dette assumée** | Le compte applicatif porte l'identité, le rôle PostgreSQL porterait le cantonnement. Voir ADR 0003. | From de697b080d0ba7a90511b0f3c154b9b5d9ddd897 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 10:21:18 +0200 Subject: [PATCH 86/87] =?UTF-8?q?docs:=20r=C3=A9tablit=20la=20hi=C3=A9rarc?= =?UTF-8?q?hie=20des=20titres=20et=20les=20motifs=20du=20document=20Data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 40-data.md était passé à quatre titres de niveau 1 et etl/README.md à cinq, alors que les huit autres documents d'architecture n'en ont qu'un. Les sections ajoutées redescendent d'un niveau. La réécriture de la section « Tables d'authentification » avait aussi vidé quatre choix de modélisation de leur raison, dont le renvoi à l'ADR 0004 sur audit_log.actor_id. Ces motifs sont rétablis, et les deux tables de réinitialisation reçoivent le leur. Documente enfin la frontière de confiance avec l'API Mock : les quatre garde-fous, les plages de PHYSICAL_BOUNDS, et ce qu'il reste à faire. --- docs/architecture/40-data.md | 83 +++++++++++++++++++++++---------- etl/README.md | 90 +++++++++++++++++++++++++----------- 2 files changed, 122 insertions(+), 51 deletions(-) diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md index 65a4d62..40a98e6 100644 --- a/docs/architecture/40-data.md +++ b/docs/architecture/40-data.md @@ -96,8 +96,8 @@ Les flèches pleines représentent les traitements actuellement implémentés. Les flèches pointillées représentent les éléments encore prévus comme cibles. -Les lectures futures de l'API et de Grafana visent l'agrégat continu plutôt que la table brute -lorsque cette partie TimescaleDB sera mise en place. +Les lectures de l'API et de Grafana viseront l'agrégat continu, pas la table brute : c'est tout +l'intérêt de TimescaleDB, et cela doit rester vrai quand les volumes augmenteront. ## Tables d'authentification @@ -170,21 +170,28 @@ erDiagram } ``` -Plusieurs choix de modélisation portent une intention précise : +Six choix de modélisation portent une intention et se défendent seuls : - **`app_user` et non `user`** : `user` est un mot réservé PostgreSQL, raccourci de - `CURRENT_USER`. Le nom rappelle aussi qu'il s'agit d'un compte applicatif. -- **`credentials_changed_at`, une seule colonne**, couvre notamment le changement de mot de passe, - le changement de rôle et la désactivation. -- **`refresh_token.expires_at` est absolu et hérité** du prédécesseur à chaque rotation. -- **`audit_log.actor_id` n'a aucune clé étrangère** afin de conserver les informations d'audit - même si l'entité d'origine évolue. -- `password_reset_token` ne stocke que l'empreinte du jeton et jamais sa valeur directement. -- `password_reset_attempt` est séparée de `audit_log`, car son volume peut être piloté - par des demandes externes répétées. + `CURRENT_USER`. Le nom rappelle en prime qu'il s'agit d'un compte applicatif, par opposition + au rôle PostgreSQL qui portera le cantonnement de l'ETL. +- **`credentials_changed_at`, une seule colonne**, couvre le changement de mot de passe, le + changement de rôle et la désactivation. Un compteur de version ne dirait rien à un humain qui + lit un audit. +- **`refresh_token.expires_at` est absolu et hérité** du prédécesseur à chaque rotation. S'il + glissait, la promesse de sept jours serait fictive et une session active ne finirait jamais. +- **`audit_log.actor_id` n'a aucune clé étrangère**, et `actor_email` comme `actor_role` sont + dénormalisés. Une contrainte `ON DELETE SET NULL` déclencherait un `UPDATE` que le déclencheur + d'ajout seul refuserait. Voir l'[ADR 0004](../adr/0004-journal-d-audit-en-ajout-seul.md). +- **`password_reset_token` ne stocke que l'empreinte du jeton**, jamais sa valeur. Une fuite de + la table ne donne donc rien à rejouer. +- **`password_reset_attempt` est séparée de `audit_log`** : son volume est piloté par le + demandeur, comme celui de `login_attempt`, donc elle doit pouvoir se purger. -`audit_log` porte des déclencheurs qui refusent `UPDATE`, `DELETE` et `TRUNCATE`. -Elle n'est donc **pas** une hypertable. +`audit_log` porte deux déclencheurs qui refusent `UPDATE`, `DELETE` et `TRUNCATE`. Elle n'est +donc **pas** une hypertable : une politique de rétention émettrait des `DELETE` qu'ils +refuseraient. `login_attempt`, à l'inverse, est faite pour se purger, puisque son volume est +piloté par l'attaquant. ## Gabarit de révision créant une hypertable @@ -234,7 +241,8 @@ colonne de temps : les index déclarés dans la révision le couvrent déjà. ## Questions ouvertes -Elles portent maintenant principalement sur l'exploitation du schéma : +Elles relèvent du jalon J2, « valider le périmètre retenu ». Le schéma et l'ingestion sont +livrés : ce qui suit porte sur leur exploitation, plus sur leur forme. - **Quelle granularité** conserver à long terme à l'ingestion : seconde, minute ou quart d'heure. - **Quels agrégats continus** créer et sur quelles fenêtres. @@ -287,7 +295,7 @@ Elles servent à l'analyse des données et ne sont pas considérées comme des a - Une alerte peut être associée à une prévision du même site. - Une alerte peut donner lieu à plusieurs recommandations. -# Ingestion des données historiques +## Ingestion des données historiques Statut : `Fait`. @@ -301,7 +309,7 @@ Les fichiers sources CSV et JSON sont nécessaires uniquement pour l'initialisat Ils ne sont pas versionnés dans Git et sont placés localement dans `data/raw/`. -## Architecture du flux historique +### Architecture du flux historique ```text Dataset CSV + métadonnées JSON @@ -351,7 +359,7 @@ source = "csv" dataset_id = identifiant du dataset ``` -## Résultats validés pour l'historique +### Résultats validés pour l'historique Le chargement de référence a permis d'obtenir : @@ -366,7 +374,7 @@ aucune nouvelle mesure n'a été créée et le nombre de `reading` est resté à La procédure détaillée d'installation, d'exécution, de validation et de contrôle du pipeline est disponible dans `etl/README.md`. -# Ingestion depuis l'API Mock +## Ingestion depuis l'API Mock Statut : `Fait`. @@ -378,7 +386,7 @@ Le traitement est implémenté dans : apps/backend/app/etl/mock_api_import.py ``` -## Endpoints utilisés +### Endpoints utilisés Le pipeline récupère les informations des sites depuis : @@ -410,7 +418,7 @@ Les paramètres de ligne de commande disponibles pour l'import sont : --dry-run ``` -## Flux d'ingestion API Mock +### Flux d'ingestion API Mock ```text API Mock @@ -454,7 +462,32 @@ La réponse source reçue depuis l'API est conservée dans : raw_data ``` -## Qualité des données de l'API Mock +### Frontière de confiance avec l'API Mock + +L'API Mock de l'école n'a aucune authentification et expose un endpoint mutatif à quiconque. Sa +réponse est donc traitée comme une entrée hostile, conformément à API10 dans +[la traçabilité OWASP](owasp-traceabilite.md). Le risque premier n'est pas la fausse alerte, +c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. + +Quatre garde-fous, tous dans `mock_api_import.py` : + +| Garde-fou | Mise en œuvre | +|---|---| +| Timeout | `APP_MOCK_API_TIMEOUT_SECONDS`, dix secondes par défaut | +| Taille de tableau plafonnée | `MAX_SITES` sites, et au plus `--limit` mesures par site | +| Bornes physiques | `PHYSICAL_BOUNDS`, une plage par grandeur | +| Frontière d'anti-corruption | `build_site_row()` et `build_reading_row()`, qui ne recopient que les champs attendus | + +Une valeur hors bornes, d'un type inattendu, `NaN` ou infinie devient `NULL`. Elle laisse sa +trace dans `null_reasons` sous la forme `out_of_physical_bounds:`, et `data_quality` +descend à `degraded`. Une `data_quality` que `ck_reading_quality` refuserait devient `NULL` +plutôt que de faire échouer le lot entier. Dans tous les cas `raw_data` conserve la réponse +d'origine intacte : rien n'est perdu, seule son exploitation est bornée. + +Le plafond de taille s'applique après désérialisation de la réponse. Borner le corps HTTP +lui-même demanderait une lecture en flux, et reste à faire. + +### Qualité des données de l'API Mock Les valeurs `NULL` ne sont pas remplacées pendant l'ingestion. @@ -475,7 +508,7 @@ imputed_values = NULL imputation_method = NULL ``` -## Validation de l'import API Mock +### Validation de l'import API Mock Un scénario de validation a été exécuté pour les 7 sites sur la période : @@ -526,7 +559,7 @@ Les tests automatisés couvrent également : - la conservation des données sources ; - l'idempotence en base. -# Évolution prévue +## Évolution prévue La prochaine étape consiste à orchestrer les deux mécanismes d'ingestion avec Apache Airflow. @@ -563,4 +596,4 @@ Les scripts Python resteront responsables de l'extraction, de la validation, de et du chargement des données. Le pipeline servira ensuite de base à la préparation des données nécessaires au modèle -de Machine Learning. \ No newline at end of file +de Machine Learning. diff --git a/etl/README.md b/etl/README.md index 15c376b..719b828 100644 --- a/etl/README.md +++ b/etl/README.md @@ -81,9 +81,9 @@ L'API Mock est utilisée pour compléter les données historiques avec des mesur | mypy | Vérification du typage | | Pytest | Tests automatisés | -# Import du dataset historique +## Import du dataset historique -## Fonctionnement du pipeline historique +### Fonctionnement du pipeline historique Le script d'import se trouve dans : @@ -115,14 +115,14 @@ CSV + métadonnées JSON PostgreSQL / TimescaleDB ``` -### 1. Extraction +#### 1. Extraction Le pipeline charge : - `all_sites_combined.csv` avec Pandas ; - `dataset_metadata.json` avec le module JSON de Python. -### 2. Validation +#### 2. Validation Avant toute écriture en base, le pipeline contrôle notamment : @@ -136,7 +136,7 @@ Avant toute écriture en base, le pipeline contrôle notamment : Une incohérence détectée pendant cette étape interrompt l'import avant le chargement. -### 3. Dry-run +#### 3. Dry-run Un mode `--dry-run` permet d'exécuter les contrôles sans écrire de données dans PostgreSQL. @@ -149,7 +149,7 @@ Il permet notamment de vérifier : - les valeurs NULL ; - l'empreinte SHA-256. -### 4. Traçabilité +#### 4. Traçabilité Une empreinte SHA-256 est calculée à partir du fichier CSV afin d'identifier le dataset utilisé. @@ -161,7 +161,7 @@ Empreinte SHA-256 du dataset validé : Cette empreinte participe à la traçabilité du dataset chargé. -### 5. Transformation +#### 5. Transformation Les timestamps sont normalisés avec la timezone : @@ -180,7 +180,7 @@ imputed_values = NULL imputation_method = NULL ``` -### 6. Chargement +#### 6. Chargement Le chargement est réalisé avec SQLAlchemy Async dans PostgreSQL/TimescaleDB. @@ -207,7 +207,7 @@ dataset_id = identifiant du dataset Cette représentation respecte les contraintes définies dans le schéma de la base. -## Dataset validé +### Dataset validé Le dataset traité contient : @@ -226,7 +226,7 @@ Valeurs manquantes identifiées : | `humidity_percent` | 3 423 | | `solar_irradiance_wm2` | 3 964 | -## Exécution historique en dry-run +### Exécution historique en dry-run Depuis le dossier : @@ -246,7 +246,7 @@ uv run python -m app.etl.historical_import ` Aucune donnée n'est écrite dans la base pendant cette exécution. -## Chargement historique réel +### Chargement historique réel Depuis `apps/backend/` : @@ -268,7 +268,7 @@ Chargement : 2000/122647 Chargement : 122647/122647 ``` -## Résultats obtenus pour le dataset historique +### Résultats obtenus pour le dataset historique Après le chargement initial, les contrôles en base ont confirmé : @@ -285,7 +285,7 @@ Le premier import a créé : nouvelles lectures : 122647 ``` -## Idempotence du dataset historique +### Idempotence du dataset historique Le pipeline a été exécuté une deuxième fois avec exactement le même dataset afin de vérifier son idempotence. @@ -299,7 +299,7 @@ nouvelles lectures : 0 Une nouvelle exécution du même import ne crée donc pas de mesures supplémentaires pour le dataset testé. -## Vérifications SQL du dataset historique +### Vérifications SQL du dataset historique Depuis la racine du projet, vérifier le nombre d'enregistrements avec : @@ -327,9 +327,9 @@ Résultat attendu pour le dataset historique : csv | 122647 ``` -# Import depuis l'API Mock +## Import depuis l'API Mock -## Fonctionnement +### Fonctionnement Le script d'import de l'API Mock se trouve dans : @@ -386,7 +386,7 @@ limit Le paramètre `limit` doit être compris entre 1 et 1000. -## Configuration de l'API Mock +### Configuration de l'API Mock La connexion à l'API Mock est configurée avec les variables d'environnement suivantes : @@ -401,7 +401,7 @@ Les identifiants réels ne sont pas versionnés dans Git. Les fichiers `.env.example` indiquent uniquement les variables nécessaires à l'exécution. -## Transformation des mesures API +### Transformation des mesures API Les mesures provenant de l'API Mock sont enregistrées dans `reading` avec : @@ -422,7 +422,7 @@ raw_data afin de préserver la donnée reçue et faciliter la traçabilité. -## Qualité des données API +### Qualité des données API Les valeurs `NULL` fournies par l'API sont conservées telles quelles. @@ -444,6 +444,9 @@ degraded critical ``` +Ce sont les quatre seules valeurs que la contrainte `ck_reading_quality` accepte. Toute autre +valeur renvoyée par l'API est remplacée par `NULL` plutôt que de faire échouer le lot entier. + Aucune imputation n'est réalisée pendant l'ingestion : ```text @@ -453,7 +456,42 @@ imputation_method = NULL Cette stratégie permet de distinguer une véritable valeur nulle ou manquante d'une consommation égale à zéro et de conserver les informations liées aux défaillances de capteurs. -## Dry-run de l'API Mock +### Bornes physiques et frontière de confiance + +La réponse de l'API Mock est traitée comme une entrée hostile : l'API n'a pas +d'authentification et expose un endpoint mutatif à quiconque. Voir API10 dans +`docs/architecture/owasp-traceabilite.md`. + +Les plages acceptées sont déclarées dans `PHYSICAL_BOUNDS` : + +| Grandeur | Plage acceptée | +|---|---| +| `consumption_kw` | 0 à 100 000 | +| `consumption_kwh` | 0 à 100 000 | +| `voltage_v` | 0 à 1 000 | +| `current_a` | 0 à 10 000 | +| `power_factor` | 0 à 1 | +| `temperature_celsius` | -90 à 60 | +| `humidity_percent` | 0 à 100 | +| `capacity_kw` | 0 à 100 000 | + +Une valeur hors plage, d'un type inattendu, `NaN` ou infinie devient `NULL` : + +```text +null_reasons += "out_of_physical_bounds:" +data_quality = "degraded" +``` + +L'import ne s'interrompt pas pour autant : le mock émet des anomalies par construction, et +`raw_data` conserve la réponse d'origine. + +La taille des réponses est plafonnée : au plus `MAX_SITES` sites, et au plus `--limit` mesures +par site. Au-delà, l'import échoue au lieu de charger. + +Enfin, seuls les champs attendus sont recopiés vers la base. Une clé supplémentaire renvoyée par +l'API n'atteint jamais une colonne. + +### Dry-run de l'API Mock Le mode `--dry-run` permet de tester la connexion, la récupération des sites et la récupération des mesures sans écrire dans PostgreSQL. @@ -467,7 +505,7 @@ uv run python -m app.etl.mock_api_import ` --dry-run ``` -## Chargement réel depuis l'API Mock +### Chargement réel depuis l'API Mock Depuis `apps/backend/` : @@ -478,7 +516,7 @@ uv run python -m app.etl.mock_api_import ` --limit 60 ``` -## Résultat validé pour l'API Mock +### Résultat validé pour l'API Mock Le scénario de validation utilisé couvre la période : @@ -511,7 +549,7 @@ Les contrôles effectués directement dans PostgreSQL/TimescaleDB ont confirmé - la conservation de `null_reasons` ; - la conservation de la donnée source dans `raw_data`. -## Idempotence de l'import API Mock +### Idempotence de l'import API Mock Le même import a été exécuté plusieurs fois afin de vérifier qu'une mesure déjà présente n'est pas créée une seconde fois. @@ -519,7 +557,7 @@ L'idempotence repose sur la contrainte d'unicité de la table `reading` et sur l Un test d'intégration automatisé vérifie également ce comportement. -# Tests et qualité +## Tests et qualité Les tests automatisés des pipelines ETL sont situés dans : @@ -599,7 +637,7 @@ Lors de la validation de l'import API Mock : La suite backend complète a également été validée avec une couverture supérieure au seuil de 85 %. -# Suite du pipeline Data +## Suite du pipeline Data Deux sources de données sont maintenant prises en charge : @@ -631,4 +669,4 @@ Airflow permettra de planifier les traitements, gérer leur ordre d'exécution, Airflow ne remplacera pas la logique ETL Python existante. Les scripts actuels resteront responsables de l'extraction, de la validation, de la transformation et du chargement. -Le pipeline Data servira ensuite à préparer les données nécessaires au modèle de Machine Learning. \ No newline at end of file +Le pipeline Data servira ensuite à préparer les données nécessaires au modèle de Machine Learning. From 0a2ed5ad8f7b153ea7689d6545b66c4dfad38608 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 10:25:48 +0200 Subject: [PATCH 87/87] docs: distingue l'ingestion des mesures de celle des alertes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La #114 arrive sur dev avec un ADR 0006 qui note que l'ingestion de l'API Mock /alerts reste à faire. « Les deux sources sont implémentées » se lisait comme couvrant aussi les alertes. --- docs/architecture/40-data.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md index 823e92c..47fdc8a 100644 --- a/docs/architecture/40-data.md +++ b/docs/architecture/40-data.md @@ -12,8 +12,10 @@ d'énergie, dont l'hypertable `reading`. Les sections marquées `Fait` relèvent du code déjà implémenté. Les sections marquées `Cible` décrivent les éléments prévus mais pas encore réalisés. -L'ingestion des deux sources de données du MVP est maintenant implémentée. L'orchestration -Airflow, les agrégats continus, la compression et la rétention restent des cibles. +L'ingestion des **mesures** est implémentée pour les deux sources du MVP, le dataset CSV/JSON et +l'API Mock. Celle des **alertes** de l'API Mock, `/alerts`, reste à faire : voir +l'[ADR 0006](../adr/0006-moteur-de-regles-dans-le-backend.md). L'orchestration Airflow, les +agrégats continus, la compression et la rétention restent des cibles. ## Trois emplacements, trois rôles