Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ad19ed089 |
@@ -1,36 +1,18 @@
|
|||||||
BACKEND := apps/backend
|
BACKEND := apps/backend
|
||||||
FRONTEND := apps/frontend
|
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
.PHONY: help install install-backend install-frontend dev dev-backend dev-frontend \
|
.PHONY: help install dev lint format typecheck test test-cov test-integration check \
|
||||||
lint format typecheck test test-cov test-integration check \
|
|
||||||
openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin
|
openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin
|
||||||
|
|
||||||
help: ## Liste les cibles disponibles
|
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}'
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
install: install-backend install-frontend ## Installe les dépendances backend et frontend
|
install: ## Installe les dépendances du backend
|
||||||
|
|
||||||
install-backend: ## Installe les dépendances du backend
|
|
||||||
cd $(BACKEND) && uv sync --all-groups
|
cd $(BACKEND) && uv sync --all-groups
|
||||||
|
|
||||||
install-frontend: ## Installe les dépendances du frontend
|
dev: ## Lance l'API en rechargement à chaud
|
||||||
cd $(FRONTEND) && npm ci
|
|
||||||
|
|
||||||
dev: ## Lance toute la stack (backend + frontend) en rechargement à chaud
|
|
||||||
@trap 'kill 0' EXIT INT TERM; \
|
|
||||||
$(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
|
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
|
lint: ## Analyse statique du backend
|
||||||
cd $(BACKEND) && uv run ruff check .
|
cd $(BACKEND) && uv run ruff check .
|
||||||
|
|
||||||
|
|||||||
@@ -63,17 +63,16 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans
|
|||||||
|
|
||||||
## Demarrage
|
## Demarrage
|
||||||
|
|
||||||
Prerequis : uv, Docker, Node 24 LTS (npm fourni). Le poste doit disposer de Python 3.14, que
|
Prerequis : uv, Docker. Le poste doit disposer de Python 3.14, que `uv` installe seul.
|
||||||
`uv` installe seul.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env # variables de docker-compose
|
cp .env.example .env # variables de docker-compose
|
||||||
cp apps/backend/.env.example apps/backend/.env # variables du backend hors conteneur
|
cp apps/backend/.env.example apps/backend/.env # variables du backend hors conteneur
|
||||||
|
|
||||||
make db-up # PostgreSQL + TimescaleDB, publie sur le port 5433
|
make db-up # PostgreSQL + TimescaleDB, publie sur le port 5433
|
||||||
make install # dependances du backend et du frontend
|
make install # dependances du backend
|
||||||
make migrate # applique les migrations Alembic
|
make migrate # applique les migrations Alembic
|
||||||
make dev # backend sur http://localhost:8000 (docs sur /docs), frontend sur http://localhost:4200
|
make dev # API sur http://localhost:8000, docs sur /docs
|
||||||
make check # lint + typage + tests
|
make check # lint + typage + tests
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -84,11 +83,9 @@ Deux fichiers d'environnement, deux usages : `.env` a la racine alimente `docker
|
|||||||
5432, souvent deja pris par une autre base.
|
5432, souvent deja pris par une autre base.
|
||||||
|
|
||||||
La boucle de developpement est `make db-up` puis `make dev` : seule la base tourne en
|
La boucle de developpement est `make db-up` puis `make dev` : seule la base tourne en
|
||||||
conteneur, le backend et le frontend tournent tous les deux sur le poste, lances ensemble par
|
conteneur. Le service `backend` du `docker-compose.yml` sert la stack complete et la recette,
|
||||||
`make dev` (logs entrelaces dans le meme terminal, Ctrl+C arrete les deux). `make dev-backend`
|
et n'embarque pas le source, donc toute modification y demande un
|
||||||
et `make dev-frontend` restent disponibles pour lancer un seul des deux. Le service `backend`
|
`docker compose up -d --build 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 :
|
Verifier que la base repond et que l'extension est chargee :
|
||||||
|
|
||||||
|
|||||||
@@ -109,8 +109,6 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie
|
|||||||
| `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions | `admin` |
|
| `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions | `admin` |
|
||||||
| `/api/v1/sites` | Liste les sites | `lecteur` |
|
| `/api/v1/sites` | Liste les sites | `lecteur` |
|
||||||
| `/api/v1/sites/{site_id}` | Décrit un site | `lecteur` |
|
| `/api/v1/sites/{site_id}` | Décrit un site | `lecteur` |
|
||||||
| `/api/v1/recommendations` | Liste les recommandations | `lecteur` |
|
|
||||||
| `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation | `lecteur` |
|
|
||||||
| `/metrics` | Métriques au format Prometheus | jeton si `APP_METRICS_TOKEN` |
|
| `/metrics` | Métriques au format Prometheus | jeton si `APP_METRICS_TOKEN` |
|
||||||
| `/docs`, `/openapi.json` | Documentation, fermée en `staging` et `prod` | public sinon |
|
| `/docs`, `/openapi.json` | Documentation, fermée en `staging` et `prod` | public sinon |
|
||||||
|
|
||||||
|
|||||||
@@ -21,19 +21,13 @@ from app.core.roles import AccountKind, Role, has_at_least
|
|||||||
from app.core.security import TokenExpiredError, TokenInvalidError, TokenPolicy
|
from app.core.security import TokenExpiredError, TokenInvalidError, TokenPolicy
|
||||||
from app.core.security import decode_access_token as decode_token
|
from app.core.security import decode_access_token as decode_token
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.repositories.alert import AlertRepository
|
|
||||||
from app.repositories.audit_log import AuditLogRepository
|
from app.repositories.audit_log import AuditLogRepository
|
||||||
from app.repositories.login_attempt import LoginAttemptRepository
|
from app.repositories.login_attempt import LoginAttemptRepository
|
||||||
from app.repositories.reading import ReadingRepository
|
|
||||||
from app.repositories.recommendation import RecommendationRepository
|
|
||||||
from app.repositories.refresh_token import RefreshTokenRepository
|
from app.repositories.refresh_token import RefreshTokenRepository
|
||||||
from app.repositories.site import SiteRepository
|
from app.repositories.site import SiteRepository
|
||||||
from app.repositories.user import UserRepository
|
from app.repositories.user import UserRepository
|
||||||
from app.services.alert import AlertService
|
|
||||||
from app.services.auth import AuthService, LoginPolicy
|
from app.services.auth import AuthService, LoginPolicy
|
||||||
from app.services.recommendation import RecommendationService
|
|
||||||
from app.services.site import SiteService
|
from app.services.site import SiteService
|
||||||
from app.services.stats import StatsService
|
|
||||||
from app.services.user import UserService
|
from app.services.user import UserService
|
||||||
|
|
||||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||||
@@ -146,27 +140,6 @@ def get_site_service(session: SessionDep) -> SiteService:
|
|||||||
SiteServiceDep = Annotated[SiteService, Depends(get_site_service)]
|
SiteServiceDep = Annotated[SiteService, Depends(get_site_service)]
|
||||||
|
|
||||||
|
|
||||||
def get_alert_service(session: SessionDep) -> AlertService:
|
|
||||||
return AlertService(alerts=AlertRepository(session))
|
|
||||||
|
|
||||||
|
|
||||||
AlertServiceDep = Annotated[AlertService, Depends(get_alert_service)]
|
|
||||||
|
|
||||||
|
|
||||||
def get_recommendation_service(session: SessionDep) -> RecommendationService:
|
|
||||||
return RecommendationService(recommendations=RecommendationRepository(session))
|
|
||||||
|
|
||||||
|
|
||||||
RecommendationServiceDep = Annotated[RecommendationService, Depends(get_recommendation_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(
|
async def get_current_principal(
|
||||||
credentials: CredentialsDep,
|
credentials: CredentialsDep,
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
|
|||||||
@@ -54,23 +54,6 @@ TAGS: Final[list[dict[str, Any]]] = [
|
|||||||
"name": "sites",
|
"name": "sites",
|
||||||
"description": "Consultation du parc de sites. Accessible à partir du rôle `lecteur`.",
|
"description": "Consultation du parc de sites. Accessible à partir du rôle `lecteur`.",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "alerts",
|
|
||||||
"description": "Consultation des alertes de consommation. Accessible à partir du rôle "
|
|
||||||
"`lecteur`.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "recommendations",
|
|
||||||
"description": (
|
|
||||||
"Consultation des recommandations issues des alertes. Accessible à partir du rôle "
|
|
||||||
"`lecteur`."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "stats",
|
|
||||||
"description": "Statistiques agrégées de consommation. Accessible à partir du rôle "
|
|
||||||
"`lecteur`.",
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
cookie_de_rafraichissement = APIKeyCookie(
|
cookie_de_rafraichissement = APIKeyCookie(
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
from fastapi import APIRouter
|
|
||||||
|
|
||||||
from app.api.deps import AlertServiceDep, LecteurDep
|
|
||||||
from app.api.openapi import REPONSE_VALIDATION
|
|
||||||
from app.schemas.alert import AlertResponse, AlertSeverity
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"",
|
|
||||||
response_model=list[AlertResponse],
|
|
||||||
summary="Liste les alertes",
|
|
||||||
responses=REPONSE_VALIDATION,
|
|
||||||
)
|
|
||||||
async def list_alerts(
|
|
||||||
_: LecteurDep,
|
|
||||||
service: AlertServiceDep,
|
|
||||||
site_id: str | None = None,
|
|
||||||
severity: AlertSeverity | None = None,
|
|
||||||
) -> list[AlertResponse]:
|
|
||||||
alertes = await service.list_all(site_id=site_id, severity=severity)
|
|
||||||
return [AlertResponse.model_validate(alerte) for alerte in alertes]
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
from fastapi import APIRouter, HTTPException, status
|
|
||||||
|
|
||||||
from app.api.deps import LecteurDep, RecommendationServiceDep
|
|
||||||
from app.api.openapi import REPONSE_VALIDATION, Reponses
|
|
||||||
from app.schemas.errors import ErrorResponse
|
|
||||||
from app.schemas.recommendation import RecommendationResponse
|
|
||||||
from app.services.recommendation import RecommendationNotFoundError
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
REPONSES_INTROUVABLE: Reponses = {
|
|
||||||
**REPONSE_VALIDATION,
|
|
||||||
404: {"model": ErrorResponse, "description": "Aucune recommandation ne porte cet identifiant."},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[RecommendationResponse], summary="Liste les recommandations")
|
|
||||||
async def list_recommendations(
|
|
||||||
_: LecteurDep, service: RecommendationServiceDep
|
|
||||||
) -> list[RecommendationResponse]:
|
|
||||||
recommendations = await service.list_all()
|
|
||||||
return [RecommendationResponse.model_validate(r) for r in recommendations]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/{recommendation_id}",
|
|
||||||
response_model=RecommendationResponse,
|
|
||||||
summary="Décrit une recommandation",
|
|
||||||
responses=REPONSES_INTROUVABLE,
|
|
||||||
)
|
|
||||||
async def get_recommendation(
|
|
||||||
recommendation_id: int, _: LecteurDep, service: RecommendationServiceDep
|
|
||||||
) -> RecommendationResponse:
|
|
||||||
try:
|
|
||||||
recommendation = await service.get_by_id(recommendation_id)
|
|
||||||
except RecommendationNotFoundError as erreur:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Recommandation introuvable"
|
|
||||||
) from erreur
|
|
||||||
return RecommendationResponse.model_validate(recommendation)
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,20 +1,10 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR
|
from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR
|
||||||
from app.api.v1.endpoints import alerts, auth, health, recommendations, sites, stats, users
|
from app.api.v1.endpoints import auth, health, sites, users
|
||||||
|
|
||||||
api_router = APIRouter(responses=REPONSE_SERVEUR)
|
api_router = APIRouter(responses=REPONSE_SERVEUR)
|
||||||
api_router.include_router(health.router, prefix="/health", tags=["health"])
|
api_router.include_router(health.router, prefix="/health", tags=["health"])
|
||||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||||
api_router.include_router(users.router, prefix="/users", tags=["users"], responses=REPONSES_ADMIN)
|
api_router.include_router(users.router, prefix="/users", tags=["users"], responses=REPONSES_ADMIN)
|
||||||
api_router.include_router(sites.router, prefix="/sites", tags=["sites"], responses=REPONSES_LECTEUR)
|
api_router.include_router(sites.router, prefix="/sites", tags=["sites"], responses=REPONSES_LECTEUR)
|
||||||
api_router.include_router(
|
|
||||||
alerts.router, prefix="/alerts", tags=["alerts"], responses=REPONSES_LECTEUR
|
|
||||||
)
|
|
||||||
api_router.include_router(
|
|
||||||
recommendations.router,
|
|
||||||
prefix="/recommendations",
|
|
||||||
tags=["recommendations"],
|
|
||||||
responses=REPONSES_LECTEUR,
|
|
||||||
)
|
|
||||||
api_router.include_router(stats.router, prefix="/stats", tags=["stats"], responses=REPONSES_LECTEUR)
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
from collections.abc import Sequence
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.models.energy import Alert
|
|
||||||
|
|
||||||
|
|
||||||
class AlertRepository:
|
|
||||||
def __init__(self, session: AsyncSession) -> None:
|
|
||||||
self._session = session
|
|
||||||
|
|
||||||
async def list_all(
|
|
||||||
self, *, site_id: str | None = None, severity: str | None = None
|
|
||||||
) -> Sequence[Alert]:
|
|
||||||
requete = select(Alert).order_by(Alert.timestamp.desc(), Alert.alert_id.desc())
|
|
||||||
if site_id is not None:
|
|
||||||
requete = requete.where(Alert.site_id == site_id)
|
|
||||||
if severity is not None:
|
|
||||||
requete = requete.where(Alert.severity == severity)
|
|
||||||
return (await self._session.scalars(requete)).all()
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
from collections.abc import Sequence
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.models.energy import Recommendation
|
|
||||||
|
|
||||||
|
|
||||||
class RecommendationRepository:
|
|
||||||
def __init__(self, session: AsyncSession) -> None:
|
|
||||||
self._session = session
|
|
||||||
|
|
||||||
async def list_all(self) -> Sequence[Recommendation]:
|
|
||||||
requete = select(Recommendation).order_by(Recommendation.recommendation_id)
|
|
||||||
return (await self._session.scalars(requete)).all()
|
|
||||||
|
|
||||||
async def get_by_id(self, recommendation_id: int) -> Recommendation | None:
|
|
||||||
requete = select(Recommendation).where(
|
|
||||||
Recommendation.recommendation_id == recommendation_id
|
|
||||||
)
|
|
||||||
recommendation: Recommendation | None = await self._session.scalar(requete)
|
|
||||||
return recommendation
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from enum import StrEnum
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
|
||||||
|
|
||||||
|
|
||||||
class AlertType(StrEnum):
|
|
||||||
SPIKE = "spike"
|
|
||||||
THRESHOLD = "threshold"
|
|
||||||
ANOMALY = "anomaly"
|
|
||||||
OUTAGE = "outage"
|
|
||||||
SENSOR = "sensor"
|
|
||||||
|
|
||||||
|
|
||||||
class AlertSeverity(StrEnum):
|
|
||||||
LOW = "low"
|
|
||||||
MEDIUM = "medium"
|
|
||||||
HIGH = "high"
|
|
||||||
CRITICAL = "critical"
|
|
||||||
|
|
||||||
|
|
||||||
class AlertResponse(BaseModel):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
alert_id: int
|
|
||||||
site_id: str
|
|
||||||
timestamp: datetime
|
|
||||||
type: AlertType
|
|
||||||
severity: AlertSeverity
|
|
||||||
message: str
|
|
||||||
value: float | None
|
|
||||||
threshold: float | None
|
|
||||||
metric: str | None
|
|
||||||
prediction_id: int | None
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
|
||||||
|
|
||||||
|
|
||||||
class RecommendationResponse(BaseModel):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
recommendation_id: int
|
|
||||||
alert_id: int
|
|
||||||
action: str
|
|
||||||
explanation: str
|
|
||||||
rule_reference: str
|
|
||||||
created_at: datetime
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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]
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
from collections.abc import Sequence
|
|
||||||
|
|
||||||
from app.models.energy import Alert
|
|
||||||
from app.repositories.alert import AlertRepository
|
|
||||||
|
|
||||||
|
|
||||||
class AlertService:
|
|
||||||
def __init__(self, *, alerts: AlertRepository) -> None:
|
|
||||||
self._alerts = alerts
|
|
||||||
|
|
||||||
async def list_all(
|
|
||||||
self, *, site_id: str | None = None, severity: str | None = None
|
|
||||||
) -> Sequence[Alert]:
|
|
||||||
return await self._alerts.list_all(site_id=site_id, severity=severity)
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
from collections.abc import Sequence
|
|
||||||
|
|
||||||
from app.models.energy import Recommendation
|
|
||||||
from app.repositories.recommendation import RecommendationRepository
|
|
||||||
|
|
||||||
|
|
||||||
class RecommendationError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class RecommendationNotFoundError(RecommendationError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class RecommendationService:
|
|
||||||
def __init__(self, *, recommendations: RecommendationRepository) -> None:
|
|
||||||
self._recommendations = recommendations
|
|
||||||
|
|
||||||
async def list_all(self) -> Sequence[Recommendation]:
|
|
||||||
return await self._recommendations.list_all()
|
|
||||||
|
|
||||||
async def get_by_id(self, recommendation_id: int) -> Recommendation:
|
|
||||||
recommendation = await self._recommendations.get_by_id(recommendation_id)
|
|
||||||
if recommendation is None:
|
|
||||||
raise RecommendationNotFoundError(recommendation_id)
|
|
||||||
return recommendation
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
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,
|
|
||||||
)
|
|
||||||
@@ -920,313 +920,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"/api/v1/alerts": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"alerts"
|
|
||||||
],
|
|
||||||
"summary": "Liste les alertes",
|
|
||||||
"operationId": "list_alerts_api_v1_alerts_get",
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"Jeton d'accès": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "site_id",
|
|
||||||
"in": "query",
|
|
||||||
"required": false,
|
|
||||||
"schema": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Site Id"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "severity",
|
|
||||||
"in": "query",
|
|
||||||
"required": false,
|
|
||||||
"schema": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/AlertSeverity"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Severity"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Successful Response",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/AlertResponse"
|
|
||||||
},
|
|
||||||
"title": "Response List Alerts Api V1 Alerts Get"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"500": {
|
|
||||||
"description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/InternalErrorResponse"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ErrorResponse"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"description": "Mot de passe provisoire à changer (`detail` vaut `password_change_required`).",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ErrorResponse"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"422": {
|
|
||||||
"description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ValidationErrorResponse"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/v1/recommendations": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"recommendations"
|
|
||||||
],
|
|
||||||
"summary": "Liste les recommandations",
|
|
||||||
"operationId": "list_recommendations_api_v1_recommendations_get",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Successful Response",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/RecommendationResponse"
|
|
||||||
},
|
|
||||||
"type": "array",
|
|
||||||
"title": "Response List Recommendations Api V1 Recommendations Get"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"500": {
|
|
||||||
"description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/InternalErrorResponse"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ErrorResponse"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"description": "Mot de passe provisoire à changer (`detail` vaut `password_change_required`).",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ErrorResponse"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"Jeton d'accès": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/v1/recommendations/{recommendation_id}": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"recommendations"
|
|
||||||
],
|
|
||||||
"summary": "Décrit une recommandation",
|
|
||||||
"operationId": "get_recommendation_api_v1_recommendations__recommendation_id__get",
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"Jeton d'accès": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "recommendation_id",
|
|
||||||
"in": "path",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"type": "integer",
|
|
||||||
"title": "Recommendation Id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Successful Response",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/RecommendationResponse"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"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": "Aucune recommandation ne porte cet identifiant.",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ErrorResponse"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/v1/stats/summary": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"stats"
|
|
||||||
],
|
|
||||||
"summary": "Résume la consommation instantanée du parc",
|
|
||||||
"operationId": "get_summary_api_v1_stats_summary_get",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Successful Response",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/StatsSummaryResponse"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"Jeton d'accès": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
@@ -1239,112 +932,6 @@
|
|||||||
],
|
],
|
||||||
"title": "AccountKind"
|
"title": "AccountKind"
|
||||||
},
|
},
|
||||||
"AlertResponse": {
|
|
||||||
"properties": {
|
|
||||||
"alert_id": {
|
|
||||||
"type": "integer",
|
|
||||||
"title": "Alert Id"
|
|
||||||
},
|
|
||||||
"site_id": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Site Id"
|
|
||||||
},
|
|
||||||
"timestamp": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "date-time",
|
|
||||||
"title": "Timestamp"
|
|
||||||
},
|
|
||||||
"type": {
|
|
||||||
"$ref": "#/components/schemas/AlertType"
|
|
||||||
},
|
|
||||||
"severity": {
|
|
||||||
"$ref": "#/components/schemas/AlertSeverity"
|
|
||||||
},
|
|
||||||
"message": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Message"
|
|
||||||
},
|
|
||||||
"value": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Value"
|
|
||||||
},
|
|
||||||
"threshold": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Threshold"
|
|
||||||
},
|
|
||||||
"metric": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Metric"
|
|
||||||
},
|
|
||||||
"prediction_id": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Prediction Id"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"alert_id",
|
|
||||||
"site_id",
|
|
||||||
"timestamp",
|
|
||||||
"type",
|
|
||||||
"severity",
|
|
||||||
"message",
|
|
||||||
"value",
|
|
||||||
"threshold",
|
|
||||||
"metric",
|
|
||||||
"prediction_id"
|
|
||||||
],
|
|
||||||
"title": "AlertResponse"
|
|
||||||
},
|
|
||||||
"AlertSeverity": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"low",
|
|
||||||
"medium",
|
|
||||||
"high",
|
|
||||||
"critical"
|
|
||||||
],
|
|
||||||
"title": "AlertSeverity"
|
|
||||||
},
|
|
||||||
"AlertType": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"spike",
|
|
||||||
"threshold",
|
|
||||||
"anomaly",
|
|
||||||
"outage",
|
|
||||||
"sensor"
|
|
||||||
],
|
|
||||||
"title": "AlertType"
|
|
||||||
},
|
|
||||||
"ErrorResponse": {
|
"ErrorResponse": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"detail": {
|
"detail": {
|
||||||
@@ -1524,45 +1111,6 @@
|
|||||||
],
|
],
|
||||||
"title": "ReadinessStatus"
|
"title": "ReadinessStatus"
|
||||||
},
|
},
|
||||||
"RecommendationResponse": {
|
|
||||||
"properties": {
|
|
||||||
"recommendation_id": {
|
|
||||||
"type": "integer",
|
|
||||||
"title": "Recommendation Id"
|
|
||||||
},
|
|
||||||
"alert_id": {
|
|
||||||
"type": "integer",
|
|
||||||
"title": "Alert Id"
|
|
||||||
},
|
|
||||||
"action": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Action"
|
|
||||||
},
|
|
||||||
"explanation": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Explanation"
|
|
||||||
},
|
|
||||||
"rule_reference": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Rule Reference"
|
|
||||||
},
|
|
||||||
"created_at": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "date-time",
|
|
||||||
"title": "Created At"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"recommendation_id",
|
|
||||||
"alert_id",
|
|
||||||
"action",
|
|
||||||
"explanation",
|
|
||||||
"rule_reference",
|
|
||||||
"created_at"
|
|
||||||
],
|
|
||||||
"title": "RecommendationResponse"
|
|
||||||
},
|
|
||||||
"Role": {
|
"Role": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -1631,106 +1179,6 @@
|
|||||||
],
|
],
|
||||||
"title": "SiteResponse"
|
"title": "SiteResponse"
|
||||||
},
|
},
|
||||||
"SiteSummaryResponse": {
|
|
||||||
"properties": {
|
|
||||||
"site_id": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Site Id"
|
|
||||||
},
|
|
||||||
"site_name": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Site Name"
|
|
||||||
},
|
|
||||||
"current_consumption_kw": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Current Consumption Kw"
|
|
||||||
},
|
|
||||||
"capacity_kw": {
|
|
||||||
"type": "number",
|
|
||||||
"title": "Capacity Kw"
|
|
||||||
},
|
|
||||||
"load_percent": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Load Percent"
|
|
||||||
},
|
|
||||||
"data_quality": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"good",
|
|
||||||
"partial",
|
|
||||||
"degraded",
|
|
||||||
"critical"
|
|
||||||
],
|
|
||||||
"title": "Data Quality"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"site_id",
|
|
||||||
"site_name",
|
|
||||||
"current_consumption_kw",
|
|
||||||
"capacity_kw",
|
|
||||||
"load_percent",
|
|
||||||
"data_quality"
|
|
||||||
],
|
|
||||||
"title": "SiteSummaryResponse"
|
|
||||||
},
|
|
||||||
"StatsSummaryResponse": {
|
|
||||||
"properties": {
|
|
||||||
"timestamp": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "date-time",
|
|
||||||
"title": "Timestamp"
|
|
||||||
},
|
|
||||||
"total_sites": {
|
|
||||||
"type": "integer",
|
|
||||||
"title": "Total Sites"
|
|
||||||
},
|
|
||||||
"total_consumption_kw": {
|
|
||||||
"type": "number",
|
|
||||||
"title": "Total Consumption Kw"
|
|
||||||
},
|
|
||||||
"total_capacity_kw": {
|
|
||||||
"type": "number",
|
|
||||||
"title": "Total Capacity Kw"
|
|
||||||
},
|
|
||||||
"average_load_percent": {
|
|
||||||
"type": "number",
|
|
||||||
"title": "Average Load Percent"
|
|
||||||
},
|
|
||||||
"sites": {
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/SiteSummaryResponse"
|
|
||||||
},
|
|
||||||
"type": "array",
|
|
||||||
"title": "Sites"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"timestamp",
|
|
||||||
"total_sites",
|
|
||||||
"total_consumption_kw",
|
|
||||||
"total_capacity_kw",
|
|
||||||
"average_load_percent",
|
|
||||||
"sites"
|
|
||||||
],
|
|
||||||
"title": "StatsSummaryResponse"
|
|
||||||
},
|
|
||||||
"TemporaryPasswordResponse": {
|
"TemporaryPasswordResponse": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"user": {
|
"user": {
|
||||||
@@ -1947,18 +1395,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sites",
|
"name": "sites",
|
||||||
"description": "Consultation du parc de sites. Accessible à partir du rôle `lecteur`."
|
"description": "Consultation du parc de sites. Accessible à partir du rôle `lecteur`."
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "alerts",
|
|
||||||
"description": "Consultation des alertes de consommation. Accessible à partir du rôle `lecteur`."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "recommendations",
|
|
||||||
"description": "Consultation des recommandations issues des alertes. Accessible à partir du rôle `lecteur`."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "stats",
|
|
||||||
"description": "Statistiques agrégées de consommation. Accessible à partir du rôle `lecteur`."
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
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_alert_service, get_current_principal
|
|
||||||
from app.core.principal import Principal
|
|
||||||
from app.core.roles import AccountKind, Role
|
|
||||||
from app.models.energy import Alert
|
|
||||||
from app.schemas.alert import AlertSeverity
|
|
||||||
|
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def alert(alert_id: int = 1, site_id: str = "site-1", severity: str = "high") -> Alert:
|
|
||||||
return Alert(
|
|
||||||
alert_id=alert_id,
|
|
||||||
source_alert_id=f"ALR-{alert_id}",
|
|
||||||
site_id=site_id,
|
|
||||||
source="enervision",
|
|
||||||
timestamp=datetime(2026, 9, 16, tzinfo=UTC),
|
|
||||||
type="threshold",
|
|
||||||
severity=severity,
|
|
||||||
message="Dépassement du seuil configuré",
|
|
||||||
value=812.5,
|
|
||||||
threshold=720.0,
|
|
||||||
metric="consumption_kw",
|
|
||||||
prediction_id=None,
|
|
||||||
raw_data={},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class FauxService:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.alert = alert()
|
|
||||||
self.appels: list[tuple[str | None, str | None]] = []
|
|
||||||
|
|
||||||
async def list_all(
|
|
||||||
self, *, site_id: str | None = None, severity: str | None = None
|
|
||||||
) -> list[Alert]:
|
|
||||||
self.appels.append((site_id, severity))
|
|
||||||
return [self.alert]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def lecteur_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, lecteur_connecte: None) -> Iterator[Callable[[], FauxService]]:
|
|
||||||
def installe() -> FauxService:
|
|
||||||
service = FauxService()
|
|
||||||
app.dependency_overrides[get_alert_service] = lambda: service
|
|
||||||
return service
|
|
||||||
|
|
||||||
yield installe
|
|
||||||
app.dependency_overrides.pop(get_alert_service, None)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_list_alerts_returns_the_alerts(
|
|
||||||
servi: Callable[[], FauxService], client: AsyncClient
|
|
||||||
) -> None:
|
|
||||||
servi()
|
|
||||||
|
|
||||||
response = await client.get("/api/v1/alerts")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
corps = response.json()
|
|
||||||
assert corps == [
|
|
||||||
{
|
|
||||||
"alert_id": 1,
|
|
||||||
"site_id": "site-1",
|
|
||||||
"timestamp": "2026-09-16T00:00:00Z",
|
|
||||||
"type": "threshold",
|
|
||||||
"severity": "high",
|
|
||||||
"message": "Dépassement du seuil configuré",
|
|
||||||
"value": 812.5,
|
|
||||||
"threshold": 720.0,
|
|
||||||
"metric": "consumption_kw",
|
|
||||||
"prediction_id": None,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_list_alerts_transmits_the_site_id_filter(
|
|
||||||
servi: Callable[[], FauxService], client: AsyncClient
|
|
||||||
) -> None:
|
|
||||||
service = servi()
|
|
||||||
|
|
||||||
await client.get("/api/v1/alerts?site_id=site-1")
|
|
||||||
|
|
||||||
assert service.appels == [("site-1", None)]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_list_alerts_transmits_the_severity_filter(
|
|
||||||
servi: Callable[[], FauxService], client: AsyncClient
|
|
||||||
) -> None:
|
|
||||||
service = servi()
|
|
||||||
|
|
||||||
await client.get("/api/v1/alerts?severity=critical")
|
|
||||||
|
|
||||||
assert service.appels == [(None, AlertSeverity.CRITICAL)]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_list_alerts_returns_422_for_an_unknown_severity(
|
|
||||||
servi: Callable[[], FauxService], client: AsyncClient
|
|
||||||
) -> None:
|
|
||||||
servi()
|
|
||||||
|
|
||||||
response = await client.get("/api/v1/alerts?severity=invalide")
|
|
||||||
|
|
||||||
assert response.status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
async def test_list_alerts_returns_an_empty_list_when_there_is_nothing(
|
|
||||||
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
|
|
||||||
) -> None:
|
|
||||||
fake_session(result=[])
|
|
||||||
|
|
||||||
response = await client.get("/api/v1/alerts")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == []
|
|
||||||
@@ -31,10 +31,6 @@ ROUTES_A_ROLE = {
|
|||||||
("POST", "/api/v1/users/{id}/password-reset"),
|
("POST", "/api/v1/users/{id}/password-reset"),
|
||||||
("GET", "/api/v1/sites"),
|
("GET", "/api/v1/sites"),
|
||||||
("GET", "/api/v1/sites/{site_id}"),
|
("GET", "/api/v1/sites/{site_id}"),
|
||||||
("GET", "/api/v1/alerts"),
|
|
||||||
("GET", "/api/v1/recommendations"),
|
|
||||||
("GET", "/api/v1/recommendations/{recommendation_id}"),
|
|
||||||
("GET", "/api/v1/stats/summary"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
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_recommendation_service
|
|
||||||
from app.core.principal import Principal
|
|
||||||
from app.core.roles import AccountKind, Role
|
|
||||||
from app.models.energy import Recommendation
|
|
||||||
from app.services.recommendation import RecommendationNotFoundError
|
|
||||||
|
|
||||||
MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
|
|
||||||
|
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def recommendation(recommendation_id: int = 1) -> Recommendation:
|
|
||||||
return Recommendation(
|
|
||||||
recommendation_id=recommendation_id,
|
|
||||||
alert_id=1,
|
|
||||||
action="Vérifier la consommation",
|
|
||||||
explanation="Pic détecté",
|
|
||||||
rule_reference="spike-v1",
|
|
||||||
created_at=MOMENT,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class FauxService:
|
|
||||||
def __init__(self, erreur: Exception | None = None) -> None:
|
|
||||||
self._erreur = erreur
|
|
||||||
self.recommendation = recommendation()
|
|
||||||
|
|
||||||
async def list_all(self) -> list[Recommendation]:
|
|
||||||
return [self.recommendation]
|
|
||||||
|
|
||||||
async def get_by_id(self, recommendation_id: int) -> Recommendation:
|
|
||||||
if self._erreur is not None:
|
|
||||||
raise self._erreur
|
|
||||||
return self.recommendation
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def lecteur_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, lecteur_connecte: None
|
|
||||||
) -> Iterator[Callable[[Exception | None], FauxService]]:
|
|
||||||
def installe(erreur: Exception | None = None) -> FauxService:
|
|
||||||
service = FauxService(erreur)
|
|
||||||
app.dependency_overrides[get_recommendation_service] = lambda: service
|
|
||||||
return service
|
|
||||||
|
|
||||||
yield installe
|
|
||||||
app.dependency_overrides.pop(get_recommendation_service, None)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_list_recommendations_returns_the_recommendations(
|
|
||||||
servi: Callable[..., FauxService], client: AsyncClient
|
|
||||||
) -> None:
|
|
||||||
servi()
|
|
||||||
|
|
||||||
response = await client.get("/api/v1/recommendations")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
corps = response.json()
|
|
||||||
assert corps == [
|
|
||||||
{
|
|
||||||
"recommendation_id": 1,
|
|
||||||
"alert_id": 1,
|
|
||||||
"action": "Vérifier la consommation",
|
|
||||||
"explanation": "Pic détecté",
|
|
||||||
"rule_reference": "spike-v1",
|
|
||||||
"created_at": "2024-01-01T00:00:00Z",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_get_recommendation_returns_the_matching_recommendation(
|
|
||||||
servi: Callable[..., FauxService], client: AsyncClient
|
|
||||||
) -> None:
|
|
||||||
servi()
|
|
||||||
|
|
||||||
response = await client.get("/api/v1/recommendations/1")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["recommendation_id"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
async def test_get_recommendation_returns_404_for_an_unknown_recommendation(
|
|
||||||
servi: Callable[..., FauxService], client: AsyncClient
|
|
||||||
) -> None:
|
|
||||||
servi(RecommendationNotFoundError(404))
|
|
||||||
|
|
||||||
response = await client.get("/api/v1/recommendations/404")
|
|
||||||
|
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
async def test_list_recommendations_reaches_the_repository_through_the_session(
|
|
||||||
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
|
|
||||||
) -> None:
|
|
||||||
fake_session(result=[recommendation(1), recommendation(2)])
|
|
||||||
|
|
||||||
response = await client.get("/api/v1/recommendations")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert [r["recommendation_id"] for r in response.json()] == [1, 2]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_get_recommendation_reaches_the_repository_through_the_session(
|
|
||||||
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
|
|
||||||
) -> None:
|
|
||||||
fake_session(result=recommendation(1))
|
|
||||||
|
|
||||||
response = await client.get("/api/v1/recommendations/1")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["recommendation_id"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
async def test_get_recommendation_returns_404_when_the_session_finds_nothing(
|
|
||||||
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
|
|
||||||
) -> None:
|
|
||||||
fake_session(result=None)
|
|
||||||
|
|
||||||
response = await client.get("/api/v1/recommendations/404")
|
|
||||||
|
|
||||||
assert response.status_code == 404
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
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"
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.models.energy import Alert
|
|
||||||
from app.repositories.alert import AlertRepository
|
|
||||||
from app.schemas.alert import AlertSeverity
|
|
||||||
from tests.repositories.test_site import creer as creer_site
|
|
||||||
from tests.repositories.test_site import identifiant as identifiant_site
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
|
||||||
|
|
||||||
|
|
||||||
async def creer_alerte(session: AsyncSession, *, site_id: str, **overrides: object) -> Alert:
|
|
||||||
alerte = Alert(
|
|
||||||
source_alert_id=overrides.get("source_alert_id", f"ALR-{uuid.uuid4().hex[:12]}"),
|
|
||||||
site_id=site_id,
|
|
||||||
source=overrides.get("source", "enervision"),
|
|
||||||
timestamp=overrides.get("timestamp", datetime(2026, 9, 16, tzinfo=UTC)),
|
|
||||||
type=overrides.get("type", "threshold"),
|
|
||||||
severity=overrides.get("severity", "high"),
|
|
||||||
message=overrides.get("message", "Dépassement du seuil configuré"),
|
|
||||||
value=overrides.get("value", 812.5),
|
|
||||||
threshold=overrides.get("threshold", 720.0),
|
|
||||||
metric=overrides.get("metric", "consumption_kw"),
|
|
||||||
prediction_id=overrides.get("prediction_id"),
|
|
||||||
raw_data=overrides.get("raw_data", {}),
|
|
||||||
)
|
|
||||||
session.add(alerte)
|
|
||||||
await session.flush()
|
|
||||||
return alerte
|
|
||||||
|
|
||||||
|
|
||||||
async def test_list_all_returns_the_alerts_sorted_by_timestamp_descending(
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> None:
|
|
||||||
site = await creer_site(session)
|
|
||||||
depot = AlertRepository(session)
|
|
||||||
ancienne = await creer_alerte(
|
|
||||||
session, site_id=site.site_id, timestamp=datetime(2026, 9, 1, tzinfo=UTC)
|
|
||||||
)
|
|
||||||
recente = await creer_alerte(
|
|
||||||
session, site_id=site.site_id, timestamp=datetime(2026, 9, 15, tzinfo=UTC)
|
|
||||||
)
|
|
||||||
|
|
||||||
alertes = await depot.list_all()
|
|
||||||
identifiants = [
|
|
||||||
a.alert_id for a in alertes if a.alert_id in (ancienne.alert_id, recente.alert_id)
|
|
||||||
]
|
|
||||||
await session.rollback()
|
|
||||||
|
|
||||||
assert identifiants == [recente.alert_id, ancienne.alert_id]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_list_all_filters_by_site_id(session: AsyncSession) -> None:
|
|
||||||
premier = await creer_site(session)
|
|
||||||
second = await creer_site(session)
|
|
||||||
depot = AlertRepository(session)
|
|
||||||
voulue = await creer_alerte(session, site_id=premier.site_id)
|
|
||||||
await creer_alerte(session, site_id=second.site_id)
|
|
||||||
|
|
||||||
alertes = await depot.list_all(site_id=premier.site_id)
|
|
||||||
identifiants = [a.alert_id for a in alertes]
|
|
||||||
await session.rollback()
|
|
||||||
|
|
||||||
assert identifiants == [voulue.alert_id]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_list_all_filters_by_severity(session: AsyncSession) -> None:
|
|
||||||
site = await creer_site(session)
|
|
||||||
depot = AlertRepository(session)
|
|
||||||
voulue = await creer_alerte(session, site_id=site.site_id, severity="critical")
|
|
||||||
await creer_alerte(session, site_id=site.site_id, severity="low")
|
|
||||||
|
|
||||||
alertes = await depot.list_all(severity=AlertSeverity.CRITICAL)
|
|
||||||
identifiants = [a.alert_id for a in alertes]
|
|
||||||
await session.rollback()
|
|
||||||
|
|
||||||
assert identifiants == [voulue.alert_id]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_list_all_returns_an_empty_list_when_there_is_nothing(
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> None:
|
|
||||||
depot = AlertRepository(session)
|
|
||||||
|
|
||||||
alertes = await depot.list_all(site_id=identifiant_site())
|
|
||||||
|
|
||||||
assert list(alertes) == []
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
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}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.models.energy import Alert, Recommendation, Site
|
|
||||||
from app.repositories.recommendation import RecommendationRepository
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
|
||||||
|
|
||||||
MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
|
|
||||||
|
|
||||||
|
|
||||||
async def creer_site(session: AsyncSession) -> str:
|
|
||||||
site_id = f"TEST-{uuid.uuid4()}"
|
|
||||||
session.add(Site(site_id=site_id, site_name="Site de test", site_type="office"))
|
|
||||||
await session.flush()
|
|
||||||
return site_id
|
|
||||||
|
|
||||||
|
|
||||||
async def creer_alerte(session: AsyncSession) -> int:
|
|
||||||
site_id = await creer_site(session)
|
|
||||||
alerte = Alert(
|
|
||||||
source_alert_id=str(uuid.uuid4()),
|
|
||||||
site_id=site_id,
|
|
||||||
source="api_mock",
|
|
||||||
timestamp=MOMENT,
|
|
||||||
type="spike",
|
|
||||||
severity="high",
|
|
||||||
message="Test",
|
|
||||||
raw_data={},
|
|
||||||
)
|
|
||||||
session.add(alerte)
|
|
||||||
await session.flush()
|
|
||||||
return alerte.alert_id
|
|
||||||
|
|
||||||
|
|
||||||
async def creer(session: AsyncSession, **overrides: object) -> Recommendation:
|
|
||||||
recommendation = Recommendation(
|
|
||||||
alert_id=overrides.get("alert_id") or await creer_alerte(session),
|
|
||||||
action=overrides.get("action", "Vérifier la consommation"),
|
|
||||||
explanation=overrides.get("explanation", "Pic détecté"),
|
|
||||||
rule_reference=overrides.get("rule_reference", f"spike-{uuid.uuid4().hex[:8]}"),
|
|
||||||
)
|
|
||||||
session.add(recommendation)
|
|
||||||
await session.flush()
|
|
||||||
return recommendation
|
|
||||||
|
|
||||||
|
|
||||||
async def test_get_by_id_returns_the_matching_recommendation(session: AsyncSession) -> None:
|
|
||||||
depot = RecommendationRepository(session)
|
|
||||||
cree = await creer(session)
|
|
||||||
|
|
||||||
trouve = await depot.get_by_id(cree.recommendation_id)
|
|
||||||
action = trouve.action if trouve else None
|
|
||||||
await session.rollback()
|
|
||||||
|
|
||||||
assert action == "Vérifier la consommation"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_get_by_id_returns_nothing_for_an_unknown_identifier(
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> None:
|
|
||||||
trouve = await RecommendationRepository(session).get_by_id(0)
|
|
||||||
|
|
||||||
assert trouve is None
|
|
||||||
|
|
||||||
|
|
||||||
async def test_list_all_returns_the_recommendations_sorted_by_identifier(
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> None:
|
|
||||||
depot = RecommendationRepository(session)
|
|
||||||
premiere = await creer(session)
|
|
||||||
seconde = await creer(session)
|
|
||||||
|
|
||||||
recommendations = await depot.list_all()
|
|
||||||
identifiants = [
|
|
||||||
r.recommendation_id
|
|
||||||
for r in recommendations
|
|
||||||
if r.recommendation_id in (premiere.recommendation_id, seconde.recommendation_id)
|
|
||||||
]
|
|
||||||
await session.rollback()
|
|
||||||
|
|
||||||
assert identifiants == sorted(identifiants)
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
from datetime import UTC, datetime
|
|
||||||
|
|
||||||
from app.models.energy import Alert
|
|
||||||
from app.services.alert import AlertService
|
|
||||||
|
|
||||||
|
|
||||||
def alert(
|
|
||||||
alert_id: int = 1,
|
|
||||||
site_id: str = "site-1",
|
|
||||||
severity: str = "high",
|
|
||||||
) -> Alert:
|
|
||||||
return Alert(
|
|
||||||
alert_id=alert_id,
|
|
||||||
source_alert_id=f"ALR-{alert_id}",
|
|
||||||
site_id=site_id,
|
|
||||||
source="enervision",
|
|
||||||
timestamp=datetime(2026, 9, 16, tzinfo=UTC),
|
|
||||||
type="threshold",
|
|
||||||
severity=severity,
|
|
||||||
message="Dépassement du seuil configuré",
|
|
||||||
value=812.5,
|
|
||||||
threshold=720.0,
|
|
||||||
metric="consumption_kw",
|
|
||||||
prediction_id=None,
|
|
||||||
raw_data={},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class FakeRepository:
|
|
||||||
def __init__(self, alerts: list[Alert]) -> None:
|
|
||||||
self._alerts = alerts
|
|
||||||
self.appels: list[tuple[str | None, str | None]] = []
|
|
||||||
|
|
||||||
async def list_all(
|
|
||||||
self, *, site_id: str | None = None, severity: str | None = None
|
|
||||||
) -> list[Alert]:
|
|
||||||
self.appels.append((site_id, severity))
|
|
||||||
return self._alerts
|
|
||||||
|
|
||||||
|
|
||||||
async def test_list_all_returns_the_repository_alerts() -> None:
|
|
||||||
service = AlertService(alerts=FakeRepository([alert(1), alert(2)]))
|
|
||||||
|
|
||||||
alertes = await service.list_all()
|
|
||||||
|
|
||||||
assert [a.alert_id for a in alertes] == [1, 2]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_list_all_relays_the_filters_to_the_repository() -> None:
|
|
||||||
depot = FakeRepository([])
|
|
||||||
service = AlertService(alerts=depot)
|
|
||||||
|
|
||||||
await service.list_all(site_id="site-1", severity="critical")
|
|
||||||
|
|
||||||
assert depot.appels == [("site-1", "critical")]
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
from datetime import UTC, datetime
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.models.energy import Recommendation
|
|
||||||
from app.services.recommendation import RecommendationNotFoundError, RecommendationService
|
|
||||||
|
|
||||||
|
|
||||||
def recommendation(recommendation_id: int = 1) -> Recommendation:
|
|
||||||
return Recommendation(
|
|
||||||
recommendation_id=recommendation_id,
|
|
||||||
alert_id=1,
|
|
||||||
action="Vérifier la consommation",
|
|
||||||
explanation="Pic détecté",
|
|
||||||
rule_reference="spike-v1",
|
|
||||||
created_at=datetime(2024, 1, 1, tzinfo=UTC),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class FakeRepository:
|
|
||||||
def __init__(self, recommendations: list[Recommendation]) -> None:
|
|
||||||
self._recommendations = recommendations
|
|
||||||
|
|
||||||
async def list_all(self) -> list[Recommendation]:
|
|
||||||
return self._recommendations
|
|
||||||
|
|
||||||
async def get_by_id(self, recommendation_id: int) -> Recommendation | None:
|
|
||||||
return next(
|
|
||||||
(r for r in self._recommendations if r.recommendation_id == recommendation_id), None
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_list_all_returns_the_repository_recommendations() -> None:
|
|
||||||
service = RecommendationService(
|
|
||||||
recommendations=FakeRepository([recommendation(1), recommendation(2)])
|
|
||||||
)
|
|
||||||
|
|
||||||
recommendations = await service.list_all()
|
|
||||||
|
|
||||||
assert [r.recommendation_id for r in recommendations] == [1, 2]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_get_by_id_returns_the_matching_recommendation() -> None:
|
|
||||||
service = RecommendationService(recommendations=FakeRepository([recommendation(1)]))
|
|
||||||
|
|
||||||
trouve = await service.get_by_id(1)
|
|
||||||
|
|
||||||
assert trouve.recommendation_id == 1
|
|
||||||
|
|
||||||
|
|
||||||
async def test_get_by_id_raises_when_the_recommendation_is_unknown() -> None:
|
|
||||||
service = RecommendationService(recommendations=FakeRepository([]))
|
|
||||||
|
|
||||||
with pytest.raises(RecommendationNotFoundError):
|
|
||||||
await service.get_by_id(404)
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -2,8 +2,7 @@
|
|||||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"cli": {
|
"cli": {
|
||||||
"packageManager": "npm",
|
"packageManager": "npm"
|
||||||
"analytics": false
|
|
||||||
},
|
},
|
||||||
"newProjectRoot": "projects",
|
"newProjectRoot": "projects",
|
||||||
"projects": {
|
"projects": {
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ collecteur ne vient le lire.
|
|||||||
|
|
||||||
| Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement |
|
| Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites` et `recommendations` en lecture (endpoints → services → repositories → models) |
|
| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, `GET /sites` et `GET /sites/{site_id}` (première couche métier, endpoints → services → repositories → models) |
|
||||||
| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, deux services HTTP, graphiques Chart.js, données servies par des fixtures |
|
| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, deux services HTTP, graphiques Chart.js, données servies par des fixtures |
|
||||||
| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) |
|
| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) |
|
||||||
| Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée |
|
| Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée |
|
||||||
|
|||||||
@@ -35,10 +35,9 @@ 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` |
|
| `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` :
|
**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 et `ng serve` tournent sur le poste avec le rechargement
|
seule la base tourne en conteneur, l'API tourne sur le poste avec le rechargement à chaud. Le
|
||||||
à chaud, lancés ensemble par `make dev` (`make dev-backend`/`make dev-frontend` pour lancer l'un
|
service `backend` sert la stack complète et la recette. Les deux occupent le port 8000, ils ne se
|
||||||
des deux seul). Le service `backend` sert la stack complète et la recette. Les deux occupent le
|
lancent donc pas ensemble.
|
||||||
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 :
|
Deux pièges sont documentés en tête du `docker-compose.yml`, ils ne se devinent pas :
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ Les quatre couches existent désormais, portées par l'authentification.
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
ep["endpoints<br/>health, auth, users, sites,<br/>recommendations, stats"]
|
ep["endpoints<br/>health, auth, users, sites"]
|
||||||
sc["schemas<br/>Pydantic"]
|
sc["schemas<br/>Pydantic"]
|
||||||
sv["services<br/>AuthService, UserService,<br/>SiteService, RecommendationService,<br/>StatsService"]
|
sv["services<br/>AuthService, UserService,<br/>SiteService"]
|
||||||
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site, recommendation, reading"]
|
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site"]
|
||||||
md["models<br/>10 tables"]
|
md["models<br/>10 tables"]
|
||||||
db[("PostgreSQL")]
|
db[("PostgreSQL")]
|
||||||
|
|
||||||
@@ -142,10 +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 |
|
| 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` | 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}` | Décrit 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 |
|
|
||||||
| GET | `/api/v1/stats/summary` | Résume la consommation instantanée du parc. `lecteur` | 401, 403, 500 |
|
|
||||||
| GET | `/metrics` | Format Prometheus, hors du schéma. Jeton requis si `APP_METRICS_TOKEN` est posé | |
|
| GET | `/metrics` | Format Prometheus, hors du schéma. Jeton requis si `APP_METRICS_TOKEN` est posé | |
|
||||||
| GET | `/docs`, `/redoc`, `/openapi.json` | Hors du schéma. Fermés en `staging` et en `prod` | |
|
| GET | `/docs`, `/redoc`, `/openapi.json` | Hors du schéma. Fermés en `staging` et en `prod` | |
|
||||||
|
|
||||||
@@ -157,19 +153,14 @@ Les codes de la dernière colonne sont ceux que le schéma **déclare**, et le f
|
|||||||
échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Rendre une route publique impose
|
é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.
|
donc de modifier la liste dans ce fichier de test.
|
||||||
|
|
||||||
`GET /sites` et `GET /sites/{site_id}` sont la première route métier, et le gabarit repris pour
|
`GET /sites` et `GET /sites/{site_id}` sont la première route métier, et le gabarit à réutiliser
|
||||||
`GET /alerts` puis pour les suivantes (`reading`, `dataset`, `prediction`, `recommendation`) : les
|
pour les suivantes (`reading`, `dataset`, `prediction`, `alert`, `recommendation`) : les quatre
|
||||||
quatre couches `endpoints → services → repositories → models` y sont toutes présentes, sur des
|
couches `endpoints → services → repositories → models` y sont toutes présentes, sur des tables
|
||||||
tables déjà créées par la révision Alembic `e6d2026091501`. Elles n'exigent que le rôle `lecteur`,
|
déjà créées par la révision Alembic `e6d2026091501`. Elles n'exigent que le rôle `lecteur`,
|
||||||
contrairement aux routes d'administration qui exigent `admin`. `SiteRepository` lit par
|
contrairement aux routes d'administration qui exigent `admin`. `SiteRepository` lit par
|
||||||
`AsyncSession.scalar()` (une ligne) et `AsyncSession.scalars()` (plusieurs lignes) plutôt que par
|
`AsyncSession.scalar()` (une ligne) et `AsyncSession.scalars()` (plusieurs lignes) plutôt que par
|
||||||
`execute()`, ce qui la rend testable par la fixture `fake_session` au niveau endpoint sans base
|
`execute()`, ce qui la rend testable par la fixture `fake_session` au niveau endpoint sans base
|
||||||
réelle. `GET /recommendations` et `GET /recommendations/{recommendation_id}` reprennent le même
|
réelle. Le contrat détaillé pour le frontend est dans
|
||||||
gabarit à la lettre, `recommendation_id` étant un entier plutôt qu'un texte. Une recommandation ne
|
|
||||||
porte pas `site_id` : elle remonte à un site par sa seule `alert_id`, `alert` n'étant pas encore
|
|
||||||
exposée. `GET /stats/summary` agrège deux repositories (`SiteRepository`, `ReadingRepository`)
|
|
||||||
dans un service dédié plutôt que d'exposer une table : elle n'entre donc pas dans ce gabarit
|
|
||||||
route-par-table. Le contrat détaillé pour le frontend est dans
|
|
||||||
[31-contrat-authentification.md](31-contrat-authentification.md).
|
[31-contrat-authentification.md](31-contrat-authentification.md).
|
||||||
|
|
||||||
### `/health/ready`
|
### `/health/ready`
|
||||||
@@ -244,20 +235,19 @@ Les modèles de `app/schemas/errors.py` décrivent ce que les gestionnaires renv
|
|||||||
`loc` n'apparaît dans aucune réponse de cette API : `validation_error_handler()` rend `champ` et
|
`loc` n'apparaît dans aucune réponse de cette API : `validation_error_handler()` rend `champ` et
|
||||||
`type`. Renommer un champ là-bas sans le faire ici rend la documentation fausse en silence.
|
`type`. Renommer un champ là-bas sans le faire ici rend la documentation fausse en silence.
|
||||||
|
|
||||||
### Ajouter une route métier
|
**Ajouter une route métier** (`sites` est le gabarit, `reading`/`dataset`/`prediction`/`alert`/
|
||||||
|
`recommendation` suivront) :
|
||||||
|
|
||||||
Checklist pour toute nouvelle route sur le gabarit `sites`/`alerts`/`recommendations`/`stats`
|
1. Composer ses `responses=` depuis `app/api/openapi.py` : `REPONSES_LECTEUR` ou `REPONSES_ADMIN`
|
||||||
(`reading`, `dataset`, `prediction`) :
|
au niveau de l'`include_router` dans `app/api/v1/router.py` (401 et le 403 propre au rôle),
|
||||||
|
`REPONSE_VALIDATION` et les codes locaux (404, 409, ...) sur l'endpoint lui-même s'il a un
|
||||||
1. Composer ses `responses=` depuis `app/api/openapi.py` : `REPONSES_LECTEUR`/`REPONSES_ADMIN`
|
corps, un paramètre ou peut échouer par identifiant.
|
||||||
au niveau de l'`include_router()` du routeur, `REPONSE_VALIDATION` et les codes locaux
|
2. Décrire son tag dans `TAGS` (`app/api/openapi.py`).
|
||||||
(404, 409, ...) directement sur l'endpoint qui les rend.
|
3. Si elle passe par `require_role`, l'ajouter à `ROUTES_A_ROLE`
|
||||||
2. Décrire son tag dans `TAGS`.
|
(`tests/api/test_openapi.py`) ; si elle passe par `require_trusted_origin`, l'ajouter à
|
||||||
3. Si elle passe par `require_role` (`LecteurDep`/`OperateurDep`/`AdminDep`), l'ajouter à
|
`ORIGINE_VERIFIEE`. Ces deux listes sont maintenues à la main, pas déduites automatiquement du
|
||||||
`ROUTES_A_ROLE` dans `tests/api/test_openapi.py`. Si elle passe par `require_trusted_origin`,
|
code : une route protégée qui n'y figure pas ne sera pas détectée par les tests.
|
||||||
l'ajouter à `ORIGINE_VERIFIEE`. **Ces deux listes sont maintenues à la main, pas dérivées** :
|
4. `make openapi`, puis `pytest tests/api/test_openapi.py`.
|
||||||
une route oubliée n'y est pas détectée automatiquement.
|
|
||||||
4. `make openapi`, puis `uv run pytest tests/api/test_openapi.py`.
|
|
||||||
|
|
||||||
## Sécurité
|
## Sécurité
|
||||||
|
|
||||||
|
|||||||
@@ -108,9 +108,8 @@ 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
|
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.
|
tester ni construire le frontend.
|
||||||
|
|
||||||
Le frontend a ses cibles dans le `Makefile` racine (`install-frontend`, `dev-frontend`,
|
Le frontend **n'a pas de cible dans le `Makefile` racine** et **aucun service dans
|
||||||
englobées par `install` et `dev`), mais **aucun service dans `docker-compose.yml`** : en
|
`docker-compose.yml`** : il se pilote uniquement par `npm`, depuis `apps/frontend`. Le port 4200
|
||||||
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.
|
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
|
Un `Dockerfile` frontend existe sur la branche `feat/pipeline-cd`, mais il est mono-étage et sans
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ Ce qui est défendable, c'est une ligne par contrôle réellement implémenté,
|
|||||||
et une section qui dit ce qui n'est pas couvert et pourquoi.
|
et une section qui dit ce qui n'est pas couvert et pourquoi.
|
||||||
|
|
||||||
Statut : `Fait` pour le périmètre authentification et autorisation. `GET /sites` et
|
Statut : `Fait` pour le périmètre authentification et autorisation. `GET /sites` et
|
||||||
`GET /recommendations`, chacune avec sa route de détail, sont les premiers endpoints métier, en
|
`GET /sites/{site_id}` sont les premiers endpoints métier, en lecture seule ; plusieurs lignes
|
||||||
lecture seule ; plusieurs lignes resteront à compléter une fois les endpoints d'écriture posés.
|
resteront à compléter une fois les endpoints d'écriture posés.
|
||||||
|
|
||||||
## Contrôles en place
|
## Contrôles en place
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ règles Bandit. Ajouter Bandit à la CI serait redondant, contrairement à ce qu
|
|||||||
|
|
||||||
| Item | État | Raison |
|
| Item | État | Raison |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site : `GET /sites/{site_id}` et `GET /recommendations/{recommendation_id}` répondent à tout compte `lecteur` pour n'importe quel site ou recommandation, sans vérifier une affectation compte-site qui n'existe pas encore. Un opérateur du site A pourra agir sur le site B dès que les endpoints d'écriture métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. |
|
| **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site : `GET /sites/{site_id}` répond à tout compte `lecteur` pour n'importe quel site, sans vérifier une affectation compte-site qui n'existe pas encore. Un opérateur du site A pourra agir sur le site B dès que les endpoints d'écriture métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. |
|
||||||
| **API4, lectures de séries temporelles** | **ouvert** | Pas encore d'endpoint métier, donc ni pagination plafonnée, ni fenêtre temporelle maximale, ni `statement_timeout`. C'est la façon la plus probable dont la démonstration tombera : une requête sur dix ans d'historique suffit. |
|
| **API4, lectures de séries temporelles** | **ouvert** | Pas encore d'endpoint métier, donc ni pagination plafonnée, ni fenêtre temporelle maximale, ni `statement_timeout`. C'est la façon la plus probable dont la démonstration tombera : une requête sur dix ans d'historique suffit. |
|
||||||
| **API8 Security Misconfiguration, transport** | **ouvert** | Pas de TLS, donc ni HSTS, ni cookie `Secure` réellement posé en production. Ils appartiennent au terminateur TLS, qui n'existe pas. |
|
| **API8 Security Misconfiguration, transport** | **ouvert** | Pas de TLS, donc ni HSTS, ni cookie `Secure` réellement posé en production. Ils appartiennent au terminateur TLS, qui n'existe pas. |
|
||||||
| **API10 Unsafe Consumption of APIs** | **ouvert, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse doit être traitée comme une entrée hostile : bornes physiques, taille de tableau plafonnée, timeout, et frontière d'anti-corruption. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. |
|
| **API10 Unsafe Consumption of APIs** | **ouvert, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse doit être traitée comme une entrée hostile : bornes physiques, taille de tableau plafonnée, timeout, et frontière d'anti-corruption. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. |
|
||||||
|
|||||||
Reference in New Issue
Block a user