diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index f16d167..7415616 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -21,11 +21,13 @@ from app.core.roles import AccountKind, Role, has_at_least from app.core.security import TokenExpiredError, TokenInvalidError, TokenPolicy from app.core.security import decode_access_token as decode_token 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.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.site import SiteService from app.services.user import UserService @@ -140,6 +142,13 @@ def get_site_service(session: SessionDep) -> SiteService: SiteServiceDep = Annotated[SiteService, Depends(get_site_service)] +def get_alert_service(session: SessionDep) -> AlertService: + return AlertService(alerts=AlertRepository(session)) + + +AlertServiceDep = Annotated[AlertService, Depends(get_alert_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 1eaa3a9..f090ce2 100644 --- a/apps/backend/app/api/openapi.py +++ b/apps/backend/app/api/openapi.py @@ -54,6 +54,11 @@ TAGS: Final[list[dict[str, Any]]] = [ "name": "sites", "description": "Consultation du parc de sites. Accessible à partir du rôle `lecteur`.", }, + { + "name": "alerts", + "description": "Consultation des alertes de consommation. Accessible à partir du rôle " + "`lecteur`.", + }, ] cookie_de_rafraichissement = APIKeyCookie( diff --git a/apps/backend/app/api/v1/endpoints/alerts.py b/apps/backend/app/api/v1/endpoints/alerts.py new file mode 100644 index 0000000..ac9f5ae --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/alerts.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter + +from app.api.deps import AlertServiceDep, LecteurDep +from app.api.openapi import REPONSE_VALIDATION +from app.schemas.alert import AlertResponse, AlertSeverity + +router = APIRouter() + + +@router.get( + "", + response_model=list[AlertResponse], + summary="Liste les alertes", + responses=REPONSE_VALIDATION, +) +async def list_alerts( + _: LecteurDep, + service: AlertServiceDep, + site_id: str | None = None, + severity: AlertSeverity | None = None, +) -> list[AlertResponse]: + alertes = await service.list_all(site_id=site_id, severity=severity) + return [AlertResponse.model_validate(alerte) for alerte in alertes] diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py index edb035b..c72873f 100644 --- a/apps/backend/app/api/v1/router.py +++ b/apps/backend/app/api/v1/router.py @@ -1,10 +1,13 @@ from fastapi import APIRouter from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR -from app.api.v1.endpoints import auth, health, sites, users +from app.api.v1.endpoints import alerts, auth, health, sites, users api_router = APIRouter(responses=REPONSE_SERVEUR) api_router.include_router(health.router, prefix="/health", tags=["health"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(users.router, prefix="/users", tags=["users"], responses=REPONSES_ADMIN) api_router.include_router(sites.router, prefix="/sites", tags=["sites"], responses=REPONSES_LECTEUR) +api_router.include_router( + alerts.router, prefix="/alerts", tags=["alerts"], responses=REPONSES_LECTEUR +) diff --git a/apps/backend/app/repositories/alert.py b/apps/backend/app/repositories/alert.py new file mode 100644 index 0000000..4b0766f --- /dev/null +++ b/apps/backend/app/repositories/alert.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 Alert + + +class AlertRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def list_all( + self, *, site_id: str | None = None, severity: str | None = None + ) -> Sequence[Alert]: + requete = select(Alert).order_by(Alert.timestamp.desc(), Alert.alert_id.desc()) + if site_id is not None: + requete = requete.where(Alert.site_id == site_id) + if severity is not None: + requete = requete.where(Alert.severity == severity) + return (await self._session.scalars(requete)).all() diff --git a/apps/backend/app/schemas/alert.py b/apps/backend/app/schemas/alert.py new file mode 100644 index 0000000..a041b07 --- /dev/null +++ b/apps/backend/app/schemas/alert.py @@ -0,0 +1,34 @@ +from datetime import datetime +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict + + +class AlertType(StrEnum): + SPIKE = "spike" + THRESHOLD = "threshold" + ANOMALY = "anomaly" + OUTAGE = "outage" + SENSOR = "sensor" + + +class AlertSeverity(StrEnum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + + +class AlertResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + alert_id: int + site_id: str + timestamp: datetime + type: AlertType + severity: AlertSeverity + message: str + value: float | None + threshold: float | None + metric: str | None + prediction_id: int | None diff --git a/apps/backend/app/services/alert.py b/apps/backend/app/services/alert.py new file mode 100644 index 0000000..a3ad16e --- /dev/null +++ b/apps/backend/app/services/alert.py @@ -0,0 +1,14 @@ +from collections.abc import Sequence + +from app.models.energy import Alert +from app.repositories.alert import AlertRepository + + +class AlertService: + def __init__(self, *, alerts: AlertRepository) -> None: + self._alerts = alerts + + 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) diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index 3e8dc01..c7009e6 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -920,6 +920,110 @@ } } } + }, + "/api/v1/alerts": { + "get": { + "tags": [ + "alerts" + ], + "summary": "Liste les alertes", + "operationId": "list_alerts_api_v1_alerts_get", + "security": [ + { + "Jeton d'accès": [] + } + ], + "parameters": [ + { + "name": "site_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Site Id" + } + }, + { + "name": "severity", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/AlertSeverity" + }, + { + "type": "null" + } + ], + "title": "Severity" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AlertResponse" + }, + "title": "Response List Alerts Api V1 Alerts 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" + } + } + } + } + } + } } }, "components": { @@ -932,6 +1036,112 @@ ], "title": "AccountKind" }, + "AlertResponse": { + "properties": { + "alert_id": { + "type": "integer", + "title": "Alert Id" + }, + "site_id": { + "type": "string", + "title": "Site Id" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "title": "Timestamp" + }, + "type": { + "$ref": "#/components/schemas/AlertType" + }, + "severity": { + "$ref": "#/components/schemas/AlertSeverity" + }, + "message": { + "type": "string", + "title": "Message" + }, + "value": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Value" + }, + "threshold": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Threshold" + }, + "metric": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Metric" + }, + "prediction_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Prediction Id" + } + }, + "type": "object", + "required": [ + "alert_id", + "site_id", + "timestamp", + "type", + "severity", + "message", + "value", + "threshold", + "metric", + "prediction_id" + ], + "title": "AlertResponse" + }, + "AlertSeverity": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "title": "AlertSeverity" + }, + "AlertType": { + "type": "string", + "enum": [ + "spike", + "threshold", + "anomaly", + "outage", + "sensor" + ], + "title": "AlertType" + }, "ErrorResponse": { "properties": { "detail": { @@ -1395,6 +1605,10 @@ { "name": "sites", "description": "Consultation du parc de sites. Accessible à partir du rôle `lecteur`." + }, + { + "name": "alerts", + "description": "Consultation des alertes de consommation. Accessible à partir du rôle `lecteur`." } ] } diff --git a/apps/backend/tests/api/test_alerts.py b/apps/backend/tests/api/test_alerts.py new file mode 100644 index 0000000..840d6c9 --- /dev/null +++ b/apps/backend/tests/api/test_alerts.py @@ -0,0 +1,136 @@ +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_alert_service, get_current_principal +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.models.energy import Alert + + +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 alert(alert_id: int = 1, site_id: str = "site-1", severity: str = "high") -> Alert: + return Alert( + alert_id=alert_id, + source_alert_id=f"ALR-{alert_id}", + site_id=site_id, + source="enervision", + timestamp=datetime(2026, 9, 16, tzinfo=UTC), + type="threshold", + severity=severity, + message="Dépassement du seuil configuré", + value=812.5, + threshold=720.0, + metric="consumption_kw", + prediction_id=None, + raw_data={}, + ) + + +class FauxService: + def __init__(self) -> None: + self.alert = alert() + self.appels: list[tuple[str | None, str | None]] = [] + + async def list_all( + self, *, site_id: str | None = None, severity: str | None = None + ) -> list[Alert]: + self.appels.append((site_id, severity)) + return [self.alert] + + +@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() -> FauxService: + service = FauxService() + app.dependency_overrides[get_alert_service] = lambda: service + return service + + yield installe + app.dependency_overrides.pop(get_alert_service, None) + + +async def test_list_alerts_returns_the_alerts( + servi: Callable[[], FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/alerts") + + assert response.status_code == 200 + corps = response.json() + assert corps == [ + { + "alert_id": 1, + "site_id": "site-1", + "timestamp": "2026-09-16T00:00:00Z", + "type": "threshold", + "severity": "high", + "message": "Dépassement du seuil configuré", + "value": 812.5, + "threshold": 720.0, + "metric": "consumption_kw", + "prediction_id": None, + } + ] + + +async def test_list_alerts_transmits_the_site_id_filter( + servi: Callable[[], FauxService], client: AsyncClient +) -> None: + service = servi() + + await client.get("/api/v1/alerts?site_id=site-1") + + assert service.appels == [("site-1", None)] + + +async def test_list_alerts_transmits_the_severity_filter( + servi: Callable[[], FauxService], client: AsyncClient +) -> None: + service = servi() + + await client.get("/api/v1/alerts?severity=critical") + + assert service.appels == [(None, "critical")] + + +async def test_list_alerts_returns_422_for_an_unknown_severity( + servi: Callable[[], FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/alerts?severity=invalide") + + assert response.status_code == 422 + + +async def test_list_alerts_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/alerts") + + assert response.status_code == 200 + assert response.json() == [] diff --git a/apps/backend/tests/api/test_openapi.py b/apps/backend/tests/api/test_openapi.py index 96297c0..05b5dfe 100644 --- a/apps/backend/tests/api/test_openapi.py +++ b/apps/backend/tests/api/test_openapi.py @@ -22,6 +22,18 @@ ORIGINE_VERIFIEE = { ("POST", "/api/v1/auth/password"), } +# 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/alerts"), +} + @pytest.fixture(scope="module") def schema() -> dict[str, Any]: @@ -55,11 +67,11 @@ def test_every_route_demanding_an_identity_says_how_it_refuses(schema: dict[str, assert muettes == [] -def test_every_administration_route_documents_the_role_refusal(schema: dict[str, Any]) -> None: +def test_every_role_guarded_route_documents_the_role_refusal(schema: dict[str, Any]) -> None: sans_403 = [ (methode, chemin) for methode, chemin, operation in operations(schema) - if "users" in operation.get("tags", []) and "403" not in operation["responses"] + if (methode, chemin) in ROUTES_A_ROLE and "403" not in operation["responses"] ] assert sans_403 == [] diff --git a/apps/backend/tests/repositories/test_alert.py b/apps/backend/tests/repositories/test_alert.py new file mode 100644 index 0000000..45ab41a --- /dev/null +++ b/apps/backend/tests/repositories/test_alert.py @@ -0,0 +1,90 @@ +import uuid +from datetime import UTC, datetime + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Alert +from app.repositories.alert import AlertRepository +from tests.repositories.test_site import creer as creer_site +from tests.repositories.test_site import identifiant as identifiant_site + +pytestmark = pytest.mark.integration + + +async def creer_alerte(session: AsyncSession, *, site_id: str, **overrides: object) -> Alert: + alerte = Alert( + source_alert_id=overrides.get("source_alert_id", f"ALR-{uuid.uuid4().hex[:12]}"), + site_id=site_id, + source=overrides.get("source", "enervision"), + timestamp=overrides.get("timestamp", datetime(2026, 9, 16, tzinfo=UTC)), + type=overrides.get("type", "threshold"), + severity=overrides.get("severity", "high"), + message=overrides.get("message", "Dépassement du seuil configuré"), + value=overrides.get("value", 812.5), + threshold=overrides.get("threshold", 720.0), + metric=overrides.get("metric", "consumption_kw"), + prediction_id=overrides.get("prediction_id"), + raw_data=overrides.get("raw_data", {}), + ) + session.add(alerte) + await session.flush() + return alerte + + +async def test_list_all_returns_the_alerts_sorted_by_timestamp_descending( + session: AsyncSession, +) -> None: + site = await creer_site(session) + depot = AlertRepository(session) + ancienne = await creer_alerte( + session, site_id=site.site_id, timestamp=datetime(2026, 9, 1, tzinfo=UTC) + ) + recente = await creer_alerte( + session, site_id=site.site_id, timestamp=datetime(2026, 9, 15, tzinfo=UTC) + ) + + alertes = await depot.list_all() + identifiants = [ + a.alert_id for a in alertes if a.alert_id in (ancienne.alert_id, recente.alert_id) + ] + await session.rollback() + + assert identifiants == [recente.alert_id, ancienne.alert_id] + + +async def test_list_all_filters_by_site_id(session: AsyncSession) -> None: + premier = await creer_site(session) + second = await creer_site(session) + depot = AlertRepository(session) + voulue = await creer_alerte(session, site_id=premier.site_id) + await creer_alerte(session, site_id=second.site_id) + + alertes = await depot.list_all(site_id=premier.site_id) + identifiants = [a.alert_id for a in alertes] + await session.rollback() + + assert identifiants == [voulue.alert_id] + + +async def test_list_all_filters_by_severity(session: AsyncSession) -> None: + site = await creer_site(session) + depot = AlertRepository(session) + voulue = await creer_alerte(session, site_id=site.site_id, severity="critical") + await creer_alerte(session, site_id=site.site_id, severity="low") + + alertes = await depot.list_all(severity="critical") + identifiants = [a.alert_id for a in alertes] + await session.rollback() + + assert identifiants == [voulue.alert_id] + + +async def test_list_all_returns_an_empty_list_when_there_is_nothing( + session: AsyncSession, +) -> None: + depot = AlertRepository(session) + + alertes = await depot.list_all(site_id=identifiant_site()) + + assert list(alertes) == [] diff --git a/apps/backend/tests/services/test_alert.py b/apps/backend/tests/services/test_alert.py new file mode 100644 index 0000000..4a88802 --- /dev/null +++ b/apps/backend/tests/services/test_alert.py @@ -0,0 +1,55 @@ +from datetime import UTC, datetime + +from app.models.energy import Alert +from app.services.alert import AlertService + + +def alert( + alert_id: int = 1, + site_id: str = "site-1", + severity: str = "high", +) -> Alert: + return Alert( + alert_id=alert_id, + source_alert_id=f"ALR-{alert_id}", + site_id=site_id, + source="enervision", + timestamp=datetime(2026, 9, 16, tzinfo=UTC), + type="threshold", + severity=severity, + message="Dépassement du seuil configuré", + value=812.5, + threshold=720.0, + metric="consumption_kw", + prediction_id=None, + raw_data={}, + ) + + +class FakeRepository: + def __init__(self, alerts: list[Alert]) -> None: + self._alerts = alerts + self.appels: list[tuple[str | None, str | None]] = [] + + async def list_all( + self, *, site_id: str | None = None, severity: str | None = None + ) -> list[Alert]: + self.appels.append((site_id, severity)) + return self._alerts + + +async def test_list_all_returns_the_repository_alerts() -> None: + service = AlertService(alerts=FakeRepository([alert(1), alert(2)])) + + alertes = await service.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) + + await service.list_all(site_id="site-1", severity="critical") + + assert depot.appels == [("site-1", "critical")] diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index f48178f..e814ecd 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/alerts` | Liste les alertes, filtrable par `site_id` et `severity`. `lecteur` | 401, 403, 422, 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` | | @@ -153,10 +154,10 @@ Les codes de la dernière colonne sont ceux que le schéma **déclare**, et le f é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. -`GET /sites` et `GET /sites/{site_id}` sont la première route métier, et le gabarit à réutiliser -pour les suivantes (`reading`, `dataset`, `prediction`, `alert`, `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`, +`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