eptm_dashboard/src/notifier.py
Julien Balet f60cbf1b1c sync escada : gestion fine des pendings + détection orphelines
- importer.py : nouvelle logique pour les 4 cas d'absence × pending :
  * abs en PDF + pending modify : pending wins (sans force) / override (force)
  * abs en PDF + pas en DB + pending action=clear : respecte la suppression
    locale (sans force) / recrée l'abs (force)
  * orpheline (DB sans PDF) sans pending : supprimée + comptée + détaillée
  * orpheline avec pending : conservée (sans force) / supprimée (force)
- importer.py : query orpheline par classe + fenêtre de dates du PDF
  (couvre les abs locales avec import_id=None)
- run_imports.py : remonte orphelines + pending_skipped dans res_abs
- notifier.py : niveau detailed inclut absences supprimées par classe
  + détail des orphelines (max 5 par classe)
- escada.py : sépare cache disque (toutes classes pour matching Matu)
  vs liste UI (filtrée MP/MI/Formation)
- escada.py : timeout polling import passe de 60s à 15min
- escada.py : retire mode test push, fix bouton Actualiser bloqué sans
  classe sélectionnée
- cron.py : reset last_run_at à l'activation d'un job pour relance
  immédiate au prochain tick

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:24:43 +02:00

228 lines
8.1 KiB
Python

"""Notifier — envoi de notifications via bot Telegram.
Configuration globale (environment variables, lue depuis .env.prod via docker-compose) :
- TELEGRAM_BOT_TOKEN : token du bot (obtenu via @BotFather)
- TELEGRAM_CHAT_ID : chat id par défaut (obtenu via getUpdates ou @userinfobot)
Override par job possible via le champ CronJob.notify_chat_id.
"""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
_TG_API = "https://api.telegram.org"
def _bot_token() -> str:
return os.getenv("TELEGRAM_BOT_TOKEN", "").strip()
def _default_chat_id() -> str:
return os.getenv("TELEGRAM_CHAT_ID", "").strip()
def send_telegram(text: str, chat_id: str | None = None, *, parse_mode: str = "HTML") -> bool:
"""Envoie un message Telegram. Retourne True si succès, False sinon.
parse_mode="HTML" supporte <b>, <i>, <code>, <pre>.
Tronque automatiquement à 4096 caractères (limite Telegram).
"""
token = _bot_token()
chat = (chat_id or "").strip() or _default_chat_id()
if not token or not chat:
return False
text = text[:4090] + "\n" if len(text) > 4096 else text
url = f"{_TG_API}/bot{token}/sendMessage"
data = urllib.parse.urlencode({
"chat_id": chat,
"text": text,
"parse_mode": parse_mode,
"disable_web_page_preview": "true",
}).encode()
try:
req = urllib.request.Request(url, data=data, method="POST")
with urllib.request.urlopen(req, timeout=15) as r:
payload = json.loads(r.read())
return bool(payload.get("ok"))
except urllib.error.HTTPError as e:
try:
err_body = e.read().decode()
except Exception:
err_body = str(e)
print(f"[notifier] Telegram HTTPError {e.code}: {err_body[:200]}")
return False
except Exception as e:
print(f"[notifier] Telegram error: {e}")
return False
def notify_job_result(
job_name: str,
status: str,
message: str,
log_path: Path | str | None = None,
chat_id: str | None = None,
notify_on: str = "failure",
notify_level: str = "normal",
duration_s: float | None = None,
details: dict | None = None,
job_options: dict | None = None,
) -> bool:
"""Envoie une notif Telegram selon le statut et la politique notify_on.
Args:
job_name: nom du cron job
status: "ok" | "fail"
message: message court (1-2 lignes)
log_path: ignoré au niveau "normal" et "detailed" (l'utilisateur n'en veut pas)
chat_id: override du chat id (sinon TELEGRAM_CHAT_ID)
notify_on: "never" | "always" | "success" | "failure"
notify_level: "normal" (nom + statut + durée) ou "detailed" (+ détails import)
duration_s: durée d'exécution en secondes
details: dict avec clés optionnelles depuis sync_last_result.json :
- res_abs: list[dict avec classe, nouvelles, mises_a_jour, pending_skipped]
- res_bn: list[dict avec classe, nb]
- res_notes: list[dict avec classe, nb]
- res_matu: list[dict avec classe, nb]
- errors: list[str]
job_options: dict avec options du job pour savoir ce qui était sélectionné :
- sync_bn, sync_notes, sync_fiches, etc. (booléens)
"""
if notify_on == "never":
return False
if notify_on == "success" and status != "ok":
return False
if notify_on == "failure" and status == "ok":
return False
icon = "" if status == "ok" else ""
title = "Réussi" if status == "ok" else "Échec"
parts = [
f"{icon} <b>{_escape_html(job_name)}</b> — {title}",
]
if duration_s is not None:
parts.append(f"⏱ Durée : {_fmt_duration(duration_s)}")
# Niveau normal — message court uniquement
if notify_level != "detailed":
if message and status != "ok":
# En cas d'échec, on garde le message d'erreur même en normal
msg = message.strip()
if len(msg) > 500:
msg = msg[:500] + ""
parts.append(f"<pre>{_escape_html(msg)}</pre>")
return send_telegram("\n".join(parts), chat_id=chat_id)
# Niveau detailed — détails par classe et catégorie
job_options = job_options or {}
details = details or {}
# Erreurs en premier si présentes
errors = details.get("errors") or []
if errors:
parts.append("\n<b>⚠ Erreurs</b>")
for err in errors[:10]:
parts.append(f"{_escape_html(str(err)[:200])}")
if len(errors) > 10:
parts.append(f" … +{len(errors) - 10} autre(s)")
# Absences (toujours affichées si présentes)
res_abs = details.get("res_abs") or []
if res_abs:
parts.append("\n<b>📋 Absences</b>")
for r in res_abs:
classe = r.get("classe", "?")
nouv = int(r.get("nouvelles", 0) or 0)
maj = int(r.get("mises_a_jour", 0) or 0)
pend = int(r.get("pending_skipped", 0) or 0)
orph = int(r.get("orphelines", 0) or 0)
line = (
f" • <b>{_escape_html(classe)}</b> : "
f"{nouv} nouv. · {maj} modif. · {pend} pending"
)
if orph:
line += f" · <b>{orph} suppr.</b>"
parts.append(line)
# Liste des orphelines (cap à 5 par classe pour limiter la taille)
details_orph = r.get("details_orphelines") or []
if details_orph:
for d in details_orph[:5]:
parts.append(f"{_escape_html(str(d))}")
if len(details_orph) > 5:
parts.append(f" ↳ … +{len(details_orph) - 5} autre(s)")
# BN (seulement si sync_bn coché)
if job_options.get("sync_bn"):
res_bn = details.get("res_bn") or []
parts.append("\n<b>📊 Bulletins</b>")
if res_bn:
for r in res_bn:
parts.append(
f"{_escape_html(r.get('classe', '?'))} : {r.get('nb', '?')} apprenti(s)"
)
else:
parts.append(" <i>Aucun import</i>")
# Notes d'examen (seulement si sync_notes coché)
if job_options.get("sync_notes"):
res_notes = details.get("res_notes") or []
parts.append("\n<b>📝 Notes d'examen</b>")
if res_notes:
for r in res_notes:
parts.append(
f"{_escape_html(r.get('classe', '?'))} : {r.get('nb', '?')} apprenti(s)"
)
else:
parts.append(" <i>Aucun import</i>")
# Notes Matu (seulement si BN coché — Matu est lié aux apprentis BN)
if job_options.get("sync_bn"):
res_matu = details.get("res_matu") or []
if res_matu:
parts.append("\n<b>🎓 Matu</b>")
for r in res_matu[:8]: # cap à 8 pour pas exploser le message
parts.append(
f"{_escape_html(r.get('classe', '?'))} : {r.get('nb', '?')} apprenti(s)"
)
if len(res_matu) > 8:
parts.append(f" … +{len(res_matu) - 8} classe(s)")
return send_telegram("\n".join(parts), chat_id=chat_id)
def _fmt_duration(seconds: float) -> str:
s = int(seconds)
if s < 60:
return f"{s}s"
if s < 3600:
return f"{s // 60}min {s % 60}s"
return f"{s // 3600}h {(s % 3600) // 60}min"
def _escape_html(s: str) -> str:
return (
s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
def test_telegram() -> tuple[bool, str]:
"""Test rapide : envoie un message de ping. Retourne (ok, message)."""
if not _bot_token():
return False, "TELEGRAM_BOT_TOKEN non configuré dans l'environnement"
if not _default_chat_id():
return False, "TELEGRAM_CHAT_ID non configuré dans l'environnement"
ok = send_telegram("✓ <b>EPTM Dashboard</b>\nTest de notification — tout est OK.")
if ok:
return True, "Message envoyé"
return False, "Échec de l'envoi (voir logs container)"