Ajout de chat a Monitor_Saclay
This commit is contained in:
@@ -1,34 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
# Surveillance des réceptions de données dans les tables (par site) + mail d'alerte / retour à la normale
|
||||
# -*- 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 logging
|
||||
import tempfile
|
||||
|
||||
import mysql.connector
|
||||
from contextlib import closing
|
||||
import mysql.connector # important pour cibler les exceptions MySQL
|
||||
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 (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
|
||||
# ============================================================
|
||||
# PATHS / ENV
|
||||
# ============================================================
|
||||
|
||||
ENV_PATH = os.path.join(ROOT_DIR, ".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)
|
||||
|
||||
LOG_DIR = os.path.join(ROOT_DIR, "Logs")
|
||||
os.makedirs(LOG_DIR, 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)
|
||||
|
||||
log_filename = os.path.join(LOG_DIR, datetime.now().strftime("surveillance_%Y-%m-%d.log"))
|
||||
load_dotenv(ENV_PATH, override=True)
|
||||
|
||||
|
||||
# -------------------- LOGGING (UTF-8 + fichier + console) --------------------
|
||||
# ============================================================
|
||||
# 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",
|
||||
@@ -36,191 +76,329 @@ logging.basicConfig(
|
||||
logging.FileHandler(log_filename, encoding="utf-8"),
|
||||
logging.StreamHandler(sys.stdout),
|
||||
],
|
||||
force=True, # Python 3.8+
|
||||
force=True,
|
||||
)
|
||||
|
||||
# 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):
|
||||
for handler in logging.getLogger().handlers:
|
||||
if isinstance(handler, logging.StreamHandler):
|
||||
try:
|
||||
h.stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
handler.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)
|
||||
# ============================================================
|
||||
# ETAT DES ALERTES
|
||||
# ============================================================
|
||||
|
||||
def state_file(site: str) -> Path:
|
||||
return STATE_DIR / f"{site}.json"
|
||||
|
||||
|
||||
# -------------------- PARAMETRES --------------------
|
||||
# Mets ici les tables réelles à surveiller (noms exacts des tables MySQL)
|
||||
tables = ["Saclay", "Meudon"]
|
||||
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,
|
||||
}
|
||||
|
||||
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
|
||||
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 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()
|
||||
def write_state(site: str, state: dict) -> None:
|
||||
sf = state_file(site)
|
||||
try:
|
||||
with open(sf, "w", encoding="utf-8") as f:
|
||||
f.write(when.isoformat())
|
||||
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} (l'anti-spam sera moins efficace).")
|
||||
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:
|
||||
"""
|
||||
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).
|
||||
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
|
||||
"""
|
||||
last_alert = _read_last_alert(site)
|
||||
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)
|
||||
|
||||
|
||||
def clear_state(site: str) -> None:
|
||||
sf = _state_file(site)
|
||||
# ============================================================
|
||||
# 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:
|
||||
if os.path.exists(sf):
|
||||
os.remove(sf)
|
||||
except OSError as e:
|
||||
logging.warning(f"Impossible de supprimer l'état {sf} : {e}")
|
||||
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.")
|
||||
|
||||
|
||||
# -------------------- MAIN --------------------
|
||||
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)
|
||||
|
||||
# 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
|
||||
defauts_en_cours: list[str] = []
|
||||
alertes_envoyees: list[str] = []
|
||||
erreurs_sql: list[str] = []
|
||||
|
||||
# 1) Connexion MySQL (une seule fois)
|
||||
try:
|
||||
with closing(utils_db.connect_to_mysql()) as cnx, closing(cnx.cursor()) as cursor:
|
||||
cnx = cast(MySQLConnection, utils_db.connect_to_mysql())
|
||||
|
||||
# 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})")
|
||||
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_mail(
|
||||
"⚠️ ALERTE : Base MySQL inaccessible (surveillance impossible).",
|
||||
envoyer_notifications(
|
||||
"⚠️ ALERTE : Base MySQL inaccessible",
|
||||
"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}")
|
||||
except Exception as notif_e:
|
||||
logging.error(f"Impossible d'envoyer les notifications MySQL KO : {notif_e}")
|
||||
return
|
||||
|
||||
# 3) Logs de synthèse (fidèles à la réalité)
|
||||
if alertes_envoyees:
|
||||
logging.info("📧 Mail(s) envoyé(s) : " + ", ".join(alertes_envoyees))
|
||||
logging.info("📧/💬 Notification(s) envoyée(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))
|
||||
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)")
|
||||
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, aucun Mail envoyé.")
|
||||
logging.info("👍 Tout est OK, aucune notification envoyée.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user