Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cca4f130c | ||
|
|
016f226fdb | ||
|
|
88f4f9a601 | ||
|
|
2ad7692f1c |
@@ -0,0 +1,40 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Frontend — npm
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/frontend"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
frontend-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
# Backend — uv (lit pyproject.toml / uv.lock)
|
||||
- package-ecosystem: "uv"
|
||||
directory: "/apps/backend"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
backend-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
# Les workflows GitHub Actions eux-mêmes ont aussi des dépendances à jour
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
# Si un Dockerfile existe pour le backend
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/apps/backend"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/apps/frontend"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -36,13 +36,13 @@ jobs:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: apps/frontend/package-lock.json
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- run: npm ci
|
||||
working-directory: apps/frontend
|
||||
- run: npm run build
|
||||
working-directory: apps/frontend
|
||||
|
||||
test:
|
||||
test:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -52,9 +52,9 @@ jobs:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: apps/frontend/package-lock.json
|
||||
- run: npm ci
|
||||
- run: npm ci
|
||||
working-directory: apps/frontend
|
||||
- run: npm test -- --watch=false
|
||||
- run: npm test -- --watch=false --coverage
|
||||
working-directory: apps/frontend
|
||||
|
||||
sonarqube:
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
|
||||
|
||||
# deploy:
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
|
||||
@@ -142,7 +142,7 @@ UserServiceDep = Annotated[UserService, Depends(get_user_service)]
|
||||
|
||||
|
||||
def get_site_service(session: SessionDep) -> SiteService:
|
||||
return SiteService(sites=SiteRepository(session), readings=ReadingRepository(session))
|
||||
return SiteService(sites=SiteRepository(session))
|
||||
|
||||
|
||||
SiteServiceDep = Annotated[SiteService, Depends(get_site_service)]
|
||||
|
||||
@@ -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 SiteCurrentResponse, SiteResponse
|
||||
from app.schemas.site import SiteResponse
|
||||
from app.services.site import SiteNotFoundError
|
||||
|
||||
router = APIRouter()
|
||||
@@ -34,19 +34,3 @@ 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)
|
||||
|
||||
@@ -13,27 +13,14 @@ 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. `reading_id` départage
|
||||
# les égalités de timestamp, que `uq_reading_source` autorise à `source` différente.
|
||||
# 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(), Reading.reading_id.desc())
|
||||
.order_by(Reading.site_id, Reading.timestamp.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,
|
||||
*,
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
@@ -13,20 +10,3 @@ 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"]
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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")
|
||||
@@ -5,11 +5,12 @@ 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",
|
||||
@@ -79,7 +80,7 @@ def _sante_site(site: Site, derniere: Reading | None) -> SanteSite:
|
||||
overall="critical",
|
||||
)
|
||||
|
||||
qualite = qualite_ou_critique(derniere.data_quality)
|
||||
qualite = derniere.data_quality if derniere.data_quality in QUALITES_CONNUES else "critical"
|
||||
overall = _overall_depuis_qualite(qualite)
|
||||
|
||||
if overall == "critical":
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
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):
|
||||
@@ -16,26 +12,9 @@ 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, readings: ReadingRepository) -> None:
|
||||
def __init__(self, *, sites: SiteRepository) -> None:
|
||||
self._sites = sites
|
||||
self._readings = readings
|
||||
|
||||
async def list_all(self) -> Sequence[Site]:
|
||||
return await self._sites.list_all()
|
||||
@@ -45,38 +24,3 @@ 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),
|
||||
)
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
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
|
||||
from app.services.data_quality import QUALITES_CONNUES, DataQuality, qualite_ou_critique
|
||||
|
||||
DataQuality = Literal["good", "partial", "degraded", "critical"]
|
||||
|
||||
QUALITES_CONNUES: frozenset[str] = frozenset({"good", "partial", "degraded", "critical"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -54,10 +58,13 @@ 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 = qualite_ou_critique(derniere.data_quality)
|
||||
qualite = derniere.data_quality # type: ignore[assignment]
|
||||
consommation = derniere.consumption_kw
|
||||
|
||||
charge = (
|
||||
|
||||
@@ -921,93 +921,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/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": [
|
||||
@@ -2142,140 +2055,6 @@
|
||||
],
|
||||
"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": {
|
||||
|
||||
@@ -31,7 +31,6 @@ 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}"),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from collections.abc import Callable, Iterator
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
@@ -10,9 +9,7 @@ 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 SiteCurrentReading, SiteNotFoundError
|
||||
|
||||
TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
|
||||
from app.services.site import SiteNotFoundError
|
||||
|
||||
|
||||
def principal(role: Role = Role.LECTEUR) -> Principal:
|
||||
@@ -36,28 +33,10 @@ 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]
|
||||
@@ -67,11 +46,6 @@ 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]:
|
||||
@@ -135,30 +109,6 @@ 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,73 +88,6 @@ 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:
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
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(
|
||||
@@ -20,21 +15,6 @@ 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
|
||||
@@ -46,77 +26,24 @@ class FakeRepository:
|
||||
return next((s for s in self._sites if s.site_id == site_id), None)
|
||||
|
||||
|
||||
class FauxDepotLectures:
|
||||
def __init__(self, lectures: dict[str, FauxLecture]) -> None:
|
||||
self._lectures = lectures
|
||||
|
||||
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")])
|
||||
service = SiteService(sites=FakeRepository([site("a"), site("b")]))
|
||||
|
||||
sites = await svc.list_all()
|
||||
sites = await service.list_all()
|
||||
|
||||
assert [s.site_id for s in sites] == ["a", "b"]
|
||||
|
||||
|
||||
async def test_get_by_id_returns_the_matching_site() -> None:
|
||||
svc = service([site("a")])
|
||||
service = SiteService(sites=FakeRepository([site("a")]))
|
||||
|
||||
trouve = await svc.get_by_id("a")
|
||||
trouve = await service.get_by_id("a")
|
||||
|
||||
assert trouve.site_id == "a"
|
||||
|
||||
|
||||
async def test_get_by_id_raises_when_the_site_is_unknown() -> None:
|
||||
svc = service([])
|
||||
service = SiteService(sites=FakeRepository([]))
|
||||
|
||||
with pytest.raises(SiteNotFoundError):
|
||||
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"
|
||||
await service.get_by_id("inconnu")
|
||||
|
||||
@@ -142,7 +142,6 @@ 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 |
|
||||
@@ -172,11 +171,7 @@ 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. `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
|
||||
pas dans ce gabarit route-par-table. 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,
|
||||
|
||||
Reference in New Issue
Block a user