groupe-meditation/backend/app/services/admin_database.py
Daniel Allaire 393f9243cb 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
2026-05-31 18:12:26 -04:00

453 lines
24 KiB
Python

"""Operations sysadmin de remise a zero et de donnees de demonstration."""
from __future__ import annotations
import csv
from datetime import date, datetime, time, timedelta, timezone
from pathlib import Path
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import Base
from app.core.security import hash_pin
from app.models.affectation import Affectation
from app.models.candidature import Candidature
from app.models.collecte import Collecte
from app.models.config_repartition import ConfigRepartition
from app.models.depense import Depense
from app.models.destinataire_contribution import DestinataireContribution
from app.models.envoi_contribution import EnvoiContribution
from app.models.evenement import Evenement
from app.models.groupe import Groupe
from app.models.jeton import Jeton
from app.models.litterature import Litterature
from app.models.membre import Membre
from app.models.mouvement_reserve import MouvementReserve
from app.models.operation_bancaire import OperationBancaire
from app.models.poste import Poste
from app.models.poste_module import PosteModule
from app.models.presence import Presence
from app.models.proposition import Proposition
from app.models.pv_reunion import PvReunion
from app.models.rapport_adopte import RapportAdopte
from app.models.rapport_rsg import RapportRsg
from app.models.reserve import Reserve
from app.models.reunion import Reunion
from app.models.transaction_comptable import TransactionComptable
from app.models.vente_litterature import VenteLitterature
from app.models.vente_jeton import VenteJeton
POSTES = [
("RSG", "executif", "Représente le groupe auprès du district et rapporte les informations de service.", "2 ans de stabilité", 1, ["M3", "C2", "C5"]),
("RSG Substitut(e)", "executif", "Appuie le RSG et assure la relève lorsque nécessaire.", "1 an de stabilité", 2, ["M3", "C2", "C5"]),
("Trésorier(ière)", "executif", "Tient les registres, reçoit les sommes et présente les rapports mensuels.", "1 an de stabilité", 3, ["S1T", "S5", "M4", "G8"]),
("Secrétaire", "executif", "Prépare les procès-verbaux, les présences et la correspondance.", "6 mois de stabilité", 4, ["M0", "M1", "M2", "S0"]),
("Animateur(trice)", "service", "Anime les rencontres hebdomadaires.", "Disponibilité régulière", 5, ["S0"]),
("Responsable accueil", "physique", "Accueille les nouveaux et veille à l'atmosphère de la salle.", "Fiabilité", 6, ["C9"]),
("Responsable café", "physique", "Prépare le café et remet les petites dépenses au trésorier.", "Aucun", 7, ["S5"]),
("Responsable littérature", "service", "Maintient l'inventaire et remet les ventes au trésorier.", "Connaissance de la littérature", 8, ["S3", "C4", "M5"]),
("Responsable jetons", "service", "Prépare les jetons et les anniversaires.", "Aucun", 9, ["S2", "M6"]),
("Responsable clés", "physique", "Ouvre et ferme le local.", "Fiabilité", 10, []),
]
MEMBRES_DEMO = [
("Sysadmin", None, "sysadmin", "0000", "514-555-0000"),
("Claire", "B.", "claire.demo@example.test", "1111", "514-555-0101"),
("Marc", "L.", "marc.demo@example.test", "2222", "514-555-0102"),
("Nadia", "R.", "nadia.demo@example.test", "3333", "514-555-0103"),
("Simon", "T.", "simon.demo@example.test", "4444", "514-555-0104"),
("Julie", "P.", "julie.demo@example.test", "5555", "514-555-0105"),
("Karim", "A.", "karim.demo@example.test", "6666", "514-555-0106"),
("Mélanie", "S.", "melanie.demo@example.test", "7777", "514-555-0107"),
("Robert", "G.", "robert.demo@example.test", "8888", "514-555-0108"),
("Anne", "C.", "anne.demo@example.test", "9999", "514-555-0109"),
("Luc", "D.", "luc.demo@example.test", "1212", "514-555-0110"),
("Sophie", "M.", "sophie.demo@example.test", "3434", "514-555-0111"),
]
def _catalogue_litterature() -> list[dict]:
chemin = Path(__file__).resolve().parents[3] / "data" / "import" / "Liste-de-prix-rev.-Fevrier-2026.csv"
if chemin.exists():
with chemin.open(newline="", encoding="utf-8") as fichier:
return [
{
"code_bsg": row["code_bsg"].strip() or None,
"titre": row["titre"].strip(),
"categorie": row["categorie"].strip() or "brochure",
"prix_coutant": float(row["prix_coutant"]) if row["prix_coutant"].strip() else 0,
}
for row in csv.DictReader(fichier)
if row.get("titre", "").strip()
]
return [
{"code_bsg": "B-1", "titre": "Les Alcooliques anonymes", "categorie": "livre", "prix_coutant": 12.0},
{"code_bsg": "B-2", "titre": "Les Douze Étapes et les Douze Traditions", "categorie": "livre", "prix_coutant": 11.0},
{"code_bsg": "P-3", "titre": "Les AA sont-ils pour vous?", "categorie": "brochure", "prix_coutant": 0.0},
]
async def remettre_a_zero(db: AsyncSession) -> dict:
tables = ", ".join(table.name for table in reversed(Base.metadata.sorted_tables))
if tables:
await db.execute(text(f"TRUNCATE TABLE {tables} RESTART IDENTITY CASCADE"))
groupe, sysadmin, postes, litterature = await _creer_base(db)
await db.flush()
return {
"message": "Base remise à zéro",
"groupe_id": groupe.id,
"sysadmin_id": sysadmin.id,
"postes": len(postes),
"litterature": len(litterature),
}
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, groupe.id)
litterature = await _litterature(db)
await _ajouter_membres_demo(db, groupe, membres, postes)
membres = await _membres_par_courriel(db)
await _ajouter_historique_demo(db, groupe, membres, postes, litterature)
await db.flush()
return {
"message": "Base remise à zéro et données de démonstration installées",
"groupe_id": groupe.id,
"membres": len(membres),
"periode_mois": 12,
}
async def _creer_base(db: AsyncSession):
groupe = Groupe(
nom="Groupe Méditation",
district="87-16",
region="87",
adresse_local="Salle communautaire, Montréal",
jour_reunion="mardi",
heure_reunion=time(19, 30),
assemblee_affaires_ordre="premiere",
format_reunion="Discussion ouverte",
lien_aa87="https://aa87.org",
)
db.add(groupe)
await db.flush()
postes = {}
for nom, categorie, description, prerequis, ordre, modules in POSTES:
poste = Poste(
groupe_id=groupe.id,
nom=nom,
categorie=categorie,
description=description,
prerequis=prerequis,
ordre_affichage=ordre,
actif=True,
)
db.add(poste)
await db.flush()
postes[nom] = poste
for module_code in modules:
db.add(PosteModule(poste_id=poste.id, module_code=module_code))
sysadmin = Membre(
groupe_id=groupe.id,
prenom="Sysadmin",
nom=None,
telephone="514-555-0000",
courriel="sysadmin",
pin_hash=hash_pin("0000"),
actif=True,
)
db.add(sysadmin)
await db.flush()
litterature = []
for item in _catalogue_litterature():
lit = Litterature(
groupe_id=groupe.id,
titre=item["titre"][:200],
categorie=item["categorie"] if item["categorie"] in {"livre", "brochure", "depliant", "pamphlet"} else "brochure",
prix_coutant=item["prix_coutant"],
quantite_stock=8,
seuil_commande=2,
code_bsg=item["code_bsg"],
)
db.add(lit)
litterature.append(lit)
db.add(DestinataireContribution(groupe_id=groupe.id, nom="District 87-16", protege=True, actif=True))
db.add(DestinataireContribution(groupe_id=groupe.id, nom="Région 87", protege=False, actif=True))
db.add(DestinataireContribution(groupe_id=groupe.id, nom="Bureau des services généraux", protege=False, actif=True))
for destination, pct in [("district", 50), ("region", 20), ("bsg", 20), ("intergroupe", 10)]:
db.add(ConfigRepartition(groupe_id=groupe.id, destination=destination, pourcentage=pct, actif_depuis=date.today().replace(day=1)))
return groupe, sysadmin, postes, litterature
async def _ajouter_membres_demo(db: AsyncSession, groupe: Groupe, membres: dict, postes: dict) -> None:
for prenom, nom, courriel, pin, telephone in MEMBRES_DEMO[1:]:
membre = Membre(
groupe_id=groupe.id,
prenom=prenom,
nom=nom,
telephone=telephone,
courriel=courriel,
date_abstinence=date.today() - timedelta(days=365 * (2 + len(membres) % 12)),
pin_hash=hash_pin(pin),
actif=True,
)
db.add(membre)
await db.flush()
membres[courriel] = membre
affectations = [
("claire.demo@example.test", "RSG"),
("marc.demo@example.test", "RSG Substitut(e)"),
("nadia.demo@example.test", "Trésorier(ière)"),
("simon.demo@example.test", "Secrétaire"),
("julie.demo@example.test", "Animateur(trice)"),
("karim.demo@example.test", "Responsable littérature"),
("melanie.demo@example.test", "Responsable jetons"),
("robert.demo@example.test", "Responsable accueil"),
("anne.demo@example.test", "Responsable café"),
]
debut = date.today() - timedelta(days=240)
for courriel, poste_nom in affectations:
membre = membres.get(courriel)
if membre and poste_nom in postes:
db.add(Affectation(groupe_id=groupe.id, membre_id=membre.id, poste_id=postes[poste_nom].id, date_debut=debut, statut="actif"))
async def _ajouter_historique_demo(db: AsyncSession, groupe: Groupe, membres: dict, postes: dict, litterature: list[Litterature]) -> None:
tresorier = membres["nadia.demo@example.test"]
secretaire = membres["simon.demo@example.test"]
rsg = membres["claire.demo@example.test"]
membres_actifs = [m for m in membres.values() if m.courriel != "sysadmin"]
reserves = {
"Loyer": Reserve(groupe_id=groupe.id, nom="Loyer", montant=0, note="Prévoir trois mois de salle"),
"Littérature": Reserve(groupe_id=groupe.id, nom="Littérature", montant=0, note="Réapprovisionnement"),
"Prudentielle": Reserve(groupe_id=groupe.id, nom="Prudentielle", montant=0, note="Réserve de sécurité"),
}
for reserve in reserves.values():
db.add(reserve)
await db.flush()
debut = _premier_mardi(date.today() - timedelta(days=365))
dates_reunions = [debut + timedelta(days=7 * i) for i in range(52)]
mois_vus = set()
banque = 0.0
encaisse = 0.0
compteurs = {"RES": 0, "NOM": 0, "DON": 0}
for index, jour in enumerate(dates_reunions):
type_reunion = "affaires" if jour.month not in mois_vus else "reguliere"
mois_vus.add(jour.month)
reunion = Reunion(
groupe_id=groupe.id,
type=type_reunion,
date_reunion=datetime.combine(jour, groupe.heure_reunion or time(19, 30)).replace(tzinfo=timezone.utc),
animateur_id=membres_actifs[index % len(membres_actifs)].id,
theme=["Acceptation", "Service", "Gratitude", "Honnêteté"][index % 4],
annonces={"district": index % 5 == 0, "anniversaire": index % 8 == 0},
note="Réunion de démonstration réaliste.",
)
db.add(reunion)
await db.flush()
for membre in membres_actifs:
db.add(Presence(reunion_id=reunion.id, membre_id=membre.id, present=(index + len(membre.prenom)) % 7 != 0))
montant_collecte = round(82 + (index % 6) * 9 + (index % 3) * 4, 2)
collecte = Collecte(
groupe_id=groupe.id,
date_collecte=jour,
montant=montant_collecte,
reunion_id=reunion.id,
detenteur_id=membres_actifs[index % len(membres_actifs)].id,
statut="recu",
saisi_par=secretaire.id,
confirme_par=secretaire.id,
date_confirmation=datetime.combine(jour, time(21, 15)).replace(tzinfo=timezone.utc),
recu_par=tresorier.id,
date_reception=jour,
)
db.add(collecte)
await db.flush()
encaisse += montant_collecte
db.add(TransactionComptable(groupe_id=groupe.id, date_transaction=jour, type="collecte", compte="encaisse", montant=montant_collecte, sens="debit", source_table="collectes", source_id=collecte.id, note="Collecte 7e Tradition", cree_par=tresorier.id))
if index % 4 == 0:
don = 40 + (index % 5) * 10
banque += don
db.add(TransactionComptable(groupe_id=groupe.id, date_transaction=jour, type="don", compte="banque", montant=don, sens="debit", note="Don direct au compte bancaire", cree_par=tresorier.id))
if index % 4 == 3:
depot = round(encaisse - 25, 2) if encaisse > 50 else round(encaisse, 2)
if depot > 0:
encaisse -= depot
banque += depot
db.add(OperationBancaire(groupe_id=groupe.id, type="depot", montant=depot, date_operation=jour, effectue_par=tresorier.id, note="Dépôt mensuel de l'encaisse"))
db.add(TransactionComptable(groupe_id=groupe.id, date_transaction=jour, type="banque_depot", compte="banque", montant=depot, sens="debit", note="Dépôt de l'encaisse", cree_par=tresorier.id))
db.add(TransactionComptable(groupe_id=groupe.id, date_transaction=jour, type="banque_depot", compte="encaisse", montant=depot, sens="credit", note="Dépôt de l'encaisse", cree_par=tresorier.id))
if type_reunion == "affaires":
await _decision(db, groupe, reunion, compteurs, "RES", jour, secretaire, rsg, "Adopter l'ordre du jour", "L'assemblée doit traiter les affaires courantes.", "L'ordre du jour est adopté tel que présenté.")
await _decision(db, groupe, reunion, compteurs, "RES", jour, rsg, secretaire, f"Orientation du mois {jour.month:02d}", "Le groupe souhaite garder une conscience de groupe active.", "Le groupe retient les priorités présentées pour le mois.")
pv = PvReunion(
reunion_id=reunion.id,
redacteur_id=secretaire.id,
contenu=f"Procès-verbal de l'assemblée du {jour.isoformat()}.\nPrésences vérifiées. Rapports reçus. Décisions consignées.",
decisions=[{"sujet": "Adoption du procès-verbal précédent", "decision": "Adopté", "proposeur": rsg.prenom, "secondeur": secretaire.prenom, "statut": "complete"}],
approuve=index > 4,
)
db.add(pv)
db.add(RapportAdopte(
groupe_id=groupe.id,
adopte_par_id=tresorier.id,
type_rapport="tresorerie",
titre=f"Rapport du trésorier — {jour.strftime('%Y-%m')}",
annee=jour.year,
mois=jour.month,
contenu_html=f"<html><body><h1>Rapport du trésorier</h1><p>Rapport adopté pour {jour.strftime('%Y-%m')}.</p><p>Banque: {banque:.2f} $</p><p>Encaisse: {encaisse:.2f} $</p></body></html>",
donnees={"banque": round(banque, 2), "encaisse": round(encaisse, 2)},
))
if index % 6 == 1:
montant = 32 + (index % 4) * 6
methode = "COLLECTE" if encaisse >= montant else "VIREMENT"
if methode == "COLLECTE":
encaisse -= montant
compte = "encaisse"
else:
banque -= montant
compte = "banque"
depense = Depense(groupe_id=groupe.id, date_depense=jour, montant=montant, qui=membres_actifs[index % len(membres_actifs)].id, quoi=["Café et lait", "Papeterie", "Location de salle", "Fournitures accueil"][index % 4], paye=True, methode=methode, saisi_par=membres_actifs[index % len(membres_actifs)].id, confirme_par=tresorier.id, date_confirmation=datetime.combine(jour, time(20, 45)).replace(tzinfo=timezone.utc))
db.add(depense)
await db.flush()
db.add(TransactionComptable(groupe_id=groupe.id, date_transaction=jour, type="depense", compte=compte, montant=montant, sens="credit", source_table="depenses", source_id=depense.id, note=depense.quoi, cree_par=tresorier.id))
if index % 8 == 2 and litterature:
item = litterature[index % len(litterature)]
montant = float(item.prix_coutant or 2) * 2
item.quantite_stock = max(0, item.quantite_stock - 2)
vente = VenteLitterature(groupe_id=groupe.id, reunion_id=reunion.id, litterature_id=item.id, quantite=2, montant=montant, date_vente=jour, vendu_par=membres["karim.demo@example.test"].id, remis_tresorier=True, recu_par=tresorier.id, date_remise=jour)
db.add(vente)
await db.flush()
encaisse += montant
db.add(TransactionComptable(groupe_id=groupe.id, date_transaction=jour, type="vente_litterature", compte="encaisse", montant=montant, sens="debit", source_table="ventes_litterature", source_id=vente.id, note="Vente de littérature", cree_par=tresorier.id))
if index % 12 == 6:
montant = 6
vente_jeton = VenteJeton(
groupe_id=groupe.id,
reunion_id=reunion.id,
type_jeton=["desir", "1mois", "1an"][index % 3],
quantite=2,
montant=montant,
date_vente=jour,
vendu_par=membres["melanie.demo@example.test"].id,
remis_tresorier=True,
recu_par=tresorier.id,
date_remise=jour,
)
db.add(vente_jeton)
await db.flush()
encaisse += montant
db.add(TransactionComptable(groupe_id=groupe.id, date_transaction=jour, type="vente_jeton", compte="encaisse", montant=montant, sens="debit", source_table="ventes_jetons", source_id=vente_jeton.id, note="Vente de jetons", cree_par=tresorier.id))
if index % 13 == 4:
montant = 125
if banque >= montant:
banque -= montant
decision = await _decision(db, groupe, reunion, compteurs, "DON", jour, rsg, secretaire, "Contribution au district", "La conscience de groupe prévoit soutenir les services.", "Le groupe décide d'envoyer une contribution au District 87-16.")
contribution = EnvoiContribution(groupe_id=groupe.id, date_envoi=jour, destination="District 87-16", montant=montant, methode="VIREMENT", resolution_id=decision.id, engagee=True, debitee=True, saisi_par=tresorier.id)
db.add(contribution)
await db.flush()
db.add(TransactionComptable(groupe_id=groupe.id, date_transaction=jour, type="contribution", compte="banque", montant=montant, sens="credit", source_table="envois_contributions", source_id=contribution.id, note="Contribution au district", cree_par=tresorier.id))
if index in {9, 22, 35}:
candidat = membres_actifs[index % len(membres_actifs)]
poste = postes[["Responsable accueil", "Responsable café", "Responsable clés"][index % 3]]
candidature = Candidature(groupe_id=groupe.id, poste_id=poste.id, membre_id=candidat.id, type="nomination", statut="acceptee", motivation="Disponibilité confirmée.", nomme_par=rsg.id, acceptee_par_membre=True)
db.add(candidature)
await db.flush()
decision = await _decision(db, groupe, reunion, compteurs, "NOM", jour, rsg, secretaire, f"Nomination de {candidat.prenom}", "Un poste doit être pourvu pour soutenir le groupe.", f"Le groupe nomme {candidat.prenom} au poste de {poste.nom}.")
decision.candidature_id = candidature.id
candidature.proposition_id = decision.id
if index % 10 == 5:
db.add(Jeton(groupe_id=groupe.id, reunion_id=reunion.id, membre_id=membres_actifs[index % len(membres_actifs)].id, type_jeton="multiple", date_remise=jour, annees=1 + index % 5, remis_par=membres["melanie.demo@example.test"].id))
if index % 17 == 7:
reserve = reserves[["Loyer", "Littérature", "Prudentielle"][index % 3]]
montant = 75
if banque >= montant:
reserve.montant = float(reserve.montant or 0) + montant
db.add(MouvementReserve(groupe_id=groupe.id, source_id=None, destination_id=reserve.id, montant=montant, type="reserve_virement", note="Affectation au solde réservé", cree_par=tresorier.id, cree_le=datetime.combine(jour, time(21, 0)).replace(tzinfo=timezone.utc)))
db.add(Collecte(groupe_id=groupe.id, date_collecte=dates_reunions[-1] + timedelta(days=7), montant=96, detenteur_id=membres["luc.demo@example.test"].id, statut="en_attente", saisi_par=membres["luc.demo@example.test"].id))
db.add(Depense(groupe_id=groupe.id, date_depense=date.today(), montant=48, qui=membres["anne.demo@example.test"].id, quoi="Fournitures à confirmer", paye=False, methode=None, saisi_par=membres["anne.demo@example.test"].id))
db.add(Proposition(groupe_id=groupe.id, sujet="Réviser le format de discussion", description="Proposition laissée en réflexion pour la prochaine assemblée.", proposeur_id=membres["julie.demo@example.test"].id, secondeur_id=membres["marc.demo@example.test"].id, statut="secondee"))
db.add(Proposition(groupe_id=groupe.id, sujet="Acheter un projecteur", description="La dépense n'est pas jugée prioritaire.", proposeur_id=membres["luc.demo@example.test"].id, secondeur_id=membres["sophie.demo@example.test"].id, statut="rejetee", etant_donne_que="La réserve prudente doit d'abord être consolidée."))
db.add(Evenement(groupe_id=groupe.id, titre="Atelier Étapes et Traditions", description="Atelier régional ouvert aux membres.", date_evenement=date.today() + timedelta(days=24), lieu="Centre communautaire", cree_par=rsg.id))
db.add(Evenement(groupe_id=groupe.id, titre="Anniversaire du groupe", description="Souper partage et réunion ouverte.", date_evenement=date.today() - timedelta(days=40), lieu="Local habituel", cree_par=secretaire.id))
db.add(RapportRsg(groupe_id=groupe.id, reunion_dist=date.today() - timedelta(days=18), redacteur_id=rsg.id, decisions=["Le district prépare l'inventaire annuel.", "Besoin de bénévoles pour l'accueil."], infos_region="La région sollicite les groupes pour les ateliers.", sujets_groupe="Questions transmises par la conscience de groupe."))
async def _decision(db, groupe, reunion, compteurs, prefixe, jour, proposeur, secondeur, sujet, attendu, decide):
compteurs[prefixe] += 1
numero = f"{prefixe}-{jour.year}-{compteurs[prefixe]:03d}"
proposition = Proposition(
groupe_id=groupe.id,
reunion_id=reunion.id if reunion else None,
sujet=sujet,
description=attendu,
proposeur_id=proposeur.id,
secondeur_id=secondeur.id,
statut="adoptee",
numero_resolution=numero,
date_vote=jour,
date_adoption=jour,
etant_donne_que=attendu,
groupe_a_decide_de=decide,
)
db.add(proposition)
await db.flush()
return proposition
def _premier_mardi(jour: date) -> date:
delta = (1 - jour.weekday()) % 7
return jour + timedelta(days=delta)
async def _premier_groupe(db: AsyncSession) -> Groupe:
from sqlalchemy import select
result = await db.execute(select(Groupe).limit(1))
return result.scalar_one()
async def _membres_par_courriel(db: AsyncSession) -> dict[str, Membre]:
from sqlalchemy import select
result = await db.execute(select(Membre))
return {m.courriel: m for m in result.scalars().all()}
async def _postes_par_nom(db: AsyncSession, groupe_id) -> dict[str, Poste]:
from sqlalchemy import select
result = await db.execute(select(Poste).where(Poste.groupe_id == groupe_id))
return {p.nom: p for p in result.scalars().all()}
async def _litterature(db: AsyncSession) -> list[Litterature]:
from sqlalchemy import select
result = await db.execute(select(Litterature))
return result.scalars().all()