"""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), membre=Depends(get_current_membre), ): query = select(Poste).where(Poste.groupe_id == membre.groupe_id) 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( groupe_id=executif.groupe_id, 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), ): result = await db.execute(select(Poste).where(Poste.id == poste_id, Poste.groupe_id == executif.groupe_id)) poste = result.scalar_one_or_none() 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.""" result = await db.execute(select(Poste).where(Poste.id == poste_id, Poste.groupe_id == executif.groupe_id)) poste = result.scalar_one_or_none() 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.groupe_id == executif.groupe_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.groupe_id == executif.groupe_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.groupe_id == membre.groupe_id, 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, membre.groupe_id, db), }) return disponibles async def _candidats_poste(poste_id, groupe_id, db: AsyncSession): result = await db.execute( select(Candidature).where( Candidature.poste_id == poste_id, Candidature.groupe_id == groupe_id, Candidature.statut == "proposee", ) ) candidats = [] for c in result.scalars().all(): membre = await db.get(Membre, c.membre_id) if membre and membre.groupe_id == groupe_id: 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 result = await db.execute(select(Poste).where(Poste.id == poste_id, Poste.groupe_id == membre.groupe_id)) poste = result.scalar_one_or_none() 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.groupe_id == membre.groupe_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.""" result = await db.execute(select(Poste).where(Poste.id == poste_id, Poste.groupe_id == executif.groupe_id)) poste = result.scalar_one_or_none() 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.""" result = await db.execute(select(Poste).where(Poste.id == poste_id, Poste.groupe_id == membre.groupe_id)) poste = result.scalar_one_or_none() 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.groupe_id == membre.groupe_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.groupe_id == membre.groupe_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.groupe_id == membre.groupe_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) if not poste or poste.groupe_id != membre.groupe_id: raise HTTPException(status_code=404, detail="Poste introuvable") 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 if poste and poste.groupe_id != executif.groupe_id: continue 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.groupe_id == membre.groupe_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 if poste and poste.groupe_id != membre.groupe_id: continue 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 poste.groupe_id != executif.groupe_id 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.groupe_id == membre.groupe_id, Affectation.statut == "actif", Poste.groupe_id == membre.groupe_id, ) ) 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 poste.groupe_id != executif.groupe_id 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.""" result = await db.execute(select(Poste).where(Poste.id == poste_id, Poste.groupe_id == executif.groupe_id)) poste = result.scalar_one_or_none() 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).""" result = await db.execute(select(Poste).where(Poste.id == poste_id, Poste.groupe_id == executif.groupe_id)) poste = result.scalar_one_or_none() 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) .join(Poste, PosteModule.poste_id == Poste.id) .where(Poste.groupe_id == executif.groupe_id) ) 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), ): result = await db.execute(select(Poste).where(Poste.id == poste_id, Poste.groupe_id == executif.groupe_id)) poste = result.scalar_one_or_none() 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"}