Spaces:
Running
Running
| 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.") |