diff --git a/apps/backend/README.md b/apps/backend/README.md
index 12fd9ba..7be51b8 100644
--- a/apps/backend/README.md
+++ b/apps/backend/README.md
@@ -103,6 +103,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/v1/endpoints/health.py b/apps/backend/app/api/v1/endpoints/health.py
index bf6b2ee..fab0a1e 100644
--- a/apps/backend/app/api/v1/endpoints/health.py
+++ b/apps/backend/app/api/v1/endpoints/health.py
@@ -26,7 +26,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..c71ec7f
--- /dev/null
+++ b/apps/backend/app/api/v1/endpoints/sites.py
@@ -0,0 +1,24 @@
+from fastapi import APIRouter, HTTPException, status
+
+from app.api.deps import LecteurDep, SiteServiceDep
+from app.schemas.site import SiteResponse
+from app.services.site import SiteNotFoundError
+
+router = APIRouter()
+
+
+@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")
+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 76e6f28..4d151be 100644
--- a/apps/backend/app/api/v1/router.py
+++ b/apps/backend/app/api/v1/router.py
@@ -1,8 +1,9 @@
from fastapi import APIRouter
-from app.api.v1.endpoints import auth, health, users
+from app.api.v1.endpoints import auth, health, sites, users
api_router = APIRouter()
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"])
+api_router.include_router(sites.router, prefix="/sites", tags=["sites"])
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/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 8688a6a..2972a24 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` | oui | Crée un compte, rend un mot de passe provisoire. `admin` |
| PATCH | `/api/v1/users/{id}` | oui | Change le rôle ou l'activation. `admin` |
| POST | `/api/v1/users/{id}/password-reset` | oui | Réinitialise et ferme les sessions. `admin` |
+| GET | `/api/v1/sites` | oui | Liste les sites. `lecteur` |
+| GET | `/api/v1/sites/{site_id}` | oui | Décrit un site. `lecteur` |
| GET | `/metrics` | non | Format Prometheus. Jeton requis si `APP_METRICS_TOKEN` est posé |
| GET | `/docs`, `/redoc`, `/openapi.json` | non | Fermés en `staging` et en `prod` |
@@ -148,7 +150,14 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke
é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/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. |