Merge branch 'dev' into feat/openapi-contrat

Complète le contrat OpenAPI de GET /sites et GET /sites/{site_id} (merges depuis dev via #78) :
tag sites décrit, REPONSES_LECTEUR (401 + 403 mot de passe provisoire) posée au niveau du
routeur, 404 et 422 documentés sur la route detail. openapi.json régénéré.
This commit is contained in:
Johan LEROY
2026-09-16 12:04:32 +02:00
18 changed files with 664 additions and 78 deletions
+9
View File
@@ -24,8 +24,10 @@ from app.db.session import get_session
from app.repositories.audit_log import AuditLogRepository
from app.repositories.login_attempt import LoginAttemptRepository
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.user import UserService
SessionDep = Annotated[AsyncSession, Depends(get_session)]
@@ -131,6 +133,13 @@ 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)]
async def get_current_principal(
credentials: CredentialsDep,
session: SessionDep,
+16
View File
@@ -50,6 +50,10 @@ TAGS: Final[list[dict[str, Any]]] = [
"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(
@@ -113,6 +117,18 @@ REPONSES_ADMIN: Final[Reponses] = {
},
}
# `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,
+3 -1
View File
@@ -27,7 +27,9 @@ async def liveness(settings: SettingsDep) -> LivenessStatus:
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)
+3 -2
View File
@@ -1,9 +1,10 @@
from fastapi import APIRouter
from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN
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, users
api_router = APIRouter(responses=REPONSE_SERVEUR)
api_router.include_router(health.router, prefix="/health", tags=["health"])
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(users.router, prefix="/users", tags=["users"], responses=REPONSES_ADMIN)
api_router.include_router(sites.router, prefix="/sites", tags=["sites"], responses=REPONSES_LECTEUR)
+20
View File
@@ -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
+12
View File
@@ -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
+26
View File
@@ -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