feat: vue détail d'un site (#51)
Ajoute la page de détail d'un site (fiche, mesure instantanée, jauge de
consommation, historique) en remplacement du placeholder. Les champs
null sont affichés explicitement avec leur raison plutôt que masqués.
Ajoute l'endpoint GET /sites/{id}/current côté backend, qui retourne la
dernière mesure connue d'un site sans filtre temporel, conformément au
contrat de l'issue.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012i5NteMLRZgfTAKB5GD37X
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
56c134beb0
commit
5d921a9b1e
@@ -1,8 +1,9 @@
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.api.deps import LecteurDep, SiteServiceDep
|
||||
from app.api.deps import LecteurDep, ReadingServiceDep, SiteServiceDep
|
||||
from app.api.openapi import REPONSE_VALIDATION, Reponses
|
||||
from app.schemas.errors import ErrorResponse
|
||||
from app.schemas.reading import ReadingResponse
|
||||
from app.schemas.site import SiteResponse
|
||||
from app.services.site import SiteNotFoundError
|
||||
|
||||
@@ -34,3 +35,22 @@ 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=ReadingResponse | None,
|
||||
summary="Dernière mesure connue d'un site",
|
||||
responses=REPONSES_INTROUVABLE,
|
||||
)
|
||||
async def get_current(
|
||||
site_id: str, _: LecteurDep, sites: SiteServiceDep, readings: ReadingServiceDep
|
||||
) -> ReadingResponse | None:
|
||||
try:
|
||||
await sites.get_by_id(site_id)
|
||||
except SiteNotFoundError as erreur:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable"
|
||||
) from erreur
|
||||
derniere = await readings.get_latest(site_id)
|
||||
return ReadingResponse.model_validate(derniere) if derniere is not None else None
|
||||
|
||||
@@ -21,6 +21,15 @@ class ReadingRepository:
|
||||
)
|
||||
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(), Reading.reading_id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return (await self._session.scalars(requete)).first()
|
||||
|
||||
async def list_history(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -20,6 +20,9 @@ class ReadingService:
|
||||
def __init__(self, *, readings: ReadingRepository) -> None:
|
||||
self._readings = readings
|
||||
|
||||
async def get_latest(self, site_id: str) -> Reading | None:
|
||||
return await self._readings.latest_for_site(site_id)
|
||||
|
||||
async def list_history(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -1115,6 +1115,101 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/sites/{site_id}/current": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"sites"
|
||||
],
|
||||
"summary": "Dernière mesure connue d'un site",
|
||||
"operationId": "get_current_api_v1_sites__site_id__current_get",
|
||||
"security": [
|
||||
{
|
||||
"Jeton d'accès": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "site_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"title": "Site Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ReadingResponse"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Response Get Current Api V1 Sites Site Id Current Get"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ValidationErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Aucun site ne porte cet identifiant.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/alerts": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
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_site_service
|
||||
from app.api.deps import get_current_principal, get_reading_service, get_site_service
|
||||
from app.core.principal import Principal
|
||||
from app.core.roles import AccountKind, Role
|
||||
from app.models.energy import Site
|
||||
from app.models.energy import Reading, Site
|
||||
from app.services.site import SiteNotFoundError
|
||||
|
||||
|
||||
@@ -47,6 +48,26 @@ class FauxService:
|
||||
return self.site
|
||||
|
||||
|
||||
def reading(site_id: str = "site-1") -> Reading:
|
||||
return Reading(
|
||||
reading_id=1,
|
||||
site_id=site_id,
|
||||
timestamp=datetime(2026, 9, 16, tzinfo=UTC),
|
||||
source="csv",
|
||||
consumption_kw=42.5,
|
||||
data_quality="good",
|
||||
raw_data={},
|
||||
)
|
||||
|
||||
|
||||
class FauxReadingService:
|
||||
def __init__(self, derniere: Reading | None) -> None:
|
||||
self._derniere = derniere
|
||||
|
||||
async def get_latest(self, site_id: str) -> Reading | None:
|
||||
return self._derniere
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lecteur_connecte(app: FastAPI) -> Iterator[None]:
|
||||
app.dependency_overrides[get_current_principal] = lambda: principal()
|
||||
@@ -67,6 +88,19 @@ def servi(
|
||||
app.dependency_overrides.pop(get_site_service, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def readings_servis(
|
||||
app: FastAPI, lecteur_connecte: None
|
||||
) -> Iterator[Callable[[Reading | None], FauxReadingService]]:
|
||||
def installe(derniere: Reading | None) -> FauxReadingService:
|
||||
service = FauxReadingService(derniere)
|
||||
app.dependency_overrides[get_reading_service] = lambda: service
|
||||
return service
|
||||
|
||||
yield installe
|
||||
app.dependency_overrides.pop(get_reading_service, None)
|
||||
|
||||
|
||||
async def test_list_sites_returns_the_sites(
|
||||
servi: Callable[..., FauxService], client: AsyncClient
|
||||
) -> None:
|
||||
@@ -139,3 +173,45 @@ async def test_get_site_returns_404_when_the_session_finds_nothing(
|
||||
response = await client.get("/api/v1/sites/inconnu")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
async def test_get_current_returns_the_latest_reading_regardless_of_its_age(
|
||||
servi: Callable[..., FauxService],
|
||||
readings_servis: Callable[[Reading | None], FauxReadingService],
|
||||
client: AsyncClient,
|
||||
) -> None:
|
||||
servi()
|
||||
readings_servis(reading())
|
||||
|
||||
response = await client.get("/api/v1/sites/site-1/current")
|
||||
|
||||
assert response.status_code == 200
|
||||
corps = response.json()
|
||||
assert corps["consumption_kw"] == 42.5
|
||||
|
||||
|
||||
async def test_get_current_returns_null_when_the_site_has_no_reading(
|
||||
servi: Callable[..., FauxService],
|
||||
readings_servis: Callable[[Reading | None], FauxReadingService],
|
||||
client: AsyncClient,
|
||||
) -> None:
|
||||
servi()
|
||||
readings_servis(None)
|
||||
|
||||
response = await client.get("/api/v1/sites/site-1/current")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() is None
|
||||
|
||||
|
||||
async def test_get_current_returns_404_for_an_unknown_site(
|
||||
servi: Callable[..., FauxService],
|
||||
readings_servis: Callable[[Reading | None], FauxReadingService],
|
||||
client: AsyncClient,
|
||||
) -> None:
|
||||
servi(SiteNotFoundError("site-inconnu"))
|
||||
readings_servis(None)
|
||||
|
||||
response = await client.get("/api/v1/sites/site-inconnu/current")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
@@ -88,6 +88,31 @@ async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) ->
|
||||
assert identifiants == {premier, second}
|
||||
|
||||
|
||||
async def test_latest_for_site_ignores_how_old_the_reading_is(session: AsyncSession) -> None:
|
||||
site = await creer_site(session)
|
||||
depot = ReadingRepository(session)
|
||||
await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2024, 1, 1, tzinfo=UTC))
|
||||
recente = await creer_lecture(
|
||||
session, site_id=site.site_id, timestamp=datetime(2024, 12, 31, tzinfo=UTC)
|
||||
)
|
||||
|
||||
resultat = await depot.latest_for_site(site.site_id)
|
||||
await session.rollback()
|
||||
|
||||
assert resultat is not None
|
||||
assert resultat.reading_id == recente.reading_id
|
||||
|
||||
|
||||
async def test_latest_for_site_returns_none_when_the_site_has_no_reading(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
depot = ReadingRepository(session)
|
||||
|
||||
resultat = await depot.latest_for_site(identifiant_site())
|
||||
|
||||
assert resultat is None
|
||||
|
||||
|
||||
async def test_list_history_orders_the_readings_by_timestamp_descending(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
|
||||
@@ -28,6 +28,11 @@ class FakeRepository:
|
||||
def __init__(self, readings: list[Reading]) -> None:
|
||||
self._readings = readings
|
||||
self.appels: list[tuple[str | None, datetime, datetime, int, int]] = []
|
||||
self.site_interroge: str | None = None
|
||||
|
||||
async def latest_for_site(self, site_id: str) -> Reading | None:
|
||||
self.site_interroge = site_id
|
||||
return self._readings[0] if self._readings else None
|
||||
|
||||
async def list_history(
|
||||
self,
|
||||
@@ -42,6 +47,25 @@ class FakeRepository:
|
||||
return self._readings
|
||||
|
||||
|
||||
async def test_get_latest_relays_the_repository_reading() -> None:
|
||||
depot = FakeRepository([reading(1)])
|
||||
service = ReadingService(readings=depot)
|
||||
|
||||
lecture = await service.get_latest("site-1")
|
||||
|
||||
assert lecture is not None
|
||||
assert lecture.reading_id == 1
|
||||
assert depot.site_interroge == "site-1"
|
||||
|
||||
|
||||
async def test_get_latest_returns_none_when_the_site_has_no_reading() -> None:
|
||||
service = ReadingService(readings=FakeRepository([]))
|
||||
|
||||
lecture = await service.get_latest("site-1")
|
||||
|
||||
assert lecture is None
|
||||
|
||||
|
||||
async def test_list_history_returns_the_repository_readings() -> None:
|
||||
service = ReadingService(readings=FakeRepository([reading(1), reading(2)]))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user