Merge pull request #94 from ineszang/feat/get-readings
feat(backend): expose GET /api/v1/readings avec fenetre bornee et pag…
This commit is contained in:
@@ -31,6 +31,7 @@ from app.repositories.site import SiteRepository
|
||||
from app.repositories.user import UserRepository
|
||||
from app.services.alert import AlertService
|
||||
from app.services.auth import AuthService, LoginPolicy
|
||||
from app.services.reading import ReadingService
|
||||
from app.services.recommendation import RecommendationService
|
||||
from app.services.sensor import SensorService
|
||||
from app.services.site import SiteService
|
||||
@@ -168,6 +169,13 @@ def get_stats_service(session: SessionDep) -> StatsService:
|
||||
StatsServiceDep = Annotated[StatsService, Depends(get_stats_service)]
|
||||
|
||||
|
||||
def get_reading_service(session: SessionDep) -> ReadingService:
|
||||
return ReadingService(readings=ReadingRepository(session))
|
||||
|
||||
|
||||
ReadingServiceDep = Annotated[ReadingService, Depends(get_reading_service)]
|
||||
|
||||
|
||||
def get_sensor_service(session: SessionDep) -> SensorService:
|
||||
return SensorService(sites=SiteRepository(session), readings=ReadingRepository(session))
|
||||
|
||||
|
||||
@@ -71,6 +71,14 @@ TAGS: Final[list[dict[str, Any]]] = [
|
||||
"description": "Statistiques agrégées de consommation. Accessible à partir du rôle "
|
||||
"`lecteur`.",
|
||||
},
|
||||
{
|
||||
"name": "readings",
|
||||
"description": (
|
||||
"Historique des lectures de consommation. Fenêtre temporelle plafonnée à 90 jours, "
|
||||
"24 dernières heures par défaut si `start`/`end` sont omis. Accessible à partir du "
|
||||
"rôle `lecteur`."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "sensors",
|
||||
"description": "État de santé des capteurs par site. Réservé au rôle `admin`.",
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
|
||||
from app.api.deps import LecteurDep, ReadingServiceDep
|
||||
from app.api.openapi import REPONSE_VALIDATION, Reponses
|
||||
from app.schemas.errors import ErrorResponse
|
||||
from app.schemas.reading import ReadingResponse
|
||||
from app.services.reading import FenetreInverseeError, FenetreTropLargeError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
REPONSES_FENETRE: Reponses = {
|
||||
**REPONSE_VALIDATION,
|
||||
400: {
|
||||
"model": ErrorResponse,
|
||||
"description": (
|
||||
"Fenêtre temporelle invalide : `start` postérieur ou égal à `end`, ou écart entre "
|
||||
"les deux supérieur à 90 jours."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=list[ReadingResponse],
|
||||
summary="Liste l'historique des lectures",
|
||||
responses=REPONSES_FENETRE,
|
||||
)
|
||||
async def list_readings(
|
||||
_: LecteurDep,
|
||||
service: ReadingServiceDep,
|
||||
site_id: str | None = None,
|
||||
start: datetime | None = None,
|
||||
end: datetime | None = None,
|
||||
limit: int = Query(500, ge=1, le=2000),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> list[ReadingResponse]:
|
||||
try:
|
||||
lectures = await service.list_history(
|
||||
site_id=site_id, start=start, end=end, limit=limit, offset=offset
|
||||
)
|
||||
except FenetreInverseeError as erreur:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="`start` doit être strictement antérieur à `end`",
|
||||
) from erreur
|
||||
except FenetreTropLargeError as erreur:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="L'écart entre `start` et `end` ne peut pas dépasser 90 jours",
|
||||
) from erreur
|
||||
return [ReadingResponse.model_validate(lecture) for lecture in lectures]
|
||||
@@ -1,7 +1,17 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR
|
||||
from app.api.v1.endpoints import alerts, auth, health, recommendations, sensors, sites, stats, users
|
||||
from app.api.v1.endpoints import (
|
||||
alerts,
|
||||
auth,
|
||||
health,
|
||||
readings,
|
||||
recommendations,
|
||||
sensors,
|
||||
sites,
|
||||
stats,
|
||||
users,
|
||||
)
|
||||
|
||||
api_router = APIRouter(responses=REPONSE_SERVEUR)
|
||||
api_router.include_router(health.router, prefix="/health", tags=["health"])
|
||||
@@ -18,6 +28,9 @@ api_router.include_router(
|
||||
responses=REPONSES_LECTEUR,
|
||||
)
|
||||
api_router.include_router(stats.router, prefix="/stats", tags=["stats"], responses=REPONSES_LECTEUR)
|
||||
api_router.include_router(
|
||||
readings.router, prefix="/readings", tags=["readings"], responses=REPONSES_LECTEUR
|
||||
)
|
||||
api_router.include_router(
|
||||
sensors.router, prefix="/sensors", tags=["sensors"], responses=REPONSES_ADMIN
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -19,3 +20,23 @@ class ReadingRepository:
|
||||
.order_by(Reading.site_id, Reading.timestamp.desc())
|
||||
)
|
||||
return (await self._session.execute(requete)).scalars().all()
|
||||
|
||||
async def list_history(
|
||||
self,
|
||||
*,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
site_id: str | None = None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> Sequence[Reading]:
|
||||
requete = (
|
||||
select(Reading)
|
||||
.where(Reading.timestamp >= start, Reading.timestamp < end)
|
||||
.order_by(Reading.timestamp.desc(), Reading.reading_id.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
if site_id is not None:
|
||||
requete = requete.where(Reading.site_id == site_id)
|
||||
return (await self._session.scalars(requete)).all()
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class ReadingSource(StrEnum):
|
||||
CSV = "csv"
|
||||
API_CURRENT = "api_current"
|
||||
API_HISTORY = "api_history"
|
||||
|
||||
|
||||
class ReadingDataQuality(StrEnum):
|
||||
GOOD = "good"
|
||||
PARTIAL = "partial"
|
||||
DEGRADED = "degraded"
|
||||
CRITICAL = "critical"
|
||||
|
||||
|
||||
class ReadingResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
reading_id: int
|
||||
site_id: str
|
||||
timestamp: datetime
|
||||
source: ReadingSource
|
||||
consumption_kw: float | None
|
||||
consumption_kwh: float | None
|
||||
# Piège : `Decimal` (miroir de `Numeric(14, 2)` en base, pour ne pas arrondir un montant)
|
||||
# sérialise en chaîne dans le JSON, pas en nombre — un consommateur qui ferait un `parseFloat`
|
||||
# naïf perdrait la précision que ce choix visait à garder.
|
||||
consumption_euros: Decimal | None
|
||||
voltage_v: float | None
|
||||
current_a: float | None
|
||||
power_factor: float | None
|
||||
temperature_celsius: float | None
|
||||
humidity_percent: float | None
|
||||
solar_irradiance_wm2: float | None
|
||||
is_working_hours: bool | None
|
||||
data_quality: ReadingDataQuality | None
|
||||
null_reasons: list[str] | None
|
||||
imputed_values: dict[str, Any] | None
|
||||
imputation_method: str | None
|
||||
@@ -0,0 +1,59 @@
|
||||
from collections.abc import Sequence
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from app.models.energy import Reading
|
||||
from app.repositories.reading import ReadingRepository
|
||||
|
||||
FENETRE_PAR_DEFAUT = timedelta(hours=24)
|
||||
FENETRE_MAXIMALE = timedelta(days=90)
|
||||
|
||||
|
||||
class FenetreInverseeError(Exception):
|
||||
"""`start` est postérieur ou égal à `end`."""
|
||||
|
||||
|
||||
class FenetreTropLargeError(Exception):
|
||||
"""L'écart entre `start` et `end` dépasse `FENETRE_MAXIMALE`."""
|
||||
|
||||
|
||||
class ReadingService:
|
||||
def __init__(self, *, readings: ReadingRepository) -> None:
|
||||
self._readings = readings
|
||||
|
||||
async def list_history(
|
||||
self,
|
||||
*,
|
||||
site_id: str | None = None,
|
||||
start: datetime | None = None,
|
||||
end: datetime | None = None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> Sequence[Reading]:
|
||||
debut, fin = self._resoudre_fenetre(start, end)
|
||||
return await self._readings.list_history(
|
||||
site_id=site_id, start=debut, end=fin, limit=limit, offset=offset
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resoudre_fenetre(
|
||||
start: datetime | None, end: datetime | None
|
||||
) -> tuple[datetime, datetime]:
|
||||
# Piège : un datetime naïf (sans fuseau dans la chaîne ISO reçue) fait échouer la
|
||||
# comparaison à `reading.timestamp` (`timestamptz`) au niveau du pilote, en 500 plutôt
|
||||
# qu'un refus propre. On le traite comme de l'UTC plutôt que de le rejeter.
|
||||
debut = _vers_utc(start)
|
||||
fin = _vers_utc(end) or datetime.now(UTC)
|
||||
if debut is None:
|
||||
debut = fin - FENETRE_PAR_DEFAUT
|
||||
|
||||
if debut >= fin:
|
||||
raise FenetreInverseeError
|
||||
if fin - debut > FENETRE_MAXIMALE:
|
||||
raise FenetreTropLargeError
|
||||
return debut, fin
|
||||
|
||||
|
||||
def _vers_utc(instant: datetime | None) -> datetime | None:
|
||||
if instant is None:
|
||||
return None
|
||||
return instant if instant.tzinfo is not None else instant.replace(tzinfo=UTC)
|
||||
Reference in New Issue
Block a user