Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ad19ed089 | ||
|
|
12c5cf87ad | ||
|
|
7b076171d2 | ||
|
|
d632af57b8 | ||
|
|
fabd073aaf | ||
|
|
3347fa5bdb | ||
|
|
da481d7485 | ||
|
|
344f82fcdd | ||
|
|
6c1f86b4ce |
@@ -2,7 +2,7 @@ BACKEND := apps/backend
|
|||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
.PHONY: help install dev lint format typecheck test test-cov test-integration check \
|
.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
|
openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin
|
||||||
|
|
||||||
help: ## Liste les cibles disponibles
|
help: ## Liste les cibles disponibles
|
||||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
|
||||||
@@ -34,6 +34,9 @@ test-integration: ## Exécute les tests exigeant une base joignable
|
|||||||
|
|
||||||
check: lint typecheck test ## Chaîne de vérification complète
|
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: ## Construit l'image du backend
|
||||||
docker build -t enervision-backend:local $(BACKEND)
|
docker build -t enervision-backend:local $(BACKEND)
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ de demarrer sans elles.
|
|||||||
## Commandes
|
## Commandes
|
||||||
|
|
||||||
Depuis la racine du monorepo, via le `Makefile` : `make install`, `make dev`, `make lint`,
|
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 :
|
Directement depuis ce dossier :
|
||||||
|
|
||||||
@@ -39,8 +39,12 @@ uv run ruff format . # format
|
|||||||
uv run mypy app # typage strict
|
uv run mypy app # typage strict
|
||||||
uv run pytest # tests + couverture
|
uv run pytest # tests + couverture
|
||||||
uv run pytest -m integration # tests exigeant une base joignable
|
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
|
Les conventions de tests, les gabarits et le detail des marqueurs sont dans
|
||||||
[`TESTING.md`](TESTING.md).
|
[`TESTING.md`](TESTING.md).
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# 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`.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
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,
|
get_client_ip,
|
||||||
require_trusted_origin,
|
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.cookies import RefreshCookie, cookie_name
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
from app.schemas.auth import (
|
from app.schemas.auth import (
|
||||||
@@ -19,6 +26,7 @@ from app.schemas.auth import (
|
|||||||
PrincipalResponse,
|
PrincipalResponse,
|
||||||
TokenResponse,
|
TokenResponse,
|
||||||
)
|
)
|
||||||
|
from app.schemas.errors import ErrorResponse
|
||||||
from app.services.auth import (
|
from app.services.auth import (
|
||||||
AuthenticatedSession,
|
AuthenticatedSession,
|
||||||
InvalidCredentialsError,
|
InvalidCredentialsError,
|
||||||
@@ -32,6 +40,51 @@ logger = get_logger(__name__)
|
|||||||
DETAIL_IDENTIFIANTS = "Identifiants invalides"
|
DETAIL_IDENTIFIANTS = "Identifiants invalides"
|
||||||
DETAIL_SESSION = "Session invalide"
|
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(
|
def repond(
|
||||||
response: Response, settings: SettingsDep, session: AuthenticatedSession
|
response: Response, settings: SettingsDep, session: AuthenticatedSession
|
||||||
@@ -61,7 +114,12 @@ def lit_le_cookie(request: Request, settings: SettingsDep) -> str:
|
|||||||
return secret
|
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(
|
async def login(
|
||||||
payload: LoginRequest,
|
payload: LoginRequest,
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -98,7 +156,8 @@ async def login(
|
|||||||
"/refresh",
|
"/refresh",
|
||||||
response_model=TokenResponse,
|
response_model=TokenResponse,
|
||||||
summary="Fait tourner la session",
|
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(
|
async def refresh(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -133,7 +192,8 @@ async def refresh(
|
|||||||
"/logout",
|
"/logout",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
summary="Ferme la session courante",
|
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(
|
async def logout(
|
||||||
request: Request, response: Response, settings: SettingsDep, service: AuthServiceDep
|
request: Request, response: Response, settings: SettingsDep, service: AuthServiceDep
|
||||||
@@ -150,6 +210,7 @@ async def logout(
|
|||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
summary="Ferme toutes les sessions du compte",
|
summary="Ferme toutes les sessions du compte",
|
||||||
dependencies=[Depends(require_trusted_origin)],
|
dependencies=[Depends(require_trusted_origin)],
|
||||||
|
responses=REPONSES_LOGOUT_ALL,
|
||||||
)
|
)
|
||||||
async def logout_all(
|
async def logout_all(
|
||||||
principal: CurrentPrincipalDep,
|
principal: CurrentPrincipalDep,
|
||||||
@@ -163,7 +224,12 @@ async def logout_all(
|
|||||||
response.delete_cookie(**RefreshCookie.expired(settings).as_deletion_kwargs())
|
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:
|
async def me(principal: CurrentPrincipalDep) -> PrincipalResponse:
|
||||||
return PrincipalResponse.from_principal(principal)
|
return PrincipalResponse.from_principal(principal)
|
||||||
|
|
||||||
@@ -173,6 +239,7 @@ async def me(principal: CurrentPrincipalDep) -> PrincipalResponse:
|
|||||||
response_model=TokenResponse,
|
response_model=TokenResponse,
|
||||||
summary="Change son propre mot de passe",
|
summary="Change son propre mot de passe",
|
||||||
dependencies=[Depends(require_trusted_origin)],
|
dependencies=[Depends(require_trusted_origin)],
|
||||||
|
responses=REPONSES_MOT_DE_PASSE,
|
||||||
)
|
)
|
||||||
async def change_password(
|
async def change_password(
|
||||||
payload: PasswordChangeRequest,
|
payload: PasswordChangeRequest,
|
||||||
|
|||||||
@@ -3,16 +3,17 @@ from sqlalchemy import text
|
|||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
from app.api.deps import SessionDep, SettingsDep
|
from app.api.deps import SessionDep, SettingsDep
|
||||||
|
from app.api.openapi import REPONSE_INDISPONIBLE
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
from app.schemas.health import LivenessStatus, ReadinessStatus
|
from app.schemas.health import LivenessStatus, ReadinessStatus
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
router = APIRouter(tags=["health"])
|
router = APIRouter()
|
||||||
|
|
||||||
TIMESCALEDB_VERSION = text("SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'")
|
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:
|
async def liveness(settings: SettingsDep) -> LivenessStatus:
|
||||||
return LivenessStatus(
|
return LivenessStatus(
|
||||||
status="ok",
|
status="ok",
|
||||||
@@ -22,7 +23,7 @@ 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:
|
async def readiness(session: SessionDep) -> ReadinessStatus:
|
||||||
try:
|
try:
|
||||||
version: str | None = await session.scalar(TIMESCALEDB_VERSION)
|
version: str | None = await session.scalar(TIMESCALEDB_VERSION)
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
|
||||||
from app.api.deps import LecteurDep, SiteServiceDep
|
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.schemas.site import SiteResponse
|
||||||
from app.services.site import SiteNotFoundError
|
from app.services.site import SiteNotFoundError
|
||||||
|
|
||||||
router = APIRouter()
|
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")
|
@router.get("", response_model=list[SiteResponse], summary="Liste les sites")
|
||||||
async def list_sites(_: LecteurDep, service: SiteServiceDep) -> list[SiteResponse]:
|
async def list_sites(_: LecteurDep, service: SiteServiceDep) -> list[SiteResponse]:
|
||||||
@@ -13,7 +20,12 @@ async def list_sites(_: LecteurDep, service: SiteServiceDep) -> list[SiteRespons
|
|||||||
return [SiteResponse.model_validate(site) for site in sites]
|
return [SiteResponse.model_validate(site) for site in sites]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{site_id}", response_model=SiteResponse, summary="Décrit un site")
|
@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:
|
async def get_site(site_id: str, _: LecteurDep, service: SiteServiceDep) -> SiteResponse:
|
||||||
try:
|
try:
|
||||||
site = await service.get_by_id(site_id)
|
site = await service.get_by_id(site_id)
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ from uuid import UUID
|
|||||||
from fastapi import APIRouter, HTTPException, Response, status
|
from fastapi import APIRouter, HTTPException, Response, status
|
||||||
|
|
||||||
from app.api.deps import AdminDep, UserServiceDep
|
from app.api.deps import AdminDep, UserServiceDep
|
||||||
|
from app.api.openapi import REPONSE_VALIDATION, Reponses
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
|
from app.schemas.errors import ErrorResponse
|
||||||
from app.schemas.user import (
|
from app.schemas.user import (
|
||||||
TemporaryPasswordResponse,
|
TemporaryPasswordResponse,
|
||||||
UserCreateRequest,
|
UserCreateRequest,
|
||||||
@@ -15,6 +17,28 @@ from app.services.user import EmailAlreadyUsedError, LastAdminError, UserNotFoun
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = get_logger(__name__)
|
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")
|
@router.get("", response_model=list[UserResponse], summary="Liste les comptes")
|
||||||
async def list_users(_: AdminDep, service: UserServiceDep) -> list[UserResponse]:
|
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,
|
response_model=TemporaryPasswordResponse,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
summary="Crée un compte avec un mot de passe provisoire",
|
summary="Crée un compte avec un mot de passe provisoire",
|
||||||
|
responses=REPONSES_CREATION,
|
||||||
)
|
)
|
||||||
async def create_user(
|
async def create_user(
|
||||||
payload: UserCreateRequest,
|
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(
|
async def update_user(
|
||||||
user_id: UUID,
|
user_id: UUID,
|
||||||
payload: UserUpdateRequest,
|
payload: UserUpdateRequest,
|
||||||
@@ -92,6 +122,7 @@ async def update_user(
|
|||||||
"/{user_id}/password-reset",
|
"/{user_id}/password-reset",
|
||||||
response_model=TemporaryPasswordResponse,
|
response_model=TemporaryPasswordResponse,
|
||||||
summary="Réinitialise le mot de passe et ferme les sessions",
|
summary="Réinitialise le mot de passe et ferme les sessions",
|
||||||
|
responses=REPONSES_INTROUVABLE,
|
||||||
)
|
)
|
||||||
async def reset_password(
|
async def reset_password(
|
||||||
user_id: UUID, acteur: AdminDep, service: UserServiceDep, response: Response
|
user_id: UUID, acteur: AdminDep, service: UserServiceDep, response: Response
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR
|
||||||
from app.api.v1.endpoints import auth, health, sites, users
|
from app.api.v1.endpoints import auth, health, sites, users
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter(responses=REPONSE_SERVEUR)
|
||||||
api_router.include_router(health.router, prefix="/health", tags=["health"])
|
api_router.include_router(health.router, prefix="/health", tags=["health"])
|
||||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||||
api_router.include_router(users.router, prefix="/users", tags=["users"])
|
api_router.include_router(users.router, prefix="/users", tags=["users"], responses=REPONSES_ADMIN)
|
||||||
api_router.include_router(sites.router, prefix="/sites", tags=["sites"])
|
api_router.include_router(sites.router, prefix="/sites", tags=["sites"], responses=REPONSES_LECTEUR)
|
||||||
|
|||||||
@@ -7,18 +7,25 @@
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
import sys
|
import sys
|
||||||
from getpass import getpass
|
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.config import Settings, get_settings
|
||||||
from app.core.hashing import build_hasher
|
from app.core.hashing import build_hasher
|
||||||
from app.core.roles import Role
|
from app.core.roles import Role
|
||||||
from app.db.session import get_session_factory
|
from app.db.session import get_session_factory
|
||||||
|
from app.main import create_app
|
||||||
from app.repositories.user import UserRepository
|
from app.repositories.user import UserRepository
|
||||||
|
|
||||||
LONGUEUR_MOT_DE_PASSE_GENERE = 24
|
LONGUEUR_MOT_DE_PASSE_GENERE = 24
|
||||||
LONGUEUR_MINIMALE = 12
|
LONGUEUR_MINIMALE = 12
|
||||||
|
CHEMIN_CONTRAT = Path(__file__).resolve().parent.parent / "openapi.json"
|
||||||
|
|
||||||
|
|
||||||
async def create_admin(
|
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:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(prog="python -m app.cli", description="Outils EnerVision")
|
parser = argparse.ArgumentParser(prog="python -m app.cli", description="Outils EnerVision")
|
||||||
sous_commandes = parser.add_subparsers(dest="commande", required=True)
|
sous_commandes = parser.add_subparsers(dest="commande", required=True)
|
||||||
@@ -67,6 +103,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
admin.add_argument(
|
admin.add_argument(
|
||||||
"--force", action="store_true", help="Crée le compte même si un administrateur existe"
|
"--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
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@@ -86,6 +127,11 @@ def read_password(*, generate: bool) -> str:
|
|||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
arguments = build_parser().parse_args(argv)
|
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)
|
mot_de_passe = read_password(generate=arguments.generate)
|
||||||
|
|
||||||
succes, message = asyncio.run(
|
succes, message = asyncio.run(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Environment = Literal["local", "dev", "staging", "prod"]
|
|||||||
SameSite = Literal["lax", "strict", "none"]
|
SameSite = Literal["lax", "strict", "none"]
|
||||||
|
|
||||||
SECRET_KEY_MIN_LENGTH = 32
|
SECRET_KEY_MIN_LENGTH = 32
|
||||||
|
REFRESH_COOKIE_DEFAUT = "ev_refresh"
|
||||||
SENTINELLES_INTERDITES = frozenset(
|
SENTINELLES_INTERDITES = frozenset(
|
||||||
{"change_me", "changeme", "secret", "secret-de-test", "changez-moi", "todo"}
|
{"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)
|
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_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_path: str = "/api/v1/auth"
|
||||||
cookie_samesite: SameSite = "strict"
|
cookie_samesite: SameSite = "strict"
|
||||||
cookie_secure: bool | None = None
|
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.errors import register_error_handlers
|
||||||
from app.api.middleware import SecurityHeadersMiddleware
|
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.security import require_metrics_token
|
||||||
from app.api.v1.router import api_router
|
from app.api.v1.router import api_router
|
||||||
from app.core.config import Settings, get_settings
|
from app.core.config import Settings, get_settings
|
||||||
@@ -37,6 +38,9 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
application = FastAPI(
|
application = FastAPI(
|
||||||
title=resolved.name,
|
title=resolved.name,
|
||||||
version=resolved.version,
|
version=resolved.version,
|
||||||
|
summary=SUMMARY,
|
||||||
|
description=DESCRIPTION,
|
||||||
|
openapi_tags=TAGS,
|
||||||
debug=resolved.debug,
|
debug=resolved.debug,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
docs_url="/docs" if documentee else None,
|
docs_url="/docs" if documentee else None,
|
||||||
|
|||||||
@@ -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
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,119 @@
|
|||||||
|
# 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}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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}"
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app import cli
|
from app import cli
|
||||||
@@ -55,3 +58,52 @@ def test_read_password_refuses_two_different_entries(monkeypatch: pytest.MonkeyP
|
|||||||
|
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SystemExit):
|
||||||
cli.read_password(generate=False)
|
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
|
||||||
|
|||||||
@@ -126,24 +126,27 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke
|
|||||||
|
|
||||||
## Routes exposées
|
## 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/live` | Le processus répond. Ne touche pas la base | 500 |
|
||||||
| GET | `/api/v1/health/ready` | oui | La base répond **et** l'extension TimescaleDB est chargée |
|
| GET | `/api/v1/health/ready` | La base répond **et** l'extension TimescaleDB est chargée | 503, 500 |
|
||||||
| POST | `/api/v1/auth/login` | oui | Ouvre une session. Publique |
|
| POST | `/api/v1/auth/login` | Ouvre une session. Publique | 401, 422, 429, 500 |
|
||||||
| POST | `/api/v1/auth/refresh` | oui | Fait tourner la session. Cookie seulement |
|
| POST | `/api/v1/auth/refresh` | Fait tourner la session. Cookie seulement | 401, 403, 500 |
|
||||||
| POST | `/api/v1/auth/logout` | oui | Ferme la session courante. Idempotente |
|
| POST | `/api/v1/auth/logout` | Ferme la session courante. Idempotente | 403, 500 |
|
||||||
| POST | `/api/v1/auth/logout-all` | oui | Ferme toutes les sessions du compte |
|
| POST | `/api/v1/auth/logout-all` | Ferme toutes les sessions du compte | 401, 403, 500 |
|
||||||
| POST | `/api/v1/auth/password` | oui | Change son propre mot de passe |
|
| POST | `/api/v1/auth/password` | Change son propre mot de passe | 401, 403, 422, 500 |
|
||||||
| GET | `/api/v1/auth/me` | oui | Décrit le compte connecté |
|
| GET | `/api/v1/auth/me` | Décrit le compte connecté | 401, 500 |
|
||||||
| GET | `/api/v1/users` | oui | Liste les comptes. `admin` |
|
| GET | `/api/v1/users` | Liste les comptes. `admin` | 401, 403, 500 |
|
||||||
| POST | `/api/v1/users` | oui | Crée un compte, rend un mot de passe provisoire. `admin` |
|
| 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}` | oui | Change le rôle ou l'activation. `admin` |
|
| 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` | oui | Réinitialise et ferme les sessions. `admin` |
|
| POST | `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions. `admin` | 401, 403, 404, 422, 500 |
|
||||||
| GET | `/api/v1/sites` | oui | Liste les sites. `lecteur` |
|
| GET | `/api/v1/sites` | Liste les sites. `lecteur` | 401, 403, 500 |
|
||||||
| GET | `/api/v1/sites/{site_id}` | oui | Décrit un site. `lecteur` |
|
| GET | `/api/v1/sites/{site_id}` | Décrit un site. `lecteur` | 401, 403, 404, 422, 500 |
|
||||||
| GET | `/metrics` | non | Format Prometheus. Jeton requis si `APP_METRICS_TOKEN` est posé |
|
| GET | `/metrics` | Format Prometheus, hors du schéma. Jeton requis si `APP_METRICS_TOKEN` est posé | |
|
||||||
| GET | `/docs`, `/redoc`, `/openapi.json` | non | Fermés en `staging` et en `prod` |
|
| 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`.
|
**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
|
`tests/api/test_route_protection.py` interroge réellement chaque autre route sans identifiant et
|
||||||
@@ -191,6 +194,61 @@ sequenceDiagram
|
|||||||
end
|
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** (`sites` est le gabarit, `reading`/`dataset`/`prediction`/`alert`/
|
||||||
|
`recommendation` suivront) :
|
||||||
|
|
||||||
|
1. Composer ses `responses=` depuis `app/api/openapi.py` : `REPONSES_LECTEUR` ou `REPONSES_ADMIN`
|
||||||
|
au niveau de l'`include_router` dans `app/api/v1/router.py` (401 et le 403 propre au rôle),
|
||||||
|
`REPONSE_VALIDATION` et les codes locaux (404, 409, ...) sur l'endpoint lui-même s'il a un
|
||||||
|
corps, un paramètre ou peut échouer par identifiant.
|
||||||
|
2. Décrire son tag dans `TAGS` (`app/api/openapi.py`).
|
||||||
|
3. Si elle passe par `require_role`, l'ajouter à `ROUTES_A_ROLE`
|
||||||
|
(`tests/api/test_openapi.py`) ; si elle passe par `require_trusted_origin`, l'ajouter à
|
||||||
|
`ORIGINE_VERIFIEE`. Ces deux listes sont maintenues à la main, pas déduites automatiquement du
|
||||||
|
code : une route protégée qui n'y figure pas ne sera pas détectée par les tests.
|
||||||
|
4. `make openapi`, puis `pytest tests/api/test_openapi.py`.
|
||||||
|
|
||||||
## Sécurité
|
## Sécurité
|
||||||
|
|
||||||
Voir la vue consolidée dans [00-vue-ensemble.md](00-vue-ensemble.md) et les décisions dans les
|
Voir la vue consolidée dans [00-vue-ensemble.md](00-vue-ensemble.md) et les décisions dans les
|
||||||
|
|||||||
@@ -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` |
|
| 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` |
|
| 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
|
## 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 |
|
| `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: "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` 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 |
|
| `422` | corps invalide | le détail donne `champ` et `type`, jamais la valeur envoyée |
|
||||||
|
|
||||||
## Les quatre règles qui comptent
|
## 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
|
dans l'[ADR 0001](../adr/0001-postgresql-timescaledb.md), qui fait foi. Ce document décrit le
|
||||||
système qui en découle.
|
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
|
**Dix tables applicatives existent** : quatre pour l'authentification, six pour les données
|
||||||
contient qu'un commentaire, l'unique révision Alembic ne crée aucune table, et aucune hypertable
|
d'énergie, dont l'hypertable `reading`. Les sections marquées `Fait` relèvent le code. Celles
|
||||||
n'a été déclarée. Tout ce qui suit sous le statut `Cible` est une proposition de structure, pas un
|
marquées `Cible` décrivent ce qui n'est pas écrit, au premier rang desquelles la chaîne
|
||||||
relevé du code. Le modèle sera arrêté au jalon J2.
|
d'ingestion, les agrégats continus, la compression et la rétention.
|
||||||
|
|
||||||
## Trois emplacements, trois rôles
|
## Trois emplacements, trois rôles
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ Statut : `Fait`.
|
|||||||
- `db/init/100-extensions.sql` crée l'extension `timescaledb`.
|
- `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
|
- `db/init/110-test-database.sql` crée `enervision_test`, dont le nom est attendu en dur par
|
||||||
`apps/backend/tests/conftest.py`.
|
`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 :
|
établit `alembic_version` et refuse de s'appliquer si l'extension manque :
|
||||||
|
|
||||||
```sql
|
```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.
|
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`,
|
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
|
## 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
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
src["Source de mesures"] -.-> ing["Ingestion Airflow"]
|
src["Source de mesures"] -.-> ing["Ingestion Airflow"]
|
||||||
ing -.-> hy[("Hypertable mesure")]
|
ing -.-> hy[("Hypertable reading")]
|
||||||
hy -.-> agg[("Agrégat continu")]
|
hy -.-> agg[("Agrégat continu")]
|
||||||
hy -.-> comp["Compression"]
|
hy -.-> comp["Compression"]
|
||||||
hy -.-> ret["Rétention"]
|
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
|
refuseraient. `login_attempt`, à l'inverse, est faite pour se purger, puisque son volume est
|
||||||
piloté par l'attaquant.
|
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
|
## 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
|
```python
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.create_table(
|
op.create_table(
|
||||||
"mesure",
|
"reading",
|
||||||
sa.Column("horodatage", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("reading_id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
sa.Column("point_id", sa.Integer(), sa.ForeignKey("point_de_mesure.id"), nullable=False),
|
sa.Column("site_id", sa.Text(), nullable=False),
|
||||||
sa.Column("valeur", sa.Float(), nullable=False),
|
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.PrimaryKeyConstraint("horodatage", "point_id"),
|
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:
|
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.
|
`drop_table` suffit au retour arrière : supprimer la table supprime l'hypertable et ses partitions.
|
||||||
|
|
||||||
## Conventions
|
## 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
|
- **Toute colonne de temps en `timestamptz`.** Jamais de `timestamp` nu : une mesure sans fuseau
|
||||||
devient ininterprétable dès le premier changement d'heure.
|
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 :
|
- **Les politiques de rétention et de compression** vont dans `db/migrations/`, pas dans Alembic :
|
||||||
elles ne découlent pas du schéma applicatif.
|
elles ne découlent pas du schéma applicatif.
|
||||||
- **Tout modèle doit être importé dans `app/models/__init__.py`**, sans quoi
|
- **Tout modèle doit être importé dans `app/models/__init__.py`**, sans quoi
|
||||||
@@ -201,13 +182,12 @@ def downgrade() -> None:
|
|||||||
|
|
||||||
## Questions ouvertes
|
## 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.
|
- **Quelle granularité** à l'ingestion : la seconde, la minute, le quart d'heure.
|
||||||
- **Quels agrégats continus**, et sur quelles fenêtres.
|
- **Quels agrégats continus**, et sur quelles fenêtres.
|
||||||
- **Quelle profondeur de rétention** en données brutes, et à partir de quand on compresse.
|
- **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.
|
- **Multi-tenant ou non** : un site appartient-il à un client, et faut-il cloisonner les lectures.
|
||||||
|
|
||||||
## Modélisation détaillée des données
|
## Modélisation détaillée des données
|
||||||
@@ -220,12 +200,11 @@ jusqu’aux recommandations proposées à l’utilisateur.
|
|||||||
### Schéma de données
|
### Schéma de données
|
||||||
|
|
||||||
Le diagramme ci-dessous présente les tables et leurs relations.
|
Le diagramme ci-dessous présente les tables et leurs relations.
|
||||||
Il décrit une structure de conception ; les migrations correspondantes
|
La révision `e6d2026091501` les crée.
|
||||||
restent à implémenter.
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
*Figure — Modélisation des données EnerVision.*
|
*Figure : Modélisation des données EnerVision.*
|
||||||
|
|
||||||
### Description des tables
|
### Description des tables
|
||||||
|
|
||||||
@@ -234,15 +213,15 @@ des données.
|
|||||||
|
|
||||||
| Table | Rôle | Origine des informations |
|
| 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 |
|
| `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 |
|
||||||
| `sites` | Regrouper les informations des sites : identifiant, nom, type et caractéristiques disponibles | CSV et API Mock `/api/v1/sites` |
|
| `site` | 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` |
|
| `reading` | 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 |
|
| `prediction` | 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 |
|
| `alert` | 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 |
|
| `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
|
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.
|
et ne sont pas considérées comme des alertes actuelles.
|
||||||
|
|
||||||
### Relations entre les tables
|
### 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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [40-data.md](40-data.md) | Frontières `db/` et `alembic/`, cycle de vie d'une mesure, modèle |
|
||||||
|
|||||||
Reference in New Issue
Block a user