diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index 5b39098..4996917 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -31,6 +31,7 @@ from app.repositories.site import SiteRepository from app.repositories.user import UserRepository from app.services.alert import AlertService from app.services.auth import AuthService, LoginPolicy +from app.services.reading import ReadingService from app.services.recommendation import RecommendationService from app.services.sensor import SensorService from app.services.site import SiteService @@ -168,6 +169,13 @@ def get_stats_service(session: SessionDep) -> StatsService: StatsServiceDep = Annotated[StatsService, Depends(get_stats_service)] +def get_reading_service(session: SessionDep) -> ReadingService: + return ReadingService(readings=ReadingRepository(session)) + + +ReadingServiceDep = Annotated[ReadingService, Depends(get_reading_service)] + + def get_sensor_service(session: SessionDep) -> SensorService: return SensorService(sites=SiteRepository(session), readings=ReadingRepository(session)) diff --git a/apps/backend/app/api/openapi.py b/apps/backend/app/api/openapi.py index 85b7775..15f88d2 100644 --- a/apps/backend/app/api/openapi.py +++ b/apps/backend/app/api/openapi.py @@ -71,6 +71,14 @@ TAGS: Final[list[dict[str, Any]]] = [ "description": "Statistiques agrégées de consommation. Accessible à partir du rôle " "`lecteur`.", }, + { + "name": "readings", + "description": ( + "Historique des lectures de consommation. Fenêtre temporelle plafonnée à 90 jours, " + "24 dernières heures par défaut si `start`/`end` sont omis. Accessible à partir du " + "rôle `lecteur`." + ), + }, { "name": "sensors", "description": "État de santé des capteurs par site. Réservé au rôle `admin`.", diff --git a/apps/backend/app/api/v1/endpoints/readings.py b/apps/backend/app/api/v1/endpoints/readings.py new file mode 100644 index 0000000..c98ff4a --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/readings.py @@ -0,0 +1,54 @@ +from datetime import datetime + +from fastapi import APIRouter, HTTPException, Query, status + +from app.api.deps import LecteurDep, ReadingServiceDep +from app.api.openapi import REPONSE_VALIDATION, Reponses +from app.schemas.errors import ErrorResponse +from app.schemas.reading import ReadingResponse +from app.services.reading import FenetreInverseeError, FenetreTropLargeError + +router = APIRouter() + +REPONSES_FENETRE: Reponses = { + **REPONSE_VALIDATION, + 400: { + "model": ErrorResponse, + "description": ( + "Fenêtre temporelle invalide : `start` postérieur ou égal à `end`, ou écart entre " + "les deux supérieur à 90 jours." + ), + }, +} + + +@router.get( + "", + response_model=list[ReadingResponse], + summary="Liste l'historique des lectures", + responses=REPONSES_FENETRE, +) +async def list_readings( + _: LecteurDep, + service: ReadingServiceDep, + site_id: str | None = None, + start: datetime | None = None, + end: datetime | None = None, + limit: int = Query(500, ge=1, le=2000), + offset: int = Query(0, ge=0), +) -> list[ReadingResponse]: + try: + lectures = await service.list_history( + site_id=site_id, start=start, end=end, limit=limit, offset=offset + ) + except FenetreInverseeError as erreur: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="`start` doit être strictement antérieur à `end`", + ) from erreur + except FenetreTropLargeError as erreur: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="L'écart entre `start` et `end` ne peut pas dépasser 90 jours", + ) from erreur + return [ReadingResponse.model_validate(lecture) for lecture in lectures] diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py index f5075ca..8c0bd9e 100644 --- a/apps/backend/app/api/v1/router.py +++ b/apps/backend/app/api/v1/router.py @@ -1,7 +1,17 @@ from fastapi import APIRouter from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR -from app.api.v1.endpoints import alerts, auth, health, recommendations, sensors, sites, stats, users +from app.api.v1.endpoints import ( + alerts, + auth, + health, + readings, + recommendations, + sensors, + sites, + stats, + users, +) api_router = APIRouter(responses=REPONSE_SERVEUR) api_router.include_router(health.router, prefix="/health", tags=["health"]) @@ -18,6 +28,9 @@ api_router.include_router( responses=REPONSES_LECTEUR, ) api_router.include_router(stats.router, prefix="/stats", tags=["stats"], responses=REPONSES_LECTEUR) +api_router.include_router( + readings.router, prefix="/readings", tags=["readings"], responses=REPONSES_LECTEUR +) api_router.include_router( sensors.router, prefix="/sensors", tags=["sensors"], responses=REPONSES_ADMIN ) diff --git a/apps/backend/app/repositories/reading.py b/apps/backend/app/repositories/reading.py index 5424b46..71352da 100644 --- a/apps/backend/app/repositories/reading.py +++ b/apps/backend/app/repositories/reading.py @@ -1,4 +1,5 @@ from collections.abc import Sequence +from datetime import datetime from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -19,3 +20,23 @@ class ReadingRepository: .order_by(Reading.site_id, Reading.timestamp.desc()) ) return (await self._session.execute(requete)).scalars().all() + + async def list_history( + self, + *, + start: datetime, + end: datetime, + site_id: str | None = None, + limit: int, + offset: int, + ) -> Sequence[Reading]: + requete = ( + select(Reading) + .where(Reading.timestamp >= start, Reading.timestamp < end) + .order_by(Reading.timestamp.desc(), Reading.reading_id.desc()) + .limit(limit) + .offset(offset) + ) + if site_id is not None: + requete = requete.where(Reading.site_id == site_id) + return (await self._session.scalars(requete)).all() diff --git a/apps/backend/app/schemas/reading.py b/apps/backend/app/schemas/reading.py new file mode 100644 index 0000000..5deef21 --- /dev/null +++ b/apps/backend/app/schemas/reading.py @@ -0,0 +1,45 @@ +from datetime import datetime +from decimal import Decimal +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel, ConfigDict + + +class ReadingSource(StrEnum): + CSV = "csv" + API_CURRENT = "api_current" + API_HISTORY = "api_history" + + +class ReadingDataQuality(StrEnum): + GOOD = "good" + PARTIAL = "partial" + DEGRADED = "degraded" + CRITICAL = "critical" + + +class ReadingResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + reading_id: int + site_id: str + timestamp: datetime + source: ReadingSource + consumption_kw: float | None + consumption_kwh: float | None + # Piège : `Decimal` (miroir de `Numeric(14, 2)` en base, pour ne pas arrondir un montant) + # sérialise en chaîne dans le JSON, pas en nombre — un consommateur qui ferait un `parseFloat` + # naïf perdrait la précision que ce choix visait à garder. + consumption_euros: Decimal | None + voltage_v: float | None + current_a: float | None + power_factor: float | None + temperature_celsius: float | None + humidity_percent: float | None + solar_irradiance_wm2: float | None + is_working_hours: bool | None + data_quality: ReadingDataQuality | None + null_reasons: list[str] | None + imputed_values: dict[str, Any] | None + imputation_method: str | None diff --git a/apps/backend/app/services/reading.py b/apps/backend/app/services/reading.py new file mode 100644 index 0000000..818c202 --- /dev/null +++ b/apps/backend/app/services/reading.py @@ -0,0 +1,59 @@ +from collections.abc import Sequence +from datetime import UTC, datetime, timedelta + +from app.models.energy import Reading +from app.repositories.reading import ReadingRepository + +FENETRE_PAR_DEFAUT = timedelta(hours=24) +FENETRE_MAXIMALE = timedelta(days=90) + + +class FenetreInverseeError(Exception): + """`start` est postérieur ou égal à `end`.""" + + +class FenetreTropLargeError(Exception): + """L'écart entre `start` et `end` dépasse `FENETRE_MAXIMALE`.""" + + +class ReadingService: + def __init__(self, *, readings: ReadingRepository) -> None: + self._readings = readings + + async def list_history( + self, + *, + site_id: str | None = None, + start: datetime | None = None, + end: datetime | None = None, + limit: int, + offset: int, + ) -> Sequence[Reading]: + debut, fin = self._resoudre_fenetre(start, end) + return await self._readings.list_history( + site_id=site_id, start=debut, end=fin, limit=limit, offset=offset + ) + + @staticmethod + def _resoudre_fenetre( + start: datetime | None, end: datetime | None + ) -> tuple[datetime, datetime]: + # Piège : un datetime naïf (sans fuseau dans la chaîne ISO reçue) fait échouer la + # comparaison à `reading.timestamp` (`timestamptz`) au niveau du pilote, en 500 plutôt + # qu'un refus propre. On le traite comme de l'UTC plutôt que de le rejeter. + debut = _vers_utc(start) + fin = _vers_utc(end) or datetime.now(UTC) + if debut is None: + debut = fin - FENETRE_PAR_DEFAUT + + if debut >= fin: + raise FenetreInverseeError + if fin - debut > FENETRE_MAXIMALE: + raise FenetreTropLargeError + return debut, fin + + +def _vers_utc(instant: datetime | None) -> datetime | None: + if instant is None: + return None + return instant if instant.tzinfo is not None else instant.replace(tzinfo=UTC) diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index 84f9c08..e8525a9 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -1228,6 +1228,161 @@ ] } }, + "/api/v1/readings": { + "get": { + "tags": [ + "readings" + ], + "summary": "Liste l'historique des lectures", + "operationId": "list_readings_api_v1_readings_get", + "security": [ + { + "Jeton d'accès": [] + } + ], + "parameters": [ + { + "name": "site_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Site Id" + } + }, + { + "name": "start", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Start" + } + }, + { + "name": "end", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "End" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 2000, + "minimum": 1, + "default": 500, + "title": "Limit" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "default": 0, + "title": "Offset" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReadingResponse" + }, + "title": "Response List Readings Api V1 Readings Get" + } + } + } + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + }, + "401": { + "description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Mot de passe provisoire à changer (`detail` vaut `password_change_required`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + }, + "400": { + "description": "Fenêtre temporelle invalide : `start` postérieur ou égal à `end`, ou écart entre les deux supérieur à 90 jours.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/v1/sensors/status": { "get": { "tags": [ @@ -1580,6 +1735,225 @@ ], "title": "ReadinessStatus" }, + "ReadingDataQuality": { + "type": "string", + "enum": [ + "good", + "partial", + "degraded", + "critical" + ], + "title": "ReadingDataQuality" + }, + "ReadingResponse": { + "properties": { + "reading_id": { + "type": "integer", + "title": "Reading Id" + }, + "site_id": { + "type": "string", + "title": "Site Id" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "title": "Timestamp" + }, + "source": { + "$ref": "#/components/schemas/ReadingSource" + }, + "consumption_kw": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Consumption Kw" + }, + "consumption_kwh": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Consumption Kwh" + }, + "consumption_euros": { + "anyOf": [ + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + }, + { + "type": "null" + } + ], + "title": "Consumption Euros" + }, + "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" + }, + "solar_irradiance_wm2": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Solar Irradiance Wm2" + }, + "is_working_hours": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Is Working Hours" + }, + "data_quality": { + "anyOf": [ + { + "$ref": "#/components/schemas/ReadingDataQuality" + }, + { + "type": "null" + } + ] + }, + "null_reasons": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Null Reasons" + }, + "imputed_values": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Imputed Values" + }, + "imputation_method": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Imputation Method" + } + }, + "type": "object", + "required": [ + "reading_id", + "site_id", + "timestamp", + "source", + "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" + ], + "title": "ReadingResponse" + }, + "ReadingSource": { + "type": "string", + "enum": [ + "csv", + "api_current", + "api_history" + ], + "title": "ReadingSource" + }, "RecommendationResponse": { "properties": { "recommendation_id": { @@ -2129,6 +2503,10 @@ "name": "stats", "description": "Statistiques agrégées de consommation. Accessible à partir du rôle `lecteur`." }, + { + "name": "readings", + "description": "Historique des lectures de consommation. Fenêtre temporelle plafonnée à 90 jours, 24 dernières heures par défaut si `start`/`end` sont omis. Accessible à partir du rôle `lecteur`." + }, { "name": "sensors", "description": "État de santé des capteurs par site. Réservé au rôle `admin`." diff --git a/apps/backend/tests/api/test_openapi.py b/apps/backend/tests/api/test_openapi.py index 8b600bf..aa5546b 100644 --- a/apps/backend/tests/api/test_openapi.py +++ b/apps/backend/tests/api/test_openapi.py @@ -35,6 +35,7 @@ ROUTES_A_ROLE = { ("GET", "/api/v1/recommendations"), ("GET", "/api/v1/recommendations/{recommendation_id}"), ("GET", "/api/v1/stats/summary"), + ("GET", "/api/v1/readings"), ("GET", "/api/v1/sensors/status"), } diff --git a/apps/backend/tests/api/test_readings.py b/apps/backend/tests/api/test_readings.py new file mode 100644 index 0000000..0d01aa8 --- /dev/null +++ b/apps/backend/tests/api/test_readings.py @@ -0,0 +1,198 @@ +from collections.abc import Callable, Iterator +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from httpx import AsyncClient + +from app.api.deps import get_current_principal, get_reading_service +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.models.energy import Reading +from app.services.reading import FenetreInverseeError, FenetreTropLargeError + + +def principal(role: Role = Role.LECTEUR) -> Principal: + return Principal( + id=uuid4(), + email=f"{role.value}@enervision.fr", + role=role, + kind=AccountKind.HUMAIN, + must_change_password=False, + ) + + +def reading(reading_id: int = 1, site_id: str = "site-1") -> Reading: + return Reading( + reading_id=reading_id, + site_id=site_id, + timestamp=datetime(2026, 9, 16, tzinfo=UTC), + source="api_current", + consumption_kw=42.5, + consumption_kwh=None, + consumption_euros=None, + voltage_v=230.0, + current_a=None, + power_factor=None, + temperature_celsius=None, + humidity_percent=None, + solar_irradiance_wm2=None, + is_working_hours=True, + data_quality="good", + null_reasons=None, + imputed_values=None, + imputation_method=None, + raw_data={}, + ) + + +class FauxService: + def __init__(self, leve: Exception | None = None) -> None: + self.reading = reading() + self.leve = leve + self.appels: list[tuple[str | None, str | None, str | None, int, int]] = [] + + async def list_history( + self, + *, + site_id: str | None = None, + start: datetime | None = None, + end: datetime | None = None, + limit: int, + offset: int, + ) -> list[Reading]: + self.appels.append((site_id, start, end, limit, offset)) + if self.leve is not None: + raise self.leve + return [self.reading] + + +@pytest.fixture +def lecteur_connecte(app: FastAPI) -> Iterator[None]: + app.dependency_overrides[get_current_principal] = lambda: principal() + yield + app.dependency_overrides.pop(get_current_principal, None) + + +@pytest.fixture +def servi(app: FastAPI, lecteur_connecte: None) -> Iterator[Callable[..., FauxService]]: + def installe(*, leve: Exception | None = None) -> FauxService: + service = FauxService(leve=leve) + app.dependency_overrides[get_reading_service] = lambda: service + return service + + yield installe + app.dependency_overrides.pop(get_reading_service, None) + + +async def test_list_readings_returns_the_readings( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/readings") + + assert response.status_code == 200 + corps = response.json() + assert corps == [ + { + "reading_id": 1, + "site_id": "site-1", + "timestamp": "2026-09-16T00:00:00Z", + "source": "api_current", + "consumption_kw": 42.5, + "consumption_kwh": None, + "consumption_euros": None, + "voltage_v": 230.0, + "current_a": None, + "power_factor": None, + "temperature_celsius": None, + "humidity_percent": None, + "solar_irradiance_wm2": None, + "is_working_hours": True, + "data_quality": "good", + "null_reasons": None, + "imputed_values": None, + "imputation_method": None, + } + ] + + +async def test_list_readings_transmits_the_filters_and_pagination( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + service = servi() + + response = await client.get( + "/api/v1/readings", + params={ + "site_id": "site-1", + "start": "2026-09-01T00:00:00Z", + "end": "2026-09-02T00:00:00Z", + "limit": 50, + "offset": 10, + }, + ) + + assert response.status_code == 200 + assert service.appels == [ + ( + "site-1", + datetime(2026, 9, 1, tzinfo=UTC), + datetime(2026, 9, 2, tzinfo=UTC), + 50, + 10, + ) + ] + + +async def test_list_readings_returns_400_when_the_window_is_inverted( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi(leve=FenetreInverseeError()) + + response = await client.get("/api/v1/readings") + + assert response.status_code == 400 + + +async def test_list_readings_returns_400_when_the_window_is_too_large( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi(leve=FenetreTropLargeError()) + + response = await client.get("/api/v1/readings") + + assert response.status_code == 400 + + +async def test_list_readings_returns_422_for_a_limit_above_the_maximum( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/readings", params={"limit": 5000}) + + assert response.status_code == 422 + + +async def test_list_readings_returns_422_for_a_negative_offset( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/readings", params={"offset": -1}) + + assert response.status_code == 422 + + +async def test_list_readings_returns_an_empty_list_when_there_is_nothing( + lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient +) -> None: + fake_session(result=[]) + + response = await client.get("/api/v1/readings") + + assert response.status_code == 200 + assert response.json() == [] diff --git a/apps/backend/tests/repositories/test_reading.py b/apps/backend/tests/repositories/test_reading.py index 650d49a..150fa29 100644 --- a/apps/backend/tests/repositories/test_reading.py +++ b/apps/backend/tests/repositories/test_reading.py @@ -6,6 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.models.energy import Reading, Site from app.repositories.reading import ReadingRepository +from tests.repositories.test_site import creer as creer_site +from tests.repositories.test_site import identifiant as identifiant_site pytestmark = pytest.mark.integration @@ -25,6 +27,20 @@ def lecture(site_id: str, *, timestamp: datetime, consumption_kw: float) -> Read ) +async def creer_lecture(session: AsyncSession, *, site_id: str, **overrides: object) -> Reading: + reading = Reading( + site_id=site_id, + timestamp=overrides.get("timestamp", datetime(2026, 9, 16, tzinfo=UTC)), + source=overrides.get("source", "api_current"), + consumption_kw=overrides.get("consumption_kw", 10.0), + data_quality=overrides.get("data_quality", "good"), + raw_data=overrides.get("raw_data", {}), + ) + session.add(reading) + await session.flush() + return reading + + async def test_latest_by_site_keeps_only_the_most_recent_reading(session: AsyncSession) -> None: site_id = identifiant() maintenant = datetime.now(UTC) @@ -70,3 +86,110 @@ async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) -> await session.rollback() assert identifiants == {premier, second} + + +async def test_list_history_orders_the_readings_by_timestamp_descending( + session: AsyncSession, +) -> None: + site = await creer_site(session) + depot = ReadingRepository(session) + ancienne = await creer_lecture( + session, site_id=site.site_id, timestamp=datetime(2026, 9, 1, tzinfo=UTC) + ) + recente = await creer_lecture( + session, site_id=site.site_id, timestamp=datetime(2026, 9, 15, tzinfo=UTC) + ) + + resultats = await depot.list_history( + start=datetime(2026, 8, 1, tzinfo=UTC), + end=datetime(2026, 10, 1, tzinfo=UTC), + limit=100, + offset=0, + ) + identifiants = [ + r.reading_id for r in resultats if r.reading_id in (ancienne.reading_id, recente.reading_id) + ] + await session.rollback() + + assert identifiants == [recente.reading_id, ancienne.reading_id] + + +async def test_list_history_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_history( + site_id=premier.site_id, + start=datetime(2026, 8, 1, tzinfo=UTC), + end=datetime(2026, 10, 1, tzinfo=UTC), + limit=100, + offset=0, + ) + identifiants = [r.reading_id for r in resultats] + await session.rollback() + + assert identifiants == [voulue.reading_id] + + +async def test_list_history_excludes_readings_outside_the_window(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, 10, tzinfo=UTC) + ) + await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2026, 8, 1, tzinfo=UTC)) + await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2026, 10, 1, tzinfo=UTC)) + + resultats = await depot.list_history( + site_id=site.site_id, + start=datetime(2026, 9, 1, tzinfo=UTC), + end=datetime(2026, 9, 30, tzinfo=UTC), + limit=100, + offset=0, + ) + identifiants = [r.reading_id for r in resultats] + await session.rollback() + + assert identifiants == [dedans.reading_id] + + +async def test_list_history_respects_limit_and_offset(session: AsyncSession) -> None: + site = await creer_site(session) + depot = ReadingRepository(session) + lectures = [ + await creer_lecture( + session, site_id=site.site_id, timestamp=datetime(2026, 9, jour, tzinfo=UTC) + ) + for jour in (1, 2, 3) + ] + + resultats = await depot.list_history( + site_id=site.site_id, + start=datetime(2026, 8, 1, tzinfo=UTC), + end=datetime(2026, 10, 1, tzinfo=UTC), + limit=1, + offset=1, + ) + identifiants = [r.reading_id for r in resultats] + await session.rollback() + + assert identifiants == [lectures[1].reading_id] + + +async def test_list_history_returns_an_empty_list_when_there_is_nothing( + session: AsyncSession, +) -> None: + depot = ReadingRepository(session) + + resultats = await depot.list_history( + site_id=identifiant_site(), + start=datetime(2026, 8, 1, tzinfo=UTC), + end=datetime(2026, 10, 1, tzinfo=UTC), + limit=100, + offset=0, + ) + + assert list(resultats) == [] diff --git a/apps/backend/tests/services/test_reading.py b/apps/backend/tests/services/test_reading.py new file mode 100644 index 0000000..a3f0826 --- /dev/null +++ b/apps/backend/tests/services/test_reading.py @@ -0,0 +1,153 @@ +from datetime import UTC, datetime, timedelta + +import pytest + +from app.models.energy import Reading +from app.services.reading import ( + FENETRE_MAXIMALE, + FENETRE_PAR_DEFAUT, + FenetreInverseeError, + FenetreTropLargeError, + ReadingService, +) + + +def reading(reading_id: int = 1, site_id: str = "site-1") -> Reading: + return Reading( + reading_id=reading_id, + site_id=site_id, + timestamp=datetime(2026, 9, 16, tzinfo=UTC), + source="api_current", + consumption_kw=10.0, + data_quality="good", + raw_data={}, + ) + + +class FakeRepository: + def __init__(self, readings: list[Reading]) -> None: + self._readings = readings + self.appels: list[tuple[str | None, datetime, datetime, int, int]] = [] + + async def list_history( + self, + *, + start: datetime, + end: datetime, + site_id: str | None = None, + limit: int, + offset: int, + ) -> list[Reading]: + self.appels.append((site_id, start, end, limit, offset)) + return self._readings + + +async def test_list_history_returns_the_repository_readings() -> None: + service = ReadingService(readings=FakeRepository([reading(1), reading(2)])) + + lectures = await service.list_history(limit=500, offset=0) + + assert [r.reading_id for r in lectures] == [1, 2] + + +async def test_list_history_relays_the_site_id_limit_and_offset() -> None: + depot = FakeRepository([]) + service = ReadingService(readings=depot) + debut = datetime(2026, 9, 1, tzinfo=UTC) + fin = datetime(2026, 9, 2, tzinfo=UTC) + + await service.list_history(site_id="site-1", start=debut, end=fin, limit=50, offset=10) + + assert depot.appels == [("site-1", debut, fin, 50, 10)] + + +async def test_list_history_defaults_to_the_last_24_hours_when_no_window_is_given() -> None: + depot = FakeRepository([]) + service = ReadingService(readings=depot) + avant = datetime.now(UTC) + + await service.list_history(limit=500, offset=0) + + apres = datetime.now(UTC) + _, debut, fin, _, _ = depot.appels[0] + assert avant <= fin <= apres + assert fin - debut == FENETRE_PAR_DEFAUT + + +async def test_list_history_defaults_end_to_now_when_only_start_is_given() -> None: + depot = FakeRepository([]) + service = ReadingService(readings=depot) + debut = datetime.now(UTC) - timedelta(hours=1) + avant = datetime.now(UTC) + + await service.list_history(start=debut, limit=500, offset=0) + + apres = datetime.now(UTC) + _, debut_transmis, fin, _, _ = depot.appels[0] + assert debut_transmis == debut + assert avant <= fin <= apres + + +async def test_list_history_defaults_start_to_24_hours_before_end_when_only_end_is_given() -> None: + depot = FakeRepository([]) + service = ReadingService(readings=depot) + fin = datetime(2026, 9, 16, tzinfo=UTC) + + await service.list_history(end=fin, limit=500, offset=0) + + _, debut, fin_transmise, _, _ = depot.appels[0] + assert fin_transmise == fin + assert debut == fin - FENETRE_PAR_DEFAUT + + +async def test_list_history_normalizes_naive_datetimes_to_utc() -> None: + depot = FakeRepository([]) + service = ReadingService(readings=depot) + + await service.list_history( + start=datetime(2026, 9, 1), end=datetime(2026, 9, 2), limit=500, offset=0 + ) + + _, debut, fin, _, _ = depot.appels[0] + assert debut == datetime(2026, 9, 1, tzinfo=UTC) + assert fin == datetime(2026, 9, 2, tzinfo=UTC) + + +async def test_list_history_raises_when_start_is_after_end() -> None: + service = ReadingService(readings=FakeRepository([])) + + with pytest.raises(FenetreInverseeError): + await service.list_history( + start=datetime(2026, 9, 2, tzinfo=UTC), + end=datetime(2026, 9, 1, tzinfo=UTC), + limit=500, + offset=0, + ) + + +async def test_list_history_raises_when_start_equals_end() -> None: + service = ReadingService(readings=FakeRepository([])) + instant = datetime(2026, 9, 1, tzinfo=UTC) + + with pytest.raises(FenetreInverseeError): + await service.list_history(start=instant, end=instant, limit=500, offset=0) + + +async def test_list_history_raises_when_the_window_exceeds_the_maximum_span() -> None: + service = ReadingService(readings=FakeRepository([])) + debut = datetime(2026, 1, 1, tzinfo=UTC) + fin = debut + FENETRE_MAXIMALE + timedelta(seconds=1) + + with pytest.raises(FenetreTropLargeError): + await service.list_history(start=debut, end=fin, limit=500, offset=0) + + +async def test_list_history_accepts_a_window_exactly_at_the_maximum_span() -> None: + depot = FakeRepository([]) + service = ReadingService(readings=depot) + debut = datetime(2026, 1, 1, tzinfo=UTC) + fin = debut + FENETRE_MAXIMALE + + await service.list_history(start=debut, end=fin, limit=500, offset=0) + + assert depot.appels == [(None, debut, fin, 500, 0)] diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index b49a70f..f59f286 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -74,7 +74,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` et `recommendations` en lecture (endpoints → services → repositories → models) | +| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary` et `readings` en lecture (endpoints → services → repositories → models) | | Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, deux services HTTP, graphiques Chart.js, données servies par des fixtures | | Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) | | 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 d803893..553ac4e 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/recommendations` | Liste les recommandations. `lecteur` | 401, 403, 500 | | GET | `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation. `lecteur` | 401, 403, 404, 422, 500 | | GET | `/api/v1/stats/summary` | Résume la consommation instantanée du parc. `lecteur` | 401, 403, 500 | +| GET | `/api/v1/readings` | Historique des lectures, filtrable par `site_id`, fenêtre `start`/`end` (24h par défaut, 90 jours maximum) et paginé par `limit`/`offset`. `lecteur` | 400, 401, 403, 422, 500 | | GET | `/api/v1/sensors/status` | État de santé des capteurs par site, dérivé de la dernière lecture. `admin` | 401, 403, 500 | | GET | `/metrics` | Format Prometheus, hors du schéma. Jeton requis si `APP_METRICS_TOKEN` est posé | | | GET | `/docs`, `/redoc`, `/openapi.json` | Hors du schéma. Fermés en `staging` et en `prod` | | @@ -159,21 +160,33 @@ Les codes de la dernière colonne sont ceux que le schéma **déclare**, et le f donc de modifier la liste dans ce fichier de test. `GET /sites` et `GET /sites/{site_id}` sont la première route métier, et le gabarit repris pour -`GET /alerts` puis pour les suivantes (`reading`, `dataset`, `prediction`, `recommendation`) : les -quatre couches `endpoints → services → repositories → models` y sont toutes présentes, sur des -tables déjà créées par la révision Alembic `e6d2026091501`. Elles n'exigent que le rôle `lecteur`, -contrairement aux routes d'administration qui exigent `admin`. `SiteRepository` lit par -`AsyncSession.scalar()` (une ligne) et `AsyncSession.scalars()` (plusieurs lignes) plutôt que par -`execute()`, ce qui la rend testable par la fixture `fake_session` au niveau endpoint sans base -réelle. `GET /recommendations` et `GET /recommendations/{recommendation_id}` reprennent le même -gabarit à la lettre, `recommendation_id` étant un entier plutôt qu'un texte. Une recommandation ne -porte pas `site_id` : elle remonte à un site par sa seule `alert_id`, `alert` n'étant pas encore -exposée. `GET /stats/summary` et `GET /sensors/status` agrègent chacune deux repositories -(`SiteRepository`, `ReadingRepository`) dans un service dédié plutôt que d'exposer une table : -elles n'entrent donc pas dans ce gabarit route-par-table. Le contrat détaillé pour le frontend est -dans +`GET /alerts` puis pour les suivantes (`dataset`, `prediction`) : les quatre couches +`endpoints → services → repositories → models` y sont toutes présentes, sur des tables déjà créées +par la révision Alembic `e6d2026091501`. Elles n'exigent que le rôle `lecteur`, contrairement aux +routes d'administration qui exigent `admin`. `SiteRepository` lit par `AsyncSession.scalar()` (une +ligne) et `AsyncSession.scalars()` (plusieurs lignes) plutôt que par `execute()`, ce qui la rend +testable par la fixture `fake_session` au niveau endpoint sans base réelle. `GET /recommendations` +et `GET /recommendations/{recommendation_id}` reprennent le même gabarit à la lettre, +`recommendation_id` étant un entier plutôt qu'un texte. Une recommandation ne porte pas `site_id` : +elle remonte à un site par sa seule `alert_id`, `alert` n'étant pas encore exposée. `GET +/stats/summary` et `GET /sensors/status` agrègent chacune deux repositories (`SiteRepository`, +`ReadingRepository`) dans un service dédié plutôt que d'exposer une table : elles n'entrent donc +pas dans ce gabarit route-par-table. Le contrat détaillé pour le frontend est dans [31-contrat-authentification.md](31-contrat-authentification.md). +`GET /readings` reprend le même gabarit mais s'en écarte sur un point : `reading` est l'hypertable, +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 @@ -249,7 +262,7 @@ Les modèles de `app/schemas/errors.py` décrivent ce que les gestionnaires renv ### Ajouter une route métier Checklist pour toute nouvelle route sur le gabarit `sites`/`alerts`/`recommendations`/`stats`/ -`sensors` (`reading`, `dataset`, `prediction`) : +`readings`/`sensors` (`dataset`, `prediction`) : 1. Composer ses `responses=` depuis `app/api/openapi.py` : `REPONSES_LECTEUR`/`REPONSES_ADMIN` au niveau de l'`include_router()` du routeur, `REPONSE_VALIDATION` et les codes locaux @@ -326,7 +339,9 @@ Trois fichiers méritent d'être connus avant de toucher à l'authentification : agir sur le site B. C'est la limite connue du modèle, et le risque BOLA du top 10 API. - **Rôles PostgreSQL cantonnés** pour l'ETL et le travail d'apprentissage, plus le `REVOKE` sur `audit_log`. Dette assumée, décrite dans les ADR 0003 et 0004. -- **Pagination et fenêtrage** des lectures de séries temporelles, qui conditionnent la forme des - endpoints métier. Sans plafond dur, une requête sur dix ans d'historique suffit à faire tomber - l'API. +- **Pagination et fenêtrage** : posés sur `GET /readings` (fenêtre plafonnée à 90 jours, + `limit`/`offset` plafonné à 2000), mais toujours en `limit`/`offset` simple — pas de curseur ni + de plan de secours si un `offset` élevé sur une fenêtre dense devient lent en pratique. + `statement_timeout` reste absent au niveau de la connexion, donc rien n'empêche une requête + individuelle de tourner longtemps si les plafonds au-dessus d'elle s'avéraient insuffisants. - **Politique de versionnement de l'API** au-delà du préfixe `/api/v1`. diff --git a/docs/architecture/owasp-traceabilite.md b/docs/architecture/owasp-traceabilite.md index 15c2b51..34e6853 100644 --- a/docs/architecture/owasp-traceabilite.md +++ b/docs/architecture/owasp-traceabilite.md @@ -22,6 +22,7 @@ lecture seule ; plusieurs lignes resteront à compléter une fois les endpoints | Argon2id m=19456 t=2 p=1, re-hachage passif quand les paramètres changent | `app/core/hashing.py` | A02 Cryptographic Failures, A07 Identification and Authentication Failures | | Message et temps de réponse identiques quelle que soit la cause de l'échec, haché leurre sur adresse inconnue | `app/services/auth.py` | A07, API2 | | Limitation de débit à fenêtre glissante sur trois clés, évaluée avant le hachage | `app/services/auth.py`, `app/repositories/login_attempt.py` | A07, API4 Unrestricted Resource Consumption | +| `GET /readings` : fenêtre temporelle plafonnée à 90 jours (24h par défaut), `limit`/`offset` plafonné à 2000, refus `400` si la fenêtre est inversée ou trop large | `app/services/reading.py` | API4 | | Absence de verrouillage de compte, qui serait un déni de service | ADR 0002 | API4 | | Jeton de rafraîchissement opaque, haché en base, rotation avec détection de réutilisation | `app/services/auth.py`, `app/repositories/refresh_token.py` | A07, API2 | | Séparation structurelle accès / rafraîchissement, impossible à confondre | ADR 0002 | API2 | @@ -50,7 +51,7 @@ règles Bandit. Ajouter Bandit à la CI serait redondant, contrairement à ce qu | Item | État | Raison | |---|---|---| | **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** | **ouvert** | Pas encore d'endpoint métier, donc ni pagination plafonnée, ni fenêtre temporelle maximale, ni `statement_timeout`. C'est la façon la plus probable dont la démonstration tombera : une requête sur dix ans d'historique suffit. | +| **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. | | **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. |