Renforcer isolation multi-tenant
- scoper les postes et permissions par groupe - retirer les lectures district du calendrier, des evenements et anniversaires - documenter les regles d isolation multi-groupe
This commit is contained in:
parent
9b45a3110f
commit
393f9243cb
16 changed files with 164 additions and 73 deletions
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
23
docs/MULTI_TENANCY.md
Normal file
23
docs/MULTI_TENANCY.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue