2026-05-15 16:41:17 -04:00
|
|
|
"""Routes postes, affectations, candidatures et accès aux modules."""
|
2026-03-04 14:01:00 -05:00
|
|
|
from datetime import date
|
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
2026-05-25 17:39:38 -04:00
|
|
|
from sqlalchemy import select
|
2026-03-04 14:01:00 -05:00
|
|
|
|
|
|
|
|
from app.core.database import get_db
|
2026-05-25 17:39:38 -04:00
|
|
|
from app.core.decisions import prochain_numero_decision
|
2026-03-04 14:01:00 -05:00
|
|
|
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
|
2026-05-15 16:41:17 -04:00
|
|
|
from app.models.poste_module import PosteModule
|
2026-05-26 08:15:28 -04:00
|
|
|
from app.schemas.schemas import PosteOut, PosteCreate, PosteUpdate, AffectationCreate, TerminerMandatRequest
|
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),
|
2026-05-31 18:12:26 -04:00
|
|
|
membre=Depends(get_current_membre),
|
2026-04-17 04:04:04 -04:00
|
|
|
):
|
2026-05-31 18:12:26 -04:00
|
|
|
query = select(Poste).where(Poste.groupe_id == membre.groupe_id)
|
2026-04-17 04:04:04 -04:00
|
|
|
if not inclure_abolis:
|
|
|
|
|
query = query.where(Poste.actif == True)
|
|
|
|
|
result = await db.execute(query.order_by(Poste.ordre_affichage))
|
|
|
|
|
postes = result.scalars().all()
|
|
|
|
|
|
2026-05-15 16:41:17 -04:00
|
|
|
return [PosteOut.model_validate(p) for p in postes]
|
2026-04-17 04:04:04 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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(
|
2026-05-31 18:12:26 -04:00
|
|
|
groupe_id=executif.groupe_id,
|
2026-04-17 04:04:04 -04:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-05-26 08:15:28 -04:00
|
|
|
@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),
|
|
|
|
|
):
|
2026-05-31 18:12:26 -04:00
|
|
|
result = await db.execute(select(Poste).where(Poste.id == poste_id, Poste.groupe_id == executif.groupe_id))
|
|
|
|
|
poste = result.scalar_one_or_none()
|
2026-05-26 08:15:28 -04:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-04-17 04:04:04 -04:00
|
|
|
@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."""
|
2026-05-31 18:12:26 -04:00
|
|
|
result = await db.execute(select(Poste).where(Poste.id == poste_id, Poste.groupe_id == executif.groupe_id))
|
|
|
|
|
poste = result.scalar_one_or_none()
|
2026-04-17 04:04:04 -04:00
|
|
|
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,
|
2026-05-31 18:12:26 -04:00
|
|
|
Affectation.groupe_id == executif.groupe_id,
|
2026-04-17 04:04:04 -04:00
|
|
|
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,
|
2026-05-31 18:12:26 -04:00
|
|
|
Candidature.groupe_id == executif.groupe_id,
|
2026-04-17 04:04:04 -04:00
|
|
|
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(
|
2026-05-31 18:12:26 -04:00
|
|
|
select(Poste).where(Poste.groupe_id == membre.groupe_id, Poste.actif == True).order_by(Poste.ordre_affichage)
|
2026-04-17 04:04:04 -04:00
|
|
|
)
|
|
|
|
|
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,
|
2026-05-15 16:41:17 -04:00
|
|
|
"description": p.description,
|
2026-04-17 04:04:04 -04:00
|
|
|
"prerequis": p.prerequis,
|
2026-05-26 08:15:28 -04:00
|
|
|
"informations": p.informations,
|
2026-04-17 04:04:04 -04:00
|
|
|
"deja_postule": deja_postule,
|
2026-05-31 18:12:26 -04:00
|
|
|
"candidats": await _candidats_poste(p.id, membre.groupe_id, db),
|
2026-04-17 04:04:04 -04:00
|
|
|
})
|
|
|
|
|
return disponibles
|
|
|
|
|
|
|
|
|
|
|
2026-05-31 18:12:26 -04:00
|
|
|
async def _candidats_poste(poste_id, groupe_id, db: AsyncSession):
|
2026-05-26 08:15:28 -04:00
|
|
|
result = await db.execute(
|
|
|
|
|
select(Candidature).where(
|
|
|
|
|
Candidature.poste_id == poste_id,
|
2026-05-31 18:12:26 -04:00
|
|
|
Candidature.groupe_id == groupe_id,
|
2026-05-26 08:15:28 -04:00
|
|
|
Candidature.statut == "proposee",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
candidats = []
|
|
|
|
|
for c in result.scalars().all():
|
|
|
|
|
membre = await db.get(Membre, c.membre_id)
|
2026-05-31 18:12:26 -04:00
|
|
|
if membre and membre.groupe_id == groupe_id:
|
2026-05-26 08:15:28 -04:00
|
|
|
candidats.append({
|
|
|
|
|
"id": str(c.id),
|
|
|
|
|
"membre_id": str(membre.id),
|
|
|
|
|
"prenom": membre.prenom,
|
|
|
|
|
"type": c.type,
|
|
|
|
|
"motivation": c.motivation,
|
|
|
|
|
})
|
|
|
|
|
return candidats
|
|
|
|
|
|
|
|
|
|
|
2026-04-17 04:04:04 -04:00
|
|
|
# ── 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
|
|
|
|
|
|
2026-05-31 18:12:26 -04:00
|
|
|
result = await db.execute(select(Poste).where(Poste.id == poste_id, Poste.groupe_id == membre.groupe_id))
|
|
|
|
|
poste = result.scalar_one_or_none()
|
2026-04-17 04:04:04 -04:00
|
|
|
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,
|
2026-05-31 18:12:26 -04:00
|
|
|
Candidature.groupe_id == membre.groupe_id,
|
2026-04-17 04:04:04 -04:00
|
|
|
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."""
|
2026-05-31 18:12:26 -04:00
|
|
|
result = await db.execute(select(Poste).where(Poste.id == poste_id, Poste.groupe_id == executif.groupe_id))
|
|
|
|
|
poste = result.scalar_one_or_none()
|
2026-04-17 04:04:04 -04:00
|
|
|
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),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-05-26 08:15:28 -04:00
|
|
|
@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."""
|
2026-05-31 18:12:26 -04:00
|
|
|
result = await db.execute(select(Poste).where(Poste.id == poste_id, Poste.groupe_id == membre.groupe_id))
|
|
|
|
|
poste = result.scalar_one_or_none()
|
2026-05-26 08:15:28 -04:00
|
|
|
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,
|
2026-05-31 18:12:26 -04:00
|
|
|
Candidature.groupe_id == membre.groupe_id,
|
2026-05-26 08:15:28 -04:00
|
|
|
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)}
|
|
|
|
|
|
|
|
|
|
|
2026-04-17 04:04:04 -04:00
|
|
|
@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,
|
2026-05-31 18:12:26 -04:00
|
|
|
Candidature.groupe_id == membre.groupe_id,
|
2026-04-17 04:04:04 -04:00
|
|
|
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,
|
2026-05-31 18:12:26 -04:00
|
|
|
Candidature.groupe_id == membre.groupe_id,
|
2026-04-17 04:04:04 -04:00
|
|
|
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)
|
2026-05-31 18:12:26 -04:00
|
|
|
if not poste or poste.groupe_id != membre.groupe_id:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Poste introuvable")
|
2026-04-17 04:04:04 -04:00
|
|
|
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
|
2026-05-31 18:12:26 -04:00
|
|
|
if poste and poste.groupe_id != executif.groupe_id:
|
|
|
|
|
continue
|
2026-04-17 04:04:04 -04:00
|
|
|
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,
|
2026-05-31 18:12:26 -04:00
|
|
|
Candidature.groupe_id == membre.groupe_id,
|
2026-04-17 04:04:04 -04:00
|
|
|
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
|
2026-05-31 18:12:26 -04:00
|
|
|
if poste and poste.groupe_id != membre.groupe_id:
|
|
|
|
|
continue
|
2026-04-17 04:04:04 -04:00
|
|
|
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
|
|
|
|
|
|
2026-05-26 09:05:58 -04:00
|
|
|
# Si acceptée, créer l'affectation et la décision de nomination automatiquement.
|
2026-04-17 04:04:04 -04:00
|
|
|
if statut == "acceptee":
|
2026-05-26 09:05:58 -04:00
|
|
|
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")
|
2026-05-31 18:12:26 -04:00
|
|
|
if not poste or poste.groupe_id != executif.groupe_id or not poste.actif:
|
2026-05-26 09:05:58 -04:00
|
|
|
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
|
2026-04-17 04:04:04 -04:00
|
|
|
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,
|
2026-05-31 18:12:26 -04:00
|
|
|
Affectation.groupe_id == membre.groupe_id,
|
2026-03-04 14:01:00 -05:00
|
|
|
Affectation.statut == "actif",
|
2026-06-01 06:35:09 -04:00
|
|
|
Poste.groupe_id == membre.groupe_id,
|
2026-03-04 14:01:00 -05:00
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
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),
|
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
|
|
|
"membre_id": str(a.membre_id),
|
2026-03-04 14:01:00 -05:00
|
|
|
"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-05-15 16:41:17 -04:00
|
|
|
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")
|
2026-05-31 18:12:26 -04:00
|
|
|
if not poste or poste.groupe_id != executif.groupe_id or not poste.actif:
|
2026-05-15 16:41:17 -04:00
|
|
|
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é")
|
|
|
|
|
|
2026-05-25 17:39:38 -04:00
|
|
|
numero_resolution = await prochain_numero_decision(executif.groupe_id, "nomination", req.date_debut.year, db)
|
2026-05-15 16:41:17 -04:00
|
|
|
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,
|
2026-05-25 17:39:38 -04:00
|
|
|
date_adoption=req.date_debut,
|
|
|
|
|
groupe_a_decide_de=f"Nommer {cible.prenom} au poste de {poste.nom}.",
|
2026-05-15 16:41:17 -04:00
|
|
|
)
|
|
|
|
|
db.add(resolution)
|
|
|
|
|
await db.flush()
|
|
|
|
|
|
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()
|
2026-05-15 16:41:17 -04:00
|
|
|
return {
|
|
|
|
|
"message": "Affectation créée",
|
|
|
|
|
"id": str(affectation.id),
|
|
|
|
|
"resolution_id": str(resolution.id),
|
|
|
|
|
"numero_resolution": resolution.numero_resolution,
|
|
|
|
|
}
|
2026-03-04 14:01:00 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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."""
|
2026-05-31 18:12:26 -04:00
|
|
|
result = await db.execute(select(Poste).where(Poste.id == poste_id, Poste.groupe_id == executif.groupe_id))
|
|
|
|
|
poste = result.scalar_one_or_none()
|
2026-04-17 04:04:04 -04:00
|
|
|
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)."""
|
2026-05-31 18:12:26 -04:00
|
|
|
result = await db.execute(select(Poste).where(Poste.id == poste_id, Poste.groupe_id == executif.groupe_id))
|
|
|
|
|
poste = result.scalar_one_or_none()
|
2026-04-17 04:04:04 -04:00
|
|
|
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"}
|
|
|
|
|
|
|
|
|
|
|
2026-05-15 16:41:17 -04:00
|
|
|
# ── Acces aux modules ──
|
2026-04-17 04:04:04 -04:00
|
|
|
|
2026-05-15 16:41:17 -04:00
|
|
|
@router.get("/modules")
|
|
|
|
|
async def modules_par_poste(
|
2026-04-17 04:04:04 -04:00
|
|
|
executif=Depends(require_executif),
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
):
|
2026-05-31 18:12:26 -04:00
|
|
|
result = await db.execute(
|
|
|
|
|
select(PosteModule)
|
|
|
|
|
.join(Poste, PosteModule.poste_id == Poste.id)
|
|
|
|
|
.where(Poste.groupe_id == executif.groupe_id)
|
|
|
|
|
)
|
2026-05-15 16:41:17 -04:00
|
|
|
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
|
2026-04-17 04:04:04 -04:00
|
|
|
|
|
|
|
|
|
2026-05-15 16:41:17 -04:00
|
|
|
@router.put("/{poste_id}/modules")
|
|
|
|
|
async def definir_modules_poste(
|
2026-04-17 04:04:04 -04:00
|
|
|
poste_id: str,
|
2026-05-15 16:41:17 -04:00
|
|
|
codes: list[str],
|
2026-04-17 04:04:04 -04:00
|
|
|
executif=Depends(require_executif),
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
):
|
2026-05-31 18:12:26 -04:00
|
|
|
result = await db.execute(select(Poste).where(Poste.id == poste_id, Poste.groupe_id == executif.groupe_id))
|
|
|
|
|
poste = result.scalar_one_or_none()
|
2026-04-17 04:04:04 -04:00
|
|
|
if not poste or not poste.actif:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Poste introuvable ou aboli")
|
|
|
|
|
|
2026-05-15 16:41:17 -04:00
|
|
|
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))
|
2026-04-17 04:04:04 -04:00
|
|
|
await db.flush()
|
2026-05-15 16:41:17 -04:00
|
|
|
return {"message": "Accès aux modules mis à jour"}
|