Merge pull request #79 from ineszang/feat/stats-summary
feat(backend): expose GET /api/v1/stats/summary
This commit is contained in:
@@ -23,11 +23,13 @@ from app.core.security import decode_access_token as decode_token
|
|||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.repositories.audit_log import AuditLogRepository
|
from app.repositories.audit_log import AuditLogRepository
|
||||||
from app.repositories.login_attempt import LoginAttemptRepository
|
from app.repositories.login_attempt import LoginAttemptRepository
|
||||||
|
from app.repositories.reading import ReadingRepository
|
||||||
from app.repositories.refresh_token import RefreshTokenRepository
|
from app.repositories.refresh_token import RefreshTokenRepository
|
||||||
from app.repositories.site import SiteRepository
|
from app.repositories.site import SiteRepository
|
||||||
from app.repositories.user import UserRepository
|
from app.repositories.user import UserRepository
|
||||||
from app.services.auth import AuthService, LoginPolicy
|
from app.services.auth import AuthService, LoginPolicy
|
||||||
from app.services.site import SiteService
|
from app.services.site import SiteService
|
||||||
|
from app.services.stats import StatsService
|
||||||
from app.services.user import UserService
|
from app.services.user import UserService
|
||||||
|
|
||||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||||
@@ -140,6 +142,13 @@ def get_site_service(session: SessionDep) -> SiteService:
|
|||||||
SiteServiceDep = Annotated[SiteService, Depends(get_site_service)]
|
SiteServiceDep = Annotated[SiteService, Depends(get_site_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(
|
async def get_current_principal(
|
||||||
credentials: CredentialsDep,
|
credentials: CredentialsDep,
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ TAGS: Final[list[dict[str, Any]]] = [
|
|||||||
"name": "sites",
|
"name": "sites",
|
||||||
"description": "Consultation du parc de sites. Accessible à partir du rôle `lecteur`.",
|
"description": "Consultation du parc de sites. 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(
|
cookie_de_rafraichissement = APIKeyCookie(
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR
|
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, sites, stats, users
|
||||||
|
|
||||||
api_router = APIRouter(responses=REPONSE_SERVEUR)
|
api_router = APIRouter(responses=REPONSE_SERVEUR)
|
||||||
api_router.include_router(health.router, prefix="/health", tags=["health"])
|
api_router.include_router(health.router, prefix="/health", tags=["health"])
|
||||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
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(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(sites.router, prefix="/sites", tags=["sites"], responses=REPONSES_LECTEUR)
|
||||||
|
api_router.include_router(stats.router, prefix="/stats", tags=["stats"], responses=REPONSES_LECTEUR)
|
||||||
|
|||||||
@@ -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,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,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,
|
||||||
|
)
|
||||||
@@ -920,6 +920,62 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/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": {
|
"components": {
|
||||||
@@ -1179,6 +1235,106 @@
|
|||||||
],
|
],
|
||||||
"title": "SiteResponse"
|
"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": {
|
"TemporaryPasswordResponse": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"user": {
|
"user": {
|
||||||
@@ -1395,6 +1551,10 @@
|
|||||||
{
|
{
|
||||||
"name": "sites",
|
"name": "sites",
|
||||||
"description": "Consultation du parc de sites. Accessible à partir du rôle `lecteur`."
|
"description": "Consultation du parc de sites. Accessible à partir du rôle `lecteur`."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "stats",
|
||||||
|
"description": "Statistiques agrégées de consommation. Accessible à partir du rôle `lecteur`."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,18 @@ ORIGINE_VERIFIEE = {
|
|||||||
("POST", "/api/v1/auth/password"),
|
("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/stats/summary"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
def schema() -> dict[str, Any]:
|
def schema() -> dict[str, Any]:
|
||||||
@@ -55,11 +67,11 @@ def test_every_route_demanding_an_identity_says_how_it_refuses(schema: dict[str,
|
|||||||
assert muettes == []
|
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 = [
|
sans_403 = [
|
||||||
(methode, chemin)
|
(methode, chemin)
|
||||||
for methode, chemin, operation in operations(schema)
|
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 == []
|
assert sans_403 == []
|
||||||
|
|||||||
@@ -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,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
|
||||||
@@ -12,10 +12,10 @@ Les quatre couches existent désormais, portées par l'authentification.
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
ep["endpoints<br/>health, auth, users, sites"]
|
ep["endpoints<br/>health, auth, users, sites, stats"]
|
||||||
sc["schemas<br/>Pydantic"]
|
sc["schemas<br/>Pydantic"]
|
||||||
sv["services<br/>AuthService, UserService,<br/>SiteService"]
|
sv["services<br/>AuthService, UserService,<br/>SiteService, StatsService"]
|
||||||
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site"]
|
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site, reading"]
|
||||||
md["models<br/>10 tables"]
|
md["models<br/>10 tables"]
|
||||||
db[("PostgreSQL")]
|
db[("PostgreSQL")]
|
||||||
|
|
||||||
@@ -142,6 +142,7 @@ 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 |
|
| 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` | 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/sites/{site_id}` | Décrit un site. `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 | `/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` | |
|
| GET | `/docs`, `/redoc`, `/openapi.json` | Hors du schéma. Fermés en `staging` et en `prod` | |
|
||||||
|
|
||||||
@@ -160,7 +161,9 @@ 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
|
contrairement aux routes d'administration qui exigent `admin`. `SiteRepository` lit par
|
||||||
`AsyncSession.scalar()` (une ligne) et `AsyncSession.scalars()` (plusieurs lignes) plutôt que 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
|
`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 /stats/summary` agrège ces 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).
|
[31-contrat-authentification.md](31-contrat-authentification.md).
|
||||||
|
|
||||||
### `/health/ready`
|
### `/health/ready`
|
||||||
@@ -235,6 +238,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
|
`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.
|
`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`/`stats` (`reading`, `dataset`,
|
||||||
|
`prediction`, `alert`, `recommendation`) :
|
||||||
|
|
||||||
|
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é
|
## Sécurité
|
||||||
|
|
||||||
Voir la vue consolidée dans [00-vue-ensemble.md](00-vue-ensemble.md) et les décisions dans les
|
Voir la vue consolidée dans [00-vue-ensemble.md](00-vue-ensemble.md) et les décisions dans les
|
||||||
|
|||||||
Reference in New Issue
Block a user