groupe-meditation/backend/app/routers/litterature.py
Daniel Allaire 6677b0f57b Ajouter historique métier et protection concurrente
Ajoute modifie_le aux objets sensibles et fait transmettre la version affichée par le frontend pour refuser les écritures périmées avec un message HTTP 409.

Ajoute une table historique_modifications et un service d'historique métier afin de journaliser automatiquement les raisons des changements importants sans intervention de l'utilisateur.

Expose l'historique métier dans le module Journal, distinct du journal technique, et documente les processus utilisateurs ainsi qu'une présentation sobre aux membres.
2026-05-31 10:59:41 -04:00

154 lines
5.6 KiB
Python

"""Routes C4 / M5 — Littérature : inventaire, ajustement, gestion catalogue."""
from fastapi import APIRouter, Depends, Header, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.core.security import get_current_membre, is_sysadmin
from app.core.roles import membre_a_module
from app.models.litterature import Litterature
from app.models.affectation import Affectation
from app.models.poste import Poste
from app.schemas.phase6 import LitteratureCreate, LitteratureOut, LitteratureUpdate, AjustementStock
from app.services.historique import enregistrer_historique, verifier_version_historisee
router = APIRouter(prefix="/litterature", tags=["C4/M5 — Littérature"])
async def require_resp_litterature(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
"""Vérifie que le membre est responsable de la littérature."""
if is_sysadmin(membre) or await membre_a_module(membre.id, "M5", db):
return membre
result = await db.execute(
select(Affectation).join(Poste).where(
Affectation.membre_id == membre.id,
Affectation.statut == "actif",
Poste.nom.ilike("%littérature%"),
)
)
if not result.scalars().first():
raise HTTPException(status_code=403, detail="Réservé au responsable de la littérature")
return membre
def _to_out(item: Litterature) -> LitteratureOut:
return LitteratureOut(
id=item.id,
titre=item.titre,
categorie=item.categorie,
prix_coutant=float(item.prix_coutant) if item.prix_coutant else None,
quantite_stock=item.quantite_stock,
seuil_commande=item.seuil_commande,
code_bsg=item.code_bsg,
stock_bas=item.quantite_stock <= item.seuil_commande,
modifie_le=item.modifie_le,
)
@router.get("/", response_model=list[LitteratureOut])
async def inventaire(
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""C4 — Inventaire de la littérature du groupe avec prix."""
result = await db.execute(
select(Litterature)
.where(Litterature.groupe_id == membre.groupe_id)
.order_by(Litterature.categorie, Litterature.titre)
)
return [_to_out(item) for item in result.scalars().all()]
@router.post("/", response_model=LitteratureOut)
async def ajouter_titre(
req: LitteratureCreate,
membre=Depends(require_resp_litterature),
db: AsyncSession = Depends(get_db),
):
"""Ajouter un titre au catalogue du groupe."""
categories_valides = ["livre", "brochure", "depliant", "pamphlet"]
if req.categorie not in categories_valides:
raise HTTPException(status_code=400, detail=f"Catégorie invalide. Valeurs: {categories_valides}")
item = Litterature(
groupe_id=membre.groupe_id,
titre=req.titre,
categorie=req.categorie,
prix_coutant=req.prix_coutant,
quantite_stock=req.quantite_stock,
seuil_commande=req.seuil_commande,
code_bsg=req.code_bsg,
)
db.add(item)
await db.flush()
return _to_out(item)
@router.patch("/{item_id}", response_model=LitteratureOut)
async def modifier_titre(
item_id: str,
req: LitteratureUpdate,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_resp_litterature),
db: AsyncSession = Depends(get_db),
):
"""Modifier les infos d'un titre."""
result = await db.execute(
select(Litterature).where(Litterature.id == item_id, Litterature.groupe_id == membre.groupe_id)
)
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Titre introuvable")
await verifier_version_historisee(db, item, version_attendue, table_cible="litterature")
for field, value in req.model_dump(exclude_none=True).items():
setattr(item, field, value)
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="litterature",
objet_id=item.id,
action="litterature.modifiee",
raison="Titre de littérature modifié.",
membre_id=membre.id,
)
await db.flush()
return _to_out(item)
@router.patch("/{item_id}/stock", response_model=LitteratureOut)
async def ajuster_stock(
item_id: str,
req: AjustementStock,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(require_resp_litterature),
db: AsyncSession = Depends(get_db),
):
"""M5 — Ajuster le stock après réception d'une commande ou correction."""
result = await db.execute(
select(Litterature).where(Litterature.id == item_id, Litterature.groupe_id == membre.groupe_id)
)
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Titre introuvable")
await verifier_version_historisee(db, item, version_attendue, table_cible="litterature")
nouveau_stock = item.quantite_stock + req.quantite
if nouveau_stock < 0:
raise HTTPException(status_code=400, detail="Le stock ne peut pas être négatif")
ancien_stock = item.quantite_stock
item.quantite_stock = nouveau_stock
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="litterature",
objet_id=item.id,
action="litterature.stock_ajuste",
raison=f"Stock de littérature ajusté de {req.quantite}.",
membre_id=membre.id,
avant={"quantite_stock": ancien_stock},
apres={"quantite_stock": nouveau_stock},
)
await db.flush()
return _to_out(item)