feat(backend): expose GET /api/v1/sites/{site_id}/current pour l'issue #29

Ajoute la dernière mesure d'un site (SiteService.current), en réutilisant
la vérification d'existence déjà en place pour GET /sites/{site_id} :
SiteService gagne une dépendance ReadingRepository, sur le modèle de
composition déjà utilisé par StatsService/SensorService. Un site connu
sans lecture rend 200 avec les champs de mesure à null et
data_quality="critical" ; seul un site_id absent rend 404.
This commit is contained in:
Johan LEROY
2026-09-16 15:25:14 +02:00
parent 63ee79cf32
commit 07ea8d21dc
10 changed files with 474 additions and 12 deletions
+1 -1
View File
@@ -140,7 +140,7 @@ UserServiceDep = Annotated[UserService, Depends(get_user_service)]
def get_site_service(session: SessionDep) -> SiteService:
return SiteService(sites=SiteRepository(session))
return SiteService(sites=SiteRepository(session), readings=ReadingRepository(session))
SiteServiceDep = Annotated[SiteService, Depends(get_site_service)]
+19 -1
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, HTTPException, status
from app.api.deps import LecteurDep, SiteServiceDep
from app.api.openapi import REPONSE_VALIDATION, Reponses
from app.schemas.errors import ErrorResponse
from app.schemas.site import SiteResponse
from app.schemas.site import SiteCurrentResponse, SiteResponse
from app.services.site import SiteNotFoundError
router = APIRouter()
@@ -34,3 +34,21 @@ async def get_site(site_id: str, _: LecteurDep, service: SiteServiceDep) -> Site
status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable"
) from erreur
return SiteResponse.model_validate(site)
@router.get(
"/{site_id}/current",
response_model=SiteCurrentResponse,
summary="Dernière mesure d'un site",
responses=REPONSES_INTROUVABLE,
)
async def get_current(
site_id: str, _: LecteurDep, service: SiteServiceDep
) -> SiteCurrentResponse:
try:
actuel = await service.current(site_id)
except SiteNotFoundError as erreur:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable"
) from erreur
return SiteCurrentResponse.model_validate(actuel)
+9
View File
@@ -19,3 +19,12 @@ class ReadingRepository:
.order_by(Reading.site_id, Reading.timestamp.desc())
)
return (await self._session.execute(requete)).scalars().all()
async def latest_for_site(self, site_id: str) -> Reading | None:
requete = (
select(Reading)
.where(Reading.site_id == site_id)
.order_by(Reading.timestamp.desc())
.limit(1)
)
return await self._session.scalar(requete)
+20
View File
@@ -1,3 +1,6 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict
@@ -10,3 +13,20 @@ class SiteResponse(BaseModel):
location: str | None
capacity_kw: float | None
status: str | None
class SiteCurrentResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
timestamp: datetime | None
site_id: str
site_type: str
consumption_kw: float | None
consumption_kwh: float | None
voltage_v: float | None
current_a: float | None
power_factor: float | None
temperature_celsius: float | None
humidity_percent: float | None
null_reasons: list[str]
data_quality: Literal["good", "partial", "degraded", "critical"]
+64 -1
View File
@@ -1,8 +1,16 @@
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Literal
from app.models.energy import 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"})
class SiteError(Exception):
pass
@@ -12,9 +20,26 @@ class SiteNotFoundError(SiteError):
pass
@dataclass(frozen=True, slots=True)
class SiteCurrentReading:
timestamp: datetime | None
site_id: str
site_type: str
consumption_kw: float | None
consumption_kwh: float | None
voltage_v: float | None
current_a: float | None
power_factor: float | None
temperature_celsius: float | None
humidity_percent: float | None
null_reasons: list[str]
data_quality: DataQuality
class SiteService:
def __init__(self, *, sites: SiteRepository) -> None:
def __init__(self, *, sites: SiteRepository, readings: ReadingRepository) -> None:
self._sites = sites
self._readings = readings
async def list_all(self) -> Sequence[Site]:
return await self._sites.list_all()
@@ -24,3 +49,41 @@ class SiteService:
if site is None:
raise SiteNotFoundError(site_id)
return site
async def current(self, site_id: str) -> SiteCurrentReading:
site = await self.get_by_id(site_id)
derniere = await self._readings.latest_for_site(site_id)
if derniere is None:
return SiteCurrentReading(
timestamp=None,
site_id=site.site_id,
site_type=site.site_type,
consumption_kw=None,
consumption_kwh=None,
voltage_v=None,
current_a=None,
power_factor=None,
temperature_celsius=None,
humidity_percent=None,
null_reasons=[],
data_quality="critical",
)
qualite: DataQuality = (
derniere.data_quality if derniere.data_quality in QUALITES_CONNUES else "critical"
)
return SiteCurrentReading(
timestamp=derniere.timestamp,
site_id=site.site_id,
site_type=site.site_type,
consumption_kw=derniere.consumption_kw,
consumption_kwh=derniere.consumption_kwh,
voltage_v=derniere.voltage_v,
current_a=derniere.current_a,
power_factor=derniere.power_factor,
temperature_celsius=derniere.temperature_celsius,
humidity_percent=derniere.humidity_percent,
null_reasons=derniere.null_reasons or [],
data_quality=qualite,
)