Fusionne dev dans feat/stats-summary

Resout les conflits de deps.py, router.py, repositories/site.py et
test_site.py entre l'ajout de stats et le merge de sites/openapi-contrat
sur dev. Generalise ROUTES_A_ROLE dans test_openapi.py et documente la
checklist d'ajout d'une route metier, absentes de dev au moment du fork.
This commit is contained in:
Johan LEROY
2026-09-16 13:37:03 +02:00
32 changed files with 2544 additions and 67 deletions
+71 -4
View File
@@ -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,
+7 -4
View File
@@ -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)
+32 -1
View File
@@ -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
+6 -4
View File
@@ -1,9 +1,11 @@
from fastapi import APIRouter
from app.api.v1.endpoints import auth, health, stats, 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(stats.router, prefix="/stats", tags=["stats"])
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)