diff --git a/apps/backend/README.md b/apps/backend/README.md
index 875d8ca..91f9608 100644
--- a/apps/backend/README.md
+++ b/apps/backend/README.md
@@ -109,6 +109,8 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie
| `/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` |
+| `/api/v1/recommendations` | Liste les recommandations | `lecteur` |
+| `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation | `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 1eb67ad..a6a7f09 100644
--- a/apps/backend/app/api/deps.py
+++ b/apps/backend/app/api/deps.py
@@ -24,10 +24,12 @@ from app.db.session import get_session
from app.repositories.audit_log import AuditLogRepository
from app.repositories.login_attempt import LoginAttemptRepository
from app.repositories.reading import ReadingRepository
+from app.repositories.recommendation import RecommendationRepository
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.recommendation import RecommendationService
from app.services.site import SiteService
from app.services.stats import StatsService
from app.services.user import UserService
@@ -142,6 +144,13 @@ def get_site_service(session: SessionDep) -> SiteService:
SiteServiceDep = Annotated[SiteService, Depends(get_site_service)]
+def get_recommendation_service(session: SessionDep) -> RecommendationService:
+ return RecommendationService(recommendations=RecommendationRepository(session))
+
+
+RecommendationServiceDep = Annotated[RecommendationService, Depends(get_recommendation_service)]
+
+
def get_stats_service(session: SessionDep) -> StatsService:
return StatsService(sites=SiteRepository(session), readings=ReadingRepository(session))
diff --git a/apps/backend/app/api/openapi.py b/apps/backend/app/api/openapi.py
index ae7d1f2..399a8dd 100644
--- a/apps/backend/app/api/openapi.py
+++ b/apps/backend/app/api/openapi.py
@@ -54,6 +54,13 @@ TAGS: Final[list[dict[str, Any]]] = [
"name": "sites",
"description": "Consultation du parc de sites. Accessible à partir du rôle `lecteur`.",
},
+ {
+ "name": "recommendations",
+ "description": (
+ "Consultation des recommandations issues des alertes. Accessible à partir du rôle "
+ "`lecteur`."
+ ),
+ },
{
"name": "stats",
"description": "Statistiques agrégées de consommation. Accessible à partir du rôle "
diff --git a/apps/backend/app/api/v1/endpoints/recommendations.py b/apps/backend/app/api/v1/endpoints/recommendations.py
new file mode 100644
index 0000000..87e8be1
--- /dev/null
+++ b/apps/backend/app/api/v1/endpoints/recommendations.py
@@ -0,0 +1,40 @@
+from fastapi import APIRouter, HTTPException, status
+
+from app.api.deps import LecteurDep, RecommendationServiceDep
+from app.api.openapi import REPONSE_VALIDATION, Reponses
+from app.schemas.errors import ErrorResponse
+from app.schemas.recommendation import RecommendationResponse
+from app.services.recommendation import RecommendationNotFoundError
+
+router = APIRouter()
+
+REPONSES_INTROUVABLE: Reponses = {
+ **REPONSE_VALIDATION,
+ 404: {"model": ErrorResponse, "description": "Aucune recommandation ne porte cet identifiant."},
+}
+
+
+@router.get("", response_model=list[RecommendationResponse], summary="Liste les recommandations")
+async def list_recommendations(
+ _: LecteurDep, service: RecommendationServiceDep
+) -> list[RecommendationResponse]:
+ recommendations = await service.list_all()
+ return [RecommendationResponse.model_validate(r) for r in recommendations]
+
+
+@router.get(
+ "/{recommendation_id}",
+ response_model=RecommendationResponse,
+ summary="Décrit une recommandation",
+ responses=REPONSES_INTROUVABLE,
+)
+async def get_recommendation(
+ recommendation_id: int, _: LecteurDep, service: RecommendationServiceDep
+) -> RecommendationResponse:
+ try:
+ recommendation = await service.get_by_id(recommendation_id)
+ except RecommendationNotFoundError as erreur:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND, detail="Recommandation introuvable"
+ ) from erreur
+ return RecommendationResponse.model_validate(recommendation)
diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py
index c336af1..5642972 100644
--- a/apps/backend/app/api/v1/router.py
+++ b/apps/backend/app/api/v1/router.py
@@ -1,11 +1,17 @@
from fastapi import APIRouter
from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR
-from app.api.v1.endpoints import auth, health, sites, stats, users
+from app.api.v1.endpoints import auth, health, recommendations, sites, stats, 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)
+api_router.include_router(
+ recommendations.router,
+ prefix="/recommendations",
+ tags=["recommendations"],
+ responses=REPONSES_LECTEUR,
+)
api_router.include_router(stats.router, prefix="/stats", tags=["stats"], responses=REPONSES_LECTEUR)
diff --git a/apps/backend/app/repositories/recommendation.py b/apps/backend/app/repositories/recommendation.py
new file mode 100644
index 0000000..7870131
--- /dev/null
+++ b/apps/backend/app/repositories/recommendation.py
@@ -0,0 +1,22 @@
+from collections.abc import Sequence
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.energy import Recommendation
+
+
+class RecommendationRepository:
+ def __init__(self, session: AsyncSession) -> None:
+ self._session = session
+
+ async def list_all(self) -> Sequence[Recommendation]:
+ requete = select(Recommendation).order_by(Recommendation.recommendation_id)
+ return (await self._session.scalars(requete)).all()
+
+ async def get_by_id(self, recommendation_id: int) -> Recommendation | None:
+ requete = select(Recommendation).where(
+ Recommendation.recommendation_id == recommendation_id
+ )
+ recommendation: Recommendation | None = await self._session.scalar(requete)
+ return recommendation
diff --git a/apps/backend/app/schemas/recommendation.py b/apps/backend/app/schemas/recommendation.py
new file mode 100644
index 0000000..8764615
--- /dev/null
+++ b/apps/backend/app/schemas/recommendation.py
@@ -0,0 +1,14 @@
+from datetime import datetime
+
+from pydantic import BaseModel, ConfigDict
+
+
+class RecommendationResponse(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ recommendation_id: int
+ alert_id: int
+ action: str
+ explanation: str
+ rule_reference: str
+ created_at: datetime
diff --git a/apps/backend/app/services/recommendation.py b/apps/backend/app/services/recommendation.py
new file mode 100644
index 0000000..31115ae
--- /dev/null
+++ b/apps/backend/app/services/recommendation.py
@@ -0,0 +1,26 @@
+from collections.abc import Sequence
+
+from app.models.energy import Recommendation
+from app.repositories.recommendation import RecommendationRepository
+
+
+class RecommendationError(Exception):
+ pass
+
+
+class RecommendationNotFoundError(RecommendationError):
+ pass
+
+
+class RecommendationService:
+ def __init__(self, *, recommendations: RecommendationRepository) -> None:
+ self._recommendations = recommendations
+
+ async def list_all(self) -> Sequence[Recommendation]:
+ return await self._recommendations.list_all()
+
+ async def get_by_id(self, recommendation_id: int) -> Recommendation:
+ recommendation = await self._recommendations.get_by_id(recommendation_id)
+ if recommendation is None:
+ raise RecommendationNotFoundError(recommendation_id)
+ return recommendation
diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json
index f52755e..5393ab4 100644
--- a/apps/backend/openapi.json
+++ b/apps/backend/openapi.json
@@ -921,6 +921,153 @@
}
}
},
+ "/api/v1/recommendations": {
+ "get": {
+ "tags": [
+ "recommendations"
+ ],
+ "summary": "Liste les recommandations",
+ "operationId": "list_recommendations_api_v1_recommendations_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "items": {
+ "$ref": "#/components/schemas/RecommendationResponse"
+ },
+ "type": "array",
+ "title": "Response List Recommendations Api V1 Recommendations 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/recommendations/{recommendation_id}": {
+ "get": {
+ "tags": [
+ "recommendations"
+ ],
+ "summary": "Décrit une recommandation",
+ "operationId": "get_recommendation_api_v1_recommendations__recommendation_id__get",
+ "security": [
+ {
+ "Jeton d'accès": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "recommendation_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Recommendation Id"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RecommendationResponse"
+ }
+ }
+ }
+ },
+ "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": "Aucune recommandation ne porte cet identifiant.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/v1/stats/summary": {
"get": {
"tags": [
@@ -1167,6 +1314,45 @@
],
"title": "ReadinessStatus"
},
+ "RecommendationResponse": {
+ "properties": {
+ "recommendation_id": {
+ "type": "integer",
+ "title": "Recommendation Id"
+ },
+ "alert_id": {
+ "type": "integer",
+ "title": "Alert Id"
+ },
+ "action": {
+ "type": "string",
+ "title": "Action"
+ },
+ "explanation": {
+ "type": "string",
+ "title": "Explanation"
+ },
+ "rule_reference": {
+ "type": "string",
+ "title": "Rule Reference"
+ },
+ "created_at": {
+ "type": "string",
+ "format": "date-time",
+ "title": "Created At"
+ }
+ },
+ "type": "object",
+ "required": [
+ "recommendation_id",
+ "alert_id",
+ "action",
+ "explanation",
+ "rule_reference",
+ "created_at"
+ ],
+ "title": "RecommendationResponse"
+ },
"Role": {
"type": "string",
"enum": [
@@ -1552,6 +1738,10 @@
"name": "sites",
"description": "Consultation du parc de sites. Accessible à partir du rôle `lecteur`."
},
+ {
+ "name": "recommendations",
+ "description": "Consultation des recommandations issues des alertes. Accessible à partir du rôle `lecteur`."
+ },
{
"name": "stats",
"description": "Statistiques agrégées de consommation. Accessible à partir du rôle `lecteur`."
diff --git a/apps/backend/tests/api/test_openapi.py b/apps/backend/tests/api/test_openapi.py
index 43deb7c..838346a 100644
--- a/apps/backend/tests/api/test_openapi.py
+++ b/apps/backend/tests/api/test_openapi.py
@@ -31,6 +31,8 @@ ROUTES_A_ROLE = {
("POST", "/api/v1/users/{id}/password-reset"),
("GET", "/api/v1/sites"),
("GET", "/api/v1/sites/{site_id}"),
+ ("GET", "/api/v1/recommendations"),
+ ("GET", "/api/v1/recommendations/{recommendation_id}"),
("GET", "/api/v1/stats/summary"),
}
diff --git a/apps/backend/tests/api/test_recommendations.py b/apps/backend/tests/api/test_recommendations.py
new file mode 100644
index 0000000..d01db09
--- /dev/null
+++ b/apps/backend/tests/api/test_recommendations.py
@@ -0,0 +1,144 @@
+from collections.abc import Callable, Iterator
+from datetime import UTC, datetime
+from uuid import uuid4
+
+import pytest
+from fastapi import FastAPI
+from httpx import AsyncClient
+
+from app.api.deps import get_current_principal, get_recommendation_service
+from app.core.principal import Principal
+from app.core.roles import AccountKind, Role
+from app.models.energy import Recommendation
+from app.services.recommendation import RecommendationNotFoundError
+
+MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
+
+
+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 recommendation(recommendation_id: int = 1) -> Recommendation:
+ return Recommendation(
+ recommendation_id=recommendation_id,
+ alert_id=1,
+ action="Vérifier la consommation",
+ explanation="Pic détecté",
+ rule_reference="spike-v1",
+ created_at=MOMENT,
+ )
+
+
+class FauxService:
+ def __init__(self, erreur: Exception | None = None) -> None:
+ self._erreur = erreur
+ self.recommendation = recommendation()
+
+ async def list_all(self) -> list[Recommendation]:
+ return [self.recommendation]
+
+ async def get_by_id(self, recommendation_id: int) -> Recommendation:
+ if self._erreur is not None:
+ raise self._erreur
+ return self.recommendation
+
+
+@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_recommendation_service] = lambda: service
+ return service
+
+ yield installe
+ app.dependency_overrides.pop(get_recommendation_service, None)
+
+
+async def test_list_recommendations_returns_the_recommendations(
+ servi: Callable[..., FauxService], client: AsyncClient
+) -> None:
+ servi()
+
+ response = await client.get("/api/v1/recommendations")
+
+ assert response.status_code == 200
+ corps = response.json()
+ assert corps == [
+ {
+ "recommendation_id": 1,
+ "alert_id": 1,
+ "action": "Vérifier la consommation",
+ "explanation": "Pic détecté",
+ "rule_reference": "spike-v1",
+ "created_at": "2024-01-01T00:00:00Z",
+ }
+ ]
+
+
+async def test_get_recommendation_returns_the_matching_recommendation(
+ servi: Callable[..., FauxService], client: AsyncClient
+) -> None:
+ servi()
+
+ response = await client.get("/api/v1/recommendations/1")
+
+ assert response.status_code == 200
+ assert response.json()["recommendation_id"] == 1
+
+
+async def test_get_recommendation_returns_404_for_an_unknown_recommendation(
+ servi: Callable[..., FauxService], client: AsyncClient
+) -> None:
+ servi(RecommendationNotFoundError(404))
+
+ response = await client.get("/api/v1/recommendations/404")
+
+ assert response.status_code == 404
+
+
+async def test_list_recommendations_reaches_the_repository_through_the_session(
+ lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
+) -> None:
+ fake_session(result=[recommendation(1), recommendation(2)])
+
+ response = await client.get("/api/v1/recommendations")
+
+ assert response.status_code == 200
+ assert [r["recommendation_id"] for r in response.json()] == [1, 2]
+
+
+async def test_get_recommendation_reaches_the_repository_through_the_session(
+ lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
+) -> None:
+ fake_session(result=recommendation(1))
+
+ response = await client.get("/api/v1/recommendations/1")
+
+ assert response.status_code == 200
+ assert response.json()["recommendation_id"] == 1
+
+
+async def test_get_recommendation_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/recommendations/404")
+
+ assert response.status_code == 404
diff --git a/apps/backend/tests/repositories/test_recommendation.py b/apps/backend/tests/repositories/test_recommendation.py
new file mode 100644
index 0000000..075c9eb
--- /dev/null
+++ b/apps/backend/tests/repositories/test_recommendation.py
@@ -0,0 +1,85 @@
+import uuid
+from datetime import UTC, datetime
+
+import pytest
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.energy import Alert, Recommendation, Site
+from app.repositories.recommendation import RecommendationRepository
+
+pytestmark = pytest.mark.integration
+
+MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
+
+
+async def creer_site(session: AsyncSession) -> str:
+ site_id = f"TEST-{uuid.uuid4()}"
+ session.add(Site(site_id=site_id, site_name="Site de test", site_type="office"))
+ await session.flush()
+ return site_id
+
+
+async def creer_alerte(session: AsyncSession) -> int:
+ site_id = await creer_site(session)
+ alerte = Alert(
+ source_alert_id=str(uuid.uuid4()),
+ site_id=site_id,
+ source="api_mock",
+ timestamp=MOMENT,
+ type="spike",
+ severity="high",
+ message="Test",
+ raw_data={},
+ )
+ session.add(alerte)
+ await session.flush()
+ return alerte.alert_id
+
+
+async def creer(session: AsyncSession, **overrides: object) -> Recommendation:
+ recommendation = Recommendation(
+ alert_id=overrides.get("alert_id") or await creer_alerte(session),
+ action=overrides.get("action", "Vérifier la consommation"),
+ explanation=overrides.get("explanation", "Pic détecté"),
+ rule_reference=overrides.get("rule_reference", f"spike-{uuid.uuid4().hex[:8]}"),
+ )
+ session.add(recommendation)
+ await session.flush()
+ return recommendation
+
+
+async def test_get_by_id_returns_the_matching_recommendation(session: AsyncSession) -> None:
+ depot = RecommendationRepository(session)
+ cree = await creer(session)
+
+ trouve = await depot.get_by_id(cree.recommendation_id)
+ action = trouve.action if trouve else None
+ await session.rollback()
+
+ assert action == "Vérifier la consommation"
+
+
+async def test_get_by_id_returns_nothing_for_an_unknown_identifier(
+ session: AsyncSession,
+) -> None:
+ trouve = await RecommendationRepository(session).get_by_id(0)
+
+ assert trouve is None
+
+
+async def test_list_all_returns_the_recommendations_sorted_by_identifier(
+ session: AsyncSession,
+) -> None:
+ depot = RecommendationRepository(session)
+ premiere = await creer(session)
+ seconde = await creer(session)
+
+ recommendations = await depot.list_all()
+ identifiants = [
+ r.recommendation_id
+ for r in recommendations
+ if r.recommendation_id in (premiere.recommendation_id, seconde.recommendation_id)
+ ]
+ await session.rollback()
+
+ assert identifiants == sorted(identifiants)
diff --git a/apps/backend/tests/services/test_recommendation.py b/apps/backend/tests/services/test_recommendation.py
new file mode 100644
index 0000000..e8ed2b2
--- /dev/null
+++ b/apps/backend/tests/services/test_recommendation.py
@@ -0,0 +1,55 @@
+from datetime import UTC, datetime
+
+import pytest
+
+from app.models.energy import Recommendation
+from app.services.recommendation import RecommendationNotFoundError, RecommendationService
+
+
+def recommendation(recommendation_id: int = 1) -> Recommendation:
+ return Recommendation(
+ recommendation_id=recommendation_id,
+ alert_id=1,
+ action="Vérifier la consommation",
+ explanation="Pic détecté",
+ rule_reference="spike-v1",
+ created_at=datetime(2024, 1, 1, tzinfo=UTC),
+ )
+
+
+class FakeRepository:
+ def __init__(self, recommendations: list[Recommendation]) -> None:
+ self._recommendations = recommendations
+
+ async def list_all(self) -> list[Recommendation]:
+ return self._recommendations
+
+ async def get_by_id(self, recommendation_id: int) -> Recommendation | None:
+ return next(
+ (r for r in self._recommendations if r.recommendation_id == recommendation_id), None
+ )
+
+
+async def test_list_all_returns_the_repository_recommendations() -> None:
+ service = RecommendationService(
+ recommendations=FakeRepository([recommendation(1), recommendation(2)])
+ )
+
+ recommendations = await service.list_all()
+
+ assert [r.recommendation_id for r in recommendations] == [1, 2]
+
+
+async def test_get_by_id_returns_the_matching_recommendation() -> None:
+ service = RecommendationService(recommendations=FakeRepository([recommendation(1)]))
+
+ trouve = await service.get_by_id(1)
+
+ assert trouve.recommendation_id == 1
+
+
+async def test_get_by_id_raises_when_the_recommendation_is_unknown() -> None:
+ service = RecommendationService(recommendations=FakeRepository([]))
+
+ with pytest.raises(RecommendationNotFoundError):
+ await service.get_by_id(404)
diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md
index 96fb992..b49a70f 100644
--- a/docs/architecture/00-vue-ensemble.md
+++ b/docs/architecture/00-vue-ensemble.md
@@ -74,7 +74,7 @@ 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`, `GET /sites` et `GET /sites/{site_id}` (première couche métier, endpoints → services → repositories → models) |
+| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites` et `recommendations` en lecture (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. 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 |
diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md
index 64e77d9..d2de696 100644
--- a/docs/architecture/20-backend.md
+++ b/docs/architecture/20-backend.md
@@ -12,10 +12,10 @@ Les quatre couches existent désormais, portées par l'authentification.
```mermaid
flowchart TB
- ep["endpoints
health, auth, users, sites, stats"]
+ ep["endpoints
health, auth, users, sites,
recommendations, stats"]
sc["schemas
Pydantic"]
- sv["services
AuthService, UserService,
SiteService, StatsService"]
- rp["repositories
user, refresh_token,
login_attempt, audit_log,
site, reading"]
+ sv["services
AuthService, UserService,
SiteService, RecommendationService,
StatsService"]
+ rp["repositories
user, refresh_token,
login_attempt, audit_log,
site, recommendation, reading"]
md["models
10 tables"]
db[("PostgreSQL")]
@@ -142,6 +142,8 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke
| 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 | `/api/v1/recommendations` | Liste les recommandations. `lecteur` | 401, 403, 500 |
+| GET | `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation. `lecteur` | 401, 403, 404, 422, 500 |
| GET | `/api/v1/stats/summary` | Résume la consommation instantanée du parc. `lecteur` | 401, 403, 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` | |
@@ -161,7 +163,10 @@ déjà créées par la révision Alembic `e6d2026091501`. Elles n'exigent que le
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. `GET /stats/summary` agrège ces deux repositories (`SiteRepository`, `ReadingRepository`)
+réelle. `GET /recommendations` et `GET /recommendations/{recommendation_id}` reprennent le même
+gabarit à la lettre, `recommendation_id` étant un entier plutôt qu'un texte. Une recommandation ne
+porte pas `site_id` : elle remonte à un site par sa seule `alert_id`, `alert` n'étant pas encore
+exposée. `GET /stats/summary` agrège deux repositories (`SiteRepository`, `ReadingRepository`)
dans un service dédié plutôt que d'exposer une table : elle n'entre donc pas dans ce gabarit
route-par-table. Le contrat détaillé pour le frontend est dans
[31-contrat-authentification.md](31-contrat-authentification.md).
@@ -240,8 +245,8 @@ Les modèles de `app/schemas/errors.py` décrivent ce que les gestionnaires renv
### Ajouter une route métier
-Checklist pour toute nouvelle route sur le gabarit `sites`/`stats` (`reading`, `dataset`,
-`prediction`, `alert`, `recommendation`) :
+Checklist pour toute nouvelle route sur le gabarit `sites`/`recommendations`/`stats`
+(`reading`, `dataset`, `prediction`, `alert`) :
1. Composer ses `responses=` depuis `app/api/openapi.py` : `REPONSES_LECTEUR`/`REPONSES_ADMIN`
au niveau de l'`include_router()` du routeur, `REPONSE_VALIDATION` et les codes locaux
diff --git a/docs/architecture/owasp-traceabilite.md b/docs/architecture/owasp-traceabilite.md
index ac4a8af..15c2b51 100644
--- a/docs/architecture/owasp-traceabilite.md
+++ b/docs/architecture/owasp-traceabilite.md
@@ -9,8 +9,8 @@ Ce qui est défendable, c'est une ligne par contrôle réellement implémenté,
et une section qui dit ce qui n'est pas couvert et pourquoi.
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.
+`GET /recommendations`, chacune avec sa route de détail, 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
@@ -49,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 : `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. |
+| **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}` et `GET /recommendations/{recommendation_id}` répondent à tout compte `lecteur` pour n'importe quel site ou recommandation, 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. |