diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py
index f16d167..1eb67ad 100644
--- a/apps/backend/app/api/deps.py
+++ b/apps/backend/app/api/deps.py
@@ -23,11 +23,13 @@ from app.core.security import decode_access_token as decode_token
from app.db.session import get_session
from app.repositories.audit_log import AuditLogRepository
from app.repositories.login_attempt import LoginAttemptRepository
+from app.repositories.reading import ReadingRepository
from app.repositories.refresh_token import RefreshTokenRepository
from app.repositories.site import SiteRepository
from app.repositories.user import UserRepository
from app.services.auth import AuthService, LoginPolicy
from app.services.site import SiteService
+from app.services.stats import StatsService
from app.services.user import UserService
SessionDep = Annotated[AsyncSession, Depends(get_session)]
@@ -140,6 +142,13 @@ def get_site_service(session: SessionDep) -> SiteService:
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(
credentials: CredentialsDep,
session: SessionDep,
diff --git a/apps/backend/app/api/openapi.py b/apps/backend/app/api/openapi.py
index 1eaa3a9..ae7d1f2 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": "stats",
+ "description": "Statistiques agrégées de consommation. Accessible à partir du rôle "
+ "`lecteur`.",
+ },
]
cookie_de_rafraichissement = APIKeyCookie(
diff --git a/apps/backend/app/api/v1/endpoints/stats.py b/apps/backend/app/api/v1/endpoints/stats.py
new file mode 100644
index 0000000..5a8502e
--- /dev/null
+++ b/apps/backend/app/api/v1/endpoints/stats.py
@@ -0,0 +1,16 @@
+from fastapi import APIRouter
+
+from app.api.deps import LecteurDep, StatsServiceDep
+from app.schemas.stats import StatsSummaryResponse
+
+router = APIRouter()
+
+
+@router.get(
+ "/summary",
+ response_model=StatsSummaryResponse,
+ summary="Résume la consommation instantanée du parc",
+)
+async def get_summary(_: LecteurDep, service: StatsServiceDep) -> StatsSummaryResponse:
+ resume = await service.summary()
+ return StatsSummaryResponse.model_validate(resume)
diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py
index edb035b..c336af1 100644
--- a/apps/backend/app/api/v1/router.py
+++ b/apps/backend/app/api/v1/router.py
@@ -1,10 +1,11 @@
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 auth, health, 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(stats.router, prefix="/stats", tags=["stats"], responses=REPONSES_LECTEUR)
diff --git a/apps/backend/app/repositories/reading.py b/apps/backend/app/repositories/reading.py
new file mode 100644
index 0000000..5424b46
--- /dev/null
+++ b/apps/backend/app/repositories/reading.py
@@ -0,0 +1,21 @@
+from collections.abc import Sequence
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.energy import Reading
+
+
+class ReadingRepository:
+ def __init__(self, session: AsyncSession) -> None:
+ self._session = session
+
+ async def latest_by_site(self) -> Sequence[Reading]:
+ # `.distinct(site_id)` compile en `DISTINCT ON (site_id)` sous PostgreSQL : une seule
+ # ligne par site, la plus récente grâce à l'ordre composite qui suit.
+ requete = (
+ select(Reading)
+ .distinct(Reading.site_id)
+ .order_by(Reading.site_id, Reading.timestamp.desc())
+ )
+ return (await self._session.execute(requete)).scalars().all()
diff --git a/apps/backend/app/schemas/stats.py b/apps/backend/app/schemas/stats.py
new file mode 100644
index 0000000..b119d50
--- /dev/null
+++ b/apps/backend/app/schemas/stats.py
@@ -0,0 +1,26 @@
+from datetime import datetime
+from typing import Literal
+
+from pydantic import BaseModel, ConfigDict
+
+
+class SiteSummaryResponse(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ site_id: str
+ site_name: str
+ current_consumption_kw: float | None
+ capacity_kw: float
+ load_percent: float | None
+ data_quality: Literal["good", "partial", "degraded", "critical"]
+
+
+class StatsSummaryResponse(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ timestamp: datetime
+ total_sites: int
+ total_consumption_kw: float
+ total_capacity_kw: float
+ average_load_percent: float
+ sites: list[SiteSummaryResponse]
diff --git a/apps/backend/app/services/stats.py b/apps/backend/app/services/stats.py
new file mode 100644
index 0000000..c98eace
--- /dev/null
+++ b/apps/backend/app/services/stats.py
@@ -0,0 +1,81 @@
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from typing import Literal
+
+from app.models.energy import Reading, Site
+from app.repositories.reading import ReadingRepository
+from app.repositories.site import SiteRepository
+
+DataQuality = Literal["good", "partial", "degraded", "critical"]
+
+QUALITES_CONNUES: frozenset[str] = frozenset({"good", "partial", "degraded", "critical"})
+
+
+@dataclass(frozen=True, slots=True)
+class SiteConsumption:
+ site_id: str
+ site_name: str
+ current_consumption_kw: float | None
+ capacity_kw: float
+ load_percent: float | None
+ data_quality: DataQuality
+
+
+@dataclass(frozen=True, slots=True)
+class ConsumptionSummary:
+ timestamp: datetime
+ total_sites: int
+ total_consumption_kw: float
+ total_capacity_kw: float
+ average_load_percent: float
+ sites: list[SiteConsumption]
+
+
+class StatsService:
+ def __init__(self, sites: SiteRepository, readings: ReadingRepository) -> None:
+ self._sites = sites
+ self._readings = readings
+
+ async def summary(self) -> ConsumptionSummary:
+ sites = await self._sites.list_all()
+ dernieres = {lecture.site_id: lecture for lecture in await self._readings.latest_by_site()}
+
+ resumes = [self._resume_site(site, dernieres.get(site.site_id)) for site in sites]
+ consommation_totale = sum(r.current_consumption_kw or 0 for r in resumes)
+ capacite_totale = sum(r.capacity_kw for r in resumes)
+
+ return ConsumptionSummary(
+ timestamp=datetime.now(UTC),
+ total_sites=len(resumes),
+ total_consumption_kw=consommation_totale,
+ total_capacity_kw=capacite_totale,
+ average_load_percent=(
+ consommation_totale / capacite_totale * 100 if capacite_totale > 0 else 0
+ ),
+ sites=resumes,
+ )
+
+ @staticmethod
+ def _resume_site(site: Site, derniere: Reading | None) -> SiteConsumption:
+ capacite = site.capacity_kw or 0
+ # Piège : `data_quality` est nul dès qu'un site n'a jamais reçu de lecture, ou que le
+ # producteur n'a pas su la qualifier. Le contrat frontend n'a pas de valeur pour ce cas,
+ # `critical` est la seule des quatre qui n'induit pas une confiance qu'on n'a pas.
+ qualite: DataQuality = "critical"
+ consommation = None
+ if derniere is not None and derniere.data_quality in QUALITES_CONNUES:
+ qualite = derniere.data_quality # type: ignore[assignment]
+ consommation = derniere.consumption_kw
+
+ charge = (
+ consommation / capacite * 100 if consommation is not None and capacite > 0 else None
+ )
+
+ return SiteConsumption(
+ site_id=site.site_id,
+ site_name=site.site_name,
+ current_consumption_kw=consommation,
+ capacity_kw=capacite,
+ load_percent=charge,
+ data_quality=qualite,
+ )
diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json
index 3e8dc01..f52755e 100644
--- a/apps/backend/openapi.json
+++ b/apps/backend/openapi.json
@@ -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": {
@@ -1179,6 +1235,106 @@
],
"title": "SiteResponse"
},
+ "SiteSummaryResponse": {
+ "properties": {
+ "site_id": {
+ "type": "string",
+ "title": "Site Id"
+ },
+ "site_name": {
+ "type": "string",
+ "title": "Site Name"
+ },
+ "current_consumption_kw": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Current Consumption Kw"
+ },
+ "capacity_kw": {
+ "type": "number",
+ "title": "Capacity Kw"
+ },
+ "load_percent": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Load Percent"
+ },
+ "data_quality": {
+ "type": "string",
+ "enum": [
+ "good",
+ "partial",
+ "degraded",
+ "critical"
+ ],
+ "title": "Data Quality"
+ }
+ },
+ "type": "object",
+ "required": [
+ "site_id",
+ "site_name",
+ "current_consumption_kw",
+ "capacity_kw",
+ "load_percent",
+ "data_quality"
+ ],
+ "title": "SiteSummaryResponse"
+ },
+ "StatsSummaryResponse": {
+ "properties": {
+ "timestamp": {
+ "type": "string",
+ "format": "date-time",
+ "title": "Timestamp"
+ },
+ "total_sites": {
+ "type": "integer",
+ "title": "Total Sites"
+ },
+ "total_consumption_kw": {
+ "type": "number",
+ "title": "Total Consumption Kw"
+ },
+ "total_capacity_kw": {
+ "type": "number",
+ "title": "Total Capacity Kw"
+ },
+ "average_load_percent": {
+ "type": "number",
+ "title": "Average Load Percent"
+ },
+ "sites": {
+ "items": {
+ "$ref": "#/components/schemas/SiteSummaryResponse"
+ },
+ "type": "array",
+ "title": "Sites"
+ }
+ },
+ "type": "object",
+ "required": [
+ "timestamp",
+ "total_sites",
+ "total_consumption_kw",
+ "total_capacity_kw",
+ "average_load_percent",
+ "sites"
+ ],
+ "title": "StatsSummaryResponse"
+ },
"TemporaryPasswordResponse": {
"properties": {
"user": {
@@ -1395,6 +1551,10 @@
{
"name": "sites",
"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`."
}
]
}
diff --git a/apps/backend/tests/api/test_openapi.py b/apps/backend/tests/api/test_openapi.py
index 96297c0..43deb7c 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/stats/summary"),
+}
+
@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/api/test_stats.py b/apps/backend/tests/api/test_stats.py
new file mode 100644
index 0000000..8e4c439
--- /dev/null
+++ b/apps/backend/tests/api/test_stats.py
@@ -0,0 +1,73 @@
+from collections.abc import Callable, Iterator
+from datetime import UTC, datetime
+from uuid import uuid4
+
+import pytest
+from fastapi import FastAPI
+from httpx import AsyncClient
+
+from app.api.deps import get_current_principal, get_stats_service
+from app.core.principal import Principal
+from app.core.roles import AccountKind, Role
+from app.services.stats import ConsumptionSummary, SiteConsumption
+
+
+def principal(role: Role = Role.LECTEUR) -> Principal:
+ return Principal(
+ id=uuid4(),
+ email=f"{role.value}@enervision.fr",
+ role=role,
+ kind=AccountKind.HUMAIN,
+ must_change_password=False,
+ )
+
+
+class FauxService:
+ def __init__(self) -> None:
+ self.resume = ConsumptionSummary(
+ timestamp=datetime.now(UTC),
+ total_sites=1,
+ total_consumption_kw=87.34,
+ total_capacity_kw=200,
+ average_load_percent=43.7,
+ sites=[
+ SiteConsumption(
+ site_id="SITE001",
+ site_name="Bureau Paris La Défense",
+ current_consumption_kw=87.34,
+ capacity_kw=200,
+ load_percent=43.7,
+ data_quality="good",
+ )
+ ],
+ )
+
+ async def summary(self) -> ConsumptionSummary:
+ return self.resume
+
+
+@pytest.fixture
+def servi(app: FastAPI) -> Iterator[Callable[[], FauxService]]:
+ def installe() -> FauxService:
+ service = FauxService()
+ app.dependency_overrides[get_stats_service] = lambda: service
+ app.dependency_overrides[get_current_principal] = lambda: principal()
+ return service
+
+ yield installe
+ app.dependency_overrides.pop(get_stats_service, None)
+ app.dependency_overrides.pop(get_current_principal, None)
+
+
+async def test_get_summary_returns_the_service_result(
+ servi: Callable[[], FauxService], client: AsyncClient
+) -> None:
+ servi()
+
+ response = await client.get("/api/v1/stats/summary")
+
+ assert response.status_code == 200
+ corps = response.json()
+ assert corps["total_sites"] == 1
+ assert corps["sites"][0]["site_id"] == "SITE001"
+ assert corps["sites"][0]["data_quality"] == "good"
diff --git a/apps/backend/tests/repositories/test_reading.py b/apps/backend/tests/repositories/test_reading.py
new file mode 100644
index 0000000..650d49a
--- /dev/null
+++ b/apps/backend/tests/repositories/test_reading.py
@@ -0,0 +1,72 @@
+import uuid
+from datetime import UTC, datetime, timedelta
+
+import pytest
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.energy import Reading, Site
+from app.repositories.reading import ReadingRepository
+
+pytestmark = pytest.mark.integration
+
+
+def identifiant() -> str:
+ return f"SITE-{uuid.uuid4().hex[:8]}"
+
+
+def lecture(site_id: str, *, timestamp: datetime, consumption_kw: float) -> Reading:
+ return Reading(
+ site_id=site_id,
+ timestamp=timestamp,
+ source="api_current",
+ consumption_kw=consumption_kw,
+ data_quality="good",
+ raw_data={},
+ )
+
+
+async def test_latest_by_site_keeps_only_the_most_recent_reading(session: AsyncSession) -> None:
+ site_id = identifiant()
+ maintenant = datetime.now(UTC)
+ session.add(Site(site_id=site_id, site_name="Site", site_type="bureau", capacity_kw=100))
+ await session.flush()
+ session.add_all(
+ [
+ lecture(site_id, timestamp=maintenant - timedelta(hours=1), consumption_kw=10),
+ lecture(site_id, timestamp=maintenant, consumption_kw=42),
+ ]
+ )
+ await session.flush()
+ depot = ReadingRepository(session)
+
+ resultats = await depot.latest_by_site()
+ consommations = [r.consumption_kw for r in resultats if r.site_id == site_id]
+ await session.rollback()
+
+ assert consommations == [42]
+
+
+async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) -> None:
+ premier, second = identifiant(), identifiant()
+ maintenant = datetime.now(UTC)
+ session.add_all(
+ [
+ Site(site_id=premier, site_name="A", site_type="bureau", capacity_kw=100),
+ Site(site_id=second, site_name="B", site_type="bureau", capacity_kw=200),
+ ]
+ )
+ await session.flush()
+ session.add_all(
+ [
+ lecture(premier, timestamp=maintenant, consumption_kw=10),
+ lecture(second, timestamp=maintenant, consumption_kw=20),
+ ]
+ )
+ await session.flush()
+ depot = ReadingRepository(session)
+
+ resultats = await depot.latest_by_site()
+ identifiants = {r.site_id for r in resultats if r.site_id in (premier, second)}
+ await session.rollback()
+
+ assert identifiants == {premier, second}
diff --git a/apps/backend/tests/services/test_stats.py b/apps/backend/tests/services/test_stats.py
new file mode 100644
index 0000000..1962678
--- /dev/null
+++ b/apps/backend/tests/services/test_stats.py
@@ -0,0 +1,107 @@
+from dataclasses import dataclass
+
+from app.services.stats import StatsService
+
+
+@dataclass
+class FauxSite:
+ site_id: str
+ site_name: str
+ capacity_kw: float | None
+
+
+@dataclass
+class FauxLecture:
+ site_id: str
+ consumption_kw: float | None
+ data_quality: str | None
+
+
+class FauxDepotSites:
+ def __init__(self, sites: list[FauxSite]) -> None:
+ self._sites = sites
+
+ async def list_all(self) -> list[FauxSite]:
+ return self._sites
+
+
+class FauxDepotLectures:
+ def __init__(self, lectures: list[FauxLecture]) -> None:
+ self._lectures = lectures
+
+ async def latest_by_site(self) -> list[FauxLecture]:
+ return self._lectures
+
+
+async def test_summary_computes_totals_and_the_average_load() -> None:
+ service = StatsService(
+ sites=FauxDepotSites([FauxSite("A", "Site A", 200), FauxSite("B", "Site B", 800)]), # type: ignore[arg-type]
+ readings=FauxDepotLectures( # type: ignore[arg-type]
+ [
+ FauxLecture("A", 100, "good"),
+ FauxLecture("B", 400, "good"),
+ ]
+ ),
+ )
+
+ resume = await service.summary()
+
+ assert resume.total_sites == 2
+ assert resume.total_consumption_kw == 500
+ assert resume.total_capacity_kw == 1000
+ assert resume.average_load_percent == 50
+ par_site = {site.site_id: site for site in resume.sites}
+ assert par_site["A"].load_percent == 50
+ assert par_site["B"].load_percent == 50
+
+
+async def test_summary_treats_a_site_without_any_reading_as_critical() -> None:
+ service = StatsService(
+ sites=FauxDepotSites([FauxSite("A", "Site A", 200)]), # type: ignore[arg-type]
+ readings=FauxDepotLectures([]), # type: ignore[arg-type]
+ )
+
+ resume = await service.summary()
+
+ site = resume.sites[0]
+ assert site.data_quality == "critical"
+ assert site.current_consumption_kw is None
+ assert site.load_percent is None
+
+
+async def test_summary_treats_a_reading_with_an_unknown_quality_as_critical() -> None:
+ service = StatsService(
+ sites=FauxDepotSites([FauxSite("A", "Site A", 200)]), # type: ignore[arg-type]
+ readings=FauxDepotLectures([FauxLecture("A", 50, None)]), # type: ignore[arg-type]
+ )
+
+ resume = await service.summary()
+
+ site = resume.sites[0]
+ assert site.data_quality == "critical"
+ assert site.current_consumption_kw is None
+
+
+async def test_summary_exposes_a_missing_capacity_as_zero_without_dividing_by_it() -> None:
+ service = StatsService(
+ sites=FauxDepotSites([FauxSite("A", "Site A", None)]), # type: ignore[arg-type]
+ readings=FauxDepotLectures([FauxLecture("A", 50, "good")]), # type: ignore[arg-type]
+ )
+
+ resume = await service.summary()
+
+ site = resume.sites[0]
+ assert site.capacity_kw == 0
+ assert site.current_consumption_kw == 50
+ assert site.load_percent is None
+
+
+async def test_summary_returns_zero_average_load_when_no_site_has_a_capacity() -> None:
+ service = StatsService(
+ sites=FauxDepotSites([FauxSite("A", "Site A", None)]), # type: ignore[arg-type]
+ readings=FauxDepotLectures([]), # type: ignore[arg-type]
+ )
+
+ resume = await service.summary()
+
+ assert resume.average_load_percent == 0
diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md
index f48178f..64e77d9 100644
--- a/docs/architecture/20-backend.md
+++ b/docs/architecture/20-backend.md
@@ -12,10 +12,10 @@ Les quatre couches existent désormais, portées par l'authentification.
```mermaid
flowchart TB
- ep["endpoints
health, auth, users, sites"]
+ ep["endpoints
health, auth, users, sites, stats"]
sc["schemas
Pydantic"]
- sv["services
AuthService, UserService,
SiteService"]
- rp["repositories
user, refresh_token,
login_attempt, audit_log,
site"]
+ sv["services
AuthService, UserService,
SiteService, StatsService"]
+ rp["repositories
user, refresh_token,
login_attempt, audit_log,
site, reading"]
md["models
10 tables"]
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 |
| 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/stats/summary` | Résume la consommation instantanée du parc. `lecteur` | 401, 403, 500 |
| GET | `/metrics` | Format Prometheus, hors du schéma. Jeton requis si `APP_METRICS_TOKEN` est posé | |
| GET | `/docs`, `/redoc`, `/openapi.json` | Hors du schéma. Fermés en `staging` et en `prod` | |
@@ -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
`AsyncSession.scalar()` (une ligne) et `AsyncSession.scalars()` (plusieurs lignes) plutôt que par
`execute()`, ce qui la rend testable par la fixture `fake_session` au niveau endpoint sans base
-réelle. Le contrat détaillé pour le frontend est dans
+réelle. `GET /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).
### `/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
`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é
Voir la vue consolidée dans [00-vue-ensemble.md](00-vue-ensemble.md) et les décisions dans les