diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 17a1515..acaf408 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -2,24 +2,59 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import func, select +from pydantic import BaseModel from app.core.database import get_db -from app.core.security import get_current_membre, is_sysadmin -from app.services.admin_database import initialiser_demo, remettre_a_zero +from app.core.security import create_token, get_current_membre, is_sysadmin, verify_pin +from app.services.admin_database import ( + creer_groupe, + initialiser_demo, + modifier_groupe, + reinitialiser_demo, + remettre_a_zero, + supprimer_groupe, +) from app.models.groupe import Groupe from app.models.membre import Membre from app.models.reunion import Reunion from app.models.historique_modification import HistoriqueModification +from app.schemas.schemas import MembrePublic, TokenResponse router = APIRouter(prefix="/admin", tags=["Administration"]) +class SysadminLoginRequest(BaseModel): + pin: str + + +class GroupeAdminRequest(BaseModel): + nom: str + district: str = "87-16" + region: str = "87" + adresse_local: str | None = None + jour_reunion: str | None = "mardi" + heure_reunion: str | None = "19:30" + assemblee_affaires_ordre: str = "premiere" + format_reunion: str | None = "Discussion ouverte" + lien_aa87: str = "https://aa87.org" + + async def require_sysadmin(membre=Depends(get_current_membre)): if not is_sysadmin(membre): raise HTTPException(status_code=403, detail="Réservé au sysadmin") return membre +@router.post("/sysadmin-login", response_model=TokenResponse) +async def sysadmin_login(req: SysadminLoginRequest, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(Membre).where(Membre.courriel == "sysadmin", Membre.actif == True)) + for membre in result.scalars().all(): + if verify_pin(req.pin, membre.pin_hash): + token = create_token(str(membre.id), str(membre.groupe_id), expire_jours=1) + return TokenResponse(access_token=token, membre=MembrePublic.model_validate(membre)) + raise HTTPException(status_code=401, detail="PIN Sysadmin invalide") + + @router.post("/reset") async def reset_database( _sysadmin=Depends(require_sysadmin), @@ -36,11 +71,20 @@ async def demo_database( return await initialiser_demo(db) +@router.post("/demo/reset") +async def reset_demo_database( + _sysadmin=Depends(require_sysadmin), + db: AsyncSession = Depends(get_db), +): + return await reinitialiser_demo(db) + + @router.get("/groupes") async def liste_groupes_admin( _sysadmin=Depends(require_sysadmin), db: AsyncSession = Depends(get_db), ): + await initialiser_demo(db) groupes = (await db.execute(select(Groupe).order_by(Groupe.nom))).scalars().all() out = [] for groupe in groupes: @@ -63,9 +107,51 @@ async def liste_groupes_admin( "heure_reunion": groupe.heure_reunion.strftime("%H:%M") if groupe.heure_reunion else None, "assemblee_affaires_ordre": groupe.assemblee_affaires_ordre, "format_reunion": groupe.format_reunion, + "lien_aa87": groupe.lien_aa87, "cree_le": groupe.cree_le, "membres": nb_membres, "reunions": nb_reunions, "traces": nb_traces, + "demo": groupe.nom == "Groupe Résilience", + "protege": groupe.nom == "Groupe Résilience", }) return out + + +@router.post("/groupes") +async def creer_groupe_admin( + req: GroupeAdminRequest, + _sysadmin=Depends(require_sysadmin), + db: AsyncSession = Depends(get_db), +): + try: + groupe = await creer_groupe(db, req.model_dump()) + return {"message": "Groupe créé", "id": str(groupe.id)} + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + +@router.patch("/groupes/{groupe_id}") +async def modifier_groupe_admin( + groupe_id: str, + req: GroupeAdminRequest, + _sysadmin=Depends(require_sysadmin), + db: AsyncSession = Depends(get_db), +): + try: + groupe = await modifier_groupe(db, groupe_id, req.model_dump()) + return {"message": "Groupe modifié", "id": str(groupe.id)} + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + +@router.delete("/groupes/{groupe_id}") +async def supprimer_groupe_admin( + groupe_id: str, + _sysadmin=Depends(require_sysadmin), + db: AsyncSession = Depends(get_db), +): + try: + return await supprimer_groupe(db, groupe_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 67f22c9..24c093f 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -10,6 +10,7 @@ from app.models.membre import Membre from app.models.groupe import Groupe from app.models.invitation import Invitation from app.schemas.schemas import ConnexionRequest, InscriptionRequest, TokenResponse, MembrePublic +from app.services.admin_database import initialiser_demo router = APIRouter(prefix="/auth", tags=["Authentification"]) @@ -27,6 +28,7 @@ async def health(db: AsyncSession = Depends(get_db)): @router.get("/groupes") async def liste_groupes(db: AsyncSession = Depends(get_db)): + await initialiser_demo(db) result = await db.execute(select(Groupe).order_by(Groupe.nom)) groupes = result.scalars().all() return [{ diff --git a/backend/app/services/admin_database.py b/backend/app/services/admin_database.py index 37ba7c8..39156fe 100644 --- a/backend/app/services/admin_database.py +++ b/backend/app/services/admin_database.py @@ -111,7 +111,7 @@ async def initialiser_demo(db: AsyncSession) -> dict: if existant: membres_existants = await _membres_par_courriel(db, existant.id) return { - "message": "Le groupe de démonstration existe déjà. Aucune donnée existante n'a été modifiée.", + "message": "Le groupe de démonstration existe déjà.", "groupe_id": existant.id, "membres": len(membres_existants), "periode_mois": 12, @@ -120,6 +120,7 @@ async def initialiser_demo(db: AsyncSession) -> dict: } groupe, sysadmin, postes, litterature = await _creer_base( + db, nom_groupe="Groupe Résilience", adresse_local="Salle communautaire Saint-Louis, Montréal", ) @@ -139,6 +140,86 @@ async def initialiser_demo(db: AsyncSession) -> dict: } +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", @@ -157,7 +238,11 @@ async def _creer_base( ) 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( @@ -206,7 +291,7 @@ async def _creer_base( 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 + return sysadmin, postes, litterature async def _ajouter_membres_demo(db: AsyncSession, groupe: Groupe, membres: dict, postes: dict) -> None: @@ -587,6 +672,54 @@ 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, diff --git a/deploy/seed.py b/deploy/seed.py index e036d18..ca50417 100644 --- a/deploy/seed.py +++ b/deploy/seed.py @@ -17,6 +17,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend")) from app.core.database import engine, Base, async_session from app.core.security import hash_pin +from app.services.admin_database import initialiser_demo # Importer TOUS les modèles pour que create_all crée toutes les tables from app.models.groupe import Groupe from app.models.membre import Membre @@ -200,6 +201,9 @@ async def seed(): )) print(" ✓ Destinataire de contribution : District 87-16") + demo = await initialiser_demo(db) + print(f" ✓ Groupe de démonstration : {demo['message']}") + await db.commit() print(f"\n═══ Initialisation terminée ═══") diff --git a/frontend/src/pages/GestionGroupes.jsx b/frontend/src/pages/GestionGroupes.jsx index f100d59..94e71ad 100644 --- a/frontend/src/pages/GestionGroupes.jsx +++ b/frontend/src/pages/GestionGroupes.jsx @@ -12,6 +12,8 @@ export default function GestionGroupes() { const { loadGroupes, logout } = useStore(); const [groupes, setGroupes] = useState([]); const [ouvertId, setOuvertId] = useState(''); + const [editionId, setEditionId] = useState(''); + const [form, setForm] = useState(null); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState(''); @@ -33,15 +35,86 @@ export default function GestionGroupes() { useEffect(() => { charger(); }, []); - const installerDemo = async () => { - const confirmation = window.prompt('Cette action ajoute le groupe de démonstration "Groupe Résilience" sans effacer les groupes existants. Tapez CONFIRMER pour continuer.'); + const resetDemo = async () => { + const confirmation = window.prompt('Cette action réinitialise seulement les données fictives du groupe de démonstration. Tapez CONFIRMER pour continuer.'); if (confirmation !== 'CONFIRMER') return; setBusy(true); setError(''); setMsg(''); try { - const res = await api.post('/admin/demo'); - setMsg(res.message || 'Groupe de démonstration installé'); + const res = await api.post('/admin/demo/reset'); + setMsg(res.message || 'Groupe de démonstration réinitialisé'); + await charger(); + await loadGroupes(); + } catch (e) { + setError(e.message); + } finally { + setBusy(false); + } + }; + + const nouveauGroupe = () => { + setEditionId('nouveau'); + setForm({ + nom: '', + district: '87-16', + region: '87', + adresse_local: '', + jour_reunion: 'mardi', + heure_reunion: '19:30', + assemblee_affaires_ordre: 'premiere', + format_reunion: 'Discussion ouverte', + lien_aa87: 'https://aa87.org', + }); + }; + + const modifier = (groupe) => { + setEditionId(groupe.id); + setForm({ + nom: groupe.nom || '', + district: groupe.district || '87-16', + region: groupe.region || '87', + adresse_local: groupe.adresse_local || '', + jour_reunion: groupe.jour_reunion || 'mardi', + heure_reunion: groupe.heure_reunion || '19:30', + assemblee_affaires_ordre: groupe.assemblee_affaires_ordre || 'premiere', + format_reunion: groupe.format_reunion || '', + lien_aa87: groupe.lien_aa87 || 'https://aa87.org', + }); + setOuvertId(groupe.id); + }; + + const sauverGroupe = async () => { + if (!form?.nom?.trim()) { setError('Nom requis'); return; } + setBusy(true); + setError(''); + try { + if (editionId === 'nouveau') { + await api.post('/admin/groupes', form); + setMsg('Groupe créé'); + } else { + await api.patch(`/admin/groupes/${editionId}`, form); + setMsg('Groupe modifié'); + } + setEditionId(''); + setForm(null); + await charger(); + await loadGroupes(); + } catch (e) { + setError(e.message); + } finally { + setBusy(false); + } + }; + + const supprimer = async (groupe) => { + const confirmation = window.prompt(`Supprimer le groupe "${groupe.nom}" et ses données? Tapez SUPPRIMER pour continuer.`); + if (confirmation !== 'SUPPRIMER') return; + setBusy(true); + setError(''); + try { + await api.delete(`/admin/groupes/${groupe.id}`); + setMsg('Groupe supprimé'); await charger(); await loadGroupes(); } catch (e) { @@ -76,11 +149,11 @@ export default function GestionGroupes() {
Chargement...
}District : {groupe.district || '—'}
Région : {groupe.region || '—'}
100% logiciel libre · Hébergé au Québec