Compare commits

...
Author SHA1 Message Date
Johan LEROY b433e01fa8 fix(backend): départage aussi les égalités de timestamp dans latest_by_site
Backend / Lint, typage et tests (push) Successful in 1m18s
`latest_by_site` portait le même défaut que `latest_for_site` : `DISTINCT ON (site_id)`
ordonné sur `site_id, timestamp DESC` sans départage, alors que `uq_reading_source`
autorise deux lignes au même `site_id`+`timestamp` quand la `source` diffère.
`/stats/summary` pouvait donc afficher une consommation différente d'un appel à
l'autre pour un site alimenté par un backfill CSV et une écriture live.

Test `integration` dédié, qui échoue sans le correctif.
2026-09-18 10:28:04 +02:00
Johan LEROY 5eb74aa64a fix(backend): traite la revue de phyri0s sur la PR #84
Tri non déterministe : `latest_for_site` départage désormais les égalités de
timestamp par `reading_id` décroissant, comme `list_history`. `uq_reading_source`
autorise deux lignes au même `site_id`+`timestamp` quand la `source` diffère, donc
le `LIMIT 1` pouvait renvoyer l'une ou l'autre d'un appel à l'autre.

Tests : trois tests `integration` sur `latest_for_site` (plus récente, égalité de
timestamp, isolation par site). Le test d'égalité échoue sans le correctif ci-dessus.

Duplication : `DataQuality` et le repli vers `critical` sortent dans
`app/services/data_quality.py`, partagé par `stats.py`, `site.py` et `sensor.py`,
qui en portaient trois copies indépendantes. Supprime au passage deux
`# type: ignore[assignment]`.
2026-09-18 10:28:04 +02:00
Johan LEROY d167b64188 Merge remote-tracking branch 'origin/dev' into feat/endpoint-sites-current
# Conflicts:
#	apps/backend/app/repositories/reading.py
#	apps/backend/openapi.json
#	docs/architecture/20-backend.md
2026-09-17 12:17:05 +02:00
Johan LEROY 2f97e4d434 fix(backend): corrige formatage ruff et typage mypy sur sites/current
CI en échec sur ruff format (ligne trop longue) et mypy (retour Any non
annoté, assignation Literal non étroite). Corrige sans changer le
comportement.
2026-09-16 15:27:05 +02:00
Johan LEROY 07ea8d21dc 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.
2026-09-16 15:25:14 +02:00
14 changed files with 558 additions and 26 deletions
+1 -1
View File
@@ -142,7 +142,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)]
+17 -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,19 @@ 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)
+15 -2
View File
@@ -13,14 +13,27 @@ class ReadingRepository:
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.
# ligne par site, la plus récente grâce à l'ordre composite qui suit. `reading_id` départage
# les égalités de timestamp, que `uq_reading_source` autorise à `source` différente.
requete = (
select(Reading)
.distinct(Reading.site_id)
.order_by(Reading.site_id, Reading.timestamp.desc())
.order_by(Reading.site_id, Reading.timestamp.desc(), Reading.reading_id.desc())
)
return (await self._session.execute(requete)).scalars().all()
async def latest_for_site(self, site_id: str) -> Reading | None:
# Piège : `uq_reading_source` autorise deux lignes au même `site_id`+`timestamp` quand la
# `source` diffère. Sans `reading_id` en départage, le `LIMIT 1` renverrait au hasard.
requete = (
select(Reading)
.where(Reading.site_id == site_id)
.order_by(Reading.timestamp.desc(), Reading.reading_id.desc())
.limit(1)
)
lecture: Reading | None = await self._session.scalar(requete)
return lecture
async def list_history(
self,
*,
+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"]
+18
View File
@@ -0,0 +1,18 @@
# Contrainte : `ck_reading_quality` accepte NULL et quatre valeurs seulement, alors que le contrat
# frontend n'a aucune valeur pour l'absence de qualité. `qualite_ou_critique()` replie donc sur
# `critical`, la seule des quatre qui n'induise pas une confiance qu'on n'a pas. `QUALITES_CONNUES`
# reste exposé pour les appelants qui doivent distinguer un `critical` stocké d'un repli.
from typing import Literal, get_args
DataQuality = Literal["good", "partial", "degraded", "critical"]
QUALITES_CONNUES: frozenset[str] = frozenset(get_args(DataQuality))
_PAR_VALEUR: dict[str, DataQuality] = {valeur: valeur for valeur in get_args(DataQuality)}
def qualite_ou_critique(valeur: str | None) -> DataQuality:
if valeur is None:
return "critical"
return _PAR_VALEUR.get(valeur, "critical")
+2 -3
View File
@@ -5,12 +5,11 @@ from typing import Literal
from app.models.energy import Reading, Site
from app.repositories.reading import ReadingRepository
from app.repositories.site import SiteRepository
from app.services.data_quality import qualite_ou_critique
CapteurStatus = Literal["ok", "failing"]
OverallStatus = Literal["ok", "degraded", "critical"]
QUALITES_CONNUES: frozenset[str] = frozenset({"good", "partial", "degraded", "critical"})
RAISON_VERS_CAPTEUR: dict[str, str] = {
"consumption_sensor_failure": "consumption",
"electrical_sensor_failure": "electrical",
@@ -80,7 +79,7 @@ def _sante_site(site: Site, derniere: Reading | None) -> SanteSite:
overall="critical",
)
qualite = derniere.data_quality if derniere.data_quality in QUALITES_CONNUES else "critical"
qualite = qualite_ou_critique(derniere.data_quality)
overall = _overall_depuis_qualite(qualite)
if overall == "critical":
+57 -1
View File
@@ -1,7 +1,11 @@
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from app.models.energy import Site
from app.repositories.reading import ReadingRepository
from app.repositories.site import SiteRepository
from app.services.data_quality import DataQuality, qualite_ou_critique
class SiteError(Exception):
@@ -12,9 +16,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 +45,38 @@ 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",
)
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_ou_critique(derniere.data_quality),
)
+2 -9
View File
@@ -1,14 +1,10 @@
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"})
from app.services.data_quality import QUALITES_CONNUES, DataQuality, qualite_ou_critique
@dataclass(frozen=True, slots=True)
@@ -58,13 +54,10 @@ class StatsService:
@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]
qualite = qualite_ou_critique(derniere.data_quality)
consommation = derniere.consumption_kw
charge = (
+221
View File
@@ -921,6 +921,93 @@
}
}
},
"/api/v1/sites/{site_id}/current": {
"get": {
"tags": [
"sites"
],
"summary": "Dernière mesure 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": {
"$ref": "#/components/schemas/SiteCurrentResponse"
}
}
}
},
"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": [
@@ -2055,6 +2142,140 @@
],
"title": "SensorStatusResponse"
},
"SiteCurrentResponse": {
"properties": {
"timestamp": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Timestamp"
},
"site_id": {
"type": "string",
"title": "Site Id"
},
"site_type": {
"type": "string",
"title": "Site Type"
},
"consumption_kw": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Consumption Kw"
},
"consumption_kwh": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Consumption Kwh"
},
"voltage_v": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Voltage V"
},
"current_a": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Current A"
},
"power_factor": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Power Factor"
},
"temperature_celsius": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Temperature Celsius"
},
"humidity_percent": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Humidity Percent"
},
"null_reasons": {
"items": {
"type": "string"
},
"type": "array",
"title": "Null Reasons"
},
"data_quality": {
"type": "string",
"enum": [
"good",
"partial",
"degraded",
"critical"
],
"title": "Data Quality"
}
},
"type": "object",
"required": [
"timestamp",
"site_id",
"site_type",
"consumption_kw",
"consumption_kwh",
"voltage_v",
"current_a",
"power_factor",
"temperature_celsius",
"humidity_percent",
"null_reasons",
"data_quality"
],
"title": "SiteCurrentResponse"
},
"SiteResponse": {
"properties": {
"site_id": {
+1
View File
@@ -31,6 +31,7 @@ ROUTES_A_ROLE = {
("POST", "/api/v1/users/{id}/password-reset"),
("GET", "/api/v1/sites"),
("GET", "/api/v1/sites/{site_id}"),
("GET", "/api/v1/sites/{site_id}/current"),
("GET", "/api/v1/alerts"),
("GET", "/api/v1/recommendations"),
("GET", "/api/v1/recommendations/{recommendation_id}"),
+51 -1
View File
@@ -1,4 +1,5 @@
from collections.abc import Callable, Iterator
from datetime import UTC, datetime
from uuid import uuid4
import pytest
@@ -9,7 +10,9 @@ from app.api.deps import get_current_principal, get_site_service
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.models.energy import Site
from app.services.site import SiteNotFoundError
from app.services.site import SiteCurrentReading, SiteNotFoundError
TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
def principal(role: Role = Role.LECTEUR) -> Principal:
@@ -33,10 +36,28 @@ def site(site_id: str = "site-1") -> Site:
)
def lecture_actuelle(site_id: str = "site-1") -> SiteCurrentReading:
return SiteCurrentReading(
timestamp=TIMESTAMP,
site_id=site_id,
site_type="industriel",
consumption_kw=87.34,
consumption_kwh=87.34,
voltage_v=401.2,
current_a=132.5,
power_factor=0.923,
temperature_celsius=22.1,
humidity_percent=58.4,
null_reasons=[],
data_quality="good",
)
class FauxService:
def __init__(self, erreur: Exception | None = None) -> None:
self._erreur = erreur
self.site = site()
self.actuel = lecture_actuelle()
async def list_all(self) -> list[Site]:
return [self.site]
@@ -46,6 +67,11 @@ class FauxService:
raise self._erreur
return self.site
async def current(self, site_id: str) -> SiteCurrentReading:
if self._erreur is not None:
raise self._erreur
return self.actuel
@pytest.fixture
def lecteur_connecte(app: FastAPI) -> Iterator[None]:
@@ -109,6 +135,30 @@ async def test_get_site_returns_404_for_an_unknown_site(
assert response.status_code == 404
async def test_get_current_returns_the_latest_reading(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/sites/site-1/current")
assert response.status_code == 200
corps = response.json()
assert corps["site_id"] == "site-1"
assert corps["data_quality"] == "good"
assert corps["consumption_kw"] == 87.34
async def test_get_current_returns_404_for_an_unknown_site(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi(SiteNotFoundError("site-inconnu"))
response = await client.get("/api/v1/sites/site-inconnu/current")
assert response.status_code == 404
async def test_list_sites_reaches_the_repository_through_the_session(
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
) -> None:
@@ -88,6 +88,73 @@ async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) ->
assert identifiants == {premier, second}
async def test_latest_by_site_breaks_a_timestamp_tie_on_the_last_written_reading(
session: AsyncSession,
) -> None:
site = await creer_site(session)
depot = ReadingRepository(session)
horodatage = datetime(2026, 9, 15, tzinfo=UTC)
await creer_lecture(
session, site_id=site.site_id, timestamp=horodatage, source="api_history", consumption_kw=10
)
derniere = await creer_lecture(
session, site_id=site.site_id, timestamp=horodatage, source="api_current", consumption_kw=42
)
resultats = await depot.latest_by_site()
retenues = [r.reading_id for r in resultats if r.site_id == site.site_id]
await session.rollback()
assert retenues == [derniere.reading_id]
async def test_latest_for_site_returns_the_most_recent_reading(session: AsyncSession) -> None:
site = await creer_site(session)
depot = ReadingRepository(session)
await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2026, 9, 1, tzinfo=UTC))
recente = await creer_lecture(
session, site_id=site.site_id, timestamp=datetime(2026, 9, 15, tzinfo=UTC)
)
trouvee = await depot.latest_for_site(site.site_id)
reading_id = trouvee.reading_id if trouvee else None
await session.rollback()
assert reading_id == recente.reading_id
async def test_latest_for_site_breaks_a_timestamp_tie_on_the_last_written_reading(
session: AsyncSession,
) -> None:
site = await creer_site(session)
depot = ReadingRepository(session)
horodatage = datetime(2026, 9, 15, tzinfo=UTC)
await creer_lecture(session, site_id=site.site_id, timestamp=horodatage, source="api_history")
derniere = await creer_lecture(
session, site_id=site.site_id, timestamp=horodatage, source="api_current"
)
trouvee = await depot.latest_for_site(site.site_id)
reading_id = trouvee.reading_id if trouvee else None
await session.rollback()
assert reading_id == derniere.reading_id
async def test_latest_for_site_ignores_the_readings_of_the_other_sites(
session: AsyncSession,
) -> None:
sans_lecture = await creer_site(session)
autre = await creer_site(session)
depot = ReadingRepository(session)
await creer_lecture(session, site_id=autre.site_id)
trouvee = await depot.latest_for_site(sans_lecture.site_id)
await session.rollback()
assert trouvee is None
async def test_list_history_orders_the_readings_by_timestamp_descending(
session: AsyncSession,
) -> None:
+80 -7
View File
@@ -1,8 +1,13 @@
from dataclasses import dataclass, field
from datetime import UTC, datetime
import pytest
from app.models.energy import Site
from app.services.site import SiteNotFoundError, SiteService
TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
def site(site_id: str = "site-1") -> Site:
return Site(
@@ -15,6 +20,21 @@ def site(site_id: str = "site-1") -> Site:
)
@dataclass
class FauxLecture:
site_id: str
timestamp: datetime = TIMESTAMP
consumption_kw: float | None = 87.34
consumption_kwh: float | None = 87.34
voltage_v: float | None = 401.2
current_a: float | None = 132.5
power_factor: float | None = 0.923
temperature_celsius: float | None = 22.1
humidity_percent: float | None = 58.4
null_reasons: list[str] | None = field(default_factory=list)
data_quality: str | None = "good"
class FakeRepository:
def __init__(self, sites: list[Site]) -> None:
self._sites = sites
@@ -26,24 +46,77 @@ class FakeRepository:
return next((s for s in self._sites if s.site_id == site_id), None)
async def test_list_all_returns_the_repository_sites() -> None:
service = SiteService(sites=FakeRepository([site("a"), site("b")]))
class FauxDepotLectures:
def __init__(self, lectures: dict[str, FauxLecture]) -> None:
self._lectures = lectures
sites = await service.list_all()
async def latest_for_site(self, site_id: str) -> FauxLecture | None:
return self._lectures.get(site_id)
def service(sites: list[Site], lectures: dict[str, FauxLecture] | None = None) -> SiteService:
return SiteService(
sites=FakeRepository(sites), # type: ignore[arg-type]
readings=FauxDepotLectures(lectures or {}), # type: ignore[arg-type]
)
async def test_list_all_returns_the_repository_sites() -> None:
svc = service([site("a"), site("b")])
sites = await svc.list_all()
assert [s.site_id for s in sites] == ["a", "b"]
async def test_get_by_id_returns_the_matching_site() -> None:
service = SiteService(sites=FakeRepository([site("a")]))
svc = service([site("a")])
trouve = await service.get_by_id("a")
trouve = await svc.get_by_id("a")
assert trouve.site_id == "a"
async def test_get_by_id_raises_when_the_site_is_unknown() -> None:
service = SiteService(sites=FakeRepository([]))
svc = service([])
with pytest.raises(SiteNotFoundError):
await service.get_by_id("inconnu")
await svc.get_by_id("inconnu")
async def test_current_raises_when_the_site_is_unknown() -> None:
svc = service([])
with pytest.raises(SiteNotFoundError):
await svc.current("inconnu")
async def test_current_returns_every_field_as_null_when_the_site_has_no_reading() -> None:
svc = service([site("a")])
actuel = await svc.current("a")
assert actuel.timestamp is None
assert actuel.consumption_kw is None
assert actuel.data_quality == "critical"
assert actuel.null_reasons == []
async def test_current_copies_every_field_from_the_latest_reading() -> None:
svc = service([site("a")], {"a": FauxLecture(site_id="a")})
actuel = await svc.current("a")
assert actuel.timestamp == TIMESTAMP
assert actuel.site_type == "industriel"
assert actuel.consumption_kw == 87.34
assert actuel.voltage_v == 401.2
assert actuel.data_quality == "good"
async def test_current_treats_an_unknown_data_quality_as_critical() -> None:
svc = service([site("a")], {"a": FauxLecture(site_id="a", data_quality=None)})
actuel = await svc.current("a")
assert actuel.data_quality == "critical"
+6 -1
View File
@@ -142,6 +142,7 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke
| POST | `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions. `admin` | 401, 403, 404, 422, 500 |
| GET | `/api/v1/sites` | Liste les sites. `lecteur` | 401, 403, 500 |
| GET | `/api/v1/sites/{site_id}` | Décrit un site. `lecteur` | 401, 403, 404, 422, 500 |
| GET | `/api/v1/sites/{site_id}/current` | Dernière mesure d'un site. `lecteur` | 401, 403, 404, 422, 500 |
| GET | `/api/v1/alerts` | Liste les alertes, filtrable par `site_id` et `severity`. `lecteur` | 401, 403, 422, 500 |
| GET | `/api/v1/recommendations` | Liste les recommandations. `lecteur` | 401, 403, 500 |
| GET | `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation. `lecteur` | 401, 403, 404, 422, 500 |
@@ -171,7 +172,11 @@ et `GET /recommendations/{recommendation_id}` reprennent le même gabarit à la
elle remonte à un site par sa seule `alert_id`, `alert` n'étant pas encore exposée. `GET
/stats/summary` et `GET /sensors/status` agrègent chacune deux repositories (`SiteRepository`,
`ReadingRepository`) dans un service dédié plutôt que d'exposer une table : elles n'entrent donc
pas dans ce gabarit route-par-table. Le contrat détaillé pour le frontend est dans
pas dans ce gabarit route-par-table. `GET /sites/{site_id}/current` reste sur le gabarit `sites`,
mais `SiteService` gagne la même seconde dépendance (`ReadingRepository`) pour restituer la
dernière `Reading` du site : un site connu sans lecture rend `200` avec tous les champs de mesure
à `null` et `data_quality="critical"`, seul un `site_id` absent de la base rend `404`. Le contrat
détaillé pour le frontend est dans
[31-contrat-authentification.md](31-contrat-authentification.md).
`GET /readings` reprend le même gabarit mais s'en écarte sur un point : `reading` est l'hypertable,