Refond les pages Rencontres et Assemblées autour de tuiles ouvrables avec actions locales, PDF serveur, ventes, collectes, présences, décisions, postes et contributions. Ajoute les invitations publiques d'assemblée en lecture seule avec lien expirant, l'adoption formelle du rapport du trésorier avec proposeur/secondeur, et bloque les contributions tant que ce rapport n'est pas adopté. Uniformise la navigation et les entêtes, retire les modules redondants des contextes, ajoute la création de comptes membres par exécutif et les données démo reliées aux nouveaux processus. Ce commit capture un lot fonctionnel important; une refactorisation ciblée du module Assemblées, puis Trésorerie/Gouvernance, est maintenant pertinente pour isoler les règles métier et faciliter les tests.
244 lines
9.7 KiB
Python
244 lines
9.7 KiB
Python
"""Routes — Propositions et résolutions (réunions d'affaires)."""
|
|
from datetime import date, timezone, datetime
|
|
from fastapi import APIRouter, Depends, 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.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
|
|
|
|
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,
|
|
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,
|
|
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,
|
|
)
|
|
)
|
|
prop = result.scalar_one_or_none()
|
|
if not prop:
|
|
raise HTTPException(status_code=404, detail="Proposition introuvable")
|
|
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")
|
|
|
|
prop.secondeur_id = secondeur.id
|
|
prop.statut = "secondee"
|
|
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,
|
|
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,
|
|
)
|
|
)
|
|
prop = result.scalar_one_or_none()
|
|
if not prop:
|
|
raise HTTPException(status_code=404, detail="Proposition introuvable")
|
|
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")
|
|
|
|
# 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:
|
|
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.statut == "proposee":
|
|
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:
|
|
cand.statut = "refusee"
|
|
|
|
prop.statut = req.statut
|
|
prop.date_vote = date.today()
|
|
if req.statut == "adoptee":
|
|
prop.date_adoption = prop.date_vote
|
|
|
|
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]
|