groupe-meditation/backend/app/routers/postes.py
2026-05-25 17:39:38 -04:00

617 lines
20 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, 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.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,
"deja_postule": deja_postule,
})
return disponibles
# ── 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,
type_proposition="nomination",
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/{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,
type_proposition="nomination",
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 automatiquement
if statut == "acceptee":
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_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,
type_proposition="nomination",
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"}