"""Routes postes, affectations, candidatures, tâches.""" 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 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 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() 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 )) 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 ── @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)): 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( 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") # 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"}