#!/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 import os 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 # -------------------- 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 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, 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 -------------------- # override=True : si cron/supervisor/IDE a des variables vides, le .env reprend la main load_dotenv(ENV_PATH, override=True) # -------------------- PARAMETRES -------------------- # Mets ici les tables réelles à surveiller (noms exacts des tables MySQL) tables = ["Saclay", "Meudon"] DELAI_MINUTES = 15 RAPPEL_HEURES = 6 # 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") 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). IMPORTANT : ne modifie PAS l'état ici. L'état est mis à jour uniquement après envoi réussi (mark_alert_sent). """ last_alert = _read_last_alert(site) if last_alert is None: return True return (datetime.now() - last_alert) >= timedelta(hours=RAPPEL_HEURES) def clear_state(site: str) -> None: sf = _state_file(site) try: if os.path.exists(sf): os.remove(sf) except OSError as e: logging.warning(f"Impossible de supprimer l'état {sf} : {e}") # -------------------- 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: 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}") 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 # 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()