Vortex-Flux / src /modules /notifications.py
jaimalklyde's picture
Claude Haiku 5.5
style(ui): emojis remplaces par des balises tactiques sobres
495d60d
Raw History Blame Contribute Delete
13.8 kB
import streamlit as st
import pandas as pd
import threading
from datetime import datetime, timedelta
from apscheduler.schedulers.background import BackgroundScheduler
from core import audit_log
from Analytics.vortex_params import set_parametre, get_parametre
from core.email_client import envoyer_email, get_credential
SEUIL_JOURS_DEMANDE = 3 # age (en jours) au-dela duquel une demande EN_ATTENTE remonte dans le digest
DIGEST_PARAM_NAME = "Dernier_Digest_Notifications"
DIGEST_INTERVALLE_HEURES = 24 # doit rester coherent avec digest_intervalle_heures de demarrer_scheduler
# ==============================================================================
# VARIABLE GLOBALE β€” accessible depuis le thread background ET depuis Streamlit
# On ne peut PAS Γ©crire dans st.session_state depuis un thread sΓ©parΓ©.
# ==============================================================================
_statut_notif = {
"derniere_digest" : None, # str β€” horodatage lisible du dernier digest admin
"derniere_digest_kyc" : 0, # int β€” dossiers KYC en attente signalΓ©s
"derniere_digest_prets" : 0, # int β€” demandes de prΓͺt en attente signalΓ©es
"scheduler_started" : False, # bool β€” anti-double-dΓ©marrage
}
_scheduler_lock = threading.Lock()
# ──────────────────────────────────────────────
# DIGEST ADMIN QUOTIDIEN (dossiers KYC + demandes en attente)
# ──────────────────────────────────────────────
def _template_digest_admin(kyc_pending: list, demandes_en_attente: list) -> str:
def _lignes_kyc():
if not kyc_pending:
return '<p style="color:#666;">Aucun dossier en attente de revue.</p>'
items = "".join(
f'<tr><td style="padding:6px 4px;">{d["type"]}</td>'
f'<td style="padding:6px 4px; font-weight:bold;">{d["nom"]}</td>'
f'<td style="padding:6px 4px;">{d["id"]}</td>'
f'<td style="padding:6px 4px; color:#d97706; font-weight:bold;">{d["statut"]}</td></tr>'
for d in kyc_pending
)
return f'<table style="width:100%; border-collapse:collapse; font-size:13px;">{items}</table>'
def _lignes_demandes():
if not demandes_en_attente:
return '<p style="color:#666;">Aucune demande en attente au-delΓ  du seuil.</p>'
items = "".join(
f'<tr><td style="padding:6px 4px;">{d["id_pret"]}</td>'
f'<td style="padding:6px 4px; font-weight:bold;">{d["nom"]}</td>'
f'<td style="padding:6px 4px; color:#d97706; font-weight:bold;">{d["age_jours"]} j</td></tr>'
for d in demandes_en_attente
)
return f'<table style="width:100%; border-collapse:collapse; font-size:13px;">{items}</table>'
return f"""
<html><body style="font-family: Arial, sans-serif; color: #333; max-width: 600px; margin: auto;">
<div style="background:#2d3748; padding:20px; border-radius:8px 8px 0 0; text-align:center;">
<h2 style="color:#fff; margin:0;">[REGISTRE] Digest Logistique β€” Dossiers Γ  traiter</h2>
<p style="color:#a0aec0; margin:6px 0 0;">Vortex-Flux – SynthΓ¨se quotidienne automatique</p>
</div>
<div style="background:#f9f9f9; padding:28px; border:1px solid #e2e8f0; border-top:none;">
<p><strong>KYC en attente de revue</strong> ({len(kyc_pending)})</p>
{_lignes_kyc()}
<p style="margin-top:24px;"><strong>Demandes de prΓͺt en attente depuis plus de {SEUIL_JOURS_DEMANDE} jours</strong> ({len(demandes_en_attente)})</p>
{_lignes_demandes()}
<p style="font-size:13px; color:#718096; margin-top:24px;">
[DATE] Digest gΓ©nΓ©rΓ© le {datetime.now().strftime('%d/%m/%Y Γ  %H:%M')}
</p>
</div>
<div style="background:#e2e8f0; padding:12px; text-align:center; font-size:12px; color:#718096; border-radius:0 0 8px 8px;">
Système de rappels automatiques Vortex-Flux
</div>
</body></html>
"""
@audit_log.tracer("JOB", "job_digest_admin", resume=lambda a, k: {"force": k.get("force", False)}, acteur="JOB:job_digest_admin")
def _job_digest_admin(gspread_client, sheet_name: str, force: bool = False):
"""TÒche planifiée (thread séparé) : synthèse quotidienne des dossiers KYC
en attente de revue et des demandes de prΓͺt en attente depuis trop longtemps.
N'Γ©crit jamais dans st.session_state ni n'appelle st.* (hors contexte Streamlit).
`next_run_time=datetime.now()` dans demarrer_scheduler() fait tourner ce job
immediatement a chaque demarrage du scheduler β€” donc a chaque redemarrage du
process (redeploiement, reveil du Space apres une periode d'inactivite...),
pas seulement toutes les digest_intervalle_heures. Sans ce garde-fou, un
redemarrage frequent (ex: un deploiement juste avant que quelqu'un se
connecte) renvoyait le digest a chaque fois, independamment du dernier
envoi reel. On verifie donc l'horodatage persiste dans Parametres_Vortex
(survit aux redemarrages) avant d'envoyer, sauf si force=True (bouton
"ENVOYER LE DIGEST MAINTENANT", qui doit toujours envoyer immediatement)."""
now = datetime.now()
now_str = now.strftime('%d/%m/%Y %H:%M')
print(f"[{now_str}] β–Ά Digest admin logistique…")
if not force:
dernier_envoi = get_parametre(gspread_client, sheet_name, DIGEST_PARAM_NAME)
if dernier_envoi:
try:
dernier_envoi_dt = datetime.fromisoformat(str(dernier_envoi))
if now - dernier_envoi_dt < timedelta(hours=DIGEST_INTERVALLE_HEURES):
print(f"[{now_str}] [SAUT] Digest deja envoye le {dernier_envoi} (< {DIGEST_INTERVALLE_HEURES}h) β€” ignore.")
return
except ValueError:
pass # valeur illisible : on ne bloque pas l'envoi
try:
sh = gspread_client.open(sheet_name)
df_clients = pd.DataFrame(sh.worksheet("Clients_KYC").get_all_records())
except Exception as e:
print(f"[ECHEC] Digest : impossible de lire Clients_KYC : {e}")
return
try:
df_garants = pd.DataFrame(sh.worksheet("Garants_KYC").get_all_records())
except Exception:
df_garants = pd.DataFrame()
try:
df_req = pd.DataFrame(sh.worksheet("Prets_Requests").get_all_records())
except Exception:
df_req = pd.DataFrame()
statuts_a_signaler = {"ORANGE_TOLERANCE", "ROUGE_REJET"}
kyc_pending = []
for df, label in ((df_clients, "Client"), (df_garants, "Garant")):
if df.empty or "Statut_KYC_Global" not in df.columns:
continue
subset = df[df["Statut_KYC_Global"].astype(str).str.upper().isin(statuts_a_signaler)]
for _, row in subset.iterrows():
kyc_pending.append({
"type": label,
"nom": row.get("Nom_Complet", "N/A"),
"id": row.get("ID_Client", row.get("ID_Garant", "N/A")),
"statut": row.get("Statut_KYC_Global", ""),
})
demandes_en_attente = []
if not df_req.empty and "Statut" in df_req.columns and "Date_Creation" in df_req.columns:
en_attente = df_req[df_req["Statut"].astype(str).str.strip().str.upper() == "EN_ATTENTE"]
for _, row in en_attente.iterrows():
try:
d_creation = datetime.strptime(str(row["Date_Creation"]).strip(), "%d-%m-%Y %H:%M:%S")
except ValueError:
continue
age_jours = (now - d_creation).days
if age_jours > SEUIL_JOURS_DEMANDE:
demandes_en_attente.append({
"id_pret": row.get("ID_Pret", "N/A"),
"nom": row.get("Nom_Complet", "N/A"),
"age_jours": age_jours,
})
if kyc_pending or demandes_en_attente:
sender_email = get_credential("SENDER_EMAIL")
destinataire_digest = get_credential("ADMIN_EMAIL") or sender_email
ok, detail = envoyer_email(
destinataire_digest,
f"Digest Logistique β€” {len(kyc_pending)} KYC / {len(demandes_en_attente)} demande(s)",
html_content=_template_digest_admin(kyc_pending, demandes_en_attente),
)
if not ok:
print(f"Digest : erreur d'envoi : {detail}")
_statut_notif["derniere_digest"] = now_str
_statut_notif["derniere_digest_kyc"] = len(kyc_pending)
_statut_notif["derniere_digest_prets"] = len(demandes_en_attente)
try:
set_parametre(gspread_client, sheet_name, DIGEST_PARAM_NAME, now.isoformat(),
raison=f"{len(kyc_pending)} KYC / {len(demandes_en_attente)} demandes")
except Exception as e:
print(f"[ATTENTION] Digest : Γ©chec horodatage Parametres_Vortex : {e}")
print(f"[Fin] [REGISTRE] Digest : {len(kyc_pending)} KYC en attente, {len(demandes_en_attente)} demande(s) en attente")
# ──────────────────────────────────────────────
# DÉMARRAGE DU SCHEDULER
# ──────────────────────────────────────────────
SIGNATURES_INTERVALLE_HEURES = 6
@audit_log.tracer("JOB", "job_relances_signatures", resume=lambda a, k: {}, acteur="JOB:job_relances_signatures")
def _job_relances_signatures(gspread_client, sheet_name: str):
"""Relance a 24 h, re-emission automatique apres expiration (max 3), alerte admin ensuite.
Voir core/signature_service.traiter_relances. N'echoue jamais bruyamment."""
try:
from core import signature_service
bilan = signature_service.traiter_relances(gspread_client.open(sheet_name))
print(f"[Signatures] relances={bilan['relances']} reemissions={bilan['reemissions']} alertes={bilan['alertes']}")
except Exception as exc:
from core.errors import log_swallowed
log_swallowed("Job de relances de signatures echoue", exc)
def demarrer_scheduler(gspread_client, sheet_name: str, digest_intervalle_heures: int = 24):
"""
Lance le scheduler en arrière-plan au démarrage de l'app.
Utilise _statut_notif (global) pour Γ©viter le double dΓ©marrage,
car st.session_state n'est pas fiable ici (contexte multi-thread).
"""
with _scheduler_lock:
if _statut_notif["scheduler_started"]:
return # DΓ©jΓ  en cours, on ne relance pas
scheduler = BackgroundScheduler(daemon=True)
scheduler.add_job(
func = _job_digest_admin,
args = [gspread_client, sheet_name],
trigger = "interval",
hours = digest_intervalle_heures,
next_run_time = datetime.now(), # ← exΓ©cution immΓ©diate au dΓ©marrage
id = "digest_admin",
replace_existing = True,
)
from core.suivi_emails import job_synchronisation
scheduler.add_job(
func = job_synchronisation,
args = [gspread_client, sheet_name],
trigger = "interval",
hours = 1,
next_run_time = datetime.now() + timedelta(minutes=10),
id = "suivi_emails",
replace_existing = True,
)
scheduler.add_job(
func = _job_relances_signatures,
args = [gspread_client, sheet_name],
trigger = "interval",
hours = SIGNATURES_INTERVALLE_HEURES,
next_run_time = datetime.now() + timedelta(minutes=5),
id = "relances_signatures",
replace_existing = True,
)
scheduler.start()
_statut_notif["scheduler_started"] = True
print(f"[OK] Scheduler dΓ©marrΓ© β€” digest toutes les {digest_intervalle_heures}h")
def declencher_digest_maintenant(gspread_client, sheet_name: str):
"""Declenchement manuel et synchrone du digest (bouton admin, hors thread planifie).
force=True : ignore le garde-fou des 24h, le clic du gerant doit toujours envoyer."""
_job_digest_admin(gspread_client, sheet_name, force=True)
return {
"derniere_digest": _statut_notif["derniere_digest"],
"kyc": _statut_notif["derniere_digest_kyc"],
"demandes": _statut_notif["derniere_digest_prets"],
}
# ──────────────────────────────────────────────
# WIDGET D'AFFICHAGE (Dashboard)
# ──────────────────────────────────────────────
def afficher_statut_notifications():
"""
Lit _statut_notif (global) et affiche le statut dans l'interface.
AppelΓ© depuis le thread principal Streamlit β€” lecture seule, thread-safe.
"""
st.markdown("### Notifications automatiques")
from modules.rappels_auto import HEURE_ENVOI
st.caption(
f"Rappels des Γ©chΓ©ances en retard et critiques : envoi automatique chaque jour Γ  "
f"{HEURE_ENVOI}h00. Historique dans Repayments > Journal d'audit des rappels."
)
derniere_digest = _statut_notif["derniere_digest"]
if derniere_digest:
st.info(
f"Dernier digest logistique : **{derniere_digest}** β€” "
f"{_statut_notif['derniere_digest_kyc']} dossier(s) KYC, "
f"{_statut_notif['derniere_digest_prets']} demande(s) en attente signalΓ©(s)"
)