Spaces:
Running
Running
Download src/modules/notifications.py from klydekushy/Vortex-Flux: direct link, hf CLI and curl.
- Browser
- Download file 13.8 kB
-
https://huggingface.co/spaces/klydekushy/Vortex-Flux/resolve/main/src/modules/notifications.py
- Command line
-
hf download hf://spaces/klydekushy/Vortex-Flux/src/modules/notifications.py
-
curl -L -o notifications.py https://huggingface.co/spaces/klydekushy/Vortex-Flux/resolve/main/src/modules/notifications.py
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> | |
| """ | |
| 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 | |
| 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)" | |
| ) |