From 6c1f86b4ceca28197e55c970c7995b7ad37ee54b Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 08:47:10 +0200 Subject: [PATCH 1/8] docs(architecture): remet 40-data.md en accord avec le schema livre MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'avertissement affirmait qu'aucune table applicative n'existait, vingt lignes avant la liste des tables d'authentification. La section « Modèle métier » décrivait un modèle candidat que la « Modélisation détaillée » contredit depuis la livraison du schéma : elle disparaît, et le gabarit d'hypertable s'appuie désormais sur la révision réelle. Les conventions annonçaient une colonne de partitionnement nommée horodatage, alors qu'elle s'appelle timestamp. Les six tables data prennent leur nom au singulier, et les questions tranchées par le schéma sortent des questions ouvertes. --- docs/architecture/40-data.md | 109 ++++++++++++++--------------------- 1 file changed, 44 insertions(+), 65 deletions(-) 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 From 1325a75e9afec660e168d9828064f9c1498eddaf Mon Sep 17 00:00:00 2001 From: Dorian PESCE Date: Wed, 16 Sep 2026 11:01:13 +0200 Subject: [PATCH 2/8] feat(backend): ajoute les endpoints GET /sites et GET /sites/{site_id} --- apps/backend/app/api/v1/endpoints/health.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/app/api/v1/endpoints/health.py b/apps/backend/app/api/v1/endpoints/health.py index bf6b2ee..095eafa 100644 --- a/apps/backend/app/api/v1/endpoints/health.py +++ b/apps/backend/app/api/v1/endpoints/health.py @@ -26,7 +26,7 @@ async def liveness(settings: SettingsDep) -> LivenessStatus: async def readiness(session: SessionDep) -> ReadinessStatus: try: version: str | None = await session.scalar(TIMESCALEDB_VERSION) - except SQLAlchemyError, OSError: + except (SQLAlchemyError, OSError): logger.exception("Base de données injoignable") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, From fc6600aeafbaddd8795bc9a3d55f7663c572e1ca Mon Sep 17 00:00:00 2001 From: Dorian PESCE Date: Wed, 16 Sep 2026 11:02:00 +0200 Subject: [PATCH 3/8] Revert "feat(backend): ajoute les endpoints GET /sites et GET /sites/{site_id}" This reverts commit 1325a75e9afec660e168d9828064f9c1498eddaf. --- apps/backend/app/api/v1/endpoints/health.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/app/api/v1/endpoints/health.py b/apps/backend/app/api/v1/endpoints/health.py index 095eafa..bf6b2ee 100644 --- a/apps/backend/app/api/v1/endpoints/health.py +++ b/apps/backend/app/api/v1/endpoints/health.py @@ -26,7 +26,7 @@ async def liveness(settings: SettingsDep) -> LivenessStatus: async def readiness(session: SessionDep) -> ReadinessStatus: try: version: str | None = await session.scalar(TIMESCALEDB_VERSION) - except (SQLAlchemyError, OSError): + except SQLAlchemyError, OSError: logger.exception("Base de données injoignable") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, From 50dddf952b5b236fde081473ecbc7d854989bfc7 Mon Sep 17 00:00:00 2001 From: Dorian PESCE Date: Wed, 16 Sep 2026 11:03:06 +0200 Subject: [PATCH 4/8] feat(backend): ajoute les endpoints GET /sites et GET /sites/{site_id} --- apps/backend/README.md | 2 + apps/backend/app/api/deps.py | 9 ++ apps/backend/app/api/v1/endpoints/sites.py | 24 ++++ apps/backend/app/api/v1/router.py | 3 +- apps/backend/app/repositories/site.py | 19 +++ apps/backend/app/schemas/site.py | 12 ++ apps/backend/app/services/site.py | 26 ++++ apps/backend/tests/api/test_sites.py | 141 +++++++++++++++++++ apps/backend/tests/factories.py | 14 ++ apps/backend/tests/repositories/test_site.py | 58 ++++++++ apps/backend/tests/services/test_site.py | 49 +++++++ docs/architecture/00-vue-ensemble.md | 4 +- docs/architecture/20-backend.md | 19 ++- docs/architecture/owasp-traceabilite.md | 7 +- 14 files changed, 376 insertions(+), 11 deletions(-) create mode 100644 apps/backend/app/api/v1/endpoints/sites.py create mode 100644 apps/backend/app/repositories/site.py create mode 100644 apps/backend/app/schemas/site.py create mode 100644 apps/backend/app/services/site.py create mode 100644 apps/backend/tests/api/test_sites.py create mode 100644 apps/backend/tests/repositories/test_site.py create mode 100644 apps/backend/tests/services/test_site.py diff --git a/apps/backend/README.md b/apps/backend/README.md index 12fd9ba..7be51b8 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -103,6 +103,8 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie | `/api/v1/users` | Liste et crée des comptes | `admin` | | `/api/v1/users/{id}` | Change le rôle ou l'activation | `admin` | | `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions | `admin` | +| `/api/v1/sites` | Liste les sites | `lecteur` | +| `/api/v1/sites/{site_id}` | Décrit un site | `lecteur` | | `/metrics` | Métriques au format Prometheus | jeton si `APP_METRICS_TOKEN` | | `/docs`, `/openapi.json` | Documentation, fermée en `staging` et `prod` | public sinon | diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index 4dc32cb..f16d167 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -24,8 +24,10 @@ from app.db.session import get_session from app.repositories.audit_log import AuditLogRepository from app.repositories.login_attempt import LoginAttemptRepository from app.repositories.refresh_token import RefreshTokenRepository +from app.repositories.site import SiteRepository from app.repositories.user import UserRepository from app.services.auth import AuthService, LoginPolicy +from app.services.site import SiteService from app.services.user import UserService SessionDep = Annotated[AsyncSession, Depends(get_session)] @@ -131,6 +133,13 @@ def get_user_service( UserServiceDep = Annotated[UserService, Depends(get_user_service)] +def get_site_service(session: SessionDep) -> SiteService: + return SiteService(sites=SiteRepository(session)) + + +SiteServiceDep = Annotated[SiteService, Depends(get_site_service)] + + async def get_current_principal( credentials: CredentialsDep, session: SessionDep, diff --git a/apps/backend/app/api/v1/endpoints/sites.py b/apps/backend/app/api/v1/endpoints/sites.py new file mode 100644 index 0000000..c71ec7f --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/sites.py @@ -0,0 +1,24 @@ +from fastapi import APIRouter, HTTPException, status + +from app.api.deps import LecteurDep, SiteServiceDep +from app.schemas.site import SiteResponse +from app.services.site import SiteNotFoundError + +router = APIRouter() + + +@router.get("", response_model=list[SiteResponse], summary="Liste les sites") +async def list_sites(_: LecteurDep, service: SiteServiceDep) -> list[SiteResponse]: + sites = await service.list_all() + return [SiteResponse.model_validate(site) for site in sites] + + +@router.get("/{site_id}", response_model=SiteResponse, summary="Décrit un site") +async def get_site(site_id: str, _: LecteurDep, service: SiteServiceDep) -> SiteResponse: + try: + site = await service.get_by_id(site_id) + except SiteNotFoundError as erreur: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable" + ) from erreur + return SiteResponse.model_validate(site) diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py index 76e6f28..4d151be 100644 --- a/apps/backend/app/api/v1/router.py +++ b/apps/backend/app/api/v1/router.py @@ -1,8 +1,9 @@ from fastapi import APIRouter -from app.api.v1.endpoints import auth, health, users +from app.api.v1.endpoints import auth, health, sites, users api_router = APIRouter() api_router.include_router(health.router, prefix="/health", tags=["health"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(users.router, prefix="/users", tags=["users"]) +api_router.include_router(sites.router, prefix="/sites", tags=["sites"]) diff --git a/apps/backend/app/repositories/site.py b/apps/backend/app/repositories/site.py new file mode 100644 index 0000000..383a566 --- /dev/null +++ b/apps/backend/app/repositories/site.py @@ -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) diff --git a/apps/backend/app/schemas/site.py b/apps/backend/app/schemas/site.py new file mode 100644 index 0000000..82035f5 --- /dev/null +++ b/apps/backend/app/schemas/site.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel, ConfigDict + + +class SiteResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + site_id: str + site_name: str + site_type: str + location: str | None + capacity_kw: float | None + status: str | None diff --git a/apps/backend/app/services/site.py b/apps/backend/app/services/site.py new file mode 100644 index 0000000..515497a --- /dev/null +++ b/apps/backend/app/services/site.py @@ -0,0 +1,26 @@ +from collections.abc import Sequence + +from app.models.energy import Site +from app.repositories.site import SiteRepository + + +class SiteError(Exception): + pass + + +class SiteNotFoundError(SiteError): + pass + + +class SiteService: + def __init__(self, *, sites: SiteRepository) -> None: + self._sites = sites + + async def list_all(self) -> Sequence[Site]: + return await self._sites.list_all() + + async def get_by_id(self, site_id: str) -> Site: + site = await self._sites.get_by_id(site_id) + if site is None: + raise SiteNotFoundError(site_id) + return site diff --git a/apps/backend/tests/api/test_sites.py b/apps/backend/tests/api/test_sites.py new file mode 100644 index 0000000..3692565 --- /dev/null +++ b/apps/backend/tests/api/test_sites.py @@ -0,0 +1,141 @@ +from collections.abc import Callable, Iterator +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from httpx import AsyncClient + +from app.api.deps import get_current_principal, get_site_service +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.models.energy import Site +from app.services.site import SiteNotFoundError + + +def principal(role: Role = Role.LECTEUR) -> Principal: + return Principal( + id=uuid4(), + email=f"{role.value}@enervision.fr", + role=role, + kind=AccountKind.HUMAIN, + must_change_password=False, + ) + + +def site(site_id: str = "site-1") -> Site: + return Site( + site_id=site_id, + site_name="Site de test", + site_type="industriel", + location="Toulouse", + capacity_kw=42.0, + status="actif", + ) + + +class FauxService: + def __init__(self, erreur: Exception | None = None) -> None: + self._erreur = erreur + self.site = site() + + async def list_all(self) -> list[Site]: + return [self.site] + + async def get_by_id(self, site_id: str) -> Site: + if self._erreur is not None: + raise self._erreur + return self.site + + +@pytest.fixture +def lecteur_connecte(app: FastAPI) -> Iterator[None]: + app.dependency_overrides[get_current_principal] = lambda: principal() + yield + app.dependency_overrides.pop(get_current_principal, None) + + +@pytest.fixture +def servi( + app: FastAPI, lecteur_connecte: None +) -> Iterator[Callable[[Exception | None], FauxService]]: + def installe(erreur: Exception | None = None) -> FauxService: + service = FauxService(erreur) + app.dependency_overrides[get_site_service] = lambda: service + return service + + yield installe + app.dependency_overrides.pop(get_site_service, None) + + +async def test_list_sites_returns_the_sites( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/sites") + + assert response.status_code == 200 + corps = response.json() + assert corps == [ + { + "site_id": "site-1", + "site_name": "Site de test", + "site_type": "industriel", + "location": "Toulouse", + "capacity_kw": 42.0, + "status": "actif", + } + ] + + +async def test_get_site_returns_the_matching_site( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/sites/site-1") + + assert response.status_code == 200 + assert response.json()["site_id"] == "site-1" + + +async def test_get_site_returns_404_for_an_unknown_site( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi(SiteNotFoundError("site-inconnu")) + + response = await client.get("/api/v1/sites/site-inconnu") + + assert response.status_code == 404 + + +async def test_list_sites_reaches_the_repository_through_the_session( + lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient +) -> None: + fake_session(result=[site("a"), site("b")]) + + response = await client.get("/api/v1/sites") + + assert response.status_code == 200 + assert [s["site_id"] for s in response.json()] == ["a", "b"] + + +async def test_get_site_reaches_the_repository_through_the_session( + lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient +) -> None: + fake_session(result=site("a")) + + response = await client.get("/api/v1/sites/a") + + assert response.status_code == 200 + assert response.json()["site_id"] == "a" + + +async def test_get_site_returns_404_when_the_session_finds_nothing( + lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient +) -> None: + fake_session(result=None) + + response = await client.get("/api/v1/sites/inconnu") + + assert response.status_code == 404 diff --git a/apps/backend/tests/factories.py b/apps/backend/tests/factories.py index 17433c5..c606ee0 100644 --- a/apps/backend/tests/factories.py +++ b/apps/backend/tests/factories.py @@ -1,3 +1,4 @@ +from collections.abc import Sequence from typing import Any from app.core.config import Settings @@ -12,6 +13,16 @@ SETTINGS_DE_TEST: dict[str, Any] = { } +class FakeScalars: + """Resultat factice pour `.scalars()` : `.all()` renvoie les lignes fournies.""" + + def __init__(self, rows: Sequence[object]) -> None: + self._rows = rows + + def all(self) -> Sequence[object]: + return self._rows + + class FakeSession: """Session factice : renvoie `result`, ou leve `failure` si elle est fournie.""" @@ -25,6 +36,9 @@ class FakeSession: async def execute(self, *_: object, **__: object) -> object: return self._repondre() + async def scalars(self, *_: object, **__: object) -> FakeScalars: + return FakeScalars(self._repondre() or []) + def _repondre(self) -> object: if self._failure is not None: raise self._failure diff --git a/apps/backend/tests/repositories/test_site.py b/apps/backend/tests/repositories/test_site.py new file mode 100644 index 0000000..222d398 --- /dev/null +++ b/apps/backend/tests/repositories/test_site.py @@ -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) diff --git a/apps/backend/tests/services/test_site.py b/apps/backend/tests/services/test_site.py new file mode 100644 index 0000000..73ef21f --- /dev/null +++ b/apps/backend/tests/services/test_site.py @@ -0,0 +1,49 @@ +import pytest + +from app.models.energy import Site +from app.services.site import SiteNotFoundError, SiteService + + +def site(site_id: str = "site-1") -> Site: + return Site( + site_id=site_id, + site_name="Site de test", + site_type="industriel", + location="Toulouse", + capacity_kw=42.0, + status="actif", + ) + + +class FakeRepository: + def __init__(self, sites: list[Site]) -> None: + self._sites = sites + + async def list_all(self) -> list[Site]: + return self._sites + + async def get_by_id(self, site_id: str) -> Site | None: + return next((s for s in self._sites if s.site_id == site_id), None) + + +async def test_list_all_returns_the_repository_sites() -> None: + service = SiteService(sites=FakeRepository([site("a"), site("b")])) + + sites = await service.list_all() + + assert [s.site_id for s in sites] == ["a", "b"] + + +async def test_get_by_id_returns_the_matching_site() -> None: + service = SiteService(sites=FakeRepository([site("a")])) + + trouve = await service.get_by_id("a") + + assert trouve.site_id == "a" + + +async def test_get_by_id_raises_when_the_site_is_unknown() -> None: + service = SiteService(sites=FakeRepository([])) + + with pytest.raises(SiteNotFoundError): + await service.get_by_id("inconnu") diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index d083985..96fb992 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -74,9 +74,9 @@ collecteur ne vient le lire. | Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement | |---|---|---|---|---| -| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`. Aucune couche métier | +| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, `GET /sites` et `GET /sites/{site_id}` (première couche métier, endpoints → services → repositories → models) | | Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, deux services HTTP, graphiques Chart.js, données servies par des fixtures | -| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Aucune table applicative | +| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) | | Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API | | ETL | Apache Airflow | `etl/airflow` | `Cible` | Rien | diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index 8688a6a..2972a24 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -12,11 +12,11 @@ Les quatre couches existent désormais, portées par l'authentification. ```mermaid flowchart TB - ep["endpoints
health, auth, users"] + ep["endpoints
health, auth, users, sites"] sc["schemas
Pydantic"] - sv["services
AuthService, UserService"] - rp["repositories
user, refresh_token,
login_attempt, audit_log"] - md["models
4 tables"] + sv["services
AuthService, UserService,
SiteService"] + rp["repositories
user, refresh_token,
login_attempt, audit_log,
site"] + md["models
10 tables"] db[("PostgreSQL")] ep --> sc @@ -140,6 +140,8 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke | POST | `/api/v1/users` | oui | Crée un compte, rend un mot de passe provisoire. `admin` | | PATCH | `/api/v1/users/{id}` | oui | Change le rôle ou l'activation. `admin` | | POST | `/api/v1/users/{id}/password-reset` | oui | Réinitialise et ferme les sessions. `admin` | +| GET | `/api/v1/sites` | oui | Liste les sites. `lecteur` | +| GET | `/api/v1/sites/{site_id}` | oui | Décrit un site. `lecteur` | | GET | `/metrics` | non | Format Prometheus. Jeton requis si `APP_METRICS_TOKEN` est posé | | GET | `/docs`, `/redoc`, `/openapi.json` | non | Fermés en `staging` et en `prod` | @@ -148,7 +150,14 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Rendre une route publique impose donc de modifier la liste dans ce fichier de test. -Aucune route métier n'existe à ce jour. Le contrat détaillé pour le frontend est dans +`GET /sites` et `GET /sites/{site_id}` sont la première route métier, et le gabarit à réutiliser +pour les suivantes (`reading`, `dataset`, `prediction`, `alert`, `recommendation`) : les quatre +couches `endpoints → services → repositories → models` y sont toutes présentes, sur des tables +déjà créées par la révision Alembic `e6d2026091501`. Elles n'exigent que le rôle `lecteur`, +contrairement aux routes d'administration qui exigent `admin`. `SiteRepository` lit par +`AsyncSession.scalar()` (une ligne) et `AsyncSession.scalars()` (plusieurs lignes) plutôt que par +`execute()`, ce qui la rend testable par la fixture `fake_session` au niveau endpoint sans base +réelle. Le contrat détaillé pour le frontend est dans [31-contrat-authentification.md](31-contrat-authentification.md). ### `/health/ready` diff --git a/docs/architecture/owasp-traceabilite.md b/docs/architecture/owasp-traceabilite.md index ada1a45..ac4a8af 100644 --- a/docs/architecture/owasp-traceabilite.md +++ b/docs/architecture/owasp-traceabilite.md @@ -8,8 +8,9 @@ de réponse honnête. Ce qui est défendable, c'est une ligne par contrôle réellement implémenté, l'item qu'il adresse, et une section qui dit ce qui n'est pas couvert et pourquoi. -Statut : `Fait` pour le périmètre authentification et autorisation. Les endpoints métier -n'existent pas encore, donc plusieurs lignes resteront à compléter. +Statut : `Fait` pour le périmètre authentification et autorisation. `GET /sites` et +`GET /sites/{site_id}` sont les premiers endpoints métier, en lecture seule ; plusieurs lignes +resteront à compléter une fois les endpoints d'écriture posés. ## Contrôles en place @@ -48,7 +49,7 @@ règles Bandit. Ajouter Bandit à la CI serait redondant, contrairement à ce qu | Item | État | Raison | |---|---|---| -| **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site. Un opérateur du site A pourra agir sur le site B dès que les endpoints métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. | +| **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site : `GET /sites/{site_id}` répond à tout compte `lecteur` pour n'importe quel site, sans vérifier une affectation compte-site qui n'existe pas encore. Un opérateur du site A pourra agir sur le site B dès que les endpoints d'écriture métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. | | **API4, lectures de séries temporelles** | **ouvert** | Pas encore d'endpoint métier, donc ni pagination plafonnée, ni fenêtre temporelle maximale, ni `statement_timeout`. C'est la façon la plus probable dont la démonstration tombera : une requête sur dix ans d'historique suffit. | | **API8 Security Misconfiguration, transport** | **ouvert** | Pas de TLS, donc ni HSTS, ni cookie `Secure` réellement posé en production. Ils appartiennent au terminateur TLS, qui n'existe pas. | | **API10 Unsafe Consumption of APIs** | **ouvert, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse doit être traitée comme une entrée hostile : bornes physiques, taille de tableau plafonnée, timeout, et frontière d'anti-corruption. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. | From 31a9cb109f2de407d8b49f1f8edc45b9d4d3b688 Mon Sep 17 00:00:00 2001 From: Dorian PESCE Date: Wed, 16 Sep 2026 11:04:27 +0200 Subject: [PATCH 5/8] fix(backend): corrige la syntaxe except invalide de la sonde /health/ready --- apps/backend/app/api/v1/endpoints/health.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/app/api/v1/endpoints/health.py b/apps/backend/app/api/v1/endpoints/health.py index bf6b2ee..095eafa 100644 --- a/apps/backend/app/api/v1/endpoints/health.py +++ b/apps/backend/app/api/v1/endpoints/health.py @@ -26,7 +26,7 @@ async def liveness(settings: SettingsDep) -> LivenessStatus: async def readiness(session: SessionDep) -> ReadinessStatus: try: version: str | None = await session.scalar(TIMESCALEDB_VERSION) - except SQLAlchemyError, OSError: + except (SQLAlchemyError, OSError): logger.exception("Base de données injoignable") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, From 22ff1d93f4c2391007806189e46ac5b88ca4c5b8 Mon Sep 17 00:00:00 2001 From: Dorian PESCE Date: Wed, 16 Sep 2026 11:29:18 +0200 Subject: [PATCH 6/8] fix(backend): type le retour de SiteRepository.get_by_id pour mypy strict --- apps/backend/app/repositories/site.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/backend/app/repositories/site.py b/apps/backend/app/repositories/site.py index 383a566..c7abbe8 100644 --- a/apps/backend/app/repositories/site.py +++ b/apps/backend/app/repositories/site.py @@ -16,4 +16,5 @@ class SiteRepository: 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) + site: Site | None = await self._session.scalar(requete) + return site From d25e544db62713320cd5d4d5ff603dea0e8da387 Mon Sep 17 00:00:00 2001 From: Dorian PESCE Date: Wed, 16 Sep 2026 11:41:53 +0200 Subject: [PATCH 7/8] =?UTF-8?q?fix(backend):=20contourne=20un=20bug=20de?= =?UTF-8?q?=20ruff=20format=20sur=20le=20except=20=C3=A0=20deux=20types=20?= =?UTF-8?q?de=20health.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/app/api/v1/endpoints/health.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/backend/app/api/v1/endpoints/health.py b/apps/backend/app/api/v1/endpoints/health.py index 095eafa..fab0a1e 100644 --- a/apps/backend/app/api/v1/endpoints/health.py +++ b/apps/backend/app/api/v1/endpoints/health.py @@ -26,7 +26,9 @@ async def liveness(settings: SettingsDep) -> LivenessStatus: async def readiness(session: SessionDep) -> ReadinessStatus: try: version: str | None = await session.scalar(TIMESCALEDB_VERSION) - except (SQLAlchemyError, OSError): + # `# fmt: skip` contourne un bug de ruff format 0.16.7 : il retire les parenthèses de ce + # `except` à deux types, ce qui produit une syntaxe invalide (`except A, B:`). + except (SQLAlchemyError, OSError): # fmt: skip logger.exception("Base de données injoignable") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, From ad149db0cb37397fcbc16e501cd9dc93e765d268 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 11:55:03 +0200 Subject: [PATCH 8/8] fix(backend): corrige une assertion tautologique dans test_list_all_returns_the_sites_sorted_by_identifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'assertion comparait le résultat à lui-même trié, donc vraie quel que soit l'ordre réellement renvoyé par SiteRepository.list_all(). Compare désormais à des identifiants connus à l'avance. --- apps/backend/tests/repositories/test_site.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/backend/tests/repositories/test_site.py b/apps/backend/tests/repositories/test_site.py index 222d398..a9864a6 100644 --- a/apps/backend/tests/repositories/test_site.py +++ b/apps/backend/tests/repositories/test_site.py @@ -48,11 +48,12 @@ async def test_get_by_id_returns_nothing_for_an_unknown_identifier( 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()}") + 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] + identifiants = [site.site_id for site in sites if site.site_id in (premier, second)] await session.rollback() - assert identifiants == sorted(identifiants) + assert identifiants == [premier, second]