Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61e031fc16 | ||
|
|
56f7211f0b | ||
|
|
c83fd889b8 | ||
|
|
cf22b2ae55 | ||
|
|
12c5cf87ad | ||
|
|
7b076171d2 | ||
|
|
ad149db0cb | ||
|
|
50dcb4de32 | ||
|
|
d25e544db6 | ||
|
|
22ff1d93f4 | ||
|
|
d632af57b8 | ||
|
|
fabd073aaf | ||
|
|
31a9cb109f | ||
|
|
50dddf952b | ||
|
|
fc6600aeaf | ||
|
|
1325a75e9a | ||
|
|
16a0cc4d3b | ||
|
|
5e7cb005ac | ||
|
|
3347fa5bdb | ||
|
|
da481d7485 | ||
|
|
344f82fcdd | ||
|
|
44468e85d7 | ||
|
|
580da72eff | ||
|
|
e85c83972a | ||
|
|
da97e6aa8b | ||
|
|
0259f66b62 | ||
|
|
7b9406965e | ||
|
|
881f503f1a | ||
|
|
918bd971da | ||
|
|
6c1f86b4ce | ||
|
|
cdef30736a |
@@ -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 \
|
||||
docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin
|
||||
.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 .
|
||||
|
||||
@@ -34,6 +52,9 @@ test-integration: ## Exécute les tests exigeant une base joignable
|
||||
|
||||
check: lint typecheck test ## Chaîne de vérification complète
|
||||
|
||||
openapi: ## Régénère apps/backend/openapi.json depuis les routes déclarées
|
||||
cd $(BACKEND) && uv run python -m app.cli export-openapi
|
||||
|
||||
docker-build: ## Construit l'image du backend
|
||||
docker build -t enervision-backend:local $(BACKEND)
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m
|
||||
| Domaine | Technologie | Emplacement | Etat |
|
||||
|------------|-------------------------------------|---------------------|---------------|
|
||||
| Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise |
|
||||
| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Squelette |
|
||||
| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Tableau de bord |
|
||||
| Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise |
|
||||
| ETL | Apache Airflow | `etl/airflow` | A initialiser |
|
||||
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
|
||||
@@ -27,8 +27,9 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m
|
||||
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser |
|
||||
|
||||
Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend
|
||||
porte le squelette Angular, sans code metier : aucune route, aucun appel d'API. Les autres dossiers
|
||||
portent l'arborescence et un README de cadrage, leur contenu fait l'objet d'un ticket dedie.
|
||||
sert un tableau de bord sur `/dashboard`, dont les données proviennent de fixtures : les endpoints
|
||||
correspondants restent à écrire côté API. Les autres dossiers portent l'arborescence et un README
|
||||
de cadrage, leur contenu fait l'objet d'un ticket dedie.
|
||||
|
||||
L'etat detaille de chaque brique et les vues d'architecture sont dans
|
||||
[docs/architecture](docs/architecture/README.md).
|
||||
@@ -62,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
|
||||
```
|
||||
|
||||
@@ -82,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 :
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ de demarrer sans elles.
|
||||
## Commandes
|
||||
|
||||
Depuis la racine du monorepo, via le `Makefile` : `make install`, `make dev`, `make lint`,
|
||||
`make format`, `make typecheck`, `make test`, `make check`, `make docker-build`.
|
||||
`make format`, `make typecheck`, `make test`, `make check`, `make openapi`, `make docker-build`.
|
||||
|
||||
Directement depuis ce dossier :
|
||||
|
||||
@@ -39,8 +39,12 @@ uv run ruff format . # format
|
||||
uv run mypy app # typage strict
|
||||
uv run pytest # tests + couverture
|
||||
uv run pytest -m integration # tests exigeant une base joignable
|
||||
uv run python -m app.cli export-openapi # régénère openapi.json
|
||||
```
|
||||
|
||||
`openapi.json` est versionné : `tests/api/test_openapi.py` échoue si le fichier ne correspond
|
||||
plus aux routes déclarées. Toute PR qui change une route le régénère dans le même commit.
|
||||
|
||||
Les conventions de tests, les gabarits et le detail des marqueurs sont dans
|
||||
[`TESTING.md`](TESTING.md).
|
||||
|
||||
@@ -103,6 +107,8 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie
|
||||
| `/api/v1/users` | Liste et crée des comptes | `admin` |
|
||||
| `/api/v1/users/{id}` | Change le rôle ou l'activation | `admin` |
|
||||
| `/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` |
|
||||
| `/metrics` | Métriques au format Prometheus | jeton si `APP_METRICS_TOKEN` |
|
||||
| `/docs`, `/openapi.json` | Documentation, fermée en `staging` et `prod` | public sinon |
|
||||
|
||||
|
||||
@@ -23,9 +23,13 @@ from app.core.security import decode_access_token as decode_token
|
||||
from app.db.session import get_session
|
||||
from app.repositories.audit_log import AuditLogRepository
|
||||
from app.repositories.login_attempt import LoginAttemptRepository
|
||||
from app.repositories.reading import ReadingRepository
|
||||
from app.repositories.refresh_token import RefreshTokenRepository
|
||||
from app.repositories.site import SiteRepository
|
||||
from app.repositories.user import UserRepository
|
||||
from app.services.auth import AuthService, LoginPolicy
|
||||
from app.services.site import SiteService
|
||||
from app.services.stats import StatsService
|
||||
from app.services.user import UserService
|
||||
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
@@ -131,6 +135,20 @@ def get_user_service(
|
||||
UserServiceDep = Annotated[UserService, Depends(get_user_service)]
|
||||
|
||||
|
||||
def get_site_service(session: SessionDep) -> SiteService:
|
||||
return SiteService(sites=SiteRepository(session))
|
||||
|
||||
|
||||
SiteServiceDep = Annotated[SiteService, Depends(get_site_service)]
|
||||
|
||||
|
||||
def get_stats_service(session: SessionDep) -> StatsService:
|
||||
return StatsService(sites=SiteRepository(session), readings=ReadingRepository(session))
|
||||
|
||||
|
||||
StatsServiceDep = Annotated[StatsService, Depends(get_stats_service)]
|
||||
|
||||
|
||||
async def get_current_principal(
|
||||
credentials: CredentialsDep,
|
||||
session: SessionDep,
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# Piège : `cookie_de_rafraichissement` est purement documentaire, d'où son `auto_error=False`.
|
||||
# Avec la valeur par défaut, FastAPI répondrait 403 avant d'atteindre `lit_le_cookie()`, et
|
||||
# `/auth/refresh` cesserait de rendre le 401 que le frontend attend.
|
||||
|
||||
from typing import Any, Final
|
||||
|
||||
from fastapi.security import APIKeyCookie
|
||||
|
||||
from app.core.config import REFRESH_COOKIE_DEFAUT
|
||||
from app.schemas.errors import ErrorResponse, InternalErrorResponse, ValidationErrorResponse
|
||||
|
||||
Reponses = dict[int | str, dict[str, Any]]
|
||||
|
||||
SUMMARY: Final = "Collecte, analyse et restitution de séries temporelles énergétiques."
|
||||
|
||||
DESCRIPTION: Final = """
|
||||
Toutes les routes sont préfixées par `/api/v1`.
|
||||
|
||||
**Authentification.** Le jeton d'accès se présente dans l'en-tête `Authorization: Bearer ...`.
|
||||
Le jeton de rafraîchissement est un cookie `HttpOnly` que le code client ne voit jamais : il
|
||||
suffit d'émettre les requêtes avec les identifiants de session. `POST /auth/refresh` rend un
|
||||
nouveau jeton d'accès et fait tourner le cookie.
|
||||
|
||||
**Rôles.** `lecteur`, puis `operateur`, puis `admin`. Chaque rôle couvre les droits du
|
||||
précédent.
|
||||
|
||||
**Erreurs.** Le corps porte toujours une clé `detail`. Un `403` dont le `detail` vaut
|
||||
`password_change_required` n'est pas un refus de droits : il exige le changement du mot de passe
|
||||
provisoire avant toute autre action.
|
||||
|
||||
Le parcours de session complet est décrit dans
|
||||
`docs/architecture/31-contrat-authentification.md`.
|
||||
"""
|
||||
|
||||
TAGS: Final[list[dict[str, Any]]] = [
|
||||
{
|
||||
"name": "health",
|
||||
"description": (
|
||||
"Sondes d'infrastructure, publiques. `live` prouve que le processus répond, `ready` "
|
||||
"que la base répond et que l'extension TimescaleDB est chargée."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "auth",
|
||||
"description": (
|
||||
"Ouverture, rotation et fermeture de session, et changement de son propre mot de passe."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "users",
|
||||
"description": "Administration des comptes. Réservé au rôle `admin`.",
|
||||
},
|
||||
{
|
||||
"name": "sites",
|
||||
"description": "Consultation du parc de sites. Accessible à partir du rôle `lecteur`.",
|
||||
},
|
||||
{
|
||||
"name": "stats",
|
||||
"description": "Statistiques agrégées de consommation. Accessible à partir du rôle "
|
||||
"`lecteur`.",
|
||||
},
|
||||
]
|
||||
|
||||
cookie_de_rafraichissement = APIKeyCookie(
|
||||
name=REFRESH_COOKIE_DEFAUT,
|
||||
scheme_name="Cookie de rafraîchissement",
|
||||
description=(
|
||||
"Cookie `HttpOnly` posé par `/auth/login` et tourné par `/auth/refresh`. Il prend le "
|
||||
"préfixe `__Secure-` dès que l'API tourne derrière TLS, et n'est émis que vers "
|
||||
"`/api/v1/auth`."
|
||||
),
|
||||
auto_error=False,
|
||||
)
|
||||
|
||||
# Le 422 n'est déclaré que sur les routes qui acceptent un corps ou un paramètre : ailleurs,
|
||||
# aucune validation ne peut échouer et l'annoncer serait faux.
|
||||
REPONSE_VALIDATION: Final[Reponses] = {
|
||||
422: {
|
||||
"model": ValidationErrorResponse,
|
||||
"description": (
|
||||
"Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la "
|
||||
"valeur envoyée."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
REPONSE_SERVEUR: Final[Reponses] = {
|
||||
500: {
|
||||
"model": InternalErrorResponse,
|
||||
"description": (
|
||||
"Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas "
|
||||
"renvoyée au client."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
REPONSE_INDISPONIBLE: Final[Reponses] = {
|
||||
503: {
|
||||
"model": ErrorResponse,
|
||||
"description": "Base injoignable, ou extension TimescaleDB absente de la base.",
|
||||
},
|
||||
}
|
||||
|
||||
REPONSES_AUTHENTIFIEES: Final[Reponses] = {
|
||||
401: {
|
||||
"model": ErrorResponse,
|
||||
"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=`."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
REPONSES_ADMIN: Final[Reponses] = {
|
||||
**REPONSES_AUTHENTIFIEES,
|
||||
403: {
|
||||
"model": ErrorResponse,
|
||||
"description": (
|
||||
"Droits insuffisants, ou mot de passe provisoire à changer quand `detail` vaut "
|
||||
"`password_change_required`."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
# `lecteur` est le rôle minimum : `require_role` n'y refuse jamais un 403 pour droits
|
||||
# insuffisants, seulement pour le mot de passe provisoire.
|
||||
REPONSES_LECTEUR: Final[Reponses] = {
|
||||
**REPONSES_AUTHENTIFIEES,
|
||||
403: {
|
||||
"model": ErrorResponse,
|
||||
"description": (
|
||||
"Mot de passe provisoire à changer (`detail` vaut `password_change_required`)."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
REPONSE_ORIGINE_REFUSEE: Final[Reponses] = {
|
||||
403: {
|
||||
"model": ErrorResponse,
|
||||
"description": "Origine non autorisée (protection CSRF de `require_trusted_origin`).",
|
||||
},
|
||||
}
|
||||
@@ -11,6 +11,13 @@ from app.api.deps import (
|
||||
get_client_ip,
|
||||
require_trusted_origin,
|
||||
)
|
||||
from app.api.openapi import (
|
||||
REPONSE_ORIGINE_REFUSEE,
|
||||
REPONSE_VALIDATION,
|
||||
REPONSES_AUTHENTIFIEES,
|
||||
Reponses,
|
||||
cookie_de_rafraichissement,
|
||||
)
|
||||
from app.core.cookies import RefreshCookie, cookie_name
|
||||
from app.core.logging import get_logger
|
||||
from app.schemas.auth import (
|
||||
@@ -19,6 +26,7 @@ from app.schemas.auth import (
|
||||
PrincipalResponse,
|
||||
TokenResponse,
|
||||
)
|
||||
from app.schemas.errors import ErrorResponse
|
||||
from app.services.auth import (
|
||||
AuthenticatedSession,
|
||||
InvalidCredentialsError,
|
||||
@@ -32,6 +40,51 @@ logger = get_logger(__name__)
|
||||
DETAIL_IDENTIFIANTS = "Identifiants invalides"
|
||||
DETAIL_SESSION = "Session invalide"
|
||||
|
||||
REPONSES_LOGIN: Reponses = {
|
||||
**REPONSE_VALIDATION,
|
||||
401: {
|
||||
"model": ErrorResponse,
|
||||
"description": (
|
||||
"Identifiants faux, compte inconnu ou compte désactivé. Le message est le même dans "
|
||||
"les trois cas, et n'apprend donc rien sur l'existence du compte."
|
||||
),
|
||||
},
|
||||
429: {
|
||||
"model": ErrorResponse,
|
||||
"description": "Trop de tentatives sur cette fenêtre glissante.",
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Secondes à attendre avant une nouvelle tentative.",
|
||||
"schema": {"type": "integer"},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
REPONSES_REFRESH: Reponses = {
|
||||
**REPONSE_ORIGINE_REFUSEE,
|
||||
401: {
|
||||
"model": ErrorResponse,
|
||||
"description": (
|
||||
"Cookie absent, session expirée, révoquée, ou jeton déjà tourné. Dans ce dernier cas "
|
||||
"toute la famille de sessions est révoquée et le cookie est effacé avec la réponse."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
REPONSES_LOGOUT: Reponses = {**REPONSE_ORIGINE_REFUSEE}
|
||||
|
||||
REPONSES_LOGOUT_ALL: Reponses = {**REPONSES_AUTHENTIFIEES, **REPONSE_ORIGINE_REFUSEE}
|
||||
|
||||
REPONSES_MOT_DE_PASSE: Reponses = {
|
||||
**REPONSE_VALIDATION,
|
||||
**REPONSE_ORIGINE_REFUSEE,
|
||||
401: {
|
||||
"model": ErrorResponse,
|
||||
"description": "Jeton d'accès invalide, ou mot de passe courant faux.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def repond(
|
||||
response: Response, settings: SettingsDep, session: AuthenticatedSession
|
||||
@@ -61,7 +114,12 @@ def lit_le_cookie(request: Request, settings: SettingsDep) -> str:
|
||||
return secret
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse, summary="Ouvre une session")
|
||||
@router.post(
|
||||
"/login",
|
||||
response_model=TokenResponse,
|
||||
summary="Ouvre une session",
|
||||
responses=REPONSES_LOGIN,
|
||||
)
|
||||
async def login(
|
||||
payload: LoginRequest,
|
||||
request: Request,
|
||||
@@ -98,7 +156,8 @@ async def login(
|
||||
"/refresh",
|
||||
response_model=TokenResponse,
|
||||
summary="Fait tourner la session",
|
||||
dependencies=[Depends(require_trusted_origin)],
|
||||
dependencies=[Depends(require_trusted_origin), Depends(cookie_de_rafraichissement)],
|
||||
responses=REPONSES_REFRESH,
|
||||
)
|
||||
async def refresh(
|
||||
request: Request,
|
||||
@@ -133,7 +192,8 @@ async def refresh(
|
||||
"/logout",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Ferme la session courante",
|
||||
dependencies=[Depends(require_trusted_origin)],
|
||||
dependencies=[Depends(require_trusted_origin), Depends(cookie_de_rafraichissement)],
|
||||
responses=REPONSES_LOGOUT,
|
||||
)
|
||||
async def logout(
|
||||
request: Request, response: Response, settings: SettingsDep, service: AuthServiceDep
|
||||
@@ -150,6 +210,7 @@ async def logout(
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Ferme toutes les sessions du compte",
|
||||
dependencies=[Depends(require_trusted_origin)],
|
||||
responses=REPONSES_LOGOUT_ALL,
|
||||
)
|
||||
async def logout_all(
|
||||
principal: CurrentPrincipalDep,
|
||||
@@ -163,7 +224,12 @@ async def logout_all(
|
||||
response.delete_cookie(**RefreshCookie.expired(settings).as_deletion_kwargs())
|
||||
|
||||
|
||||
@router.get("/me", response_model=PrincipalResponse, summary="Décrit le compte connecté")
|
||||
@router.get(
|
||||
"/me",
|
||||
response_model=PrincipalResponse,
|
||||
summary="Décrit le compte connecté",
|
||||
responses=REPONSES_AUTHENTIFIEES,
|
||||
)
|
||||
async def me(principal: CurrentPrincipalDep) -> PrincipalResponse:
|
||||
return PrincipalResponse.from_principal(principal)
|
||||
|
||||
@@ -173,6 +239,7 @@ async def me(principal: CurrentPrincipalDep) -> PrincipalResponse:
|
||||
response_model=TokenResponse,
|
||||
summary="Change son propre mot de passe",
|
||||
dependencies=[Depends(require_trusted_origin)],
|
||||
responses=REPONSES_MOT_DE_PASSE,
|
||||
)
|
||||
async def change_password(
|
||||
payload: PasswordChangeRequest,
|
||||
|
||||
@@ -3,16 +3,17 @@ from sqlalchemy import text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.api.deps import SessionDep, SettingsDep
|
||||
from app.api.openapi import REPONSE_INDISPONIBLE
|
||||
from app.core.logging import get_logger
|
||||
from app.schemas.health import LivenessStatus, ReadinessStatus
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter(tags=["health"])
|
||||
router = APIRouter()
|
||||
|
||||
TIMESCALEDB_VERSION = text("SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'")
|
||||
|
||||
|
||||
@router.get("/live", summary="Sonde de vivacite")
|
||||
@router.get("/live", summary="Sonde de vivacité")
|
||||
async def liveness(settings: SettingsDep) -> LivenessStatus:
|
||||
return LivenessStatus(
|
||||
status="ok",
|
||||
@@ -22,11 +23,13 @@ async def liveness(settings: SettingsDep) -> LivenessStatus:
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ready", summary="Sonde de disponibilite")
|
||||
@router.get("/ready", summary="Sonde de disponibilité", responses=REPONSE_INDISPONIBLE)
|
||||
async def readiness(session: SessionDep) -> ReadinessStatus:
|
||||
try:
|
||||
version: str | None = await session.scalar(TIMESCALEDB_VERSION)
|
||||
except SQLAlchemyError, OSError:
|
||||
# `# fmt: skip` contourne un bug de ruff format 0.16.7 : il retire les parenthèses de ce
|
||||
# `except` à deux types, ce qui produit une syntaxe invalide (`except A, B:`).
|
||||
except (SQLAlchemyError, OSError): # fmt: skip
|
||||
logger.exception("Base de données injoignable")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.api.deps import LecteurDep, SiteServiceDep
|
||||
from app.api.openapi import REPONSE_VALIDATION, Reponses
|
||||
from app.schemas.errors import ErrorResponse
|
||||
from app.schemas.site import SiteResponse
|
||||
from app.services.site import SiteNotFoundError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
REPONSES_INTROUVABLE: Reponses = {
|
||||
**REPONSE_VALIDATION,
|
||||
404: {"model": ErrorResponse, "description": "Aucun site ne porte cet identifiant."},
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_model=list[SiteResponse], summary="Liste les sites")
|
||||
async def list_sites(_: LecteurDep, service: SiteServiceDep) -> list[SiteResponse]:
|
||||
sites = await service.list_all()
|
||||
return [SiteResponse.model_validate(site) for site in sites]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{site_id}",
|
||||
response_model=SiteResponse,
|
||||
summary="Décrit un site",
|
||||
responses=REPONSES_INTROUVABLE,
|
||||
)
|
||||
async def get_site(site_id: str, _: LecteurDep, service: SiteServiceDep) -> SiteResponse:
|
||||
try:
|
||||
site = await service.get_by_id(site_id)
|
||||
except SiteNotFoundError as erreur:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable"
|
||||
) from erreur
|
||||
return SiteResponse.model_validate(site)
|
||||
@@ -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)
|
||||
@@ -3,7 +3,9 @@ from uuid import UUID
|
||||
from fastapi import APIRouter, HTTPException, Response, status
|
||||
|
||||
from app.api.deps import AdminDep, UserServiceDep
|
||||
from app.api.openapi import REPONSE_VALIDATION, Reponses
|
||||
from app.core.logging import get_logger
|
||||
from app.schemas.errors import ErrorResponse
|
||||
from app.schemas.user import (
|
||||
TemporaryPasswordResponse,
|
||||
UserCreateRequest,
|
||||
@@ -15,6 +17,28 @@ from app.services.user import EmailAlreadyUsedError, LastAdminError, UserNotFoun
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
REPONSES_CREATION: Reponses = {
|
||||
**REPONSE_VALIDATION,
|
||||
409: {"model": ErrorResponse, "description": "Adresse déjà portée par un autre compte."},
|
||||
}
|
||||
|
||||
REPONSES_INTROUVABLE: Reponses = {
|
||||
**REPONSE_VALIDATION,
|
||||
404: {"model": ErrorResponse, "description": "Aucun compte ne porte cet identifiant."},
|
||||
}
|
||||
|
||||
REPONSES_MODIFICATION: Reponses = {
|
||||
**REPONSES_INTROUVABLE,
|
||||
400: {"model": ErrorResponse, "description": "Corps vide, aucune modification demandée."},
|
||||
409: {
|
||||
"model": ErrorResponse,
|
||||
"description": (
|
||||
"L'opération laisserait la plateforme sans administrateur actif, qu'il s'agisse de "
|
||||
"rétrograder le dernier ou de le désactiver."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_model=list[UserResponse], summary="Liste les comptes")
|
||||
async def list_users(_: AdminDep, service: UserServiceDep) -> list[UserResponse]:
|
||||
@@ -27,6 +51,7 @@ async def list_users(_: AdminDep, service: UserServiceDep) -> list[UserResponse]
|
||||
response_model=TemporaryPasswordResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Crée un compte avec un mot de passe provisoire",
|
||||
responses=REPONSES_CREATION,
|
||||
)
|
||||
async def create_user(
|
||||
payload: UserCreateRequest,
|
||||
@@ -55,7 +80,12 @@ async def create_user(
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserResponse, summary="Change le rôle ou l'activation")
|
||||
@router.patch(
|
||||
"/{user_id}",
|
||||
response_model=UserResponse,
|
||||
summary="Change le rôle ou l'activation",
|
||||
responses=REPONSES_MODIFICATION,
|
||||
)
|
||||
async def update_user(
|
||||
user_id: UUID,
|
||||
payload: UserUpdateRequest,
|
||||
@@ -92,6 +122,7 @@ async def update_user(
|
||||
"/{user_id}/password-reset",
|
||||
response_model=TemporaryPasswordResponse,
|
||||
summary="Réinitialise le mot de passe et ferme les sessions",
|
||||
responses=REPONSES_INTROUVABLE,
|
||||
)
|
||||
async def reset_password(
|
||||
user_id: UUID, acteur: AdminDep, service: UserServiceDep, response: Response
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.endpoints import auth, health, users
|
||||
from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR
|
||||
from app.api.v1.endpoints import auth, health, sites, stats, users
|
||||
|
||||
api_router = APIRouter()
|
||||
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"])
|
||||
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(stats.router, prefix="/stats", tags=["stats"], responses=REPONSES_LECTEUR)
|
||||
|
||||
@@ -7,18 +7,25 @@
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import secrets
|
||||
import sys
|
||||
from getpass import getpass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import SecretStr
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.hashing import build_hasher
|
||||
from app.core.roles import Role
|
||||
from app.db.session import get_session_factory
|
||||
from app.main import create_app
|
||||
from app.repositories.user import UserRepository
|
||||
|
||||
LONGUEUR_MOT_DE_PASSE_GENERE = 24
|
||||
LONGUEUR_MINIMALE = 12
|
||||
CHEMIN_CONTRAT = Path(__file__).resolve().parent.parent / "openapi.json"
|
||||
|
||||
|
||||
async def create_admin(
|
||||
@@ -55,6 +62,35 @@ async def create_admin(
|
||||
)
|
||||
|
||||
|
||||
# Piège : le schéma ne doit dépendre ni du `.env` du poste ni des variables `APP_*`, sinon le
|
||||
# fichier versionné changerait de machine en machine et le test de dérive deviendrait un oracle
|
||||
# de configuration locale. Tout ce qui atteint le schéma est donc posé ici, `_env_file` compris.
|
||||
def settings_du_contrat() -> Settings:
|
||||
return Settings(
|
||||
_env_file=None,
|
||||
name="EnerVision API",
|
||||
version="0.1.0",
|
||||
env="local",
|
||||
api_prefix="/api/v1",
|
||||
secret_key=SecretStr("contrat-openapi-sans-effet-sur-le-schema"),
|
||||
database_url="postgresql+asyncpg://openapi:contrat@localhost:5432/enervision",
|
||||
)
|
||||
|
||||
|
||||
def schema_du_contrat() -> dict[str, Any]:
|
||||
schema: dict[str, Any] = create_app(settings_du_contrat()).openapi()
|
||||
return schema
|
||||
|
||||
|
||||
def rend_le_contrat() -> str:
|
||||
return json.dumps(schema_du_contrat(), indent=2, ensure_ascii=False) + "\n"
|
||||
|
||||
|
||||
def export_openapi(destination: Path) -> str:
|
||||
destination.write_text(rend_le_contrat(), encoding="utf-8")
|
||||
return f"Contrat OpenAPI écrit dans {destination}"
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="python -m app.cli", description="Outils EnerVision")
|
||||
sous_commandes = parser.add_subparsers(dest="commande", required=True)
|
||||
@@ -67,6 +103,11 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
admin.add_argument(
|
||||
"--force", action="store_true", help="Crée le compte même si un administrateur existe"
|
||||
)
|
||||
|
||||
contrat = sous_commandes.add_parser(
|
||||
"export-openapi", help="Écrit le contrat OpenAPI sur disque"
|
||||
)
|
||||
contrat.add_argument("--output", default=str(CHEMIN_CONTRAT))
|
||||
return parser
|
||||
|
||||
|
||||
@@ -86,6 +127,11 @@ def read_password(*, generate: bool) -> str:
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = build_parser().parse_args(argv)
|
||||
|
||||
if arguments.commande == "export-openapi":
|
||||
print(export_openapi(Path(arguments.output)))
|
||||
return 0
|
||||
|
||||
mot_de_passe = read_password(generate=arguments.generate)
|
||||
|
||||
succes, message = asyncio.run(
|
||||
|
||||
@@ -8,6 +8,7 @@ Environment = Literal["local", "dev", "staging", "prod"]
|
||||
SameSite = Literal["lax", "strict", "none"]
|
||||
|
||||
SECRET_KEY_MIN_LENGTH = 32
|
||||
REFRESH_COOKIE_DEFAUT = "ev_refresh"
|
||||
SENTINELLES_INTERDITES = frozenset(
|
||||
{"change_me", "changeme", "secret", "secret-de-test", "changez-moi", "todo"}
|
||||
)
|
||||
@@ -38,7 +39,7 @@ class Settings(BaseSettings):
|
||||
access_token_ttl_seconds: int = Field(default=900, ge=60, le=3600)
|
||||
refresh_token_ttl_seconds: int = Field(default=604800, ge=3600, le=2592000)
|
||||
|
||||
refresh_cookie_name: str = "ev_refresh"
|
||||
refresh_cookie_name: str = REFRESH_COOKIE_DEFAUT
|
||||
cookie_path: str = "/api/v1/auth"
|
||||
cookie_samesite: SameSite = "strict"
|
||||
cookie_secure: bool | None = None
|
||||
|
||||
@@ -7,6 +7,7 @@ from prometheus_fastapi_instrumentator import Instrumentator
|
||||
|
||||
from app.api.errors import register_error_handlers
|
||||
from app.api.middleware import SecurityHeadersMiddleware
|
||||
from app.api.openapi import DESCRIPTION, SUMMARY, TAGS
|
||||
from app.api.security import require_metrics_token
|
||||
from app.api.v1.router import api_router
|
||||
from app.core.config import Settings, get_settings
|
||||
@@ -37,6 +38,9 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
application = FastAPI(
|
||||
title=resolved.name,
|
||||
version=resolved.version,
|
||||
summary=SUMMARY,
|
||||
description=DESCRIPTION,
|
||||
openapi_tags=TAGS,
|
||||
debug=resolved.debug,
|
||||
lifespan=lifespan,
|
||||
docs_url="/docs" if documentee else None,
|
||||
|
||||
@@ -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,20 @@
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.energy import Site
|
||||
|
||||
|
||||
class SiteRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def list_all(self) -> Sequence[Site]:
|
||||
requete = select(Site).order_by(Site.site_id)
|
||||
return (await self._session.scalars(requete)).all()
|
||||
|
||||
async def get_by_id(self, site_id: str) -> Site | None:
|
||||
requete = select(Site).where(Site.site_id == site_id)
|
||||
site: Site | None = await self._session.scalar(requete)
|
||||
return site
|
||||
@@ -0,0 +1,23 @@
|
||||
# Piège : ces modèles ne décrivent rien, ils publient. Ce sont eux que Swagger montre, donc ils
|
||||
# doivent suivre `validation_error_handler()` et `unhandled_error_handler()` d'`app/api/errors.py`
|
||||
# à la lettre. Un champ renommé là-bas sans l'être ici rend la documentation fausse en silence.
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
detail: str
|
||||
|
||||
|
||||
class FieldError(BaseModel):
|
||||
champ: str
|
||||
type: str
|
||||
|
||||
|
||||
class ValidationErrorResponse(BaseModel):
|
||||
detail: list[FieldError]
|
||||
|
||||
|
||||
class InternalErrorResponse(BaseModel):
|
||||
detail: str
|
||||
correlation: str
|
||||
@@ -0,0 +1,12 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class SiteResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
site_id: str
|
||||
site_name: str
|
||||
site_type: str
|
||||
location: str | None
|
||||
capacity_kw: float | None
|
||||
status: str | None
|
||||
@@ -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]
|
||||
@@ -0,0 +1,26 @@
|
||||
from collections.abc import Sequence
|
||||
|
||||
from app.models.energy import Site
|
||||
from app.repositories.site import SiteRepository
|
||||
|
||||
|
||||
class SiteError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SiteNotFoundError(SiteError):
|
||||
pass
|
||||
|
||||
|
||||
class SiteService:
|
||||
def __init__(self, *, sites: SiteRepository) -> None:
|
||||
self._sites = sites
|
||||
|
||||
async def list_all(self) -> Sequence[Site]:
|
||||
return await self._sites.list_all()
|
||||
|
||||
async def get_by_id(self, site_id: str) -> Site:
|
||||
site = await self._sites.get_by_id(site_id)
|
||||
if site is None:
|
||||
raise SiteNotFoundError(site_id)
|
||||
return site
|
||||
@@ -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,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,120 @@
|
||||
# Pourquoi : `openapi.json` est versionné, donc une route qui change son contrat public le montre
|
||||
# dans la diff d'une pull request. `test_the_committed_contract_matches_the_generated_one` est ce
|
||||
# qui empêche le fichier de dériver du code sans que personne ne le voie.
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app import cli
|
||||
|
||||
METHODES = {"get", "post", "patch", "put", "delete"}
|
||||
|
||||
# `/auth/logout` lit le cookie mais ne le réclame pas : sans session elle répond 204, et un 401
|
||||
# documenté y serait faux.
|
||||
SANS_REFUS = {("POST", "/api/v1/auth/logout")}
|
||||
|
||||
ORIGINE_VERIFIEE = {
|
||||
("POST", "/api/v1/auth/refresh"),
|
||||
("POST", "/api/v1/auth/logout"),
|
||||
("POST", "/api/v1/auth/logout-all"),
|
||||
("POST", "/api/v1/auth/password"),
|
||||
}
|
||||
|
||||
# Toute route derrière `require_role` (LecteurDep, OperateurDep, AdminDep) peut rendre 403 pour
|
||||
# `password_change_required`, pas seulement les routes `admin`.
|
||||
ROUTES_A_ROLE = {
|
||||
("GET", "/api/v1/users"),
|
||||
("POST", "/api/v1/users"),
|
||||
("PATCH", "/api/v1/users/{id}"),
|
||||
("POST", "/api/v1/users/{id}/password-reset"),
|
||||
("GET", "/api/v1/sites"),
|
||||
("GET", "/api/v1/sites/{site_id}"),
|
||||
("GET", "/api/v1/stats/summary"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def schema() -> dict[str, Any]:
|
||||
return cli.schema_du_contrat()
|
||||
|
||||
|
||||
def operations(schema: dict[str, Any]) -> list[tuple[str, str, dict[str, Any]]]:
|
||||
return [
|
||||
(methode.upper(), chemin, operation)
|
||||
for chemin, operations_du_chemin in schema["paths"].items()
|
||||
for methode, operation in operations_du_chemin.items()
|
||||
if methode in METHODES
|
||||
]
|
||||
|
||||
|
||||
def test_the_committed_contract_matches_the_generated_one(schema: dict[str, Any]) -> None:
|
||||
publie = json.loads(cli.CHEMIN_CONTRAT.read_text(encoding="utf-8"))
|
||||
|
||||
assert publie == schema, "lancer `make openapi` et versionner le fichier obtenu"
|
||||
|
||||
|
||||
def test_every_route_demanding_an_identity_says_how_it_refuses(schema: dict[str, Any]) -> None:
|
||||
muettes = [
|
||||
(methode, chemin)
|
||||
for methode, chemin, operation in operations(schema)
|
||||
if operation.get("security")
|
||||
and (methode, chemin) not in SANS_REFUS
|
||||
and "401" not in operation["responses"]
|
||||
]
|
||||
|
||||
assert muettes == []
|
||||
|
||||
|
||||
def test_every_role_guarded_route_documents_the_role_refusal(schema: dict[str, Any]) -> None:
|
||||
sans_403 = [
|
||||
(methode, chemin)
|
||||
for methode, chemin, operation in operations(schema)
|
||||
if (methode, chemin) in ROUTES_A_ROLE and "403" not in operation["responses"]
|
||||
]
|
||||
|
||||
assert sans_403 == []
|
||||
|
||||
|
||||
def test_every_origin_checked_route_documents_the_csrf_refusal(schema: dict[str, Any]) -> None:
|
||||
sans_403 = [
|
||||
(methode, chemin)
|
||||
for methode, chemin, operation in operations(schema)
|
||||
if (methode, chemin) in ORIGINE_VERIFIEE and "403" not in operation["responses"]
|
||||
]
|
||||
|
||||
assert sans_403 == []
|
||||
|
||||
|
||||
def test_the_validation_model_matches_what_the_handler_returns(schema: dict[str, Any]) -> None:
|
||||
modeles = {
|
||||
operation["responses"]["422"]["content"]["application/json"]["schema"]["$ref"]
|
||||
for _, _, operation in operations(schema)
|
||||
if "422" in operation["responses"]
|
||||
}
|
||||
|
||||
assert modeles == {"#/components/schemas/ValidationErrorResponse"}
|
||||
assert "HTTPValidationError" not in schema["components"]["schemas"]
|
||||
|
||||
|
||||
def test_the_rate_limit_documents_the_delay_header(schema: dict[str, Any]) -> None:
|
||||
trop_de_tentatives = schema["paths"]["/api/v1/auth/login"]["post"]["responses"]["429"]
|
||||
|
||||
assert "Retry-After" in trop_de_tentatives["headers"]
|
||||
|
||||
|
||||
def test_the_refresh_cookie_appears_in_the_security_schemes(schema: dict[str, Any]) -> None:
|
||||
schemes = schema["components"]["securitySchemes"]
|
||||
|
||||
assert schemes["Cookie de rafraîchissement"]["in"] == "cookie"
|
||||
assert schemes["Cookie de rafraîchissement"]["name"] == "ev_refresh"
|
||||
|
||||
|
||||
def test_each_tag_used_by_a_route_is_described(schema: dict[str, Any]) -> None:
|
||||
decrits = {tag["name"] for tag in schema["tags"]}
|
||||
|
||||
for methode, chemin, operation in operations(schema):
|
||||
poses = operation.get("tags", [])
|
||||
assert len(poses) == len(set(poses)), f"tag en double sur {methode} {chemin}"
|
||||
assert set(poses) <= decrits, f"tag non décrit sur {methode} {chemin}"
|
||||
@@ -0,0 +1,141 @@
|
||||
from collections.abc import Callable, Iterator
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.api.deps import get_current_principal, get_site_service
|
||||
from app.core.principal import Principal
|
||||
from app.core.roles import AccountKind, Role
|
||||
from app.models.energy import Site
|
||||
from app.services.site import SiteNotFoundError
|
||||
|
||||
|
||||
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 site(site_id: str = "site-1") -> Site:
|
||||
return Site(
|
||||
site_id=site_id,
|
||||
site_name="Site de test",
|
||||
site_type="industriel",
|
||||
location="Toulouse",
|
||||
capacity_kw=42.0,
|
||||
status="actif",
|
||||
)
|
||||
|
||||
|
||||
class FauxService:
|
||||
def __init__(self, erreur: Exception | None = None) -> None:
|
||||
self._erreur = erreur
|
||||
self.site = site()
|
||||
|
||||
async def list_all(self) -> list[Site]:
|
||||
return [self.site]
|
||||
|
||||
async def get_by_id(self, site_id: str) -> Site:
|
||||
if self._erreur is not None:
|
||||
raise self._erreur
|
||||
return self.site
|
||||
|
||||
|
||||
@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_site_service] = lambda: service
|
||||
return service
|
||||
|
||||
yield installe
|
||||
app.dependency_overrides.pop(get_site_service, None)
|
||||
|
||||
|
||||
async def test_list_sites_returns_the_sites(
|
||||
servi: Callable[..., FauxService], client: AsyncClient
|
||||
) -> None:
|
||||
servi()
|
||||
|
||||
response = await client.get("/api/v1/sites")
|
||||
|
||||
assert response.status_code == 200
|
||||
corps = response.json()
|
||||
assert corps == [
|
||||
{
|
||||
"site_id": "site-1",
|
||||
"site_name": "Site de test",
|
||||
"site_type": "industriel",
|
||||
"location": "Toulouse",
|
||||
"capacity_kw": 42.0,
|
||||
"status": "actif",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
async def test_get_site_returns_the_matching_site(
|
||||
servi: Callable[..., FauxService], client: AsyncClient
|
||||
) -> None:
|
||||
servi()
|
||||
|
||||
response = await client.get("/api/v1/sites/site-1")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["site_id"] == "site-1"
|
||||
|
||||
|
||||
async def test_get_site_returns_404_for_an_unknown_site(
|
||||
servi: Callable[..., FauxService], client: AsyncClient
|
||||
) -> None:
|
||||
servi(SiteNotFoundError("site-inconnu"))
|
||||
|
||||
response = await client.get("/api/v1/sites/site-inconnu")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
async def test_list_sites_reaches_the_repository_through_the_session(
|
||||
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
|
||||
) -> None:
|
||||
fake_session(result=[site("a"), site("b")])
|
||||
|
||||
response = await client.get("/api/v1/sites")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert [s["site_id"] for s in response.json()] == ["a", "b"]
|
||||
|
||||
|
||||
async def test_get_site_reaches_the_repository_through_the_session(
|
||||
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
|
||||
) -> None:
|
||||
fake_session(result=site("a"))
|
||||
|
||||
response = await client.get("/api/v1/sites/a")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["site_id"] == "a"
|
||||
|
||||
|
||||
async def test_get_site_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/sites/inconnu")
|
||||
|
||||
assert response.status_code == 404
|
||||
@@ -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"
|
||||
@@ -1,3 +1,4 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import Settings
|
||||
@@ -12,6 +13,16 @@ SETTINGS_DE_TEST: dict[str, Any] = {
|
||||
}
|
||||
|
||||
|
||||
class FakeScalars:
|
||||
"""Resultat factice pour `.scalars()` : `.all()` renvoie les lignes fournies."""
|
||||
|
||||
def __init__(self, rows: Sequence[object]) -> None:
|
||||
self._rows = rows
|
||||
|
||||
def all(self) -> Sequence[object]:
|
||||
return self._rows
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""Session factice : renvoie `result`, ou leve `failure` si elle est fournie."""
|
||||
|
||||
@@ -25,6 +36,9 @@ class FakeSession:
|
||||
async def execute(self, *_: object, **__: object) -> object:
|
||||
return self._repondre()
|
||||
|
||||
async def scalars(self, *_: object, **__: object) -> FakeScalars:
|
||||
return FakeScalars(self._repondre() or [])
|
||||
|
||||
def _repondre(self) -> object:
|
||||
if self._failure is not None:
|
||||
raise self._failure
|
||||
|
||||
@@ -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,59 @@
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.energy import Site
|
||||
from app.repositories.site import SiteRepository
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def identifiant() -> str:
|
||||
return f"site-{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
async def creer(session: AsyncSession, **overrides: object) -> Site:
|
||||
site = Site(
|
||||
site_id=overrides.get("site_id", identifiant()),
|
||||
site_name=overrides.get("site_name", "Site de test"),
|
||||
site_type=overrides.get("site_type", "industriel"),
|
||||
location=overrides.get("location", "Toulouse"),
|
||||
capacity_kw=overrides.get("capacity_kw", 42.0),
|
||||
status=overrides.get("status", "actif"),
|
||||
)
|
||||
session.add(site)
|
||||
await session.flush()
|
||||
return site
|
||||
|
||||
|
||||
async def test_get_by_id_returns_the_matching_site(session: AsyncSession) -> None:
|
||||
depot = SiteRepository(session)
|
||||
cree = await creer(session)
|
||||
|
||||
trouve = await depot.get_by_id(cree.site_id)
|
||||
nom = trouve.site_name if trouve else None
|
||||
await session.rollback()
|
||||
|
||||
assert nom == "Site de test"
|
||||
|
||||
|
||||
async def test_get_by_id_returns_nothing_for_an_unknown_identifier(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
trouve = await SiteRepository(session).get_by_id(identifiant())
|
||||
|
||||
assert trouve is None
|
||||
|
||||
|
||||
async def test_list_all_returns_the_sites_sorted_by_identifier(session: AsyncSession) -> None:
|
||||
depot = SiteRepository(session)
|
||||
premier, second = sorted([f"zz-{identifiant()}", f"aa-{identifiant()}"])
|
||||
await creer(session, site_id=second)
|
||||
await creer(session, site_id=premier)
|
||||
|
||||
sites = await depot.list_all()
|
||||
identifiants = [site.site_id for site in sites if site.site_id in (premier, second)]
|
||||
await session.rollback()
|
||||
|
||||
assert identifiants == [premier, second]
|
||||
@@ -0,0 +1,49 @@
|
||||
import pytest
|
||||
|
||||
from app.models.energy import Site
|
||||
from app.services.site import SiteNotFoundError, SiteService
|
||||
|
||||
|
||||
def site(site_id: str = "site-1") -> Site:
|
||||
return Site(
|
||||
site_id=site_id,
|
||||
site_name="Site de test",
|
||||
site_type="industriel",
|
||||
location="Toulouse",
|
||||
capacity_kw=42.0,
|
||||
status="actif",
|
||||
)
|
||||
|
||||
|
||||
class FakeRepository:
|
||||
def __init__(self, sites: list[Site]) -> None:
|
||||
self._sites = sites
|
||||
|
||||
async def list_all(self) -> list[Site]:
|
||||
return self._sites
|
||||
|
||||
async def get_by_id(self, site_id: str) -> Site | None:
|
||||
return next((s for s in self._sites if s.site_id == site_id), None)
|
||||
|
||||
|
||||
async def test_list_all_returns_the_repository_sites() -> None:
|
||||
service = SiteService(sites=FakeRepository([site("a"), site("b")]))
|
||||
|
||||
sites = await service.list_all()
|
||||
|
||||
assert [s.site_id for s in sites] == ["a", "b"]
|
||||
|
||||
|
||||
async def test_get_by_id_returns_the_matching_site() -> None:
|
||||
service = SiteService(sites=FakeRepository([site("a")]))
|
||||
|
||||
trouve = await service.get_by_id("a")
|
||||
|
||||
assert trouve.site_id == "a"
|
||||
|
||||
|
||||
async def test_get_by_id_raises_when_the_site_is_unknown() -> None:
|
||||
service = SiteService(sites=FakeRepository([]))
|
||||
|
||||
with pytest.raises(SiteNotFoundError):
|
||||
await service.get_by_id("inconnu")
|
||||
@@ -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
|
||||
@@ -1,3 +1,6 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import cli
|
||||
@@ -55,3 +58,52 @@ def test_read_password_refuses_two_different_entries(monkeypatch: pytest.MonkeyP
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
cli.read_password(generate=False)
|
||||
|
||||
|
||||
def test_build_parser_reads_the_export_openapi_arguments() -> None:
|
||||
arguments = cli.build_parser().parse_args(
|
||||
["export-openapi", "--output", "ailleurs/contrat.json"]
|
||||
)
|
||||
|
||||
assert arguments.commande == "export-openapi"
|
||||
assert arguments.output == "ailleurs/contrat.json"
|
||||
|
||||
|
||||
def test_build_parser_defaults_the_export_to_the_versioned_contract() -> None:
|
||||
arguments = cli.build_parser().parse_args(["export-openapi"])
|
||||
|
||||
assert arguments.output == str(cli.CHEMIN_CONTRAT)
|
||||
|
||||
|
||||
def test_settings_of_the_contract_ignore_the_local_environment(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("APP_API_PREFIX", "/api/v9")
|
||||
monkeypatch.setenv("APP_NAME", "API du poste de Johan")
|
||||
|
||||
settings = cli.settings_du_contrat()
|
||||
|
||||
assert settings.api_prefix == "/api/v1"
|
||||
assert settings.name == "EnerVision API"
|
||||
|
||||
|
||||
def test_export_openapi_writes_a_readable_schema_where_asked(tmp_path: Path) -> None:
|
||||
destination = tmp_path / "contrat.json"
|
||||
|
||||
cli.export_openapi(destination)
|
||||
|
||||
assert json.loads(destination.read_text(encoding="utf-8"))["openapi"].startswith("3.")
|
||||
|
||||
|
||||
# Piège : `main()` réclamait un mot de passe avant de lire la commande. Sans le branchement,
|
||||
# l'export resterait bloqué sur `getpass` et aucune CI ne pourrait le rejouer.
|
||||
def test_main_exports_the_contract_without_asking_for_a_password(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
destination = tmp_path / "contrat.json"
|
||||
|
||||
code = cli.main(["export-openapi", "--output", str(destination)])
|
||||
|
||||
assert code == 0
|
||||
assert destination.exists()
|
||||
assert str(destination) in capsys.readouterr().out
|
||||
|
||||
@@ -34,6 +34,7 @@ yarn-error.log
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/test-results
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Generated
+19
@@ -14,6 +14,7 @@
|
||||
"@angular/forms": "^22.1.0",
|
||||
"@angular/platform-browser": "^22.1.0",
|
||||
"@angular/router": "^22.1.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -2038,6 +2039,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@listr2/prompt-adapter-inquirer": {
|
||||
"version": "4.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-4.2.5.tgz",
|
||||
@@ -4220,6 +4227,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"@angular/forms": "^22.1.0",
|
||||
"@angular/platform-browser": "^22.1.0",
|
||||
"@angular/router": "^22.1.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { ApplicationConfig, 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';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)],
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(withInterceptors([mockApiInterceptor])),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,353 +1 @@
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * Delete the template below * * * * * * * * * -->
|
||||
<!-- * * * * * * * to get started with your project! * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
|
||||
<style>
|
||||
:host {
|
||||
--bright-blue: oklch(51.01% 0.274 263.83);
|
||||
--electric-violet: oklch(53.18% 0.28 296.97);
|
||||
--french-violet: oklch(47.66% 0.246 305.88);
|
||||
--vivid-pink: oklch(69.02% 0.277 332.77);
|
||||
--hot-red: oklch(61.42% 0.238 15.34);
|
||||
--orange-red: oklch(63.32% 0.24 31.68);
|
||||
|
||||
--gray-900: oklch(19.37% 0.006 300.98);
|
||||
--gray-700: oklch(36.98% 0.014 302.71);
|
||||
--gray-400: oklch(70.9% 0.015 304.04);
|
||||
|
||||
--red-to-pink-to-purple-vertical-gradient: linear-gradient(
|
||||
180deg,
|
||||
var(--orange-red) 0%,
|
||||
var(--vivid-pink) 50%,
|
||||
var(--electric-violet) 100%
|
||||
);
|
||||
|
||||
--red-to-pink-to-purple-horizontal-gradient: linear-gradient(
|
||||
90deg,
|
||||
var(--orange-red) 0%,
|
||||
var(--vivid-pink) 50%,
|
||||
var(--electric-violet) 100%
|
||||
);
|
||||
|
||||
--pill-accent: var(--bright-blue);
|
||||
|
||||
font-family:
|
||||
'Inter',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Helvetica,
|
||||
Arial,
|
||||
sans-serif,
|
||||
'Apple Color Emoji',
|
||||
'Segoe UI Emoji',
|
||||
'Segoe UI Symbol';
|
||||
box-sizing: border-box;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
display: block;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.125rem;
|
||||
color: var(--gray-900);
|
||||
font-weight: 500;
|
||||
line-height: 100%;
|
||||
letter-spacing: -0.125rem;
|
||||
margin: 0;
|
||||
font-family:
|
||||
'Inter Tight',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Helvetica,
|
||||
Arial,
|
||||
sans-serif,
|
||||
'Apple Color Emoji',
|
||||
'Segoe UI Emoji',
|
||||
'Segoe UI Symbol';
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
main {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
box-sizing: inherit;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.angular-logo {
|
||||
max-width: 9.2rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
.content p {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
background: var(--red-to-pink-to-purple-vertical-gradient);
|
||||
margin-inline: 0.5rem;
|
||||
}
|
||||
|
||||
.pill-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
--pill-accent: var(--bright-blue);
|
||||
background: color-mix(in srgb, var(--pill-accent) 5%, transparent);
|
||||
color: var(--pill-accent);
|
||||
padding-inline: 0.75rem;
|
||||
padding-block: 0.375rem;
|
||||
border-radius: 2.75rem;
|
||||
border: 0;
|
||||
transition: background 0.3s ease;
|
||||
font-family: var(--inter-font);
|
||||
font-size: 0.875rem;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 1.4rem;
|
||||
letter-spacing: -0.00875rem;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill:hover {
|
||||
background: color-mix(in srgb, var(--pill-accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.pill-group .pill:nth-child(6n + 1) {
|
||||
--pill-accent: var(--bright-blue);
|
||||
}
|
||||
.pill-group .pill:nth-child(6n + 2) {
|
||||
--pill-accent: var(--electric-violet);
|
||||
}
|
||||
.pill-group .pill:nth-child(6n + 3) {
|
||||
--pill-accent: var(--french-violet);
|
||||
}
|
||||
|
||||
.pill-group .pill:nth-child(6n + 4),
|
||||
.pill-group .pill:nth-child(6n + 5),
|
||||
.pill-group .pill:nth-child(6n + 6) {
|
||||
--pill-accent: var(--hot-red);
|
||||
}
|
||||
|
||||
.pill-group svg {
|
||||
margin-inline-start: 0.25rem;
|
||||
}
|
||||
|
||||
.social-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.73rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.social-links path {
|
||||
transition: fill 0.3s ease;
|
||||
fill: var(--gray-400);
|
||||
}
|
||||
|
||||
.social-links a:hover svg path {
|
||||
fill: var(--gray-900);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 650px) {
|
||||
.content {
|
||||
flex-direction: column;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background: var(--red-to-pink-to-purple-horizontal-gradient);
|
||||
margin-block: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<main class="main">
|
||||
<div class="content">
|
||||
<div class="left-side">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 982 239"
|
||||
fill="none"
|
||||
class="angular-logo"
|
||||
>
|
||||
<g clip-path="url(#a)">
|
||||
<path
|
||||
fill="url(#b)"
|
||||
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
||||
/>
|
||||
<path
|
||||
fill="url(#c)"
|
||||
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<radialGradient
|
||||
id="c"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#FF41F8" />
|
||||
<stop offset=".707" stop-color="#FF41F8" stop-opacity=".5" />
|
||||
<stop offset="1" stop-color="#FF41F8" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
<linearGradient id="b" x1="0" x2="982" y1="192" y2="192" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#F0060B" />
|
||||
<stop offset="0" stop-color="#F0070C" />
|
||||
<stop offset=".526" stop-color="#CC26D5" />
|
||||
<stop offset="1" stop-color="#7702FF" />
|
||||
</linearGradient>
|
||||
<clipPath id="a"><path fill="#fff" d="M0 0h982v239H0z" /></clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<h1>Hello, {{ title() }}</h1>
|
||||
<p>Congratulations! Your app is running. 🎉</p>
|
||||
</div>
|
||||
<div class="divider" role="separator" aria-label="Divider"></div>
|
||||
<div class="right-side">
|
||||
<div class="pill-group">
|
||||
@for (
|
||||
item of [
|
||||
{ title: 'Explore the Docs', link: 'https://angular.dev' },
|
||||
{ title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' },
|
||||
{
|
||||
title: 'Prompt and best practices for AI',
|
||||
link: 'https://angular.dev/ai/develop-with-ai',
|
||||
},
|
||||
{ title: 'CLI Docs', link: 'https://angular.dev/tools/cli' },
|
||||
{
|
||||
title: 'Angular Language Service',
|
||||
link: 'https://angular.dev/tools/language-service',
|
||||
},
|
||||
{ title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' },
|
||||
];
|
||||
track item.title
|
||||
) {
|
||||
<a class="pill" [href]="item.link" target="_blank" rel="noopener">
|
||||
<span>{{ item.title }}</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="14"
|
||||
viewBox="0 -960 960 960"
|
||||
width="14"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<div class="social-links">
|
||||
<a
|
||||
href="https://github.com/angular/angular"
|
||||
aria-label="Github"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<svg
|
||||
width="25"
|
||||
height="24"
|
||||
viewBox="0 0 25 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
alt="Github"
|
||||
>
|
||||
<path
|
||||
d="M12.3047 0C5.50634 0 0 5.50942 0 12.3047C0 17.7423 3.52529 22.3535 8.41332 23.9787C9.02856 24.0946 9.25414 23.7142 9.25414 23.3871C9.25414 23.0949 9.24389 22.3207 9.23876 21.2953C5.81601 22.0377 5.09414 19.6444 5.09414 19.6444C4.53427 18.2243 3.72524 17.8449 3.72524 17.8449C2.61064 17.082 3.81137 17.0973 3.81137 17.0973C5.04697 17.1835 5.69604 18.3647 5.69604 18.3647C6.79321 20.2463 8.57636 19.7029 9.27978 19.3881C9.39052 18.5924 9.70736 18.0499 10.0591 17.7423C7.32641 17.4347 4.45429 16.3765 4.45429 11.6618C4.45429 10.3185 4.9311 9.22133 5.72065 8.36C5.58222 8.04931 5.16694 6.79833 5.82831 5.10337C5.82831 5.10337 6.85883 4.77319 9.2121 6.36459C10.1965 6.09082 11.2424 5.95546 12.2883 5.94931C13.3342 5.95546 14.3801 6.09082 15.3644 6.36459C17.7023 4.77319 18.7328 5.10337 18.7328 5.10337C19.3942 6.79833 18.9789 8.04931 18.8559 8.36C19.6403 9.22133 20.1171 10.3185 20.1171 11.6618C20.1171 16.3888 17.2409 17.4296 14.5031 17.7321C14.9338 18.1012 15.3337 18.8559 15.3337 20.0084C15.3337 21.6552 15.3183 22.978 15.3183 23.3779C15.3183 23.7009 15.5336 24.0854 16.1642 23.9623C21.0871 22.3484 24.6094 17.7341 24.6094 12.3047C24.6094 5.50942 19.0999 0 12.3047 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="https://x.com/angular" aria-label="X" target="_blank" rel="noopener">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
alt="X"
|
||||
>
|
||||
<path
|
||||
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw"
|
||||
aria-label="Youtube"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<svg
|
||||
width="29"
|
||||
height="20"
|
||||
viewBox="0 0 29 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
alt="Youtube"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M27.4896 1.52422C27.9301 1.96749 28.2463 2.51866 28.4068 3.12258C29.0004 5.35161 29.0004 10 29.0004 10C29.0004 10 29.0004 14.6484 28.4068 16.8774C28.2463 17.4813 27.9301 18.0325 27.4896 18.4758C27.0492 18.9191 26.5 19.2389 25.8972 19.4032C23.6778 20 14.8068 20 14.8068 20C14.8068 20 5.93586 20 3.71651 19.4032C3.11363 19.2389 2.56449 18.9191 2.12405 18.4758C1.68361 18.0325 1.36732 17.4813 1.20683 16.8774C0.613281 14.6484 0.613281 10 0.613281 10C0.613281 10 0.613281 5.35161 1.20683 3.12258C1.36732 2.51866 1.68361 1.96749 2.12405 1.52422C2.56449 1.08095 3.11363 0.76113 3.71651 0.596774C5.93586 0 14.8068 0 14.8068 0C14.8068 0 23.6778 0 25.8972 0.596774C26.5 0.76113 27.0492 1.08095 27.4896 1.52422ZM19.3229 10L11.9036 5.77905V14.221L19.3229 10Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
|
||||
<router-outlet />
|
||||
<router-outlet></router-outlet>
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const routes: Routes = [];
|
||||
export const routes: Routes = [
|
||||
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||
{
|
||||
path: 'dashboard',
|
||||
loadComponent: () => import('./features/dashboard/dashboard').then((m) => m.Dashboard),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -13,11 +13,4 @@ describe('App', () => {
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render title', async () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
await fixture.whenStable();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { mockApiInterceptor } from './mock-api-interceptor';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { STATS_SUMMARY_FIXTURE } from '../mocks/stats-summary.fixture';
|
||||
|
||||
describe('mockApiInterceptor', () => {
|
||||
let http: HttpClient;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(withInterceptors([mockApiInterceptor])),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
});
|
||||
http = TestBed.inject(HttpClient);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
environment.useMockFixtures = true;
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('renvoie la fixture sans appel réseau quand useMockFixtures est activé', () => {
|
||||
environment.useMockFixtures = true;
|
||||
let result: unknown;
|
||||
|
||||
http.get(`${environment.apiUrl}/stats/summary`).subscribe((r) => (result = r));
|
||||
|
||||
httpMock.expectNone(`${environment.apiUrl}/stats/summary`);
|
||||
expect((result as typeof STATS_SUMMARY_FIXTURE).total_sites).toBe(
|
||||
STATS_SUMMARY_FIXTURE.total_sites,
|
||||
);
|
||||
});
|
||||
|
||||
it('laisse passer la vraie requête quand useMockFixtures est désactivé', () => {
|
||||
environment.useMockFixtures = false;
|
||||
|
||||
http.get(`${environment.apiUrl}/stats/summary`).subscribe();
|
||||
|
||||
const req = httpMock.expectOne(`${environment.apiUrl}/stats/summary`);
|
||||
req.flush({});
|
||||
});
|
||||
|
||||
it("laisse passer une requête qui ne correspond à aucune route connue de l'interceptor", () => {
|
||||
environment.useMockFixtures = true;
|
||||
|
||||
http.get('/api/v1/autre-chose').subscribe();
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/autre-chose');
|
||||
req.flush({});
|
||||
});
|
||||
|
||||
it('renvoie la fixture des alertes sans appel réseau quand useMockFixtures est activé', () => {
|
||||
environment.useMockFixtures = true;
|
||||
let result: unknown;
|
||||
|
||||
http.get(`${environment.apiUrl}/alerts`).subscribe((r) => (result = r));
|
||||
|
||||
httpMock.expectNone(`${environment.apiUrl}/alerts`);
|
||||
expect((result as unknown[]).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { HttpInterceptorFn, HttpResponse } from '@angular/common/http';
|
||||
import { of } from 'rxjs';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { STATS_SUMMARY_FIXTURE } from '../mocks/stats-summary.fixture';
|
||||
import { ALERTS_FIXTURE } from '../mocks/alerts.fixture';
|
||||
|
||||
function withJitter(base: typeof STATS_SUMMARY_FIXTURE) {
|
||||
const jitter = () => (Math.random() - 0.5) * 40;
|
||||
const totalConsumption = Math.max(0, base.total_consumption_kw + jitter());
|
||||
|
||||
return {
|
||||
...base,
|
||||
timestamp: new Date().toISOString(),
|
||||
total_consumption_kw: Math.round(totalConsumption * 100) / 100,
|
||||
average_load_percent: Math.round((totalConsumption / base.total_capacity_kw) * 1000) / 10,
|
||||
};
|
||||
}
|
||||
|
||||
export const mockApiInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
if (!environment.useMockFixtures) {
|
||||
return next(req);
|
||||
}
|
||||
if (req.url.endsWith(`${environment.apiUrl}/stats/summary`)) {
|
||||
return of(new HttpResponse({ status: 200, body: withJitter(STATS_SUMMARY_FIXTURE) }));
|
||||
}
|
||||
if (req.url.endsWith(`${environment.apiUrl}/alerts`)) {
|
||||
return of(new HttpResponse({ status: 200, body: ALERTS_FIXTURE }));
|
||||
}
|
||||
return next(req);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Alert } from '../../shared/models/alert.model';
|
||||
|
||||
export const ALERTS_FIXTURE: Alert[] = [
|
||||
{
|
||||
alert_id: 'ALR-SITE002-1718458320',
|
||||
timestamp: '2026-09-15T11:12:00',
|
||||
site_id: 'SITE002',
|
||||
severity: 'critical',
|
||||
type: 'outage',
|
||||
message: 'Risque de surcharge sur Usine Lyon Vénissieux',
|
||||
value: 812.5,
|
||||
threshold: 720.0,
|
||||
},
|
||||
{
|
||||
alert_id: 'ALR-SITE003-1718458321',
|
||||
timestamp: '2026-09-15T11:05:00',
|
||||
site_id: 'SITE003',
|
||||
severity: 'critical',
|
||||
type: 'sensor',
|
||||
message: 'Perte réseau totale sur Data Center Marseille',
|
||||
value: 0,
|
||||
threshold: 0,
|
||||
},
|
||||
{
|
||||
alert_id: 'ALR-SITE005-1718458322',
|
||||
timestamp: '2026-09-15T10:47:00',
|
||||
site_id: 'SITE005',
|
||||
severity: 'high',
|
||||
type: 'threshold',
|
||||
message: 'Usine Toulouse approche de son seuil de capacité',
|
||||
value: 410.0,
|
||||
threshold: 480.0,
|
||||
},
|
||||
{
|
||||
alert_id: 'ALR-SITE006-1718458323',
|
||||
timestamp: '2026-09-15T10:30:00',
|
||||
site_id: 'SITE006',
|
||||
severity: 'medium',
|
||||
type: 'sensor',
|
||||
message: 'Capteur de température défaillant sur Bureau Lille',
|
||||
value: 0,
|
||||
threshold: 0,
|
||||
},
|
||||
{
|
||||
alert_id: 'ALR-SITE004-1718458324',
|
||||
timestamp: '2026-09-15T09:58:00',
|
||||
site_id: 'SITE004',
|
||||
severity: 'low',
|
||||
type: 'anomaly',
|
||||
message: 'Comportement de consommation inhabituel sur Bureau Bordeaux',
|
||||
value: 62.0,
|
||||
threshold: 55.0,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,67 @@
|
||||
import { StatsSummary } from '../../shared/models/stats.model';
|
||||
|
||||
export const STATS_SUMMARY_FIXTURE: StatsSummary = {
|
||||
timestamp: '2026-09-15T11:32:00',
|
||||
total_sites: 7,
|
||||
total_consumption_kw: 1826.44,
|
||||
total_capacity_kw: 3830,
|
||||
average_load_percent: 55.1,
|
||||
sites: [
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
site_id: 'SITE002',
|
||||
site_name: 'Usine Lyon Vénissieux',
|
||||
current_consumption_kw: 542.1,
|
||||
capacity_kw: 1000,
|
||||
load_percent: 54.2,
|
||||
data_quality: 'good',
|
||||
},
|
||||
{
|
||||
site_id: 'SITE003',
|
||||
site_name: 'Data Center Marseille',
|
||||
current_consumption_kw: null,
|
||||
capacity_kw: 800,
|
||||
load_percent: null,
|
||||
data_quality: 'critical',
|
||||
},
|
||||
{
|
||||
site_id: 'SITE004',
|
||||
site_name: 'Bureau Bordeaux',
|
||||
current_consumption_kw: 62.0,
|
||||
capacity_kw: 150,
|
||||
load_percent: 41.3,
|
||||
data_quality: 'partial',
|
||||
},
|
||||
{
|
||||
site_id: 'SITE005',
|
||||
site_name: 'Usine Toulouse',
|
||||
current_consumption_kw: 410.0,
|
||||
capacity_kw: 600,
|
||||
load_percent: 68.3,
|
||||
data_quality: 'good',
|
||||
},
|
||||
{
|
||||
site_id: 'SITE006',
|
||||
site_name: 'Bureau Lille',
|
||||
current_consumption_kw: 95.0,
|
||||
capacity_kw: 180,
|
||||
load_percent: 52.8,
|
||||
data_quality: 'degraded',
|
||||
},
|
||||
{
|
||||
site_id: 'SITE007',
|
||||
site_name: 'Data Center Nantes',
|
||||
current_consumption_kw: 630.0,
|
||||
capacity_kw: 900,
|
||||
load_percent: 70.0,
|
||||
data_quality: 'good',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { AlertsService } from './alerts.service';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
describe('AlertsService', () => {
|
||||
let service: AlertsService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||
});
|
||||
service = TestBed.inject(AlertsService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it("appelle le bon endpoint et retourne un tableau d'alertes", () => {
|
||||
let result: unknown;
|
||||
service.getAlerts().subscribe((r) => (result = r));
|
||||
|
||||
const req = httpMock.expectOne(`${environment.apiUrl}/alerts`);
|
||||
expect(req.request.method).toBe('GET');
|
||||
|
||||
req.flush([
|
||||
{
|
||||
alert_id: 'ALR-TEST-1',
|
||||
timestamp: '2026-09-15T12:00:00',
|
||||
site_id: 'SITE001',
|
||||
severity: 'high',
|
||||
type: 'threshold',
|
||||
message: 'Test',
|
||||
value: 100,
|
||||
threshold: 90,
|
||||
},
|
||||
]);
|
||||
|
||||
expect((result as unknown[]).length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Service, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { Alert } from '../../shared/models/alert.model';
|
||||
|
||||
@Service()
|
||||
export class AlertsService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
getAlerts() {
|
||||
return this.http.get<Alert[]>(`${environment.apiUrl}/alerts`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { StatsService } from './stats.service';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
describe('StatsService', () => {
|
||||
let service: StatsService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||
});
|
||||
service = TestBed.inject(StatsService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('appelle le bon endpoint et retourne le résumé', () => {
|
||||
let result: unknown;
|
||||
service.getSummary().subscribe((r) => (result = r));
|
||||
|
||||
const req = httpMock.expectOne(`${environment.apiUrl}/stats/summary`);
|
||||
expect(req.request.method).toBe('GET');
|
||||
|
||||
req.flush({
|
||||
timestamp: '2026-09-15T12:00:00',
|
||||
total_sites: 7,
|
||||
total_consumption_kw: 1800,
|
||||
total_capacity_kw: 3800,
|
||||
average_load_percent: 47.4,
|
||||
sites: [],
|
||||
});
|
||||
|
||||
expect((result as { total_sites: number }).total_sites).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Service, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { StatsSummary } from '../../shared/models/stats.model';
|
||||
|
||||
@Service()
|
||||
export class StatsService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
getSummary() {
|
||||
return this.http.get<StatsSummary>(`${environment.apiUrl}/stats/summary`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<div class="dashboard">
|
||||
<header class="dashboard__header">
|
||||
<h1>Vue d'ensemble</h1>
|
||||
<p class="dashboard__subtitle">Consommation instantanée du parc</p>
|
||||
</header>
|
||||
|
||||
@if (error(); as message) {
|
||||
<p class="banner-error" role="alert">{{ message }}</p>
|
||||
}
|
||||
|
||||
@if (stats(); as s) {
|
||||
<section class="overview">
|
||||
<div class="card card--gauge">
|
||||
<span class="card__label">Consommation vs capacité</span>
|
||||
<app-consumption-gauge
|
||||
[consumption]="s.total_consumption_kw"
|
||||
[capacity]="s.total_capacity_kw"
|
||||
/>
|
||||
<span class="card__value"
|
||||
>{{ s.total_consumption_kw | number: '1.0-1' }} /
|
||||
{{ s.total_capacity_kw | number }} kW</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="card__label">Charge moyenne du parc</span>
|
||||
<span class="card__value">{{ s.average_load_percent }} %</span>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar__fill" [style.width.%]="s.average_load_percent"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="card__label">Sites suivis</span>
|
||||
<span class="card__value">{{ s.total_sites }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="chart-section">
|
||||
<h2>Charge et alerte visuelle par site</h2>
|
||||
<app-site-load-chart [sites]="s.sites" />
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (alerts().length > 0) {
|
||||
<section class="alerts-section">
|
||||
<h2>Alertes actives</h2>
|
||||
<ul class="alerts-list">
|
||||
@for (alert of alerts(); track alert.alert_id) {
|
||||
<li class="alert-item" [class]="'alert-item--' + alert.severity">
|
||||
<span class="alert-item__badge">{{ alert.severity }}</span>
|
||||
<span class="alert-item__message">{{ alert.message }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,146 @@
|
||||
:host {
|
||||
--color-good: #2e7d32;
|
||||
--color-partial: #f9a825;
|
||||
--color-degraded: #ef6c00;
|
||||
--color-critical: #c62828;
|
||||
--color-bg-card: #ffffff;
|
||||
--color-border: #e5e7eb;
|
||||
--color-text-muted: #6b7280;
|
||||
--radius: 10px;
|
||||
|
||||
display: block;
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
color: #1f2937;
|
||||
padding: 2rem;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.dashboard__header {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard__subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.banner-error {
|
||||
margin: 0 0 1.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--color-critical);
|
||||
border-left-width: 4px;
|
||||
border-radius: var(--radius);
|
||||
background: #fdecea;
|
||||
color: var(--color-critical);
|
||||
}
|
||||
|
||||
.overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.card--gauge {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card__label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.card__value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.progress-bar__fill {
|
||||
height: 100%;
|
||||
background: #3b82f6;
|
||||
border-radius: 999px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.alerts-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.alert-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.7rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.alert-item__badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
background: var(--color-critical);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.alert-item--high .alert-item__badge {
|
||||
background: var(--color-degraded);
|
||||
}
|
||||
.alert-item--medium .alert-item__badge {
|
||||
background: var(--color-partial);
|
||||
}
|
||||
.alert-item--low .alert-item__badge {
|
||||
background: var(--color-good);
|
||||
}
|
||||
|
||||
.alert-item__message {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { vi } from 'vitest';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { Dashboard } from './dashboard';
|
||||
import { StatsService } from '../../core/services/stats.service';
|
||||
import { AlertsService } from '../../core/services/alerts.service';
|
||||
|
||||
vi.mock('chart.js', () => {
|
||||
class ChartMock {
|
||||
update = vi.fn();
|
||||
destroy = vi.fn();
|
||||
data = { datasets: [{}] };
|
||||
static register = vi.fn();
|
||||
}
|
||||
return { Chart: ChartMock, registerables: [] };
|
||||
});
|
||||
|
||||
describe('Dashboard', () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('charge les stats et les alertes au démarrage', async () => {
|
||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([{ alert_id: 'A1' }])) };
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [Dashboard],
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(Dashboard);
|
||||
fixture.detectChanges();
|
||||
|
||||
// laisse le timer(0, ...) se déclencher avant de vérifier
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(statsMock.getSummary).toHaveBeenCalled();
|
||||
expect(alertsMock.getAlerts).toHaveBeenCalled();
|
||||
expect(fixture.componentInstance.alerts().length).toBe(1);
|
||||
expect(fixture.componentInstance.error()).toBeNull();
|
||||
});
|
||||
|
||||
it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => {
|
||||
vi.useFakeTimers();
|
||||
const statsMock = {
|
||||
getSummary: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(throwError(() => new Error('API injoignable')))
|
||||
.mockReturnValue(of({ total_sites: 7, sites: [] })),
|
||||
};
|
||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [Dashboard],
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(Dashboard);
|
||||
fixture.detectChanges();
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(statsMock.getSummary).toHaveBeenCalledTimes(1);
|
||||
expect(fixture.componentInstance.error()).not.toBeNull();
|
||||
expect(fixture.componentInstance.stats()).toBeNull();
|
||||
|
||||
vi.advanceTimersByTime(10000);
|
||||
expect(statsMock.getSummary).toHaveBeenCalledTimes(2);
|
||||
expect(fixture.componentInstance.stats()).not.toBeNull();
|
||||
expect(fixture.componentInstance.error()).toBeNull();
|
||||
});
|
||||
|
||||
it("n'interrompt pas la page quand le chargement des alertes échoue", () => {
|
||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) };
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [Dashboard],
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(Dashboard);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.alerts().length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
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 { 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 { StatsSummary } from '../../shared/models/stats.model';
|
||||
import { Alert } from '../../shared/models/alert.model';
|
||||
|
||||
const REFRESH_INTERVAL_MS = 10000;
|
||||
const UNAVAILABLE_MESSAGE =
|
||||
'Données indisponibles, les valeurs affichées datent du dernier relevé.';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
standalone: true,
|
||||
imports: [DecimalPipe, ConsumptionGauge, SiteLoadChart],
|
||||
templateUrl: './dashboard.html',
|
||||
styleUrl: './dashboard.scss',
|
||||
})
|
||||
export class Dashboard implements OnInit {
|
||||
private statsService = inject(StatsService);
|
||||
private alertsService = inject(AlertsService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
stats = signal<StatsSummary | null>(null);
|
||||
alerts = signal<Alert[]>([]);
|
||||
error = signal<string | null>(null);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.alertsService
|
||||
.getAlerts()
|
||||
.pipe(catchError(() => this.reportUnavailable()))
|
||||
.subscribe((alerts) => this.alerts.set(alerts));
|
||||
|
||||
// Piège : le catchError porte sur l'observable interne. Sur le flux externe il
|
||||
// terminerait le timer, et le rafraîchissement ne repartirait jamais.
|
||||
timer(0, REFRESH_INTERVAL_MS)
|
||||
.pipe(
|
||||
switchMap(() =>
|
||||
this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable())),
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe((stats) => {
|
||||
this.error.set(null);
|
||||
this.stats.set(stats);
|
||||
});
|
||||
}
|
||||
|
||||
private reportUnavailable(): Observable<never> {
|
||||
this.error.set(UNAVAILABLE_MESSAGE);
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<canvas #canvas></canvas>
|
||||
@@ -0,0 +1,6 @@
|
||||
:host {
|
||||
display: block;
|
||||
height: 200px;
|
||||
width: 200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { vi } from 'vitest';
|
||||
import { Chart } from 'chart.js';
|
||||
import { ConsumptionGauge } from './consumption-gauge';
|
||||
|
||||
vi.mock('chart.js', () => {
|
||||
class ChartMock {
|
||||
static instances: ChartMock[] = [];
|
||||
static register = vi.fn();
|
||||
update = vi.fn();
|
||||
destroy = vi.fn();
|
||||
data = { datasets: [{}] };
|
||||
constructor() {
|
||||
ChartMock.instances.push(this);
|
||||
}
|
||||
}
|
||||
return { Chart: ChartMock, registerables: [] };
|
||||
});
|
||||
|
||||
type ChartDouble = { destroy: ReturnType<typeof vi.fn> };
|
||||
|
||||
function lastChart(): ChartDouble | undefined {
|
||||
return (Chart as unknown as { instances: ChartDouble[] }).instances.at(-1);
|
||||
}
|
||||
|
||||
describe('ConsumptionGauge', () => {
|
||||
it('se crée sans erreur avec des entrées valides', () => {
|
||||
TestBed.configureTestingModule({ imports: [ConsumptionGauge] });
|
||||
const fixture = TestBed.createComponent(ConsumptionGauge);
|
||||
fixture.componentRef.setInput('consumption', 300);
|
||||
fixture.componentRef.setInput('capacity', 1000);
|
||||
expect(() => fixture.detectChanges()).not.toThrow();
|
||||
});
|
||||
it('met à jour le graphique quand les valeurs changent après initialisation', () => {
|
||||
TestBed.configureTestingModule({ imports: [ConsumptionGauge] });
|
||||
const fixture = TestBed.createComponent(ConsumptionGauge);
|
||||
fixture.componentRef.setInput('consumption', 300);
|
||||
fixture.componentRef.setInput('capacity', 1000);
|
||||
fixture.detectChanges(); // déclenche ngAfterViewInit, this.chart existe désormais
|
||||
|
||||
fixture.componentRef.setInput('consumption', 500);
|
||||
fixture.detectChanges(); // ré-exécute l'effect, cette fois avec this.chart défini
|
||||
|
||||
expect(() => fixture.detectChanges()).not.toThrow();
|
||||
});
|
||||
|
||||
it('détruit le graphique quand le composant est détruit', () => {
|
||||
TestBed.configureTestingModule({ imports: [ConsumptionGauge] });
|
||||
const fixture = TestBed.createComponent(ConsumptionGauge);
|
||||
fixture.componentRef.setInput('consumption', 300);
|
||||
fixture.componentRef.setInput('capacity', 1000);
|
||||
fixture.detectChanges();
|
||||
|
||||
const chart = lastChart();
|
||||
fixture.destroy();
|
||||
|
||||
expect(chart?.destroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
ViewChild,
|
||||
input,
|
||||
effect,
|
||||
AfterViewInit,
|
||||
OnDestroy,
|
||||
} from '@angular/core';
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
@Component({
|
||||
selector: 'app-consumption-gauge',
|
||||
standalone: true,
|
||||
templateUrl: './consumption-gauge.html',
|
||||
styleUrl: './consumption-gauge.scss',
|
||||
})
|
||||
export class ConsumptionGauge implements AfterViewInit, OnDestroy {
|
||||
consumption = input.required<number>();
|
||||
capacity = input.required<number>();
|
||||
|
||||
@ViewChild('canvas') private canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||
private chart?: Chart;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const used = this.consumption();
|
||||
const remaining = Math.max(0, this.capacity() - used);
|
||||
if (this.chart) {
|
||||
this.chart.data.datasets[0].data = [used, remaining];
|
||||
this.chart.update('none');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
const used = this.consumption();
|
||||
const remaining = Math.max(0, this.capacity() - used);
|
||||
|
||||
this.chart = new Chart(this.canvasRef.nativeElement, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Utilisé', 'Disponible'],
|
||||
datasets: [
|
||||
{
|
||||
data: [used, remaining],
|
||||
backgroundColor: ['#3b82f6', '#e5e7eb'],
|
||||
borderWidth: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '70%',
|
||||
animation: { duration: 300 },
|
||||
plugins: { legend: { display: false } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.chart?.destroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<canvas #canvas></canvas>
|
||||
@@ -0,0 +1,4 @@
|
||||
:host {
|
||||
display: block;
|
||||
height: 260px;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { vi } from 'vitest';
|
||||
import { Chart } from 'chart.js';
|
||||
import { SiteLoadChart } from './site-load-chart';
|
||||
|
||||
vi.mock('chart.js', () => {
|
||||
class ChartMock {
|
||||
static instances: ChartMock[] = [];
|
||||
static register = vi.fn();
|
||||
update = vi.fn();
|
||||
destroy = vi.fn();
|
||||
data = { datasets: [{}] };
|
||||
constructor() {
|
||||
ChartMock.instances.push(this);
|
||||
}
|
||||
}
|
||||
return { Chart: ChartMock, registerables: [] };
|
||||
});
|
||||
|
||||
type ChartDouble = { destroy: ReturnType<typeof vi.fn> };
|
||||
|
||||
function lastChart(): ChartDouble | undefined {
|
||||
return (Chart as unknown as { instances: ChartDouble[] }).instances.at(-1);
|
||||
}
|
||||
|
||||
describe('SiteLoadChart', () => {
|
||||
it('se crée sans erreur avec une liste de sites valide', () => {
|
||||
TestBed.configureTestingModule({ imports: [SiteLoadChart] });
|
||||
const fixture = TestBed.createComponent(SiteLoadChart);
|
||||
fixture.componentRef.setInput('sites', [
|
||||
{
|
||||
site_id: 'S1',
|
||||
site_name: 'Test',
|
||||
current_consumption_kw: 50,
|
||||
capacity_kw: 100,
|
||||
load_percent: 50,
|
||||
data_quality: 'good',
|
||||
},
|
||||
]);
|
||||
expect(() => fixture.detectChanges()).not.toThrow();
|
||||
});
|
||||
it('met à jour le graphique quand les sites changent après initialisation', () => {
|
||||
TestBed.configureTestingModule({ imports: [SiteLoadChart] });
|
||||
const fixture = TestBed.createComponent(SiteLoadChart);
|
||||
fixture.componentRef.setInput('sites', [
|
||||
{
|
||||
site_id: 'S1',
|
||||
site_name: 'A',
|
||||
current_consumption_kw: 50,
|
||||
capacity_kw: 100,
|
||||
load_percent: 50,
|
||||
data_quality: 'good',
|
||||
},
|
||||
]);
|
||||
fixture.detectChanges(); // déclenche ngAfterViewInit, this.chart existe désormais
|
||||
|
||||
fixture.componentRef.setInput('sites', [
|
||||
{
|
||||
site_id: 'S2',
|
||||
site_name: 'B',
|
||||
current_consumption_kw: 80,
|
||||
capacity_kw: 100,
|
||||
load_percent: 80,
|
||||
data_quality: 'critical',
|
||||
},
|
||||
]);
|
||||
fixture.detectChanges(); // ré-exécute l'effect, cette fois avec this.chart défini
|
||||
|
||||
expect(() => fixture.detectChanges()).not.toThrow();
|
||||
});
|
||||
|
||||
it('détruit le graphique quand le composant est détruit', () => {
|
||||
TestBed.configureTestingModule({ imports: [SiteLoadChart] });
|
||||
const fixture = TestBed.createComponent(SiteLoadChart);
|
||||
fixture.componentRef.setInput('sites', [
|
||||
{
|
||||
site_id: 'S1',
|
||||
site_name: 'A',
|
||||
current_consumption_kw: 50,
|
||||
capacity_kw: 100,
|
||||
load_percent: 50,
|
||||
data_quality: 'good',
|
||||
},
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
|
||||
const chart = lastChart();
|
||||
fixture.destroy();
|
||||
|
||||
expect(chart?.destroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
ViewChild,
|
||||
input,
|
||||
effect,
|
||||
AfterViewInit,
|
||||
OnDestroy,
|
||||
} from '@angular/core';
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
import { SiteSummary } from '../../models/stats.model';
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
const QUALITY_COLORS: Record<SiteSummary['data_quality'], string> = {
|
||||
good: '#2e7d32',
|
||||
partial: '#f9a825',
|
||||
degraded: '#ef6c00',
|
||||
critical: '#c62828',
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-site-load-chart',
|
||||
standalone: true,
|
||||
templateUrl: './site-load-chart.html',
|
||||
styleUrl: './site-load-chart.scss',
|
||||
})
|
||||
export class SiteLoadChart implements AfterViewInit, OnDestroy {
|
||||
sites = input.required<SiteSummary[]>();
|
||||
|
||||
@ViewChild('canvas') private canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||
private chart?: Chart;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const sites = this.sites();
|
||||
if (this.chart) {
|
||||
this.chart.data.labels = sites.map((s) => s.site_name);
|
||||
this.chart.data.datasets[0].data = sites.map((s) => s.load_percent ?? 0);
|
||||
this.chart.data.datasets[0].backgroundColor = sites.map(
|
||||
(s) => QUALITY_COLORS[s.data_quality],
|
||||
);
|
||||
this.chart.update('none');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
const sites = this.sites();
|
||||
this.chart = new Chart(this.canvasRef.nativeElement, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: sites.map((s) => s.site_name),
|
||||
datasets: [
|
||||
{
|
||||
data: sites.map((s) => s.load_percent ?? 0),
|
||||
backgroundColor: sites.map((s) => QUALITY_COLORS[s.data_quality]),
|
||||
borderRadius: 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
y: { beginAtZero: true, max: 100, title: { display: true, text: 'Charge (%)' } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.chart?.destroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export type AlertSeverity = 'low' | 'medium' | 'high' | 'critical';
|
||||
export type AlertType = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor';
|
||||
|
||||
export interface Alert {
|
||||
alert_id: string;
|
||||
timestamp: string;
|
||||
site_id: string;
|
||||
severity: AlertSeverity;
|
||||
type: AlertType;
|
||||
message: string;
|
||||
value: number;
|
||||
threshold: number;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface SiteSummary {
|
||||
site_id: string;
|
||||
site_name: string;
|
||||
current_consumption_kw: number | null;
|
||||
capacity_kw: number;
|
||||
load_percent: number | null;
|
||||
data_quality: 'good' | 'partial' | 'degraded' | 'critical';
|
||||
}
|
||||
|
||||
export interface StatsSummary {
|
||||
timestamp: string;
|
||||
total_sites: number;
|
||||
total_consumption_kw: number;
|
||||
total_capacity_kw: number;
|
||||
average_load_percent: number;
|
||||
sites: SiteSummary[];
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
apiUrl: '/api/v1'
|
||||
apiUrl: '/api/v1',
|
||||
useMockFixtures: true, // a passer a false une fois le backend prêt
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
apiUrl: 'http://localhost:8000/api/v1'
|
||||
apiUrl: 'http://localhost:8000/api/v1',
|
||||
useMockFixtures: false,
|
||||
};
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<testsuites name="vitest tests" tests="2" failures="0" errors="0" time="0.0699261">
|
||||
<testsuite name="src/app/app.spec.ts" timestamp="2026-09-14T14:27:28.895Z" hostname="76SE37-GL5HHZ3" tests="2" failures="0" errors="0" skipped="0" time="0.0699261">
|
||||
<testcase classname="src/app/app.spec.ts" name="App > should create the app" time="0.0527847">
|
||||
</testcase>
|
||||
<testcase classname="src/app/app.spec.ts" name="App > should render title" time="0.015831">
|
||||
</testcase>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
@@ -63,8 +63,9 @@ flowchart TB
|
||||
grafana -.-> prom
|
||||
```
|
||||
|
||||
Le lien `front -.-> api` est en pointillé à dessein : le frontend n'appelle aujourd'hui aucune
|
||||
API, `provideHttpClient` n'est pas encore installé. Voir [30-frontend.md](30-frontend.md).
|
||||
Le lien `front -.-> api` reste en pointillé : le frontend appelle bien une API, mais un
|
||||
intercepteur répond à sa place tant que les endpoints n'existent pas. Voir
|
||||
[30-frontend.md](30-frontend.md).
|
||||
|
||||
Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun
|
||||
collecteur ne vient le lire.
|
||||
@@ -73,9 +74,9 @@ 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`. Aucune couche métier |
|
||||
| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Squelette `ng new` standalone, routes vides, aucun service HTTP |
|
||||
| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Aucune table applicative |
|
||||
| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, `GET /sites` et `GET /sites/{site_id}` (première couche métier, endpoints → services → repositories → models) |
|
||||
| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, deux services HTTP, graphiques Chart.js, données servies par des fixtures |
|
||||
| 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 |
|
||||
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
|
||||
| ETL | Apache Airflow | `etl/airflow` | `Cible` | Rien |
|
||||
|
||||
@@ -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 :
|
||||
|
||||
|
||||
@@ -12,11 +12,11 @@ Les quatre couches existent désormais, portées par l'authentification.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
ep["endpoints<br/>health, auth, users"]
|
||||
ep["endpoints<br/>health, auth, users, sites, stats"]
|
||||
sc["schemas<br/>Pydantic"]
|
||||
sv["services<br/>AuthService, UserService"]
|
||||
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log"]
|
||||
md["models<br/>4 tables"]
|
||||
sv["services<br/>AuthService, UserService,<br/>SiteService, StatsService"]
|
||||
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site, reading"]
|
||||
md["models<br/>10 tables"]
|
||||
db[("PostgreSQL")]
|
||||
|
||||
ep --> sc
|
||||
@@ -126,29 +126,44 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke
|
||||
|
||||
## Routes exposées
|
||||
|
||||
| Méthode | Chemin | Dans l'OpenAPI | Rôle |
|
||||
| Méthode | Chemin | Rôle | Erreurs déclarées |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/health/live` | oui | Le processus répond. Ne touche pas la base |
|
||||
| GET | `/api/v1/health/ready` | oui | La base répond **et** l'extension TimescaleDB est chargée |
|
||||
| POST | `/api/v1/auth/login` | oui | Ouvre une session. Publique |
|
||||
| POST | `/api/v1/auth/refresh` | oui | Fait tourner la session. Cookie seulement |
|
||||
| POST | `/api/v1/auth/logout` | oui | Ferme la session courante. Idempotente |
|
||||
| POST | `/api/v1/auth/logout-all` | oui | Ferme toutes les sessions du compte |
|
||||
| POST | `/api/v1/auth/password` | oui | Change son propre mot de passe |
|
||||
| GET | `/api/v1/auth/me` | oui | Décrit le compte connecté |
|
||||
| GET | `/api/v1/users` | oui | Liste les comptes. `admin` |
|
||||
| POST | `/api/v1/users` | oui | Crée un compte, rend un mot de passe provisoire. `admin` |
|
||||
| PATCH | `/api/v1/users/{id}` | oui | Change le rôle ou l'activation. `admin` |
|
||||
| POST | `/api/v1/users/{id}/password-reset` | oui | Réinitialise et ferme les sessions. `admin` |
|
||||
| GET | `/metrics` | non | Format Prometheus. Jeton requis si `APP_METRICS_TOKEN` est posé |
|
||||
| GET | `/docs`, `/redoc`, `/openapi.json` | non | Fermés en `staging` et en `prod` |
|
||||
| GET | `/api/v1/health/live` | Le processus répond. Ne touche pas la base | 500 |
|
||||
| GET | `/api/v1/health/ready` | La base répond **et** l'extension TimescaleDB est chargée | 503, 500 |
|
||||
| POST | `/api/v1/auth/login` | Ouvre une session. Publique | 401, 422, 429, 500 |
|
||||
| POST | `/api/v1/auth/refresh` | Fait tourner la session. Cookie seulement | 401, 403, 500 |
|
||||
| POST | `/api/v1/auth/logout` | Ferme la session courante. Idempotente | 403, 500 |
|
||||
| POST | `/api/v1/auth/logout-all` | Ferme toutes les sessions du compte | 401, 403, 500 |
|
||||
| POST | `/api/v1/auth/password` | Change son propre mot de passe | 401, 403, 422, 500 |
|
||||
| GET | `/api/v1/auth/me` | Décrit le compte connecté | 401, 500 |
|
||||
| GET | `/api/v1/users` | Liste les comptes. `admin` | 401, 403, 500 |
|
||||
| POST | `/api/v1/users` | Crée un compte, rend un mot de passe provisoire. `admin` | 401, 403, 409, 422, 500 |
|
||||
| PATCH | `/api/v1/users/{id}` | Change le rôle ou l'activation. `admin` | 400, 401, 403, 404, 409, 422, 500 |
|
||||
| POST | `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions. `admin` | 401, 403, 404, 422, 500 |
|
||||
| GET | `/api/v1/sites` | Liste les sites. `lecteur` | 401, 403, 500 |
|
||||
| GET | `/api/v1/sites/{site_id}` | Décrit un site. `lecteur` | 401, 403, 404, 422, 500 |
|
||||
| GET | `/api/v1/stats/summary` | Résume la consommation instantanée du parc. `lecteur` | 401, 403, 500 |
|
||||
| GET | `/metrics` | Format Prometheus, hors du schéma. Jeton requis si `APP_METRICS_TOKEN` est posé | |
|
||||
| GET | `/docs`, `/redoc`, `/openapi.json` | Hors du schéma. Fermés en `staging` et en `prod` | |
|
||||
|
||||
Les codes de la dernière colonne sont ceux que le schéma **déclare**, et le fichier
|
||||
`openapi.json` versionné interdit qu'ils divergent de ce que les routes rendent.
|
||||
|
||||
**Quatre routes seulement sont publiques** : les deux sondes, `/auth/login` et `/auth/logout`.
|
||||
`tests/api/test_route_protection.py` interroge réellement chaque autre route sans identifiant et
|
||||
échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Rendre une route publique impose
|
||||
donc de modifier la liste dans ce fichier de test.
|
||||
|
||||
Aucune route métier n'existe à ce jour. Le contrat détaillé pour le frontend est dans
|
||||
`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`,
|
||||
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. `GET /stats/summary` agrège ces deux repositories (`SiteRepository`, `ReadingRepository`)
|
||||
dans un service dédié plutôt que d'exposer une table : elle n'entre donc pas dans ce gabarit
|
||||
route-par-table. Le contrat détaillé pour le frontend est dans
|
||||
[31-contrat-authentification.md](31-contrat-authentification.md).
|
||||
|
||||
### `/health/ready`
|
||||
@@ -182,6 +197,62 @@ sequenceDiagram
|
||||
end
|
||||
```
|
||||
|
||||
## Contrat OpenAPI
|
||||
|
||||
Statut : `Fait`.
|
||||
|
||||
Le schéma est servi sur `/openapi.json`, `/docs` et `/redoc`, fermés en `staging` et en `prod`.
|
||||
Il est aussi **versionné** dans [`apps/backend/openapi.json`](../../apps/backend/openapi.json) :
|
||||
|
||||
```bash
|
||||
make openapi
|
||||
```
|
||||
|
||||
Pourquoi un fichier en plus de la route. Une route qui change son contrat public le montre alors
|
||||
dans la diff de la pull request, et le frontend dispose d'une référence lisible sans lancer l'API.
|
||||
`tests/api/test_openapi.py` compare le fichier au schéma généré et échoue si l'un bouge sans
|
||||
l'autre ; le fichier vivant sous `apps/backend/`, le filtre de chemins de `backend.yml` le couvre.
|
||||
|
||||
**Le schéma exporté ne dépend pas du poste.** `settings_du_contrat()` pose le nom, la version et
|
||||
le préfixe, et coupe la lecture du `.env`. Sans cela, un `APP_API_PREFIX` local suffirait à faire
|
||||
diverger le fichier d'une machine à l'autre, et le test deviendrait un oracle de configuration
|
||||
plutôt qu'un garde-fou de contrat.
|
||||
|
||||
Trois champs sont volontairement absents d'`info`, parce qu'ils poseraient une décision qui n'est
|
||||
pas prise :
|
||||
|
||||
| Champ | Pourquoi |
|
||||
|---|---|
|
||||
| `servers` | L'URL publique dépend de l'ingress, question ouverte dans [10-infra.md](10-infra.md) |
|
||||
| `license_info` | Aucune licence n'est choisie |
|
||||
| `contact` | Aucun canal de support n'existe |
|
||||
|
||||
Deux schémas de sécurité sont déclarés : `Jeton d'accès` pour le porteur JWT, et
|
||||
`Cookie de rafraîchissement` pour `/auth/refresh` et `/auth/logout`. **Le second est purement
|
||||
documentaire** : son `auto_error=False` garantit qu'il ne décide d'aucun refus. Le passer à vrai
|
||||
ferait répondre 403 avant d'atteindre `lit_le_cookie()`, et `/auth/refresh` cesserait de rendre le
|
||||
401 sur lequel le frontend déclenche sa déconnexion.
|
||||
|
||||
Les modèles de `app/schemas/errors.py` décrivent ce que les gestionnaires renvoient réellement.
|
||||
`ValidationErrorResponse` remplace le `HTTPValidationError` par défaut de FastAPI, dont la clé
|
||||
`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
|
||||
|
||||
Checklist pour toute nouvelle route sur le gabarit `sites`/`stats` (`reading`, `dataset`,
|
||||
`prediction`, `alert`, `recommendation`) :
|
||||
|
||||
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é
|
||||
|
||||
Voir la vue consolidée dans [00-vue-ensemble.md](00-vue-ensemble.md) et les décisions dans les
|
||||
|
||||
@@ -4,30 +4,36 @@ Application Angular 22, 100 % standalone, testée avec Vitest. Source dans `apps
|
||||
|
||||
## État actuel
|
||||
|
||||
Statut : `En cours`. Le projet est un `ng new` intact. Le tableau de la
|
||||
[vue d'ensemble](00-vue-ensemble.md) le classe désormais correctement, le `README.md` racine le
|
||||
disait encore « à initialiser » alors que le squelette existe depuis `49f4697`.
|
||||
Statut : `En cours`. L'application sert une première page métier, le tableau de bord, alimentée
|
||||
par des fixtures : les endpoints qu'elle appelle n'existent pas encore côté API.
|
||||
|
||||
Ce qui est en place :
|
||||
|
||||
- Bootstrap par `bootstrapApplication(App, appConfig)`, **aucun `NgModule`** dans le dépôt.
|
||||
- `app.config.ts` fournit `provideBrowserGlobalErrorListeners()` et `provideRouter(routes)`.
|
||||
- Vitest via le builder `@angular/build:unit-test`, couverture activée, un fichier de test.
|
||||
- `app.config.ts` fournit `provideBrowserGlobalErrorListeners()`, `provideRouter(routes)` et
|
||||
`provideHttpClient(withInterceptors([mockApiInterceptor]))`.
|
||||
- Une route `/dashboard` en composant différé, et une redirection depuis la racine.
|
||||
- `core/services` porte `StatsService` et `AlertsService`, `core/interceptors` l'intercepteur de
|
||||
fixtures, `features/dashboard` la page, `shared/components` la jauge de consommation et le
|
||||
graphique de charge par site, tous deux construits sur Chart.js.
|
||||
- L'état vit dans des signaux, sans bibliothèque dédiée.
|
||||
- Vitest via le builder `@angular/build:unit-test`, couverture activée, sept fichiers de test.
|
||||
- Prettier configuré, parser `angular` pour les gabarits HTML.
|
||||
|
||||
Ce qui n'existe pas encore :
|
||||
|
||||
- `routes` est un tableau vide. Aucune page, aucune navigation.
|
||||
- **`provideHttpClient` n'est pas fourni** et `@angular/common/http` n'est importé nulle part :
|
||||
l'application n'appelle aucune API.
|
||||
- `app.html` est la page d'accueil Angular par défaut, commentaires de remplacement compris.
|
||||
- Aucune bibliothèque de graphiques, aucun kit d'interface, aucune gestion d'état.
|
||||
- **Aucun endpoint réel derrière l'écran.** `GET /api/v1/stats/summary` et `GET /api/v1/alerts`
|
||||
sont servis par l'intercepteur ; l'API expose `/health`, `/auth` et `/users`, rien d'autre.
|
||||
- Aucune authentification côté interface : ni garde de route, ni intercepteur de jeton, alors que
|
||||
les routes métier de l'API en exigent un. Voir
|
||||
[31-contrat-authentification.md](31-contrat-authentification.md).
|
||||
- Aucun état de chargement : tant que la première réponse n'est pas arrivée, la page reste vide.
|
||||
- Aucun lint : ESLint n'est pas installé.
|
||||
|
||||
## Arborescence cible
|
||||
## Arborescence
|
||||
|
||||
Statut : `Cible`. Elle n'est pas inventée ici : [`TESTING.md`](../../apps/frontend/TESTING.md) la
|
||||
prescrit déjà dans ses gabarits de tests.
|
||||
Statut : `Fait`. Elle suit ce que [`TESTING.md`](../../apps/frontend/TESTING.md) prescrit dans ses
|
||||
gabarits de tests.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
@@ -48,22 +54,33 @@ directement : ils passent par un service, ce qui rend le double de test trivial.
|
||||
|
||||
## Flux HTTP
|
||||
|
||||
Statut : `Cible`. Le chemin est câblé, rien ne l'emprunte encore.
|
||||
Statut : `En cours`. Le chemin complet est câblé, mais un intercepteur se place devant et répond
|
||||
lui-même tant que les endpoints n'existent pas.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Composant
|
||||
participant S as Service Angular
|
||||
participant I as mockApiInterceptor
|
||||
participant P as ng serve, proxy
|
||||
participant A as FastAPI
|
||||
|
||||
C->>S: appel de méthode
|
||||
S->>P: GET /api/v1/...
|
||||
S->>I: GET /api/v1/...
|
||||
alt useMockFixtures actif et route connue
|
||||
I-->>S: fixture locale
|
||||
else
|
||||
I->>P: la requête poursuit
|
||||
P->>A: http://localhost:8000/api/v1/...
|
||||
A-->>S: JSON
|
||||
end
|
||||
S-->>C: modèle typé
|
||||
```
|
||||
|
||||
`mockApiInterceptor` n'intercepte que `/stats/summary` et `/alerts`, et seulement si
|
||||
`environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en
|
||||
production : toute autre requête, et toutes les requêtes en production, suivent le chemin réel.
|
||||
|
||||
En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce
|
||||
qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un
|
||||
`apiUrl` relatif, `/api/v1`.
|
||||
@@ -87,8 +104,13 @@ déploiement, en même temps que sera tranchée la question de l'ingress dans
|
||||
| `npm run test` | Vitest en mode observateur |
|
||||
| `npm run test:ci` | Vitest en une passe |
|
||||
|
||||
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
|
||||
**Version de Node.** L'Angular CLI refuse de démarrer en dessous de 22.22.3, 24.15.0 ou 26.0.0, et
|
||||
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 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
|
||||
@@ -98,8 +120,9 @@ avec un service statique, il reste à écrire.
|
||||
## Sécurité
|
||||
|
||||
- Le frontend ne détient aucun secret : `environment.ts` ne porte qu'une URL.
|
||||
- L'authentification n'existe pas côté API, donc pas de garde ni d'intercepteur de jeton à ce
|
||||
stade. `core/guards` et `core/interceptors` sont prévus pour cela.
|
||||
- L'authentification existe côté API mais pas côté interface : aucune garde de route, aucun
|
||||
intercepteur de jeton. `core/guards` reste à créer, `core/interceptors` n'héberge aujourd'hui
|
||||
que les fixtures.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -107,8 +130,7 @@ Conventions et gabarits : [`apps/frontend/TESTING.md`](../../apps/frontend/TESTI
|
||||
|
||||
## Questions ouvertes
|
||||
|
||||
- **Quelle bibliothèque de graphiques** pour les séries temporelles, et si Grafana en couvre déjà
|
||||
une partie du besoin.
|
||||
- **Gestion d'état** : signaux seuls, ou une bibliothèque dédiée.
|
||||
- **Gestion d'état** : les signaux suffisent aujourd'hui, la question se reposera quand plusieurs
|
||||
pages partageront le même état.
|
||||
- **Comment `apiUrl` est injecté en production** : build par environnement, ou configuration lue
|
||||
au démarrage.
|
||||
|
||||
@@ -26,7 +26,9 @@ gérer : il suffit d'envoyer les requêtes avec `withCredentials`.
|
||||
| PATCH | `/api/v1/users/{id}` | jeton d'accès, `admin` | `200` `UserResponse` |
|
||||
| POST | `/api/v1/users/{id}/password-reset` | jeton d'accès, `admin` | `200` `TemporaryPasswordResponse` |
|
||||
|
||||
Le schéma exact est dans `/docs` (Swagger), servi en local et en développement.
|
||||
Le schéma exact est dans [`apps/backend/openapi.json`](../../apps/backend/openapi.json),
|
||||
lisible sans lancer l'API, et servi par `/docs` en local et en développement. La table des
|
||||
codes d'erreur ci-dessous reste la référence de comportement, le schéma celle de forme.
|
||||
|
||||
## Charges utiles
|
||||
|
||||
@@ -66,6 +68,7 @@ Le secret de rafraîchissement **n'apparaît jamais** dans le corps de la répon
|
||||
| `401` sur `/auth/refresh` | session révoquée, expirée ou rejouée | **déconnecter** et renvoyer vers la page de connexion |
|
||||
| `403` avec `detail: "password_change_required"` | mot de passe provisoire | rediriger vers l'écran de changement de mot de passe |
|
||||
| `403` avec `detail: "Droits insuffisants"` | rôle trop bas | masquer ou griser l'action, ne pas déconnecter |
|
||||
| `403` sur `/auth/refresh`, `/logout`, `/logout-all`, `/password` | origine hors liste autorisée (voir « Origines autorisées ») | erreur de configuration réseau, pas un cas à gérer par l'utilisateur |
|
||||
| `422` | corps invalide | le détail donne `champ` et `type`, jamais la valeur envoyée |
|
||||
|
||||
## Les quatre règles qui comptent
|
||||
|
||||
@@ -4,12 +4,12 @@ PostgreSQL 17 avec l'extension TimescaleDB. Le choix, ses alternatives et ses co
|
||||
dans l'[ADR 0001](../adr/0001-postgresql-timescaledb.md), qui fait foi. Ce document décrit le
|
||||
système qui en découle.
|
||||
|
||||
## Avertissement
|
||||
## Ce que couvre ce document
|
||||
|
||||
**Aucune table applicative n'existe à ce jour.** `Base.metadata` est vide, `app/models/` ne
|
||||
contient qu'un commentaire, l'unique révision Alembic ne crée aucune table, et aucune hypertable
|
||||
n'a été déclarée. Tout ce qui suit sous le statut `Cible` est une proposition de structure, pas un
|
||||
relevé du code. Le modèle sera arrêté au jalon J2.
|
||||
**Dix tables applicatives existent** : quatre pour l'authentification, six pour les données
|
||||
d'énergie, dont l'hypertable `reading`. Les sections marquées `Fait` relèvent le code. Celles
|
||||
marquées `Cible` décrivent ce qui n'est pas écrit, au premier rang desquelles la chaîne
|
||||
d'ingestion, les agrégats continus, la compression et la rétention.
|
||||
|
||||
## Trois emplacements, trois rôles
|
||||
|
||||
@@ -35,7 +35,7 @@ Statut : `Fait`.
|
||||
- `db/init/100-extensions.sql` crée l'extension `timescaledb`.
|
||||
- `db/init/110-test-database.sql` crée `enervision_test`, dont le nom est attendu en dur par
|
||||
`apps/backend/tests/conftest.py`.
|
||||
- Quatre révisions Alembic. La première, `5353c0e4f094`, **ne crée aucune table** : elle
|
||||
- Cinq révisions Alembic. La première, `5353c0e4f094`, **ne crée aucune table** : elle
|
||||
établit `alembic_version` et refuse de s'appliquer si l'extension manque :
|
||||
|
||||
```sql
|
||||
@@ -48,16 +48,18 @@ Cette garde forme paire avec le 503 de `/api/v1/health/ready`. Un bootstrap saut
|
||||
au démarrage de l'API : ces deux gardes le rendent visible tôt, des deux côtés.
|
||||
|
||||
Les trois suivantes créent les tables de l'authentification, décrites plus bas : `app_user`,
|
||||
puis `login_attempt` et `audit_log`, puis `refresh_token`.
|
||||
puis `login_attempt` et `audit_log`, puis `refresh_token`. La cinquième, `e6d2026091501`, crée
|
||||
les six tables de données décrites en fin de document et déclare l'hypertable `reading`.
|
||||
|
||||
## Cycle de vie d'une mesure
|
||||
|
||||
Statut : `Cible`. Aucun de ces maillons n'existe.
|
||||
Statut : `Cible`, sauf l'hypertable `reading` qui existe. Ni l'ingestion, ni les agrégats
|
||||
continus, ni la compression, ni la rétention ne sont écrits.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
src["Source de mesures"] -.-> ing["Ingestion Airflow"]
|
||||
ing -.-> hy[("Hypertable mesure")]
|
||||
ing -.-> hy[("Hypertable reading")]
|
||||
hy -.-> agg[("Agrégat continu")]
|
||||
hy -.-> comp["Compression"]
|
||||
hy -.-> ret["Rétention"]
|
||||
@@ -133,67 +135,46 @@ donc **pas** une hypertable : une politique de rétention émettrait des `DELETE
|
||||
refuseraient. `login_attempt`, à l'inverse, est faite pour se purger, puisque son volume est
|
||||
piloté par l'attaquant.
|
||||
|
||||
## Modèle métier
|
||||
|
||||
Statut : `Cible`. Les entités ci-dessous sont des **candidates**, à valider en J2. Elles
|
||||
s'appuient sur les gabarits de [`apps/backend/TESTING.md`](../../apps/backend/TESTING.md), qui
|
||||
évoquent déjà un modèle `Site`, un `SiteRepository` et un `ConsumptionService` exposant un
|
||||
`total_kwh(site_id)`.
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
SITE ||--o{ POINT_DE_MESURE : porte
|
||||
POINT_DE_MESURE ||--o{ MESURE : produit
|
||||
|
||||
SITE {
|
||||
int id PK
|
||||
string nom
|
||||
}
|
||||
POINT_DE_MESURE {
|
||||
int id PK
|
||||
int site_id FK
|
||||
string libelle
|
||||
string unite
|
||||
}
|
||||
MESURE {
|
||||
timestamptz horodatage PK
|
||||
int point_id PK
|
||||
double valeur
|
||||
}
|
||||
```
|
||||
|
||||
`MESURE` est la table destinée à devenir une hypertable, partitionnée sur `horodatage`. Sa clé
|
||||
primaire doit inclure la colonne de temps : TimescaleDB l'exige, une clé sur le seul identifiant
|
||||
de point serait refusée.
|
||||
|
||||
## Gabarit de révision créant une hypertable
|
||||
|
||||
Conforme à la règle de l'ADR 0001 : table et hypertable dans la même révision.
|
||||
Conforme à la règle de l'ADR 0001 : table et hypertable dans la même révision. La révision
|
||||
`e6d2026091501` en est l'exemple réel, réduit ici à l'essentiel.
|
||||
|
||||
```python
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"mesure",
|
||||
sa.Column("horodatage", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("point_id", sa.Integer(), sa.ForeignKey("point_de_mesure.id"), nullable=False),
|
||||
sa.Column("valeur", sa.Float(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("horodatage", "point_id"),
|
||||
"reading",
|
||||
sa.Column("reading_id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("site_id", sa.Text(), nullable=False),
|
||||
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("reading_id", "timestamp"),
|
||||
)
|
||||
op.execute(
|
||||
"SELECT create_hypertable('reading', by_range('timestamp'), "
|
||||
"create_default_indexes => FALSE)"
|
||||
)
|
||||
op.execute("SELECT create_hypertable('mesure', by_range('horodatage'))")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("mesure")
|
||||
op.drop_table("reading")
|
||||
```
|
||||
|
||||
La clé primaire inclut la colonne de temps parce que TimescaleDB l'exige : toute contrainte
|
||||
unique d'une hypertable doit porter la colonne de partitionnement, et une clé sur le seul
|
||||
`reading_id` serait refusée par `create_hypertable`.
|
||||
|
||||
`create_default_indexes => FALSE` écarte l'index que TimescaleDB pose d'office sur la seule
|
||||
colonne de temps : les index déclarés dans la révision le couvrent déjà.
|
||||
|
||||
`drop_table` suffit au retour arrière : supprimer la table supprime l'hypertable et ses partitions.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Noms au singulier**, en minuscules, sans préfixe de table.
|
||||
- **Noms au singulier**, en minuscules, sans préfixe de table : `app_user`, `reading`.
|
||||
- **Toute colonne de temps en `timestamptz`.** Jamais de `timestamp` nu : une mesure sans fuseau
|
||||
devient ininterprétable dès le premier changement d'heure.
|
||||
- **La colonne de partitionnement s'appelle `horodatage`** et entre dans la clé primaire.
|
||||
- **La colonne de partitionnement entre dans la clé primaire.** Dans `reading` elle s'appelle
|
||||
`timestamp` : c'est un nom de colonne, son type reste `timestamptz`.
|
||||
- **Les politiques de rétention et de compression** vont dans `db/migrations/`, pas dans Alembic :
|
||||
elles ne découlent pas du schéma applicatif.
|
||||
- **Tout modèle doit être importé dans `app/models/__init__.py`**, sans quoi
|
||||
@@ -201,13 +182,12 @@ def downgrade() -> None:
|
||||
|
||||
## Questions ouvertes
|
||||
|
||||
Elles relèvent du jalon J2, « valider le périmètre retenu », et bloquent le modèle définitif.
|
||||
Elles relèvent du jalon J2, « valider le périmètre retenu ». Le schéma est livré : ce qui suit
|
||||
porte sur son exploitation, plus sur sa forme.
|
||||
|
||||
- **Quelles sources de mesures**, et selon quel protocole elles sont collectées.
|
||||
- **Quelle granularité** à l'ingestion : la seconde, la minute, le quart d'heure.
|
||||
- **Quels agrégats continus**, et sur quelles fenêtres.
|
||||
- **Quelle profondeur de rétention** en données brutes, et à partir de quand on compresse.
|
||||
- **Quelles unités** sont manipulées, et si une même table les mélange.
|
||||
- **Multi-tenant ou non** : un site appartient-il à un client, et faut-il cloisonner les lectures.
|
||||
|
||||
## Modélisation détaillée des données
|
||||
@@ -220,12 +200,11 @@ jusqu’aux recommandations proposées à l’utilisateur.
|
||||
### Schéma de données
|
||||
|
||||
Le diagramme ci-dessous présente les tables et leurs relations.
|
||||
Il décrit une structure de conception ; les migrations correspondantes
|
||||
restent à implémenter.
|
||||
La révision `e6d2026091501` les crée.
|
||||
|
||||

|
||||
|
||||
*Figure — Modélisation des données EnerVision.*
|
||||
*Figure : Modélisation des données EnerVision.*
|
||||
|
||||
### Description des tables
|
||||
|
||||
@@ -234,15 +213,15 @@ des données.
|
||||
|
||||
| Table | Rôle | Origine des informations |
|
||||
|---|---|---|
|
||||
| `datasets` | Identifier les jeux historiques, retrouver leurs fichiers et conserver leurs métadonnées | Archive CSV/JSON et informations ajoutées lors de l’import |
|
||||
| `sites` | Regrouper les informations des sites : identifiant, nom, type et caractéristiques disponibles | CSV et API Mock `/api/v1/sites` |
|
||||
| `readings` | Stocker les mesures, leur provenance, leur qualité et les éventuelles valeurs imputées | CSV et API Mock `/current` et `/readings` |
|
||||
| `predictions` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d’EnerVision |
|
||||
| `alerts` | Enregistrer les alertes, leur type, leur gravité et leur message | API Mock `/alerts` et détections EnerVision |
|
||||
| `recommendations` | Proposer des actions et expliquer la règle qui les motive | Règles métier d’EnerVision |
|
||||
| `dataset` | Identifier les jeux historiques, retrouver leurs fichiers et conserver leurs métadonnées | Archive CSV/JSON et informations ajoutées lors de l’import |
|
||||
| `site` | Regrouper les informations des sites : identifiant, nom, type et caractéristiques disponibles | CSV et API Mock `/api/v1/sites` |
|
||||
| `reading` | Stocker les mesures, leur provenance, leur qualité et les éventuelles valeurs imputées | CSV et API Mock `/current` et `/readings` |
|
||||
| `prediction` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d’EnerVision |
|
||||
| `alert` | Enregistrer les alertes, leur type, leur gravité et leur message | API Mock `/alerts` et détections EnerVision |
|
||||
| `recommendation` | Proposer des actions et expliquer la règle qui les motive | Règles métier d’EnerVision |
|
||||
|
||||
Les anomalies historiques décrites dans les JSON sont conservées
|
||||
dans `datasets.metadata`. Elles servent à l’analyse des données
|
||||
dans `dataset.metadata`. Elles servent à l’analyse des données
|
||||
et ne sont pas considérées comme des alertes actuelles.
|
||||
|
||||
### Relations entre les tables
|
||||
|
||||
@@ -10,7 +10,7 @@ contredisent, c'est l'ADR qui fait foi et la vue qui est en retard.
|
||||
|---|---|
|
||||
| [00-vue-ensemble.md](00-vue-ensemble.md) | Jalons du projet, contexte, conteneurs, sécurité, flux bout en bout |
|
||||
| [10-infra.md](10-infra.md) | Poste de développement, cible k3s, décisions figées, ports et noms |
|
||||
| [20-backend.md](20-backend.md) | Couches FastAPI, séquence de démarrage, routes, configuration |
|
||||
| [20-backend.md](20-backend.md) | Couches FastAPI, séquence de démarrage, routes, configuration, contrat OpenAPI |
|
||||
| [30-frontend.md](30-frontend.md) | Angular, arborescence cible, flux HTTP |
|
||||
| [31-contrat-authentification.md](31-contrat-authentification.md) | Ce que le frontend doit savoir pour coder la connexion |
|
||||
| [40-data.md](40-data.md) | Frontières `db/` et `alembic/`, cycle de vie d'une mesure, modèle |
|
||||
|
||||
@@ -8,8 +8,9 @@ de réponse honnête.
|
||||
Ce qui est défendable, c'est une ligne par contrôle réellement implémenté, l'item qu'il adresse,
|
||||
et une section qui dit ce qui n'est pas couvert et pourquoi.
|
||||
|
||||
Statut : `Fait` pour le périmètre authentification et autorisation. Les endpoints métier
|
||||
n'existent pas encore, donc plusieurs lignes resteront à compléter.
|
||||
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.
|
||||
|
||||
## Contrôles en place
|
||||
|
||||
@@ -48,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. Un opérateur du site A pourra agir sur le site B dès que les endpoints métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. |
|
||||
| **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site : `GET /sites/{site_id}` répond à tout compte `lecteur` pour n'importe quel site, sans vérifier une affectation compte-site qui n'existe pas encore. Un opérateur du site A pourra agir sur le site B dès que les endpoints d'écriture métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. |
|
||||
| **API4, lectures de séries temporelles** | **ouvert** | Pas encore d'endpoint métier, donc ni pagination plafonnée, ni fenêtre temporelle maximale, ni `statement_timeout`. C'est la façon la plus probable dont la démonstration tombera : une requête sur dix ans d'historique suffit. |
|
||||
| **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. |
|
||||
|
||||
Reference in New Issue
Block a user