From 5e7cb005acb8a4c14ecbb0c44cd9ce52655ff4a8 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 10:25:22 +0200 Subject: [PATCH 1/3] feat(build): branche le frontend sur make dev Ajoute install-frontend/dev-frontend au Makefile, dev/install deviennent composites (backend + frontend lances ensemble), et met a jour README et docs/architecture en consequence. Closes #75 --- Makefile | 22 +++++++++++++++++++--- README.md | 15 +++++++++------ docs/architecture/10-infra.md | 7 ++++--- docs/architecture/30-frontend.md | 5 +++-- 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/Makefile b/Makefile index bf45b61..1576eae 100644 --- a/Makefile +++ b/Makefile @@ -1,18 +1,34 @@ BACKEND := apps/backend +FRONTEND := apps/frontend .DEFAULT_GOAL := help -.PHONY: help install dev lint format typecheck test test-cov test-integration check \ +.PHONY: help install install-backend install-frontend dev dev-backend dev-frontend \ + lint format typecheck test test-cov test-integration check \ docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin help: ## Liste les cibles disponibles @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' -install: ## Installe les dépendances du backend +install: install-backend install-frontend ## Installe les dépendances backend et frontend + +install-backend: ## Installe les dépendances du backend cd $(BACKEND) && uv sync --all-groups -dev: ## Lance l'API en rechargement à chaud +install-frontend: ## Installe les dépendances du frontend + cd $(FRONTEND) && npm ci + +dev: ## Lance toute la stack (backend + frontend) en rechargement à chaud + @trap 'kill 0' EXIT INT TERM; \ + $(MAKE) dev-backend & \ + $(MAKE) dev-frontend & \ + wait + +dev-backend: ## Lance l'API seule en rechargement à chaud cd $(BACKEND) && uv run uvicorn app.main:create_app --factory --reload --host 0.0.0.0 --port 8000 +dev-frontend: ## Lance le frontend seul en rechargement à chaud + cd $(FRONTEND) && npm start + lint: ## Analyse statique du backend cd $(BACKEND) && uv run ruff check . diff --git a/README.md b/README.md index 75a1ab6..27affce 100644 --- a/README.md +++ b/README.md @@ -63,16 +63,17 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans ## Demarrage -Prerequis : uv, Docker. Le poste doit disposer de Python 3.14, que `uv` installe seul. +Prerequis : uv, Docker, Node 24 LTS (npm fourni). Le poste doit disposer de Python 3.14, que +`uv` installe seul. ```bash cp .env.example .env # variables de docker-compose cp apps/backend/.env.example apps/backend/.env # variables du backend hors conteneur make db-up # PostgreSQL + TimescaleDB, publie sur le port 5433 -make install # dependances du backend +make install # dependances du backend et du frontend make migrate # applique les migrations Alembic -make dev # API sur http://localhost:8000, docs sur /docs +make dev # backend sur http://localhost:8000 (docs sur /docs), frontend sur http://localhost:4200 make check # lint + typage + tests ``` @@ -83,9 +84,11 @@ Deux fichiers d'environnement, deux usages : `.env` a la racine alimente `docker 5432, souvent deja pris par une autre base. La boucle de developpement est `make db-up` puis `make dev` : seule la base tourne en -conteneur. Le service `backend` du `docker-compose.yml` sert la stack complete et la recette, -et n'embarque pas le source, donc toute modification y demande un -`docker compose up -d --build backend`. +conteneur, le backend et le frontend tournent tous les deux sur le poste, lances ensemble par +`make dev` (logs entrelaces dans le meme terminal, Ctrl+C arrete les deux). `make dev-backend` +et `make dev-frontend` restent disponibles pour lancer un seul des deux. Le service `backend` +du `docker-compose.yml` sert la stack complete et la recette, et n'embarque pas le source, donc +toute modification y demande un `docker compose up -d --build backend`. Verifier que la base repond et que l'extension est chargee : diff --git a/docs/architecture/10-infra.md b/docs/architecture/10-infra.md index 4e82445..745c6f5 100644 --- a/docs/architecture/10-infra.md +++ b/docs/architecture/10-infra.md @@ -35,9 +35,10 @@ flowchart TB | `backend` | Construite depuis `apps/backend` | `depends_on: db, condition: service_healthy`. **N'embarque pas le source** : toute modification impose `docker compose up -d --build backend` | **La boucle de développement n'utilise pas le service `backend`.** `make db-up` puis `make dev` : -seule la base tourne en conteneur, l'API tourne sur le poste avec le rechargement à chaud. Le -service `backend` sert la stack complète et la recette. Les deux occupent le port 8000, ils ne se -lancent donc pas ensemble. +seule la base tourne en conteneur, l'API et `ng serve` tournent sur le poste avec le rechargement +à chaud, lancés ensemble par `make dev` (`make dev-backend`/`make dev-frontend` pour lancer l'un +des deux seul). Le service `backend` sert la stack complète et la recette. Les deux occupent le +port 8000, ils ne se lancent donc pas ensemble. Deux pièges sont documentés en tête du `docker-compose.yml`, ils ne se devinent pas : diff --git a/docs/architecture/30-frontend.md b/docs/architecture/30-frontend.md index baea451..3c361e5 100644 --- a/docs/architecture/30-frontend.md +++ b/docs/architecture/30-frontend.md @@ -108,8 +108,9 @@ déploiement, en même temps que sera tranchée la question de l'ingress dans le message d'erreur arrive avant toute compilation. Un poste en 22.21 ou en 24.12 ne peut donc ni tester ni construire le frontend. -Le frontend **n'a pas de cible dans le `Makefile` racine** et **aucun service dans -`docker-compose.yml`** : il se pilote uniquement par `npm`, depuis `apps/frontend`. Le port 4200 +Le frontend a ses cibles dans le `Makefile` racine (`install-frontend`, `dev-frontend`, +englobées par `install` et `dev`), mais **aucun service dans `docker-compose.yml`** : en +développement il tourne toujours directement via `npm`, depuis `apps/frontend`. Le port 4200 n'apparaît dans le compose que comme valeur par défaut d'`APP_CORS_ORIGINS`, côté backend. Un `Dockerfile` frontend existe sur la branche `feat/pipeline-cd`, mais il est mono-étage et sans From 16a0cc4d3b4dfb35e7ec9425b0bc333afcde7e86 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 10:36:31 +0200 Subject: [PATCH 2/3] fix(build): stabilise make dev pour le frontend Desactive le prompt d'analytics Angular CLI (bloquait ng serve en sous-processus non interactif) et affiche les URLs backend/frontend au demarrage de make dev. --- Makefile | 6 ++++-- apps/frontend/angular.json | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 1576eae..05a8f7d 100644 --- a/Makefile +++ b/Makefile @@ -19,14 +19,16 @@ install-frontend: ## Installe les dépendances du frontend dev: ## Lance toute la stack (backend + frontend) en rechargement à chaud @trap 'kill 0' EXIT INT TERM; \ - $(MAKE) dev-backend & \ - $(MAKE) dev-frontend & \ + $(MAKE) --no-print-directory dev-backend & \ + $(MAKE) --no-print-directory dev-frontend & \ wait dev-backend: ## Lance l'API seule en rechargement à chaud + @echo "backend -> http://localhost:8000 (docs sur /docs)" cd $(BACKEND) && uv run uvicorn app.main:create_app --factory --reload --host 0.0.0.0 --port 8000 dev-frontend: ## Lance le frontend seul en rechargement à chaud + @echo "frontend -> http://localhost:4200" cd $(FRONTEND) && npm start lint: ## Analyse statique du backend diff --git a/apps/frontend/angular.json b/apps/frontend/angular.json index ddf87a3..814e4f8 100644 --- a/apps/frontend/angular.json +++ b/apps/frontend/angular.json @@ -2,7 +2,8 @@ "$schema": "./node_modules/@angular/cli/lib/config/schema.json", "version": 1, "cli": { - "packageManager": "npm" + "packageManager": "npm", + "analytics": false }, "newProjectRoot": "projects", "projects": { From 50dcb4de32b22a06ab8d0e44bc44e90a2227c756 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 11:45:58 +0200 Subject: [PATCH 3/3] feat(backend): expose GET /api/v1/stats/summary Ajoute le resume instantane de consommation du parc attendu par le frontend (deja developpe contre ce contrat en mode mock). Nouveaux SiteRepository et ReadingRepository (derniere lecture par site via DISTINCT ON), StatsService pour l'agregation et les cas de repli (capacite nulle, absence de lecture, data_quality inconnue), et le endpoint lecteur-seul correspondant. Documentation des routes et du schema des couches mises a jour. --- apps/backend/app/api/deps.py | 10 ++ apps/backend/app/api/v1/endpoints/stats.py | 16 +++ apps/backend/app/api/v1/router.py | 3 +- apps/backend/app/repositories/reading.py | 21 ++++ apps/backend/app/repositories/site.py | 15 +++ apps/backend/app/schemas/stats.py | 26 +++++ apps/backend/app/services/stats.py | 81 +++++++++++++ apps/backend/tests/api/test_stats.py | 73 ++++++++++++ .../tests/repositories/test_reading.py | 72 ++++++++++++ apps/backend/tests/repositories/test_site.py | 31 +++++ apps/backend/tests/services/test_stats.py | 107 ++++++++++++++++++ docs/architecture/20-backend.md | 11 +- 12 files changed, 460 insertions(+), 6 deletions(-) create mode 100644 apps/backend/app/api/v1/endpoints/stats.py create mode 100644 apps/backend/app/repositories/reading.py create mode 100644 apps/backend/app/repositories/site.py create mode 100644 apps/backend/app/schemas/stats.py create mode 100644 apps/backend/app/services/stats.py create mode 100644 apps/backend/tests/api/test_stats.py create mode 100644 apps/backend/tests/repositories/test_reading.py create mode 100644 apps/backend/tests/repositories/test_site.py create mode 100644 apps/backend/tests/services/test_stats.py diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index 4dc32cb..3407de3 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -23,9 +23,12 @@ from app.core.security import decode_access_token as decode_token from app.db.session import get_session from app.repositories.audit_log import AuditLogRepository from app.repositories.login_attempt import LoginAttemptRepository +from app.repositories.reading import ReadingRepository from app.repositories.refresh_token import RefreshTokenRepository +from app.repositories.site import SiteRepository from app.repositories.user import UserRepository from app.services.auth import AuthService, LoginPolicy +from app.services.stats import StatsService from app.services.user import UserService SessionDep = Annotated[AsyncSession, Depends(get_session)] @@ -131,6 +134,13 @@ def get_user_service( UserServiceDep = Annotated[UserService, Depends(get_user_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, diff --git a/apps/backend/app/api/v1/endpoints/stats.py b/apps/backend/app/api/v1/endpoints/stats.py new file mode 100644 index 0000000..5a8502e --- /dev/null +++ b/apps/backend/app/api/v1/endpoints/stats.py @@ -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) diff --git a/apps/backend/app/api/v1/router.py b/apps/backend/app/api/v1/router.py index 76e6f28..06e8852 100644 --- a/apps/backend/app/api/v1/router.py +++ b/apps/backend/app/api/v1/router.py @@ -1,8 +1,9 @@ from fastapi import APIRouter -from app.api.v1.endpoints import auth, health, users +from app.api.v1.endpoints import auth, health, stats, users api_router = APIRouter() api_router.include_router(health.router, prefix="/health", tags=["health"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(users.router, prefix="/users", tags=["users"]) +api_router.include_router(stats.router, prefix="/stats", tags=["stats"]) diff --git a/apps/backend/app/repositories/reading.py b/apps/backend/app/repositories/reading.py new file mode 100644 index 0000000..5424b46 --- /dev/null +++ b/apps/backend/app/repositories/reading.py @@ -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() diff --git a/apps/backend/app/repositories/site.py b/apps/backend/app/repositories/site.py new file mode 100644 index 0000000..cd36329 --- /dev/null +++ b/apps/backend/app/repositories/site.py @@ -0,0 +1,15 @@ +from collections.abc import Sequence + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Site + + +class SiteRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def list_all(self) -> Sequence[Site]: + requete = select(Site).order_by(Site.site_id) + return (await self._session.execute(requete)).scalars().all() diff --git a/apps/backend/app/schemas/stats.py b/apps/backend/app/schemas/stats.py new file mode 100644 index 0000000..b119d50 --- /dev/null +++ b/apps/backend/app/schemas/stats.py @@ -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] diff --git a/apps/backend/app/services/stats.py b/apps/backend/app/services/stats.py new file mode 100644 index 0000000..c98eace --- /dev/null +++ b/apps/backend/app/services/stats.py @@ -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, + ) diff --git a/apps/backend/tests/api/test_stats.py b/apps/backend/tests/api/test_stats.py new file mode 100644 index 0000000..8e4c439 --- /dev/null +++ b/apps/backend/tests/api/test_stats.py @@ -0,0 +1,73 @@ +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_stats_service +from app.core.principal import Principal +from app.core.roles import AccountKind, Role +from app.services.stats import ConsumptionSummary, SiteConsumption + + +def principal(role: Role = Role.LECTEUR) -> 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: + self.resume = ConsumptionSummary( + timestamp=datetime.now(UTC), + total_sites=1, + total_consumption_kw=87.34, + total_capacity_kw=200, + average_load_percent=43.7, + sites=[ + SiteConsumption( + site_id="SITE001", + site_name="Bureau Paris La Défense", + current_consumption_kw=87.34, + capacity_kw=200, + load_percent=43.7, + data_quality="good", + ) + ], + ) + + async def summary(self) -> ConsumptionSummary: + return self.resume + + +@pytest.fixture +def servi(app: FastAPI) -> Iterator[Callable[[], FauxService]]: + def installe() -> FauxService: + service = FauxService() + app.dependency_overrides[get_stats_service] = lambda: service + app.dependency_overrides[get_current_principal] = lambda: principal() + return service + + yield installe + app.dependency_overrides.pop(get_stats_service, None) + app.dependency_overrides.pop(get_current_principal, None) + + +async def test_get_summary_returns_the_service_result( + servi: Callable[[], FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/stats/summary") + + assert response.status_code == 200 + corps = response.json() + assert corps["total_sites"] == 1 + assert corps["sites"][0]["site_id"] == "SITE001" + assert corps["sites"][0]["data_quality"] == "good" diff --git a/apps/backend/tests/repositories/test_reading.py b/apps/backend/tests/repositories/test_reading.py new file mode 100644 index 0000000..650d49a --- /dev/null +++ b/apps/backend/tests/repositories/test_reading.py @@ -0,0 +1,72 @@ +import uuid +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Reading, Site +from app.repositories.reading import ReadingRepository + +pytestmark = pytest.mark.integration + + +def identifiant() -> str: + return f"SITE-{uuid.uuid4().hex[:8]}" + + +def lecture(site_id: str, *, timestamp: datetime, consumption_kw: float) -> Reading: + return Reading( + site_id=site_id, + timestamp=timestamp, + source="api_current", + consumption_kw=consumption_kw, + data_quality="good", + raw_data={}, + ) + + +async def test_latest_by_site_keeps_only_the_most_recent_reading(session: AsyncSession) -> None: + site_id = identifiant() + maintenant = datetime.now(UTC) + session.add(Site(site_id=site_id, site_name="Site", site_type="bureau", capacity_kw=100)) + await session.flush() + session.add_all( + [ + lecture(site_id, timestamp=maintenant - timedelta(hours=1), consumption_kw=10), + lecture(site_id, timestamp=maintenant, consumption_kw=42), + ] + ) + await session.flush() + depot = ReadingRepository(session) + + resultats = await depot.latest_by_site() + consommations = [r.consumption_kw for r in resultats if r.site_id == site_id] + await session.rollback() + + assert consommations == [42] + + +async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) -> None: + premier, second = identifiant(), identifiant() + maintenant = datetime.now(UTC) + session.add_all( + [ + Site(site_id=premier, site_name="A", site_type="bureau", capacity_kw=100), + Site(site_id=second, site_name="B", site_type="bureau", capacity_kw=200), + ] + ) + await session.flush() + session.add_all( + [ + lecture(premier, timestamp=maintenant, consumption_kw=10), + lecture(second, timestamp=maintenant, consumption_kw=20), + ] + ) + await session.flush() + depot = ReadingRepository(session) + + resultats = await depot.latest_by_site() + identifiants = {r.site_id for r in resultats if r.site_id in (premier, second)} + await session.rollback() + + assert identifiants == {premier, second} diff --git a/apps/backend/tests/repositories/test_site.py b/apps/backend/tests/repositories/test_site.py new file mode 100644 index 0000000..499e808 --- /dev/null +++ b/apps/backend/tests/repositories/test_site.py @@ -0,0 +1,31 @@ +import uuid + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.energy import Site +from app.repositories.site import SiteRepository + +pytestmark = pytest.mark.integration + + +def identifiant() -> str: + return f"SITE-{uuid.uuid4().hex[:8]}" + + +async def test_list_all_returns_every_site_sorted_by_id(session: AsyncSession) -> None: + premier, second = sorted([identifiant(), identifiant()]) + session.add_all( + [ + Site(site_id=second, site_name="B", site_type="bureau", capacity_kw=100), + Site(site_id=premier, site_name="A", site_type="bureau", capacity_kw=50), + ] + ) + await session.flush() + depot = SiteRepository(session) + + sites = await depot.list_all() + identifiants = [site.site_id for site in sites if site.site_id in (premier, second)] + await session.rollback() + + assert identifiants == [premier, second] diff --git a/apps/backend/tests/services/test_stats.py b/apps/backend/tests/services/test_stats.py new file mode 100644 index 0000000..1962678 --- /dev/null +++ b/apps/backend/tests/services/test_stats.py @@ -0,0 +1,107 @@ +from dataclasses import dataclass + +from app.services.stats import StatsService + + +@dataclass +class FauxSite: + site_id: str + site_name: str + capacity_kw: float | None + + +@dataclass +class FauxLecture: + site_id: str + consumption_kw: float | None + data_quality: str | None + + +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_summary_computes_totals_and_the_average_load() -> None: + service = StatsService( + sites=FauxDepotSites([FauxSite("A", "Site A", 200), FauxSite("B", "Site B", 800)]), # type: ignore[arg-type] + readings=FauxDepotLectures( # type: ignore[arg-type] + [ + FauxLecture("A", 100, "good"), + FauxLecture("B", 400, "good"), + ] + ), + ) + + resume = await service.summary() + + assert resume.total_sites == 2 + assert resume.total_consumption_kw == 500 + assert resume.total_capacity_kw == 1000 + assert resume.average_load_percent == 50 + par_site = {site.site_id: site for site in resume.sites} + assert par_site["A"].load_percent == 50 + assert par_site["B"].load_percent == 50 + + +async def test_summary_treats_a_site_without_any_reading_as_critical() -> None: + service = StatsService( + sites=FauxDepotSites([FauxSite("A", "Site A", 200)]), # type: ignore[arg-type] + readings=FauxDepotLectures([]), # type: ignore[arg-type] + ) + + resume = await service.summary() + + site = resume.sites[0] + assert site.data_quality == "critical" + assert site.current_consumption_kw is None + assert site.load_percent is None + + +async def test_summary_treats_a_reading_with_an_unknown_quality_as_critical() -> None: + service = StatsService( + sites=FauxDepotSites([FauxSite("A", "Site A", 200)]), # type: ignore[arg-type] + readings=FauxDepotLectures([FauxLecture("A", 50, None)]), # type: ignore[arg-type] + ) + + resume = await service.summary() + + site = resume.sites[0] + assert site.data_quality == "critical" + assert site.current_consumption_kw is None + + +async def test_summary_exposes_a_missing_capacity_as_zero_without_dividing_by_it() -> None: + service = StatsService( + sites=FauxDepotSites([FauxSite("A", "Site A", None)]), # type: ignore[arg-type] + readings=FauxDepotLectures([FauxLecture("A", 50, "good")]), # type: ignore[arg-type] + ) + + resume = await service.summary() + + site = resume.sites[0] + assert site.capacity_kw == 0 + assert site.current_consumption_kw == 50 + assert site.load_percent is None + + +async def test_summary_returns_zero_average_load_when_no_site_has_a_capacity() -> None: + service = StatsService( + sites=FauxDepotSites([FauxSite("A", "Site A", None)]), # type: ignore[arg-type] + readings=FauxDepotLectures([]), # type: ignore[arg-type] + ) + + resume = await service.summary() + + assert resume.average_load_percent == 0 diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index 8688a6a..d361323 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -12,11 +12,11 @@ Les quatre couches existent désormais, portées par l'authentification. ```mermaid flowchart TB - ep["endpoints
health, auth, users"] + ep["endpoints
health, auth, users, stats"] sc["schemas
Pydantic"] - sv["services
AuthService, UserService"] - rp["repositories
user, refresh_token,
login_attempt, audit_log"] - md["models
4 tables"] + sv["services
AuthService, UserService, StatsService"] + rp["repositories
user, refresh_token,
login_attempt, audit_log,
site, reading"] + md["models
6 tables"] db[("PostgreSQL")] ep --> sc @@ -140,6 +140,7 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke | POST | `/api/v1/users` | oui | Crée un compte, rend un mot de passe provisoire. `admin` | | PATCH | `/api/v1/users/{id}` | oui | Change le rôle ou l'activation. `admin` | | POST | `/api/v1/users/{id}/password-reset` | oui | Réinitialise et ferme les sessions. `admin` | +| GET | `/api/v1/stats/summary` | oui | Résume la consommation instantanée du parc. `lecteur` | | GET | `/metrics` | non | Format Prometheus. Jeton requis si `APP_METRICS_TOKEN` est posé | | GET | `/docs`, `/redoc`, `/openapi.json` | non | Fermés en `staging` et en `prod` | @@ -148,7 +149,7 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Rendre une route publique impose donc de modifier la liste dans ce fichier de test. -Aucune route métier n'existe à ce jour. Le contrat détaillé pour le frontend est dans +Le contrat détaillé pour le frontend est dans [31-contrat-authentification.md](31-contrat-authentification.md). ### `/health/ready`