Vortex-Flux / src /modules /notifications.py
klydekushy's picture
Update src/modules/notifications.py
688a66d verified
import streamlit as st
import pandas as pd
import smtplib
import threading
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime, timedelta
from apscheduler.schedulers.background import BackgroundScheduler
# ==============================================================================
# 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_verif" : None, # str β€” horodatage lisible
"derniere_verif_ok" : 0, # int — rappels envoyés avec succès
"derniere_verif_ko" : 0, # int β€” Γ©checs
"scheduler_started" : False, # bool β€” anti-double-dΓ©marrage
}
_scheduler_lock = threading.Lock()
# ──────────────────────────────────────────────
# TEMPLATES EMAIL HTML
# ──────────────────────────────────────────────
def _template_client(details: dict) -> str:
return f"""
<html><body style="font-family: Arial, sans-serif; color: #333; max-width: 600px; margin: auto;">
<div style="background:#1a1a2e; padding:20px; border-radius:8px 8px 0 0; text-align:center;">
<h2 style="color:#e0e0e0; margin:0;">πŸ”” Rappel de paiement</h2>
<p style="color:#a0aec0; margin:6px 0 0;">Vortex-Flux – Notification automatique</p>
</div>
<div style="background:#f9f9f9; padding:28px; border:1px solid #e2e8f0; border-top:none;">
<p>Bonjour <strong>{details['Nom_Complet']}</strong>,</p>
<p>Nous vous rappelons qu'une Γ©chΓ©ance de remboursement arrive dans <strong>2 jours</strong>.
Merci de vous assurer que les fonds nΓ©cessaires sont disponibles sur votre compte.</p>
<div style="background:#fff; border-left:4px solid #4f46e5; padding:16px; margin:20px 0; border-radius:4px;">
<p style="margin:0 0 8px;"><strong>πŸ“‹ DΓ©tails de l'Γ©chΓ©ance</strong></p>
<table style="width:100%; border-collapse:collapse; font-size:14px;">
<tr><td style="padding:6px 0; color:#666;">RΓ©fΓ©rence prΓͺt</td>
<td style="padding:6px 0; font-weight:bold;">{details['ID_Pret']}</td></tr>
<tr style="background:#f1f5f9;"><td style="padding:6px 4px; color:#666;">Date d'Γ©chΓ©ance</td>
<td style="padding:6px 4px; font-weight:bold;">{details['Date_Echeance']}</td></tr>
<tr><td style="padding:6px 0; color:#666;">Montant Γ  rΓ©gler</td>
<td style="padding:6px 0; font-weight:bold; color:#4f46e5;">{details['Montant_Versement']} XOF</td></tr>
</table>
</div>
<p>En cas de difficultΓ©s, contactez notre service client avant la date d'Γ©chΓ©ance.</p>
<p>Cordialement,<br><strong>L'Γ©quipe Vortex-Flux</strong></p>
</div>
<div style="background:#e2e8f0; padding:12px; text-align:center; font-size:12px; color:#718096; border-radius:0 0 8px 8px;">
Message automatique β€” merci de ne pas rΓ©pondre directement.
</div>
</body></html>
"""
def _template_admin(details: dict, email_client: str) -> str:
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;">βš™οΈ Notification Admin β€” Rappel J-2</h2>
<p style="color:#a0aec0; margin:6px 0 0;">Vortex-Flux – Rapport interne automatique</p>
</div>
<div style="background:#f9f9f9; padding:28px; border:1px solid #e2e8f0; border-top:none;">
<p>Un rappel automatique a Γ©tΓ© envoyΓ© au client suivant :</p>
<div style="background:#fff; border-left:4px solid #f59e0b; padding:16px; margin:20px 0; border-radius:4px;">
<table style="width:100%; border-collapse:collapse; font-size:14px;">
<tr><td style="padding:6px 0; color:#666; width:45%;">Nom complet</td>
<td style="padding:6px 0; font-weight:bold;">{details['Nom_Complet']}</td></tr>
<tr style="background:#f1f5f9;"><td style="padding:6px 4px; color:#666;">Email client</td>
<td style="padding:6px 4px;">{email_client or '<i>Non renseignΓ©</i>'}</td></tr>
<tr><td style="padding:6px 0; color:#666;">ID PrΓͺt</td>
<td style="padding:6px 0; font-weight:bold;">{details['ID_Pret']}</td></tr>
<tr style="background:#f1f5f9;"><td style="padding:6px 4px; color:#666;">Date d'Γ©chΓ©ance</td>
<td style="padding:6px 4px; font-weight:bold;">{details['Date_Echeance']}</td></tr>
<tr><td style="padding:6px 0; color:#666;">Montant</td>
<td style="padding:6px 0; font-weight:bold; color:#d97706;">{details['Montant_Versement']} XOF</td></tr>
</table>
</div>
<p style="font-size:13px; color:#718096;">
πŸ“… Notification gΓ©nΓ©rΓ©e 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>
"""
# ──────────────────────────────────────────────
# ENVOI EMAIL
# ──────────────────────────────────────────────
def envoyer_email_rappel(email_client: str, email_admin: str, details: dict) -> bool:
try:
smtp_server = st.secrets["smtp"]["server"]
smtp_port = int(st.secrets["smtp"]["port"])
sender_email = st.secrets["smtp"]["email"]
sender_pwd = st.secrets["smtp"]["password"]
except KeyError:
print("⚠️ Secrets SMTP introuvables.")
return False
def _build_msg(dest: str, sujet: str, html: str) -> MIMEMultipart:
msg = MIMEMultipart("alternative")
msg["From"] = sender_email
msg["To"] = dest
msg["Subject"] = sujet
msg.attach(MIMEText(html, "html", "utf-8"))
return msg
try:
with smtplib.SMTP(smtp_server, smtp_port, timeout=10) as server:
server.ehlo()
server.starttls()
server.login(sender_email, sender_pwd)
if email_client and "@" in email_client:
server.sendmail(
sender_email, email_client,
_build_msg(
email_client,
f"πŸ”” Rappel Γ©chΓ©ance dans 2 jours – PrΓͺt {details['ID_Pret']}",
_template_client(details)
).as_string()
)
server.sendmail(
sender_email, email_admin,
_build_msg(
email_admin,
f"[ADMIN] Rappel J-2 β€” {details['Nom_Complet']} / {details['ID_Pret']}",
_template_admin(details, email_client)
).as_string()
)
return True
except Exception as e:
print(f"❌ Erreur SMTP : {e}")
return False
# ──────────────────────────────────────────────
# TΓ‚CHE AUTOMATIQUE (thread background)
# ──────────────────────────────────────────────
def _job_verifier_echeances(gspread_client, sheet_name: str):
"""
TΓ’che planifiΓ©e β€” s'exΓ©cute dans un thread sΓ©parΓ©.
Γ‰crit dans _statut_notif (global) et NON dans st.session_state
car st.session_state n'est pas accessible hors contexte Streamlit.
"""
now_str = datetime.now().strftime('%d/%m/%Y %H:%M')
print(f"[{now_str}] β–Ά VΓ©rification automatique des Γ©chΓ©ances…")
try:
email_admin = st.secrets["smtp"]["email"]
except Exception:
email_admin = "admin@vortex.com"
delta_limit = timedelta(days=2)
now = datetime.now()
alertes_ok = 0
alertes_ko = 0
try:
sh = gspread_client.open(sheet_name)
df_prets = pd.DataFrame(sh.worksheet("Prets_Master").get_all_records())
df_clients = pd.DataFrame(sh.worksheet("Clients_KYC").get_all_records())
except Exception as e:
print(f"❌ Impossible de lire le Google Sheet : {e}")
_statut_notif["derniere_verif"] = f"{now_str} (erreur lecture Sheet)"
return
map_emails = dict(zip(df_clients["ID_Client"], df_clients["Email"]))
for _, row in df_prets.iterrows():
if str(row.get("Statut", "")).strip().upper() != "ACTIF":
continue
dates_str = str(row.get("Dates_Versements", "")).strip()
if not dates_str:
continue
for d_str in dates_str.split(";"):
d_str = d_str.strip()
try:
d_obj = datetime.strptime(d_str, "%d/%m/%Y")
except ValueError:
continue
diff = d_obj - now
if timedelta(seconds=0) < diff <= delta_limit:
email_client = map_emails.get(row["ID_Client"], "")
details = {
"ID_Pret" : row["ID_Pret"],
"Nom_Complet" : row["Nom_Complet"],
"Date_Echeance" : d_str,
"Montant_Versement" : row["Montant_Versement"],
}
if envoyer_email_rappel(email_client, email_admin, details):
print(f" βœ… Rappel β†’ {row['Nom_Complet']} ({d_str})")
alertes_ok += 1
else:
print(f" ❌ Γ‰chec β†’ {row['Nom_Complet']} ({d_str})")
alertes_ko += 1
# βœ… Γ‰criture dans la variable GLOBALE (et non st.session_state)
_statut_notif["derniere_verif"] = now_str
_statut_notif["derniere_verif_ok"] = alertes_ok
_statut_notif["derniere_verif_ko"] = alertes_ko
print(f"[Fin] βœ… {alertes_ok} envoyΓ©(s) | ❌ {alertes_ko} Γ©chec(s)")
# ──────────────────────────────────────────────
# DÉMARRAGE DU SCHEDULER
# ──────────────────────────────────────────────
def demarrer_scheduler(gspread_client, sheet_name: str, intervalle_heures: int = 12):
"""
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_verifier_echeances,
args = [gspread_client, sheet_name],
trigger = "interval",
hours = intervalle_heures,
next_run_time = datetime.now(), # ← exΓ©cution immΓ©diate au dΓ©marrage
id = "verif_echeances",
replace_existing = True,
)
scheduler.start()
_statut_notif["scheduler_started"] = True
print(f"βœ… Scheduler dΓ©marrΓ© β€” vΓ©rification toutes les {intervalle_heures}h")
# ──────────────────────────────────────────────
# 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")
derniere = _statut_notif["derniere_verif"]
if derniere:
ok = _statut_notif["derniere_verif_ok"]
ko = _statut_notif["derniere_verif_ko"]
if ko > 0:
st.warning(
f"⚠️ Dernière vérification : **{derniere}** "
f"β€” {ok} rappel(s) envoyΓ©(s), {ko} Γ©chec(s)"
)
elif ok > 0:
st.success(
f"βœ… DerniΓ¨re vΓ©rification : **{derniere}** "
f"β€” {ok} rappel(s) envoyΓ©(s)"
)
else:
st.info(f"βœ… DerniΓ¨re vΓ©rification : **{derniere}** β€” Aucune Γ©chΓ©ance dans 2 jours.")
else:
# Le scheduler a dΓ©marrΓ© mais le premier job n'a pas encore terminΓ©
st.info("⏳ VΓ©rification en cours… (rΓ©sultat disponible dans quelques secondes)")
st.caption("Les emails sont envoyΓ©s automatiquement toutes les 12h. Aucune action requise.")