Compare commits

...
Author SHA1 Message Date
Dorian PESCE c7490d01b3 Merge remote-tracking branch 'origin/dev' into feat/endpoint-recommandations 2026-09-16 14:03:24 +02:00
Johan LEROYandGitHub 523b623dc1 Merge pull request #79 from ineszang/feat/stats-summary
feat(backend): expose GET /api/v1/stats/summary
2026-09-16 13:37:47 +02:00
Johan LEROY 61e031fc16 Fusionne dev dans feat/stats-summary
Resout les conflits de deps.py, router.py, repositories/site.py et
test_site.py entre l'ajout de stats et le merge de sites/openapi-contrat
sur dev. Generalise ROUTES_A_ROLE dans test_openapi.py et documente la
checklist d'ajout d'une route metier, absentes de dev au moment du fork.
2026-09-16 13:37:03 +02:00
Dorian PESCE 781644b28e feat(backend): ajoute GET /recommendations et GET /recommendations/{recommendation_id} 2026-09-16 13:30:51 +02:00
PhyriosandGitHub 56f7211f0b Merge pull request #77 from ineszang/feat/frontend-make-dev
feat(build): branche le frontend sur make dev
2026-09-16 12:34:41 +02:00
Johan LEROY 50dcb4de32 feat(backend): expose GET /api/v1/stats/summary
Ajoute le resume instantane de consommation du parc attendu par le
frontend (deja developpe contre ce contrat en mode mock). Nouveaux
SiteRepository et ReadingRepository (derniere lecture par site via
DISTINCT ON), StatsService pour l'agregation et les cas de repli
(capacite nulle, absence de lecture, data_quality inconnue), et le
endpoint lecteur-seul correspondant. Documentation des routes et du
schema des couches mises a jour.
2026-09-16 11:45:58 +02:00
23 changed files with 1219 additions and 11 deletions
+2
View File
@@ -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 |
+18
View File
@@ -23,11 +23,15 @@ from app.core.security import decode_access_token as decode_token
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
SessionDep = Annotated[AsyncSession, Depends(get_session)]
@@ -140,6 +144,20 @@ 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))
StatsServiceDep = Annotated[StatsService, Depends(get_stats_service)]
async def get_current_principal(
credentials: CredentialsDep,
session: SessionDep,
+12
View File
@@ -54,6 +54,18 @@ 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 "
"`lecteur`.",
},
]
cookie_de_rafraichissement = APIKeyCookie(
@@ -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)
@@ -0,0 +1,16 @@
from fastapi import APIRouter
from app.api.deps import LecteurDep, StatsServiceDep
from app.schemas.stats import StatsSummaryResponse
router = APIRouter()
@router.get(
"/summary",
response_model=StatsSummaryResponse,
summary="Résume la consommation instantanée du parc",
)
async def get_summary(_: LecteurDep, service: StatsServiceDep) -> StatsSummaryResponse:
resume = await service.summary()
return StatsSummaryResponse.model_validate(resume)
+8 -1
View File
@@ -1,10 +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, 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)
+21
View File
@@ -0,0 +1,21 @@
from collections.abc import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Reading
class ReadingRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def latest_by_site(self) -> Sequence[Reading]:
# `.distinct(site_id)` compile en `DISTINCT ON (site_id)` sous PostgreSQL : une seule
# ligne par site, la plus récente grâce à l'ordre composite qui suit.
requete = (
select(Reading)
.distinct(Reading.site_id)
.order_by(Reading.site_id, Reading.timestamp.desc())
)
return (await self._session.execute(requete)).scalars().all()
@@ -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
@@ -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
+26
View File
@@ -0,0 +1,26 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict
class SiteSummaryResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
site_id: str
site_name: str
current_consumption_kw: float | None
capacity_kw: float
load_percent: float | None
data_quality: Literal["good", "partial", "degraded", "critical"]
class StatsSummaryResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
timestamp: datetime
total_sites: int
total_consumption_kw: float
total_capacity_kw: float
average_load_percent: float
sites: list[SiteSummaryResponse]
@@ -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
+81
View File
@@ -0,0 +1,81 @@
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Literal
from app.models.energy import Reading, Site
from app.repositories.reading import ReadingRepository
from app.repositories.site import SiteRepository
DataQuality = Literal["good", "partial", "degraded", "critical"]
QUALITES_CONNUES: frozenset[str] = frozenset({"good", "partial", "degraded", "critical"})
@dataclass(frozen=True, slots=True)
class SiteConsumption:
site_id: str
site_name: str
current_consumption_kw: float | None
capacity_kw: float
load_percent: float | None
data_quality: DataQuality
@dataclass(frozen=True, slots=True)
class ConsumptionSummary:
timestamp: datetime
total_sites: int
total_consumption_kw: float
total_capacity_kw: float
average_load_percent: float
sites: list[SiteConsumption]
class StatsService:
def __init__(self, sites: SiteRepository, readings: ReadingRepository) -> None:
self._sites = sites
self._readings = readings
async def summary(self) -> ConsumptionSummary:
sites = await self._sites.list_all()
dernieres = {lecture.site_id: lecture for lecture in await self._readings.latest_by_site()}
resumes = [self._resume_site(site, dernieres.get(site.site_id)) for site in sites]
consommation_totale = sum(r.current_consumption_kw or 0 for r in resumes)
capacite_totale = sum(r.capacity_kw for r in resumes)
return ConsumptionSummary(
timestamp=datetime.now(UTC),
total_sites=len(resumes),
total_consumption_kw=consommation_totale,
total_capacity_kw=capacite_totale,
average_load_percent=(
consommation_totale / capacite_totale * 100 if capacite_totale > 0 else 0
),
sites=resumes,
)
@staticmethod
def _resume_site(site: Site, derniere: Reading | None) -> SiteConsumption:
capacite = site.capacity_kw or 0
# Piège : `data_quality` est nul dès qu'un site n'a jamais reçu de lecture, ou que le
# producteur n'a pas su la qualifier. Le contrat frontend n'a pas de valeur pour ce cas,
# `critical` est la seule des quatre qui n'induit pas une confiance qu'on n'a pas.
qualite: DataQuality = "critical"
consommation = None
if derniere is not None and derniere.data_quality in QUALITES_CONNUES:
qualite = derniere.data_quality # type: ignore[assignment]
consommation = derniere.consumption_kw
charge = (
consommation / capacite * 100 if consommation is not None and capacite > 0 else None
)
return SiteConsumption(
site_id=site.site_id,
site_name=site.site_name,
current_consumption_kw=consommation,
capacity_kw=capacite,
load_percent=charge,
data_quality=qualite,
)
+350
View File
@@ -920,6 +920,209 @@
}
}
}
},
"/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": [
"stats"
],
"summary": "Résume la consommation instantanée du parc",
"operationId": "get_summary_api_v1_stats_summary_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StatsSummaryResponse"
}
}
}
},
"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": []
}
]
}
}
},
"components": {
@@ -1111,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": [
@@ -1179,6 +1421,106 @@
],
"title": "SiteResponse"
},
"SiteSummaryResponse": {
"properties": {
"site_id": {
"type": "string",
"title": "Site Id"
},
"site_name": {
"type": "string",
"title": "Site Name"
},
"current_consumption_kw": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Current Consumption Kw"
},
"capacity_kw": {
"type": "number",
"title": "Capacity Kw"
},
"load_percent": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Load Percent"
},
"data_quality": {
"type": "string",
"enum": [
"good",
"partial",
"degraded",
"critical"
],
"title": "Data Quality"
}
},
"type": "object",
"required": [
"site_id",
"site_name",
"current_consumption_kw",
"capacity_kw",
"load_percent",
"data_quality"
],
"title": "SiteSummaryResponse"
},
"StatsSummaryResponse": {
"properties": {
"timestamp": {
"type": "string",
"format": "date-time",
"title": "Timestamp"
},
"total_sites": {
"type": "integer",
"title": "Total Sites"
},
"total_consumption_kw": {
"type": "number",
"title": "Total Consumption Kw"
},
"total_capacity_kw": {
"type": "number",
"title": "Total Capacity Kw"
},
"average_load_percent": {
"type": "number",
"title": "Average Load Percent"
},
"sites": {
"items": {
"$ref": "#/components/schemas/SiteSummaryResponse"
},
"type": "array",
"title": "Sites"
}
},
"type": "object",
"required": [
"timestamp",
"total_sites",
"total_consumption_kw",
"total_capacity_kw",
"average_load_percent",
"sites"
],
"title": "StatsSummaryResponse"
},
"TemporaryPasswordResponse": {
"properties": {
"user": {
@@ -1395,6 +1737,14 @@
{
"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`."
}
]
}
+16 -2
View File
@@ -22,6 +22,20 @@ ORIGINE_VERIFIEE = {
("POST", "/api/v1/auth/password"),
}
# Toute route derrière `require_role` (LecteurDep, OperateurDep, AdminDep) peut rendre 403 pour
# `password_change_required`, pas seulement les routes `admin`.
ROUTES_A_ROLE = {
("GET", "/api/v1/users"),
("POST", "/api/v1/users"),
("PATCH", "/api/v1/users/{id}"),
("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"),
}
@pytest.fixture(scope="module")
def schema() -> dict[str, Any]:
@@ -55,11 +69,11 @@ def test_every_route_demanding_an_identity_says_how_it_refuses(schema: dict[str,
assert muettes == []
def test_every_administration_route_documents_the_role_refusal(schema: dict[str, Any]) -> None:
def test_every_role_guarded_route_documents_the_role_refusal(schema: dict[str, Any]) -> None:
sans_403 = [
(methode, chemin)
for methode, chemin, operation in operations(schema)
if "users" in operation.get("tags", []) and "403" not in operation["responses"]
if (methode, chemin) in ROUTES_A_ROLE and "403" not in operation["responses"]
]
assert sans_403 == []
@@ -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
+73
View File
@@ -0,0 +1,73 @@
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_stats_service
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.services.stats import ConsumptionSummary, SiteConsumption
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,
)
class FauxService:
def __init__(self) -> None:
self.resume = ConsumptionSummary(
timestamp=datetime.now(UTC),
total_sites=1,
total_consumption_kw=87.34,
total_capacity_kw=200,
average_load_percent=43.7,
sites=[
SiteConsumption(
site_id="SITE001",
site_name="Bureau Paris La Défense",
current_consumption_kw=87.34,
capacity_kw=200,
load_percent=43.7,
data_quality="good",
)
],
)
async def summary(self) -> ConsumptionSummary:
return self.resume
@pytest.fixture
def servi(app: FastAPI) -> Iterator[Callable[[], FauxService]]:
def installe() -> FauxService:
service = FauxService()
app.dependency_overrides[get_stats_service] = lambda: service
app.dependency_overrides[get_current_principal] = lambda: principal()
return service
yield installe
app.dependency_overrides.pop(get_stats_service, None)
app.dependency_overrides.pop(get_current_principal, None)
async def test_get_summary_returns_the_service_result(
servi: Callable[[], FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/stats/summary")
assert response.status_code == 200
corps = response.json()
assert corps["total_sites"] == 1
assert corps["sites"][0]["site_id"] == "SITE001"
assert corps["sites"][0]["data_quality"] == "good"
@@ -0,0 +1,72 @@
import uuid
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Reading, Site
from app.repositories.reading import ReadingRepository
pytestmark = pytest.mark.integration
def identifiant() -> str:
return f"SITE-{uuid.uuid4().hex[:8]}"
def lecture(site_id: str, *, timestamp: datetime, consumption_kw: float) -> Reading:
return Reading(
site_id=site_id,
timestamp=timestamp,
source="api_current",
consumption_kw=consumption_kw,
data_quality="good",
raw_data={},
)
async def test_latest_by_site_keeps_only_the_most_recent_reading(session: AsyncSession) -> None:
site_id = identifiant()
maintenant = datetime.now(UTC)
session.add(Site(site_id=site_id, site_name="Site", site_type="bureau", capacity_kw=100))
await session.flush()
session.add_all(
[
lecture(site_id, timestamp=maintenant - timedelta(hours=1), consumption_kw=10),
lecture(site_id, timestamp=maintenant, consumption_kw=42),
]
)
await session.flush()
depot = ReadingRepository(session)
resultats = await depot.latest_by_site()
consommations = [r.consumption_kw for r in resultats if r.site_id == site_id]
await session.rollback()
assert consommations == [42]
async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) -> None:
premier, second = identifiant(), identifiant()
maintenant = datetime.now(UTC)
session.add_all(
[
Site(site_id=premier, site_name="A", site_type="bureau", capacity_kw=100),
Site(site_id=second, site_name="B", site_type="bureau", capacity_kw=200),
]
)
await session.flush()
session.add_all(
[
lecture(premier, timestamp=maintenant, consumption_kw=10),
lecture(second, timestamp=maintenant, consumption_kw=20),
]
)
await session.flush()
depot = ReadingRepository(session)
resultats = await depot.latest_by_site()
identifiants = {r.site_id for r in resultats if r.site_id in (premier, second)}
await session.rollback()
assert identifiants == {premier, second}
@@ -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)
@@ -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)
+107
View File
@@ -0,0 +1,107 @@
from dataclasses import dataclass
from app.services.stats import StatsService
@dataclass
class FauxSite:
site_id: str
site_name: str
capacity_kw: float | None
@dataclass
class FauxLecture:
site_id: str
consumption_kw: float | None
data_quality: str | None
class FauxDepotSites:
def __init__(self, sites: list[FauxSite]) -> None:
self._sites = sites
async def list_all(self) -> list[FauxSite]:
return self._sites
class FauxDepotLectures:
def __init__(self, lectures: list[FauxLecture]) -> None:
self._lectures = lectures
async def latest_by_site(self) -> list[FauxLecture]:
return self._lectures
async def test_summary_computes_totals_and_the_average_load() -> None:
service = StatsService(
sites=FauxDepotSites([FauxSite("A", "Site A", 200), FauxSite("B", "Site B", 800)]), # type: ignore[arg-type]
readings=FauxDepotLectures( # type: ignore[arg-type]
[
FauxLecture("A", 100, "good"),
FauxLecture("B", 400, "good"),
]
),
)
resume = await service.summary()
assert resume.total_sites == 2
assert resume.total_consumption_kw == 500
assert resume.total_capacity_kw == 1000
assert resume.average_load_percent == 50
par_site = {site.site_id: site for site in resume.sites}
assert par_site["A"].load_percent == 50
assert par_site["B"].load_percent == 50
async def test_summary_treats_a_site_without_any_reading_as_critical() -> None:
service = StatsService(
sites=FauxDepotSites([FauxSite("A", "Site A", 200)]), # type: ignore[arg-type]
readings=FauxDepotLectures([]), # type: ignore[arg-type]
)
resume = await service.summary()
site = resume.sites[0]
assert site.data_quality == "critical"
assert site.current_consumption_kw is None
assert site.load_percent is None
async def test_summary_treats_a_reading_with_an_unknown_quality_as_critical() -> None:
service = StatsService(
sites=FauxDepotSites([FauxSite("A", "Site A", 200)]), # type: ignore[arg-type]
readings=FauxDepotLectures([FauxLecture("A", 50, None)]), # type: ignore[arg-type]
)
resume = await service.summary()
site = resume.sites[0]
assert site.data_quality == "critical"
assert site.current_consumption_kw is None
async def test_summary_exposes_a_missing_capacity_as_zero_without_dividing_by_it() -> None:
service = StatsService(
sites=FauxDepotSites([FauxSite("A", "Site A", None)]), # type: ignore[arg-type]
readings=FauxDepotLectures([FauxLecture("A", 50, "good")]), # type: ignore[arg-type]
)
resume = await service.summary()
site = resume.sites[0]
assert site.capacity_kw == 0
assert site.current_consumption_kw == 50
assert site.load_percent is None
async def test_summary_returns_zero_average_load_when_no_site_has_a_capacity() -> None:
service = StatsService(
sites=FauxDepotSites([FauxSite("A", "Site A", None)]), # type: ignore[arg-type]
readings=FauxDepotLectures([]), # type: ignore[arg-type]
)
resume = await service.summary()
assert resume.average_load_percent == 0
+1 -1
View File
@@ -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 |
+27 -4
View File
@@ -12,10 +12,10 @@ Les quatre couches existent désormais, portées par l'authentification.
```mermaid
flowchart TB
ep["endpoints<br/>health, auth, users, sites"]
ep["endpoints<br/>health, auth, users, sites,<br/>recommendations, stats"]
sc["schemas<br/>Pydantic"]
sv["services<br/>AuthService, UserService,<br/>SiteService"]
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site"]
sv["services<br/>AuthService, UserService,<br/>SiteService, RecommendationService,<br/>StatsService"]
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site, recommendation, reading"]
md["models<br/>10 tables"]
db[("PostgreSQL")]
@@ -142,6 +142,9 @@ 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` | |
@@ -160,7 +163,12 @@ 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. Le contrat détaillé pour le frontend est dans
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).
### `/health/ready`
@@ -235,6 +243,21 @@ Les modèles de `app/schemas/errors.py` décrivent ce que les gestionnaires renv
`loc` n'apparaît dans aucune réponse de cette API : `validation_error_handler()` rend `champ` et
`type`. Renommer un champ là-bas sans le faire ici rend la documentation fausse en silence.
### Ajouter une route métier
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
(404, 409, ...) directement sur l'endpoint qui les rend.
2. Décrire son tag dans `TAGS`.
3. Si elle passe par `require_role` (`LecteurDep`/`OperateurDep`/`AdminDep`), l'ajouter à
`ROUTES_A_ROLE` dans `tests/api/test_openapi.py`. Si elle passe par `require_trusted_origin`,
l'ajouter à `ORIGINE_VERIFIEE`. **Ces deux listes sont maintenues à la main, pas dérivées** :
une route oubliée n'y est pas détectée automatiquement.
4. `make openapi`, puis `uv run pytest tests/api/test_openapi.py`.
## Sécurité
Voir la vue consolidée dans [00-vue-ensemble.md](00-vue-ensemble.md) et les décisions dans les
+3 -3
View File
@@ -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. |