diff --git a/backend/app/core/roles.py b/backend/app/core/roles.py index a426a1c..ead5cca 100644 --- a/backend/app/core/roles.py +++ b/backend/app/core/roles.py @@ -15,10 +15,14 @@ async def membre_a_poste_ou_executif(membre_id, libelle_poste: str, db: AsyncSes membre = await db.get(Membre, membre_id) if membre and is_sysadmin(membre): return True + if not membre: + return False 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, (Poste.nom.ilike(f"%{libelle_poste}%") | (Poste.categorie == "executif")), ) ) @@ -30,10 +34,14 @@ async def membre_a_module(membre_id, module_code: str, db: AsyncSession) -> bool membre = await db.get(Membre, membre_id) if membre and is_sysadmin(membre): return True + if not membre: + return False result = await db.execute( select(Affectation).join(Poste).join(PosteModule, PosteModule.poste_id == Poste.id).where( Affectation.membre_id == membre_id, + Affectation.groupe_id == membre.groupe_id, Affectation.statut == "actif", + Poste.groupe_id == membre.groupe_id, PosteModule.module_code == module_code, ) ) diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 4af9b2e..2c6693a 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -73,7 +73,9 @@ async def require_executif( 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, Poste.categorie == "executif", ) ) diff --git a/backend/app/main.py b/backend/app/main.py index 1aba828..151ef13 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -39,6 +39,12 @@ async def lifespan(app: FastAPI): "ALTER TABLE groupes " "ADD COLUMN IF NOT EXISTS assemblee_affaires_ordre VARCHAR(20) DEFAULT 'premiere'" )) + await conn.execute(text("ALTER TABLE postes ADD COLUMN IF NOT EXISTS groupe_id UUID REFERENCES groupes(id)")) + await conn.execute(text( + "UPDATE postes SET groupe_id = (SELECT id FROM groupes ORDER BY cree_le LIMIT 1) " + "WHERE groupe_id IS NULL AND EXISTS (SELECT 1 FROM groupes)" + )) + await conn.execute(text("ALTER TABLE postes ALTER COLUMN groupe_id SET NOT NULL")) await conn.execute(text("ALTER TABLE propositions ADD COLUMN IF NOT EXISTS date_adoption DATE")) await conn.execute(text("ALTER TABLE propositions ADD COLUMN IF NOT EXISTS etant_donne_que TEXT")) await conn.execute(text("ALTER TABLE propositions ADD COLUMN IF NOT EXISTS groupe_a_decide_de TEXT")) diff --git a/backend/app/models/poste.py b/backend/app/models/poste.py index 97012f3..a875897 100644 --- a/backend/app/models/poste.py +++ b/backend/app/models/poste.py @@ -1,6 +1,6 @@ """Table postes.""" import uuid -from sqlalchemy import String, Text, Integer, Boolean, Interval, Enum as SAEnum +from sqlalchemy import String, Text, Integer, Boolean, Interval, Enum as SAEnum, ForeignKey from sqlalchemy.orm import Mapped, mapped_column from app.core.database import Base @@ -9,6 +9,7 @@ class Poste(Base): __tablename__ = "postes" id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) nom: Mapped[str] = mapped_column(String(100), nullable=False) categorie: Mapped[str] = mapped_column(SAEnum("executif", "service", "physique", name="categorie_poste"), nullable=False) description: Mapped[str | None] = mapped_column(Text) diff --git a/backend/app/routers/accueil.py b/backend/app/routers/accueil.py index e25f712..f99d105 100644 --- a/backend/app/routers/accueil.py +++ b/backend/app/routers/accueil.py @@ -112,7 +112,9 @@ async def accueil( 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, ) ) affectations = result.scalars().all() @@ -122,7 +124,7 @@ async def accueil( poste_ids = [] for a in affectations: poste = await db.get(Poste, a.poste_id) - if poste: + if poste and poste.groupe_id == membre.groupe_id: postes_noms.append(poste.nom) postes_categories.append(poste.categorie) poste_ids.append(poste.id) diff --git a/backend/app/routers/anniversaires.py b/backend/app/routers/anniversaires.py index a50f8d2..593d7e5 100644 --- a/backend/app/routers/anniversaires.py +++ b/backend/app/routers/anniversaires.py @@ -1,4 +1,4 @@ -"""Routes C3 — Anniversaires district 87-16.""" +"""Routes C3 — Anniversaires du groupe.""" from datetime import date, timedelta from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession @@ -10,7 +10,7 @@ from app.models.membre import Membre from app.models.groupe import Groupe from app.schemas.phase5 import AnniversaireOut -router = APIRouter(prefix="/anniversaires", tags=["C3 — Anniversaires district"]) +router = APIRouter(prefix="/anniversaires", tags=["C3 — Anniversaires"]) def _calcul_anniversaire(date_abstinence: date, aujourdhui: date) -> tuple[int, date, int]: @@ -45,39 +45,36 @@ def _calcul_anniversaire(date_abstinence: date, aujourdhui: date) -> tuple[int, @router.get("/", response_model=list[AnniversaireOut]) -async def anniversaires_district( +async def anniversaires_groupe( prochains_jours: int = 90, membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db), ): - """C3 — Anniversaires de sobriété de tous les groupes du district 87-16.""" + """C3 — Anniversaires de sobriété du groupe courant.""" aujourdhui = date.today() - # Récupérer le district du groupe du membre groupe = await db.get(Groupe, membre.groupe_id) - district = groupe.district if groupe else "87-16" + if not groupe: + return [] - # Tous les membres actifs du district ayant une date d'abstinence result = await db.execute( - select(Membre, Groupe.nom) - .join(Groupe, Membre.groupe_id == Groupe.id) + select(Membre) .where( - Groupe.district == district, + Membre.groupe_id == membre.groupe_id, Membre.actif == True, Membre.date_abstinence.isnot(None), ) ) - rows = result.all() anniversaires = [] - for m, groupe_nom in rows: + for m in result.scalars().all(): annees_sobriete, prochain, jours_restants = _calcul_anniversaire(m.date_abstinence, aujourdhui) # Filtrer : prochains X jours OU anniversaire aujourd'hui if 0 <= jours_restants <= prochains_jours: anniversaires.append(AnniversaireOut( membre_prenom=m.prenom, - groupe_nom=groupe_nom, + groupe_nom=groupe.nom, date_abstinence=m.date_abstinence, annees_sobriete=annees_sobriete + 1, # prochain anniversaire = +1 prochain_anniversaire=prochain, diff --git a/backend/app/routers/calendrier.py b/backend/app/routers/calendrier.py index c4ff35c..651224e 100644 --- a/backend/app/routers/calendrier.py +++ b/backend/app/routers/calendrier.py @@ -1,4 +1,4 @@ -"""Routes C2 — Calendrier district 87-16.""" +"""Routes C2 — Calendrier du groupe.""" from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select @@ -11,29 +11,22 @@ from app.models.poste import Poste from app.models.membre import Membre from app.schemas.phase2 import CalendrierEntry -router = APIRouter(prefix="/calendrier", tags=["C2 — Calendrier district"]) +router = APIRouter(prefix="/calendrier", tags=["C2 — Calendrier"]) @router.get("/", response_model=list[CalendrierEntry]) -async def calendrier_district( +async def calendrier_groupe_courant( membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db), ): - """C2 — Calendrier des serviteurs de tous les groupes du district 87-16.""" - # Récupérer le district du membre + """C2 — Calendrier des serviteurs du groupe courant.""" groupe = await db.get(Groupe, membre.groupe_id) - district = groupe.district if groupe else "87-16" + if not groupe: + return [] - # Tous les groupes du même district - result = await db.execute( - select(Groupe).where(Groupe.district == district).order_by(Groupe.nom) - ) - groupes = {g.id: g for g in result.scalars().all()} - - # Toutes les affectations actives de ces groupes result = await db.execute( select(Affectation).where( - Affectation.groupe_id.in_(groupes.keys()), + Affectation.groupe_id == membre.groupe_id, Affectation.statut == "actif", ) ) @@ -43,18 +36,16 @@ async def calendrier_district( for a in affectations: poste = await db.get(Poste, a.poste_id) mbr = await db.get(Membre, a.membre_id) - grp = groupes.get(a.groupe_id) - if poste and mbr and grp: + if poste and poste.groupe_id == membre.groupe_id and mbr and mbr.groupe_id == membre.groupe_id: entries.append(CalendrierEntry( - groupe_nom=grp.nom, - groupe_id=grp.id, - jour_reunion=grp.jour_reunion, + groupe_nom=groupe.nom, + groupe_id=groupe.id, + jour_reunion=groupe.jour_reunion, poste_nom=poste.nom, membre_prenom=mbr.prenom, date_debut=a.date_debut, )) - # Trier par groupe puis par ordre de poste entries.sort(key=lambda e: (e.groupe_nom, e.poste_nom)) return entries @@ -65,14 +56,16 @@ async def calendrier_groupe( membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db), ): - """Détail des serviteurs d'un groupe spécifique.""" - groupe = await db.get(Groupe, groupe_id) + """Détail des serviteurs du groupe courant uniquement.""" + if str(groupe_id) != str(membre.groupe_id): + return {"groupe": None, "serviteurs": []} + groupe = await db.get(Groupe, membre.groupe_id) if not groupe: return {"groupe": None, "serviteurs": []} result = await db.execute( select(Affectation).where( - Affectation.groupe_id == groupe_id, + Affectation.groupe_id == membre.groupe_id, Affectation.statut == "actif", ) ) @@ -82,7 +75,7 @@ async def calendrier_groupe( for a in affectations: poste = await db.get(Poste, a.poste_id) mbr = await db.get(Membre, a.membre_id) - if poste and mbr: + if poste and poste.groupe_id == membre.groupe_id and mbr and mbr.groupe_id == membre.groupe_id: serviteurs.append({ "poste": poste.nom, "categorie": poste.categorie, diff --git a/backend/app/routers/evenements.py b/backend/app/routers/evenements.py index 2373dc3..0d689bd 100644 --- a/backend/app/routers/evenements.py +++ b/backend/app/routers/evenements.py @@ -35,21 +35,21 @@ async def evenements_a_venir( debut = aujourdhui - timedelta(days=jours_avant) limite = aujourdhui + timedelta(days=jours) groupe = await db.get(Groupe, membre.groupe_id) - district = groupe.district if groupe else "87-16" + if not groupe: + return [] evenements = [] - # ── 1. Anniversaires de sobriété du district ── + # ── 1. Anniversaires de sobriété du groupe ── result = await db.execute( - select(Membre, Groupe.nom) - .join(Groupe, Membre.groupe_id == Groupe.id) + select(Membre) .where( - Groupe.district == district, + Membre.groupe_id == membre.groupe_id, Membre.actif == True, Membre.date_abstinence.isnot(None), ) ) - for m, groupe_nom in result.all(): + for m in result.scalars().all(): for annee in range(debut.year, limite.year + 1): annees, anniv = _anniversaire_sobriete(m.date_abstinence, annee) if anniv < debut or anniv > limite: @@ -58,29 +58,28 @@ async def evenements_a_venir( "type": "anniversaire", "date": str(anniv), "titre": f"🎂 {m.prenom} — {annees} an{'s' if annees > 1 else ''}", - "detail": f"Anniversaire de sobriété · {groupe_nom}", + "detail": f"Anniversaire de sobriété · {groupe.nom}", "lieu": None, - "groupe_nom": groupe_nom, + "groupe_nom": groupe.nom, }) # ── 2. Événements ad hoc ── result = await db.execute( - select(Evenement, Groupe.nom) - .join(Groupe, Evenement.groupe_id == Groupe.id) + select(Evenement) .where( - Groupe.district == district, + Evenement.groupe_id == membre.groupe_id, Evenement.date_evenement >= debut, Evenement.date_evenement <= limite, ) ) - for ev, groupe_nom in result.all(): + for ev in result.scalars().all(): evenements.append({ "type": "adhoc", "date": str(ev.date_evenement), "titre": ev.titre, "detail": ev.description, "lieu": ev.lieu, - "groupe_nom": groupe_nom, + "groupe_nom": groupe.nom, }) # Trier par date diff --git a/backend/app/routers/postes.py b/backend/app/routers/postes.py index b436cf9..17d9e68 100644 --- a/backend/app/routers/postes.py +++ b/backend/app/routers/postes.py @@ -23,9 +23,9 @@ router = APIRouter(prefix="/postes", tags=["Postes et affectations"]) async def liste_postes( inclure_abolis: bool = False, db: AsyncSession = Depends(get_db), - _=Depends(get_current_membre), + membre=Depends(get_current_membre), ): - query = select(Poste) + 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)) @@ -44,6 +44,7 @@ async def creer_poste( 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, @@ -63,7 +64,8 @@ async def modifier_poste( executif=Depends(require_executif), db: AsyncSession = Depends(get_db), ): - poste = await db.get(Poste, poste_id) + 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"): @@ -85,7 +87,8 @@ async def abolir_poste( db: AsyncSession = Depends(get_db), ): """Abolir un poste — termine tous les mandats actifs associés.""" - poste = await db.get(Poste, poste_id) + 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") @@ -93,6 +96,7 @@ async def abolir_poste( result = await db.execute( select(Affectation).where( Affectation.poste_id == poste.id, + Affectation.groupe_id == executif.groupe_id, Affectation.statut == "actif", ) ) @@ -108,6 +112,7 @@ async def abolir_poste( result = await db.execute( select(Candidature).where( Candidature.poste_id == poste.id, + Candidature.groupe_id == executif.groupe_id, Candidature.statut == "proposee", ) ) @@ -131,7 +136,7 @@ async def postes_disponibles( ): """Postes actifs sans titulaire — opportunités d'implication.""" result = await db.execute( - select(Poste).where(Poste.actif == True).order_by(Poste.ordre_affichage) + select(Poste).where(Poste.groupe_id == membre.groupe_id, Poste.actif == True).order_by(Poste.ordre_affichage) ) postes = result.scalars().all() @@ -163,22 +168,23 @@ async def postes_disponibles( "prerequis": p.prerequis, "informations": p.informations, "deja_postule": deja_postule, - "candidats": await _candidats_poste(p.id, db), + "candidats": await _candidats_poste(p.id, membre.groupe_id, db), }) return disponibles -async def _candidats_poste(poste_id, db: AsyncSession): +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: + if membre and membre.groupe_id == groupe_id: candidats.append({ "id": str(c.id), "membre_id": str(membre.id), @@ -201,7 +207,8 @@ async def postuler( """Proposer sa candidature — crée automatiquement une proposition de nomination.""" from app.models.proposition import Proposition - poste = await db.get(Poste, poste_id) + 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") @@ -209,6 +216,7 @@ async def postuler( 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", ) @@ -256,7 +264,8 @@ async def nominer( 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) + 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) @@ -288,7 +297,8 @@ async def proposer_membre( db: AsyncSession = Depends(get_db), ): """Proposer un autre membre pour un poste.""" - poste = await db.get(Poste, poste_id) + 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) @@ -300,6 +310,7 @@ async def proposer_membre( 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", ) @@ -332,6 +343,7 @@ async def retirer_candidature( 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", ) @@ -365,6 +377,7 @@ async def accepter_nomination( 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", @@ -383,6 +396,8 @@ async def accepter_nomination( # 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( @@ -421,6 +436,8 @@ async def liste_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 "?", @@ -443,6 +460,7 @@ async def mes_candidatures( result = await db.execute( select(Candidature).where( Candidature.membre_id == membre.id, + Candidature.groupe_id == membre.groupe_id, Candidature.statut == "proposee", ) ) @@ -450,6 +468,8 @@ async def mes_candidatures( 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 "?", @@ -493,7 +513,7 @@ async def traiter_candidature( 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 not poste.actif: + 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( @@ -541,6 +561,7 @@ async def mes_affectations(membre=Depends(get_current_membre), db: AsyncSession result = await db.execute( select(Affectation).join(Poste).where( Affectation.membre_id == membre.id, + Affectation.groupe_id == membre.groupe_id, Affectation.statut == "actif", ) ) @@ -597,7 +618,7 @@ async def creer_affectation(req: AffectationCreate, executif=Depends(require_exe raise HTTPException(status_code=404, detail="Proposeur introuvable ou inactif") if not secondeur or secondeur.groupe_id != executif.groupe_id or not secondeur.actif: raise HTTPException(status_code=404, detail="Secondeur introuvable ou inactif") - if not poste or not poste.actif: + 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( @@ -675,7 +696,8 @@ async def reactiver_poste( db: AsyncSession = Depends(get_db), ): """Réactiver un poste aboli.""" - poste = await db.get(Poste, poste_id) + 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: @@ -692,7 +714,8 @@ async def supprimer_poste_permanent( db: AsyncSession = Depends(get_db), ): """Supprimer définitivement un poste aboli (irréversible).""" - poste = await db.get(Poste, poste_id) + 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: @@ -710,7 +733,11 @@ async def modules_par_poste( executif=Depends(require_executif), db: AsyncSession = Depends(get_db), ): - result = await db.execute(select(PosteModule)) + 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: @@ -725,7 +752,8 @@ async def definir_modules_poste( executif=Depends(require_executif), db: AsyncSession = Depends(get_db), ): - poste = await db.get(Poste, poste_id) + 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") diff --git a/backend/app/routers/presences.py b/backend/app/routers/presences.py index e93ae91..4c5e839 100644 --- a/backend/app/routers/presences.py +++ b/backend/app/routers/presences.py @@ -36,7 +36,12 @@ def _date_civile_reunion(valeur): async def _postes_vacants(groupe_id, db: AsyncSession): - result = await db.execute(select(Poste).where(Poste.actif == True).order_by(Poste.ordre_affichage)) + result = await db.execute( + select(Poste).where( + Poste.groupe_id == groupe_id, + Poste.actif == True, + ).order_by(Poste.ordre_affichage) + ) postes = result.scalars().all() vacants = [] for poste in postes: diff --git a/backend/app/routers/rotations.py b/backend/app/routers/rotations.py index 6492958..37d5810 100644 --- a/backend/app/routers/rotations.py +++ b/backend/app/routers/rotations.py @@ -17,6 +17,10 @@ router = APIRouter(prefix="/rotations", tags=["G5 — Rotations"]) async def _to_out(r: Rotation, db: AsyncSession) -> RotationOut: poste = await db.get(Poste, r.poste_id) mbr = await db.get(Membre, r.membre_id) + if poste and poste.groupe_id != r.groupe_id: + poste = None + if mbr and mbr.groupe_id != r.groupe_id: + mbr = None return RotationOut( id=r.id, date_reunion=r.date_reunion, @@ -72,6 +76,12 @@ async def creer_rotation( db: AsyncSession = Depends(get_db), ): """G5 — Assigner un serviteur pour une date de réunion.""" + poste = await db.get(Poste, req.poste_id) + mbr = await db.get(Membre, req.membre_id) + if not poste or poste.groupe_id != executif.groupe_id or not poste.actif: + raise HTTPException(status_code=404, detail="Poste introuvable ou aboli") + if not mbr or mbr.groupe_id != executif.groupe_id or not mbr.actif: + raise HTTPException(status_code=404, detail="Membre introuvable ou inactif") rotation = Rotation( groupe_id=executif.groupe_id, date_reunion=req.date_reunion, @@ -94,6 +104,12 @@ async def creer_rotations_batch( """G5 — Assigner plusieurs serviteurs d'un coup pour une semaine.""" created = [] for req in rotations: + poste = await db.get(Poste, req.poste_id) + mbr = await db.get(Membre, req.membre_id) + if not poste or poste.groupe_id != executif.groupe_id or not poste.actif: + raise HTTPException(status_code=404, detail="Poste introuvable ou aboli") + if not mbr or mbr.groupe_id != executif.groupe_id or not mbr.actif: + raise HTTPException(status_code=404, detail="Membre introuvable ou inactif") rotation = Rotation( groupe_id=executif.groupe_id, date_reunion=req.date_reunion, diff --git a/backend/app/services/admin_database.py b/backend/app/services/admin_database.py index 17dd5e2..a38c811 100644 --- a/backend/app/services/admin_database.py +++ b/backend/app/services/admin_database.py @@ -107,7 +107,7 @@ async def initialiser_demo(db: AsyncSession) -> dict: await remettre_a_zero(db) groupe = await _premier_groupe(db) membres = await _membres_par_courriel(db) - postes = await _postes_par_nom(db) + postes = await _postes_par_nom(db, groupe.id) litterature = await _litterature(db) await _ajouter_membres_demo(db, groupe, membres, postes) @@ -140,6 +140,7 @@ async def _creer_base(db: AsyncSession): postes = {} for nom, categorie, description, prerequis, ordre, modules in POSTES: poste = Poste( + groupe_id=groupe.id, nom=nom, categorie=categorie, description=description, @@ -438,10 +439,10 @@ async def _membres_par_courriel(db: AsyncSession) -> dict[str, Membre]: return {m.courriel: m for m in result.scalars().all()} -async def _postes_par_nom(db: AsyncSession) -> dict[str, Poste]: +async def _postes_par_nom(db: AsyncSession, groupe_id) -> dict[str, Poste]: from sqlalchemy import select - result = await db.execute(select(Poste)) + result = await db.execute(select(Poste).where(Poste.groupe_id == groupe_id)) return {p.nom: p for p in result.scalars().all()} diff --git a/backend/tests/test_business_rules.py b/backend/tests/test_business_rules.py index 28ed892..28f563e 100644 --- a/backend/tests/test_business_rules.py +++ b/backend/tests/test_business_rules.py @@ -8,6 +8,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from app.core.decision_types import type_decision_depuis_proposition from app.core.financial_position import PositionTresorerie from app.core.sanitization import safe_query_params +from app.models.poste import Poste class DecisionTypeTests(unittest.TestCase): @@ -46,5 +47,11 @@ class AuditTests(unittest.TestCase): self.assertEqual(safe_query_params(request.query_params), {"pin": "***", "membre_id": "abc"}) +class MultiTenantTests(unittest.TestCase): + def test_postes_are_group_scoped(self): + self.assertIn("groupe_id", Poste.__table__.columns) + self.assertFalse(Poste.__table__.columns["groupe_id"].nullable) + + if __name__ == "__main__": unittest.main() diff --git a/deploy/seed.py b/deploy/seed.py index 7fda784..4fccb64 100644 --- a/deploy/seed.py +++ b/deploy/seed.py @@ -111,6 +111,7 @@ async def seed(): postes_map = {} for nom, cat, desc, duree, prereq, ordre in POSTES: poste = Poste( + groupe_id=groupe.id, nom=nom, categorie=cat, description=desc, diff --git a/docs/MULTI_TENANCY.md b/docs/MULTI_TENANCY.md new file mode 100644 index 0000000..a1e934b --- /dev/null +++ b/docs/MULTI_TENANCY.md @@ -0,0 +1,23 @@ +# Isolation multi-groupe + +L'application est servie sous une URL commune, mais les donnees d'un groupe doivent rester isolees de celles des autres groupes. Le contexte actif est toujours le `groupe_id` du membre authentifie, transporte dans le jeton de session et verifie au chargement du membre. + +## Regles d'isolation + +- Une requete authentifiee ne doit lire ou modifier que les donnees du groupe du membre courant. +- Les postes, affectations, candidatures, rotations, reunions, assemblees, propositions, transactions, reserves et journaux sont des donnees de groupe. +- Les permissions de modules sont derivees des postes du groupe courant seulement. +- Le calendrier affiche uniquement les serviteurs du groupe courant. Il ne fait pas de lecture district ou multi-groupe. +- Les liens invites donnent acces seulement a l'assemblee ciblee par l'invitation et a ses donnees publiques. +- Les tables sans `groupe_id` direct doivent etre bornees par leur parent : par exemple une presence est bornee par sa reunion, un PV par sa reunion, une permission de module par son poste. + +## Points de vigilance + +- Une route qui accepte un identifiant fourni par le client doit verifier que l'objet appartient au groupe courant avant de l'utiliser. +- Une jointure de permissions doit filtrer a la fois l'affectation et le poste sur le groupe courant. +- Une initialisation ou remise a zero par Sysadmin est une operation d'instance. Elle ne doit pas etre exposee comme operation courante en production multi-groupe. +- Toute nouvelle table metier doit recevoir un `groupe_id`, sauf si son rattachement a une table parent isolee est strict et documente. + +## Etat actuel + +Les postes sont maintenant scopes par `groupe_id`, ce qui evite qu'un groupe modifie les definitions de postes ou les permissions d'un autre groupe. Les routes de calendrier, de postes, d'assemblees et de rotations filtrent les donnees par groupe courant. diff --git a/docs/REGLES_METIER.md b/docs/REGLES_METIER.md index 6d51035..e027879 100644 --- a/docs/REGLES_METIER.md +++ b/docs/REGLES_METIER.md @@ -75,6 +75,8 @@ la regle est formulee selon le code et l'ecart est liste dans la section - RM-POS-019 - Terminer un mandat met l'affectation au statut `termine` et conserve la date de fin, la raison et le membre qui l'a terminee. - RM-POS-020 - Les modules consultables par un membre dependent des permissions associees a ses postes, avec acces complet pour `Sysadmin`. - RM-POS-021 - Les permissions de modules accordees aux postes sont reconnues par les verifications API des responsabilites specialisees. +- RM-POS-022 - Les postes et leurs permissions appartiennent a un seul groupe. +- RM-POS-023 - Un poste d'un groupe ne peut jamais accorder un acces, une affectation ou une candidature dans un autre groupe. ## Reunions et dates