From 5f9d1c09118a929abee4eb8a79c8d0ea2e87bbed Mon Sep 17 00:00:00 2001 From: Michel Date: Wed, 11 Feb 2026 11:13:20 +0100 Subject: [PATCH] =?UTF-8?q?Remise=20en=20=C3=A9tat=20des=20alertes=20table?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 4 +- app/domo91.py | 6 +- app/surveillance_releves.py | 289 +++++++++++++++++++++--------------- app/utils_mail.py | 39 +++-- 4 files changed, 193 insertions(+), 145 deletions(-) diff --git a/.env b/.env index ebed3c0..e262a32 100644 --- a/.env +++ b/.env @@ -34,11 +34,11 @@ LOGLEVEL=INFO # paramètres mail SMTP_HOST=ssl0.ovh.net SMTP_PORT=465 -SMTP_SECURITY=STARTTLS +SMTP_SECURITY=SSL SMTP_USER=services@domo91.fr SMTP_PASS=VHq3278YA#sGV*bh#mR MAIL_FROM=services@domo91.fr -MAIL_TO=michel@mj91.fr +MAIL_TO=robots@domo91.fr MAIL_TO_SACLAY=robots@domo91.fr,nicolas.thibaut@bw-paris-saclay.com MAIL_FROM_SACLAY="DOMO91 Saclay " MAIL_TO_MEUDON=robots@domo91.fr,chef@parismeudonermitage.com diff --git a/app/domo91.py b/app/domo91.py index 5d13ffe..745787f 100644 --- a/app/domo91.py +++ b/app/domo91.py @@ -865,9 +865,9 @@ if st.session_state.get("authenticated"): ) st.session_state["selected_site"] = site_actuel else: - st.info(f"Site imposé : {site_actuel}") - - assert_site_ok(site_actuel) + st.info(f"Site imposé") + #st.info(f"Site imposé : {site_actuel}") + #assert_site_ok(site_actuel) # Voyant Gyro st.subheader(f"🚨 Statut Gyro — {site_actuel}") diff --git a/app/surveillance_releves.py b/app/surveillance_releves.py index 5b3f0cc..0f9b1c3 100644 --- a/app/surveillance_releves.py +++ b/app/surveillance_releves.py @@ -1,95 +1,113 @@ -#!/home/debian/Gestion_sondes/myenv/bin/python3 -# Surveillance de l'arrivée des relevés (par table/site) + SMS d'alerte / retour à la normale +#!/usr/bin/env python3 +# Surveillance des réceptions de données dans les tables (par site) + mail d'alerte / retour à la normale + +from __future__ import annotations from datetime import datetime, timedelta -from dotenv import load_dotenv import os -import logging import sys +import logging +import tempfile +from contextlib import closing import mysql.connector # important pour cibler les exceptions MySQL +from dotenv import load_dotenv import utils_db from utils_mail import envoyer_mail -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(levelname)s - %(message)s", - handlers=[logging.StreamHandler(sys.stdout)] -) +# -------------------- PATHS (portable Windows/Linux) -------------------- +APP_DIR = os.path.dirname(os.path.abspath(__file__)) # .../Gestion_sondes/app +ROOT_DIR = os.path.abspath(os.path.join(APP_DIR, "..")) # .../Gestion_sondes -# Forcer l'encodage UTF-8 du flux si possible (Windows/PyCharm) -try: - sys.stdout.reconfigure(encoding="utf-8") -except Exception: - pass -# -------------------- LOGS -------------------- -LOG_DIR = '/home/debian/Gestion_sondes/Logs' +ENV_PATH = os.path.join(ROOT_DIR, ".env") + +LOG_DIR = os.path.join(ROOT_DIR, "Logs") os.makedirs(LOG_DIR, exist_ok=True) log_filename = os.path.join(LOG_DIR, datetime.now().strftime("surveillance_%Y-%m-%d.log")) + + +# -------------------- LOGGING (UTF-8 + fichier + console) -------------------- logging.basicConfig( level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s', - handlers=[logging.FileHandler(log_filename), logging.StreamHandler()] + format="%(asctime)s - %(levelname)s - %(message)s", + handlers=[ + logging.FileHandler(log_filename, encoding="utf-8"), + logging.StreamHandler(sys.stdout), + ], + force=True, # Python 3.8+ ) +# Fix Windows/PyCharm : forcer l'encodage UTF-8 sur le StreamHandler (évite UnicodeEncodeError cp1252) +for h in logging.getLogger().handlers: + if isinstance(h, logging.StreamHandler): + try: + h.stream.reconfigure(encoding="utf-8", errors="replace") + except Exception: + # Certains streams/IDEs ne supportent pas reconfigure + pass + + # -------------------- ENV -------------------- -load_dotenv('/home/debian/Gestion_sondes/.env') +# override=True : si cron/supervisor/IDE a des variables vides, le .env reprend la main +load_dotenv(ENV_PATH, override=True) + # -------------------- PARAMETRES -------------------- -tables = ['Saclay', 'Meudon'] +# Mets ici les tables réelles à surveiller (noms exacts des tables MySQL) +tables = ["Saclay", "Meudon"] + DELAI_MINUTES = 15 RAPPEL_HEURES = 6 -STATE_DIR = '/tmp/surveillance_states' +# State portable (Windows/Linux) +STATE_DIR = os.path.join(tempfile.gettempdir(), "surveillance_states") os.makedirs(STATE_DIR, exist_ok=True) TABLES_SET = set(tables) # whitelist simple + +# -------------------- STATE MANAGEMENT -------------------- def _state_file(site: str) -> str: - return os.path.join(STATE_DIR, f'{site}.state') + return os.path.join(STATE_DIR, f"{site}.state") + + +def _read_last_alert(site: str) -> datetime | None: + sf = _state_file(site) + if not os.path.exists(sf): + return None + try: + with open(sf, "r", encoding="utf-8") as f: + raw = f.read().strip() + return datetime.fromisoformat(raw) + except (OSError, ValueError) as e: + logging.warning(f"Etat corrompu pour {site} ({sf}) : {e}. On ignorera cet état.") + return None + + +def mark_alert_sent(site: str, when: datetime | None = None) -> None: + """Écrit/actualise l'état *après* un envoi mail réussi.""" + sf = _state_file(site) + when = when or datetime.now() + try: + with open(sf, "w", encoding="utf-8") as f: + f.write(when.isoformat()) + except OSError as e: + logging.warning(f"Impossible d'écrire l'état {sf} : {e} (l'anti-spam sera moins efficace).") + def should_send_alert(site: str) -> bool: """ Retourne True si on doit envoyer une alerte maintenant (première fois ou rappel). - Met à jour le fichier d'état en cas d'envoi. + IMPORTANT : ne modifie PAS l'état ici. + L'état est mis à jour uniquement après envoi réussi (mark_alert_sent). """ - sf = _state_file(site) - now = datetime.now() - - if not os.path.exists(sf): - # première alerte - try: - with open(sf, 'w') as f: - f.write(now.isoformat()) - except OSError as e: - logging.warning(f"Impossible d'écrire l'état {sf} : {e} (on alerte quand même).") + last_alert = _read_last_alert(site) + if last_alert is None: return True + return (datetime.now() - last_alert) >= timedelta(hours=RAPPEL_HEURES) - try: - with open(sf, 'r') as f: - raw = f.read().strip() - last_alert = datetime.fromisoformat(raw) - except (OSError, ValueError) as e: - # état illisible/corrompu -> on réinitialise et on alerte - logging.warning(f"Etat corrompu pour {site} ({sf}) : {e}. Réinitialisation.") - try: - with open(sf, 'w') as f: - f.write(now.isoformat()) - except OSError as e2: - logging.warning(f"Impossible de réécrire l'état {sf} : {e2}") - return True - - if now - last_alert >= timedelta(hours=RAPPEL_HEURES): - try: - with open(sf, 'w') as f: - f.write(now.isoformat()) - except OSError as e: - logging.warning(f"Impossible de mettre à jour l'état {sf} : {e} (on alerte quand même).") - return True - - return False def clear_state(site: str) -> None: sf = _state_file(site) @@ -99,80 +117,111 @@ def clear_state(site: str) -> None: except OSError as e: logging.warning(f"Impossible de supprimer l'état {sf} : {e}") -def main(): - problemes = [] + +# -------------------- MAIN -------------------- +def main() -> None: limite = datetime.now() - timedelta(minutes=DELAI_MINUTES) + # pour logs plus clairs + defauts_en_cours: list[str] = [] # défaut détecté (même si déjà signalé) + alertes_envoyees: list[str] = [] # alertes envoyées à CE run (pour mail groupé) + erreurs_sql: list[str] = [] # SQL KO sur tables + # 1) Connexion MySQL (une seule fois) try: - cnx = utils_db.connect_to_mysql() - cursor = cnx.cursor() + with closing(utils_db.connect_to_mysql()) as cnx, closing(cnx.cursor()) as cursor: + + # 2) Surveillance par table + for table in tables: + if table not in TABLES_SET: + logging.warning(f"Table ignorée (non whitelistée) : {table}") + continue + + # 2a) Lecture de la dernière date + try: + cursor.execute(f"SELECT MAX(Date) FROM `{table}`") + row = cursor.fetchone() + last_update = row[0] if row else None + except mysql.connector.Error as e: + msg = f"Erreur SQL sur {table} : {e}" + erreurs_sql.append(table) + logging.error(msg) + + # Alerte SQL (anti-spam) + if should_send_alert(table): + try: + envoyer_mail( + f"⚠️ ALERTE : erreur SQL sur {table} (voir logs).", + f"Erreur SQL détectée sur la table {table}.\n\nDétail:\n{e}" + ) + mark_alert_sent(table) + alertes_envoyees.append(f"{table} (SQL)") + except Exception as mail_e: + logging.error(f"Impossible d'envoyer le mail d'erreur SQL pour {table} : {mail_e}") + else: + logging.info(f"⏳ {table} : erreur SQL déjà signalée, rappel dans {RAPPEL_HEURES}h.") + continue + + # 2b) Logique métier + if (last_update is None) or (last_update < limite): + defauts_en_cours.append(table) + + if should_send_alert(table): + logging.warning(f"⚠️ {table} en défaut (dernier relevé : {last_update})") + + # On envoie le mail tout de suite (alerte individuelle) + try: + envoyer_mail( + f"⚠️ ALERTE : {table} absence de relevés", + f"Pas de relevés depuis > {DELAI_MINUTES} min.\nDernier relevé : {last_update}" + ) + mark_alert_sent(table) + alertes_envoyees.append(f"{table} (dernier : {last_update})") + except Exception as mail_e: + # pas de mark_alert_sent => on retentera au prochain run + logging.error(f"Erreur envoi mail alerte pour {table} : {mail_e}") + else: + logging.info(f"⏳ {table} déjà signalé, rappel dans {RAPPEL_HEURES}h.") + + else: + # OK => si on avait un état, on envoie un "retour à la normale" + if os.path.exists(_state_file(table)): + message = f"✅ {table} : relevés à nouveau reçus (dernier : {last_update}). Situation normale." + try: + envoyer_mail(f"✅ OK : {table} relevés reçus", message) + clear_state(table) + logging.info(f"📩 Retour à la normale envoyé pour {table}.") + except Exception as mail_e: + # on conserve le state => on retentera le retour à la normale + logging.error(f"Erreur envoi mail retour à la normale pour {table} : {mail_e}") + else: + logging.info(f"✅ {table} OK (dernier relevé : {last_update})") + except mysql.connector.Error as e: logging.error(f"MySQL KO : {e}") - envoyer_mail( - "⚠️ ALERTE : Base MySQL inaccessible (surveillance impossible).", - "Connexion MySQL impossible : la surveillance des relevés ne peut pas s’exécuter." - ) + try: + envoyer_mail( + "⚠️ ALERTE : Base MySQL inaccessible (surveillance impossible).", + "Connexion MySQL impossible : la surveillance des relevés ne peut pas s’exécuter." + ) + except Exception as mail_e: + logging.error(f"Impossible d'envoyer le mail MySQL KO : {mail_e}") return - # 2) Surveillance par table (try SQL à l'intérieur de la boucle) - try: - for table in tables: - if table not in TABLES_SET: - logging.warning(f"Table ignorée (non whitelistée) : {table}") - continue - - # 2a) Lecture de la dernière date (erreurs SQL gérées finement) - try: - cursor.execute(f"SELECT MAX(Date) FROM `{table}`") - (last_update,) = cursor.fetchone() - except mysql.connector.Error as e: - logging.error(f"Erreur SQL sur {table} : {e}") - # Vous pouvez décider ici si vous voulez un SMS ou seulement un log. - if should_send_alert(table): - envoyer_mail( - f"⚠️ ALERTE : erreur SQL sur {table} (voir logs).", - f"Erreur SQL détectée sur la table {table}. Merci de consulter le fichier log pour le détail." - ) - continue - - # 2b) Logique métier (hors try SQL) - if (last_update is None) or (last_update < limite): - if should_send_alert(table): - problemes.append(f"{table} (dernier relevé : {last_update})") - logging.warning(f"⚠️ {table} en défaut (dernier relevé : {last_update})") - else: - logging.info(f"⏳ {table} déjà signalé, rappel dans {RAPPEL_HEURES}h.") - else: - # Retour à la normale uniquement si on était en défaut (state présent) - if os.path.exists(_state_file(table)): - message = f"✅ {table} : relevés à nouveau reçus (dernier : {last_update}). Situation normale." - envoyer_mail( - f"✅ OK : {table} relevés reçus", - message - ) - clear_state(table) - logging.info(f"📩 Retour à la normale envoyé pour {table}.") - else: - logging.info(f"✅ {table} OK (dernier relevé : {last_update})") - - finally: - # 3) Nettoyage MySQL - try: - cursor.close() - cnx.close() - except mysql.connector.Error: - pass - - # 4) Alerte groupée si besoin - if problemes: - message = f"⚠️ ALERTE : pas de relevés depuis >{DELAI_MINUTES}min :\n" + "\n".join(problemes) - envoyer_mail( - f"⚠️ ALERTE : absence de relevés > {DELAI_MINUTES} min", - message - ) + # 3) Logs de synthèse (fidèles à la réalité) + if alertes_envoyees: + logging.info("📧 Mail(s) envoyé(s) : " + ", ".join(alertes_envoyees)) + elif defauts_en_cours or erreurs_sql: + # défauts en cours mais déjà signalés (ou erreurs SQL déjà signalées) + bloc = [] + if defauts_en_cours: + bloc.append("défaut(s) relevés: " + ", ".join(defauts_en_cours)) + if erreurs_sql: + bloc.append("erreur(s) SQL: " + ", ".join(erreurs_sql)) + logging.info("⚠️ " + " | ".join(bloc) + " (déjà signalé / pas de mail envoyé à ce run)") else: logging.info("👍 Tout est OK, aucun Mail envoyé.") + if __name__ == "__main__": main() diff --git a/app/utils_mail.py b/app/utils_mail.py index d8ba75f..8d552ef 100644 --- a/app/utils_mail.py +++ b/app/utils_mail.py @@ -1,25 +1,22 @@ -# /home/debian/Gestion_sondes/utils_mail.py import os import smtplib import logging from email.mime.text import MIMEText from dotenv import load_dotenv -load_dotenv('/home/debian/Gestion_sondes/.env') +# Charge le .env (et override pour éviter des variables vides héritées de cron/supervisor) +load_dotenv('/home/debian/Gestion_sondes/.env', override=True) -SMTP_HOST = os.getenv("SMTP_HOST", "smtp.mail.ovh.net") +SMTP_HOST = os.getenv("SMTP_HOST") SMTP_PORT = int(os.getenv("SMTP_PORT", "465")) -SMTP_LOGIN = os.getenv("SMTP_USER") # ex: services@domo91.fr -SMTP_PASSWORD = os.getenv("SMTP_PASS") # mot de passe OVH -MAIL_FROM = os.getenv("MAIL_FROM", SMTP_LOGIN) -MAIL_TO = os.getenv("MAIL_TO") # ex: services@domo91.fr +SMTP_LOGIN = os.getenv("SMTP_USER") # <-- correspond à ton .env +SMTP_PASSWORD = os.getenv("SMTP_PASS") # <-- correspond à ton .env + +MAIL_FROM = os.getenv("MAIL_FROM") +MAIL_TO = os.getenv("MAIL_TO") def envoyer_mail(sujet: str, contenu: str, destinataires=None) -> None: - """ - Envoi email via OVH SMTP SSL 465 (process identique à supervisor_watchdog.py). - destinataires: str unique ou liste; si None => MAIL_TO depuis .env - """ if destinataires is None: if not MAIL_TO: raise ValueError("MAIL_TO manquant dans le .env") @@ -27,19 +24,21 @@ def envoyer_mail(sujet: str, contenu: str, destinataires=None) -> None: elif isinstance(destinataires, str): destinataires = [destinataires] + if not SMTP_HOST: + raise ValueError("SMTP_HOST manquant dans le .env") if not SMTP_LOGIN or not SMTP_PASSWORD: - raise ValueError("SMTP_LOGIN / SMTP_PASSWORD manquants dans le .env") + raise ValueError("SMTP_USER / SMTP_PASS manquants dans le .env") + if not MAIL_FROM: + raise ValueError("MAIL_FROM manquant dans le .env") msg = MIMEText(contenu) msg["Subject"] = sujet msg["From"] = MAIL_FROM msg["To"] = ", ".join(destinataires) - try: - with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT) as server: - server.login(SMTP_LOGIN, SMTP_PASSWORD) - server.sendmail(MAIL_FROM, destinataires, msg.as_string()) - logging.info("📧 Mail envoyé: %s -> %s", sujet, destinataires) - except Exception as e: - logging.error("Erreur envoi mail: %s", e) - raise + # SSL direct (OVH ssl0.ovh.net:465) + with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=30) as server: + server.login(SMTP_LOGIN, SMTP_PASSWORD) + server.sendmail(MAIL_FROM, destinataires, msg.as_string()) + + logging.info("📧 Mail envoyé: %s -> %s", sujet, destinataires)