feat(backend): ajoute les endpoints GET /sites et GET /sites/{site_id}

This commit is contained in:
Dorian PESCE
2026-09-16 11:03:06 +02:00
parent fc6600aeaf
commit 50dddf952b
14 changed files with 376 additions and 11 deletions
+2
View File
@@ -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` | Liste et crée des comptes | `admin` |
| `/api/v1/users/{id}` | Change le rôle ou l'activation | `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/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` | | `/metrics` | Métriques au format Prometheus | jeton si `APP_METRICS_TOKEN` |
| `/docs`, `/openapi.json` | Documentation, fermée en `staging` et `prod` | public sinon | | `/docs`, `/openapi.json` | Documentation, fermée en `staging` et `prod` | public sinon |
+9
View File
@@ -24,8 +24,10 @@ 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.refresh_token import RefreshTokenRepository from app.repositories.refresh_token import RefreshTokenRepository
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.user import UserService from app.services.user import UserService
SessionDep = Annotated[AsyncSession, Depends(get_session)] SessionDep = Annotated[AsyncSession, Depends(get_session)]
@@ -131,6 +133,13 @@ def get_user_service(
UserServiceDep = Annotated[UserService, Depends(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( async def get_current_principal(
credentials: CredentialsDep, credentials: CredentialsDep,
session: SessionDep, session: SessionDep,
@@ -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)
+2 -1
View File
@@ -1,8 +1,9 @@
from fastapi import APIRouter 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 = APIRouter()
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"]) api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(sites.router, prefix="/sites", tags=["sites"])
+19
View File
@@ -0,0 +1,19 @@
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)
return await self._session.scalar(requete)
+12
View File
@@ -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
+26
View File
@@ -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
+141
View File
@@ -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
+14
View File
@@ -1,3 +1,4 @@
from collections.abc import Sequence
from typing import Any from typing import Any
from app.core.config import Settings 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: class FakeSession:
"""Session factice : renvoie `result`, ou leve `failure` si elle est fournie.""" """Session factice : renvoie `result`, ou leve `failure` si elle est fournie."""
@@ -25,6 +36,9 @@ class FakeSession:
async def execute(self, *_: object, **__: object) -> object: async def execute(self, *_: object, **__: object) -> object:
return self._repondre() return self._repondre()
async def scalars(self, *_: object, **__: object) -> FakeScalars:
return FakeScalars(self._repondre() or [])
def _repondre(self) -> object: def _repondre(self) -> object:
if self._failure is not None: if self._failure is not None:
raise self._failure raise self._failure
@@ -0,0 +1,58 @@
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)
await creer(session, site_id=f"zz-{identifiant()}")
await creer(session, site_id=f"aa-{identifiant()}")
sites = await depot.list_all()
identifiants = [site.site_id for site in sites]
await session.rollback()
assert identifiants == sorted(identifiants)
+49
View File
@@ -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")
+2 -2
View File
@@ -74,9 +74,9 @@ collecteur ne vient le lire.
| Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement | | 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 | | 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 | | 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 | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
| ETL | Apache Airflow | `etl/airflow` | `Cible` | Rien | | ETL | Apache Airflow | `etl/airflow` | `Cible` | Rien |
+14 -5
View File
@@ -12,11 +12,11 @@ Les quatre couches existent désormais, portées par l'authentification.
```mermaid ```mermaid
flowchart TB flowchart TB
ep["endpoints<br/>health, auth, users"] ep["endpoints<br/>health, auth, users, sites"]
sc["schemas<br/>Pydantic"] sc["schemas<br/>Pydantic"]
sv["services<br/>AuthService, UserService"] sv["services<br/>AuthService, UserService,<br/>SiteService"]
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log"] rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site"]
md["models<br/>4 tables"] md["models<br/>10 tables"]
db[("PostgreSQL")] db[("PostgreSQL")]
ep --> sc 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` | | 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` | | 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` | | 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 | `/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` | | 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 é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. 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). [31-contrat-authentification.md](31-contrat-authentification.md).
### `/health/ready` ### `/health/ready`
+4 -3
View File
@@ -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, 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. 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 Statut : `Fait` pour le périmètre authentification et autorisation. `GET /sites` et
n'existent pas encore, donc plusieurs lignes resteront à compléter. `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 ## Contrôles en place
@@ -48,7 +49,7 @@ règles Bandit. Ajouter Bandit à la CI serait redondant, contrairement à ce qu
| Item | État | Raison | | 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. | | **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. | | **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. | | **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. |