- 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>
228 lines
8.1 KiB
Python
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("&", "&")
|
|
.replace("<", "<")
|
|
.replace(">", ">")
|
|
)
|
|
|
|
|
|
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)"
|