Ajouter acces sysadmin aux groupes

- bouton Sysadmin public avec PIN

- CRUD sysadmin des groupes

- groupe demo protege et reinitialisable
This commit is contained in:
Daniel Allaire 2026-05-31 22:48:07 -04:00
parent c4c7dc7b64
commit 1946b8741e
6 changed files with 379 additions and 12 deletions

View file

@ -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))

View file

@ -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 [{

View file

@ -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,

View file

@ -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 ═══")

View file

@ -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() {
<div className="grid grid-cols-1 gap-2">
<button
onClick={installerDemo}
onClick={nouveauGroupe}
disabled={busy}
className="w-full min-h-[48px] p-3 bg-aa-blue text-white rounded-xl text-sm font-bold disabled:opacity-40"
>
Installer le groupe de démonstration
Créer un groupe
</button>
<button
onClick={remettreAZero}
@ -91,6 +164,34 @@ export default function GestionGroupes() {
</button>
</div>
{form && (
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<h2 className="font-bold text-gray-800">{editionId === 'nouveau' ? 'Nouveau groupe' : 'Modifier le groupe'}</h2>
<input value={form.nom} onChange={(e) => setForm({ ...form, nom: e.target.value })} placeholder="Nom du groupe" className="w-full p-3 bg-gray-50 rounded-xl border-0" />
<input value={form.adresse_local} onChange={(e) => setForm({ ...form, adresse_local: e.target.value })} placeholder="Adresse / coordonnées" className="w-full p-3 bg-gray-50 rounded-xl border-0" />
<div className="grid grid-cols-2 gap-2">
<input value={form.jour_reunion} onChange={(e) => setForm({ ...form, jour_reunion: e.target.value })} placeholder="Jour" className="w-full p-3 bg-gray-50 rounded-xl border-0" />
<input type="time" value={form.heure_reunion} onChange={(e) => setForm({ ...form, heure_reunion: e.target.value })} className="w-full p-3 bg-gray-50 rounded-xl border-0" />
</div>
<div className="grid grid-cols-2 gap-2">
<input value={form.district} onChange={(e) => setForm({ ...form, district: e.target.value })} placeholder="District" className="w-full p-3 bg-gray-50 rounded-xl border-0" />
<input value={form.region} onChange={(e) => setForm({ ...form, region: e.target.value })} placeholder="Région" className="w-full p-3 bg-gray-50 rounded-xl border-0" />
</div>
<select value={form.assemblee_affaires_ordre} onChange={(e) => setForm({ ...form, assemblee_affaires_ordre: e.target.value })} className="w-full p-3 bg-gray-50 rounded-xl border-0">
<option value="premiere">Assemblée la première réunion du mois</option>
<option value="deuxieme">Assemblée la deuxième réunion du mois</option>
<option value="troisieme">Assemblée la troisième réunion du mois</option>
<option value="quatrieme">Assemblée la quatrième réunion du mois</option>
<option value="derniere">Assemblée la dernière réunion du mois</option>
</select>
<textarea value={form.format_reunion} onChange={(e) => setForm({ ...form, format_reunion: e.target.value })} placeholder="Format de réunion" rows={2} className="w-full p-3 bg-gray-50 rounded-xl border-0" />
<div className="grid grid-cols-2 gap-2">
<button onClick={sauverGroupe} disabled={busy} className="p-3 bg-green-600 text-white rounded-xl text-sm font-bold disabled:opacity-40">Enregistrer</button>
<button onClick={() => { setEditionId(''); setForm(null); }} className="p-3 bg-gray-100 text-gray-600 rounded-xl text-sm font-bold">Annuler</button>
</div>
</div>
)}
{loading && <p className="text-center text-gray-400 py-8">Chargement...</p>}
<div className="space-y-3">
@ -105,10 +206,18 @@ export default function GestionGroupes() {
className="flex w-full items-center justify-between gap-3 p-4 text-left active:bg-gray-50"
aria-expanded={ouvert}
>
<span className="min-w-0">
<span className="min-w-0 flex-1">
<span className="block truncate text-lg font-bold text-gray-900">{groupe.nom}</span>
<span className="mt-1 block text-sm font-semibold text-aa-blue">{horaire}</span>
</span>
{groupe.demo && (
<span
onClick={(e) => { e.stopPropagation(); resetDemo(); }}
className="shrink-0 rounded-xl bg-indigo-50 px-3 py-2 text-xs font-bold text-indigo-700"
>
Réinitialiser
</span>
)}
<span className={`shrink-0 text-2xl text-aa-blue transition ${ouvert ? 'rotate-180' : ''}`}>⌄</span>
</button>
@ -125,6 +234,12 @@ export default function GestionGroupes() {
<p><span className="font-semibold text-gray-900">District : </span>{groupe.district || '—'}</p>
<p><span className="font-semibold text-gray-900">Région : </span>{groupe.region || '—'}</p>
</div>
<div className="grid grid-cols-2 gap-2">
<button onClick={() => modifier(groupe)} className="p-3 bg-gray-100 text-gray-700 rounded-xl text-sm font-bold">Modifier</button>
<button onClick={() => supprimer(groupe)} disabled={groupe.protege || busy} className="p-3 bg-red-50 text-red-700 rounded-xl text-sm font-bold disabled:opacity-40">
Supprimer
</button>
</div>
</div>
)}
</section>

View file

@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { useStore } from '../stores/store';
import { api } from '../stores/api';
const jourLabel = (jour) => {
if (!jour) return 'Jour à préciser';
@ -7,7 +8,7 @@ const jourLabel = (jour) => {
};
export default function Login() {
const { login, loading, error, groupes, loadGroupes, setPage } = useStore();
const { login, loadAccueil, loading, error, groupes, loadGroupes, setPage } = useStore();
const [courriel, setCourriel] = useState(localStorage.getItem('gm_courriel') || '');
const [pin, setPin] = useState('');
const [groupeId, setGroupeId] = useState(localStorage.getItem('gm_groupe_id') || '');
@ -47,6 +48,25 @@ export default function Login() {
} catch {}
};
const ouvrirSysadmin = async () => {
const pin = window.prompt('PIN Sysadmin');
if (!pin) return;
try {
const data = await api.post('/admin/sysadmin-login', { pin });
api.setToken(data.access_token, false);
useStore.setState({
token: data.access_token,
membre: data.membre,
groupeId: data.membre.groupe_id,
error: null,
});
await loadAccueil();
setPage('G14');
} catch (e) {
useStore.setState({ error: e.message });
}
};
return (
<div className="min-h-screen bg-gradient-to-b from-aa-blue to-blue-900 p-4">
<div className="mx-auto flex min-h-screen w-full max-w-md flex-col py-6">
@ -158,6 +178,13 @@ export default function Login() {
})}
</div>
<button
type="button"
onClick={ouvrirSysadmin}
className="mx-auto mb-3 block px-3 py-2 text-xs font-semibold text-blue-200/80"
>
Sysadmin
</button>
<p className="text-center text-xs text-blue-300">100% logiciel libre · Hébergé au Québec</p>
</div>
</div>