diff --git a/apps/backend/README.md b/apps/backend/README.md
index 400498c..875d8ca 100644
--- a/apps/backend/README.md
+++ b/apps/backend/README.md
@@ -107,6 +107,8 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie
| `/api/v1/users` | Liste et crée des comptes | `admin` |
| `/api/v1/users/{id}` | Change le rôle ou l'activation | `admin` |
| `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions | `admin` |
+| `/api/v1/sites` | Liste les sites | `lecteur` |
+| `/api/v1/sites/{site_id}` | Décrit un site | `lecteur` |
| `/metrics` | Métriques au format Prometheus | jeton si `APP_METRICS_TOKEN` |
| `/docs`, `/openapi.json` | Documentation, fermée en `staging` et `prod` | public sinon |
diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py
index 4dc32cb..f16d167 100644
--- a/apps/backend/app/api/deps.py
+++ b/apps/backend/app/api/deps.py
@@ -24,8 +24,10 @@ from app.db.session import get_session
from app.repositories.audit_log import AuditLogRepository
from app.repositories.login_attempt import LoginAttemptRepository
from app.repositories.refresh_token import RefreshTokenRepository
+from app.repositories.site import SiteRepository
from app.repositories.user import UserRepository
from app.services.auth import AuthService, LoginPolicy
+from app.services.site import SiteService
from app.services.user import UserService
SessionDep = Annotated[AsyncSession, Depends(get_session)]
@@ -131,6 +133,13 @@ def get_user_service(
UserServiceDep = Annotated[UserService, Depends(get_user_service)]
+def get_site_service(session: SessionDep) -> SiteService:
+ return SiteService(sites=SiteRepository(session))
+
+
+SiteServiceDep = Annotated[SiteService, Depends(get_site_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 c96e351..1eaa3a9 100644
--- a/apps/backend/app/api/openapi.py
+++ b/apps/backend/app/api/openapi.py
@@ -50,6 +50,10 @@ TAGS: Final[list[dict[str, Any]]] = [
"name": "users",
"description": "Administration des comptes. Réservé au rôle `admin`.",
},
+ {
+ "name": "sites",
+ "description": "Consultation du parc de sites. Accessible à partir du rôle `lecteur`.",
+ },
]
cookie_de_rafraichissement = APIKeyCookie(
@@ -113,6 +117,18 @@ REPONSES_ADMIN: Final[Reponses] = {
},
}
+# `lecteur` est le rôle minimum : `require_role` n'y refuse jamais un 403 pour droits
+# insuffisants, seulement pour le mot de passe provisoire.
+REPONSES_LECTEUR: Final[Reponses] = {
+ **REPONSES_AUTHENTIFIEES,
+ 403: {
+ "model": ErrorResponse,
+ "description": (
+ "Mot de passe provisoire à changer (`detail` vaut `password_change_required`)."
+ ),
+ },
+}
+
REPONSE_ORIGINE_REFUSEE: Final[Reponses] = {
403: {
"model": ErrorResponse,
diff --git a/apps/backend/app/api/v1/endpoints/health.py b/apps/backend/app/api/v1/endpoints/health.py
index e6d780a..57e4187 100644
--- a/apps/backend/app/api/v1/endpoints/health.py
+++ b/apps/backend/app/api/v1/endpoints/health.py
@@ -27,7 +27,9 @@ async def liveness(settings: SettingsDep) -> LivenessStatus:
async def readiness(session: SessionDep) -> ReadinessStatus:
try:
version: str | None = await session.scalar(TIMESCALEDB_VERSION)
- except SQLAlchemyError, OSError:
+ # `# fmt: skip` contourne un bug de ruff format 0.16.7 : il retire les parenthèses de ce
+ # `except` à deux types, ce qui produit une syntaxe invalide (`except A, B:`).
+ except (SQLAlchemyError, OSError): # fmt: skip
logger.exception("Base de données injoignable")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
diff --git a/apps/backend/app/api/v1/endpoints/sites.py b/apps/backend/app/api/v1/endpoints/sites.py
new file mode 100644
index 0000000..984dd8b
--- /dev/null
+++ b/apps/backend/app/api/v1/endpoints/sites.py
@@ -0,0 +1,36 @@
+from fastapi import APIRouter, HTTPException, status
+
+from app.api.deps import LecteurDep, SiteServiceDep
+from app.api.openapi import REPONSE_VALIDATION, Reponses
+from app.schemas.errors import ErrorResponse
+from app.schemas.site import SiteResponse
+from app.services.site import SiteNotFoundError
+
+router = APIRouter()
+
+REPONSES_INTROUVABLE: Reponses = {
+ **REPONSE_VALIDATION,
+ 404: {"model": ErrorResponse, "description": "Aucun site ne porte cet identifiant."},
+}
+
+
+@router.get("", response_model=list[SiteResponse], summary="Liste les sites")
+async def list_sites(_: LecteurDep, service: SiteServiceDep) -> list[SiteResponse]:
+ sites = await service.list_all()
+ return [SiteResponse.model_validate(site) for site in sites]
+
+
+@router.get(
+ "/{site_id}",
+ response_model=SiteResponse,
+ summary="Décrit un site",
+ responses=REPONSES_INTROUVABLE,
+)
+async def get_site(site_id: str, _: LecteurDep, service: SiteServiceDep) -> SiteResponse:
+ try:
+ site = await service.get_by_id(site_id)
+ except SiteNotFoundError as erreur:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable"
+ ) from erreur
+ return SiteResponse.model_validate(site)
diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py
index 4a35810..edb035b 100644
--- a/apps/backend/app/api/v1/router.py
+++ b/apps/backend/app/api/v1/router.py
@@ -1,9 +1,10 @@
from fastapi import APIRouter
-from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN
-from app.api.v1.endpoints import auth, health, users
+from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR
+from app.api.v1.endpoints import auth, health, sites, users
api_router = APIRouter(responses=REPONSE_SERVEUR)
api_router.include_router(health.router, prefix="/health", tags=["health"])
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(users.router, prefix="/users", tags=["users"], responses=REPONSES_ADMIN)
+api_router.include_router(sites.router, prefix="/sites", tags=["sites"], responses=REPONSES_LECTEUR)
diff --git a/apps/backend/app/repositories/site.py b/apps/backend/app/repositories/site.py
new file mode 100644
index 0000000..c7abbe8
--- /dev/null
+++ b/apps/backend/app/repositories/site.py
@@ -0,0 +1,20 @@
+from collections.abc import Sequence
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.energy import Site
+
+
+class SiteRepository:
+ def __init__(self, session: AsyncSession) -> None:
+ self._session = session
+
+ async def list_all(self) -> Sequence[Site]:
+ requete = select(Site).order_by(Site.site_id)
+ return (await self._session.scalars(requete)).all()
+
+ async def get_by_id(self, site_id: str) -> Site | None:
+ requete = select(Site).where(Site.site_id == site_id)
+ site: Site | None = await self._session.scalar(requete)
+ return site
diff --git a/apps/backend/app/schemas/site.py b/apps/backend/app/schemas/site.py
new file mode 100644
index 0000000..82035f5
--- /dev/null
+++ b/apps/backend/app/schemas/site.py
@@ -0,0 +1,12 @@
+from pydantic import BaseModel, ConfigDict
+
+
+class SiteResponse(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ site_id: str
+ site_name: str
+ site_type: str
+ location: str | None
+ capacity_kw: float | None
+ status: str | None
diff --git a/apps/backend/app/services/site.py b/apps/backend/app/services/site.py
new file mode 100644
index 0000000..515497a
--- /dev/null
+++ b/apps/backend/app/services/site.py
@@ -0,0 +1,26 @@
+from collections.abc import Sequence
+
+from app.models.energy import Site
+from app.repositories.site import SiteRepository
+
+
+class SiteError(Exception):
+ pass
+
+
+class SiteNotFoundError(SiteError):
+ pass
+
+
+class SiteService:
+ def __init__(self, *, sites: SiteRepository) -> None:
+ self._sites = sites
+
+ async def list_all(self) -> Sequence[Site]:
+ return await self._sites.list_all()
+
+ async def get_by_id(self, site_id: str) -> Site:
+ site = await self._sites.get_by_id(site_id)
+ if site is None:
+ raise SiteNotFoundError(site_id)
+ return site
diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json
index cca65d0..3e8dc01 100644
--- a/apps/backend/openapi.json
+++ b/apps/backend/openapi.json
@@ -773,6 +773,153 @@
}
}
}
+ },
+ "/api/v1/sites": {
+ "get": {
+ "tags": [
+ "sites"
+ ],
+ "summary": "Liste les sites",
+ "operationId": "list_sites_api_v1_sites_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "items": {
+ "$ref": "#/components/schemas/SiteResponse"
+ },
+ "type": "array",
+ "title": "Response List Sites Api V1 Sites 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"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "Jeton d'accès": []
+ }
+ ]
+ }
+ },
+ "/api/v1/sites/{site_id}": {
+ "get": {
+ "tags": [
+ "sites"
+ ],
+ "summary": "Décrit un site",
+ "operationId": "get_site_api_v1_sites__site_id__get",
+ "security": [
+ {
+ "Jeton d'accès": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "site_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Site Id"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SiteResponse"
+ }
+ }
+ }
+ },
+ "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"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Aucun site ne porte cet identifiant.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ }
}
},
"components": {
@@ -973,6 +1120,65 @@
],
"title": "Role"
},
+ "SiteResponse": {
+ "properties": {
+ "site_id": {
+ "type": "string",
+ "title": "Site Id"
+ },
+ "site_name": {
+ "type": "string",
+ "title": "Site Name"
+ },
+ "site_type": {
+ "type": "string",
+ "title": "Site Type"
+ },
+ "location": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Location"
+ },
+ "capacity_kw": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Capacity Kw"
+ },
+ "status": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Status"
+ }
+ },
+ "type": "object",
+ "required": [
+ "site_id",
+ "site_name",
+ "site_type",
+ "location",
+ "capacity_kw",
+ "status"
+ ],
+ "title": "SiteResponse"
+ },
"TemporaryPasswordResponse": {
"properties": {
"user": {
@@ -1185,6 +1391,10 @@
{
"name": "users",
"description": "Administration des comptes. Réservé au rôle `admin`."
+ },
+ {
+ "name": "sites",
+ "description": "Consultation du parc de sites. Accessible à partir du rôle `lecteur`."
}
]
}
diff --git a/apps/backend/tests/api/test_sites.py b/apps/backend/tests/api/test_sites.py
new file mode 100644
index 0000000..3692565
--- /dev/null
+++ b/apps/backend/tests/api/test_sites.py
@@ -0,0 +1,141 @@
+from collections.abc import Callable, Iterator
+from uuid import uuid4
+
+import pytest
+from fastapi import FastAPI
+from httpx import AsyncClient
+
+from app.api.deps import get_current_principal, get_site_service
+from app.core.principal import Principal
+from app.core.roles import AccountKind, Role
+from app.models.energy import Site
+from app.services.site import SiteNotFoundError
+
+
+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 site(site_id: str = "site-1") -> Site:
+ return Site(
+ site_id=site_id,
+ site_name="Site de test",
+ site_type="industriel",
+ location="Toulouse",
+ capacity_kw=42.0,
+ status="actif",
+ )
+
+
+class FauxService:
+ def __init__(self, erreur: Exception | None = None) -> None:
+ self._erreur = erreur
+ self.site = site()
+
+ async def list_all(self) -> list[Site]:
+ return [self.site]
+
+ async def get_by_id(self, site_id: str) -> Site:
+ if self._erreur is not None:
+ raise self._erreur
+ return self.site
+
+
+@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[[Exception | None], FauxService]]:
+ def installe(erreur: Exception | None = None) -> FauxService:
+ service = FauxService(erreur)
+ app.dependency_overrides[get_site_service] = lambda: service
+ return service
+
+ yield installe
+ app.dependency_overrides.pop(get_site_service, None)
+
+
+async def test_list_sites_returns_the_sites(
+ servi: Callable[..., FauxService], client: AsyncClient
+) -> None:
+ servi()
+
+ response = await client.get("/api/v1/sites")
+
+ assert response.status_code == 200
+ corps = response.json()
+ assert corps == [
+ {
+ "site_id": "site-1",
+ "site_name": "Site de test",
+ "site_type": "industriel",
+ "location": "Toulouse",
+ "capacity_kw": 42.0,
+ "status": "actif",
+ }
+ ]
+
+
+async def test_get_site_returns_the_matching_site(
+ servi: Callable[..., FauxService], client: AsyncClient
+) -> None:
+ servi()
+
+ response = await client.get("/api/v1/sites/site-1")
+
+ assert response.status_code == 200
+ assert response.json()["site_id"] == "site-1"
+
+
+async def test_get_site_returns_404_for_an_unknown_site(
+ servi: Callable[..., FauxService], client: AsyncClient
+) -> None:
+ servi(SiteNotFoundError("site-inconnu"))
+
+ response = await client.get("/api/v1/sites/site-inconnu")
+
+ assert response.status_code == 404
+
+
+async def test_list_sites_reaches_the_repository_through_the_session(
+ lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
+) -> None:
+ fake_session(result=[site("a"), site("b")])
+
+ response = await client.get("/api/v1/sites")
+
+ assert response.status_code == 200
+ assert [s["site_id"] for s in response.json()] == ["a", "b"]
+
+
+async def test_get_site_reaches_the_repository_through_the_session(
+ lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
+) -> None:
+ fake_session(result=site("a"))
+
+ response = await client.get("/api/v1/sites/a")
+
+ assert response.status_code == 200
+ assert response.json()["site_id"] == "a"
+
+
+async def test_get_site_returns_404_when_the_session_finds_nothing(
+ lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
+) -> None:
+ fake_session(result=None)
+
+ response = await client.get("/api/v1/sites/inconnu")
+
+ assert response.status_code == 404
diff --git a/apps/backend/tests/factories.py b/apps/backend/tests/factories.py
index 17433c5..c606ee0 100644
--- a/apps/backend/tests/factories.py
+++ b/apps/backend/tests/factories.py
@@ -1,3 +1,4 @@
+from collections.abc import Sequence
from typing import Any
from app.core.config import Settings
@@ -12,6 +13,16 @@ SETTINGS_DE_TEST: dict[str, Any] = {
}
+class FakeScalars:
+ """Resultat factice pour `.scalars()` : `.all()` renvoie les lignes fournies."""
+
+ def __init__(self, rows: Sequence[object]) -> None:
+ self._rows = rows
+
+ def all(self) -> Sequence[object]:
+ return self._rows
+
+
class FakeSession:
"""Session factice : renvoie `result`, ou leve `failure` si elle est fournie."""
@@ -25,6 +36,9 @@ class FakeSession:
async def execute(self, *_: object, **__: object) -> object:
return self._repondre()
+ async def scalars(self, *_: object, **__: object) -> FakeScalars:
+ return FakeScalars(self._repondre() or [])
+
def _repondre(self) -> object:
if self._failure is not None:
raise self._failure
diff --git a/apps/backend/tests/repositories/test_site.py b/apps/backend/tests/repositories/test_site.py
new file mode 100644
index 0000000..a9864a6
--- /dev/null
+++ b/apps/backend/tests/repositories/test_site.py
@@ -0,0 +1,59 @@
+import uuid
+
+import pytest
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.energy import Site
+from app.repositories.site import SiteRepository
+
+pytestmark = pytest.mark.integration
+
+
+def identifiant() -> str:
+ return f"site-{uuid.uuid4().hex[:12]}"
+
+
+async def creer(session: AsyncSession, **overrides: object) -> Site:
+ site = Site(
+ site_id=overrides.get("site_id", identifiant()),
+ site_name=overrides.get("site_name", "Site de test"),
+ site_type=overrides.get("site_type", "industriel"),
+ location=overrides.get("location", "Toulouse"),
+ capacity_kw=overrides.get("capacity_kw", 42.0),
+ status=overrides.get("status", "actif"),
+ )
+ session.add(site)
+ await session.flush()
+ return site
+
+
+async def test_get_by_id_returns_the_matching_site(session: AsyncSession) -> None:
+ depot = SiteRepository(session)
+ cree = await creer(session)
+
+ trouve = await depot.get_by_id(cree.site_id)
+ nom = trouve.site_name if trouve else None
+ await session.rollback()
+
+ assert nom == "Site de test"
+
+
+async def test_get_by_id_returns_nothing_for_an_unknown_identifier(
+ session: AsyncSession,
+) -> None:
+ trouve = await SiteRepository(session).get_by_id(identifiant())
+
+ assert trouve is None
+
+
+async def test_list_all_returns_the_sites_sorted_by_identifier(session: AsyncSession) -> None:
+ depot = SiteRepository(session)
+ premier, second = sorted([f"zz-{identifiant()}", f"aa-{identifiant()}"])
+ await creer(session, site_id=second)
+ await creer(session, site_id=premier)
+
+ sites = await depot.list_all()
+ identifiants = [site.site_id for site in sites if site.site_id in (premier, second)]
+ await session.rollback()
+
+ assert identifiants == [premier, second]
diff --git a/apps/backend/tests/services/test_site.py b/apps/backend/tests/services/test_site.py
new file mode 100644
index 0000000..73ef21f
--- /dev/null
+++ b/apps/backend/tests/services/test_site.py
@@ -0,0 +1,49 @@
+import pytest
+
+from app.models.energy import Site
+from app.services.site import SiteNotFoundError, SiteService
+
+
+def site(site_id: str = "site-1") -> Site:
+ return Site(
+ site_id=site_id,
+ site_name="Site de test",
+ site_type="industriel",
+ location="Toulouse",
+ capacity_kw=42.0,
+ status="actif",
+ )
+
+
+class FakeRepository:
+ def __init__(self, sites: list[Site]) -> None:
+ self._sites = sites
+
+ async def list_all(self) -> list[Site]:
+ return self._sites
+
+ async def get_by_id(self, site_id: str) -> Site | None:
+ return next((s for s in self._sites if s.site_id == site_id), None)
+
+
+async def test_list_all_returns_the_repository_sites() -> None:
+ service = SiteService(sites=FakeRepository([site("a"), site("b")]))
+
+ sites = await service.list_all()
+
+ assert [s.site_id for s in sites] == ["a", "b"]
+
+
+async def test_get_by_id_returns_the_matching_site() -> None:
+ service = SiteService(sites=FakeRepository([site("a")]))
+
+ trouve = await service.get_by_id("a")
+
+ assert trouve.site_id == "a"
+
+
+async def test_get_by_id_raises_when_the_site_is_unknown() -> None:
+ service = SiteService(sites=FakeRepository([]))
+
+ with pytest.raises(SiteNotFoundError):
+ await service.get_by_id("inconnu")
diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md
index d083985..96fb992 100644
--- a/docs/architecture/00-vue-ensemble.md
+++ b/docs/architecture/00-vue-ensemble.md
@@ -74,9 +74,9 @@ collecteur ne vient le lire.
| Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement |
|---|---|---|---|---|
-| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`. Aucune couche métier |
+| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, `GET /sites` et `GET /sites/{site_id}` (première couche métier, endpoints → services → repositories → models) |
| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, deux services HTTP, graphiques Chart.js, données servies par des fixtures |
-| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Aucune table applicative |
+| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) |
| Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée |
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
| ETL | Apache Airflow | `etl/airflow` | `Cible` | Rien |
diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md
index 7158c9a..f48178f 100644
--- a/docs/architecture/20-backend.md
+++ b/docs/architecture/20-backend.md
@@ -12,11 +12,11 @@ Les quatre couches existent désormais, portées par l'authentification.
```mermaid
flowchart TB
- ep["endpoints
health, auth, users"]
+ ep["endpoints
health, auth, users, sites"]
sc["schemas
Pydantic"]
- sv["services
AuthService, UserService"]
- rp["repositories
user, refresh_token,
login_attempt, audit_log"]
- md["models
4 tables"]
+ sv["services
AuthService, UserService,
SiteService"]
+ rp["repositories
user, refresh_token,
login_attempt, audit_log,
site"]
+ md["models
10 tables"]
db[("PostgreSQL")]
ep --> sc
@@ -140,6 +140,8 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke
| POST | `/api/v1/users` | Crée un compte, rend un mot de passe provisoire. `admin` | 401, 403, 409, 422, 500 |
| PATCH | `/api/v1/users/{id}` | Change le rôle ou l'activation. `admin` | 400, 401, 403, 404, 409, 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/{site_id}` | Décrit un site. `lecteur` | 401, 403, 404, 422, 500 |
| GET | `/metrics` | Format Prometheus, hors du schéma. Jeton requis si `APP_METRICS_TOKEN` est posé | |
| GET | `/docs`, `/redoc`, `/openapi.json` | Hors du schéma. Fermés en `staging` et en `prod` | |
@@ -151,7 +153,14 @@ 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.
-Aucune route métier n'existe à ce jour. Le contrat détaillé pour le frontend est dans
+`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`,
+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
[31-contrat-authentification.md](31-contrat-authentification.md).
### `/health/ready`
diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md
index 5774687..6566753 100644
--- a/docs/architecture/40-data.md
+++ b/docs/architecture/40-data.md
@@ -4,12 +4,12 @@ PostgreSQL 17 avec l'extension TimescaleDB. Le choix, ses alternatives et ses co
dans l'[ADR 0001](../adr/0001-postgresql-timescaledb.md), qui fait foi. Ce document décrit le
système qui en découle.
-## Avertissement
+## Ce que couvre ce document
-**Aucune table applicative n'existe à ce jour.** `Base.metadata` est vide, `app/models/` ne
-contient qu'un commentaire, l'unique révision Alembic ne crée aucune table, et aucune hypertable
-n'a été déclarée. Tout ce qui suit sous le statut `Cible` est une proposition de structure, pas un
-relevé du code. Le modèle sera arrêté au jalon J2.
+**Dix tables applicatives existent** : quatre pour l'authentification, six pour les données
+d'énergie, dont l'hypertable `reading`. Les sections marquées `Fait` relèvent le code. Celles
+marquées `Cible` décrivent ce qui n'est pas écrit, au premier rang desquelles la chaîne
+d'ingestion, les agrégats continus, la compression et la rétention.
## Trois emplacements, trois rôles
@@ -35,7 +35,7 @@ Statut : `Fait`.
- `db/init/100-extensions.sql` crée l'extension `timescaledb`.
- `db/init/110-test-database.sql` crée `enervision_test`, dont le nom est attendu en dur par
`apps/backend/tests/conftest.py`.
-- Quatre révisions Alembic. La première, `5353c0e4f094`, **ne crée aucune table** : elle
+- Cinq révisions Alembic. La première, `5353c0e4f094`, **ne crée aucune table** : elle
établit `alembic_version` et refuse de s'appliquer si l'extension manque :
```sql
@@ -48,16 +48,18 @@ Cette garde forme paire avec le 503 de `/api/v1/health/ready`. Un bootstrap saut
au démarrage de l'API : ces deux gardes le rendent visible tôt, des deux côtés.
Les trois suivantes créent les tables de l'authentification, décrites plus bas : `app_user`,
-puis `login_attempt` et `audit_log`, puis `refresh_token`.
+puis `login_attempt` et `audit_log`, puis `refresh_token`. La cinquième, `e6d2026091501`, crée
+les six tables de données décrites en fin de document et déclare l'hypertable `reading`.
## Cycle de vie d'une mesure
-Statut : `Cible`. Aucun de ces maillons n'existe.
+Statut : `Cible`, sauf l'hypertable `reading` qui existe. Ni l'ingestion, ni les agrégats
+continus, ni la compression, ni la rétention ne sont écrits.
```mermaid
flowchart LR
src["Source de mesures"] -.-> ing["Ingestion Airflow"]
- ing -.-> hy[("Hypertable mesure")]
+ ing -.-> hy[("Hypertable reading")]
hy -.-> agg[("Agrégat continu")]
hy -.-> comp["Compression"]
hy -.-> ret["Rétention"]
@@ -133,67 +135,46 @@ donc **pas** une hypertable : une politique de rétention émettrait des `DELETE
refuseraient. `login_attempt`, à l'inverse, est faite pour se purger, puisque son volume est
piloté par l'attaquant.
-## Modèle métier
-
-Statut : `Cible`. Les entités ci-dessous sont des **candidates**, à valider en J2. Elles
-s'appuient sur les gabarits de [`apps/backend/TESTING.md`](../../apps/backend/TESTING.md), qui
-évoquent déjà un modèle `Site`, un `SiteRepository` et un `ConsumptionService` exposant un
-`total_kwh(site_id)`.
-
-```mermaid
-erDiagram
- SITE ||--o{ POINT_DE_MESURE : porte
- POINT_DE_MESURE ||--o{ MESURE : produit
-
- SITE {
- int id PK
- string nom
- }
- POINT_DE_MESURE {
- int id PK
- int site_id FK
- string libelle
- string unite
- }
- MESURE {
- timestamptz horodatage PK
- int point_id PK
- double valeur
- }
-```
-
-`MESURE` est la table destinée à devenir une hypertable, partitionnée sur `horodatage`. Sa clé
-primaire doit inclure la colonne de temps : TimescaleDB l'exige, une clé sur le seul identifiant
-de point serait refusée.
-
## Gabarit de révision créant une hypertable
-Conforme à la règle de l'ADR 0001 : table et hypertable dans la même révision.
+Conforme à la règle de l'ADR 0001 : table et hypertable dans la même révision. La révision
+`e6d2026091501` en est l'exemple réel, réduit ici à l'essentiel.
```python
def upgrade() -> None:
op.create_table(
- "mesure",
- sa.Column("horodatage", sa.DateTime(timezone=True), nullable=False),
- sa.Column("point_id", sa.Integer(), sa.ForeignKey("point_de_mesure.id"), nullable=False),
- sa.Column("valeur", sa.Float(), nullable=False),
- sa.PrimaryKeyConstraint("horodatage", "point_id"),
+ "reading",
+ sa.Column("reading_id", sa.BigInteger(), autoincrement=True, nullable=False),
+ sa.Column("site_id", sa.Text(), nullable=False),
+ sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False),
+ sa.PrimaryKeyConstraint("reading_id", "timestamp"),
+ )
+ op.execute(
+ "SELECT create_hypertable('reading', by_range('timestamp'), "
+ "create_default_indexes => FALSE)"
)
- op.execute("SELECT create_hypertable('mesure', by_range('horodatage'))")
def downgrade() -> None:
- op.drop_table("mesure")
+ op.drop_table("reading")
```
+La clé primaire inclut la colonne de temps parce que TimescaleDB l'exige : toute contrainte
+unique d'une hypertable doit porter la colonne de partitionnement, et une clé sur le seul
+`reading_id` serait refusée par `create_hypertable`.
+
+`create_default_indexes => FALSE` écarte l'index que TimescaleDB pose d'office sur la seule
+colonne de temps : les index déclarés dans la révision le couvrent déjà.
+
`drop_table` suffit au retour arrière : supprimer la table supprime l'hypertable et ses partitions.
## Conventions
-- **Noms au singulier**, en minuscules, sans préfixe de table.
+- **Noms au singulier**, en minuscules, sans préfixe de table : `app_user`, `reading`.
- **Toute colonne de temps en `timestamptz`.** Jamais de `timestamp` nu : une mesure sans fuseau
devient ininterprétable dès le premier changement d'heure.
-- **La colonne de partitionnement s'appelle `horodatage`** et entre dans la clé primaire.
+- **La colonne de partitionnement entre dans la clé primaire.** Dans `reading` elle s'appelle
+ `timestamp` : c'est un nom de colonne, son type reste `timestamptz`.
- **Les politiques de rétention et de compression** vont dans `db/migrations/`, pas dans Alembic :
elles ne découlent pas du schéma applicatif.
- **Tout modèle doit être importé dans `app/models/__init__.py`**, sans quoi
@@ -201,13 +182,12 @@ def downgrade() -> None:
## Questions ouvertes
-Elles relèvent du jalon J2, « valider le périmètre retenu », et bloquent le modèle définitif.
+Elles relèvent du jalon J2, « valider le périmètre retenu ». Le schéma est livré : ce qui suit
+porte sur son exploitation, plus sur sa forme.
-- **Quelles sources de mesures**, et selon quel protocole elles sont collectées.
- **Quelle granularité** à l'ingestion : la seconde, la minute, le quart d'heure.
- **Quels agrégats continus**, et sur quelles fenêtres.
- **Quelle profondeur de rétention** en données brutes, et à partir de quand on compresse.
-- **Quelles unités** sont manipulées, et si une même table les mélange.
- **Multi-tenant ou non** : un site appartient-il à un client, et faut-il cloisonner les lectures.
## Modélisation détaillée des données
@@ -220,12 +200,11 @@ jusqu’aux recommandations proposées à l’utilisateur.
### Schéma de données
Le diagramme ci-dessous présente les tables et leurs relations.
-Il décrit une structure de conception ; les migrations correspondantes
-restent à implémenter.
+La révision `e6d2026091501` les crée.

-*Figure — Modélisation des données EnerVision.*
+*Figure : Modélisation des données EnerVision.*
### Description des tables
@@ -234,15 +213,15 @@ des données.
| Table | Rôle | Origine des informations |
|---|---|---|
-| `datasets` | Identifier les jeux historiques, retrouver leurs fichiers et conserver leurs métadonnées | Archive CSV/JSON et informations ajoutées lors de l’import |
-| `sites` | Regrouper les informations des sites : identifiant, nom, type et caractéristiques disponibles | CSV et API Mock `/api/v1/sites` |
-| `readings` | Stocker les mesures, leur provenance, leur qualité et les éventuelles valeurs imputées | CSV et API Mock `/current` et `/readings` |
-| `predictions` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d’EnerVision |
-| `alerts` | Enregistrer les alertes, leur type, leur gravité et leur message | API Mock `/alerts` et détections EnerVision |
-| `recommendations` | Proposer des actions et expliquer la règle qui les motive | Règles métier d’EnerVision |
+| `dataset` | Identifier les jeux historiques, retrouver leurs fichiers et conserver leurs métadonnées | Archive CSV/JSON et informations ajoutées lors de l’import |
+| `site` | Regrouper les informations des sites : identifiant, nom, type et caractéristiques disponibles | CSV et API Mock `/api/v1/sites` |
+| `reading` | Stocker les mesures, leur provenance, leur qualité et les éventuelles valeurs imputées | CSV et API Mock `/current` et `/readings` |
+| `prediction` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d’EnerVision |
+| `alert` | Enregistrer les alertes, leur type, leur gravité et leur message | API Mock `/alerts` et détections EnerVision |
+| `recommendation` | Proposer des actions et expliquer la règle qui les motive | Règles métier d’EnerVision |
Les anomalies historiques décrites dans les JSON sont conservées
-dans `datasets.metadata`. Elles servent à l’analyse des données
+dans `dataset.metadata`. Elles servent à l’analyse des données
et ne sont pas considérées comme des alertes actuelles.
### Relations entre les tables
diff --git a/docs/architecture/owasp-traceabilite.md b/docs/architecture/owasp-traceabilite.md
index ada1a45..ac4a8af 100644
--- a/docs/architecture/owasp-traceabilite.md
+++ b/docs/architecture/owasp-traceabilite.md
@@ -8,8 +8,9 @@ de réponse honnête.
Ce qui est défendable, c'est une ligne par contrôle réellement implémenté, l'item qu'il adresse,
et une section qui dit ce qui n'est pas couvert et pourquoi.
-Statut : `Fait` pour le périmètre authentification et autorisation. Les endpoints métier
-n'existent pas encore, donc plusieurs lignes resteront à compléter.
+Statut : `Fait` pour le périmètre authentification et autorisation. `GET /sites` et
+`GET /sites/{site_id}` sont les premiers endpoints métier, en lecture seule ; plusieurs lignes
+resteront à compléter une fois les endpoints d'écriture posés.
## Contrôles en place
@@ -48,7 +49,7 @@ règles Bandit. Ajouter Bandit à la CI serait redondant, contrairement à ce qu
| Item | État | Raison |
|---|---|---|
-| **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site. Un opérateur du site A pourra agir sur le site B dès que les endpoints métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. |
+| **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site : `GET /sites/{site_id}` répond à tout compte `lecteur` pour n'importe quel site, sans vérifier une affectation compte-site qui n'existe pas encore. Un opérateur du site A pourra agir sur le site B dès que les endpoints d'écriture métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. |
| **API4, lectures de séries temporelles** | **ouvert** | Pas encore d'endpoint métier, donc ni pagination plafonnée, ni fenêtre temporelle maximale, ni `statement_timeout`. C'est la façon la plus probable dont la démonstration tombera : une requête sur dix ans d'historique suffit. |
| **API8 Security Misconfiguration, transport** | **ouvert** | Pas de TLS, donc ni HSTS, ni cookie `Secure` réellement posé en production. Ils appartiennent au terminateur TLS, qui n'existe pas. |
| **API10 Unsafe Consumption of APIs** | **ouvert, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse doit être traitée comme une entrée hostile : bornes physiques, taille de tableau plafonnée, timeout, et frontière d'anti-corruption. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. |