282 lines
12 KiB
Python
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]
|