Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76fa90dfcb | ||
|
|
e13096c62a | ||
|
|
3fb907d6f6 | ||
|
|
1654e4dd81 | ||
|
|
e50921c907 |
@@ -21,6 +21,7 @@ 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.reading import ReadingRepository
|
||||
@@ -28,6 +29,7 @@ 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
|
||||
@@ -144,6 +146,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)]
|
||||
|
||||
|
||||
def get_recommendation_service(session: SessionDep) -> RecommendationService:
|
||||
return RecommendationService(recommendations=RecommendationRepository(session))
|
||||
|
||||
|
||||
@@ -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`.",
|
||||
},
|
||||
{
|
||||
"name": "recommendations",
|
||||
"description": (
|
||||
|
||||
@@ -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]
|
||||
@@ -1,13 +1,16 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR
|
||||
from app.api.v1.endpoints import auth, health, recommendations, sites, stats, 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"])
|
||||
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
|
||||
)
|
||||
api_router.include_router(
|
||||
recommendations.router,
|
||||
prefix="/recommendations",
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -921,6 +921,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/recommendations": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -1135,6 +1239,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": {
|
||||
@@ -1738,6 +1948,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`."
|
||||
},
|
||||
{
|
||||
"name": "recommendations",
|
||||
"description": "Consultation des recommandations issues des alertes. Accessible à partir du rôle `lecteur`."
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
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
|
||||
from app.schemas.alert import AlertSeverity
|
||||
|
||||
|
||||
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, AlertSeverity.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() == []
|
||||
@@ -31,6 +31,7 @@ ROUTES_A_ROLE = {
|
||||
("POST", "/api/v1/users/{id}/password-reset"),
|
||||
("GET", "/api/v1/sites"),
|
||||
("GET", "/api/v1/sites/{site_id}"),
|
||||
("GET", "/api/v1/alerts"),
|
||||
("GET", "/api/v1/recommendations"),
|
||||
("GET", "/api/v1/recommendations/{recommendation_id}"),
|
||||
("GET", "/api/v1/stats/summary"),
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
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 app.schemas.alert import AlertSeverity
|
||||
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=AlertSeverity.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) == []
|
||||
@@ -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")]
|
||||
@@ -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 | `/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 |
|
||||
@@ -156,10 +157,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
|
||||
@@ -245,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`/`recommendations`/`stats`
|
||||
(`reading`, `dataset`, `prediction`, `alert`) :
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user