groupe-meditation/backend/app/services/admin_database.py
Daniel Allaire 1946b8741e Ajouter acces sysadmin aux groupes
- bouton Sysadmin public avec PIN

- CRUD sysadmin des groupes

- groupe demo protege et reinitialisable
2026-05-31 22:50:35 -04:00

796 lines
38 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 select, 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.historique_modification import HistoriqueModification
from app.models.jeton import Jeton
from app.models.journal_action import JournalAction
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.rotation import Rotation
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", "M6"]),
("Responsable jetons", "service", "Prépare les jetons et les anniversaires.", "Aucun", 9, ["S2", "M5", "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:
existant = (await db.execute(select(Groupe).where(Groupe.nom == "Groupe Résilience"))).scalar_one_or_none()
if existant:
membres_existants = await _membres_par_courriel(db, existant.id)
return {
"message": "Le groupe de démonstration existe déjà.",
"groupe_id": existant.id,
"membres": len(membres_existants),
"periode_mois": 12,
"rencontres": 52,
"assemblees": 12,
}
groupe, sysadmin, postes, litterature = await _creer_base(
db,
nom_groupe="Groupe Résilience",
adresse_local="Salle communautaire Saint-Louis, Montréal",
)
membres = {sysadmin.courriel: sysadmin}
await _ajouter_membres_demo(db, groupe, membres, postes)
membres = await _membres_par_courriel(db, groupe.id)
await _ajouter_historique_demo(db, groupe, membres, postes, litterature)
await db.flush()
return {
"message": "Groupe de démonstration installé sans modifier les autres groupes",
"groupe_id": groupe.id,
"membres": len(membres),
"periode_mois": 12,
"rencontres": 52,
"assemblees": 12,
}
async def reinitialiser_demo(db: AsyncSession) -> dict:
groupe = (await db.execute(select(Groupe).where(Groupe.nom == "Groupe Résilience"))).scalar_one_or_none()
if not groupe:
return await initialiser_demo(db)
await _vider_groupe(db, groupe.id)
sysadmin, postes, litterature = await _peupler_groupe_base(db, groupe)
membres = {sysadmin.courriel: sysadmin}
await _ajouter_membres_demo(db, groupe, membres, postes)
membres = await _membres_par_courriel(db, groupe.id)
await _ajouter_historique_demo(db, groupe, membres, postes, litterature)
await db.flush()
return {
"message": "Données fictives du groupe de démonstration réinitialisées",
"groupe_id": groupe.id,
"membres": len(membres),
"periode_mois": 12,
"rencontres": 52,
"assemblees": 12,
}
async def creer_groupe(db: AsyncSession, data: dict) -> Groupe:
nom = (data.get("nom") or "").strip()
if not nom:
raise ValueError("Nom requis")
if nom == "Groupe Résilience":
raise ValueError("Ce nom est réservé au groupe de démonstration")
groupe = Groupe(
nom=nom,
district=(data.get("district") or "87-16").strip(),
region=(data.get("region") or "87").strip(),
adresse_local=(data.get("adresse_local") or "").strip() or None,
jour_reunion=(data.get("jour_reunion") or "mardi").strip() or None,
heure_reunion=_parse_heure(data.get("heure_reunion")),
assemblee_affaires_ordre=(data.get("assemblee_affaires_ordre") or "premiere").strip(),
format_reunion=(data.get("format_reunion") or "Discussion ouverte").strip() or None,
lien_aa87=(data.get("lien_aa87") or "https://aa87.org").strip(),
)
db.add(groupe)
await db.flush()
await _peupler_groupe_base(db, groupe)
await db.flush()
return groupe
async def modifier_groupe(db: AsyncSession, groupe_id, data: dict) -> Groupe:
groupe = await db.get(Groupe, groupe_id)
if not groupe:
raise ValueError("Groupe introuvable")
nom = (data.get("nom") or groupe.nom).strip()
if groupe.nom == "Groupe Résilience" and nom != "Groupe Résilience":
raise ValueError("Le groupe de démonstration ne peut pas être renommé")
if nom == "Groupe Résilience" and groupe.nom != "Groupe Résilience":
raise ValueError("Ce nom est réservé au groupe de démonstration")
groupe.nom = nom
for attr in ["district", "region", "adresse_local", "jour_reunion", "assemblee_affaires_ordre", "format_reunion", "lien_aa87"]:
if attr in data:
valeur = data.get(attr)
setattr(groupe, attr, valeur.strip() if isinstance(valeur, str) and valeur.strip() else None)
if "heure_reunion" in data:
groupe.heure_reunion = _parse_heure(data.get("heure_reunion"))
await db.flush()
return groupe
async def supprimer_groupe(db: AsyncSession, groupe_id) -> dict:
groupe = await db.get(Groupe, groupe_id)
if not groupe:
raise ValueError("Groupe introuvable")
if groupe.nom == "Groupe Résilience":
raise ValueError("Le groupe de démonstration est protégé")
nb_groupes = (await db.execute(select(Groupe))).scalars().all()
if len(nb_groupes) <= 1:
raise ValueError("Il doit rester au moins un groupe")
await _vider_groupe(db, groupe.id)
await db.delete(groupe)
await db.flush()
return {"message": f"Groupe {groupe.nom} supprimé"}
async def _creer_base(
db: AsyncSession,
nom_groupe: str = "Groupe Méditation",
adresse_local: str = "Salle communautaire, Montréal",
):
groupe = Groupe(
nom=nom_groupe,
district="87-16",
region="87",
adresse_local=adresse_local,
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()
sysadmin, postes, litterature = await _peupler_groupe_base(db, groupe)
return groupe, sysadmin, postes, litterature
async def _peupler_groupe_base(db: AsyncSession, groupe: Groupe):
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 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}
rotations_postes = ["Animateur(trice)", "Responsable accueil", "Responsable café", "Responsable clés"]
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.",
cree_le=_moment(jour, 18, 30),
modifie_le=_moment(jour, 18, 30),
)
db.add(reunion)
await db.flush()
await _trace(
db, groupe, secretaire, jour, "reunions", "reunion.planifiee",
f"{'Assemblée d affaires' if type_reunion == 'affaires' else 'Rencontre hebdomadaire'} planifiée.",
reunion.id,
apres={"date": jour.isoformat(), "type": type_reunion},
chemin="/reunions" if type_reunion == "reguliere" else "/presences/reunion",
)
for decalage, poste_nom in enumerate(rotations_postes):
poste = postes.get(poste_nom)
if not poste:
continue
serviteur = membres_actifs[(index + decalage) % len(membres_actifs)]
rotation = Rotation(
groupe_id=groupe.id,
date_reunion=jour,
poste_id=poste.id,
membre_id=serviteur.id,
note="Rotation planifiée automatiquement pour la démonstration.",
cree_par=secretaire.id,
cree_le=_moment(jour, 18, 35),
)
db.add(rotation)
await db.flush()
await _trace(
db, groupe, secretaire, jour, "rotations", "rotations.planifiees",
"Rotations hebdomadaires planifiées.",
reunion.id,
apres={"postes": rotations_postes},
chemin="/rotations/batch",
)
for membre in membres_actifs:
db.add(Presence(reunion_id=reunion.id, membre_id=membre.id, present=(index + len(membre.prenom)) % 7 != 0))
await _trace(
db, groupe, secretaire, jour, "presences", "presences.enregistrees",
"Présences de la rencontre enregistrées.",
reunion.id,
apres={"presents": sum(1 for m in membres_actifs if (index + len(m.prenom)) % 7 != 0)},
chemin="/presences/",
)
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,
cree_le=_moment(jour, 20, 55),
modifie_le=_moment(jour, 21, 5),
)
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))
await _trace(
db, groupe, tresorier, jour, "collectes", "collecte.recue",
"Collecte saisie puis reçue par le trésorier.",
collecte.id,
apres={"montant": montant_collecte, "statut": "recu"},
chemin="/collectes",
)
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))
await _trace(
db, groupe, tresorier, jour, "transactions_comptables", "don.direct",
"Don reçu directement au compte bancaire.",
reunion.id,
apres={"montant": don, "compte": "banque"},
chemin="/operations-bancaires/don",
)
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))
await _trace(
db, groupe, tresorier, jour, "operations_bancaires", "encaisse.deposee",
"Dépôt de l'encaisse au compte bancaire.",
collecte.id,
avant={"encaisse": round(encaisse + depot, 2), "banque": round(banque - depot, 2)},
apres={"encaisse": round(encaisse, 2), "banque": round(banque, 2)},
chemin="/operations-bancaires",
)
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)},
adopte_le=_moment(jour, 21, 35),
))
await _trace(
db, groupe, secretaire, jour, "pv_reunions", "pv.redige",
"Procès-verbal d'assemblée rédigé et classé.",
pv.id,
apres={"approuve": pv.approuve},
chemin="/pv/",
)
await _trace(
db, groupe, tresorier, jour, "rapports_adoptes", "rapport_tresorier.adopte",
"Rapport du trésorier adopté par la conscience de groupe.",
reunion.id,
apres={"annee": jour.year, "mois": jour.month, "banque": round(banque, 2), "encaisse": round(encaisse, 2)},
chemin="/rapports/etat-resultats/adopter",
)
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))
await _trace(
db, groupe, tresorier, jour, "depenses", "depense.confirmee",
"Dépense soumise puis confirmée par le trésorier.",
depense.id,
apres={"montant": montant, "methode": methode, "quoi": depense.quoi},
chemin="/depenses",
)
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))
await _trace(
db, groupe, membres["karim.demo@example.test"], jour, "ventes_litterature", "vente_litterature.recue",
"Vente de littérature enregistrée et remise au trésorier.",
vente.id,
apres={"titre": item.titre, "quantite": 2, "montant": montant},
chemin="/ventes-litterature",
)
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))
await _trace(
db, groupe, membres["melanie.demo@example.test"], jour, "ventes_jetons", "vente_jeton.recue",
"Vente de jetons enregistrée et remise au trésorier.",
vente_jeton.id,
apres={"type_jeton": vente_jeton.type_jeton, "quantite": 2, "montant": montant},
chemin="/jetons/ventes",
)
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))
await _trace(
db, groupe, tresorier, jour, "envois_contributions", "contribution.debitee",
"Contribution décidée, engagée et débitée.",
contribution.id,
apres={"destination": contribution.destination, "montant": montant},
chemin="/contributions",
)
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
db.add(Affectation(groupe_id=groupe.id, membre_id=candidat.id, poste_id=poste.id, date_debut=jour, statut="actif"))
await _trace(
db, groupe, rsg, jour, "candidatures", "nomination.adoptee",
"Nomination proposée, secondée et adoptée.",
candidature.id,
apres={"membre": candidat.prenom, "poste": poste.nom, "resolution": decision.numero_resolution},
chemin="/postes/candidatures",
)
if index % 10 == 5:
jeton = 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, cree_le=_moment(jour, 20, 5))
db.add(jeton)
await db.flush()
await _trace(
db, groupe, membres["melanie.demo@example.test"], jour, "jetons", "jeton.remis",
"Remise de jeton enregistrée pour statistiques.",
jeton.id,
apres={"annees": jeton.annees},
chemin="/jetons",
)
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)))
await _trace(
db, groupe, tresorier, jour, "mouvements_reserves", "reserve.transfert",
"Montant transféré du disponible vers une réserve.",
reserve.id,
avant={"reserve": round(float(reserve.montant or 0) - montant, 2)},
apres={"reserve": round(float(reserve.montant or 0), 2), "montant": montant},
chemin="/operations-bancaires/reserves/transfert",
)
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,
cree_le=_moment(jour, 20, 30),
modifie_le=_moment(jour, 20, 45),
)
db.add(proposition)
await db.flush()
await _trace(
db, groupe, proposeur, jour, "propositions", f"decision.{prefixe.lower()}",
"Décision adoptée par la conscience de groupe.",
proposition.id,
apres={"numero": numero, "sujet": sujet, "decision": decide},
chemin="/propositions/voter",
)
return proposition
def _moment(jour: date, heure: int = 20, minute: int = 0) -> datetime:
return datetime.combine(jour, time(heure, minute)).replace(tzinfo=timezone.utc)
def _parse_heure(valeur) -> time | None:
if isinstance(valeur, time):
return valeur
if not valeur:
return None
heures, minutes = str(valeur).split(":", 1)
return time(int(heures), int(minutes[:2]))
async def _vider_groupe(db: AsyncSession, groupe_id) -> None:
params = {"gid": str(groupe_id)}
await db.execute(text("DELETE FROM presences WHERE reunion_id IN (SELECT id FROM reunions WHERE groupe_id = :gid)"), params)
await db.execute(text("DELETE FROM pv_reunions WHERE reunion_id IN (SELECT id FROM reunions WHERE groupe_id = :gid)"), params)
await db.execute(text("DELETE FROM postes_modules WHERE poste_id IN (SELECT id FROM postes WHERE groupe_id = :gid)"), params)
await db.execute(text("UPDATE candidatures SET proposition_id = NULL WHERE groupe_id = :gid"), params)
await db.execute(text("UPDATE propositions SET candidature_id = NULL WHERE groupe_id = :gid"), params)
for table in [
"journal_actions",
"historique_modifications",
"push_subscriptions",
"mouvements_reserves",
"transactions_comptables",
"rapports_adoptes",
"rapports_rsg",
"ventes_litterature",
"ventes_jetons",
"jetons",
"envois_contributions",
"destinataires_contributions",
"config_repartition",
"operations_bancaires",
"reserves",
"evenements",
"depenses",
"collectes",
"propositions",
"candidatures",
"rotations",
"affectations",
"litterature",
"invitations",
"reunions",
"postes",
"membres",
]:
await db.execute(text(f"DELETE FROM {table} WHERE groupe_id = :gid"), params)
async def _trace(
db: AsyncSession,
groupe: Groupe,
membre: Membre,
jour: date,
table: str,
action: str,
raison: str,
objet_id=None,
avant: dict | None = None,
apres: dict | None = None,
chemin: str | None = None,
):
moment = _moment(jour, 21, 50)
cible = objet_id or groupe.id
db.add(HistoriqueModification(
groupe_id=groupe.id,
table_cible=table,
objet_id=cible,
action=action,
raison=raison,
membre_id=membre.id if membre else None,
chemin_api=chemin,
avant=avant,
apres=apres,
cree_le=moment,
))
db.add(JournalAction(
groupe_id=groupe.id,
membre_id=membre.id if membre else None,
methode="POST",
chemin=chemin or f"/demo/{table}",
statut_http=200,
adresse_ip="127.0.0.1",
user_agent="DemoSeeder/1.0",
details={"action": action, "raison": raison},
cree_le=moment,
))
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, groupe_id=None) -> dict[str, Membre]:
from sqlalchemy import select
query = select(Membre)
if groupe_id is not None:
query = query.where(Membre.groupe_id == groupe_id)
result = await db.execute(query)
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()