Files
Gestion_sondes/app/surveillance_releves.py
2026-04-20 09:36:11 +02:00

406 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Surveillance des réceptions de données dans les tables (par site)
+ alerte mail
+ alerte Synology Chat
+ retour à la normale
"""
from __future__ import annotations
from datetime import datetime, timedelta
from pathlib import Path
import json
import logging
import os
import sys
import mysql.connector
from contextlib import closing
from typing import cast
from mysql.connector.connection import MySQLConnection
from mysql.connector.cursor import MySQLCursor
import requests
from dotenv import load_dotenv
import utils_db
from utils_mail import envoyer_mail
# ============================================================
# PATHS / ENV
# ============================================================
APP_DIR = Path(__file__).resolve().parent # .../Gestion_sondes/app
ROOT_DIR = APP_DIR.parent # .../Gestion_sondes
ENV_PATH = ROOT_DIR / ".env"
LOG_DIR = ROOT_DIR / "Logs"
LOG_DIR.mkdir(parents=True, exist_ok=True)
# Etat persistant dans le projet (évite les surprises de /tmp)
STATE_DIR = APP_DIR / "state"
STATE_DIR.mkdir(parents=True, exist_ok=True)
load_dotenv(ENV_PATH, override=True)
# ============================================================
# CONFIGURATION
# ============================================================
TABLES = ["Saclay", "Meudon"]
TABLES_SET = set(TABLES)
DELAI_MINUTES = 15
RAPPEL_HEURES = 6
SYNOLOGY_CHAT_WEBHOOK_URL = os.getenv("SYNOLOGY_CHAT_WEBHOOK_URL", "").strip()
SYNOLOGY_CHAT_VERIFY_SSL = os.getenv("SYNOLOGY_CHAT_VERIFY_SSL", "true").strip().lower() in (
"1", "true", "yes", "on"
)
# ============================================================
# LOGGING
# ============================================================
log_filename = LOG_DIR / f"surveillance_{datetime.now():%Y-%m-%d}.log"
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,
)
for handler in logging.getLogger().handlers:
if isinstance(handler, logging.StreamHandler):
try:
handler.stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
# ============================================================
# ETAT DES ALERTES
# ============================================================
def state_file(site: str) -> Path:
return STATE_DIR / f"{site}.json"
def read_state(site: str) -> dict:
sf = state_file(site)
if not sf.exists():
return {
"status": "ok", # ok | alerting
"first_alert_at": None,
"last_alert_at": None,
"last_data_at": None,
}
try:
data = json.loads(sf.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError("format JSON invalide")
return {
"status": data.get("status", "ok"),
"first_alert_at": data.get("first_alert_at"),
"last_alert_at": data.get("last_alert_at"),
"last_data_at": data.get("last_data_at"),
}
except Exception as e:
logging.warning(f"Etat corrompu pour {site} ({sf}) : {e}. Etat réinitialisé.")
return {
"status": "ok",
"first_alert_at": None,
"last_alert_at": None,
"last_data_at": None,
}
def write_state(site: str, state: dict) -> None:
sf = state_file(site)
try:
sf.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
except OSError as e:
logging.warning(f"Impossible d'écrire l'état {sf} : {e}")
def dt_to_iso(value) -> str | None:
if value is None:
return None
if isinstance(value, datetime):
return value.isoformat()
return str(value)
def iso_to_dt(value: str | None) -> datetime | None:
if not value:
return None
try:
return datetime.fromisoformat(value)
except ValueError:
return None
def enter_alert_state(site: str, last_update) -> None:
state = read_state(site)
now = datetime.now()
state["status"] = "alerting"
state["first_alert_at"] = state["first_alert_at"] or now.isoformat()
state["last_alert_at"] = now.isoformat()
state["last_data_at"] = dt_to_iso(last_update)
write_state(site, state)
def update_last_data(site: str, last_update) -> None:
state = read_state(site)
state["last_data_at"] = dt_to_iso(last_update)
write_state(site, state)
def clear_state(site: str) -> None:
write_state(site, {
"status": "ok",
"first_alert_at": None,
"last_alert_at": None,
"last_data_at": None,
})
def is_alerting(site: str) -> bool:
return read_state(site).get("status") == "alerting"
def should_send_alert(site: str) -> bool:
"""
Règle :
- 1ère alerte dès que l'absence de données dépasse DELAI_MINUTES
- puis 1 rappel toutes les RAPPEL_HEURES tant que le défaut persiste
"""
state = read_state(site)
if state.get("status") != "alerting":
return True
last_alert = iso_to_dt(state.get("last_alert_at"))
if last_alert is None:
return True
return (datetime.now() - last_alert) >= timedelta(hours=RAPPEL_HEURES)
# ============================================================
# NOTIFICATIONS
# ============================================================
def envoyer_chat(titre: str, message: str) -> None:
if not SYNOLOGY_CHAT_WEBHOOK_URL:
logging.warning("Webhook Synology Chat non configuré : notification Chat ignorée.")
return
texte = f"{titre}\n{message}"
payload = {"text": texte}
response = requests.post(
SYNOLOGY_CHAT_WEBHOOK_URL,
data={"payload": json.dumps(payload, ensure_ascii=False)},
timeout=10,
verify=SYNOLOGY_CHAT_VERIFY_SSL,
)
response.raise_for_status()
try:
rep_json = response.json()
if isinstance(rep_json, dict) and rep_json.get("success") is False:
raise RuntimeError(f"Synology Chat a refusé le message : {rep_json}")
except ValueError:
pass
logging.info("💬 Notification Synology Chat envoyée.")
def envoyer_notifications(sujet: str, message: str) -> None:
"""
Envoie mail + chat.
Lève une erreur si au moins un des deux canaux échoue.
"""
erreurs: list[str] = []
try:
envoyer_mail(sujet, message)
logging.info("📧 Mail envoyé.")
except Exception as e:
erreurs.append(f"mail: {e}")
try:
envoyer_chat(sujet, message)
except Exception as e:
erreurs.append(f"chat: {e}")
if erreurs:
raise RuntimeError(" | ".join(erreurs))
# ============================================================
# ACCES BASE
# ============================================================
def get_last_update(cursor, table: str) -> datetime | None:
cursor.execute(f"SELECT MAX(Date) FROM `{table}`")
row = cursor.fetchone()
if not row or row[0] is None:
return None
value = row[0]
if isinstance(value, datetime):
return value
return None
# ============================================================
# TRAITEMENT DES TABLES
# ============================================================
def traiter_table(cursor, table: str, limite: datetime,
defauts_en_cours: list[str],
alertes_envoyees: list[str],
erreurs_sql: list[str]) -> None:
"""
Gère la surveillance d'une table.
"""
if table not in TABLES_SET:
logging.warning(f"Table ignorée (non whitelistée) : {table}")
return
try:
last_update = get_last_update(cursor, table)
except mysql.connector.Error as e:
erreurs_sql.append(table)
logging.error(f"Erreur SQL sur {table} : {e}")
if should_send_alert(table):
try:
envoyer_notifications(
f"⚠️ ALERTE : erreur SQL sur {table}",
f"Erreur SQL détectée sur la table {table}.\n\nDétail :\n{e}"
)
enter_alert_state(table, None)
alertes_envoyees.append(f"{table} (SQL)")
except Exception as notif_e:
logging.error(f"Impossible d'envoyer les notifications SQL pour {table} : {notif_e}")
else:
logging.info(f"{table} : erreur SQL déjà signalée, rappel dans {RAPPEL_HEURES}h.")
return
# Cas défaut : aucune donnée ou donnée trop ancienne
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})")
try:
envoyer_notifications(
f"⚠️ ALERTE : {table} absence de relevés",
f"Pas de relevés depuis plus de {DELAI_MINUTES} min.\nDernier relevé : {last_update}"
)
enter_alert_state(table, last_update)
alertes_envoyees.append(f"{table} (dernier : {last_update})")
except Exception as notif_e:
logging.error(f"Erreur envoi notifications alerte pour {table} : {notif_e}")
else:
logging.info(f"{table} déjà signalé, rappel dans {RAPPEL_HEURES}h.")
return
# Cas normal : de nouvelles données sont présentes
was_alerting = is_alerting(table)
previous_state = read_state(table)
previous_last_data = previous_state.get("last_data_at")
current_last_data = dt_to_iso(last_update)
# on mémorise la dernière donnée vue, même en état normal
update_last_data(table, last_update)
# retour à la normale seulement si on sort réellement d'un état d'alerte
# et qu'une donnée plus récente est arrivée
if was_alerting and current_last_data != previous_last_data:
message = f"{table} : relevés à nouveau reçus (dernier : {last_update}). Situation normale."
try:
envoyer_notifications(
f"✅ OK : {table} relevés reçus",
message
)
clear_state(table)
logging.info(f"📩 Retour à la normale envoyé pour {table}.")
except Exception as notif_e:
logging.error(f"Erreur envoi notifications retour à la normale pour {table} : {notif_e}")
else:
logging.info(f"{table} OK (dernier relevé : {last_update})")
# ============================================================
# MAIN
# ============================================================
def main() -> None:
limite = datetime.now() - timedelta(minutes=DELAI_MINUTES)
defauts_en_cours: list[str] = []
alertes_envoyees: list[str] = []
erreurs_sql: list[str] = []
try:
cnx = cast(MySQLConnection, utils_db.connect_to_mysql())
with closing(cnx):
cursor = cast(MySQLCursor, cnx.cursor())
with closing(cursor):
for table in TABLES:
traiter_table(
cursor=cursor,
table=table,
limite=limite,
defauts_en_cours=defauts_en_cours,
alertes_envoyees=alertes_envoyees,
erreurs_sql=erreurs_sql,
)
except mysql.connector.Error as e:
logging.error(f"MySQL KO : {e}")
try:
envoyer_notifications(
"⚠️ ALERTE : Base MySQL inaccessible",
"Connexion MySQL impossible : la surveillance des relevés ne peut pas sexécuter."
)
except Exception as notif_e:
logging.error(f"Impossible d'envoyer les notifications MySQL KO : {notif_e}")
return
if alertes_envoyees:
logging.info("📧/💬 Notification(s) envoyée(s) : " + ", ".join(alertes_envoyees))
elif defauts_en_cours or erreurs_sql:
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 notification envoyée à ce run)")
else:
logging.info("👍 Tout est OK, aucune notification envoyée.")
if __name__ == "__main__":
main()