Proteger droit oubli et enrichir journal
This commit is contained in:
parent
c24b0625d3
commit
ea4bab3c94
17 changed files with 246 additions and 33 deletions
|
|
@ -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_")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)"}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
*,
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<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><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">Téléphone</p><p className="font-medium">{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">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">{telephoneVisible(detail) ? detail.telephone : '—'}</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">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'}`}>
|
||||
{detail.actif ? 'Actif' : 'Inactif'}
|
||||
<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'}`}>
|
||||
{anonymise ? 'Anonymisé' : detail.actif ? 'Actif' : 'Inactif'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact rapide */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{detail.telephone && (
|
||||
{telephoneVisible(detail) && (
|
||||
<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">
|
||||
📞 Appeler
|
||||
</a>
|
||||
)}
|
||||
{detail.courriel && (
|
||||
{courrielVisible(detail) && (
|
||||
<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">
|
||||
✉️ Courriel
|
||||
|
|
@ -147,20 +165,26 @@ export default function GererMembres() {
|
|||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{estSysadmin && detail.id !== membre?.id && !confirmAction && (
|
||||
{estSysadmin && detail.id !== membre?.id && !anonymise && !confirmAction && (
|
||||
<button onClick={() => agirComme(detail.id)}
|
||||
className="w-full p-3 bg-aa-blue text-white rounded-xl font-bold text-sm">
|
||||
Agir comme ce membre
|
||||
</button>
|
||||
)}
|
||||
|
||||
{detail.actif && !confirmAction && (
|
||||
{actionsAdministrativesPermises && detail.actif && !confirmAction && (
|
||||
<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">
|
||||
Désactiver ce membre
|
||||
</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')}
|
||||
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)
|
||||
|
|
@ -177,11 +201,20 @@ export default function GererMembres() {
|
|||
</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' && (
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -193,7 +226,7 @@ export default function GererMembres() {
|
|||
|
||||
return (
|
||||
<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">
|
||||
{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">
|
||||
<div>
|
||||
<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>
|
||||
<span className="text-gray-300">→</span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -60,6 +60,19 @@ export default function JournalActions() {
|
|||
dateStyle: 'medium',
|
||||
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 resetFiltres = () => {
|
||||
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>
|
||||
<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>
|
||||
<span className="text-xs font-bold rounded-full bg-blue-50 text-blue-700 px-2 py-1">
|
||||
{item.action}
|
||||
</span>
|
||||
</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">
|
||||
<p>Membre: {item.membre_prenom || item.membre_id || '—'}</p>
|
||||
<p>Objet: {item.table_cible} · {item.objet_id}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue