groupe-meditation/backend/app/routers/propositions.py

282 lines
12 KiB
Python

"""Routes — Propositions et résolutions (réunions d'affaires)."""
from datetime import date, timezone, datetime
from fastapi import APIRouter, Depends, Header, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, extract
from app.core.database import get_db
from app.core.decisions import prochain_numero_decision
from app.core.decision_types import type_decision_depuis_proposition
from app.core.ownership import ensure_owned_or_404
from app.core.security import get_current_membre, is_sysadmin, require_executif
from app.core.roles import membre_a_poste_ou_executif
from app.models.proposition import Proposition
from app.models.reunion import Reunion
from app.models.membre import Membre
from app.models.candidature import Candidature
from app.models.affectation import Affectation
from app.models.poste import Poste
from app.schemas.propositions import PropositionCreate, PropositionOut, PropositionVote
from app.services.historique import enregistrer_historique, snapshot, verifier_version_historisee
router = APIRouter(prefix="/propositions", tags=["Propositions et résolutions"])
async def _to_out(p: Proposition, db: AsyncSession) -> PropositionOut:
proposeur = await db.get(Membre, p.proposeur_id)
secondeur = await db.get(Membre, p.secondeur_id) if p.secondeur_id else None
reunion = await db.get(Reunion, p.reunion_id) if p.reunion_id else None
return PropositionOut(
id=p.id,
reunion_id=p.reunion_id,
type_decision=type_decision_depuis_proposition(p),
sujet=p.sujet,
description=p.description,
proposeur_prenom=proposeur.prenom if proposeur else "?",
secondeur_prenom=secondeur.prenom if secondeur else None,
statut=p.statut,
numero_resolution=p.numero_resolution,
date_vote=p.date_vote,
date_adoption=p.date_adoption,
etant_donne_que=p.etant_donne_que,
groupe_a_decide_de=p.groupe_a_decide_de,
note=p.note,
candidature_id=p.candidature_id,
cree_le=p.cree_le,
modifie_le=p.modifie_le,
date_reunion=reunion.date_reunion if reunion else None,
)
@router.post("/", response_model=PropositionOut)
async def creer_proposition(
req: PropositionCreate,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Soumettre une proposition — peut être créée en tout temps."""
if req.reunion_id:
result = await db.execute(
select(Reunion).where(
Reunion.id == req.reunion_id,
Reunion.groupe_id == membre.groupe_id,
)
)
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Réunion introuvable")
prop = Proposition(
groupe_id=membre.groupe_id,
reunion_id=req.reunion_id,
sujet=req.sujet.strip(),
description=req.description,
proposeur_id=membre.id,
statut="proposee",
)
db.add(prop)
await db.flush()
return await _to_out(prop, db)
@router.post("/{proposition_id}/seconder", response_model=PropositionOut)
async def seconder_proposition(
proposition_id: str,
membre_id: str | None = None,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Seconder une proposition pour la rendre recevable."""
result = await db.execute(
select(Proposition).where(
Proposition.id == proposition_id,
Proposition.groupe_id == membre.groupe_id,
).with_for_update()
)
prop = result.scalar_one_or_none()
if not prop:
raise HTTPException(status_code=404, detail="Proposition introuvable")
await verifier_version_historisee(db, prop, version_attendue, table_cible="propositions")
if prop.statut != "proposee":
raise HTTPException(status_code=400, detail="Seule une proposition en attente peut être secondée")
secondeur = membre
if membre_id and str(membre_id) != str(membre.id):
if not await membre_a_poste_ou_executif(membre.id, "executif", db):
raise HTTPException(status_code=403, detail="Réservé aux exécutifs")
secondeur = await db.get(Membre, membre_id)
if not secondeur or secondeur.groupe_id != membre.groupe_id or not secondeur.actif:
raise HTTPException(status_code=404, detail="Membre introuvable ou inactif")
if prop.proposeur_id == secondeur.id and not is_sysadmin(membre):
raise HTTPException(status_code=400, detail="On ne peut pas seconder sa propre proposition")
avant = snapshot(prop, "statut", "secondeur_id")
prop.secondeur_id = secondeur.id
prop.statut = "secondee"
await enregistrer_historique(
db,
groupe_id=membre.groupe_id,
table_cible="propositions",
objet_id=prop.id,
action="proposition.secondee",
raison=f"Proposition secondée par {secondeur.prenom}.",
membre_id=membre.id,
avant=avant,
apres=snapshot(prop, "statut", "secondeur_id"),
)
await db.flush()
return await _to_out(prop, db)
@router.patch("/{proposition_id}/voter", response_model=PropositionOut)
async def voter_proposition(
proposition_id: str,
req: PropositionVote,
version_attendue: str | None = Header(default=None, alias="X-Resource-Version"),
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Enregistrer le résultat du vote sur une proposition secondée (exécutif)."""
if req.statut not in ("adoptee", "rejetee"):
raise HTTPException(status_code=400, detail="Le statut doit être 'adoptee' ou 'rejetee'")
result = await db.execute(
select(Proposition).where(
Proposition.id == proposition_id,
Proposition.groupe_id == executif.groupe_id,
).with_for_update()
)
prop = result.scalar_one_or_none()
if not prop:
raise HTTPException(status_code=404, detail="Proposition introuvable")
await verifier_version_historisee(db, prop, version_attendue, table_cible="propositions")
if req.statut == "adoptee" and prop.statut != "secondee":
raise HTTPException(status_code=400, detail="Seule une proposition secondée peut être transformée en résolution")
if req.statut == "rejetee" and prop.statut not in ("proposee", "secondee"):
raise HTTPException(status_code=400, detail="Seule une proposition en cours peut être rejetée")
avant = snapshot(prop, "statut", "numero_resolution", "date_vote", "date_adoption", "etant_donne_que", "groupe_a_decide_de", "note")
# Attribuer un numéro de résolution si adoptée
if req.statut == "adoptee":
etant_donne_que = (req.etant_donne_que or "").strip()
groupe_a_decide_de = (req.groupe_a_decide_de or "").strip()
if not etant_donne_que or not groupe_a_decide_de:
raise HTTPException(
status_code=400,
detail='Les sections "ÉTANT DONNÉ QUE" et "LE GROUPE A DÉCIDÉ DE" sont obligatoires',
)
prop.etant_donne_que = etant_donne_que
prop.groupe_a_decide_de = groupe_a_decide_de
prop.note = None
type_decision = type_decision_depuis_proposition(prop)
if type_decision == "proposition":
type_decision = "resolution"
prop.numero_resolution = await prochain_numero_decision(
executif.groupe_id, type_decision, date.today().year, db
)
if req.reunion_id and not prop.reunion_id:
await ensure_owned_or_404(db, Reunion, req.reunion_id, executif.groupe_id, "Réunion introuvable")
prop.reunion_id = req.reunion_id
# ── NOMINATION : créer l'affectation automatiquement ──
if type_decision == "nomination" and prop.candidature_id:
cand = await db.get(Candidature, prop.candidature_id)
if cand and cand.groupe_id == executif.groupe_id and cand.statut == "proposee":
membre_cible = await db.get(Membre, cand.membre_id)
poste = await db.get(Poste, cand.poste_id)
if not membre_cible or membre_cible.groupe_id != executif.groupe_id:
raise HTTPException(status_code=404, detail="Membre de nomination introuvable")
if not poste or poste.groupe_id != executif.groupe_id:
raise HTTPException(status_code=404, detail="Poste de nomination introuvable")
cand.statut = "acceptee"
cand.proposition_id = prop.id
affectation = Affectation(
groupe_id=executif.groupe_id,
membre_id=cand.membre_id,
poste_id=cand.poste_id,
date_debut=date.today(),
statut="actif",
)
db.add(affectation)
elif req.statut == "rejetee":
etant_donne_que = (req.etant_donne_que or "").strip()
if not etant_donne_que:
raise HTTPException(status_code=400, detail='La section "ÉTANT DONNÉ QUE" est obligatoire pour rejeter une proposition')
prop.etant_donne_que = etant_donne_que
prop.groupe_a_decide_de = None
prop.note = (req.note or "").strip() or None
# Si nomination rejetée, mettre la candidature en refusée
if type_decision_depuis_proposition(prop) == "nomination" and prop.candidature_id:
cand = await db.get(Candidature, prop.candidature_id)
if cand and cand.groupe_id == executif.groupe_id:
cand.statut = "refusee"
prop.statut = req.statut
prop.date_vote = date.today()
if req.statut == "adoptee":
prop.date_adoption = prop.date_vote
await enregistrer_historique(
db,
groupe_id=executif.groupe_id,
table_cible="propositions",
objet_id=prop.id,
action="proposition.adoptee" if req.statut == "adoptee" else "proposition.rejetee",
raison="Proposition transformée en décision du groupe." if req.statut == "adoptee" else "Proposition rejetée par le groupe.",
membre_id=executif.id,
avant=avant,
apres=snapshot(prop, "statut", "numero_resolution", "date_vote", "date_adoption", "etant_donne_que", "groupe_a_decide_de", "note"),
)
await db.flush()
return await _to_out(prop, db)
@router.get("/", response_model=list[PropositionOut])
async def liste_propositions(
statut: str | None = None,
reunion_id: str | None = None,
type_decision: str | None = None,
limite: int = 50,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Lister les propositions du groupe avec filtres optionnels."""
query = select(Proposition).where(Proposition.groupe_id == membre.groupe_id)
if statut:
query = query.where(Proposition.statut == statut)
if reunion_id:
query = query.where(Proposition.reunion_id == reunion_id)
query = query.order_by(desc(Proposition.cree_le)).limit(limite)
result = await db.execute(query)
propositions = result.scalars().all()
if type_decision:
propositions = [p for p in propositions if type_decision_depuis_proposition(p) == type_decision]
return [await _to_out(p, db) for p in propositions]
@router.get("/resolutions", response_model=list[PropositionOut])
async def registre_resolutions(
annee: int | None = None,
type_decision: str | None = None,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Registre des décisions adoptées ou rejetées."""
query = select(Proposition).where(
Proposition.groupe_id == membre.groupe_id,
Proposition.statut.in_(["adoptee", "rejetee"]),
)
if annee:
query = query.where(extract("year", Proposition.cree_le) == annee)
query = query.order_by(Proposition.date_vote, Proposition.cree_le, Proposition.numero_resolution)
result = await db.execute(query)
propositions = result.scalars().all()
if type_decision:
propositions = [p for p in propositions if type_decision_depuis_proposition(p) == type_decision]
return [await _to_out(p, db) for p in propositions]