Merge branch 'dev' into feat/openapi-contrat
Complète le contrat OpenAPI de GET /sites et GET /sites/{site_id} (merges depuis dev via #78) :
tag sites décrit, REPONSES_LECTEUR (401 + 403 mot de passe provisoire) posée au niveau du
routeur, 404 et 422 documentés sur la route detail. openapi.json régénéré.
This commit is contained in:
@@ -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 |
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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`."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -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")
|
||||
@@ -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 |
|
||||
|
||||
@@ -12,11 +12,11 @@ Les quatre couches existent désormais, portées par l'authentification.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
ep["endpoints<br/>health, auth, users"]
|
||||
ep["endpoints<br/>health, auth, users, sites"]
|
||||
sc["schemas<br/>Pydantic"]
|
||||
sv["services<br/>AuthService, UserService"]
|
||||
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log"]
|
||||
md["models<br/>4 tables"]
|
||||
sv["services<br/>AuthService, UserService,<br/>SiteService"]
|
||||
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site"]
|
||||
md["models<br/>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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user