Compare commits

...
Author SHA1 Message Date
Johan LEROY 61e031fc16 Fusionne dev dans feat/stats-summary
Resout les conflits de deps.py, router.py, repositories/site.py et
test_site.py entre l'ajout de stats et le merge de sites/openapi-contrat
sur dev. Generalise ROUTES_A_ROLE dans test_openapi.py et documente la
checklist d'ajout d'une route metier, absentes de dev au moment du fork.
2026-09-16 13:37:03 +02:00
PhyriosandGitHub 56f7211f0b Merge pull request #77 from ineszang/feat/frontend-make-dev
feat(build): branche le frontend sur make dev
2026-09-16 12:34:41 +02:00
Johan LEROY 50dcb4de32 feat(backend): expose GET /api/v1/stats/summary
Ajoute le resume instantane de consommation du parc attendu par le
frontend (deja developpe contre ce contrat en mode mock). Nouveaux
SiteRepository et ReadingRepository (derniere lecture par site via
DISTINCT ON), StatsService pour l'agregation et les cas de repli
(capacite nulle, absence de lecture, data_quality inconnue), et le
endpoint lecteur-seul correspondant. Documentation des routes et du
schema des couches mises a jour.
2026-09-16 11:45:58 +02:00
13 changed files with 608 additions and 7 deletions
+9
View File
@@ -23,11 +23,13 @@ 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.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.reading import ReadingRepository
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.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.stats import StatsService
from app.services.user import UserService from app.services.user import UserService
SessionDep = Annotated[AsyncSession, Depends(get_session)] SessionDep = Annotated[AsyncSession, Depends(get_session)]
@@ -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_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( 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": "stats",
"description": "Statistiques agrégées de consommation. Accessible à partir du rôle "
"`lecteur`.",
},
] ]
cookie_de_rafraichissement = APIKeyCookie( cookie_de_rafraichissement = APIKeyCookie(
@@ -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)
+2 -1
View File
@@ -1,10 +1,11 @@
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 auth, health, sites, stats, 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(stats.router, prefix="/stats", tags=["stats"], 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 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()
+26
View File
@@ -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]
+81
View File
@@ -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,
)
+160
View File
@@ -920,6 +920,62 @@
} }
} }
} }
},
"/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": { "components": {
@@ -1179,6 +1235,106 @@
], ],
"title": "SiteResponse" "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": { "TemporaryPasswordResponse": {
"properties": { "properties": {
"user": { "user": {
@@ -1395,6 +1551,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": "stats",
"description": "Statistiques agrégées de consommation. Accessible à partir du rôle `lecteur`."
} }
] ]
} }
+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/stats/summary"),
}
@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 == []
+73
View File
@@ -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"
@@ -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}
+107
View File
@@ -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
+22 -4
View File
@@ -12,10 +12,10 @@ Les quatre couches existent désormais, portées par l'authentification.
```mermaid ```mermaid
flowchart TB flowchart TB
ep["endpoints<br/>health, auth, users, sites"] ep["endpoints<br/>health, auth, users, sites, stats"]
sc["schemas<br/>Pydantic"] sc["schemas<br/>Pydantic"]
sv["services<br/>AuthService, UserService,<br/>SiteService"] sv["services<br/>AuthService, UserService,<br/>SiteService, StatsService"]
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site"] rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site, reading"]
md["models<br/>10 tables"] md["models<br/>10 tables"]
db[("PostgreSQL")] db[("PostgreSQL")]
@@ -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/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 | `/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` | |
@@ -160,7 +161,9 @@ déjà créées par la révision Alembic `e6d2026091501`. Elles n'exigent que le
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
réelle. Le contrat détaillé pour le frontend est dans réelle. `GET /stats/summary` agrège ces 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). [31-contrat-authentification.md](31-contrat-authentification.md).
### `/health/ready` ### `/health/ready`
@@ -235,6 +238,21 @@ Les modèles de `app/schemas/errors.py` décrivent ce que les gestionnaires renv
`loc` n'apparaît dans aucune réponse de cette API : `validation_error_handler()` rend `champ` et `loc` n'apparaît dans aucune réponse de cette API : `validation_error_handler()` rend `champ` et
`type`. Renommer un champ là-bas sans le faire ici rend la documentation fausse en silence. `type`. Renommer un champ là-bas sans le faire ici rend la documentation fausse en silence.
### Ajouter une route métier
Checklist pour toute nouvelle route sur le gabarit `sites`/`stats` (`reading`, `dataset`,
`prediction`, `alert`, `recommendation`) :
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
(404, 409, ...) directement sur l'endpoint qui les rend.
2. Décrire son tag dans `TAGS`.
3. Si elle passe par `require_role` (`LecteurDep`/`OperateurDep`/`AdminDep`), l'ajouter à
`ROUTES_A_ROLE` dans `tests/api/test_openapi.py`. Si elle passe par `require_trusted_origin`,
l'ajouter à `ORIGINE_VERIFIEE`. **Ces deux listes sont maintenues à la main, pas dérivées** :
une route oubliée n'y est pas détectée automatiquement.
4. `make openapi`, puis `uv run pytest tests/api/test_openapi.py`.
## Sécurité ## Sécurité
Voir la vue consolidée dans [00-vue-ensemble.md](00-vue-ensemble.md) et les décisions dans les Voir la vue consolidée dans [00-vue-ensemble.md](00-vue-ensemble.md) et les décisions dans les