Fusionne dev dans feat/endpoint-alertes-predictives
Resout les conflits additifs entre les routes alerts, recommendations et stats mergees sur dev (PR #79, PR #82) pendant le developpement de cette branche : deps.py, router.py, openapi.py, openapi.json, test_openapi.py et 20-backend.md conservent desormais les trois routes.
This commit is contained in:
@@ -24,12 +24,16 @@ from app.db.session import get_session
|
||||
from app.repositories.alert import AlertRepository
|
||||
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.alert import AlertService
|
||||
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)]
|
||||
@@ -149,6 +153,20 @@ def get_alert_service(session: SessionDep) -> AlertService:
|
||||
AlertServiceDep = Annotated[AlertService, Depends(get_alert_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,
|
||||
|
||||
@@ -59,6 +59,18 @@ TAGS: Final[list[dict[str, Any]]] = [
|
||||
"description": "Consultation des alertes de consommation. 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)
|
||||
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR
|
||||
from app.api.v1.endpoints import alerts, auth, health, sites, users
|
||||
from app.api.v1.endpoints import alerts, auth, health, recommendations, sites, stats, users
|
||||
|
||||
api_router = APIRouter(responses=REPONSE_SERVEUR)
|
||||
api_router.include_router(health.router, prefix="/health", tags=["health"])
|
||||
@@ -11,3 +11,10 @@ api_router.include_router(sites.router, prefix="/sites", tags=["sites"], respons
|
||||
api_router.include_router(
|
||||
alerts.router, prefix="/alerts", tags=["alerts"], 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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user