Compare commits

..
Author SHA1 Message Date
ValentinDeFariaandGitHub 88f4f9a601 chore(ci): ajoute la surveillance docker du frontend a dependabot 2026-09-17 13:42:02 +02:00
Valentin 2ad7692f1c Ajoute la configuration Dependabot (npm, uv, github-actions, docker) 2026-09-17 12:12:48 +02:00
Johan LEROYandGitHub 3692d486c6 Merge pull request #83 from ineszang/feat/endpoint-sensors-status
feat(backend): expose GET /api/v1/sensors/status
2026-09-17 09:16:24 +02:00
ValentinDeFariaandGitHub 24bf8bf4b9 Update apps/backend/tests/services/test_sensor.py
Backend / Lint, typage et tests (push) Successful in 1m11s
2026-09-17 09:10:04 +02:00
Johan LEROYandGitHub 9f465538bf Merge pull request #85 from ineszang/feat/auth-front
feat(frontend): authentification frontend
2026-09-17 09:09:59 +02:00
Johan LEROY 515a92b395 fix(frontend): isole les fichiers de tests vitest pour eviter la pollution de mocks
Le test site-load-chart.spec.ts echouait de facon intermittente en CI : sans
isolation, vitest partage le registre de modules entre fichiers de spec, donc
le mock chart.js d'un fichier pouvait ecraser celui d'un autre selon l'ordre
d'execution.
2026-09-17 09:02:53 +02:00
ValentinDeFariaandGitHub 0174272bdd Update dashboard.html 2026-09-16 17:00:52 +02:00
ineszangandGitHub c740b61b24 Merge pull request #80 from ineszang/feat/pipeline-ci
Feat/pipeline ci
2026-09-16 16:41:55 +02:00
Valentin 5669cd63ec feat(frontend): authentification frontend
ajout de la page login, changement de mot de passe forcé, rafraîchissement de session en mémoire, intercepteur, déconnexion, bouton logout sur le dashboard
2026-09-16 16:07:28 +02:00
ineszang44 06cb60463c fix(frontend): droit d'accès au fichier de config de nginx, réduction de code smells 2026-09-16 15:12:04 +02:00
ineszang44 1c6b6105bd chore(frontend): ajout de la configuration nginx 2026-09-16 14:54:12 +02:00
Johan LEROY 77440281f8 feat(backend): expose GET /api/v1/sensors/status pour l'issue #32
Dérive l'état de santé de 5 capteurs par site et un statut overall depuis
la dernière lecture (data_quality, null_reasons, nullité des colonnes),
sur le gabarit d'agrégation de StatsService. Route réservée au rôle admin.
2026-09-16 14:53:54 +02:00
Johan LEROYandGitHub 63ee79cf32 Merge pull request #81 from ineszang/feat/endpoint-alertes-predictives
feat(backend): expose GET /api/v1/alerts
2026-09-16 14:44:27 +02:00
Johan LEROY 76fa90dfcb test(backend): exerce AlertSeverity comme enum plutot qu'une chaine dans les tests alerts
Backend / Lint, typage et tests (push) Successful in 1m8s
Le filtre severity passait par une chaine brute dans les tests, sans jamais
exercer le trajet reel AlertSeverity (enum) -> SQLAlchemy -> PostgreSQL.
2026-09-16 14:43:27 +02:00
ineszang44 6ecec1afef chore(frontend): ajout du dockerignore et du dockerfile 2026-09-16 14:40:55 +02:00
Johan LEROY e13096c62a Fusionne dev dans feat/endpoint-alertes-predictives
Resout les conflits additifs entre les routes alerts, recommendations
et stats mergees sur dev (PR #79, PR #82) pendant le developpement de
cette branche : deps.py, router.py, openapi.py, openapi.json,
test_openapi.py et 20-backend.md conservent desormais les trois routes.
2026-09-16 14:25:49 +02:00
ineszang44 970a4a50b8 fix(frontend): suppression de dépendance dans le service frontend 2026-09-16 14:22:50 +02:00
Johan LEROYandGitHub 3fb907d6f6 Merge pull request #82 from ineszang/feat/endpoint-recommandations
feat(backend): ajoute GET /recommendations et GET /recommendations/{r…
2026-09-16 14:21:08 +02:00
Dorian PESCE c7490d01b3 Merge remote-tracking branch 'origin/dev' into feat/endpoint-recommandations 2026-09-16 14:03:24 +02:00
Johan LEROYandGitHub 523b623dc1 Merge pull request #79 from ineszang/feat/stats-summary
feat(backend): expose GET /api/v1/stats/summary
2026-09-16 13:37:47 +02:00
Johan LEROY 61e031fc16 Fusionne dev dans feat/stats-summary
Resout les conflits de deps.py, router.py, repositories/site.py et
test_site.py entre l'ajout de stats et le merge de sites/openapi-contrat
sur dev. Generalise ROUTES_A_ROLE dans test_openapi.py et documente la
checklist d'ajout d'une route metier, absentes de dev au moment du fork.
2026-09-16 13:37:03 +02:00
Dorian PESCE 781644b28e feat(backend): ajoute GET /recommendations et GET /recommendations/{recommendation_id} 2026-09-16 13:30:51 +02:00
Johan LEROY 1654e4dd81 docs(backend): documente la checklist d'ajout d'une route metier
La generalisation de ROUTES_A_ROLE (commit precedent) avait deja ete
approuvee sur feat/openapi-contrat mais poussee apres la fermeture de
la PR #76 : elle n'a donc jamais atteint dev, et sa documentation non
plus. Complete ce qui manquait pour que le passage a l'echelle du
contrat OpenAPI soit reellement utilisable par la prochaine route.
2026-09-16 13:27:41 +02:00
Johan LEROY e50921c907 feat(backend): expose GET /api/v1/alerts
Consultation des alertes de consommation, filtrable par site_id et
severity a l'identique du contrat GET /alerts de l'API Mock. Reprend
le gabarit endpoints -> services -> repositories -> models pose par
sites, sur la table alert deja creee par la revision Alembic
e6d2026091501.

Generalise aussi le garde-fou OpenAPI du 403 (ROUTES_A_ROLE) au-dela
du seul tag users, pour que l'ajout d'alerts a la liste des routes
protegees par role soit reellement verifie.

Closes #59
2026-09-16 13:06:37 +02:00
PhyriosandGitHub 56f7211f0b Merge pull request #77 from ineszang/feat/frontend-make-dev
feat(build): branche le frontend sur make dev
2026-09-16 12:34:41 +02:00
ineszang44 730adb69b1 chore(frontend): faux positifs cwe 2026-09-16 12:32:28 +02:00
ineszang44 f43c9f76a0 test(frontend): workflow 2026-09-16 12:29:54 +02:00
Johan LEROY c83fd889b8 Fusionne dev dans feat/frontend-make-dev
Combine les cibles install-backend/install-frontend/dev-backend/dev-frontend
introduites ici avec la cible openapi ajoutee par PR #76 (merge de dev).
2026-09-16 12:21:20 +02:00
ineszang44 04e4913952 fix(frontend): code smells 2026-09-16 12:20:15 +02:00
Johan LEROYandGitHub cf22b2ae55 Merge pull request #76 from ineszang/feat/openapi-contrat
feat(backend): documente et verse le contrat OpenAPI
2026-09-16 12:05:27 +02:00
Johan LEROY 50dcb4de32 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.
2026-09-16 11:45:58 +02:00
ineszang44 d1e4d8cfa0 test(frontend): lancement automatique du workflow après un push ou avec une pull request 2026-09-16 11:31:10 +02:00
ineszang44 1f0eb410eb test(frontend): suppression des conditions if 2026-09-16 11:28:49 +02:00
ineszang44 078983a41d test(frontend): suppression de la propriété 'pull-request' 2026-09-16 11:25:55 +02:00
ineszang44 596cf43eda test(frontend): lancement manuel du workflow 2026-09-16 11:22:58 +02:00
Johan LEROY 16a0cc4d3b 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.
2026-09-16 10:36:31 +02:00
ineszang44 11baea7117 changement d'ordre des jobs + ajout des dépendances entre les jobs 2026-09-16 10:29:47 +02:00
Johan LEROY 5e7cb005ac 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
2026-09-16 10:25:22 +02:00
ineszang44 61b3494d12 correction nom du workflow pour le frontend 2026-09-16 10:03:59 +02:00
ineszang b5cffbf56f fix(frontend): changement de version des actions pour raisons de compatibilité 2026-09-15 16:59:34 +02:00
ineszang 3ef7de5baa feat(frontend): ajout chemins dans le pipeline CI 2026-09-15 16:46:16 +02:00
ineszang ff6e3c288c feat(frontend): ajout du job de build 2026-09-15 16:35:50 +02:00
ineszang 2390e58f78 chore: sonarqube 2026-09-15 16:10:39 +02:00
ineszang b300be5186 chore: init de la config du frontend sur docker compose 2026-09-15 16:10:18 +02:00
ineszang b8f806518f feat(frontend): ajout sonarqube dans le pipeline 2026-09-15 16:08:31 +02:00
ineszangandGitHub 83392c7ff4 chore: init pipeline frontend 2026-09-15 14:56:33 +02:00
69 changed files with 3957 additions and 46 deletions
+27
View File
@@ -0,0 +1,27 @@
# Dépendances (réinstallées dans l'image)
node_modules/
vendor/
__pycache__/
*.pyc
# Git et IDE
.git/
.gitignore
.vscode/
.idea/
*.swp
# Fichiers de build locaux
dist/
build/
*.log
# Secrets et config locale (CRITIQUE : risque d'exfiltration)
.env
.env.local
*.pem
*.key
secrets/
.npmrc
.pypirc
kubeconfig
+40
View File
@@ -0,0 +1,40 @@
version: 2
updates:
# Frontend — npm
- package-ecosystem: "npm"
directory: "/apps/frontend"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
frontend-dependencies:
patterns:
- "*"
# Backend — uv (lit pyproject.toml / uv.lock)
- package-ecosystem: "uv"
directory: "/apps/backend"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
backend-dependencies:
patterns:
- "*"
# Les workflows GitHub Actions eux-mêmes ont aussi des dépendances à jour
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
# Si un Dockerfile existe pour le backend
- package-ecosystem: "docker"
directory: "/apps/backend"
schedule:
interval: "weekly"
- package-ecosystem: "docker"
directory: "/apps/frontend"
schedule:
interval: "weekly"
+77
View File
@@ -0,0 +1,77 @@
name: Frontend
# Pipeline à choix multiple
on:
# workflow_dispatch -> lancement manuel des jobs
workflow_dispatch:
inputs:
job_choice:
required: true
description: "Choix du job"
type: choice
default: all
options:
- build
- sonarqube
- test
- all # lancer tous les jobs
push:
paths:
- "apps/frontend/**"
- ".github/workflows/frontend.yml"
pull_request:
paths:
- "apps/frontend/**"
- ".github/workflows/frontend.yml"
# Ordre de lancement des jobs
# build -> test -> sonarqube -> deploy
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
- run: npm ci
working-directory: apps/frontend
- run: npm run build
working-directory: apps/frontend
test:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
- run: npm ci
working-directory: apps/frontend
- run: npm test -- --watch=false
working-directory: apps/frontend
sonarqube:
needs: [build, test]
name: SonarQube
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@7006c4492b2e0ee0f816d36501671557c97f5995 # v8.1.0
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
# deploy:
# runs-on: ubuntu-latest
# steps:
# - run: echo "DEPLOY job is running"
+21 -3
View File
@@ -1,18 +1,36 @@
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 \
openapi 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) --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
cd $(BACKEND) && uv run ruff check .
+9 -6
View File
@@ -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 :
+2
View File
@@ -109,6 +109,8 @@ 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/sites` | Liste les sites | `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` |
| `/docs`, `/openapi.json` | Documentation, fermée en `staging` et `prod` | public sinon |
+35
View File
@@ -21,13 +21,20 @@ from app.core.roles import AccountKind, Role, has_at_least
from app.core.security import TokenExpiredError, TokenInvalidError, TokenPolicy
from app.core.security import decode_access_token as decode_token
from app.db.session import get_session
from app.repositories.alert import AlertRepository
from app.repositories.audit_log import AuditLogRepository
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.site import SiteRepository
from app.repositories.user import UserRepository
from app.services.alert import AlertService
from app.services.auth import AuthService, LoginPolicy
from app.services.recommendation import RecommendationService
from app.services.sensor import SensorService
from app.services.site import SiteService
from app.services.stats import StatsService
from app.services.user import UserService
SessionDep = Annotated[AsyncSession, Depends(get_session)]
@@ -140,6 +147,34 @@ def get_site_service(session: SessionDep) -> SiteService:
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)]
def get_sensor_service(session: SessionDep) -> SensorService:
return SensorService(sites=SiteRepository(session), readings=ReadingRepository(session))
SensorServiceDep = Annotated[SensorService, Depends(get_sensor_service)]
async def get_current_principal(
credentials: CredentialsDep,
session: SessionDep,
+21
View File
@@ -54,6 +54,27 @@ TAGS: Final[list[dict[str, Any]]] = [
"name": "sites",
"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`.",
},
{
"name": "sensors",
"description": "État de santé des capteurs par site. Réservé au rôle `admin`.",
},
]
cookie_de_rafraichissement = APIKeyCookie(
@@ -0,0 +1,23 @@
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]
@@ -0,0 +1,40 @@
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)
@@ -0,0 +1,16 @@
from fastapi import APIRouter
from app.api.deps import AdminDep, SensorServiceDep
from app.schemas.sensor import SensorStatusResponse
router = APIRouter()
@router.get(
"/status",
response_model=SensorStatusResponse,
summary="État de santé des capteurs par site",
)
async def get_status(_: AdminDep, service: SensorServiceDep) -> SensorStatusResponse:
etat = await service.status()
return SensorStatusResponse.model_validate(etat)
@@ -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)
+14 -1
View File
@@ -1,10 +1,23 @@
from fastapi import APIRouter
from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR
from app.api.v1.endpoints import auth, health, sites, users
from app.api.v1.endpoints import alerts, auth, health, recommendations, sensors, sites, stats, users
api_router = APIRouter(responses=REPONSE_SERVEUR)
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"], responses=REPONSES_ADMIN)
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)
api_router.include_router(
sensors.router, prefix="/sensors", tags=["sensors"], responses=REPONSES_ADMIN
)
+21
View File
@@ -0,0 +1,21 @@
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()
+21
View File
@@ -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()
@@ -0,0 +1,22 @@
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
+34
View File
@@ -0,0 +1,34 @@
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
@@ -0,0 +1,14 @@
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
+42
View File
@@ -0,0 +1,42 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
class SensorDiagnosticResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
status: Literal["ok", "failing"]
since: datetime | None = Field(
description=(
"Horodatage de la dernière lecture reçue pour ce site. Ce n'est pas le début de la "
"panne : l'historique ne permet pas de le dater sans requête supplémentaire."
)
)
class SiteSensorsResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
consumption: SensorDiagnosticResponse
electrical: SensorDiagnosticResponse
temperature: SensorDiagnosticResponse
humidity: SensorDiagnosticResponse
network: SensorDiagnosticResponse
class SiteSensorStatusResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
site_id: str
site_name: str
sensors: SiteSensorsResponse
overall: Literal["ok", "degraded", "critical"]
class SensorStatusResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
timestamp: datetime
sites: list[SiteSensorStatusResponse]
+26
View File
@@ -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]
+14
View File
@@ -0,0 +1,14 @@
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)
@@ -0,0 +1,26 @@
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
+137
View File
@@ -0,0 +1,137 @@
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
CapteurStatus = Literal["ok", "failing"]
OverallStatus = Literal["ok", "degraded", "critical"]
QUALITES_CONNUES: frozenset[str] = frozenset({"good", "partial", "degraded", "critical"})
RAISON_VERS_CAPTEUR: dict[str, str] = {
"consumption_sensor_failure": "consumption",
"electrical_sensor_failure": "electrical",
"temperature_sensor_failure": "temperature",
"humidity_sensor_failure": "humidity",
"network_loss": "network",
}
CHAMPS_PAR_CAPTEUR: dict[str, tuple[str, ...]] = {
"consumption": ("consumption_kw",),
"electrical": ("voltage_v", "current_a", "power_factor"),
"temperature": ("temperature_celsius",),
"humidity": ("humidity_percent",),
}
@dataclass(frozen=True, slots=True)
class DiagnosticCapteur:
status: CapteurStatus
since: datetime | None
@dataclass(frozen=True, slots=True)
class SanteCapteurs:
consumption: DiagnosticCapteur
electrical: DiagnosticCapteur
temperature: DiagnosticCapteur
humidity: DiagnosticCapteur
network: DiagnosticCapteur
@dataclass(frozen=True, slots=True)
class SanteSite:
site_id: str
site_name: str
sensors: SanteCapteurs
overall: OverallStatus
@dataclass(frozen=True, slots=True)
class EtatCapteurs:
timestamp: datetime
sites: list[SanteSite]
class SensorService:
def __init__(self, sites: SiteRepository, readings: ReadingRepository) -> None:
self._sites = sites
self._readings = readings
async def status(self) -> EtatCapteurs:
sites = await self._sites.list_all()
dernieres = {lecture.site_id: lecture for lecture in await self._readings.latest_by_site()}
return EtatCapteurs(
timestamp=datetime.now(UTC),
sites=[_sante_site(site, dernieres.get(site.site_id)) for site in sites],
)
def _sante_site(site: Site, derniere: Reading | None) -> SanteSite:
if derniere is None:
return SanteSite(
site_id=site.site_id,
site_name=site.site_name,
sensors=_tout_en_echec(since=None),
overall="critical",
)
qualite = derniere.data_quality if derniere.data_quality in QUALITES_CONNUES else "critical"
overall = _overall_depuis_qualite(qualite)
if overall == "critical":
return SanteSite(
site_id=site.site_id,
site_name=site.site_name,
sensors=_tout_en_echec(since=derniere.timestamp),
overall="critical",
)
raisons_signalees = {
RAISON_VERS_CAPTEUR[raison]
for raison in (derniere.null_reasons or [])
if raison in RAISON_VERS_CAPTEUR
}
return SanteSite(
site_id=site.site_id,
site_name=site.site_name,
sensors=SanteCapteurs(
consumption=_diagnostic("consumption", derniere, raisons_signalees),
electrical=_diagnostic("electrical", derniere, raisons_signalees),
temperature=_diagnostic("temperature", derniere, raisons_signalees),
humidity=_diagnostic("humidity", derniere, raisons_signalees),
network=_diagnostic("network", derniere, raisons_signalees),
),
overall=overall,
)
def _overall_depuis_qualite(qualite: str) -> OverallStatus:
if qualite == "good":
return "ok"
if qualite in ("partial", "degraded"):
return "degraded"
return "critical"
def _diagnostic(capteur: str, derniere: Reading, raisons_signalees: set[str]) -> DiagnosticCapteur:
champs = CHAMPS_PAR_CAPTEUR.get(capteur, ())
en_echec = capteur in raisons_signalees or any(
getattr(derniere, champ) is None for champ in champs
)
return DiagnosticCapteur(
status="failing" if en_echec else "ok",
since=derniere.timestamp if en_echec else None,
)
def _tout_en_echec(since: datetime | None) -> SanteCapteurs:
echec = DiagnosticCapteur(status="failing", since=since)
return SanteCapteurs(
consumption=echec, electrical=echec, temperature=echec, humidity=echec, network=echec
)
+81
View File
@@ -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,
)
+737
View File
@@ -920,6 +920,369 @@
}
}
}
},
"/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": []
}
]
}
},
"/api/v1/sensors/status": {
"get": {
"tags": [
"sensors"
],
"summary": "État de santé des capteurs par site",
"operationId": "get_status_api_v1_sensors_status_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SensorStatusResponse"
}
}
}
},
"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": "Droits insuffisants, ou mot de passe provisoire à changer quand `detail` vaut `password_change_required`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
},
"security": [
{
"Jeton d'accès": []
}
]
}
}
},
"components": {
@@ -932,6 +1295,112 @@
],
"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": {
"properties": {
"detail": {
@@ -1111,6 +1580,45 @@
],
"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": {
"type": "string",
"enum": [
@@ -1120,6 +1628,59 @@
],
"title": "Role"
},
"SensorDiagnosticResponse": {
"properties": {
"status": {
"type": "string",
"enum": [
"ok",
"failing"
],
"title": "Status"
},
"since": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Since",
"description": "Horodatage de la dernière lecture reçue pour ce site. Ce n'est pas le début de la panne : l'historique ne permet pas de le dater sans requête supplémentaire."
}
},
"type": "object",
"required": [
"status",
"since"
],
"title": "SensorDiagnosticResponse"
},
"SensorStatusResponse": {
"properties": {
"timestamp": {
"type": "string",
"format": "date-time",
"title": "Timestamp"
},
"sites": {
"items": {
"$ref": "#/components/schemas/SiteSensorStatusResponse"
},
"type": "array",
"title": "Sites"
}
},
"type": "object",
"required": [
"timestamp",
"sites"
],
"title": "SensorStatusResponse"
},
"SiteResponse": {
"properties": {
"site_id": {
@@ -1179,6 +1740,166 @@
],
"title": "SiteResponse"
},
"SiteSensorStatusResponse": {
"properties": {
"site_id": {
"type": "string",
"title": "Site Id"
},
"site_name": {
"type": "string",
"title": "Site Name"
},
"sensors": {
"$ref": "#/components/schemas/SiteSensorsResponse"
},
"overall": {
"type": "string",
"enum": [
"ok",
"degraded",
"critical"
],
"title": "Overall"
}
},
"type": "object",
"required": [
"site_id",
"site_name",
"sensors",
"overall"
],
"title": "SiteSensorStatusResponse"
},
"SiteSensorsResponse": {
"properties": {
"consumption": {
"$ref": "#/components/schemas/SensorDiagnosticResponse"
},
"electrical": {
"$ref": "#/components/schemas/SensorDiagnosticResponse"
},
"temperature": {
"$ref": "#/components/schemas/SensorDiagnosticResponse"
},
"humidity": {
"$ref": "#/components/schemas/SensorDiagnosticResponse"
},
"network": {
"$ref": "#/components/schemas/SensorDiagnosticResponse"
}
},
"type": "object",
"required": [
"consumption",
"electrical",
"temperature",
"humidity",
"network"
],
"title": "SiteSensorsResponse"
},
"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": {
"properties": {
"user": {
@@ -1395,6 +2116,22 @@
{
"name": "sites",
"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`."
},
{
"name": "sensors",
"description": "État de santé des capteurs par site. Réservé au rôle `admin`."
}
]
}
+137
View File
@@ -0,0 +1,137 @@
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() == []
+5
View File
@@ -31,6 +31,11 @@ ROUTES_A_ROLE = {
("POST", "/api/v1/users/{id}/password-reset"),
("GET", "/api/v1/sites"),
("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"),
("GET", "/api/v1/sensors/status"),
}
@@ -0,0 +1,144 @@
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
+91
View File
@@ -0,0 +1,91 @@
from collections.abc import Callable, Iterator
from datetime import UTC, datetime
from uuid import uuid4
import pytest
from fastapi import FastAPI
from httpx import AsyncClient
from app.api.deps import get_current_principal, get_sensor_service
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.services.sensor import DiagnosticCapteur, EtatCapteurs, SanteCapteurs, SanteSite
TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
def principal(role: Role = Role.ADMIN) -> Principal:
return Principal(
id=uuid4(),
email=f"{role.value}@enervision.fr",
role=role,
kind=AccountKind.HUMAIN,
must_change_password=False,
)
class FauxService:
def __init__(self) -> None:
ok = DiagnosticCapteur(status="ok", since=None)
en_echec = DiagnosticCapteur(status="failing", since=TIMESTAMP)
self.etat = EtatCapteurs(
timestamp=TIMESTAMP,
sites=[
SanteSite(
site_id="SITE001",
site_name="Bureau Paris La Défense",
sensors=SanteCapteurs(
consumption=ok,
electrical=ok,
temperature=en_echec,
humidity=ok,
network=ok,
),
overall="degraded",
)
],
)
async def status(self) -> EtatCapteurs:
return self.etat
@pytest.fixture
def admin_connecte(app: FastAPI) -> Iterator[None]:
app.dependency_overrides[get_current_principal] = lambda: principal()
yield
app.dependency_overrides.pop(get_current_principal, None)
@pytest.fixture
def servi(app: FastAPI, admin_connecte: None) -> Iterator[Callable[[], FauxService]]:
def installe() -> FauxService:
service = FauxService()
app.dependency_overrides[get_sensor_service] = lambda: service
return service
yield installe
app.dependency_overrides.pop(get_sensor_service, None)
async def test_get_status_returns_the_service_result(
servi: Callable[[], FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/sensors/status")
assert response.status_code == 200
corps = response.json()
assert corps["sites"][0]["site_id"] == "SITE001"
assert corps["sites"][0]["overall"] == "degraded"
assert corps["sites"][0]["sensors"]["temperature"]["status"] == "failing"
assert corps["sites"][0]["sensors"]["consumption"]["status"] == "ok"
async def test_get_status_refuses_a_reader(app: FastAPI, client: AsyncClient) -> None:
app.dependency_overrides[get_current_principal] = lambda: principal(Role.LECTEUR)
response = await client.get("/api/v1/sensors/status")
assert response.status_code == 403
+73
View File
@@ -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"
@@ -0,0 +1,91 @@
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) == []
@@ -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}
@@ -0,0 +1,85 @@
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)
+55
View File
@@ -0,0 +1,55 @@
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")]
@@ -0,0 +1,55 @@
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)
+224
View File
@@ -0,0 +1,224 @@
from dataclasses import dataclass, field
from datetime import UTC, datetime
from app.services.sensor import SensorService
TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
@dataclass
class FauxSite:
site_id: str
site_name: str
@dataclass
class FauxLecture:
site_id: str
timestamp: datetime
data_quality: str | None
null_reasons: list[str] | None = field(default_factory=list)
consumption_kw: float | None = 10.0
voltage_v: float | None = 230.0
current_a: float | None = 5.0
power_factor: float | None = 0.95
temperature_celsius: float | None = 21.0
humidity_percent: float | None = 40.0
class FauxDepotSites:
def __init__(self, sites: list[FauxSite]) -> None:
self._sites = sites
async def list_all(self) -> list[FauxSite]:
return self._sites
class FauxDepotLectures:
def __init__(self, lectures: list[FauxLecture]) -> None:
self._lectures = lectures
async def latest_by_site(self) -> list[FauxLecture]:
return self._lectures
async def test_status_marks_a_site_without_any_reading_as_critical_with_every_sensor_failing() -> (
None
):
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures([]), # type: ignore[arg-type]
)
etat = await service.status()
site = etat.sites[0]
assert site.overall == "critical"
for capteur in (
site.sensors.consumption,
site.sensors.electrical,
site.sensors.temperature,
site.sensors.humidity,
site.sensors.network,
):
assert capteur.status == "failing"
assert capteur.since is None
async def test_status_marks_every_sensor_ok_on_a_good_quality_reading_with_no_null_field() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures([FauxLecture("A", TIMESTAMP, "good")]), # type: ignore[arg-type]
)
etat = await service.status()
site = etat.sites[0]
assert site.overall == "ok"
for capteur in (
site.sensors.consumption,
site.sensors.electrical,
site.sensors.temperature,
site.sensors.humidity,
site.sensors.network,
):
assert capteur.status == "ok"
assert capteur.since is None
async def test_status_flags_the_sensor_named_in_null_reasons() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures( # type: ignore[arg-type]
[
FauxLecture(
"A",
TIMESTAMP,
"partial",
null_reasons=["temperature_sensor_failure"],
temperature_celsius=None,
)
]
),
)
etat = await service.status()
site = etat.sites[0]
assert site.overall == "degraded"
assert site.sensors.temperature.status == "failing"
assert site.sensors.temperature.since == TIMESTAMP
assert site.sensors.consumption.status == "ok"
assert site.sensors.electrical.status == "ok"
assert site.sensors.humidity.status == "ok"
assert site.sensors.network.status == "ok"
async def test_status_flags_a_sensor_from_a_null_field_even_without_a_null_reason() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures( # type: ignore[arg-type]
[FauxLecture("A", TIMESTAMP, "partial", null_reasons=[], humidity_percent=None)]
),
)
etat = await service.status()
site = etat.sites[0]
assert site.sensors.humidity.status == "failing"
assert site.sensors.humidity.since == TIMESTAMP
async def test_status_flags_electrical_as_failing_when_any_of_its_three_fields_is_null() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures( # type: ignore[arg-type]
[FauxLecture("A", TIMESTAMP, "partial", null_reasons=[], power_factor=None)]
),
)
etat = await service.status()
site = etat.sites[0]
assert site.sensors.electrical.status == "failing"
async def test_status_forces_every_sensor_to_failing_when_overall_is_critical() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures([FauxLecture("A", TIMESTAMP, "critical", null_reasons=[])]), # type: ignore[arg-type]
)
etat = await service.status()
site = etat.sites[0]
assert site.overall == "critical"
for capteur in (
site.sensors.consumption,
site.sensors.electrical,
site.sensors.temperature,
site.sensors.humidity,
site.sensors.network,
):
assert capteur.status == "failing"
assert capteur.since == TIMESTAMP
async def test_status_treats_an_unknown_data_quality_as_critical() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures([FauxLecture("A", TIMESTAMP, None, null_reasons=[])]), # type: ignore[arg-type]
)
etat = await service.status()
assert etat.sites[0].overall == "critical"
async def test_status_ignores_an_unknown_null_reason() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures( # type: ignore[arg-type]
[FauxLecture("A", TIMESTAMP, "good", null_reasons=["something_else"])]
),
)
etat = await service.status()
site = etat.sites[0]
assert site.overall == "ok"
for capteur in (
site.sensors.consumption,
site.sensors.electrical,
site.sensors.temperature,
site.sensors.humidity,
site.sensors.network,
):
assert capteur.status == "ok"
async def test_status_flags_network_from_null_reasons_only() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures( # type: ignore[arg-type]
[
FauxLecture(
"A",
TIMESTAMP,
"partial",
null_reasons=["network_loss"],
)
]
),
)
etat = await service.status()
site = etat.sites[0]
assert site.overall == "degraded"
assert site.sensors.network.status == "failing"
assert site.sensors.network.since == TIMESTAMP
assert site.sensors.consumption.status == "ok"
assert site.sensors.electrical.status == "ok"
assert site.sensors.temperature.status == "ok"
assert site.sensors.humidity.status == "ok"
+107
View File
@@ -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
+47
View File
@@ -0,0 +1,47 @@
# ==================
# Étape 1 : Build
# ==================
# Image pour frontend
FROM node:24-alpine3.22 AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
# Installation des dépendances du projet avec npm
RUN npm ci
# Copie du code source vers le conteneur
COPY . .
# Build
RUN npm run build
# ==================
# Étape 2 : Runner
# ==================
FROM dhi.io/nginx:1.28.0-alpine3.21-dev AS runner
# Copie de la configuration de nginx
COPY --chown=root:root --chmod=755 nginx.conf /etc/nginx/nginx.conf
# Copy the static build output from the build stage to Nginx's default HTML serving directory
COPY --chown=root:root --chmod=755 --from=builder /app/dist/*/browser /usr/share/nginx/html
# Create necessary directories with proper permissions for nginx
RUN mkdir -p /var/log/nginx /var/cache/nginx && \
chown -R nginx:nginx /var/log/nginx /var/cache/nginx /usr/share/nginx/html
# Use a non-root user for security best practices
USER nginx
# Frontend : port 3000
# Backend : port 8000
EXPOSE 3000
# Start Nginx directly with custom config
ENTRYPOINT ["nginx", "-c", "/etc/nginx/nginx.conf"]
CMD ["-g", "daemon off;"]
+3 -1
View File
@@ -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": {
@@ -80,6 +81,7 @@
"builder": "@angular/build:unit-test",
"options": {
"coverage": true,
"isolate": true,
"coverageReporters": [
"text-summary",
"lcov",
+32
View File
@@ -0,0 +1,32 @@
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /tmp/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server {
listen 3000;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location ~ /\. {
deny all;
}
}
}
+10 -2
View File
@@ -1,13 +1,21 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import {ApplicationConfig, inject, provideAppInitializer, provideBrowserGlobalErrorListeners} from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { mockApiInterceptor } from './core/interceptors/mock-api-interceptor';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import {catchError, firstValueFrom, of} from 'rxjs';
import {AuthService} from './core/services/auth.service';
import {authInterceptor} from './core/interceptors/auth-interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(withInterceptors([mockApiInterceptor])),
provideHttpClient(withInterceptors([authInterceptor, mockApiInterceptor])),
provideAppInitializer(() => {
const auth = inject(AuthService);
// Un 401 ici est normal : ça veut juste dire qu'il n'y a pas de session.
return firstValueFrom(auth.refreshShared().pipe(catchError(() => of(null))));
}),
],
};
+5 -1
View File
@@ -1,9 +1,13 @@
import { Routes } from '@angular/router';
import {authGuard} from './core/guards/auth-guard';
export const routes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: 'login', loadComponent: () => import('./features/auth/login/login').then(m => m.Login) },
{ path: 'change-password', loadComponent: () => import('./features/auth/change-password/change-password').then(m => m.ChangePassword) },
{
path: 'dashboard',
loadComponent: () => import('./features/dashboard/dashboard').then((m) => m.Dashboard),
canActivate: [authGuard],
loadComponent: () => import('./features/dashboard/dashboard').then(m => m.Dashboard),
},
];
@@ -0,0 +1,67 @@
import { TestBed } from '@angular/core/testing';
import { Router, ActivatedRouteSnapshot } from '@angular/router';
import { vi } from 'vitest';
import { authGuard } from './auth-guard';
import { AuthService } from '../services/auth.service';
describe('authGuard', () => {
let authMock: { isAuthenticated: ReturnType<typeof vi.fn>; principal: ReturnType<typeof vi.fn> };
let routerMock: { navigate: ReturnType<typeof vi.fn> };
beforeEach(() => {
authMock = { isAuthenticated: vi.fn(), principal: vi.fn() };
routerMock = { navigate: vi.fn() };
TestBed.configureTestingModule({
providers: [
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
],
});
});
it('redirige vers /login si non authentifié', () => {
authMock.isAuthenticated.mockReturnValue(false);
const result = TestBed.runInInjectionContext(() =>
authGuard({ data: {} } as ActivatedRouteSnapshot, {} as any)
);
expect(result).toBe(false);
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
});
it('redirige vers /login si le rôle ne correspond pas', () => {
authMock.isAuthenticated.mockReturnValue(true);
authMock.principal.mockReturnValue({ role: 'lecteur' });
const result = TestBed.runInInjectionContext(() =>
authGuard({ data: { role: 'admin' } } as unknown as ActivatedRouteSnapshot, {} as any)
);
expect(result).toBe(false);
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
});
it('autorise si authentifié et rôle correspondant', () => {
authMock.isAuthenticated.mockReturnValue(true);
authMock.principal.mockReturnValue({ role: 'admin' });
const result = TestBed.runInInjectionContext(() =>
authGuard({ data: { role: 'admin' } } as unknown as ActivatedRouteSnapshot, {} as any)
);
expect(result).toBe(true);
});
it('autorise si authentifié et aucun rôle requis', () => {
authMock.isAuthenticated.mockReturnValue(true);
authMock.principal.mockReturnValue({ role: 'lecteur' });
const result = TestBed.runInInjectionContext(() =>
authGuard({ data: {} } as ActivatedRouteSnapshot, {} as any)
);
expect(result).toBe(true);
});
});
@@ -0,0 +1,21 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../services/auth.service';
export const authGuard: CanActivateFn = (route) => {
const auth = inject(AuthService);
const router = inject(Router);
if (!auth.isAuthenticated()) {
router.navigate(['/login']);
return false;
}
const requiredRole = route.data['role'] as string | undefined;
if (requiredRole && auth.principal()?.role !== requiredRole) {
router.navigate(['/login']);
return false;
}
return true;
};
@@ -0,0 +1,161 @@
import { TestBed } from '@angular/core/testing';
import {
HttpClient,
HttpHandlerFn,
HttpHeaders,
HttpRequest,
provideHttpClient,
withInterceptors
} from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { Router } from '@angular/router';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { authInterceptor } from './auth-interceptor';
import { AuthService } from '../services/auth.service';
describe('authInterceptor', () => {
let http: HttpClient;
let httpMock: HttpTestingController;
let authMock: { getAccessToken: ReturnType<typeof vi.fn>; clearSession: ReturnType<typeof vi.fn>; refreshShared: ReturnType<typeof vi.fn> };
let routerMock: { navigate: ReturnType<typeof vi.fn> };
beforeEach(() => {
authMock = {
getAccessToken: vi.fn().mockReturnValue('fake-token'),
clearSession: vi.fn(),
refreshShared: vi.fn(),
};
routerMock = { navigate: vi.fn() };
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
],
});
http = TestBed.inject(HttpClient);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('ajoute le header Authorization quand un token est disponible', () => {
http.get('/api/v1/stats/summary').subscribe();
const req = httpMock.expectOne('/api/v1/stats/summary');
expect(req.request.headers.get('Authorization')).toBe('Bearer fake-token');
req.flush({});
});
it("n'ajoute pas le header Authorization sur /auth/login", () => {
http.post('/api/v1/auth/login', {}).subscribe();
const req = httpMock.expectOne('/api/v1/auth/login');
expect(req.request.headers.has('Authorization')).toBe(false);
req.flush({});
});
it('ajoute withCredentials sur les routes /auth/*', () => {
http.post('/api/v1/auth/login', {}).subscribe();
const req = httpMock.expectOne('/api/v1/auth/login');
expect(req.request.withCredentials).toBe(true);
req.flush({});
});
it('redirige vers /change-password sur un 403 avec ce detail précis', () => {
http.get('/api/v1/dashboard').subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/dashboard');
req.flush({ detail: 'password_change_required' }, { status: 403, statusText: 'Forbidden' });
expect(routerMock.navigate).toHaveBeenCalledWith(['/change-password']);
});
it('ne redirige pas sur un 403 avec un autre detail', () => {
http.get('/api/v1/dashboard').subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/dashboard');
req.flush({ detail: 'Droits insuffisants' }, { status: 403, statusText: 'Forbidden' });
expect(routerMock.navigate).not.toHaveBeenCalled();
});
it('déconnecte et redirige vers /login sur un 401 avec error="invalid_token"', () => {
http.get('/api/v1/dashboard').subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/dashboard');
req.flush(
{},
{ status: 401, statusText: 'Unauthorized', headers: new HttpHeaders({ 'WWW-Authenticate': 'Bearer error="invalid_token"' }) }
);
expect(authMock.clearSession).toHaveBeenCalled();
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
});
it('déconnecte directement sur un 401 provenant de /auth/refresh, sans tenter de rafraîchir', () => {
http.post('/api/v1/auth/refresh', {}).subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/auth/refresh');
req.flush({}, { status: 401, statusText: 'Unauthorized' });
expect(authMock.clearSession).toHaveBeenCalled();
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
});
it('rafraîchit puis rejoue la requête sur un 401 avec error="expired"', () => {
authMock.refreshShared.mockReturnValue(of({ access_token: 'new-token' }));
authMock.getAccessToken.mockReturnValueOnce('old-token').mockReturnValue('new-token');
let result: unknown;
http.get('/api/v1/dashboard').subscribe((r) => (result = r));
const firstReq = httpMock.expectOne('/api/v1/dashboard');
firstReq.flush({}, { status: 401, statusText: 'Unauthorized', headers: new HttpHeaders({ 'WWW-Authenticate': 'Bearer error="expired"' }) });
const retriedReq = httpMock.expectOne('/api/v1/dashboard');
expect(retriedReq.request.headers.get('Authorization')).toBe('Bearer new-token');
retriedReq.flush({ ok: true });
expect(result).toEqual({ ok: true });
});
it('déconnecte si le rafraîchissement échoue après un 401 "expired"', () => {
authMock.refreshShared.mockReturnValue(throwError(() => new Error('refresh failed')));
http.get('/api/v1/dashboard').subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/dashboard');
req.flush({}, { status: 401, statusText: 'Unauthorized', headers: new HttpHeaders({ 'WWW-Authenticate': 'Bearer error="expired"' }) });
expect(authMock.clearSession).toHaveBeenCalled();
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
});
it("propage l'erreur telle quelle si ce n'est pas une HttpErrorResponse", () => {
const req = new HttpRequest('GET', '/api/v1/dashboard');
const boom = new Error('erreur inattendue, pas HTTP');
const next: HttpHandlerFn = () => throwError(() => boom);
let captured: unknown;
TestBed.runInInjectionContext(() => {
authInterceptor(req, next).subscribe({ error: (e) => (captured = e) });
});
expect(captured).toBe(boom);
});
it('propage un 401 sur /auth/login sans tenter de rafraîchir ni déconnecter', () => {
http.post('/api/v1/auth/login', {}).subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/auth/login');
req.flush({}, { status: 401, statusText: 'Unauthorized' });
expect(authMock.refreshShared).not.toHaveBeenCalled();
expect(authMock.clearSession).not.toHaveBeenCalled();
});
it("propage un 401 dont le WWW-Authenticate ne correspond à aucun cas connu", () => {
http.get('/api/v1/dashboard').subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/dashboard');
req.flush(
{},
{ status: 401, statusText: 'Unauthorized', headers: new HttpHeaders({ 'WWW-Authenticate': 'Bearer error="unknown_case"' }) }
);
expect(authMock.refreshShared).not.toHaveBeenCalled();
expect(authMock.clearSession).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,77 @@
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { Observable, catchError, switchMap, throwError } from 'rxjs';
import { AuthService } from '../services/auth.service';
import { TokenResponse } from '../../shared/models/auth.model';
function parseAuthError(response: HttpErrorResponse): string | null {
const header = response.headers?.get('WWW-Authenticate') ?? '';
const match = header.match(/error="([^"]+)"/);
return match ? match[1] : null;
}
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const router = inject(Router);
const isAuthRoute = req.url.includes('/auth/');
let request = isAuthRoute ? req.clone({ withCredentials: true }) : req;
const token = auth.getAccessToken();
if (token && !req.url.endsWith('/auth/login')) {
request = request.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
}
return next(request).pipe(
catchError((error: unknown) => {
if (!(error instanceof HttpErrorResponse)) {
return throwError(() => error);
}
if (error.status === 403) {
const detail = (error.error as { detail?: string })?.detail;
if (detail === 'password_change_required') {
router.navigate(['/change-password']);
}
return throwError(() => error);
}
if (error.status !== 401 || req.url.endsWith('/auth/login')) {
return throwError(() => error);
}
if (req.url.endsWith('/auth/refresh')) {
auth.clearSession();
router.navigate(['/login']);
return throwError(() => error);
}
const kind = parseAuthError(error);
if (kind === 'invalid_token') {
auth.clearSession();
router.navigate(['/login']);
return throwError(() => error);
}
if (kind === 'expired' || kind === 'token_stale') {
return (auth.refreshShared() as Observable<TokenResponse>).pipe(
switchMap(() => {
const retried = request.clone({
setHeaders: { Authorization: `Bearer ${auth.getAccessToken()}` },
});
return next(retried);
}),
catchError((refreshError) => {
auth.clearSession();
router.navigate(['/login']);
return throwError(() => refreshError);
})
);
}
return throwError(() => error);
})
);
};
@@ -0,0 +1,86 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { AuthService } from './auth.service';
import { environment } from '../../../environments/environment';
describe('AuthService', () => {
let service: AuthService;
let httpMock: HttpTestingController;
const tokenResponse = {
access_token: 'abc123',
token_type: 'bearer',
expires_in: 900,
principal: {
id: '1',
email: 'a@a.com',
role: 'admin' as const,
kind: 'human' as const,
must_change_password: false,
},
};
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(AuthService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('stocke le token et le principal après un login réussi', () => {
service.login({ email: 'a@a.com', password: 'secret' }).subscribe();
const req = httpMock.expectOne(`${environment.apiUrl}/auth/login`);
expect(req.request.withCredentials).toBe(true);
req.flush(tokenResponse);
expect(service.getAccessToken()).toBe('abc123');
expect(service.principal()?.email).toBe('a@a.com');
expect(service.isAuthenticated()).toBe(true);
});
it('efface la session au logout', () => {
service.login({ email: 'a@a.com', password: 'secret' }).subscribe();
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush(tokenResponse);
service.logout().subscribe();
httpMock.expectOne(`${environment.apiUrl}/auth/logout`).flush(null);
expect(service.getAccessToken()).toBeNull();
expect(service.isAuthenticated()).toBe(false);
});
it("ne déclenche qu'un seul appel réseau si refreshShared est appelé plusieurs fois avant la réponse", () => {
service.refreshShared().subscribe();
service.refreshShared().subscribe();
service.refreshShared().subscribe();
const requests = httpMock.match(`${environment.apiUrl}/auth/refresh`);
expect(requests.length).toBe(1);
requests[0].flush(tokenResponse);
});
it('met à jour la session après un changement de mot de passe réussi', () => {
service.changePassword({ current_password: 'old', new_password: 'new-password-1234' }).subscribe();
const req = httpMock.expectOne(`${environment.apiUrl}/auth/password`);
req.flush(tokenResponse);
expect(service.getAccessToken()).toBe('abc123');
});
it('récupère le principal courant via /auth/me', () => {
let result: unknown;
service.me().subscribe((r) => (result = r));
const req = httpMock.expectOne(`${environment.apiUrl}/auth/me`);
expect(req.request.method).toBe('GET');
req.flush(tokenResponse.principal);
expect(result).toEqual(tokenResponse.principal);
});
});
@@ -0,0 +1,69 @@
import { Service, signal, computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, tap, finalize, shareReplay } from 'rxjs';
import { LoginRequest, PasswordChangeRequest, Principal, TokenResponse } from '../../shared/models/auth.model';
import { environment } from '../../../environments/environment';
@Service()
export class AuthService {
private http = inject(HttpClient);
// Jamais de localStorage/sessionStorage/cookie côté JS : juste un signal en
// mémoire. Un rechargement de page le perd, c'est voulu par le contrat.
private accessTokenSignal = signal<string | null>(null);
private principalSignal = signal<Principal | null>(null);
readonly principal = this.principalSignal.asReadonly();
readonly isAuthenticated = computed(() => this.principalSignal() !== null);
private rotation$?: Observable<TokenResponse>;
getAccessToken(): string | null {
return this.accessTokenSignal();
}
private setSession(response: TokenResponse): void {
this.accessTokenSignal.set(response.access_token);
this.principalSignal.set(response.principal);
}
clearSession(): void {
this.accessTokenSignal.set(null);
this.principalSignal.set(null);
}
login(credentials: LoginRequest): Observable<TokenResponse> {
return this.http
.post<TokenResponse>(`${environment.apiUrl}/auth/login`, credentials, { withCredentials: true })
.pipe(tap((response) => this.setSession(response)));
}
// Un seul rafraîchissement en vol à la fois, partagé entre tous les
// appelants (sinon le serveur révoque toute la session sur des rotations concurrentes).
refreshShared(): Observable<TokenResponse> {
this.rotation$ ??= this.http
.post<TokenResponse>(`${environment.apiUrl}/auth/refresh`, {}, { withCredentials: true })
.pipe(
tap((response) => this.setSession(response)),
finalize(() => (this.rotation$ = undefined)),
shareReplay(1)
);
return this.rotation$;
}
logout(): Observable<void> {
return this.http
.post<void>(`${environment.apiUrl}/auth/logout`, {}, { withCredentials: true })
.pipe(tap(() => this.clearSession()));
}
changePassword(payload: PasswordChangeRequest): Observable<TokenResponse> {
return this.http
.post<TokenResponse>(`${environment.apiUrl}/auth/password`, payload, { withCredentials: true })
.pipe(tap((response) => this.setSession(response)));
}
me(): Observable<Principal> {
return this.http.get<Principal>(`${environment.apiUrl}/auth/me`);
}
}
@@ -0,0 +1,31 @@
<div class="auth-page">
<form class="auth-card" [formGroup]="form" (ngSubmit)="onSubmit()">
<h1>Nouveau mot de passe</h1>
<p class="auth-subtitle">Votre mot de passe est provisoire, vous devez le modifier avant de continuer</p>
<label for="current_password">Mot de passe actuel</label>
<input
id="current_password"
type="password"
formControlName="current_password"
autocomplete="current-password"
/>
<label for="new_password">Nouveau mot de passe</label>
<input
id="new_password"
type="password"
formControlName="new_password"
autocomplete="new-password"
/>
<span class="auth-hint">12 à 128 caractères</span>
@if (errorMessage()) {
<p class="auth-error">{{ errorMessage() }}</p>
}
<button type="submit" [disabled]="form.invalid || isLoading()">
{{ isLoading() ? 'Modification...' : 'Valider' }}
</button>
</form>
</div>
@@ -0,0 +1,88 @@
:host {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f3f4f6;
font-family: 'Segoe UI', system-ui, sans-serif;
}
.auth-card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 2.5rem;
width: 100%;
max-width: 360px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: column;
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.auth-subtitle {
margin: 0.25rem 0 1.5rem;
color: #6b7280;
font-size: 0.9rem;
line-height: 1.4;
}
label {
font-size: 0.85rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.35rem;
margin-top: 1rem;
}
input {
padding: 0.6rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
button {
margin-top: 1.5rem;
padding: 0.7rem;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
&:disabled {
background: #9ca3af;
cursor: not-allowed;
}
&:not(:disabled):hover {
background: #2563eb;
}
}
}
.auth-hint {
font-size: 0.75rem;
color: #9ca3af;
margin-top: 0.25rem;
}
.auth-error {
margin: 0.75rem 0 0;
color: #dc2626;
font-size: 0.85rem;
}
@@ -0,0 +1,88 @@
import { TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { ChangePassword } from './change-password';
import { AuthService } from '../../../core/services/auth.service';
describe('ChangePassword', () => {
let authMock: { changePassword: ReturnType<typeof vi.fn> };
let routerMock: { navigate: ReturnType<typeof vi.fn> };
beforeEach(async () => {
authMock = { changePassword: vi.fn() };
routerMock = { navigate: vi.fn() };
await TestBed.configureTestingModule({
imports: [ChangePassword, ReactiveFormsModule],
providers: [
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
],
}).compileComponents();
});
it('ne soumet pas si le formulaire est invalide (mot de passe trop court)', () => {
const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance;
component.form.setValue({ current_password: 'old', new_password: 'trop-court' });
component.onSubmit();
expect(authMock.changePassword).not.toHaveBeenCalled();
});
it('redirige vers /dashboard après un changement réussi', () => {
const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance;
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' });
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
component.onSubmit();
expect(routerMock.navigate).toHaveBeenCalledWith(['/dashboard']);
});
it("affiche un message d'erreur si le mot de passe actuel est incorrect", () => {
const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance;
component.form.setValue({ current_password: 'mauvais-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' });
authMock.changePassword.mockReturnValue(throwError(() => new Error('401')));
component.onSubmit();
fixture.detectChanges(); // rend le bloc @if (errorMessage())
expect(component.errorMessage()).toContain('incorrect');
const errorEl = fixture.nativeElement.querySelector('.auth-error');
expect(errorEl?.textContent).toContain('incorrect');
});
it('désactive le bouton tant que le formulaire est invalide', () => {
const fixture = TestBed.createComponent(ChangePassword);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button[type="submit"]');
expect(button.disabled).toBe(true);
expect(fixture.nativeElement.querySelector('.auth-error')).toBeNull();
});
it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => {
const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance;
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' });
fixture.detectChanges();
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
const form = fixture.nativeElement.querySelector('form');
form.dispatchEvent(new Event('submit'));
fixture.detectChanges();
expect(authMock.changePassword).toHaveBeenCalledWith({
current_password: 'ancien-mot-de-passe',
new_password: 'un-nouveau-mot-de-passe-valide',
});
});
});
@@ -0,0 +1,41 @@
import { Component, inject, signal } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { AuthService } from '../../../core/services/auth.service';
@Component({
selector: 'app-change-password',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './change-password.html',
styleUrl: './change-password.scss',
})
export class ChangePassword {
private fb = inject(FormBuilder);
private auth = inject(AuthService);
private router = inject(Router);
errorMessage = signal<string | null>(null);
isLoading = signal(false);
form = this.fb.nonNullable.group({
current_password: ['', Validators.required],
new_password: ['', [Validators.required, Validators.minLength(12), Validators.maxLength(128)]],
});
onSubmit(): void {
if (this.form.invalid) return;
this.isLoading.set(true);
this.errorMessage.set(null);
this.auth.changePassword(this.form.getRawValue()).subscribe({
next: (response) => {
this.router.navigate(['/dashboard']);
},
error: () => {
this.isLoading.set(false);
this.errorMessage.set('Mot de passe actuel incorrect, ou nouveau mot de passe invalide (12 à 128 caractères).');
},
});
}
}
@@ -0,0 +1,36 @@
<div class="auth-page">
<form class="auth-card" [formGroup]="form" (ngSubmit)="onSubmit()">
<h1>Connexion</h1>
<p class="auth-subtitle">Accédez à votre espace EnerVision</p>
<label for="email">Email</label>
<input
id="email"
type="email"
formControlName="email"
autocomplete="username"
placeholder="vous@enervision.fr"
/>
<label for="password">Mot de passe</label>
<input
id="password"
type="password"
formControlName="password"
autocomplete="current-password"
/>
@if (errorMessage()) {
<p class="auth-error">
{{ errorMessage() }}
@if (retryAfterSeconds(); as seconds) {
(réessayez dans {{ seconds }}s)
}
</p>
}
<button type="submit" [disabled]="form.invalid || isLoading()">
{{ isLoading() ? 'Connexion...' : 'Se connecter' }}
</button>
</form>
</div>
@@ -0,0 +1,81 @@
:host {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f3f4f6;
font-family: 'Segoe UI', system-ui, sans-serif;
}
.auth-card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 2.5rem;
width: 100%;
max-width: 360px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: column;
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.auth-subtitle {
margin: 0.25rem 0 1.5rem;
color: #6b7280;
font-size: 0.9rem;
}
label {
font-size: 0.85rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.35rem;
margin-top: 1rem;
}
input {
padding: 0.6rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
button {
margin-top: 1.5rem;
padding: 0.7rem;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
&:disabled {
background: #9ca3af;
cursor: not-allowed;
}
&:not(:disabled):hover {
background: #2563eb;
}
}
}
.auth-error {
margin: 0.75rem 0 0;
color: #dc2626;
font-size: 0.85rem;
}
@@ -0,0 +1,110 @@
import { TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { HttpErrorResponse, HttpHeaders } from '@angular/common/http';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { Login } from './login';
import { AuthService } from '../../../core/services/auth.service';
describe('Login', () => {
let authMock: { login: ReturnType<typeof vi.fn> };
let routerMock: { navigate: ReturnType<typeof vi.fn> };
beforeEach(async () => {
authMock = { login: vi.fn() };
routerMock = { navigate: vi.fn() };
await TestBed.configureTestingModule({
imports: [Login, ReactiveFormsModule],
providers: [
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
],
}).compileComponents();
});
it('ne soumet pas si le formulaire est invalide', () => {
const fixture = TestBed.createComponent(Login);
fixture.componentInstance.onSubmit();
expect(authMock.login).not.toHaveBeenCalled();
});
it('redirige vers /change-password si must_change_password est vrai', () => {
const fixture = TestBed.createComponent(Login);
const component = fixture.componentInstance;
component.form.setValue({ email: 'a@a.com', password: 'secret' });
authMock.login.mockReturnValue(of({ principal: { role: 'admin', must_change_password: true } }));
component.onSubmit();
expect(routerMock.navigate).toHaveBeenCalledWith(['/change-password']);
});
it('redirige vers /dashboard si le mot de passe est déjà à jour', () => {
const fixture = TestBed.createComponent(Login);
const component = fixture.componentInstance;
component.form.setValue({ email: 'a@a.com', password: 'secret' });
authMock.login.mockReturnValue(of({ principal: { role: 'lecteur', must_change_password: false } }));
component.onSubmit();
expect(routerMock.navigate).toHaveBeenCalledWith(['/dashboard']);
});
it('affiche un message générique sur un 401', () => {
const fixture = TestBed.createComponent(Login);
const component = fixture.componentInstance;
component.form.setValue({ email: 'a@a.com', password: 'wrong' });
authMock.login.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 401 })));
component.onSubmit();
fixture.detectChanges(); // rend le bloc @if (errorMessage()) du template
expect(component.errorMessage()).toBe('Email ou mot de passe incorrect.');
const errorEl = fixture.nativeElement.querySelector('.auth-error');
expect(errorEl?.textContent).toContain('Email ou mot de passe incorrect.');
});
it("affiche le délai d'attente sur un 429 avec Retry-After", () => {
const fixture = TestBed.createComponent(Login);
const component = fixture.componentInstance;
component.form.setValue({ email: 'a@a.com', password: 'wrong' });
authMock.login.mockReturnValue(
throwError(() => new HttpErrorResponse({ status: 429, headers: new HttpHeaders({ 'Retry-After': '30' }) }))
);
component.onSubmit();
fixture.detectChanges(); // rend aussi le sous-bloc @if (retryAfterSeconds(); as seconds)
expect(component.retryAfterSeconds()).toBe(30);
const errorEl = fixture.nativeElement.querySelector('.auth-error');
expect(errorEl?.textContent).toContain('30s');
});
it('désactive le bouton tant que le formulaire est invalide', () => {
const fixture = TestBed.createComponent(Login);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button[type="submit"]');
expect(button.disabled).toBe(true);
expect(fixture.nativeElement.querySelector('.auth-error')).toBeNull();
});
it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => {
const fixture = TestBed.createComponent(Login);
const component = fixture.componentInstance;
component.form.setValue({ email: 'a@a.com', password: 'secret' });
fixture.detectChanges();
authMock.login.mockReturnValue(of({ principal: { role: 'lecteur', must_change_password: false } }));
const form = fixture.nativeElement.querySelector('form');
form.dispatchEvent(new Event('submit'));
fixture.detectChanges();
expect(authMock.login).toHaveBeenCalledWith({ email: 'a@a.com', password: 'secret' });
});
});
@@ -0,0 +1,55 @@
import { Component, inject, signal } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http';
import { AuthService } from '../../../core/services/auth.service';
@Component({
selector: 'app-login',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './login.html',
styleUrl: './login.scss',
})
export class Login {
private fb = inject(FormBuilder);
private auth = inject(AuthService);
private router = inject(Router);
errorMessage = signal<string | null>(null);
retryAfterSeconds = signal<number | null>(null);
isLoading = signal(false);
form = this.fb.nonNullable.group({
email: ['', [Validators.required, Validators.email]],
password: ['', Validators.required],
});
onSubmit(): void {
if (this.form.invalid) return;
this.isLoading.set(true);
this.errorMessage.set(null);
this.retryAfterSeconds.set(null);
this.auth.login(this.form.getRawValue()).subscribe({
next: (response) => {
if (response.principal.must_change_password) {
this.router.navigate(['/change-password']);
return;
}
this.router.navigate(['/dashboard']);
},
error: (error: HttpErrorResponse) => {
this.isLoading.set(false);
if (error.status === 429) {
const retryAfter = error.headers.get('Retry-After');
this.retryAfterSeconds.set(retryAfter ? Number(retryAfter) : null);
this.errorMessage.set('Trop de tentatives, réessayez plus tard.');
return;
}
this.errorMessage.set('Email ou mot de passe incorrect.');
},
});
}
}
@@ -1,7 +1,10 @@
<div class="dashboard">
<header class="dashboard__header">
<h1>Vue d'ensemble</h1>
<p class="dashboard__subtitle">Consommation instantanée du parc</p>
<div>
<h1>Vue d'ensemble</h1>
<p class="dashboard__subtitle">Consommation instantanée du parc</p>
</div>
<button type="button" class="logout-button" (click)="onLogout()">Déconnexion</button>
</header>
@if (error(); as message) {
@@ -144,3 +144,30 @@ h2 {
.alert-item__message {
font-size: 0.9rem;
}
.dashboard__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 2rem;
h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
}
}
.logout-button {
padding: 0.5rem 1rem;
background: #ffffff;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 600;
color: #374151;
cursor: pointer;
&:hover {
background: #f3f4f6;
}
}
@@ -4,6 +4,8 @@ import { of, throwError } from 'rxjs';
import { Dashboard } from './dashboard';
import { StatsService } from '../../core/services/stats.service';
import { AlertsService } from '../../core/services/alerts.service';
import {AuthService} from '../../core/services/auth.service';
import {Router} from '@angular/router';
vi.mock('chart.js', () => {
class ChartMock {
@@ -92,4 +94,58 @@ describe('Dashboard', () => {
expect(fixture.componentInstance.alerts().length).toBe(0);
});
it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
const authMock = { logout: vi.fn().mockReturnValue(of(undefined)), clearSession: vi.fn() };
const routerMock = { navigate: vi.fn() };
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
],
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.logout-button');
button.click();
expect(authMock.logout).toHaveBeenCalled();
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
});
it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
const authMock = {
logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))),
clearSession: vi.fn(),
};
const routerMock = { navigate: vi.fn() };
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
],
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.logout-button');
button.click();
expect(authMock.clearSession).toHaveBeenCalled();
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
});
});
@@ -2,10 +2,12 @@ import { Component, OnInit, inject, signal, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { timer, switchMap, catchError, EMPTY, Observable } from 'rxjs';
import { DecimalPipe } from '@angular/common';
import { Router } from '@angular/router';
import { StatsService } from '../../core/services/stats.service';
import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge';
import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart';
import { AlertsService } from '../../core/services/alerts.service';
import { AuthService } from '../../core/services/auth.service';
import { StatsSummary } from '../../shared/models/stats.model';
import { Alert } from '../../shared/models/alert.model';
@@ -23,6 +25,8 @@ const UNAVAILABLE_MESSAGE =
export class Dashboard implements OnInit {
private statsService = inject(StatsService);
private alertsService = inject(AlertsService);
private auth = inject(AuthService);
private router = inject(Router);
private destroyRef = inject(DestroyRef);
stats = signal<StatsSummary | null>(null);
@@ -50,6 +54,17 @@ export class Dashboard implements OnInit {
});
}
onLogout(): void {
this.auth.logout().subscribe({
next: () => this.router.navigate(['/login']),
error: () => {
// Même si l'appel réseau échoue, on considère l'utilisateur déconnecté localement.
this.auth.clearSession();
this.router.navigate(['/login']);
},
});
}
private reportUnavailable(): Observable<never> {
this.error.set(UNAVAILABLE_MESSAGE);
return EMPTY;
@@ -0,0 +1,26 @@
export type Role = 'lecteur' | 'operateur' | 'admin';
export interface LoginRequest {
email: string;
password: string;
}
export interface PasswordChangeRequest {
current_password: string;
new_password: string;
}
export interface Principal {
id: string;
email: string;
role: Role;
kind: 'human';
must_change_password: boolean;
}
export interface TokenResponse {
access_token: string;
token_type: string;
expires_in: number;
principal: Principal;
}
@@ -1,5 +1,5 @@
export const environment = {
production: true,
apiUrl: 'http://localhost:8000/api/v1',
apiUrl: '/api/v1',
useMockFixtures: false,
};
+7
View File
@@ -43,5 +43,12 @@ services:
- "${BACKEND_PORT:-8000}:8000"
restart: unless-stopped
frontend:
build: ./apps/frontend
ports:
- "${FRONTEND_PORT:-3000}:80"
restart: unless-stopped
volumes:
pgdata:
+1 -1
View File
@@ -74,7 +74,7 @@ collecteur ne vient le lire.
| 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`, `GET /sites` et `GET /sites/{site_id}` (première couche métier, endpoints → services → repositories → models) |
| 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) |
| 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`) |
| Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée |
+4 -3
View File
@@ -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 :
+32 -20
View File
@@ -12,10 +12,10 @@ Les quatre couches existent désormais, portées par l'authentification.
```mermaid
flowchart TB
ep["endpoints<br/>health, auth, users, sites"]
ep["endpoints<br/>health, auth, users, sites, alerts,<br/>recommendations, stats, sensors"]
sc["schemas<br/>Pydantic"]
sv["services<br/>AuthService, UserService,<br/>SiteService"]
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site"]
sv["services<br/>AuthService, UserService,<br/>SiteService, AlertService, RecommendationService,<br/>StatsService, SensorService"]
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site, alert, recommendation, reading"]
md["models<br/>10 tables"]
db[("PostgreSQL")]
@@ -142,6 +142,11 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke
| POST | `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions. `admin` | 401, 403, 404, 422, 500 |
| GET | `/api/v1/sites` | Liste les sites. `lecteur` | 401, 403, 500 |
| GET | `/api/v1/sites/{site_id}` | Décrit un site. `lecteur` | 401, 403, 404, 422, 500 |
| GET | `/api/v1/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 | `/api/v1/sensors/status` | État de santé des capteurs par site, dérivé de la dernière lecture. `admin` | 401, 403, 500 |
| 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` | |
@@ -153,14 +158,20 @@ 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
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 à réutiliser
pour les suivantes (`reading`, `dataset`, `prediction`, `alert`, `recommendation`) : les quatre
couches `endpoints → services → repositories → models` y sont toutes présentes, sur des tables
déjà créées par la révision Alembic `e6d2026091501`. Elles n'exigent que le rôle `lecteur`,
`GET /sites` et `GET /sites/{site_id}` sont la première route métier, et le gabarit repris pour
`GET /alerts` puis pour les suivantes (`reading`, `dataset`, `prediction`, `recommendation`) : les
quatre couches `endpoints → services → repositories → models` y sont toutes présentes, sur des
tables 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
`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
réelle. Le contrat détaillé pour le frontend est dans
réelle. `GET /recommendations` et `GET /recommendations/{recommendation_id}` reprennent le même
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` et `GET /sensors/status` agrègent chacune deux repositories
(`SiteRepository`, `ReadingRepository`) dans un service dédié plutôt que d'exposer une table :
elles n'entrent donc pas dans ce gabarit route-par-table. Le contrat détaillé pour le frontend est
dans
[31-contrat-authentification.md](31-contrat-authentification.md).
### `/health/ready`
@@ -235,19 +246,20 @@ 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
`type`. Renommer un champ là-bas sans le faire ici rend la documentation fausse en silence.
**Ajouter une route métier** (`sites` est le gabarit, `reading`/`dataset`/`prediction`/`alert`/
`recommendation` suivront) :
### Ajouter une route métier
1. Composer ses `responses=` depuis `app/api/openapi.py` : `REPONSES_LECTEUR` ou `REPONSES_ADMIN`
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
corps, un paramètre ou peut échouer par identifiant.
2. Décrire son tag dans `TAGS` (`app/api/openapi.py`).
3. Si elle passe par `require_role`, l'ajouter à `ROUTES_A_ROLE`
(`tests/api/test_openapi.py`) ; si elle passe par `require_trusted_origin`, l'ajouter à
`ORIGINE_VERIFIEE`. Ces deux listes sont maintenues à la main, pas déduites automatiquement du
code : une route protégée qui n'y figure pas ne sera pas détectée par les tests.
4. `make openapi`, puis `pytest tests/api/test_openapi.py`.
Checklist pour toute nouvelle route sur le gabarit `sites`/`alerts`/`recommendations`/`stats`/
`sensors` (`reading`, `dataset`, `prediction`) :
1. Composer ses `responses=` depuis `app/api/openapi.py` : `REPONSES_LECTEUR`/`REPONSES_ADMIN`
au niveau de l'`include_router()` du routeur, `REPONSE_VALIDATION` et les codes locaux
(404, 409, ...) directement sur l'endpoint qui les rend.
2. Décrire son tag dans `TAGS`.
3. Si elle passe par `require_role` (`LecteurDep`/`OperateurDep`/`AdminDep`), l'ajouter à
`ROUTES_A_ROLE` dans `tests/api/test_openapi.py`. Si elle passe par `require_trusted_origin`,
l'ajouter à `ORIGINE_VERIFIEE`. **Ces deux listes sont maintenues à la main, pas dérivées** :
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é
+3 -2
View File
@@ -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
+3 -3
View File
@@ -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.
Statut : `Fait` pour le périmètre authentification et autorisation. `GET /sites` et
`GET /sites/{site_id}` sont les premiers endpoints métier, en lecture seule ; plusieurs lignes
resteront à compléter une fois les endpoints d'écriture posés.
`GET /recommendations`, chacune avec sa route de détail, sont les premiers endpoints métier, en
lecture seule ; plusieurs lignes resteront à compléter une fois les endpoints d'écriture posés.
## Contrôles en place
@@ -49,7 +49,7 @@ règles Bandit. Ajouter Bandit à la CI serait redondant, contrairement à ce qu
| 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}` 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. |
| **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. |
| **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. |
| **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. |
+14
View File
@@ -0,0 +1,14 @@
sonar.projectKey=ProjetPiscine_EnerVision
sonar.organization=groupe3-ener-vision
# This is the name and version displayed in the SonarCloud UI.
#sonar.projectName=ProjetPiscine_EnerVision
#sonar.projectVersion=1.0
# Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows.
#sonar.sources=.
# Encoding of the source code. Default is default system encoding
#sonar.sourceEncoding=UTF-8