feat(backend): expose GET /api/v1/alerts

Consultation des alertes de consommation, filtrable par site_id et
severity a l'identique du contrat GET /alerts de l'API Mock. Reprend
le gabarit endpoints -> services -> repositories -> models pose par
sites, sur la table alert deja creee par la revision Alembic
e6d2026091501.

Generalise aussi le garde-fou OpenAPI du 403 (ROUTES_A_ROLE) au-dela
du seul tag users, pour que l'ajout d'alerts a la liste des routes
protegees par role soit reellement verifie.

Closes #59
This commit is contained in:
Johan LEROY
2026-09-16 13:06:37 +02:00
parent 56f7211f0b
commit e50921c907
13 changed files with 624 additions and 7 deletions
+9
View File
@@ -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 TokenExpiredError, TokenInvalidError, TokenPolicy
from app.core.security import decode_access_token as decode_token from app.core.security import decode_access_token as decode_token
from app.db.session import get_session from app.db.session import get_session
from app.repositories.alert import AlertRepository
from app.repositories.audit_log import AuditLogRepository from app.repositories.audit_log import AuditLogRepository
from app.repositories.login_attempt import LoginAttemptRepository from app.repositories.login_attempt import LoginAttemptRepository
from app.repositories.refresh_token import RefreshTokenRepository from app.repositories.refresh_token import RefreshTokenRepository
from app.repositories.site import SiteRepository from app.repositories.site import SiteRepository
from app.repositories.user import UserRepository from app.repositories.user import UserRepository
from app.services.alert import AlertService
from app.services.auth import AuthService, LoginPolicy from app.services.auth import AuthService, LoginPolicy
from app.services.site import SiteService from app.services.site import SiteService
from app.services.user import UserService from app.services.user import UserService
@@ -140,6 +142,13 @@ def get_site_service(session: SessionDep) -> SiteService:
SiteServiceDep = Annotated[SiteService, Depends(get_site_service)] 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( async def get_current_principal(
credentials: CredentialsDep, credentials: CredentialsDep,
session: SessionDep, session: SessionDep,
+5
View File
@@ -54,6 +54,11 @@ TAGS: Final[list[dict[str, Any]]] = [
"name": "sites", "name": "sites",
"description": "Consultation du parc de sites. Accessible à partir du rôle `lecteur`.", "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( cookie_de_rafraichissement = APIKeyCookie(
@@ -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]
+4 -1
View File
@@ -1,10 +1,13 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR 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 = APIRouter(responses=REPONSE_SERVEUR)
api_router.include_router(health.router, prefix="/health", tags=["health"]) api_router.include_router(health.router, prefix="/health", tags=["health"])
api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) 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(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(sites.router, prefix="/sites", tags=["sites"], responses=REPONSES_LECTEUR)
api_router.include_router(
alerts.router, prefix="/alerts", tags=["alerts"], responses=REPONSES_LECTEUR
)
+21
View File
@@ -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()
+34
View File
@@ -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
+14
View File
@@ -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)
+214
View File
@@ -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": { "components": {
@@ -932,6 +1036,112 @@
], ],
"title": "AccountKind" "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": { "ErrorResponse": {
"properties": { "properties": {
"detail": { "detail": {
@@ -1395,6 +1605,10 @@
{ {
"name": "sites", "name": "sites",
"description": "Consultation du parc de sites. Accessible à partir du rôle `lecteur`." "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`."
} }
] ]
} }
+136
View File
@@ -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() == []
+14 -2
View File
@@ -22,6 +22,18 @@ ORIGINE_VERIFIEE = {
("POST", "/api/v1/auth/password"), ("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") @pytest.fixture(scope="module")
def schema() -> dict[str, Any]: 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 == [] 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 = [ sans_403 = [
(methode, chemin) (methode, chemin)
for methode, chemin, operation in operations(schema) 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 == [] assert sans_403 == []
@@ -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) == []
+55
View File
@@ -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")]
+5 -4
View File
@@ -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 | | 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` | Liste les sites. `lecteur` | 401, 403, 500 |
| GET | `/api/v1/sites/{site_id}` | Décrit un site. `lecteur` | 401, 403, 404, 422, 500 | | GET | `/api/v1/sites/{site_id}` | 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 | `/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` | | | 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 échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Rendre une route publique impose
donc de modifier la liste dans ce fichier de test. donc de modifier 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 `GET /sites` et `GET /sites/{site_id}` sont la première route métier, et le gabarit repris pour
pour les suivantes (`reading`, `dataset`, `prediction`, `alert`, `recommendation`) : les quatre `GET /alerts` puis pour les suivantes (`reading`, `dataset`, `prediction`, `recommendation`) : les
couches `endpoints → services → repositories → models` y sont toutes présentes, sur des tables quatre couches `endpoints → services → repositories → models` y sont toutes présentes, sur des
déjà créées par la révision Alembic `e6d2026091501`. Elles n'exigent que le rôle `lecteur`, 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 contrairement aux routes d'administration qui exigent `admin`. `SiteRepository` lit par
`AsyncSession.scalar()` (une ligne) et `AsyncSession.scalars()` (plusieurs lignes) plutôt que 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 `execute()`, ce qui la rend testable par la fixture `fake_session` au niveau endpoint sans base