diff --git a/backend/app/models/membre.py b/backend/app/models/membre.py index f862903..13ddde8 100644 --- a/backend/app/models/membre.py +++ b/backend/app/models/membre.py @@ -32,3 +32,7 @@ class Membre(Base): @property def is_sysadmin(self) -> bool: return (self.courriel or "").strip().lower() == "sysadmin" or bool(getattr(self, "_sysadmin_session", False)) + + @property + def anonymise(self) -> bool: + return (self.pin_hash or "") == "SUPPRIME" or (self.courriel or "").startswith("supprime_") diff --git a/backend/app/routers/collectes.py b/backend/app/routers/collectes.py index 4eb7b55..a190586 100644 --- a/backend/app/routers/collectes.py +++ b/backend/app/routers/collectes.py @@ -18,7 +18,7 @@ from app.models.reserve import Reserve from app.models.mouvement_reserve import MouvementReserve from app.schemas.phase1 import CollecteCreate, CollecteOut, CollecteUpdate, SoldesOut from app.services.finance import encaisse, solde_banque, solde_reserve, transaction -from app.services.historique import enregistrer_historique, verifier_version_historisee +from app.services.historique import enregistrer_historique, snapshot, verifier_version_historisee router = APIRouter(prefix="/collectes", tags=["S1 — Collecte 7e Tradition"]) @@ -143,6 +143,7 @@ async def ajuster_collecte( if collecte.statut != "en_attente": raise HTTPException(status_code=400, detail="Le montant ne peut plus être modifié après confirmation") + avant = snapshot(collecte, "montant", "statut") if req.montant is not None: collecte.montant = req.montant await enregistrer_historique( @@ -153,6 +154,8 @@ async def ajuster_collecte( action="collecte.modifiee", raison="Montant de collecte ajusté avant confirmation.", membre_id=membre.id, + avant=avant, + apres=snapshot(collecte, "montant", "statut"), ) await db.flush() return await _to_out(collecte, db) @@ -181,6 +184,7 @@ async def confirmer_collecte( if collecte.saisi_par == membre.id: raise HTTPException(status_code=400, detail="Le montant doit être confirmé par un autre membre") + avant = snapshot(collecte, "statut", "confirme_par", "recu_par", "date_reception") collecte.confirme_par = membre.id collecte.date_confirmation = datetime.now(timezone.utc) @@ -204,6 +208,8 @@ async def confirmer_collecte( action="collecte.confirmee" if collecte.statut == "confirmee" else "collecte.recue", raison="Collecte confirmée." if collecte.statut == "confirmee" else "Collecte confirmée et reçue par le trésorier.", membre_id=membre.id, + avant=avant, + apres=snapshot(collecte, "statut", "confirme_par", "recu_par", "date_reception"), ) await db.flush() @@ -231,6 +237,7 @@ async def accuser_reception( if collecte.statut == "recu": raise HTTPException(status_code=400, detail="Cette collecte a déjà été reçue") + avant = snapshot(collecte, "statut", "confirme_par", "recu_par", "date_reception") # Si en_attente, confirme aussi if collecte.statut == "en_attente": collecte.confirme_par = membre.id @@ -248,6 +255,8 @@ async def accuser_reception( action="collecte.recue", raison="Collecte reçue par le trésorier.", membre_id=membre.id, + avant=avant, + apres=snapshot(collecte, "statut", "confirme_par", "recu_par", "date_reception"), ) await db.flush() return await _to_out(collecte, db) diff --git a/backend/app/routers/contributions.py b/backend/app/routers/contributions.py index 3980c26..c7c8ed8 100644 --- a/backend/app/routers/contributions.py +++ b/backend/app/routers/contributions.py @@ -14,7 +14,7 @@ from app.models.config_repartition import ConfigRepartition from app.models.membre import Membre from app.models.destinataire_contribution import DestinataireContribution from app.services.finance import position_tresorerie, transaction -from app.services.historique import enregistrer_historique, verifier_version_historisee +from app.services.historique import enregistrer_historique, snapshot, verifier_version_historisee from app.schemas.phase7 import ( EnvoiContributionCreate, EnvoiContributionOut, CalculRepartitionIn, CalculRepartitionOut, @@ -152,6 +152,7 @@ async def modifier_destinataire( nom = req.nom.strip() if not nom: raise HTTPException(status_code=400, detail="Nom requis") + avant = snapshot(dest, "nom", "actif") dest.nom = nom await enregistrer_historique( db, @@ -161,6 +162,8 @@ async def modifier_destinataire( action="destinataire.modifie", raison="Destinataire de contribution renommé.", membre_id=membre.id, + avant=avant, + apres=snapshot(dest, "nom", "actif"), ) await db.flush() return DestinataireContributionOut.model_validate(dest) @@ -179,6 +182,7 @@ async def supprimer_destinataire( await verifier_version_historisee(db, dest, version_attendue, table_cible="destinataires_contributions") if dest.protege: raise HTTPException(status_code=400, detail="Ce destinataire est protégé") + avant = snapshot(dest, "nom", "actif") dest.actif = False await enregistrer_historique( db, @@ -188,6 +192,8 @@ async def supprimer_destinataire( action="destinataire.supprime", raison="Destinataire de contribution désactivé.", membre_id=membre.id, + avant=avant, + apres=snapshot(dest, "nom", "actif"), ) await db.flush() return {"message": "Destinataire supprimé"} @@ -314,6 +320,7 @@ async def confirmer_debit_contribution( if position.solde_banque - float(envoi.montant) < position.total_reserves: raise HTTPException(status_code=400, detail="Ce débit ferait dépasser les réserves par rapport au solde en banque") + avant = snapshot(envoi, "engagee", "debitee", "methode", "montant") envoi.debitee = True envoi.engagee = True db.add(transaction(membre.groupe_id, membre.id, date.today(), "contribution", "banque", envoi.montant, "credit", "envois_contributions", envoi.id, DESTINATIONS_LABELS.get(envoi.destination, envoi.destination))) @@ -325,6 +332,8 @@ async def confirmer_debit_contribution( action="contribution.debit_confirme", raison="Débit bancaire de la contribution confirmé.", membre_id=membre.id, + avant=avant, + apres=snapshot(envoi, "engagee", "debitee", "methode", "montant"), ) await db.flush() diff --git a/backend/app/routers/depenses.py b/backend/app/routers/depenses.py index 1512075..1922a32 100644 --- a/backend/app/routers/depenses.py +++ b/backend/app/routers/depenses.py @@ -12,7 +12,7 @@ from app.models.depense import Depense from app.models.membre import Membre from app.schemas.phase1 import DepenseCreate, DepenseOut, DepenseUpdate from app.services.finance import position_tresorerie, transaction -from app.services.historique import enregistrer_historique, verifier_version_historisee +from app.services.historique import enregistrer_historique, snapshot, verifier_version_historisee router = APIRouter(prefix="/depenses", tags=["S5 — Dépenses"]) @@ -144,6 +144,7 @@ async def confirmer_depense( detail="Le solde bancaire disponible est insuffisant pour engager cette dépense", ) + avant = snapshot(depense, "paye", "methode", "confirme_par", "date_confirmation") depense.methode = methode_norm depense.confirme_par = membre.id depense.date_confirmation = datetime.now(timezone.utc) @@ -162,6 +163,8 @@ async def confirmer_depense( action="depense.traitee", raison=f"Dépense traitée par {methode_norm.lower()}.", membre_id=membre.id, + avant=avant, + apres=snapshot(depense, "paye", "methode", "confirme_par", "date_confirmation"), ) await db.flush() @@ -194,6 +197,7 @@ async def confirmer_debit( if position.solde_banque - float(depense.montant) < position.total_reserves: raise HTTPException(status_code=400, detail="Ce débit ferait dépasser les réserves par rapport au solde en banque") + avant = snapshot(depense, "paye", "methode", "date_confirmation") depense.paye = True depense.date_confirmation = datetime.now(timezone.utc) db.add(transaction(membre.groupe_id, membre.id, date.today(), "depense", "banque", depense.montant, "credit", "depenses", depense.id, depense.quoi)) @@ -205,6 +209,8 @@ async def confirmer_debit( action="depense.debit_confirme", raison="Débit bancaire de la dépense confirmé.", membre_id=membre.id, + avant=avant, + apres=snapshot(depense, "paye", "methode", "date_confirmation"), ) await db.flush() return await _to_out(depense, db) @@ -228,6 +234,7 @@ async def rejeter_depense( if depense.confirme_par is not None: raise HTTPException(status_code=400, detail="Seules les dépenses soumises non traitées peuvent être rejetées") + avant = snapshot(depense, "date_depense", "montant", "qui", "quoi", "paye", "methode", "preuve_nom") if depense.preuve_nom: chemin = os.path.join(UPLOAD_DIR, depense.preuve_nom) if os.path.exists(chemin): @@ -241,6 +248,7 @@ async def rejeter_depense( action="depense.rejetee", raison="Dépense soumise rejetée.", membre_id=membre.id, + avant=avant, ) await db.delete(depense) await db.flush() @@ -302,6 +310,7 @@ async def modifier_depense( raise HTTPException(status_code=404, detail="Dépense introuvable") await verifier_version_historisee(db, depense, version_attendue, table_cible="depenses") + avant = snapshot(depense, "paye", "methode", "quoi") if req.paye is not None: depense.paye = req.paye if req.methode is not None: @@ -318,6 +327,8 @@ async def modifier_depense( action="depense.modifiee", raison="Dépense modifiée par le trésorier.", membre_id=membre.id, + avant=avant, + apres=snapshot(depense, "paye", "methode", "quoi"), ) await db.flush() diff --git a/backend/app/routers/litterature.py b/backend/app/routers/litterature.py index 549af8a..e686953 100644 --- a/backend/app/routers/litterature.py +++ b/backend/app/routers/litterature.py @@ -10,7 +10,7 @@ from app.models.litterature import Litterature from app.models.affectation import Affectation from app.models.poste import Poste from app.schemas.phase6 import LitteratureCreate, LitteratureOut, LitteratureUpdate, AjustementStock -from app.services.historique import enregistrer_historique, verifier_version_historisee +from app.services.historique import enregistrer_historique, snapshot, verifier_version_historisee router = APIRouter(prefix="/litterature", tags=["C4/M5 — Littérature"]) @@ -101,6 +101,7 @@ async def modifier_titre( raise HTTPException(status_code=404, detail="Titre introuvable") await verifier_version_historisee(db, item, version_attendue, table_cible="litterature") + avant = snapshot(item, "titre", "categorie", "prix_coutant", "seuil_commande", "code_bsg") for field, value in req.model_dump(exclude_none=True).items(): setattr(item, field, value) await enregistrer_historique( @@ -111,6 +112,8 @@ async def modifier_titre( action="litterature.modifiee", raison="Titre de littérature modifié.", membre_id=membre.id, + avant=avant, + apres=snapshot(item, "titre", "categorie", "prix_coutant", "seuil_commande", "code_bsg"), ) await db.flush() return _to_out(item) diff --git a/backend/app/routers/membres.py b/backend/app/routers/membres.py index 47defc0..abdae1f 100644 --- a/backend/app/routers/membres.py +++ b/backend/app/routers/membres.py @@ -7,6 +7,7 @@ from app.core.database import get_db from app.core.security import get_current_membre, require_executif, hash_pin, verify_pin, is_sysadmin from app.models.membre import Membre from app.schemas.schemas import MembreCreateAdmin, MembrePublic, MembreComplet, MembreUpdate, ChangerPinRequest +from app.services.historique import enregistrer_historique router = APIRouter(prefix="/membres", tags=["Membres"]) @@ -101,8 +102,7 @@ async def liste_membres( result = await db.execute( select(Membre).where( Membre.groupe_id == executif.groupe_id, - Membre.actif == True, - ).order_by(Membre.prenom) + ).order_by(Membre.actif.desc(), Membre.prenom) ) return [MembreComplet.model_validate(m) for m in result.scalars().all()] @@ -164,11 +164,63 @@ async def desactiver_membre( raise HTTPException(status_code=404, detail="Membre introuvable") if is_sysadmin(membre): raise HTTPException(status_code=400, detail="Le compte Sysadmin est protégé") + if membre.anonymise: + raise HTTPException(status_code=400, detail="Ce membre a exercé son droit à l'oubli") + if not membre.actif: + return {"message": f"{membre.prenom} est déjà désactivé"} membre.actif = False + await enregistrer_historique( + db, + groupe_id=executif.groupe_id, + table_cible="membres", + objet_id=membre.id, + action="desactivation_membre", + raison=f"Compte de {membre.prenom} désactivé", + membre_id=executif.id, + chemin_api=f"/membres/{membre_id}/desactiver", + avant={"actif": True}, + apres={"actif": False}, + ) await db.flush() return {"message": f"{membre.prenom} a été désactivé"} +@router.patch("/{membre_id}/reactiver") +async def reactiver_membre( + membre_id: str, + executif=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + """G2 — Réactiver le droit de connexion d'un membre.""" + result = await db.execute( + select(Membre).where(Membre.id == membre_id, Membre.groupe_id == executif.groupe_id) + ) + membre = result.scalar_one_or_none() + if not membre: + raise HTTPException(status_code=404, detail="Membre introuvable") + if is_sysadmin(membre): + raise HTTPException(status_code=400, detail="Le compte Sysadmin est protégé") + if membre.anonymise: + raise HTTPException(status_code=400, detail="Ce membre a exercé son droit à l'oubli et ne peut pas être réactivé") + if membre.actif: + return {"message": f"{membre.prenom} est déjà actif"} + membre.actif = True + await enregistrer_historique( + db, + groupe_id=executif.groupe_id, + table_cible="membres", + objet_id=membre.id, + action="reactivation_membre", + raison=f"Compte de {membre.prenom} réactivé", + membre_id=executif.id, + chemin_api=f"/membres/{membre_id}/reactiver", + avant={"actif": False}, + apres={"actif": True}, + ) + await db.flush() + return {"message": f"{membre.prenom} a été réactivé"} + + @router.delete("/{membre_id}/oubli") async def droit_oubli( membre_id: str, @@ -186,16 +238,30 @@ async def droit_oubli( raise HTTPException(status_code=400, detail="Impossible de supprimer votre propre compte") if is_sysadmin(membre): raise HTTPException(status_code=400, detail="Le compte Sysadmin est protégé") + if membre.anonymise: + return {"message": f"Données de {membre.prenom} déjà anonymisées"} prenom_original = membre.prenom - # Anonymiser - membre.prenom = "Ancien membre" + actif_avant = membre.actif membre.nom = None membre.telephone = "000-000-0000" membre.courriel = f"supprime_{membre.id}@anon.local" membre.pin_hash = "SUPPRIME" membre.actif = False membre.push_sub = None + membre.notification_preferences = None membre.date_abstinence = None + await enregistrer_historique( + db, + groupe_id=executif.groupe_id, + table_cible="membres", + objet_id=membre.id, + action="droit_oubli", + raison=f"Données personnelles de {prenom_original} anonymisées; prénom conservé pour l'historique", + membre_id=executif.id, + chemin_api=f"/membres/{membre_id}/oubli", + avant={"actif": actif_avant, "anonymise": False}, + apres={"actif": False, "anonymise": True, "prenom_conserve": prenom_original}, + ) await db.flush() return {"message": f"Données de {prenom_original} anonymisées (droit à l'oubli)"} diff --git a/backend/app/routers/operations_bancaires.py b/backend/app/routers/operations_bancaires.py index c750965..ab76756 100644 --- a/backend/app/routers/operations_bancaires.py +++ b/backend/app/routers/operations_bancaires.py @@ -13,7 +13,7 @@ from app.models.reserve import Reserve from app.models.membre import Membre from app.schemas.phase1 import OperationBancaireCreate, OperationBancaireOut, ReserveCreate, ReserveOut, ReserveMouvementOut, ReserveTransfer, ReserveUpdate, TransactionComptableOut from app.services.finance import position_tresorerie, reserve_disponible, solde_reserve -from app.services.historique import enregistrer_historique, verifier_version_historisee +from app.services.historique import enregistrer_historique, snapshot, verifier_version_historisee router = APIRouter(prefix="/banque", tags=["Opérations bancaires"]) @@ -214,6 +214,7 @@ async def modifier_reserve( ancien_montant = await solde_reserve(reserve.id, db) if req.montant is not None and round(req.montant, 2) != round(ancien_montant, 2): raise HTTPException(status_code=400, detail="Modifier le montant d'une réserve via l'action Virement") + avant = snapshot(reserve, "nom", "note", "active", "montant") if req.nom is not None: if not req.nom.strip(): raise HTTPException(status_code=400, detail="Nom requis") @@ -229,6 +230,8 @@ async def modifier_reserve( action="reserve.modifiee", raison="Réserve renommée ou note modifiée.", membre_id=membre.id, + avant=avant, + apres=snapshot(reserve, "nom", "note", "active", "montant"), ) await db.flush() return ReserveOut(id=reserve.id, nom=reserve.nom, montant=float(reserve.montant), note=reserve.note, active=reserve.active, cree_le=reserve.cree_le, modifie_le=reserve.modifie_le) @@ -331,6 +334,7 @@ async def supprimer_reserve( montant = await solde_reserve(reserve.id, db) if round(montant, 2) != 0: raise HTTPException(status_code=400, detail="Une réserve doit être vide avant d'être supprimée") + avant = snapshot(reserve, "nom", "note", "active", "montant") reserve.active = False await enregistrer_historique( db, @@ -340,6 +344,8 @@ async def supprimer_reserve( action="reserve.supprimee", raison="Réserve supprimée après retour à zéro.", membre_id=membre.id, + avant=avant, + apres=snapshot(reserve, "nom", "note", "active", "montant"), ) await db.flush() return {"message": "Réserve supprimée"} diff --git a/backend/app/routers/propositions.py b/backend/app/routers/propositions.py index 29afa63..8a9d0a9 100644 --- a/backend/app/routers/propositions.py +++ b/backend/app/routers/propositions.py @@ -16,7 +16,7 @@ from app.models.candidature import Candidature from app.models.affectation import Affectation from app.models.poste import Poste from app.schemas.propositions import PropositionCreate, PropositionOut, PropositionVote -from app.services.historique import enregistrer_historique, verifier_version_historisee +from app.services.historique import enregistrer_historique, snapshot, verifier_version_historisee router = APIRouter(prefix="/propositions", tags=["Propositions et résolutions"]) @@ -110,6 +110,7 @@ async def seconder_proposition( if prop.proposeur_id == secondeur.id and not is_sysadmin(membre): raise HTTPException(status_code=400, detail="On ne peut pas seconder sa propre proposition") + avant = snapshot(prop, "statut", "secondeur_id") prop.secondeur_id = secondeur.id prop.statut = "secondee" await enregistrer_historique( @@ -120,6 +121,8 @@ async def seconder_proposition( action="proposition.secondee", raison=f"Proposition secondée par {secondeur.prenom}.", membre_id=membre.id, + avant=avant, + apres=snapshot(prop, "statut", "secondeur_id"), ) await db.flush() return await _to_out(prop, db) @@ -152,6 +155,7 @@ async def voter_proposition( if req.statut == "rejetee" and prop.statut not in ("proposee", "secondee"): raise HTTPException(status_code=400, detail="Seule une proposition en cours peut être rejetée") + avant = snapshot(prop, "statut", "numero_resolution", "date_vote", "date_adoption", "etant_donne_que", "groupe_a_decide_de", "note") # Attribuer un numéro de résolution si adoptée if req.statut == "adoptee": etant_donne_que = (req.etant_donne_que or "").strip() @@ -215,6 +219,8 @@ async def voter_proposition( action="proposition.adoptee" if req.statut == "adoptee" else "proposition.rejetee", raison="Proposition transformée en décision du groupe." if req.statut == "adoptee" else "Proposition rejetée par le groupe.", membre_id=executif.id, + avant=avant, + apres=snapshot(prop, "statut", "numero_resolution", "date_vote", "date_adoption", "etant_donne_que", "groupe_a_decide_de", "note"), ) await db.flush() diff --git a/backend/app/routers/pv.py b/backend/app/routers/pv.py index 80aae26..f48993f 100644 --- a/backend/app/routers/pv.py +++ b/backend/app/routers/pv.py @@ -10,7 +10,7 @@ from app.models.pv_reunion import PvReunion from app.models.reunion import Reunion from app.models.membre import Membre from app.schemas.phase4 import PvAdoption, PvCreate, PvOut, PvUpdate -from app.services.historique import enregistrer_historique, verifier_version_historisee +from app.services.historique import enregistrer_historique, snapshot, verifier_version_historisee router = APIRouter(prefix="/pv", tags=["M1 — Procès-verbal"]) @@ -119,6 +119,7 @@ async def modifier_pv( raise HTTPException(status_code=404, detail="PV introuvable") await verifier_version_historisee(db, pv, version_attendue, table_cible="pv_reunions") + avant = snapshot(pv, "contenu", "decisions", "approuve") if req.contenu is not None: pv.contenu = req.contenu.strip() if req.decisions is not None: @@ -133,6 +134,8 @@ async def modifier_pv( action="pv.modifie", raison="Procès-verbal modifié.", membre_id=membre.id, + avant=avant, + apres=snapshot(pv, "contenu", "decisions", "approuve"), ) await db.flush() return await _to_out(pv, db) @@ -155,6 +158,7 @@ async def adopter_pv( if not pv: raise HTTPException(status_code=404, detail="PV introuvable") await verifier_version_historisee(db, pv, version_attendue, table_cible="pv_reunions") + avant = snapshot(pv, "contenu", "decisions", "approuve") if pv.approuve: raise HTTPException(status_code=400, detail="PV déjà adopté") if req.proposeur_id == req.secondeur_id: @@ -192,6 +196,8 @@ async def adopter_pv( action="pv.adopte", raison="Procès-verbal adopté.", membre_id=membre.id, + avant=avant, + apres=snapshot(pv, "contenu", "decisions", "approuve"), ) await db.flush() return await _to_out(pv, db) diff --git a/backend/app/routers/reunions.py b/backend/app/routers/reunions.py index 80acd87..0900b0e 100644 --- a/backend/app/routers/reunions.py +++ b/backend/app/routers/reunions.py @@ -20,7 +20,7 @@ from app.models.litterature import Litterature from app.models.vente_jeton import VenteJeton from app.models.vente_litterature import VenteLitterature from app.schemas.phase1 import ReunionCreate, ReunionOut -from app.services.historique import enregistrer_historique, verifier_version_historisee +from app.services.historique import enregistrer_historique, snapshot, verifier_version_historisee router = APIRouter(prefix="/reunions", tags=["S4 — Rapport de réunion"]) @@ -248,6 +248,7 @@ async def modifier_reunion( if not reunion: raise HTTPException(status_code=404, detail="Réunion introuvable") await verifier_version_historisee(db, reunion, version_attendue, table_cible="reunions") + avant = snapshot(reunion, "animateur_id", "theme", "annonces", "note") animateur = None if req.animateur_id: animateur = await db.get(Membre, req.animateur_id) @@ -266,6 +267,8 @@ async def modifier_reunion( action="reunion.modifiee", raison="Rencontre modifiée.", membre_id=membre.id, + avant=avant, + apres=snapshot(reunion, "animateur_id", "theme", "annonces", "note"), ) await db.flush() return ReunionOut( diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 0785952..780b26e 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -45,6 +45,7 @@ class MembreComplet(MembrePublic): nom: Optional[str] = None telephone: str actif: bool + anonymise: bool = False cree_le: datetime model_config = {"from_attributes": True} diff --git a/backend/app/services/historique.py b/backend/app/services/historique.py index 7584130..5a73339 100644 --- a/backend/app/services/historique.py +++ b/backend/app/services/historique.py @@ -8,6 +8,19 @@ from app.models.historique_modification import HistoriqueModification from app.models.membre import Membre +def snapshot(obj, *champs: str) -> dict: + """Extrait un état JSON simple d'un objet métier.""" + data = {} + for champ in champs: + valeur = getattr(obj, champ, None) + if hasattr(valeur, "isoformat"): + valeur = valeur.isoformat() + else: + valeur = str(valeur) if valeur is not None and not isinstance(valeur, (str, int, float, bool, list, dict)) else valeur + data[champ] = valeur + return data + + async def enregistrer_historique( db: AsyncSession, *, diff --git a/docs/REGLES_METIER.md b/docs/REGLES_METIER.md index 4f90c51..6d51035 100644 --- a/docs/REGLES_METIER.md +++ b/docs/REGLES_METIER.md @@ -51,7 +51,9 @@ la regle est formulee selon le code et l'ecart est liste dans la section - RM-MEM-006 - Un executif peut consulter les membres actifs du groupe. - RM-MEM-007 - La desactivation d'un membre conserve son historique. - RM-MEM-008 - Le droit a l'oubli anonymise le membre au lieu d'effacer les donnees relationnelles. -- RM-MEM-009 - Le droit a l'oubli retire le prenom, le nom, le telephone, la date d'abstinence et remplace le courriel par une adresse locale anonyme. +- RM-MEM-009 - Le droit a l'oubli conserve l'identifiant technique et le prenom du membre pour preserver l'historique. +- RM-MEM-010 - Le droit a l'oubli retire le nom, neutralise le telephone, la date d'abstinence, le courriel de connexion, le PIN, les preferences de notification et les abonnements push. +- RM-MEM-011 - La desactivation ou la reactivation d'un membre agit seulement sur son droit de connexion. - RM-POS-001 - Les postes appartiennent a une categorie valide: `executif`, `service` ou `physique`. - RM-POS-002 - Une affectation active lie un membre, un poste et une date de debut. - RM-POS-003 - Une affectation directe creee par le module d'affectation produit une decision adoptee de type `Nomination`. diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index 753a252..65318e3 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -194,7 +194,9 @@ Pas de récupération en libre-service (par design). L'exécutif doit : 1. Exécutif ouvre **G2 — Gérer les membres** 2. Sélectionne le membre → **Droit à l'oubli** -3. Les données personnelles sont supprimées, les données historiques anonymisées +3. Le prénom et l'ID demeurent pour préserver l'historique +4. Le nom, le téléphone, l'identifiant de connexion, le PIN, les préférences de notification, les abonnements push et la date d'abstinence sont effacés ou neutralisés +5. Le compte ne peut plus être réactivé ### Restaurer la base après incident diff --git a/docs/UTILISATEURS.md b/docs/UTILISATEURS.md index 875bb21..fd39cc1 100644 --- a/docs/UTILISATEURS.md +++ b/docs/UTILISATEURS.md @@ -463,14 +463,16 @@ Terminer un mandat conserve l'historique. Une nouvelle affectation peut ensuite Les exécutifs peuvent gérer les membres : -- consulter les membres actifs; +- consulter les membres actifs, inactifs et anonymisés; - créer un compte pour un membre; - générer des invitations; -- désactiver un membre; +- désactiver ou réactiver le droit de connexion d'un membre; - corriger certaines informations; - utiliser les actions administratives permises. -Le compte `Sysadmin` est protégé contre la suppression. +Le droit à l'oubli est distinct de la désactivation. Il conserve l'ID et le prénom pour préserver l'historique, mais efface ou neutralise les autres renseignements personnels et empêche toute réactivation du compte. + +Le compte `Sysadmin` est protégé contre la désactivation et le droit à l'oubli. ### Invitations de membres diff --git a/frontend/src/pages/GererMembres.jsx b/frontend/src/pages/GererMembres.jsx index b65a093..b3b04c6 100644 --- a/frontend/src/pages/GererMembres.jsx +++ b/frontend/src/pages/GererMembres.jsx @@ -69,6 +69,17 @@ export default function GererMembres() { } catch (e) { alert(e.message); } }; + const reactiver = async (id) => { + try { + const res = await api.patch(`/membres/${id}/reactiver`); + setMsg(res.message); + setConfirmAction(null); + setDetail(null); + charger(); + setTimeout(() => setMsg(''), 3000); + } catch (e) { alert(e.message); } + }; + const droitOubli = async (id) => { try { const res = await api.request('DELETE', `/membres/${id}/oubli`); @@ -106,8 +117,15 @@ export default function GererMembres() { setCreateLoading(false); }; + const estAnonymise = (m) => Boolean(m?.anonymise || m?.courriel?.startsWith('supprime_')); + const telephoneVisible = (m) => m?.telephone && m.telephone !== '000-000-0000'; + const courrielVisible = (m) => m?.courriel && !m.courriel.startsWith('supprime_'); + // Vue détail d'un membre if (detail) { + const anonymise = estAnonymise(detail); + const membreCourant = detail.id === membre?.id; + const actionsAdministrativesPermises = !detail.is_sysadmin && !membreCourant; return (
Prénom
{detail.prenom}
Nom
{detail.nom}
Téléphone
{detail.telephone || '—'}
Courriel
{detail.courriel || '—'}
Nom
{detail.nom || '—'}
Téléphone
{telephoneVisible(detail) ? detail.telephone : '—'}
Courriel
{courrielVisible(detail) ? detail.courriel : '—'}
Date d'abstinence
{detail.date_abstinence || '—'}
Statut
- - {detail.actif ? 'Actif' : 'Inactif'} + + {anonymise ? 'Anonymisé' : detail.actif ? 'Actif' : 'Inactif'}Réactiver {detail.prenom} ? Le membre pourra de nouveau se connecter avec son identifiant et son PIN existants.
+⚠️ Cette action est irréversible. Toutes les données personnelles de {detail.prenom} seront anonymisées.
+⚠️ Cette action est irréversible. Le prénom de {detail.prenom} demeurera pour préserver l'historique; le nom, le téléphone, l'identifiant, le PIN et la date d'abstinence seront effacés ou neutralisés.
{m.prenom} {m.nom}
-{m.telephone || m.courriel || '—'}
++ {estAnonymise(m) ? 'Anonymisé' : m.actif ? (m.telephone || m.courriel || '—') : 'Inactif'} +
{item.raison}
-{formatDate(item.cree_le)}
++ {formatDate(item.cree_le)} · {item.membre_prenom || item.membre_id || 'Membre inconnu'} +
{cle}: {formatValeur(valeur)}
+ ))} +Membre: {item.membre_prenom || item.membre_id || '—'}
Objet: {item.table_cible} · {item.objet_id}