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.
This commit is contained in:
@@ -23,9 +23,12 @@ 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.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.stats import StatsService
|
||||
from app.services.user import UserService
|
||||
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
@@ -131,6 +134,13 @@ def get_user_service(
|
||||
UserServiceDep = Annotated[UserService, Depends(get_user_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,
|
||||
|
||||
@@ -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,8 +1,9 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.endpoints import auth, health, users
|
||||
from app.api.v1.endpoints import auth, health, stats, users
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(health.router, prefix="/health", tags=["health"])
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
api_router.include_router(users.router, prefix="/users", tags=["users"])
|
||||
api_router.include_router(stats.router, prefix="/stats", tags=["stats"])
|
||||
|
||||
@@ -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,15 @@
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.energy import Site
|
||||
|
||||
|
||||
class SiteRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def list_all(self) -> Sequence[Site]:
|
||||
requete = select(Site).order_by(Site.site_id)
|
||||
return (await self._session.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,
|
||||
)
|
||||
@@ -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,31 @@
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.energy import Site
|
||||
from app.repositories.site import SiteRepository
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def identifiant() -> str:
|
||||
return f"SITE-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
async def test_list_all_returns_every_site_sorted_by_id(session: AsyncSession) -> None:
|
||||
premier, second = sorted([identifiant(), identifiant()])
|
||||
session.add_all(
|
||||
[
|
||||
Site(site_id=second, site_name="B", site_type="bureau", capacity_kw=100),
|
||||
Site(site_id=premier, site_name="A", site_type="bureau", capacity_kw=50),
|
||||
]
|
||||
)
|
||||
await session.flush()
|
||||
depot = SiteRepository(session)
|
||||
|
||||
sites = await depot.list_all()
|
||||
identifiants = [site.site_id for site in sites if site.site_id in (premier, second)]
|
||||
await session.rollback()
|
||||
|
||||
assert identifiants == [premier, second]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user