Proteger droit oubli et enrichir journal

This commit is contained in:
Daniel Allaire 2026-05-31 12:28:04 -04:00
parent c24b0625d3
commit ea4bab3c94
17 changed files with 246 additions and 33 deletions

View file

@ -32,3 +32,7 @@ class Membre(Base):
@property @property
def is_sysadmin(self) -> bool: def is_sysadmin(self) -> bool:
return (self.courriel or "").strip().lower() == "sysadmin" or bool(getattr(self, "_sysadmin_session", False)) 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_")

View file

@ -18,7 +18,7 @@ from app.models.reserve import Reserve
from app.models.mouvement_reserve import MouvementReserve from app.models.mouvement_reserve import MouvementReserve
from app.schemas.phase1 import CollecteCreate, CollecteOut, CollecteUpdate, SoldesOut from app.schemas.phase1 import CollecteCreate, CollecteOut, CollecteUpdate, SoldesOut
from app.services.finance import encaisse, solde_banque, solde_reserve, transaction 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"]) router = APIRouter(prefix="/collectes", tags=["S1 — Collecte 7e Tradition"])
@ -143,6 +143,7 @@ async def ajuster_collecte(
if collecte.statut != "en_attente": if collecte.statut != "en_attente":
raise HTTPException(status_code=400, detail="Le montant ne peut plus être modifié après confirmation") 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: if req.montant is not None:
collecte.montant = req.montant collecte.montant = req.montant
await enregistrer_historique( await enregistrer_historique(
@ -153,6 +154,8 @@ async def ajuster_collecte(
action="collecte.modifiee", action="collecte.modifiee",
raison="Montant de collecte ajusté avant confirmation.", raison="Montant de collecte ajusté avant confirmation.",
membre_id=membre.id, membre_id=membre.id,
avant=avant,
apres=snapshot(collecte, "montant", "statut"),
) )
await db.flush() await db.flush()
return await _to_out(collecte, db) return await _to_out(collecte, db)
@ -181,6 +184,7 @@ async def confirmer_collecte(
if collecte.saisi_par == membre.id: if collecte.saisi_par == membre.id:
raise HTTPException(status_code=400, detail="Le montant doit être confirmé par un autre membre") 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.confirme_par = membre.id
collecte.date_confirmation = datetime.now(timezone.utc) 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", 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.", raison="Collecte confirmée." if collecte.statut == "confirmee" else "Collecte confirmée et reçue par le trésorier.",
membre_id=membre.id, membre_id=membre.id,
avant=avant,
apres=snapshot(collecte, "statut", "confirme_par", "recu_par", "date_reception"),
) )
await db.flush() await db.flush()
@ -231,6 +237,7 @@ async def accuser_reception(
if collecte.statut == "recu": if collecte.statut == "recu":
raise HTTPException(status_code=400, detail="Cette collecte a déjà été reçue") 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 # Si en_attente, confirme aussi
if collecte.statut == "en_attente": if collecte.statut == "en_attente":
collecte.confirme_par = membre.id collecte.confirme_par = membre.id
@ -248,6 +255,8 @@ async def accuser_reception(
action="collecte.recue", action="collecte.recue",
raison="Collecte reçue par le trésorier.", raison="Collecte reçue par le trésorier.",
membre_id=membre.id, membre_id=membre.id,
avant=avant,
apres=snapshot(collecte, "statut", "confirme_par", "recu_par", "date_reception"),
) )
await db.flush() await db.flush()
return await _to_out(collecte, db) return await _to_out(collecte, db)

View file

@ -14,7 +14,7 @@ from app.models.config_repartition import ConfigRepartition
from app.models.membre import Membre from app.models.membre import Membre
from app.models.destinataire_contribution import DestinataireContribution from app.models.destinataire_contribution import DestinataireContribution
from app.services.finance import position_tresorerie, transaction 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 ( from app.schemas.phase7 import (
EnvoiContributionCreate, EnvoiContributionOut, EnvoiContributionCreate, EnvoiContributionOut,
CalculRepartitionIn, CalculRepartitionOut, CalculRepartitionIn, CalculRepartitionOut,
@ -152,6 +152,7 @@ async def modifier_destinataire(
nom = req.nom.strip() nom = req.nom.strip()
if not nom: if not nom:
raise HTTPException(status_code=400, detail="Nom requis") raise HTTPException(status_code=400, detail="Nom requis")
avant = snapshot(dest, "nom", "actif")
dest.nom = nom dest.nom = nom
await enregistrer_historique( await enregistrer_historique(
db, db,
@ -161,6 +162,8 @@ async def modifier_destinataire(
action="destinataire.modifie", action="destinataire.modifie",
raison="Destinataire de contribution renommé.", raison="Destinataire de contribution renommé.",
membre_id=membre.id, membre_id=membre.id,
avant=avant,
apres=snapshot(dest, "nom", "actif"),
) )
await db.flush() await db.flush()
return DestinataireContributionOut.model_validate(dest) 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") await verifier_version_historisee(db, dest, version_attendue, table_cible="destinataires_contributions")
if dest.protege: if dest.protege:
raise HTTPException(status_code=400, detail="Ce destinataire est protégé") raise HTTPException(status_code=400, detail="Ce destinataire est protégé")
avant = snapshot(dest, "nom", "actif")
dest.actif = False dest.actif = False
await enregistrer_historique( await enregistrer_historique(
db, db,
@ -188,6 +192,8 @@ async def supprimer_destinataire(
action="destinataire.supprime", action="destinataire.supprime",
raison="Destinataire de contribution désactivé.", raison="Destinataire de contribution désactivé.",
membre_id=membre.id, membre_id=membre.id,
avant=avant,
apres=snapshot(dest, "nom", "actif"),
) )
await db.flush() await db.flush()
return {"message": "Destinataire supprimé"} return {"message": "Destinataire supprimé"}
@ -314,6 +320,7 @@ async def confirmer_debit_contribution(
if position.solde_banque - float(envoi.montant) < position.total_reserves: 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") 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.debitee = True
envoi.engagee = 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))) 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", action="contribution.debit_confirme",
raison="Débit bancaire de la contribution confirmé.", raison="Débit bancaire de la contribution confirmé.",
membre_id=membre.id, membre_id=membre.id,
avant=avant,
apres=snapshot(envoi, "engagee", "debitee", "methode", "montant"),
) )
await db.flush() await db.flush()

View file

@ -12,7 +12,7 @@ from app.models.depense import Depense
from app.models.membre import Membre from app.models.membre import Membre
from app.schemas.phase1 import DepenseCreate, DepenseOut, DepenseUpdate from app.schemas.phase1 import DepenseCreate, DepenseOut, DepenseUpdate
from app.services.finance import position_tresorerie, transaction 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"]) 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", 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.methode = methode_norm
depense.confirme_par = membre.id depense.confirme_par = membre.id
depense.date_confirmation = datetime.now(timezone.utc) depense.date_confirmation = datetime.now(timezone.utc)
@ -162,6 +163,8 @@ async def confirmer_depense(
action="depense.traitee", action="depense.traitee",
raison=f"Dépense traitée par {methode_norm.lower()}.", raison=f"Dépense traitée par {methode_norm.lower()}.",
membre_id=membre.id, membre_id=membre.id,
avant=avant,
apres=snapshot(depense, "paye", "methode", "confirme_par", "date_confirmation"),
) )
await db.flush() await db.flush()
@ -194,6 +197,7 @@ async def confirmer_debit(
if position.solde_banque - float(depense.montant) < position.total_reserves: 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") 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.paye = True
depense.date_confirmation = datetime.now(timezone.utc) 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)) 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", action="depense.debit_confirme",
raison="Débit bancaire de la dépense confirmé.", raison="Débit bancaire de la dépense confirmé.",
membre_id=membre.id, membre_id=membre.id,
avant=avant,
apres=snapshot(depense, "paye", "methode", "date_confirmation"),
) )
await db.flush() await db.flush()
return await _to_out(depense, db) return await _to_out(depense, db)
@ -228,6 +234,7 @@ async def rejeter_depense(
if depense.confirme_par is not None: 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") 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: if depense.preuve_nom:
chemin = os.path.join(UPLOAD_DIR, depense.preuve_nom) chemin = os.path.join(UPLOAD_DIR, depense.preuve_nom)
if os.path.exists(chemin): if os.path.exists(chemin):
@ -241,6 +248,7 @@ async def rejeter_depense(
action="depense.rejetee", action="depense.rejetee",
raison="Dépense soumise rejetée.", raison="Dépense soumise rejetée.",
membre_id=membre.id, membre_id=membre.id,
avant=avant,
) )
await db.delete(depense) await db.delete(depense)
await db.flush() await db.flush()
@ -302,6 +310,7 @@ async def modifier_depense(
raise HTTPException(status_code=404, detail="Dépense introuvable") raise HTTPException(status_code=404, detail="Dépense introuvable")
await verifier_version_historisee(db, depense, version_attendue, table_cible="depenses") await verifier_version_historisee(db, depense, version_attendue, table_cible="depenses")
avant = snapshot(depense, "paye", "methode", "quoi")
if req.paye is not None: if req.paye is not None:
depense.paye = req.paye depense.paye = req.paye
if req.methode is not None: if req.methode is not None:
@ -318,6 +327,8 @@ async def modifier_depense(
action="depense.modifiee", action="depense.modifiee",
raison="Dépense modifiée par le trésorier.", raison="Dépense modifiée par le trésorier.",
membre_id=membre.id, membre_id=membre.id,
avant=avant,
apres=snapshot(depense, "paye", "methode", "quoi"),
) )
await db.flush() await db.flush()

View file

@ -10,7 +10,7 @@ from app.models.litterature import Litterature
from app.models.affectation import Affectation from app.models.affectation import Affectation
from app.models.poste import Poste from app.models.poste import Poste
from app.schemas.phase6 import LitteratureCreate, LitteratureOut, LitteratureUpdate, AjustementStock 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"]) 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") raise HTTPException(status_code=404, detail="Titre introuvable")
await verifier_version_historisee(db, item, version_attendue, table_cible="litterature") 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(): for field, value in req.model_dump(exclude_none=True).items():
setattr(item, field, value) setattr(item, field, value)
await enregistrer_historique( await enregistrer_historique(
@ -111,6 +112,8 @@ async def modifier_titre(
action="litterature.modifiee", action="litterature.modifiee",
raison="Titre de littérature modifié.", raison="Titre de littérature modifié.",
membre_id=membre.id, membre_id=membre.id,
avant=avant,
apres=snapshot(item, "titre", "categorie", "prix_coutant", "seuil_commande", "code_bsg"),
) )
await db.flush() await db.flush()
return _to_out(item) return _to_out(item)

View file

@ -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.core.security import get_current_membre, require_executif, hash_pin, verify_pin, is_sysadmin
from app.models.membre import Membre from app.models.membre import Membre
from app.schemas.schemas import MembreCreateAdmin, MembrePublic, MembreComplet, MembreUpdate, ChangerPinRequest from app.schemas.schemas import MembreCreateAdmin, MembrePublic, MembreComplet, MembreUpdate, ChangerPinRequest
from app.services.historique import enregistrer_historique
router = APIRouter(prefix="/membres", tags=["Membres"]) router = APIRouter(prefix="/membres", tags=["Membres"])
@ -101,8 +102,7 @@ async def liste_membres(
result = await db.execute( result = await db.execute(
select(Membre).where( select(Membre).where(
Membre.groupe_id == executif.groupe_id, Membre.groupe_id == executif.groupe_id,
Membre.actif == True, ).order_by(Membre.actif.desc(), Membre.prenom)
).order_by(Membre.prenom)
) )
return [MembreComplet.model_validate(m) for m in result.scalars().all()] 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") raise HTTPException(status_code=404, detail="Membre introuvable")
if is_sysadmin(membre): if is_sysadmin(membre):
raise HTTPException(status_code=400, detail="Le compte Sysadmin est protégé") 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 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() await db.flush()
return {"message": f"{membre.prenom} a été désactivé"} 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") @router.delete("/{membre_id}/oubli")
async def droit_oubli( async def droit_oubli(
membre_id: str, membre_id: str,
@ -186,16 +238,30 @@ async def droit_oubli(
raise HTTPException(status_code=400, detail="Impossible de supprimer votre propre compte") raise HTTPException(status_code=400, detail="Impossible de supprimer votre propre compte")
if is_sysadmin(membre): if is_sysadmin(membre):
raise HTTPException(status_code=400, detail="Le compte Sysadmin est protégé") 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 prenom_original = membre.prenom
# Anonymiser actif_avant = membre.actif
membre.prenom = "Ancien membre"
membre.nom = None membre.nom = None
membre.telephone = "000-000-0000" membre.telephone = "000-000-0000"
membre.courriel = f"supprime_{membre.id}@anon.local" membre.courriel = f"supprime_{membre.id}@anon.local"
membre.pin_hash = "SUPPRIME" membre.pin_hash = "SUPPRIME"
membre.actif = False membre.actif = False
membre.push_sub = None membre.push_sub = None
membre.notification_preferences = None
membre.date_abstinence = 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() await db.flush()
return {"message": f"Données de {prenom_original} anonymisées (droit à l'oubli)"} return {"message": f"Données de {prenom_original} anonymisées (droit à l'oubli)"}

View file

@ -13,7 +13,7 @@ from app.models.reserve import Reserve
from app.models.membre import Membre from app.models.membre import Membre
from app.schemas.phase1 import OperationBancaireCreate, OperationBancaireOut, ReserveCreate, ReserveOut, ReserveMouvementOut, ReserveTransfer, ReserveUpdate, TransactionComptableOut 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.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"]) router = APIRouter(prefix="/banque", tags=["Opérations bancaires"])
@ -214,6 +214,7 @@ async def modifier_reserve(
ancien_montant = await solde_reserve(reserve.id, db) ancien_montant = await solde_reserve(reserve.id, db)
if req.montant is not None and round(req.montant, 2) != round(ancien_montant, 2): 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") 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 req.nom is not None:
if not req.nom.strip(): if not req.nom.strip():
raise HTTPException(status_code=400, detail="Nom requis") raise HTTPException(status_code=400, detail="Nom requis")
@ -229,6 +230,8 @@ async def modifier_reserve(
action="reserve.modifiee", action="reserve.modifiee",
raison="Réserve renommée ou note modifiée.", raison="Réserve renommée ou note modifiée.",
membre_id=membre.id, membre_id=membre.id,
avant=avant,
apres=snapshot(reserve, "nom", "note", "active", "montant"),
) )
await db.flush() 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) 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) montant = await solde_reserve(reserve.id, db)
if round(montant, 2) != 0: if round(montant, 2) != 0:
raise HTTPException(status_code=400, detail="Une réserve doit être vide avant d'être supprimée") 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 reserve.active = False
await enregistrer_historique( await enregistrer_historique(
db, db,
@ -340,6 +344,8 @@ async def supprimer_reserve(
action="reserve.supprimee", action="reserve.supprimee",
raison="Réserve supprimée après retour à zéro.", raison="Réserve supprimée après retour à zéro.",
membre_id=membre.id, membre_id=membre.id,
avant=avant,
apres=snapshot(reserve, "nom", "note", "active", "montant"),
) )
await db.flush() await db.flush()
return {"message": "Réserve supprimée"} return {"message": "Réserve supprimée"}

View file

@ -16,7 +16,7 @@ from app.models.candidature import Candidature
from app.models.affectation import Affectation from app.models.affectation import Affectation
from app.models.poste import Poste from app.models.poste import Poste
from app.schemas.propositions import PropositionCreate, PropositionOut, PropositionVote 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"]) 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): 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") 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.secondeur_id = secondeur.id
prop.statut = "secondee" prop.statut = "secondee"
await enregistrer_historique( await enregistrer_historique(
@ -120,6 +121,8 @@ async def seconder_proposition(
action="proposition.secondee", action="proposition.secondee",
raison=f"Proposition secondée par {secondeur.prenom}.", raison=f"Proposition secondée par {secondeur.prenom}.",
membre_id=membre.id, membre_id=membre.id,
avant=avant,
apres=snapshot(prop, "statut", "secondeur_id"),
) )
await db.flush() await db.flush()
return await _to_out(prop, db) 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"): 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") 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 # Attribuer un numéro de résolution si adoptée
if req.statut == "adoptee": if req.statut == "adoptee":
etant_donne_que = (req.etant_donne_que or "").strip() 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", 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.", raison="Proposition transformée en décision du groupe." if req.statut == "adoptee" else "Proposition rejetée par le groupe.",
membre_id=executif.id, 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() await db.flush()

View file

@ -10,7 +10,7 @@ from app.models.pv_reunion import PvReunion
from app.models.reunion import Reunion from app.models.reunion import Reunion
from app.models.membre import Membre from app.models.membre import Membre
from app.schemas.phase4 import PvAdoption, PvCreate, PvOut, PvUpdate 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"]) 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") raise HTTPException(status_code=404, detail="PV introuvable")
await verifier_version_historisee(db, pv, version_attendue, table_cible="pv_reunions") await verifier_version_historisee(db, pv, version_attendue, table_cible="pv_reunions")
avant = snapshot(pv, "contenu", "decisions", "approuve")
if req.contenu is not None: if req.contenu is not None:
pv.contenu = req.contenu.strip() pv.contenu = req.contenu.strip()
if req.decisions is not None: if req.decisions is not None:
@ -133,6 +134,8 @@ async def modifier_pv(
action="pv.modifie", action="pv.modifie",
raison="Procès-verbal modifié.", raison="Procès-verbal modifié.",
membre_id=membre.id, membre_id=membre.id,
avant=avant,
apres=snapshot(pv, "contenu", "decisions", "approuve"),
) )
await db.flush() await db.flush()
return await _to_out(pv, db) return await _to_out(pv, db)
@ -155,6 +158,7 @@ async def adopter_pv(
if not pv: if not pv:
raise HTTPException(status_code=404, detail="PV introuvable") raise HTTPException(status_code=404, detail="PV introuvable")
await verifier_version_historisee(db, pv, version_attendue, table_cible="pv_reunions") await verifier_version_historisee(db, pv, version_attendue, table_cible="pv_reunions")
avant = snapshot(pv, "contenu", "decisions", "approuve")
if pv.approuve: if pv.approuve:
raise HTTPException(status_code=400, detail="PV déjà adopté") raise HTTPException(status_code=400, detail="PV déjà adopté")
if req.proposeur_id == req.secondeur_id: if req.proposeur_id == req.secondeur_id:
@ -192,6 +196,8 @@ async def adopter_pv(
action="pv.adopte", action="pv.adopte",
raison="Procès-verbal adopté.", raison="Procès-verbal adopté.",
membre_id=membre.id, membre_id=membre.id,
avant=avant,
apres=snapshot(pv, "contenu", "decisions", "approuve"),
) )
await db.flush() await db.flush()
return await _to_out(pv, db) return await _to_out(pv, db)

View file

@ -20,7 +20,7 @@ from app.models.litterature import Litterature
from app.models.vente_jeton import VenteJeton from app.models.vente_jeton import VenteJeton
from app.models.vente_litterature import VenteLitterature from app.models.vente_litterature import VenteLitterature
from app.schemas.phase1 import ReunionCreate, ReunionOut 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"]) router = APIRouter(prefix="/reunions", tags=["S4 — Rapport de réunion"])
@ -248,6 +248,7 @@ async def modifier_reunion(
if not reunion: if not reunion:
raise HTTPException(status_code=404, detail="Réunion introuvable") raise HTTPException(status_code=404, detail="Réunion introuvable")
await verifier_version_historisee(db, reunion, version_attendue, table_cible="reunions") await verifier_version_historisee(db, reunion, version_attendue, table_cible="reunions")
avant = snapshot(reunion, "animateur_id", "theme", "annonces", "note")
animateur = None animateur = None
if req.animateur_id: if req.animateur_id:
animateur = await db.get(Membre, req.animateur_id) animateur = await db.get(Membre, req.animateur_id)
@ -266,6 +267,8 @@ async def modifier_reunion(
action="reunion.modifiee", action="reunion.modifiee",
raison="Rencontre modifiée.", raison="Rencontre modifiée.",
membre_id=membre.id, membre_id=membre.id,
avant=avant,
apres=snapshot(reunion, "animateur_id", "theme", "annonces", "note"),
) )
await db.flush() await db.flush()
return ReunionOut( return ReunionOut(

View file

@ -45,6 +45,7 @@ class MembreComplet(MembrePublic):
nom: Optional[str] = None nom: Optional[str] = None
telephone: str telephone: str
actif: bool actif: bool
anonymise: bool = False
cree_le: datetime cree_le: datetime
model_config = {"from_attributes": True} model_config = {"from_attributes": True}

View file

@ -8,6 +8,19 @@ from app.models.historique_modification import HistoriqueModification
from app.models.membre import Membre 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( async def enregistrer_historique(
db: AsyncSession, db: AsyncSession,
*, *,

View file

@ -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-006 - Un executif peut consulter les membres actifs du groupe.
- RM-MEM-007 - La desactivation d'un membre conserve son historique. - 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-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-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-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`. - RM-POS-003 - Une affectation directe creee par le module d'affectation produit une decision adoptee de type `Nomination`.

View file

@ -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** 1. Exécutif ouvre **G2 — Gérer les membres**
2. Sélectionne le membre → **Droit à l'oubli** 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 ### Restaurer la base après incident

View file

@ -463,14 +463,16 @@ Terminer un mandat conserve l'historique. Une nouvelle affectation peut ensuite
Les exécutifs peuvent gérer les membres : 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; - créer un compte pour un membre;
- générer des invitations; - générer des invitations;
- désactiver un membre; - désactiver ou réactiver le droit de connexion d'un membre;
- corriger certaines informations; - corriger certaines informations;
- utiliser les actions administratives permises. - 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 ### Invitations de membres

View file

@ -69,6 +69,17 @@ export default function GererMembres() {
} catch (e) { alert(e.message); } } 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) => { const droitOubli = async (id) => {
try { try {
const res = await api.request('DELETE', `/membres/${id}/oubli`); const res = await api.request('DELETE', `/membres/${id}/oubli`);
@ -106,8 +117,15 @@ export default function GererMembres() {
setCreateLoading(false); 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 // Vue détail d'un membre
if (detail) { if (detail) {
const anonymise = estAnonymise(detail);
const membreCourant = detail.id === membre?.id;
const actionsAdministrativesPermises = !detail.is_sysadmin && !membreCourant;
return ( return (
<div className="min-h-screen bg-gray-100"> <div className="min-h-screen bg-gray-100">
<div className="bg-aa-blue text-white p-4 flex items-center gap-3"> <div className="bg-aa-blue text-white p-4 flex items-center gap-3">
@ -119,26 +137,26 @@ export default function GererMembres() {
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3"> <div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<div><p className="text-xs text-gray-400">Prénom</p><p className="font-medium">{detail.prenom}</p></div> <div><p className="text-xs text-gray-400">Prénom</p><p className="font-medium">{detail.prenom}</p></div>
<div><p className="text-xs text-gray-400">Nom</p><p className="font-medium">{detail.nom}</p></div> <div><p className="text-xs text-gray-400">Nom</p><p className="font-medium">{detail.nom || '—'}</p></div>
<div><p className="text-xs text-gray-400">Téléphone</p><p className="font-medium">{detail.telephone || '—'}</p></div> <div><p className="text-xs text-gray-400">Téléphone</p><p className="font-medium">{telephoneVisible(detail) ? detail.telephone : '—'}</p></div>
<div><p className="text-xs text-gray-400">Courriel</p><p className="font-medium">{detail.courriel || '—'}</p></div> <div><p className="text-xs text-gray-400">Courriel</p><p className="font-medium">{courrielVisible(detail) ? detail.courriel : '—'}</p></div>
<div><p className="text-xs text-gray-400">Date d'abstinence</p><p className="font-medium">{detail.date_abstinence || '—'}</p></div> <div><p className="text-xs text-gray-400">Date d'abstinence</p><p className="font-medium">{detail.date_abstinence || '—'}</p></div>
<div><p className="text-xs text-gray-400">Statut</p> <div><p className="text-xs text-gray-400">Statut</p>
<span className={`inline-block px-2 py-1 rounded-full text-xs font-bold ${detail.actif ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}> <span className={`inline-block px-2 py-1 rounded-full text-xs font-bold ${detail.actif ? 'bg-green-100 text-green-700' : anonymise ? 'bg-gray-100 text-gray-600' : 'bg-red-100 text-red-700'}`}>
{detail.actif ? 'Actif' : 'Inactif'} {anonymise ? 'Anonymisé' : detail.actif ? 'Actif' : 'Inactif'}
</span> </span>
</div> </div>
</div> </div>
{/* Contact rapide */} {/* Contact rapide */}
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{detail.telephone && ( {telephoneVisible(detail) && (
<a href={`tel:${detail.telephone}`} <a href={`tel:${detail.telephone}`}
className="p-3 bg-green-50 border border-green-200 rounded-xl text-center text-green-700 font-bold text-sm"> className="p-3 bg-green-50 border border-green-200 rounded-xl text-center text-green-700 font-bold text-sm">
📞 Appeler 📞 Appeler
</a> </a>
)} )}
{detail.courriel && ( {courrielVisible(detail) && (
<a href={`mailto:${detail.courriel}`} <a href={`mailto:${detail.courriel}`}
className="p-3 bg-blue-50 border border-blue-200 rounded-xl text-center text-blue-700 font-bold text-sm"> className="p-3 bg-blue-50 border border-blue-200 rounded-xl text-center text-blue-700 font-bold text-sm">
✉️ Courriel ✉️ Courriel
@ -147,20 +165,26 @@ export default function GererMembres() {
</div> </div>
{/* Actions */} {/* Actions */}
{estSysadmin && detail.id !== membre?.id && !confirmAction && ( {estSysadmin && detail.id !== membre?.id && !anonymise && !confirmAction && (
<button onClick={() => agirComme(detail.id)} <button onClick={() => agirComme(detail.id)}
className="w-full p-3 bg-aa-blue text-white rounded-xl font-bold text-sm"> className="w-full p-3 bg-aa-blue text-white rounded-xl font-bold text-sm">
Agir comme ce membre Agir comme ce membre
</button> </button>
)} )}
{detail.actif && !confirmAction && ( {actionsAdministrativesPermises && detail.actif && !confirmAction && (
<button onClick={() => setConfirmAction('desactiver')} <button onClick={() => setConfirmAction('desactiver')}
className="w-full p-3 bg-orange-50 border border-orange-200 text-orange-700 rounded-xl font-bold text-sm"> className="w-full p-3 bg-orange-50 border border-orange-200 text-orange-700 rounded-xl font-bold text-sm">
Désactiver ce membre Désactiver ce membre
</button> </button>
)} )}
{!confirmAction && ( {actionsAdministrativesPermises && !detail.actif && !anonymise && !confirmAction && (
<button onClick={() => setConfirmAction('reactiver')}
className="w-full p-3 bg-green-50 border border-green-200 text-green-700 rounded-xl font-bold text-sm">
Réactiver ce membre
</button>
)}
{actionsAdministrativesPermises && !anonymise && !confirmAction && (
<button onClick={() => setConfirmAction('oubli')} <button onClick={() => setConfirmAction('oubli')}
className="w-full p-3 bg-red-50 border border-red-200 text-red-700 rounded-xl font-bold text-sm"> className="w-full p-3 bg-red-50 border border-red-200 text-red-700 rounded-xl font-bold text-sm">
Droit à l'oubli (irréversible) Droit à l'oubli (irréversible)
@ -177,11 +201,20 @@ export default function GererMembres() {
</div> </div>
</div> </div>
)} )}
{confirmAction === 'reactiver' && (
<div className="bg-green-50 rounded-2xl p-4 border border-green-200">
<p className="text-sm text-green-800 mb-3">Réactiver {detail.prenom} ? Le membre pourra de nouveau se connecter avec son identifiant et son PIN existants.</p>
<div className="flex gap-2">
<button onClick={() => reactiver(detail.id)} className="flex-1 p-3 bg-green-600 text-white rounded-xl font-bold">Confirmer</button>
<button onClick={() => setConfirmAction(null)} className="flex-1 p-3 bg-gray-200 text-gray-700 rounded-xl font-bold">Annuler</button>
</div>
</div>
)}
{confirmAction === 'oubli' && ( {confirmAction === 'oubli' && (
<div className="bg-red-50 rounded-2xl p-4 border border-red-200"> <div className="bg-red-50 rounded-2xl p-4 border border-red-200">
<p className="text-sm text-red-800 mb-3">⚠️ Cette action est irréversible. Toutes les données personnelles de {detail.prenom} seront anonymisées.</p> <p className="text-sm text-red-800 mb-3">⚠️ 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.</p>
<div className="flex gap-2"> <div className="flex gap-2">
<button onClick={() => droitOubli(detail.id)} className="flex-1 p-3 bg-red-600 text-white rounded-xl font-bold">Supprimer</button> <button onClick={() => droitOubli(detail.id)} className="flex-1 p-3 bg-red-600 text-white rounded-xl font-bold">Anonymiser</button>
<button onClick={() => setConfirmAction(null)} className="flex-1 p-3 bg-gray-200 text-gray-700 rounded-xl font-bold">Annuler</button> <button onClick={() => setConfirmAction(null)} className="flex-1 p-3 bg-gray-200 text-gray-700 rounded-xl font-bold">Annuler</button>
</div> </div>
</div> </div>
@ -193,7 +226,7 @@ export default function GererMembres() {
return ( return (
<div className="min-h-screen bg-gray-100"> <div className="min-h-screen bg-gray-100">
<PageHeader titre="Gestion des membres" sousTitre="Membres, invitations, désactivation" /> <PageHeader titre="Gestion des membres" sousTitre="Membres, invitations, accès" />
<div className="p-4 space-y-4"> <div className="p-4 space-y-4">
{msg && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium mb-2">{msg}</div>} {msg && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium mb-2">{msg}</div>}
@ -261,7 +294,9 @@ export default function GererMembres() {
className="w-full bg-white rounded-2xl p-4 shadow-sm flex items-center justify-between active:scale-[0.98] transition"> className="w-full bg-white rounded-2xl p-4 shadow-sm flex items-center justify-between active:scale-[0.98] transition">
<div> <div>
<p className="font-bold text-gray-800">{m.prenom} {m.nom}</p> <p className="font-bold text-gray-800">{m.prenom} {m.nom}</p>
<p className="text-xs text-gray-400">{m.telephone || m.courriel || '—'}</p> <p className="text-xs text-gray-400">
{estAnonymise(m) ? 'Anonymisé' : m.actif ? (m.telephone || m.courriel || '—') : 'Inactif'}
</p>
</div> </div>
<span className="text-gray-300">→</span> <span className="text-gray-300">→</span>
</button> </button>

View file

@ -60,6 +60,19 @@ export default function JournalActions() {
dateStyle: 'medium', dateStyle: 'medium',
timeStyle: 'short', timeStyle: 'short',
}); });
const formatValeur = (value) => {
if (value === null || value === undefined || value === '') return '—';
if (typeof value === 'boolean') return value ? 'oui' : 'non';
if (Array.isArray(value)) return `${value.length} élément(s)`;
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
};
const changements = (avant = {}, apres = {}) => {
const cles = [...new Set([...Object.keys(avant || {}), ...Object.keys(apres || {})])];
return cles
.map((cle) => [cle, avant?.[cle], apres?.[cle]])
.filter(([, a, b]) => JSON.stringify(a) !== JSON.stringify(b));
};
const updateFiltre = (key, value) => setFiltres((f) => ({ ...f, [key]: value })); const updateFiltre = (key, value) => setFiltres((f) => ({ ...f, [key]: value }));
const resetFiltres = () => { const resetFiltres = () => {
const vides = { date_debut: '', date_fin: '', membre_id: '', table_cible: '', action: '', q: '' }; const vides = { date_debut: '', date_fin: '', membre_id: '', table_cible: '', action: '', q: '' };
@ -136,14 +149,36 @@ export default function JournalActions() {
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div>
<p className="font-bold text-gray-800">{item.raison}</p> <p className="font-bold text-gray-800">{item.raison}</p>
<p className="text-xs text-gray-400 mt-1">{formatDate(item.cree_le)}</p> <p className="text-xs text-gray-400 mt-1">
{formatDate(item.cree_le)} · {item.membre_prenom || item.membre_id || 'Membre inconnu'}
</p>
</div> </div>
<span className="text-xs font-bold rounded-full bg-blue-50 text-blue-700 px-2 py-1"> <span className="text-xs font-bold rounded-full bg-blue-50 text-blue-700 px-2 py-1">
{item.action} {item.action}
</span> </span>
</div> </div>
{item.apres && changements(item.avant, item.apres).length > 0 && (
<div className="mt-3 rounded-xl bg-gray-50 p-3 text-xs text-gray-600 space-y-2">
{changements(item.avant, item.apres).map(([cle, avant, apres]) => (
<div key={cle} className="grid grid-cols-[90px_1fr] gap-2">
<span className="font-bold text-gray-500">{cle}</span>
<span>
<span className="text-red-700">{formatValeur(avant)}</span>
<span className="text-gray-400"> → </span>
<span className="text-green-700">{formatValeur(apres)}</span>
</span>
</div>
))}
</div>
)}
{item.avant && !item.apres && (
<div className="mt-3 rounded-xl bg-red-50 p-3 text-xs text-red-700 space-y-1">
{Object.entries(item.avant).map(([cle, valeur]) => (
<p key={cle}><span className="font-bold">{cle}:</span> {formatValeur(valeur)}</p>
))}
</div>
)}
<div className="mt-3 text-xs text-gray-500 space-y-1"> <div className="mt-3 text-xs text-gray-500 space-y-1">
<p>Membre: {item.membre_prenom || item.membre_id || '—'}</p>
<p>Objet: {item.table_cible} · {item.objet_id}</p> <p>Objet: {item.table_cible} · {item.objet_id}</p>
</div> </div>
</div> </div>