groupe-meditation/backend/app/routers/postes.py
Daniel Allaire 69309acd13 Intégrer rencontres et assemblées pilotées par tuiles
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.
2026-05-31 09:02:02 -04:00

738 lines
25 KiB
Python

"""Routes postes, affectations, candidatures et accès aux modules."""
from datetime import date
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.core.decisions import prochain_numero_decision
from app.core.security import get_current_membre, require_executif
from app.models.poste import Poste
from app.models.affectation import Affectation
from app.models.membre import Membre
from app.models.candidature import Candidature
from app.models.poste_module import PosteModule
from app.schemas.schemas import PosteOut, PosteCreate, PosteUpdate, AffectationCreate, TerminerMandatRequest
router = APIRouter(prefix="/postes", tags=["Postes et affectations"])
# ── Postes ──
@router.get("/", response_model=list[PosteOut])
async def liste_postes(
inclure_abolis: bool = False,
db: AsyncSession = Depends(get_db),
_=Depends(get_current_membre),
):
query = select(Poste)
if not inclure_abolis:
query = query.where(Poste.actif == True)
result = await db.execute(query.order_by(Poste.ordre_affichage))
postes = result.scalars().all()
return [PosteOut.model_validate(p) for p in postes]
@router.post("/", response_model=PosteOut)
async def creer_poste(
req: PosteCreate,
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Créer un nouveau poste (exécutif)."""
if req.categorie not in ("executif", "service", "physique"):
raise HTTPException(status_code=400, detail="Catégorie invalide")
poste = Poste(
nom=req.nom.strip(),
categorie=req.categorie,
description=req.description,
prerequis=req.prerequis,
informations=req.informations,
ordre_affichage=req.ordre_affichage or 99,
)
db.add(poste)
await db.flush()
return PosteOut.model_validate(poste)
@router.patch("/{poste_id}", response_model=PosteOut)
async def modifier_poste(
poste_id: str,
req: PosteUpdate,
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
poste = await db.get(Poste, poste_id)
if not poste:
raise HTTPException(status_code=404, detail="Poste introuvable")
if req.categorie is not None and req.categorie not in ("executif", "service", "physique"):
raise HTTPException(status_code=400, detail="Catégorie invalide")
for attr in ["nom", "categorie", "description", "prerequis", "informations", "ordre_affichage"]:
value = getattr(req, attr)
if value is not None:
setattr(poste, attr, value.strip() if isinstance(value, str) else value)
if not poste.nom:
raise HTTPException(status_code=400, detail="Nom requis")
await db.flush()
return PosteOut.model_validate(poste)
@router.delete("/{poste_id}")
async def abolir_poste(
poste_id: str,
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Abolir un poste — termine tous les mandats actifs associés."""
poste = await db.get(Poste, poste_id)
if not poste:
raise HTTPException(status_code=404, detail="Poste introuvable")
# Terminer les mandats actifs
result = await db.execute(
select(Affectation).where(
Affectation.poste_id == poste.id,
Affectation.statut == "actif",
)
)
mandats_termines = 0
for a in result.scalars().all():
a.statut = "termine"
a.date_fin = date.today()
a.note_transition = f"Poste aboli: {poste.nom}"
a.termine_par = executif.id
mandats_termines += 1
# Refuser les candidatures en attente
result = await db.execute(
select(Candidature).where(
Candidature.poste_id == poste.id,
Candidature.statut == "proposee",
)
)
for c in result.scalars().all():
c.statut = "refusee"
poste.actif = False
await db.flush()
return {
"message": f"Poste «{poste.nom}» aboli",
"mandats_termines": mandats_termines,
}
# ── Postes disponibles (vacants) ──
@router.get("/disponibles")
async def postes_disponibles(
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Postes actifs sans titulaire — opportunités d'implication."""
result = await db.execute(
select(Poste).where(Poste.actif == True).order_by(Poste.ordre_affichage)
)
postes = result.scalars().all()
disponibles = []
for p in postes:
result = await db.execute(
select(Affectation).where(
Affectation.poste_id == p.id,
Affectation.statut == "actif",
Affectation.groupe_id == membre.groupe_id,
)
)
if not result.scalars().first():
# Vérifier les candidatures existantes du membre
result_cand = await db.execute(
select(Candidature).where(
Candidature.poste_id == p.id,
Candidature.membre_id == membre.id,
Candidature.statut == "proposee",
)
)
deja_postule = result_cand.scalars().first() is not None
disponibles.append({
"id": str(p.id),
"nom": p.nom,
"categorie": p.categorie,
"description": p.description,
"prerequis": p.prerequis,
"informations": p.informations,
"deja_postule": deja_postule,
"candidats": await _candidats_poste(p.id, db),
})
return disponibles
async def _candidats_poste(poste_id, db: AsyncSession):
result = await db.execute(
select(Candidature).where(
Candidature.poste_id == poste_id,
Candidature.statut == "proposee",
)
)
candidats = []
for c in result.scalars().all():
membre = await db.get(Membre, c.membre_id)
if membre:
candidats.append({
"id": str(c.id),
"membre_id": str(membre.id),
"prenom": membre.prenom,
"type": c.type,
"motivation": c.motivation,
})
return candidats
# ── Candidatures ──
@router.post("/candidatures")
async def postuler(
poste_id: str,
motivation: str = "",
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Proposer sa candidature — crée automatiquement une proposition de nomination."""
from app.models.proposition import Proposition
poste = await db.get(Poste, poste_id)
if not poste or not poste.actif:
raise HTTPException(status_code=404, detail="Poste introuvable ou aboli")
# Vérifier qu'il n'a pas déjà postulé
result = await db.execute(
select(Candidature).where(
Candidature.poste_id == poste_id,
Candidature.membre_id == membre.id,
Candidature.statut == "proposee",
)
)
if result.scalars().first():
raise HTTPException(status_code=400, detail="Vous avez déjà postulé pour ce poste")
# Créer la candidature
cand = Candidature(
groupe_id=membre.groupe_id,
poste_id=poste_id,
membre_id=membre.id,
type="candidature",
motivation=motivation.strip() or None,
)
db.add(cand)
await db.flush()
# Créer automatiquement la proposition de nomination
prop = Proposition(
groupe_id=membre.groupe_id,
sujet=f"Nomination de {membre.prenom} au poste de {poste.nom}",
description=motivation.strip() or None,
proposeur_id=membre.id,
candidature_id=cand.id,
statut="proposee",
)
db.add(prop)
cand.proposition_id = prop.id
await db.flush()
return {
"message": f"Candidature soumise pour {poste.nom} — proposition créée",
"id": str(cand.id),
"proposition_id": str(prop.id),
}
@router.post("/candidatures/nominer")
async def nominer(
poste_id: str,
membre_id: str,
motivation: str = "",
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Nominer quelqu'un (exécutif). Le nominé devra accepter avant que la proposition soit créée."""
poste = await db.get(Poste, poste_id)
if not poste or not poste.actif:
raise HTTPException(status_code=404, detail="Poste introuvable ou aboli")
nominee = await db.get(Membre, membre_id)
if not nominee or nominee.groupe_id != executif.groupe_id:
raise HTTPException(status_code=404, detail="Membre introuvable")
cand = Candidature(
groupe_id=executif.groupe_id,
poste_id=poste_id,
membre_id=membre_id,
type="nomination",
nomme_par=executif.id,
motivation=motivation.strip() or None,
)
db.add(cand)
await db.flush()
return {
"message": f"{nominee.prenom} nominé(e) pour {poste.nom} — en attente de son acceptation",
"id": str(cand.id),
}
@router.post("/candidatures/proposer")
async def proposer_membre(
poste_id: str,
membre_id: str,
motivation: str = "",
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Proposer un autre membre pour un poste."""
poste = await db.get(Poste, poste_id)
if not poste or not poste.actif:
raise HTTPException(status_code=404, detail="Poste introuvable ou aboli")
cible = await db.get(Membre, membre_id)
if not cible or cible.groupe_id != membre.groupe_id or not cible.actif:
raise HTTPException(status_code=404, detail="Membre introuvable")
if cible.id == membre.id:
raise HTTPException(status_code=400, detail="Utilisez Postuler pour votre propre candidature")
result = await db.execute(
select(Candidature).where(
Candidature.poste_id == poste_id,
Candidature.membre_id == membre_id,
Candidature.statut == "proposee",
)
)
if result.scalars().first():
raise HTTPException(status_code=400, detail="Ce membre est déjà proposé pour ce poste")
cand = Candidature(
groupe_id=membre.groupe_id,
poste_id=poste_id,
membre_id=membre_id,
type="nomination",
nomme_par=membre.id,
motivation=motivation.strip() or None,
)
db.add(cand)
await db.flush()
return {"message": f"{cible.prenom} proposé(e) pour {poste.nom}", "id": str(cand.id)}
@router.post("/candidatures/{candidature_id}/retirer")
async def retirer_candidature(
candidature_id: str,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Retirer sa propre candidature. Retire aussi la proposition associée."""
from app.models.proposition import Proposition
result = await db.execute(
select(Candidature).where(
Candidature.id == candidature_id,
Candidature.membre_id == membre.id,
Candidature.statut == "proposee",
)
)
cand = result.scalar_one_or_none()
if not cand:
raise HTTPException(status_code=404, detail="Candidature introuvable ou déjà traitée")
cand.statut = "retiree"
# Retirer la proposition associée si elle n'est pas encore votée
if cand.proposition_id:
prop = await db.get(Proposition, cand.proposition_id)
if prop and prop.statut in ("proposee", "secondee"):
prop.statut = "rejetee"
prop.note = "Candidature retirée par le membre"
await db.flush()
return {"message": "Candidature retirée"}
@router.post("/candidatures/{candidature_id}/accepter-nomination")
async def accepter_nomination(
candidature_id: str,
accepter: bool = True,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Le nominé accepte/refuse. Si accepté, la proposition est créée automatiquement."""
from app.models.proposition import Proposition
result = await db.execute(
select(Candidature).where(
Candidature.id == candidature_id,
Candidature.membre_id == membre.id,
Candidature.type == "nomination",
Candidature.statut == "proposee",
)
)
cand = result.scalar_one_or_none()
if not cand:
raise HTTPException(status_code=404, detail="Nomination introuvable")
cand.acceptee_par_membre = accepter
if not accepter:
cand.statut = "refusee"
await db.flush()
return {"message": "Nomination refusée"}
# Acceptée → créer la proposition de nomination
poste = await db.get(Poste, cand.poste_id)
nomme_par = await db.get(Membre, cand.nomme_par) if cand.nomme_par else None
prop = Proposition(
groupe_id=cand.groupe_id,
sujet=f"Nomination de {membre.prenom} au poste de {poste.nom if poste else '?'}",
description=f"Nominé par {nomme_par.prenom if nomme_par else '?'}. {cand.motivation or ''}".strip(),
proposeur_id=cand.nomme_par or membre.id,
candidature_id=cand.id,
statut="proposee",
)
db.add(prop)
cand.proposition_id = prop.id
await db.flush()
return {
"message": "Nomination acceptée — proposition créée, en attente d'être secondée",
"proposition_id": str(prop.id),
}
@router.get("/candidatures")
async def liste_candidatures(
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Liste des candidatures du groupe (exécutif)."""
result = await db.execute(
select(Candidature).where(
Candidature.groupe_id == executif.groupe_id,
Candidature.statut == "proposee",
)
)
candidatures = result.scalars().all()
out = []
for c in candidatures:
mbr = await db.get(Membre, c.membre_id)
poste = await db.get(Poste, c.poste_id)
nomme = await db.get(Membre, c.nomme_par) if c.nomme_par else None
out.append({
"id": str(c.id),
"membre_prenom": mbr.prenom if mbr else "?",
"poste_nom": poste.nom if poste else "?",
"type": c.type,
"motivation": c.motivation,
"nomme_par_prenom": nomme.prenom if nomme else None,
"acceptee_par_membre": c.acceptee_par_membre,
"cree_le": str(c.cree_le),
})
return out
@router.get("/candidatures/mes")
async def mes_candidatures(
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Mes candidatures et nominations en attente."""
result = await db.execute(
select(Candidature).where(
Candidature.membre_id == membre.id,
Candidature.statut == "proposee",
)
)
out = []
for c in result.scalars().all():
poste = await db.get(Poste, c.poste_id)
nomme = await db.get(Membre, c.nomme_par) if c.nomme_par else None
out.append({
"id": str(c.id),
"poste_nom": poste.nom if poste else "?",
"type": c.type,
"nomme_par_prenom": nomme.prenom if nomme else None,
"acceptee_par_membre": c.acceptee_par_membre,
})
return out
@router.patch("/candidatures/{candidature_id}")
async def traiter_candidature(
candidature_id: str,
statut: str,
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Accepter ou refuser une candidature (exécutif)."""
if statut not in ("acceptee", "refusee"):
raise HTTPException(status_code=400, detail="Statut: acceptee ou refusee")
result = await db.execute(
select(Candidature).where(
Candidature.id == candidature_id,
Candidature.groupe_id == executif.groupe_id,
)
)
cand = result.scalar_one_or_none()
if not cand:
raise HTTPException(status_code=404, detail="Candidature introuvable")
cand.statut = statut
# Si acceptée, créer l'affectation et la décision de nomination automatiquement.
if statut == "acceptee":
if str(executif.id) == str(cand.membre_id):
raise HTTPException(
status_code=400,
detail="Un second membre est requis pour accepter cette candidature comme nomination",
)
membre_cible = await db.get(Membre, cand.membre_id)
poste = await db.get(Poste, cand.poste_id)
if not membre_cible or not membre_cible.actif or membre_cible.groupe_id != executif.groupe_id:
raise HTTPException(status_code=404, detail="Membre à affecter introuvable ou inactif")
if not poste or not poste.actif:
raise HTTPException(status_code=404, detail="Poste introuvable ou aboli")
result = await db.execute(
select(Affectation).where(
Affectation.groupe_id == executif.groupe_id,
Affectation.poste_id == cand.poste_id,
Affectation.statut == "actif",
)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Ce poste est déjà affecté")
numero_resolution = await prochain_numero_decision(executif.groupe_id, "nomination", date.today().year, db)
resolution = Proposition(
groupe_id=executif.groupe_id,
sujet=f"Nomination de {membre_cible.prenom} au poste de {poste.nom}",
description=cand.motivation,
proposeur_id=executif.id,
secondeur_id=cand.membre_id,
statut="adoptee",
numero_resolution=numero_resolution,
date_vote=date.today(),
date_adoption=date.today(),
groupe_a_decide_de=f"Nommer {membre_cible.prenom} au poste de {poste.nom}.",
candidature_id=cand.id,
)
db.add(resolution)
await db.flush()
cand.proposition_id = resolution.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)
await db.flush()
return {"message": "Candidature " + ("acceptée" if statut == "acceptee" else "refusée")}
# ── Affectations ──
@router.get("/affectations")
async def mes_affectations(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Affectation).join(Poste).where(
Affectation.membre_id == membre.id,
Affectation.statut == "actif",
)
)
out = []
for a in result.scalars().all():
poste = await db.get(Poste, a.poste_id)
out.append({
"id": str(a.id),
"poste_nom": poste.nom if poste else "?",
"poste_categorie": poste.categorie if poste else "?",
"date_debut": str(a.date_debut),
"statut": a.statut,
})
return out
@router.get("/affectations/groupe")
async def affectations_groupe(executif=Depends(require_executif), db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Affectation).where(
Affectation.groupe_id == executif.groupe_id,
Affectation.statut == "actif",
)
)
out = []
for a in result.scalars().all():
poste = await db.get(Poste, a.poste_id)
mbr = await db.get(Membre, a.membre_id)
out.append({
"id": str(a.id),
"membre_id": str(a.membre_id),
"membre_prenom": mbr.prenom if mbr else "?",
"poste_nom": poste.nom if poste else "?",
"poste_categorie": poste.categorie if poste else "?",
"date_debut": str(a.date_debut),
})
return out
@router.post("/affectations")
async def creer_affectation(req: AffectationCreate, executif=Depends(require_executif), db: AsyncSession = Depends(get_db)):
from app.models.proposition import Proposition
if req.proposeur_id == req.secondeur_id:
raise HTTPException(status_code=400, detail="Le proposeur et le secondeur doivent être différents")
cible = await db.get(Membre, req.membre_id)
proposeur = await db.get(Membre, req.proposeur_id)
secondeur = await db.get(Membre, req.secondeur_id)
poste = await db.get(Poste, req.poste_id)
if not cible or cible.groupe_id != executif.groupe_id or not cible.actif:
raise HTTPException(status_code=404, detail="Membre à affecter introuvable ou inactif")
if not proposeur or proposeur.groupe_id != executif.groupe_id or not proposeur.actif:
raise HTTPException(status_code=404, detail="Proposeur introuvable ou inactif")
if not secondeur or secondeur.groupe_id != executif.groupe_id or not secondeur.actif:
raise HTTPException(status_code=404, detail="Secondeur introuvable ou inactif")
if not poste or not poste.actif:
raise HTTPException(status_code=404, detail="Poste introuvable ou aboli")
result = await db.execute(
select(Affectation).where(
Affectation.groupe_id == executif.groupe_id,
Affectation.poste_id == req.poste_id,
Affectation.statut == "actif",
)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Ce poste est déjà affecté")
numero_resolution = await prochain_numero_decision(executif.groupe_id, "nomination", req.date_debut.year, db)
resolution = Proposition(
groupe_id=executif.groupe_id,
sujet=f"Nomination de {cible.prenom} au poste de {poste.nom}",
description=f"Affectation directe au poste de {poste.nom} à partir du {req.date_debut}.",
proposeur_id=req.proposeur_id,
secondeur_id=req.secondeur_id,
statut="adoptee",
numero_resolution=numero_resolution,
date_vote=req.date_debut,
date_adoption=req.date_debut,
groupe_a_decide_de=f"Nommer {cible.prenom} au poste de {poste.nom}.",
)
db.add(resolution)
await db.flush()
affectation = Affectation(
groupe_id=executif.groupe_id,
membre_id=req.membre_id,
poste_id=req.poste_id,
date_debut=req.date_debut,
statut="actif",
)
db.add(affectation)
await db.flush()
return {
"message": "Affectation créée",
"id": str(affectation.id),
"resolution_id": str(resolution.id),
"numero_resolution": resolution.numero_resolution,
}
@router.post("/affectations/{affectation_id}/terminer")
async def terminer_mandat(
affectation_id: str, req: TerminerMandatRequest,
executif=Depends(require_executif), db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Affectation).where(
Affectation.id == affectation_id,
Affectation.groupe_id == executif.groupe_id,
Affectation.statut == "actif",
)
)
affectation = result.scalar_one_or_none()
if not affectation:
raise HTTPException(status_code=404, detail="Affectation introuvable")
affectation.statut = "termine"
affectation.date_fin = date.today()
affectation.note_transition = req.note_transition
affectation.termine_par = executif.id
await db.flush()
return {"message": "Mandat terminé"}
# ── Réactivation / suppression définitive de postes ──
@router.post("/{poste_id}/reactiver")
async def reactiver_poste(
poste_id: str,
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Réactiver un poste aboli."""
poste = await db.get(Poste, poste_id)
if not poste:
raise HTTPException(status_code=404, detail="Poste introuvable")
if poste.actif:
raise HTTPException(status_code=400, detail="Ce poste est déjà actif")
poste.actif = True
await db.flush()
return {"message": f"Poste «{poste.nom}» réactivé"}
@router.delete("/{poste_id}/permanent")
async def supprimer_poste_permanent(
poste_id: str,
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Supprimer définitivement un poste aboli (irréversible)."""
poste = await db.get(Poste, poste_id)
if not poste:
raise HTTPException(status_code=404, detail="Poste introuvable")
if poste.actif:
raise HTTPException(status_code=400, detail="Abolissez d'abord le poste avant de le supprimer")
await db.delete(poste)
await db.flush()
return {"message": f"Poste «{poste.nom}» supprimé définitivement"}
# ── Acces aux modules ──
@router.get("/modules")
async def modules_par_poste(
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(PosteModule))
modules = result.scalars().all()
par_poste: dict[str, list[str]] = {}
for m in modules:
par_poste.setdefault(str(m.poste_id), []).append(m.module_code)
return par_poste
@router.put("/{poste_id}/modules")
async def definir_modules_poste(
poste_id: str,
codes: list[str],
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
poste = await db.get(Poste, poste_id)
if not poste or not poste.actif:
raise HTTPException(status_code=404, detail="Poste introuvable ou aboli")
result = await db.execute(select(PosteModule).where(PosteModule.poste_id == poste.id))
for item in result.scalars().all():
await db.delete(item)
for code in sorted(set(codes)):
db.add(PosteModule(poste_id=poste.id, module_code=code))
await db.flush()
return {"message": "Accès aux modules mis à jour"}