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. ![Schéma de données EnerVision](images/EnerVision-schema-donnees.png) -*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. |