feat(backend): expose GET /api/v1/sensors/status pour l'issue #32
Dérive l'état de santé de 5 capteurs par site et un statut overall depuis la dernière lecture (data_quality, null_reasons, nullité des colonnes), sur le gabarit d'agrégation de StatsService. Route réservée au rôle admin.
This commit is contained in:
@@ -35,6 +35,7 @@ ROUTES_A_ROLE = {
|
||||
("GET", "/api/v1/recommendations"),
|
||||
("GET", "/api/v1/recommendations/{recommendation_id}"),
|
||||
("GET", "/api/v1/stats/summary"),
|
||||
("GET", "/api/v1/sensors/status"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
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_sensor_service
|
||||
from app.core.principal import Principal
|
||||
from app.core.roles import AccountKind, Role
|
||||
from app.services.sensor import DiagnosticCapteur, EtatCapteurs, SanteCapteurs, SanteSite
|
||||
|
||||
TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def principal(role: Role = Role.ADMIN) -> 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:
|
||||
ok = DiagnosticCapteur(status="ok", since=None)
|
||||
en_echec = DiagnosticCapteur(status="failing", since=TIMESTAMP)
|
||||
self.etat = EtatCapteurs(
|
||||
timestamp=TIMESTAMP,
|
||||
sites=[
|
||||
SanteSite(
|
||||
site_id="SITE001",
|
||||
site_name="Bureau Paris La Défense",
|
||||
sensors=SanteCapteurs(
|
||||
consumption=ok,
|
||||
electrical=ok,
|
||||
temperature=en_echec,
|
||||
humidity=ok,
|
||||
network=ok,
|
||||
),
|
||||
overall="degraded",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
async def status(self) -> EtatCapteurs:
|
||||
return self.etat
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_connecte(app: FastAPI) -> Iterator[None]:
|
||||
app.dependency_overrides[get_current_principal] = lambda: principal()
|
||||
yield
|
||||
app.dependency_overrides.pop(get_current_principal, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def servi(app: FastAPI, admin_connecte: None) -> Iterator[Callable[[], FauxService]]:
|
||||
def installe() -> FauxService:
|
||||
service = FauxService()
|
||||
app.dependency_overrides[get_sensor_service] = lambda: service
|
||||
return service
|
||||
|
||||
yield installe
|
||||
app.dependency_overrides.pop(get_sensor_service, None)
|
||||
|
||||
|
||||
async def test_get_status_returns_the_service_result(
|
||||
servi: Callable[[], FauxService], client: AsyncClient
|
||||
) -> None:
|
||||
servi()
|
||||
|
||||
response = await client.get("/api/v1/sensors/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
corps = response.json()
|
||||
assert corps["sites"][0]["site_id"] == "SITE001"
|
||||
assert corps["sites"][0]["overall"] == "degraded"
|
||||
assert corps["sites"][0]["sensors"]["temperature"]["status"] == "failing"
|
||||
assert corps["sites"][0]["sensors"]["consumption"]["status"] == "ok"
|
||||
|
||||
|
||||
async def test_get_status_refuses_a_reader(app: FastAPI, client: AsyncClient) -> None:
|
||||
app.dependency_overrides[get_current_principal] = lambda: principal(Role.LECTEUR)
|
||||
|
||||
response = await client.get("/api/v1/sensors/status")
|
||||
|
||||
assert response.status_code == 403
|
||||
@@ -0,0 +1,197 @@
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.services.sensor import SensorService
|
||||
|
||||
TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FauxSite:
|
||||
site_id: str
|
||||
site_name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class FauxLecture:
|
||||
site_id: str
|
||||
timestamp: datetime
|
||||
data_quality: str | None
|
||||
null_reasons: list[str] | None = field(default_factory=list)
|
||||
consumption_kw: float | None = 10.0
|
||||
voltage_v: float | None = 230.0
|
||||
current_a: float | None = 5.0
|
||||
power_factor: float | None = 0.95
|
||||
temperature_celsius: float | None = 21.0
|
||||
humidity_percent: float | None = 40.0
|
||||
|
||||
|
||||
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_status_marks_a_site_without_any_reading_as_critical_with_every_sensor_failing() -> (
|
||||
None
|
||||
):
|
||||
service = SensorService(
|
||||
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
|
||||
readings=FauxDepotLectures([]), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
etat = await service.status()
|
||||
|
||||
site = etat.sites[0]
|
||||
assert site.overall == "critical"
|
||||
for capteur in (
|
||||
site.sensors.consumption,
|
||||
site.sensors.electrical,
|
||||
site.sensors.temperature,
|
||||
site.sensors.humidity,
|
||||
site.sensors.network,
|
||||
):
|
||||
assert capteur.status == "failing"
|
||||
assert capteur.since is None
|
||||
|
||||
|
||||
async def test_status_marks_every_sensor_ok_on_a_good_quality_reading_with_no_null_field() -> None:
|
||||
service = SensorService(
|
||||
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
|
||||
readings=FauxDepotLectures([FauxLecture("A", TIMESTAMP, "good")]), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
etat = await service.status()
|
||||
|
||||
site = etat.sites[0]
|
||||
assert site.overall == "ok"
|
||||
for capteur in (
|
||||
site.sensors.consumption,
|
||||
site.sensors.electrical,
|
||||
site.sensors.temperature,
|
||||
site.sensors.humidity,
|
||||
site.sensors.network,
|
||||
):
|
||||
assert capteur.status == "ok"
|
||||
assert capteur.since is None
|
||||
|
||||
|
||||
async def test_status_flags_the_sensor_named_in_null_reasons() -> None:
|
||||
service = SensorService(
|
||||
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
|
||||
readings=FauxDepotLectures( # type: ignore[arg-type]
|
||||
[
|
||||
FauxLecture(
|
||||
"A",
|
||||
TIMESTAMP,
|
||||
"partial",
|
||||
null_reasons=["temperature_sensor_failure"],
|
||||
temperature_celsius=None,
|
||||
)
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
etat = await service.status()
|
||||
|
||||
site = etat.sites[0]
|
||||
assert site.overall == "degraded"
|
||||
assert site.sensors.temperature.status == "failing"
|
||||
assert site.sensors.temperature.since == TIMESTAMP
|
||||
assert site.sensors.consumption.status == "ok"
|
||||
assert site.sensors.electrical.status == "ok"
|
||||
assert site.sensors.humidity.status == "ok"
|
||||
assert site.sensors.network.status == "ok"
|
||||
|
||||
|
||||
async def test_status_flags_a_sensor_from_a_null_field_even_without_a_null_reason() -> None:
|
||||
service = SensorService(
|
||||
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
|
||||
readings=FauxDepotLectures( # type: ignore[arg-type]
|
||||
[FauxLecture("A", TIMESTAMP, "partial", null_reasons=[], humidity_percent=None)]
|
||||
),
|
||||
)
|
||||
|
||||
etat = await service.status()
|
||||
|
||||
site = etat.sites[0]
|
||||
assert site.sensors.humidity.status == "failing"
|
||||
assert site.sensors.humidity.since == TIMESTAMP
|
||||
|
||||
|
||||
async def test_status_flags_electrical_as_failing_when_any_of_its_three_fields_is_null() -> None:
|
||||
service = SensorService(
|
||||
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
|
||||
readings=FauxDepotLectures( # type: ignore[arg-type]
|
||||
[FauxLecture("A", TIMESTAMP, "partial", null_reasons=[], power_factor=None)]
|
||||
),
|
||||
)
|
||||
|
||||
etat = await service.status()
|
||||
|
||||
site = etat.sites[0]
|
||||
assert site.sensors.electrical.status == "failing"
|
||||
|
||||
|
||||
async def test_status_forces_every_sensor_to_failing_when_overall_is_critical() -> None:
|
||||
service = SensorService(
|
||||
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
|
||||
readings=FauxDepotLectures([FauxLecture("A", TIMESTAMP, "critical", null_reasons=[])]), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
etat = await service.status()
|
||||
|
||||
site = etat.sites[0]
|
||||
assert site.overall == "critical"
|
||||
for capteur in (
|
||||
site.sensors.consumption,
|
||||
site.sensors.electrical,
|
||||
site.sensors.temperature,
|
||||
site.sensors.humidity,
|
||||
site.sensors.network,
|
||||
):
|
||||
assert capteur.status == "failing"
|
||||
assert capteur.since == TIMESTAMP
|
||||
|
||||
|
||||
async def test_status_treats_an_unknown_data_quality_as_critical() -> None:
|
||||
service = SensorService(
|
||||
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
|
||||
readings=FauxDepotLectures([FauxLecture("A", TIMESTAMP, None, null_reasons=[])]), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
etat = await service.status()
|
||||
|
||||
assert etat.sites[0].overall == "critical"
|
||||
|
||||
|
||||
async def test_status_ignores_an_unknown_null_reason() -> None:
|
||||
service = SensorService(
|
||||
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
|
||||
readings=FauxDepotLectures( # type: ignore[arg-type]
|
||||
[FauxLecture("A", TIMESTAMP, "good", null_reasons=["something_else"])]
|
||||
),
|
||||
)
|
||||
|
||||
etat = await service.status()
|
||||
|
||||
site = etat.sites[0]
|
||||
assert site.overall == "ok"
|
||||
for capteur in (
|
||||
site.sensors.consumption,
|
||||
site.sensors.electrical,
|
||||
site.sensors.temperature,
|
||||
site.sensors.humidity,
|
||||
site.sensors.network,
|
||||
):
|
||||
assert capteur.status == "ok"
|
||||
Reference in New Issue
Block a user