diff --git a/apps/backend/README.md b/apps/backend/README.md index 875d8ca..91f9608 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -109,6 +109,8 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie | `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions | `admin` | | `/api/v1/sites` | Liste les sites | `lecteur` | | `/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` | | `/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 7415616..aaf7403 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -24,12 +24,16 @@ 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.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.recommendation import RecommendationService from app.services.site import SiteService +from app.services.stats import StatsService from app.services.user import UserService SessionDep = Annotated[AsyncSession, Depends(get_session)] @@ -149,6 +153,20 @@ def get_alert_service(session: SessionDep) -> AlertService: AlertServiceDep = Annotated[AlertService, Depends(get_alert_service)] +def get_recommendation_service(session: SessionDep) -> RecommendationService: + return RecommendationService(recommendations=RecommendationRepository(session)) + + +RecommendationServiceDep = Annotated[RecommendationService, Depends(get_recommendation_service)] + + +def get_stats_service(session: SessionDep) -> StatsService: + return StatsService(sites=SiteRepository(session), readings=ReadingRepository(session)) + + +StatsServiceDep = Annotated[StatsService, Depends(get_stats_service)] + + async def get_current_principal( credentials: CredentialsDep, session: SessionDep, diff --git a/apps/backend/app/api/openapi.py b/apps/backend/app/api/openapi.py index f090ce2..6eb02a2 100644 --- a/apps/backend/app/api/openapi.py +++ b/apps/backend/app/api/openapi.py @@ -59,6 +59,18 @@ TAGS: Final[list[dict[str, Any]]] = [ "description": "Consultation des alertes de consommation. Accessible à partir du rôle " "`lecteur`.", }, + { + "name": "recommendations", + "description": ( + "Consultation des recommandations issues des alertes. Accessible à partir du rôle " + "`lecteur`." + ), + }, + { + "name": "stats", + "description": "Statistiques agrégées de consommation. Accessible à partir du rôle " + "`lecteur`.", + }, ] cookie_de_rafraichissement = APIKeyCookie( diff --git a/apps/backend/app/api/v1/endpoints/recommendations.py b/apps/backend/app/api/v1/endpoints/recommendations.py new file mode 100644 index 0000000..87e8be1 --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/recommendations.py @@ -0,0 +1,40 @@ +from fastapi import APIRouter, HTTPException, status + +from app.api.deps import LecteurDep, RecommendationServiceDep +from app.api.openapi import REPONSE_VALIDATION, Reponses +from app.schemas.errors import ErrorResponse +from app.schemas.recommendation import RecommendationResponse +from app.services.recommendation import RecommendationNotFoundError + +router = APIRouter() + +REPONSES_INTROUVABLE: Reponses = { + **REPONSE_VALIDATION, + 404: {"model": ErrorResponse, "description": "Aucune recommandation ne porte cet identifiant."}, +} + + +@router.get("", response_model=list[RecommendationResponse], summary="Liste les recommandations") +async def list_recommendations( + _: LecteurDep, service: RecommendationServiceDep +) -> list[RecommendationResponse]: + recommendations = await service.list_all() + return [RecommendationResponse.model_validate(r) for r in recommendations] + + +@router.get( + "/{recommendation_id}", + response_model=RecommendationResponse, + summary="Décrit une recommandation", + responses=REPONSES_INTROUVABLE, +) +async def get_recommendation( + recommendation_id: int, _: LecteurDep, service: RecommendationServiceDep +) -> RecommendationResponse: + try: + recommendation = await service.get_by_id(recommendation_id) + except RecommendationNotFoundError as erreur: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Recommandation introuvable" + ) from erreur + return RecommendationResponse.model_validate(recommendation) diff --git a/apps/backend/app/api/v1/endpoints/stats.py b/apps/backend/app/api/v1/endpoints/stats.py new file mode 100644 index 0000000..5a8502e --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/stats.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter + +from app.api.deps import LecteurDep, StatsServiceDep +from app.schemas.stats import StatsSummaryResponse + +router = APIRouter() + + +@router.get( + "/summary", + response_model=StatsSummaryResponse, + summary="Résume la consommation instantanée du parc", +) +async def get_summary(_: LecteurDep, service: StatsServiceDep) -> StatsSummaryResponse: + resume = await service.summary() + return StatsSummaryResponse.model_validate(resume) diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py index c72873f..60171df 100644 --- a/apps/backend/app/api/v1/router.py +++ b/apps/backend/app/api/v1/router.py @@ -1,7 +1,7 @@ from fastapi import APIRouter from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR -from app.api.v1.endpoints import alerts, auth, health, sites, users +from app.api.v1.endpoints import alerts, auth, health, recommendations, sites, stats, users api_router = APIRouter(responses=REPONSE_SERVEUR) api_router.include_router(health.router, prefix="/health", tags=["health"]) @@ -11,3 +11,10 @@ api_router.include_router(sites.router, prefix="/sites", tags=["sites"], respons api_router.include_router( alerts.router, prefix="/alerts", tags=["alerts"], responses=REPONSES_LECTEUR ) +api_router.include_router( + recommendations.router, + prefix="/recommendations", + tags=["recommendations"], + responses=REPONSES_LECTEUR, +) +api_router.include_router(stats.router, prefix="/stats", tags=["stats"], responses=REPONSES_LECTEUR) diff --git a/apps/backend/app/repositories/reading.py b/apps/backend/app/repositories/reading.py new file mode 100644 index 0000000..5424b46 --- /dev/null +++ b/apps/backend/app/repositories/reading.py @@ -0,0 +1,21 @@ +from collections.abc import Sequence + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Reading + + +class ReadingRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def latest_by_site(self) -> Sequence[Reading]: + # `.distinct(site_id)` compile en `DISTINCT ON (site_id)` sous PostgreSQL : une seule + # ligne par site, la plus récente grâce à l'ordre composite qui suit. + requete = ( + select(Reading) + .distinct(Reading.site_id) + .order_by(Reading.site_id, Reading.timestamp.desc()) + ) + return (await self._session.execute(requete)).scalars().all() diff --git a/apps/backend/app/repositories/recommendation.py b/apps/backend/app/repositories/recommendation.py new file mode 100644 index 0000000..7870131 --- /dev/null +++ b/apps/backend/app/repositories/recommendation.py @@ -0,0 +1,22 @@ +from collections.abc import Sequence + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Recommendation + + +class RecommendationRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def list_all(self) -> Sequence[Recommendation]: + requete = select(Recommendation).order_by(Recommendation.recommendation_id) + return (await self._session.scalars(requete)).all() + + async def get_by_id(self, recommendation_id: int) -> Recommendation | None: + requete = select(Recommendation).where( + Recommendation.recommendation_id == recommendation_id + ) + recommendation: Recommendation | None = await self._session.scalar(requete) + return recommendation diff --git a/apps/backend/app/schemas/recommendation.py b/apps/backend/app/schemas/recommendation.py new file mode 100644 index 0000000..8764615 --- /dev/null +++ b/apps/backend/app/schemas/recommendation.py @@ -0,0 +1,14 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class RecommendationResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + recommendation_id: int + alert_id: int + action: str + explanation: str + rule_reference: str + created_at: datetime diff --git a/apps/backend/app/schemas/stats.py b/apps/backend/app/schemas/stats.py new file mode 100644 index 0000000..b119d50 --- /dev/null +++ b/apps/backend/app/schemas/stats.py @@ -0,0 +1,26 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict + + +class SiteSummaryResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + site_id: str + site_name: str + current_consumption_kw: float | None + capacity_kw: float + load_percent: float | None + data_quality: Literal["good", "partial", "degraded", "critical"] + + +class StatsSummaryResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + timestamp: datetime + total_sites: int + total_consumption_kw: float + total_capacity_kw: float + average_load_percent: float + sites: list[SiteSummaryResponse] diff --git a/apps/backend/app/services/recommendation.py b/apps/backend/app/services/recommendation.py new file mode 100644 index 0000000..31115ae --- /dev/null +++ b/apps/backend/app/services/recommendation.py @@ -0,0 +1,26 @@ +from collections.abc import Sequence + +from app.models.energy import Recommendation +from app.repositories.recommendation import RecommendationRepository + + +class RecommendationError(Exception): + pass + + +class RecommendationNotFoundError(RecommendationError): + pass + + +class RecommendationService: + def __init__(self, *, recommendations: RecommendationRepository) -> None: + self._recommendations = recommendations + + async def list_all(self) -> Sequence[Recommendation]: + return await self._recommendations.list_all() + + async def get_by_id(self, recommendation_id: int) -> Recommendation: + recommendation = await self._recommendations.get_by_id(recommendation_id) + if recommendation is None: + raise RecommendationNotFoundError(recommendation_id) + return recommendation diff --git a/apps/backend/app/services/stats.py b/apps/backend/app/services/stats.py new file mode 100644 index 0000000..c98eace --- /dev/null +++ b/apps/backend/app/services/stats.py @@ -0,0 +1,81 @@ +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Literal + +from app.models.energy import Reading, Site +from app.repositories.reading import ReadingRepository +from app.repositories.site import SiteRepository + +DataQuality = Literal["good", "partial", "degraded", "critical"] + +QUALITES_CONNUES: frozenset[str] = frozenset({"good", "partial", "degraded", "critical"}) + + +@dataclass(frozen=True, slots=True) +class SiteConsumption: + site_id: str + site_name: str + current_consumption_kw: float | None + capacity_kw: float + load_percent: float | None + data_quality: DataQuality + + +@dataclass(frozen=True, slots=True) +class ConsumptionSummary: + timestamp: datetime + total_sites: int + total_consumption_kw: float + total_capacity_kw: float + average_load_percent: float + sites: list[SiteConsumption] + + +class StatsService: + def __init__(self, sites: SiteRepository, readings: ReadingRepository) -> None: + self._sites = sites + self._readings = readings + + async def summary(self) -> ConsumptionSummary: + sites = await self._sites.list_all() + dernieres = {lecture.site_id: lecture for lecture in await self._readings.latest_by_site()} + + resumes = [self._resume_site(site, dernieres.get(site.site_id)) for site in sites] + consommation_totale = sum(r.current_consumption_kw or 0 for r in resumes) + capacite_totale = sum(r.capacity_kw for r in resumes) + + return ConsumptionSummary( + timestamp=datetime.now(UTC), + total_sites=len(resumes), + total_consumption_kw=consommation_totale, + total_capacity_kw=capacite_totale, + average_load_percent=( + consommation_totale / capacite_totale * 100 if capacite_totale > 0 else 0 + ), + sites=resumes, + ) + + @staticmethod + def _resume_site(site: Site, derniere: Reading | None) -> SiteConsumption: + capacite = site.capacity_kw or 0 + # Piège : `data_quality` est nul dès qu'un site n'a jamais reçu de lecture, ou que le + # producteur n'a pas su la qualifier. Le contrat frontend n'a pas de valeur pour ce cas, + # `critical` est la seule des quatre qui n'induit pas une confiance qu'on n'a pas. + qualite: DataQuality = "critical" + consommation = None + if derniere is not None and derniere.data_quality in QUALITES_CONNUES: + qualite = derniere.data_quality # type: ignore[assignment] + consommation = derniere.consumption_kw + + charge = ( + consommation / capacite * 100 if consommation is not None and capacite > 0 else None + ) + + return SiteConsumption( + site_id=site.site_id, + site_name=site.site_name, + current_consumption_kw=consommation, + capacity_kw=capacite, + load_percent=charge, + data_quality=qualite, + ) diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index c7009e6..af962df 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -1024,6 +1024,209 @@ } } } + }, + "/api/v1/recommendations": { + "get": { + "tags": [ + "recommendations" + ], + "summary": "Liste les recommandations", + "operationId": "list_recommendations_api_v1_recommendations_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/RecommendationResponse" + }, + "type": "array", + "title": "Response List Recommendations Api V1 Recommendations 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" + } + } + } + } + }, + "security": [ + { + "Jeton d'accès": [] + } + ] + } + }, + "/api/v1/recommendations/{recommendation_id}": { + "get": { + "tags": [ + "recommendations" + ], + "summary": "Décrit une recommandation", + "operationId": "get_recommendation_api_v1_recommendations__recommendation_id__get", + "security": [ + { + "Jeton d'accès": [] + } + ], + "parameters": [ + { + "name": "recommendation_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Recommendation Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecommendationResponse" + } + } + } + }, + "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": "Aucune recommandation ne porte cet identifiant.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/stats/summary": { + "get": { + "tags": [ + "stats" + ], + "summary": "Résume la consommation instantanée du parc", + "operationId": "get_summary_api_v1_stats_summary_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatsSummaryResponse" + } + } + } + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + }, + "401": { + "description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Mot de passe provisoire à changer (`detail` vaut `password_change_required`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "Jeton d'accès": [] + } + ] + } } }, "components": { @@ -1321,6 +1524,45 @@ ], "title": "ReadinessStatus" }, + "RecommendationResponse": { + "properties": { + "recommendation_id": { + "type": "integer", + "title": "Recommendation Id" + }, + "alert_id": { + "type": "integer", + "title": "Alert Id" + }, + "action": { + "type": "string", + "title": "Action" + }, + "explanation": { + "type": "string", + "title": "Explanation" + }, + "rule_reference": { + "type": "string", + "title": "Rule Reference" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "recommendation_id", + "alert_id", + "action", + "explanation", + "rule_reference", + "created_at" + ], + "title": "RecommendationResponse" + }, "Role": { "type": "string", "enum": [ @@ -1389,6 +1631,106 @@ ], "title": "SiteResponse" }, + "SiteSummaryResponse": { + "properties": { + "site_id": { + "type": "string", + "title": "Site Id" + }, + "site_name": { + "type": "string", + "title": "Site Name" + }, + "current_consumption_kw": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Current Consumption Kw" + }, + "capacity_kw": { + "type": "number", + "title": "Capacity Kw" + }, + "load_percent": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Load Percent" + }, + "data_quality": { + "type": "string", + "enum": [ + "good", + "partial", + "degraded", + "critical" + ], + "title": "Data Quality" + } + }, + "type": "object", + "required": [ + "site_id", + "site_name", + "current_consumption_kw", + "capacity_kw", + "load_percent", + "data_quality" + ], + "title": "SiteSummaryResponse" + }, + "StatsSummaryResponse": { + "properties": { + "timestamp": { + "type": "string", + "format": "date-time", + "title": "Timestamp" + }, + "total_sites": { + "type": "integer", + "title": "Total Sites" + }, + "total_consumption_kw": { + "type": "number", + "title": "Total Consumption Kw" + }, + "total_capacity_kw": { + "type": "number", + "title": "Total Capacity Kw" + }, + "average_load_percent": { + "type": "number", + "title": "Average Load Percent" + }, + "sites": { + "items": { + "$ref": "#/components/schemas/SiteSummaryResponse" + }, + "type": "array", + "title": "Sites" + } + }, + "type": "object", + "required": [ + "timestamp", + "total_sites", + "total_consumption_kw", + "total_capacity_kw", + "average_load_percent", + "sites" + ], + "title": "StatsSummaryResponse" + }, "TemporaryPasswordResponse": { "properties": { "user": { @@ -1609,6 +1951,14 @@ { "name": "alerts", "description": "Consultation des alertes de consommation. Accessible à partir du rôle `lecteur`." + }, + { + "name": "recommendations", + "description": "Consultation des recommandations issues des alertes. Accessible à partir du rôle `lecteur`." + }, + { + "name": "stats", + "description": "Statistiques agrégées de consommation. Accessible à partir du rôle `lecteur`." } ] } diff --git a/apps/backend/tests/api/test_openapi.py b/apps/backend/tests/api/test_openapi.py index 05b5dfe..f7147da 100644 --- a/apps/backend/tests/api/test_openapi.py +++ b/apps/backend/tests/api/test_openapi.py @@ -32,6 +32,9 @@ ROUTES_A_ROLE = { ("GET", "/api/v1/sites"), ("GET", "/api/v1/sites/{site_id}"), ("GET", "/api/v1/alerts"), + ("GET", "/api/v1/recommendations"), + ("GET", "/api/v1/recommendations/{recommendation_id}"), + ("GET", "/api/v1/stats/summary"), } diff --git a/apps/backend/tests/api/test_recommendations.py b/apps/backend/tests/api/test_recommendations.py new file mode 100644 index 0000000..d01db09 --- /dev/null +++ b/apps/backend/tests/api/test_recommendations.py @@ -0,0 +1,144 @@ +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_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 + +MOMENT = datetime(2024, 1, 1, tzinfo=UTC) + + +def principal(role: Role = Role.LECTEUR) -> Principal: + return Principal( + id=uuid4(), + email=f"{role.value}@enervision.fr", + role=role, + kind=AccountKind.HUMAIN, + must_change_password=False, + ) + + +def recommendation(recommendation_id: int = 1) -> Recommendation: + return Recommendation( + recommendation_id=recommendation_id, + alert_id=1, + action="Vérifier la consommation", + explanation="Pic détecté", + rule_reference="spike-v1", + created_at=MOMENT, + ) + + +class FauxService: + def __init__(self, erreur: Exception | None = None) -> None: + self._erreur = erreur + self.recommendation = recommendation() + + async def list_all(self) -> list[Recommendation]: + return [self.recommendation] + + async def get_by_id(self, recommendation_id: int) -> Recommendation: + if self._erreur is not None: + raise self._erreur + return self.recommendation + + +@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[[Exception | None], FauxService]]: + def installe(erreur: Exception | None = None) -> FauxService: + service = FauxService(erreur) + app.dependency_overrides[get_recommendation_service] = lambda: service + return service + + yield installe + app.dependency_overrides.pop(get_recommendation_service, None) + + +async def test_list_recommendations_returns_the_recommendations( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/recommendations") + + assert response.status_code == 200 + corps = response.json() + assert corps == [ + { + "recommendation_id": 1, + "alert_id": 1, + "action": "Vérifier la consommation", + "explanation": "Pic détecté", + "rule_reference": "spike-v1", + "created_at": "2024-01-01T00:00:00Z", + } + ] + + +async def test_get_recommendation_returns_the_matching_recommendation( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/recommendations/1") + + assert response.status_code == 200 + assert response.json()["recommendation_id"] == 1 + + +async def test_get_recommendation_returns_404_for_an_unknown_recommendation( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi(RecommendationNotFoundError(404)) + + response = await client.get("/api/v1/recommendations/404") + + assert response.status_code == 404 + + +async def test_list_recommendations_reaches_the_repository_through_the_session( + lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient +) -> None: + fake_session(result=[recommendation(1), recommendation(2)]) + + response = await client.get("/api/v1/recommendations") + + assert response.status_code == 200 + assert [r["recommendation_id"] for r in response.json()] == [1, 2] + + +async def test_get_recommendation_reaches_the_repository_through_the_session( + lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient +) -> None: + fake_session(result=recommendation(1)) + + response = await client.get("/api/v1/recommendations/1") + + assert response.status_code == 200 + assert response.json()["recommendation_id"] == 1 + + +async def test_get_recommendation_returns_404_when_the_session_finds_nothing( + lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient +) -> None: + fake_session(result=None) + + response = await client.get("/api/v1/recommendations/404") + + assert response.status_code == 404 diff --git a/apps/backend/tests/api/test_stats.py b/apps/backend/tests/api/test_stats.py new file mode 100644 index 0000000..8e4c439 --- /dev/null +++ b/apps/backend/tests/api/test_stats.py @@ -0,0 +1,73 @@ +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_stats_service +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.services.stats import ConsumptionSummary, SiteConsumption + + +def principal(role: Role = Role.LECTEUR) -> Principal: + return Principal( + id=uuid4(), + email=f"{role.value}@enervision.fr", + role=role, + kind=AccountKind.HUMAIN, + must_change_password=False, + ) + + +class FauxService: + def __init__(self) -> None: + self.resume = ConsumptionSummary( + timestamp=datetime.now(UTC), + total_sites=1, + total_consumption_kw=87.34, + total_capacity_kw=200, + average_load_percent=43.7, + sites=[ + SiteConsumption( + site_id="SITE001", + site_name="Bureau Paris La Défense", + current_consumption_kw=87.34, + capacity_kw=200, + load_percent=43.7, + data_quality="good", + ) + ], + ) + + async def summary(self) -> ConsumptionSummary: + return self.resume + + +@pytest.fixture +def servi(app: FastAPI) -> Iterator[Callable[[], FauxService]]: + def installe() -> FauxService: + service = FauxService() + app.dependency_overrides[get_stats_service] = lambda: service + app.dependency_overrides[get_current_principal] = lambda: principal() + return service + + yield installe + app.dependency_overrides.pop(get_stats_service, None) + app.dependency_overrides.pop(get_current_principal, None) + + +async def test_get_summary_returns_the_service_result( + servi: Callable[[], FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/stats/summary") + + assert response.status_code == 200 + corps = response.json() + assert corps["total_sites"] == 1 + assert corps["sites"][0]["site_id"] == "SITE001" + assert corps["sites"][0]["data_quality"] == "good" diff --git a/apps/backend/tests/repositories/test_reading.py b/apps/backend/tests/repositories/test_reading.py new file mode 100644 index 0000000..650d49a --- /dev/null +++ b/apps/backend/tests/repositories/test_reading.py @@ -0,0 +1,72 @@ +import uuid +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Reading, Site +from app.repositories.reading import ReadingRepository + +pytestmark = pytest.mark.integration + + +def identifiant() -> str: + return f"SITE-{uuid.uuid4().hex[:8]}" + + +def lecture(site_id: str, *, timestamp: datetime, consumption_kw: float) -> Reading: + return Reading( + site_id=site_id, + timestamp=timestamp, + source="api_current", + consumption_kw=consumption_kw, + data_quality="good", + raw_data={}, + ) + + +async def test_latest_by_site_keeps_only_the_most_recent_reading(session: AsyncSession) -> None: + site_id = identifiant() + maintenant = datetime.now(UTC) + session.add(Site(site_id=site_id, site_name="Site", site_type="bureau", capacity_kw=100)) + await session.flush() + session.add_all( + [ + lecture(site_id, timestamp=maintenant - timedelta(hours=1), consumption_kw=10), + lecture(site_id, timestamp=maintenant, consumption_kw=42), + ] + ) + await session.flush() + depot = ReadingRepository(session) + + resultats = await depot.latest_by_site() + consommations = [r.consumption_kw for r in resultats if r.site_id == site_id] + await session.rollback() + + assert consommations == [42] + + +async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) -> None: + premier, second = identifiant(), identifiant() + maintenant = datetime.now(UTC) + session.add_all( + [ + Site(site_id=premier, site_name="A", site_type="bureau", capacity_kw=100), + Site(site_id=second, site_name="B", site_type="bureau", capacity_kw=200), + ] + ) + await session.flush() + session.add_all( + [ + lecture(premier, timestamp=maintenant, consumption_kw=10), + lecture(second, timestamp=maintenant, consumption_kw=20), + ] + ) + await session.flush() + depot = ReadingRepository(session) + + resultats = await depot.latest_by_site() + identifiants = {r.site_id for r in resultats if r.site_id in (premier, second)} + await session.rollback() + + assert identifiants == {premier, second} diff --git a/apps/backend/tests/repositories/test_recommendation.py b/apps/backend/tests/repositories/test_recommendation.py new file mode 100644 index 0000000..075c9eb --- /dev/null +++ b/apps/backend/tests/repositories/test_recommendation.py @@ -0,0 +1,85 @@ +import uuid +from datetime import UTC, datetime + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Alert, Recommendation, Site +from app.repositories.recommendation import RecommendationRepository + +pytestmark = pytest.mark.integration + +MOMENT = datetime(2024, 1, 1, tzinfo=UTC) + + +async def creer_site(session: AsyncSession) -> str: + site_id = f"TEST-{uuid.uuid4()}" + session.add(Site(site_id=site_id, site_name="Site de test", site_type="office")) + await session.flush() + return site_id + + +async def creer_alerte(session: AsyncSession) -> int: + site_id = await creer_site(session) + alerte = Alert( + source_alert_id=str(uuid.uuid4()), + site_id=site_id, + source="api_mock", + timestamp=MOMENT, + type="spike", + severity="high", + message="Test", + raw_data={}, + ) + session.add(alerte) + await session.flush() + return alerte.alert_id + + +async def creer(session: AsyncSession, **overrides: object) -> Recommendation: + recommendation = Recommendation( + alert_id=overrides.get("alert_id") or await creer_alerte(session), + action=overrides.get("action", "Vérifier la consommation"), + explanation=overrides.get("explanation", "Pic détecté"), + rule_reference=overrides.get("rule_reference", f"spike-{uuid.uuid4().hex[:8]}"), + ) + session.add(recommendation) + await session.flush() + return recommendation + + +async def test_get_by_id_returns_the_matching_recommendation(session: AsyncSession) -> None: + depot = RecommendationRepository(session) + cree = await creer(session) + + trouve = await depot.get_by_id(cree.recommendation_id) + action = trouve.action if trouve else None + await session.rollback() + + assert action == "Vérifier la consommation" + + +async def test_get_by_id_returns_nothing_for_an_unknown_identifier( + session: AsyncSession, +) -> None: + trouve = await RecommendationRepository(session).get_by_id(0) + + assert trouve is None + + +async def test_list_all_returns_the_recommendations_sorted_by_identifier( + session: AsyncSession, +) -> None: + depot = RecommendationRepository(session) + premiere = await creer(session) + seconde = await creer(session) + + recommendations = await depot.list_all() + identifiants = [ + r.recommendation_id + for r in recommendations + if r.recommendation_id in (premiere.recommendation_id, seconde.recommendation_id) + ] + await session.rollback() + + assert identifiants == sorted(identifiants) diff --git a/apps/backend/tests/services/test_recommendation.py b/apps/backend/tests/services/test_recommendation.py new file mode 100644 index 0000000..e8ed2b2 --- /dev/null +++ b/apps/backend/tests/services/test_recommendation.py @@ -0,0 +1,55 @@ +from datetime import UTC, datetime + +import pytest + +from app.models.energy import Recommendation +from app.services.recommendation import RecommendationNotFoundError, RecommendationService + + +def recommendation(recommendation_id: int = 1) -> Recommendation: + return Recommendation( + recommendation_id=recommendation_id, + alert_id=1, + action="Vérifier la consommation", + explanation="Pic détecté", + rule_reference="spike-v1", + created_at=datetime(2024, 1, 1, tzinfo=UTC), + ) + + +class FakeRepository: + def __init__(self, recommendations: list[Recommendation]) -> None: + self._recommendations = recommendations + + async def list_all(self) -> list[Recommendation]: + return self._recommendations + + async def get_by_id(self, recommendation_id: int) -> Recommendation | None: + return next( + (r for r in self._recommendations if r.recommendation_id == recommendation_id), None + ) + + +async def test_list_all_returns_the_repository_recommendations() -> None: + service = RecommendationService( + recommendations=FakeRepository([recommendation(1), recommendation(2)]) + ) + + recommendations = await service.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) + + 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) diff --git a/apps/backend/tests/services/test_stats.py b/apps/backend/tests/services/test_stats.py new file mode 100644 index 0000000..1962678 --- /dev/null +++ b/apps/backend/tests/services/test_stats.py @@ -0,0 +1,107 @@ +from dataclasses import dataclass + +from app.services.stats import StatsService + + +@dataclass +class FauxSite: + site_id: str + site_name: str + capacity_kw: float | None + + +@dataclass +class FauxLecture: + site_id: str + consumption_kw: float | None + data_quality: str | None + + +class FauxDepotSites: + def __init__(self, sites: list[FauxSite]) -> None: + self._sites = sites + + async def list_all(self) -> list[FauxSite]: + return self._sites + + +class FauxDepotLectures: + def __init__(self, lectures: list[FauxLecture]) -> None: + self._lectures = lectures + + async def latest_by_site(self) -> list[FauxLecture]: + return self._lectures + + +async def test_summary_computes_totals_and_the_average_load() -> None: + service = StatsService( + sites=FauxDepotSites([FauxSite("A", "Site A", 200), FauxSite("B", "Site B", 800)]), # type: ignore[arg-type] + readings=FauxDepotLectures( # type: ignore[arg-type] + [ + FauxLecture("A", 100, "good"), + FauxLecture("B", 400, "good"), + ] + ), + ) + + resume = await service.summary() + + assert resume.total_sites == 2 + assert resume.total_consumption_kw == 500 + assert resume.total_capacity_kw == 1000 + assert resume.average_load_percent == 50 + par_site = {site.site_id: site for site in resume.sites} + assert par_site["A"].load_percent == 50 + assert par_site["B"].load_percent == 50 + + +async def test_summary_treats_a_site_without_any_reading_as_critical() -> None: + service = StatsService( + sites=FauxDepotSites([FauxSite("A", "Site A", 200)]), # type: ignore[arg-type] + readings=FauxDepotLectures([]), # type: ignore[arg-type] + ) + + resume = await service.summary() + + site = resume.sites[0] + assert site.data_quality == "critical" + assert site.current_consumption_kw is None + assert site.load_percent is None + + +async def test_summary_treats_a_reading_with_an_unknown_quality_as_critical() -> None: + service = StatsService( + sites=FauxDepotSites([FauxSite("A", "Site A", 200)]), # type: ignore[arg-type] + readings=FauxDepotLectures([FauxLecture("A", 50, None)]), # type: ignore[arg-type] + ) + + resume = await service.summary() + + site = resume.sites[0] + assert site.data_quality == "critical" + assert site.current_consumption_kw is None + + +async def test_summary_exposes_a_missing_capacity_as_zero_without_dividing_by_it() -> None: + service = StatsService( + sites=FauxDepotSites([FauxSite("A", "Site A", None)]), # type: ignore[arg-type] + readings=FauxDepotLectures([FauxLecture("A", 50, "good")]), # type: ignore[arg-type] + ) + + resume = await service.summary() + + site = resume.sites[0] + assert site.capacity_kw == 0 + assert site.current_consumption_kw == 50 + assert site.load_percent is None + + +async def test_summary_returns_zero_average_load_when_no_site_has_a_capacity() -> None: + service = StatsService( + sites=FauxDepotSites([FauxSite("A", "Site A", None)]), # type: ignore[arg-type] + readings=FauxDepotLectures([]), # type: ignore[arg-type] + ) + + resume = await service.summary() + + assert resume.average_load_percent == 0 diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index 96fb992..b49a70f 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`, `GET /sites` et `GET /sites/{site_id}` (première couche métier, 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` et `recommendations` 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 5347e3e..fec2794 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -12,10 +12,10 @@ Les quatre couches existent désormais, portées par l'authentification. ```mermaid flowchart TB - ep["endpoints
health, auth, users, sites"] + ep["endpoints
health, auth, users, sites,
recommendations, stats"] sc["schemas
Pydantic"] - sv["services
AuthService, UserService,
SiteService"] - rp["repositories
user, refresh_token,
login_attempt, audit_log,
site"] + sv["services
AuthService, UserService,
SiteService, RecommendationService,
StatsService"] + rp["repositories
user, refresh_token,
login_attempt, audit_log,
site, recommendation, reading"] md["models
10 tables"] db[("PostgreSQL")] @@ -143,6 +143,9 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke | 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/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 | +| GET | `/api/v1/stats/summary` | Résume la consommation instantanée du parc. `lecteur` | 401, 403, 500 | | GET | `/metrics` | Format Prometheus, hors du schéma. Jeton requis si `APP_METRICS_TOKEN` est posé | | | GET | `/docs`, `/redoc`, `/openapi.json` | Hors du schéma. Fermés en `staging` et en `prod` | | @@ -161,7 +164,12 @@ tables déjà créées par la révision Alembic `e6d2026091501`. Elles n'exigent 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. Le contrat détaillé pour le frontend est dans +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` 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 [31-contrat-authentification.md](31-contrat-authentification.md). ### `/health/ready` @@ -238,8 +246,8 @@ 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` (`reading`, `dataset`, -`prediction`, `recommendation`) : +Checklist pour toute nouvelle route sur le gabarit `sites`/`alerts`/`recommendations`/`stats` +(`reading`, `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 diff --git a/docs/architecture/owasp-traceabilite.md b/docs/architecture/owasp-traceabilite.md index ac4a8af..15c2b51 100644 --- a/docs/architecture/owasp-traceabilite.md +++ b/docs/architecture/owasp-traceabilite.md @@ -9,8 +9,8 @@ Ce qui est défendable, c'est une ligne par contrôle réellement implémenté, et une section qui dit ce qui n'est pas couvert et pourquoi. Statut : `Fait` pour le périmètre authentification et autorisation. `GET /sites` et -`GET /sites/{site_id}` sont les premiers endpoints métier, en lecture seule ; plusieurs lignes -resteront à compléter une fois les endpoints d'écriture posés. +`GET /recommendations`, chacune avec sa route de détail, sont les premiers endpoints métier, en +lecture seule ; plusieurs lignes resteront à compléter une fois les endpoints d'écriture posés. ## Contrôles en place @@ -49,7 +49,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}` répond à tout compte `lecteur` pour n'importe quel site, 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. | +| **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. | | **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. |