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 '
Aucun dossier en attente de revue.
'
items = "".join(
f'| {d["type"]} | '
f'{d["nom"]} | '
f'{d["id"]} | '
f'{d["statut"]} |
'
for d in kyc_pending
)
return f''
def _lignes_demandes():
if not demandes_en_attente:
return 'Aucune demande en attente au-delà du seuil.
'
items = "".join(
f'| {d["id_pret"]} | '
f'{d["nom"]} | '
f'{d["age_jours"]} j |
'
for d in demandes_en_attente
)
return f''
return f"""
[REGISTRE] Digest Logistique — Dossiers à traiter
Vortex-Flux – Synthèse quotidienne automatique
KYC en attente de revue ({len(kyc_pending)})
{_lignes_kyc()}
Demandes de prêt en attente depuis plus de {SEUIL_JOURS_DEMANDE} jours ({len(demandes_en_attente)})
{_lignes_demandes()}
[DATE] Digest généré le {datetime.now().strftime('%d/%m/%Y à %H:%M')}
Système de rappels automatiques Vortex-Flux
"""
@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)"
)