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

699 lines
23 KiB
Python
Raw Normal View History

2026-04-17 04:04:04 -04:00
"""Routes postes, affectations, candidatures, tâches."""
2026-03-04 14:01:00 -05:00
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.security import get_current_membre, require_executif
from app.models.poste import Poste
from app.models.affectation import Affectation
2026-04-17 04:04:04 -04:00
from app.models.membre import Membre
from app.models.candidature import Candidature
from app.models.tache import Tache, PosteTache
from app.schemas.schemas import PosteOut, PosteCreate, AffectationCreate, TerminerMandatRequest, TacheCreate, TacheOut
2026-03-04 14:01:00 -05:00
router = APIRouter(prefix="/postes", tags=["Postes et affectations"])
2026-04-17 04:04:04 -04:00
# ── Postes ──
2026-03-04 14:01:00 -05:00
@router.get("/", response_model=list[PosteOut])
2026-04-17 04:04:04 -04:00
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()
out = []
for p in postes:
# Charger les tâches associées
result_t = await db.execute(
select(Tache).join(PosteTache).where(PosteTache.poste_id == p.id)
.order_by(Tache.ordre_affichage)
)
taches_list = result_t.scalars().all()
taches = [{"id": str(t.id), "nom": t.nom, "informations": t.informations}
for t in taches_list]
# Description composite : description du poste + tâches associées
desc_parts = []
if p.description:
desc_parts.append(p.description)
if taches_list:
desc_parts.append("Tâches : " + ", ".join(
f"{t.nom}" + (f" ({t.informations})" if t.informations else "")
for t in taches_list
))
po = PosteOut.model_validate(p)
po.description = " — ".join(desc_parts) if desc_parts else p.description
po.taches = taches
out.append(po)
return out
@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
# Description composite avec tâches
result_t = await db.execute(
select(Tache).join(PosteTache).where(PosteTache.poste_id == p.id)
.order_by(Tache.ordre_affichage)
)
taches_list = result_t.scalars().all()
desc_parts = []
if p.description:
desc_parts.append(p.description)
if taches_list:
desc_parts.append("Tâches : " + ", ".join(
f"{t.nom}" + (f" ({t.informations})" if t.informations else "")
for t in taches_list
))
2026-03-04 14:01:00 -05:00
2026-04-17 04:04:04 -04:00
disponibles.append({
"id": str(p.id),
"nom": p.nom,
"categorie": p.categorie,
"description": " — ".join(desc_parts) if desc_parts else 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 ──
2026-03-04 14:01:00 -05:00
@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 = []
2026-04-17 04:04:04 -04:00
for a in result.scalars().all():
2026-03-04 14:01:00 -05:00
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")
2026-04-17 04:04:04 -04:00
async def affectations_groupe(executif=Depends(require_executif), db: AsyncSession = Depends(get_db)):
2026-03-04 14:01:00 -05:00
result = await db.execute(
select(Affectation).where(
Affectation.groupe_id == executif.groupe_id,
Affectation.statut == "actif",
)
)
out = []
2026-04-17 04:04:04 -04:00
for a in result.scalars().all():
2026-03-04 14:01:00 -05:00
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")
2026-04-17 04:04:04 -04:00
async def creer_affectation(req: AffectationCreate, executif=Depends(require_executif), db: AsyncSession = Depends(get_db)):
2026-03-04 14:01:00 -05:00
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)}
@router.post("/affectations/{affectation_id}/terminer")
async def terminer_mandat(
2026-04-17 04:04:04 -04:00
affectation_id: str, req: TerminerMandatRequest,
executif=Depends(require_executif), db: AsyncSession = Depends(get_db),
2026-03-04 14:01:00 -05:00
):
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é"}
2026-04-17 04:04:04 -04:00
# ── 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")
# Supprimer les liens tâches
result = await db.execute(select(PosteTache).where(PosteTache.poste_id == poste.id))
for pt in result.scalars().all():
await db.delete(pt)
await db.delete(poste)
await db.flush()
return {"message": f"Poste «{poste.nom}» supprimé définitivement"}
# ── Tâches ──
@router.get("/taches", response_model=list[TacheOut])
async def liste_taches(
_=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Lister toutes les tâches avec les postes associés."""
result = await db.execute(select(Tache).order_by(Tache.ordre_affichage))
taches = result.scalars().all()
out = []
for t in taches:
result_p = await db.execute(
select(Poste.nom).join(PosteTache).where(PosteTache.tache_id == t.id)
)
postes_noms = [row[0] for row in result_p.all()]
out.append(TacheOut(
id=t.id, nom=t.nom, informations=t.informations, postes=postes_noms
))
return out
@router.post("/taches", response_model=TacheOut)
async def creer_tache(
req: TacheCreate,
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Créer une tâche (exécutif)."""
tache = Tache(nom=req.nom.strip(), informations=req.informations)
db.add(tache)
await db.flush()
return TacheOut(id=tache.id, nom=tache.nom, informations=tache.informations)
@router.patch("/taches/{tache_id}")
async def modifier_tache(
tache_id: str,
nom: str | None = None,
informations: str | None = None,
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Modifier une tâche."""
tache = await db.get(Tache, tache_id)
if not tache:
raise HTTPException(status_code=404, detail="Tâche introuvable")
if nom is not None:
tache.nom = nom.strip()
if informations is not None:
tache.informations = informations.strip() or None
await db.flush()
return {"message": "Tâche modifiée"}
@router.delete("/taches/{tache_id}")
async def supprimer_tache(
tache_id: str,
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Supprimer une tâche et ses associations."""
tache = await db.get(Tache, tache_id)
if not tache:
raise HTTPException(status_code=404, detail="Tâche introuvable")
result = await db.execute(select(PosteTache).where(PosteTache.tache_id == tache.id))
for pt in result.scalars().all():
await db.delete(pt)
await db.delete(tache)
await db.flush()
return {"message": f"Tâche «{tache.nom}» supprimée"}
# ── Affectation tâche ↔ poste ──
@router.post("/{poste_id}/taches/{tache_id}")
async def affecter_tache(
poste_id: str,
tache_id: str,
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Affecter une tâche à un poste. Une tâche peut être affectée à plusieurs postes."""
poste = await db.get(Poste, poste_id)
tache = await db.get(Tache, tache_id)
if not poste or not poste.actif:
raise HTTPException(status_code=404, detail="Poste introuvable ou aboli")
if not tache:
raise HTTPException(status_code=404, detail="Tâche introuvable")
# Vérifier doublon
result = await db.execute(
select(PosteTache).where(PosteTache.poste_id == poste_id, PosteTache.tache_id == tache_id)
)
if result.scalars().first():
raise HTTPException(status_code=400, detail="Cette tâche est déjà affectée à ce poste")
pt = PosteTache(poste_id=poste_id, tache_id=tache_id)
db.add(pt)
await db.flush()
return {"message": f"Tâche «{tache.nom}» affectée au poste «{poste.nom}»"}
@router.delete("/{poste_id}/taches/{tache_id}")
async def desaffecter_tache(
poste_id: str,
tache_id: str,
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Retirer une tâche d'un poste."""
result = await db.execute(
select(PosteTache).where(PosteTache.poste_id == poste_id, PosteTache.tache_id == tache_id)
)
pt = result.scalars().first()
if not pt:
raise HTTPException(status_code=404, detail="Association introuvable")
await db.delete(pt)
await db.flush()
return {"message": "Tâche retirée du poste"}