added cron and notification telegram

This commit is contained in:
Julien Balet 2026-05-10 12:11:42 +02:00
parent 23e0b2bf60
commit 4d3e49ff64
13 changed files with 1884 additions and 10 deletions

View file

@ -1 +1,3 @@
REFLEX_SECRET_KEY=af16a3c0a6f2a94583ebd704f4e9716743abe27c10e8837633274d08441c20c2
TELEGRAM_BOT_TOKEN=8659950969:AAEpc3sl34txpsHyYC5-3rnfgVnkEuQoU_Q
TELEGRAM_CHAT_ID=-4992234358

View file

@ -1,5 +1,6 @@
{
"EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=38bbf90d-51da-406e-a2af-4d5f8f5958bd",
"AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=687fa97d-1032-4078-94ae-1899fc1e6014",
"AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=8cb48a35-290c-4488-b98c-437d2c9186a6"
"AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=8cb48a35-290c-4488-b98c-437d2c9186a6",
"EM-AU 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=da3e0b68-5559-4c0c-a8be-f764c68dbca9",
"EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=ef32322a-8bd9-45d4-9583-c7c22cbc577d"
}

View file

@ -6,6 +6,7 @@ from .pages.fiche import fiche_page, FicheState
from .pages.classe import classe_page, ClasseState
from .pages.escada import escada_page, EscadaState
from .pages.logs import logs_page, LogsState
from .pages.cron import cron_page, CronState
from .pages.users import users_page, UsersState
from .pages.params import params_page, ParamsState
@ -30,5 +31,6 @@ app.add_page(fiche_page, route="/fiche", on_load=[AuthState.check_auth,
app.add_page(classe_page, route="/classe", on_load=[AuthState.check_auth, ClasseState.load_data], title=TITLE)
app.add_page(escada_page, route="/escada", on_load=[AuthState.check_auth, EscadaState.load_data], title=TITLE)
app.add_page(logs_page, route="/logs", on_load=[AuthState.check_auth, LogsState.load_data], title=TITLE)
app.add_page(cron_page, route="/cron", on_load=[AuthState.check_auth, CronState.load_data], title=TITLE)
app.add_page(users_page, route="/users", on_load=[AuthState.check_auth, UsersState.load_data], title=TITLE)
app.add_page(params_page, route="/params", on_load=[AuthState.check_auth, ParamsState.load_data], title=TITLE)

View file

@ -0,0 +1,885 @@
"""Page /cron — gestion des tâches planifiées Escada (push / sync auto)."""
from __future__ import annotations
import json
import os
import sys
from datetime import datetime, timedelta
from pathlib import Path
import reflex as rx
from sqlalchemy import select
# Pour les imports src/* qui n'ont pas systématiquement le path setup
_ROOT = Path(__file__).resolve().parent.parent.parent
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
from src.db import CronJob, get_session, init_db, Apprenti # noqa: E402
from src.notifier import test_telegram # noqa: E402
from ..state import AuthState
from ..sidebar import layout
_DAY_NAMES = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"]
_DAY_LABELS = {
"MON": "Lun", "TUE": "Mar", "WED": "Mer", "THU": "Jeu",
"FRI": "Ven", "SAT": "Sam", "SUN": "Dim",
}
# ── State ─────────────────────────────────────────────────────────────────────
class CronState(AuthState):
jobs: list[dict] = []
classes_avail: list[str] = []
# Édition
editing_id: int = 0 # 0 = nouveau, sinon id du job en édition
edit_open: bool = False
f_name: str = ""
f_enabled: bool = True
f_schedule_kind: str = "daily" # "daily" | "weekly" | "interval"
f_time_hh: str = "03"
f_time_mm: str = "00"
f_interval_min: str = "60"
f_days: list[str] = [] # ["MON","WED",...]
f_task_kind: str = "push_then_sync"
f_sync_abs: bool = True
f_sync_bn: bool = True
f_sync_notes: bool = True
f_sync_fiches: bool = False
f_force_abs: bool = False
f_classes_all: bool = True
f_classes: list[str] = []
f_notify_on: str = "failure"
f_notify_level: str = "normal"
f_notify_chat_id: str = ""
save_error: str = ""
save_ok: bool = False
tg_test_msg: str = ""
tg_test_ok: bool = False
# ── Helpers ───────────────────────────────────────────────────────────────
def _ensure_admin(self):
return self.authenticated and self.role == "admin"
def _job_to_dict(self, job: CronJob) -> dict:
# Calcul d'une description lisible
desc = self._human_schedule(job.schedule_kind, job.schedule_value)
next_run = self._next_run_estimate(job)
return {
"id": job.id,
"name": job.name,
"enabled": job.enabled,
"schedule_desc": desc,
"task_kind": job.task_kind,
"task_label": {"push": "Push", "sync": "Sync",
"push_then_sync": "Push + Sync"}.get(job.task_kind, job.task_kind),
"last_run_at": job.last_run_at.strftime("%d.%m.%Y %H:%M") if job.last_run_at else "",
"last_status": job.last_status,
"last_message": job.last_message[:120] if job.last_message else "",
"last_log_path": job.last_log_path or "",
"next_run": next_run,
"notify_on": job.notify_on,
}
@staticmethod
def _human_schedule(kind: str, value: str) -> str:
if kind == "daily":
return f"Tous les jours à {value}"
if kind == "weekly":
try:
days_part, time_part = value.split(":", 1)
labels = ", ".join(_DAY_LABELS.get(d.strip(), d.strip())
for d in days_part.split(","))
return f"{labels} à {time_part}"
except ValueError:
return value
if kind == "interval":
try:
m = int(value)
if m % 60 == 0:
return f"Toutes les {m // 60} h"
return f"Toutes les {m} min"
except (TypeError, ValueError):
return value
return value
@staticmethod
def _next_run_estimate(job: CronJob) -> str:
"""Estime grossièrement la prochaine exécution (lisible)."""
if not job.enabled:
return ""
now = datetime.now()
if job.schedule_kind == "interval":
try:
m = int(job.schedule_value)
except (TypeError, ValueError):
return ""
if job.last_run_at is None:
return "Au prochain tick"
nxt = job.last_run_at + timedelta(minutes=m)
return nxt.strftime("%d.%m %H:%M")
if job.schedule_kind == "daily":
try:
hh, mm = job.schedule_value.split(":")
target = now.replace(hour=int(hh), minute=int(mm), second=0, microsecond=0)
if (job.last_run_at and job.last_run_at.date() == now.date()
and job.last_run_at >= target):
target += timedelta(days=1)
elif target < now:
target += timedelta(days=1)
return target.strftime("%d.%m %H:%M")
except (ValueError, AttributeError):
return ""
if job.schedule_kind == "weekly":
return "Selon planning"
return ""
# ── Load / refresh ────────────────────────────────────────────────────────
def load_data(self):
if not self.authenticated:
return rx.redirect("/login")
if self.role != "admin":
return rx.redirect("/accueil")
# Garantit que la table existe
try:
init_db()
except Exception:
pass
self._refresh()
def _refresh(self):
sess = get_session()
try:
jobs = sess.execute(select(CronJob).order_by(CronJob.id)).scalars().all()
self.jobs = [self._job_to_dict(j) for j in jobs]
# Liste des classes disponibles (réutilisé par le widget)
classes = sess.execute(
select(Apprenti.classe).distinct().order_by(Apprenti.classe)
).scalars().all()
self.classes_avail = [c for c in classes if c and not c.startswith(("MP", "MI"))]
finally:
sess.close()
# ── Form actions ──────────────────────────────────────────────────────────
def open_new(self):
self.editing_id = 0
self.edit_open = True
self.f_name = ""
self.f_enabled = True
self.f_schedule_kind = "daily"
self.f_time_hh = "03"
self.f_time_mm = "00"
self.f_interval_min = "60"
self.f_days = ["MON", "TUE", "WED", "THU", "FRI"]
self.f_task_kind = "push_then_sync"
self.f_sync_abs = True
self.f_sync_bn = True
self.f_sync_notes = True
self.f_sync_fiches = False
self.f_force_abs = False
self.f_classes_all = True
self.f_classes = []
self.f_notify_on = "failure"
self.f_notify_level = "normal"
self.f_notify_chat_id = ""
self.save_error = ""
self.save_ok = False
def open_edit(self, job_id: int):
sess = get_session()
try:
job = sess.get(CronJob, job_id)
if not job:
return
self.editing_id = job.id
self.edit_open = True
self.f_name = job.name
self.f_enabled = job.enabled
self.f_schedule_kind = job.schedule_kind
if job.schedule_kind == "daily":
hh, _, mm = (job.schedule_value or "03:00").partition(":")
self.f_time_hh = hh.zfill(2)
self.f_time_mm = mm.zfill(2)
self.f_interval_min = "60"
self.f_days = ["MON", "TUE", "WED", "THU", "FRI"]
elif job.schedule_kind == "weekly":
try:
days_part, time_part = job.schedule_value.split(":", 1)
hh, _, mm = time_part.partition(":")
self.f_time_hh = hh.zfill(2)
self.f_time_mm = mm.zfill(2)
self.f_days = [d.strip() for d in days_part.split(",") if d.strip()]
except ValueError:
self.f_time_hh = "03"
self.f_time_mm = "00"
self.f_days = ["MON", "TUE", "WED", "THU", "FRI"]
self.f_interval_min = "60"
else: # interval
self.f_interval_min = job.schedule_value or "60"
self.f_time_hh = "03"
self.f_time_mm = "00"
self.f_days = ["MON", "TUE", "WED", "THU", "FRI"]
self.f_task_kind = job.task_kind
self.f_sync_abs = job.sync_abs
self.f_sync_bn = job.sync_bn
self.f_sync_notes = job.sync_notes
self.f_sync_fiches = job.sync_fiches
self.f_force_abs = job.force_abs
classes_raw = (job.classes_json or "ALL").strip()
if classes_raw == "ALL":
self.f_classes_all = True
self.f_classes = []
else:
try:
lst = json.loads(classes_raw)
self.f_classes_all = False
self.f_classes = [str(c) for c in (lst if isinstance(lst, list) else [])]
except Exception:
self.f_classes_all = True
self.f_classes = []
self.f_notify_on = job.notify_on
self.f_notify_level = getattr(job, "notify_level", "normal") or "normal"
self.f_notify_chat_id = job.notify_chat_id or ""
self.save_error = ""
self.save_ok = False
finally:
sess.close()
def close_edit(self):
self.edit_open = False
self.save_error = ""
self.save_ok = False
def set_f_name(self, v: str): self.f_name = v
def set_f_enabled(self, v: bool): self.f_enabled = v
def set_f_schedule_kind(self, v: str): self.f_schedule_kind = v
def set_f_time_hh(self, v: str):
v = "".join(ch for ch in v if ch.isdigit())[:2]
self.f_time_hh = v
def set_f_time_mm(self, v: str):
v = "".join(ch for ch in v if ch.isdigit())[:2]
self.f_time_mm = v
def set_f_interval_min(self, v: str):
v = "".join(ch for ch in v if ch.isdigit())[:5]
self.f_interval_min = v
def toggle_f_day(self, day: str):
if day in self.f_days:
self.f_days = [d for d in self.f_days if d != day]
else:
self.f_days = self.f_days + [day]
def set_f_task_kind(self, v: str): self.f_task_kind = v
def set_f_sync_abs(self, v: bool): self.f_sync_abs = v
def set_f_sync_bn(self, v: bool): self.f_sync_bn = v
def set_f_sync_notes(self, v: bool): self.f_sync_notes = v
def set_f_sync_fiches(self, v: bool): self.f_sync_fiches = v
def set_f_force_abs(self, v: bool): self.f_force_abs = v
def set_f_classes_all(self, v: bool): self.f_classes_all = v
def toggle_f_class(self, c: str):
if c in self.f_classes:
self.f_classes = [x for x in self.f_classes if x != c]
else:
self.f_classes = self.f_classes + [c]
def set_f_notify_on(self, v: str): self.f_notify_on = v
def set_f_notify_level(self, v: str): self.f_notify_level = v
def set_f_notify_chat_id(self, v: str): self.f_notify_chat_id = v.strip()
def save_job(self):
self.save_error = ""
self.save_ok = False
if not self.f_name.strip():
self.save_error = "Le nom est obligatoire."
return
# Construire schedule_value selon kind
if self.f_schedule_kind == "daily":
try:
hh = int(self.f_time_hh or "0"); mm = int(self.f_time_mm or "0")
if not (0 <= hh < 24 and 0 <= mm < 60):
raise ValueError
except ValueError:
self.save_error = "Heure invalide."
return
schedule_value = f"{hh:02d}:{mm:02d}"
elif self.f_schedule_kind == "weekly":
if not self.f_days:
self.save_error = "Sélectionne au moins un jour de la semaine."
return
try:
hh = int(self.f_time_hh or "0"); mm = int(self.f_time_mm or "0")
if not (0 <= hh < 24 and 0 <= mm < 60):
raise ValueError
except ValueError:
self.save_error = "Heure invalide."
return
ordered = [d for d in _DAY_NAMES if d in self.f_days]
schedule_value = f"{','.join(ordered)}:{hh:02d}:{mm:02d}"
else: # interval
try:
m = int(self.f_interval_min or "0")
if m < 1:
raise ValueError
except ValueError:
self.save_error = "Intervalle invalide (minutes > 0)."
return
schedule_value = str(m)
if self.f_classes_all:
classes_json = "ALL"
else:
if not self.f_classes:
self.save_error = "Sélectionne au moins une classe (ou coche \"Toutes les classes\")."
return
classes_json = json.dumps(self.f_classes)
sess = get_session()
try:
now = datetime.now()
if self.editing_id == 0:
job = CronJob(
name=self.f_name.strip(),
enabled=self.f_enabled,
schedule_kind=self.f_schedule_kind,
schedule_value=schedule_value,
task_kind=self.f_task_kind,
sync_abs=self.f_sync_abs,
sync_bn=self.f_sync_bn,
sync_notes=self.f_sync_notes,
sync_fiches=self.f_sync_fiches,
force_abs=self.f_force_abs,
classes_json=classes_json,
notify_on=self.f_notify_on,
notify_level=self.f_notify_level,
notify_chat_id=self.f_notify_chat_id,
created_at=now,
updated_at=now,
)
sess.add(job)
else:
job = sess.get(CronJob, self.editing_id)
if not job:
self.save_error = "Job introuvable."
return
job.name = self.f_name.strip()
job.enabled = self.f_enabled
job.schedule_kind = self.f_schedule_kind
job.schedule_value = schedule_value
job.task_kind = self.f_task_kind
job.sync_abs = self.f_sync_abs
job.sync_bn = self.f_sync_bn
job.sync_notes = self.f_sync_notes
job.sync_fiches = self.f_sync_fiches
job.force_abs = self.f_force_abs
job.classes_json = classes_json
job.notify_on = self.f_notify_on
job.notify_level = self.f_notify_level
job.notify_chat_id = self.f_notify_chat_id
job.updated_at = now
sess.commit()
self.save_ok = True
self._refresh()
self.edit_open = False
except Exception as e:
sess.rollback()
self.save_error = f"Erreur DB : {e}"
finally:
sess.close()
def toggle_enabled(self, job_id: int):
sess = get_session()
try:
job = sess.get(CronJob, job_id)
if job:
job.enabled = not job.enabled
job.updated_at = datetime.now()
sess.commit()
self._refresh()
finally:
sess.close()
def delete_job(self, job_id: int):
sess = get_session()
try:
job = sess.get(CronJob, job_id)
if job:
sess.delete(job)
sess.commit()
self._refresh()
finally:
sess.close()
def test_telegram(self):
ok, msg = test_telegram()
self.tg_test_ok = ok
self.tg_test_msg = msg
# ── UI components ─────────────────────────────────────────────────────────────
def _badge_status(status: rx.Var) -> rx.Component:
return rx.match(
status,
("ok", rx.badge("OK", color_scheme="green", variant="soft", size="1")),
("fail", rx.badge("Échec", color_scheme="red", variant="soft", size="1")),
("running", rx.badge("Running", color_scheme="orange", variant="soft", size="1")),
rx.badge("", color_scheme="gray", variant="soft", size="1"),
)
def _job_row(job: rx.Var) -> rx.Component:
return rx.box(
rx.grid(
# Nom + statut
rx.vstack(
rx.hstack(
rx.text(job["name"], weight="bold", size="2"),
rx.cond(
job["enabled"],
rx.badge("Actif", color_scheme="green", variant="soft", size="1"),
rx.badge("Désactivé", color_scheme="gray", variant="soft", size="1"),
),
spacing="2", align="center", wrap="wrap",
),
rx.text(job["schedule_desc"], size="1", color="var(--gray-10)"),
spacing="0", align="start",
),
# Tâche
rx.text(job["task_label"], size="2"),
# Dernière exécution
rx.vstack(
rx.text(
rx.cond(job["last_run_at"] != "", job["last_run_at"], ""),
size="1",
),
_badge_status(job["last_status"]),
spacing="1", align="start",
),
# Prochaine exécution
rx.text(job["next_run"], size="2", color="var(--gray-10)"),
# Actions
rx.hstack(
rx.button(
rx.icon(rx.cond(job["enabled"], "pause", "play"), size=14),
on_click=CronState.toggle_enabled(job["id"]),
variant="ghost", size="1", color_scheme="gray",
),
rx.button(
rx.icon("pencil", size=14),
on_click=CronState.open_edit(job["id"]),
variant="ghost", size="1", color_scheme="gray",
),
rx.alert_dialog.root(
rx.alert_dialog.trigger(
rx.button(rx.icon("trash-2", size=14),
variant="ghost", size="1", color_scheme="red"),
),
rx.alert_dialog.content(
rx.alert_dialog.title("Supprimer ce job ?"),
rx.alert_dialog.description(
"Le job sera supprimé. Les fichiers de log conservés."
),
rx.flex(
rx.alert_dialog.cancel(rx.button("Annuler", variant="soft")),
rx.alert_dialog.action(
rx.button("Supprimer", color_scheme="red",
on_click=CronState.delete_job(job["id"])),
),
gap="2", justify="end", margin_top="1rem",
),
),
),
spacing="1", align="center",
),
columns="2.5fr 1fr 1.3fr 1.2fr auto",
gap="0.75rem",
align="center",
width="100%",
),
rx.cond(
job["last_message"] != "",
rx.text(
"", job["last_message"],
size="1", color="var(--gray-10)", margin_top="0.25rem",
),
),
padding="0.75rem 1rem",
background_color="white",
border="1px solid var(--gray-5)",
border_radius="6px",
width="100%",
)
def _form_schedule_picker() -> rx.Component:
return rx.vstack(
rx.text("Planification", size="2", font_weight="600"),
rx.radio(
["daily", "weekly", "interval"],
value=CronState.f_schedule_kind,
on_change=CronState.set_f_schedule_kind,
direction="row",
),
rx.cond(
CronState.f_schedule_kind == "interval",
rx.hstack(
rx.text("Toutes les", size="2"),
rx.input(
value=CronState.f_interval_min,
on_change=CronState.set_f_interval_min,
width="80px",
),
rx.text("minutes", size="2"),
spacing="2", align="center",
),
rx.cond(
CronState.f_schedule_kind == "weekly",
rx.vstack(
rx.flex(
*[
rx.box(
rx.text(_DAY_LABELS[d], size="1", weight="bold"),
on_click=CronState.toggle_f_day(d),
cursor="pointer",
padding="0.35rem 0.7rem",
border_radius="6px",
border="2px solid",
border_color=rx.cond(
CronState.f_days.contains(d),
"var(--red-9)", "var(--gray-6)",
),
background_color=rx.cond(
CronState.f_days.contains(d),
"var(--red-9)", "transparent",
),
color=rx.cond(
CronState.f_days.contains(d),
"white", "var(--gray-12)",
),
)
for d in _DAY_NAMES
],
gap="0.3rem",
wrap="wrap",
),
rx.hstack(
rx.text("Heure :", size="2"),
rx.input(value=CronState.f_time_hh,
on_change=CronState.set_f_time_hh, width="60px"),
rx.text(":", size="3"),
rx.input(value=CronState.f_time_mm,
on_change=CronState.set_f_time_mm, width="60px"),
spacing="2", align="center",
),
spacing="2",
),
# daily
rx.hstack(
rx.text("Heure :", size="2"),
rx.input(value=CronState.f_time_hh,
on_change=CronState.set_f_time_hh, width="60px"),
rx.text(":", size="3"),
rx.input(value=CronState.f_time_mm,
on_change=CronState.set_f_time_mm, width="60px"),
spacing="2", align="center",
),
),
),
spacing="2", width="100%",
)
def _form_task_picker() -> rx.Component:
return rx.vstack(
rx.text("Tâche", size="2", font_weight="600"),
rx.radio(
["push", "sync", "push_then_sync"],
value=CronState.f_task_kind,
on_change=CronState.set_f_task_kind,
direction="column",
),
rx.cond(
CronState.f_task_kind != "push",
rx.vstack(
rx.text("Données à synchroniser", size="2", font_weight="600",
margin_top="0.5rem"),
rx.flex(
rx.hstack(
rx.checkbox(checked=CronState.f_sync_abs,
on_change=CronState.set_f_sync_abs, size="2"),
rx.text("Absences", size="2"),
spacing="2", align="center",
),
rx.hstack(
rx.checkbox(checked=CronState.f_sync_bn,
on_change=CronState.set_f_sync_bn, size="2"),
rx.text("BN + Matu", size="2"),
spacing="2", align="center",
),
rx.hstack(
rx.checkbox(checked=CronState.f_sync_notes,
on_change=CronState.set_f_sync_notes, size="2"),
rx.text("Notes d'examen", size="2"),
spacing="2", align="center",
),
rx.hstack(
rx.checkbox(checked=CronState.f_sync_fiches,
on_change=CronState.set_f_sync_fiches, size="2"),
rx.text("Fiches apprentis", size="2"),
spacing="2", align="center",
),
gap="0.5rem 1.25rem",
flex_wrap="wrap",
),
rx.hstack(
rx.checkbox(checked=CronState.f_force_abs,
on_change=CronState.set_f_force_abs, size="2"),
rx.text("Forcer le retéléchargement des PDFs absences", size="2"),
spacing="2", align="center",
),
spacing="2",
),
),
spacing="2", width="100%",
)
def _form_classes_picker() -> rx.Component:
return rx.vstack(
rx.text("Classes", size="2", font_weight="600"),
rx.hstack(
rx.checkbox(checked=CronState.f_classes_all,
on_change=CronState.set_f_classes_all, size="2"),
rx.text("Toutes les classes", size="2"),
spacing="2", align="center",
),
rx.cond(
~CronState.f_classes_all,
rx.flex(
rx.foreach(
CronState.classes_avail,
lambda c: rx.box(
rx.text(c, size="1"),
on_click=CronState.toggle_f_class(c),
cursor="pointer",
padding="0.3rem 0.6rem",
border_radius="9999px",
border="1px solid",
border_color=rx.cond(
CronState.f_classes.contains(c),
"var(--red-9)", "var(--gray-6)",
),
background_color=rx.cond(
CronState.f_classes.contains(c),
"var(--red-9)", "transparent",
),
color=rx.cond(
CronState.f_classes.contains(c),
"white", "var(--gray-12)",
),
),
),
gap="0.3rem",
wrap="wrap",
),
),
spacing="2", width="100%",
)
def _form_notify_picker() -> rx.Component:
return rx.vstack(
rx.text("Notifications Telegram", size="2", font_weight="600"),
rx.text("Quand notifier", size="1", color="var(--gray-10)"),
rx.radio(
["never", "failure", "success", "always"],
value=CronState.f_notify_on,
on_change=CronState.set_f_notify_on,
direction="row",
),
rx.text("Niveau de détail", size="1", color="var(--gray-10)", margin_top="0.25rem"),
rx.radio(
["normal", "detailed"],
value=CronState.f_notify_level,
on_change=CronState.set_f_notify_level,
direction="row",
),
rx.text(
rx.cond(
CronState.f_notify_level == "detailed",
"Détaillée : nom + statut + durée + classes importées + détail BN/notes/Matu + (nouvelles / modifiées / pending) absences",
"Normal : nom + statut + durée uniquement",
),
size="1",
color="var(--gray-9)",
),
rx.input(
placeholder="Chat ID Telegram (vide = défaut configuré côté serveur)",
value=CronState.f_notify_chat_id,
on_change=CronState.set_f_notify_chat_id,
width="100%",
),
spacing="2", width="100%",
)
def _edit_form() -> rx.Component:
return rx.box(
rx.vstack(
rx.hstack(
rx.text(
rx.cond(CronState.editing_id == 0,
"Nouveau job", "Modifier le job"),
weight="bold", size="3",
),
rx.spacer(),
rx.button(rx.icon("x", size=14),
on_click=CronState.close_edit,
variant="ghost", color_scheme="gray", size="1"),
width="100%", align="center",
),
rx.divider(),
rx.input(
placeholder="Nom (ex: Sync nocturne)",
value=CronState.f_name,
on_change=CronState.set_f_name,
width="100%",
),
rx.hstack(
rx.checkbox(checked=CronState.f_enabled,
on_change=CronState.set_f_enabled, size="2"),
rx.text("Activé", size="2"),
spacing="2", align="center",
),
rx.divider(),
_form_schedule_picker(),
rx.divider(),
_form_task_picker(),
rx.divider(),
_form_classes_picker(),
rx.divider(),
_form_notify_picker(),
rx.cond(
CronState.save_error != "",
rx.box(
rx.text(CronState.save_error, color="red", size="2"),
padding="0.5rem 1rem",
background_color="#fff5f5",
border="1px solid #ffcccc",
border_radius="6px",
width="100%",
),
),
rx.hstack(
rx.button("Enregistrer", on_click=CronState.save_job,
color_scheme="indigo", size="2"),
rx.button("Annuler", on_click=CronState.close_edit,
variant="soft", color_scheme="gray", size="2"),
spacing="2",
),
spacing="3",
width="100%",
),
padding="1.25rem",
background_color="var(--blue-2)",
border_radius="8px",
border="1px solid var(--blue-6)",
width="100%",
)
def _telegram_test_box() -> rx.Component:
return rx.box(
rx.hstack(
rx.text("Notifications Telegram", weight="bold", size="2"),
rx.spacer(),
rx.button(
rx.icon("send", size=14),
"Envoyer un test",
on_click=CronState.test_telegram,
variant="outline", size="1", color_scheme="indigo",
),
width="100%", align="center",
),
rx.cond(
CronState.tg_test_msg != "",
rx.text(
CronState.tg_test_msg,
size="1",
color=rx.cond(CronState.tg_test_ok, "var(--green-11)", "var(--red-11)"),
margin_top="0.5rem",
),
),
padding="0.75rem 1rem",
background_color="var(--gray-2)",
border="1px solid var(--gray-5)",
border_radius="6px",
width="100%",
)
# ── Page ──────────────────────────────────────────────────────────────────────
def cron_page() -> rx.Component:
return layout(
rx.vstack(
rx.hstack(
rx.heading("Tâches planifiées", size="7"),
rx.spacer(),
rx.button(
rx.icon("plus", size=14), "Nouveau job",
on_click=CronState.open_new,
color_scheme="indigo", size="2",
),
width="100%", align="center",
),
rx.text(
"Planification automatique des opérations Escada (push, sync). "
"Le serveur lance ces jobs selon leur horaire (timezone Europe/Zurich).",
size="2", color="var(--gray-10)",
),
_telegram_test_box(),
rx.cond(
CronState.edit_open,
_edit_form(),
),
rx.cond(
CronState.jobs.length() == 0,
rx.box(
rx.text(
"Aucun job planifié. Clique sur \"Nouveau job\" pour en créer un.",
size="2", color="#666",
),
padding="1rem",
background_color="#e3f2fd",
border_radius="6px",
border="1px solid #90caf9",
width="100%",
),
rx.vstack(
rx.foreach(CronState.jobs, _job_row),
spacing="2",
width="100%",
),
),
spacing="4",
width="100%",
)
)

View file

@ -10,11 +10,15 @@ from ..sidebar import layout
_ROOT = Path(__file__).resolve().parent.parent.parent
DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data")))
_LOG_FILE = DATA_DIR / "logs" / "operations.log"
# Logs cron : bind-mount persistent /logs/cron (override via env si besoin)
_CRON_DIR = Path(os.getenv("CRON_LOG_DIR", "/logs/cron"))
# ── State ──────────────────────────────────────────────────────────────────────
class LogsState(AuthState):
# Source: "ops" | "cron:<filename>"
source: str = "ops"
log_level: str = "PROD"
log_content: str = ""
log_total: int = 0
@ -22,7 +26,30 @@ class LogsState(AuthState):
log_empty: bool = True
confirm_clear: bool = False
def _read_log(self):
# Liste des logs cron disponibles (filenames seulement)
cron_logs: list[dict] = []
def _refresh_cron_list(self):
if not _CRON_DIR.exists():
self.cron_logs = []
return
entries = []
for fp in sorted(_CRON_DIR.glob("job_*.log"), reverse=True):
try:
stat = fp.stat()
size_kb = f"{stat.st_size / 1024:.1f} Ko"
entries.append({
"name": fp.name,
"size": size_kb,
"mtime": stat.st_mtime,
"label": f"{fp.name} ({size_kb})",
"value": f"cron:{fp.name}",
})
except Exception:
continue
self.cron_logs = entries[:200] # cap à 200
def _read_ops_log(self):
if not _LOG_FILE.exists() or _LOG_FILE.stat().st_size == 0:
self.log_empty = True
self.log_content = ""
@ -44,18 +71,50 @@ class LogsState(AuthState):
self.log_content = raw
self.log_shown = self.log_total
def _read_cron_log(self, filename: str):
# Sanitize : forcer fichier dans _CRON_DIR
target = (_CRON_DIR / filename).resolve()
if not str(target).startswith(str(_CRON_DIR.resolve())):
self.log_empty = True
self.log_content = "Chemin invalide."
return
if not target.exists():
self.log_empty = True
self.log_content = ""
self.log_total = 0
self.log_shown = 0
return
raw = target.read_text(encoding="utf-8", errors="replace")
lines = raw.splitlines()
self.log_content = raw
self.log_total = len(lines)
self.log_shown = len(lines)
self.log_empty = len(lines) == 0
def _read_log(self):
if self.source.startswith("cron:"):
self._read_cron_log(self.source.split(":", 1)[1])
else:
self._read_ops_log()
def load_data(self):
if not self.authenticated:
return rx.redirect("/login")
self._refresh_cron_list()
self._read_log()
def refresh(self):
self._refresh_cron_list()
self._read_log()
def set_log_level(self, level: str):
self.log_level = level
self._read_log()
def select_source(self, value: str):
self.source = value
self._read_log()
def ask_clear(self):
self.confirm_clear = True
@ -71,9 +130,14 @@ class LogsState(AuthState):
self._read_log()
def download_logs(self):
if self.source.startswith("cron:"):
filename = self.source.split(":", 1)[1]
target = (_CRON_DIR / filename).resolve()
if not str(target).startswith(str(_CRON_DIR.resolve())) or not target.exists():
return
return rx.download(data=target.read_bytes(), filename=filename)
if _LOG_FILE.exists():
raw = _LOG_FILE.read_bytes()
return rx.download(data=raw, filename="operations.log")
return rx.download(data=_LOG_FILE.read_bytes(), filename="operations.log")
# ── UI ─────────────────────────────────────────────────────────────────────────
@ -169,6 +233,37 @@ def _log_display() -> rx.Component:
)
def _source_picker() -> rx.Component:
"""Dropdown : log opérations ou un fichier de log cron individuel."""
return rx.hstack(
rx.text("Source :", size="2", weight="medium", color="#555"),
rx.select.root(
rx.select.trigger(),
rx.select.content(
rx.select.group(
rx.select.label("Application"),
rx.select.item("Opérations (operations.log)", value="ops"),
),
rx.cond(
LogsState.cron_logs.length() > 0,
rx.select.group(
rx.select.label("Cron jobs"),
rx.foreach(
LogsState.cron_logs,
lambda l: rx.select.item(l["label"], value=l["value"]),
),
),
),
),
value=LogsState.source,
on_change=LogsState.select_source,
size="1",
),
align="center",
gap="0.375rem",
)
def logs_page() -> rx.Component:
return layout(
rx.vstack(
@ -176,6 +271,7 @@ def logs_page() -> rx.Component:
rx.flex(
rx.heading("Logs", size="6"),
rx.flex(
_source_picker(),
rx.hstack(
rx.text("Niveau :", size="2", weight="medium", color="#555"),
rx.select(
@ -204,7 +300,10 @@ def logs_page() -> rx.Component:
variant="soft",
disabled=LogsState.log_empty,
),
rx.cond(
LogsState.source == "ops",
_clear_zone(),
),
gap="0.5rem",
align="center",
flex_wrap="wrap",

View file

@ -23,6 +23,7 @@ _PAGES = [
_ADMIN_PAGES = [
("Escada", "/escada", "globe"),
("Cron", "/cron", "alarm-clock"),
("Logs", "/logs", "file-text"),
("Utilisateurs", "/users", "user-cog"),
("Parametres", "/params", "settings"),

View file

@ -0,0 +1,141 @@
=== Job #3 'Import absences toutes les 2h' — démarré 2026-05-10T09:29:02 ===
task_kind=sync classes=["EM-AU 1", "EM-AU 2"]
━━━ Sync Escada ━━━
$ /usr/local/bin/python3 /app/scripts/sync_esacada.py --sync-all EM-AU 1 EM-AU 2 --skip-fiches
[11:29:04] SESSION_EXPIRED
[11:29:05] [LOGIN] Identifiants configurés — connexion automatique en cours.
[11:29:05] [2FA] Secret TOTP configure - code saisi automatiquement quand demande.
[11:29:05] [LOGIN] url: https://edusso.apps.vs.ch/auth/realms/ictvs/protocol/openid-connect/auth?client_id=EscadaWeb&respons
[11:29:05] [LOGIN] Formulaire Keycloak détecté — saisie automatique des identifiants.
[11:29:06] [2FA] Tentative remplissage OTP sur: https://edusso.apps.vs.ch/auth/realms/ictvs/login-actions/authenticate?execution
[11:29:06] [2FA] JS fill result: filled
[11:29:06] [2FA] OTP saisi via JS — soumission du formulaire.
[11:29:06] [2FA] submit result: clicked
[11:29:08] LOGIN_OK
[11:29:08] TOTAL 2
[11:29:08] PROGRESS 1/2 EM-AU 1
[11:29:08] [LANG] Navigation vers DlgEinstellungen…
[11:29:09] [LANG] Valeur actuelle: 'français'
[11:29:09] [LANG] Déjà en français
[11:29:10] [lrn p=1] 'EM-AU 1'
[11:29:10] [lrn] lien trouvé : ViewLernende.aspx?id=fc04fbce-a012-42dd-9cad-67f8894ee59c
[11:29:12] [lrn] OK bouton BN présent
[11:29:12] [NOTES EM-AU 1] href direct: https://escadaweb.vs.ch/Lehrpersonen/Reports/RptEscada.aspx?id=f4d1a632-37b7-429
[11:29:16] OK NOTES EM-AU 1 [href size=139611]
[11:29:18] [BN] +1s attente…
[11:29:22] [BN] +5s attente…
[11:29:23] [BN] download main page capturé: Bulletin de note.pdf
[11:29:23] OK BN EM-AU 1 [main download size=92774]
[11:29:25] [scan page=1] recherche 'EM-AU 1'…
[11:29:25] [scan page=1] -> TROUVE
[11:29:26] [nav abs] url après nav: https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=da3e0b68-5559
[11:29:26] [abs nav] url: https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=da3e0b68-5559
[11:29:26] [abs] page url avant clic: https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=da3e0b68-5559
[11:29:26] [abs] GET direct: https://escadaweb.vs.ch/Lehrpersonen/Reports/RptEscada.aspx?id=ac3993d6-86c2-404
[11:29:28] OK EM-AU 1 [href size=74557]
[11:29:28] PROGRESS 2/2 EM-AU 2
[11:29:29] [lrn p=1] 'EM-AU 2'
[11:29:29] [lrn] lien trouvé : ViewLernende.aspx?id=a736d575-58b6-40e6-84f4-c5a6aa87cb9b
[11:29:31] [lrn] OK bouton BN présent
[11:29:31] [NOTES EM-AU 2] href direct: https://escadaweb.vs.ch/Lehrpersonen/Reports/RptEscada.aspx?id=cf3257f8-9c54-410
[11:29:33] OK NOTES EM-AU 2 [href size=117997]
[11:29:36] [BN] +1s attente…
[11:29:40] [BN] +5s attente…
[11:29:42] [BN] download main page capturé: Bulletin de note.pdf
[11:29:42] OK BN EM-AU 2 [main download size=125570]
[11:29:43] [scan page=1] recherche 'EM-AU 2'…
[11:29:43] [scan page=1] -> TROUVE
[11:29:44] [nav abs] url après nav: https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=ef32322a-8bd9
[11:29:44] [abs nav] url: https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=ef32322a-8bd9
[11:29:44] [abs] page url avant clic: https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=ef32322a-8bd9
[11:29:44] [abs] GET direct: https://escadaweb.vs.ch/Lehrpersonen/Reports/RptEscada.aspx?id=78c83132-4183-434
[11:29:46] OK EM-AU 2 [href size=72579]
[11:29:46] MATU classes cibles: ['MP1-TASV 1A', 'MP1-TASV 1B', 'MP1-TASV 1C', 'MP1-TASV 1D', 'MP1-TASV 1E', 'MP1-TASV 2A', 'MP1-TASV 2B', 'MP1-TASV 2C', 'MP1-TASV 2D', 'MP1-TASV 2E']
[11:29:46] MATU 1/10 MP1-TASV 1A
[11:29:47] [lrn p=1] 'MP1-TASV 1A'
[11:29:47] [lrn] lien trouvé : ViewLernende.aspx?id=00d5d0a5-a6ee-438f-84a8-b7be7feaf230
[11:29:49] [lrn] OK bouton BN présent
[11:29:49] [MATU MP1-TASV 1A] href direct: https://escadaweb.vs.ch/Lehrpersonen/Reports/RptEscada.aspx?id=6aafbfa6-0139-41e
[11:29:50] OK MATU MP1-TASV 1A [href size=55167]
[11:29:50] MATU 2/10 MP1-TASV 1B
[11:29:52] [lrn p=1] 'MP1-TASV 1B'
[11:29:52] [lrn] lien trouvé : ViewLernende.aspx?id=7f50128d-d967-4456-b674-0843bb443f87
[11:29:53] [lrn] OK bouton BN présent
[11:29:53] [MATU MP1-TASV 1B] href direct: https://escadaweb.vs.ch/Lehrpersonen/Reports/RptEscada.aspx?id=e69bcdcf-c117-440
[11:29:55] OK MATU MP1-TASV 1B [href size=53244]
[11:29:55] MATU 3/10 MP1-TASV 1C
[11:29:56] [lrn p=1] 'MP1-TASV 1C'
[11:29:56] [lrn] tentative B: clic sur 'MP1-TASV 1C' dans la grille
[11:29:56] [pagination] -> page 2
[11:29:57] [lrn p=2] 'MP1-TASV 1C'
[11:29:57] [lrn] lien trouvé : ViewLernende.aspx?id=b744b1dc-f208-4e53-8f7f-c04845ed1b8f
[11:29:58] [lrn] OK bouton BN présent
[11:29:58] [MATU MP1-TASV 1C] href direct: https://escadaweb.vs.ch/Lehrpersonen/Reports/RptEscada.aspx?id=757ed578-b54c-497
[11:30:00] OK MATU MP1-TASV 1C [href size=54365]
[11:30:00] MATU 4/10 MP1-TASV 1D
[11:30:02] [lrn p=1] 'MP1-TASV 1D'
[11:30:02] [lrn] lien trouvé : ViewLernende.aspx?id=3aaa7dbf-2719-483c-9cad-b4c0f7324468
[11:30:34] [lrn] OK bouton BN présent
[11:30:34] [MATU MP1-TASV 1D] href direct: https://escadaweb.vs.ch/Lehrpersonen/Reports/RptEscada.aspx?id=d6155f65-614c-4db
[11:30:35] OK MATU MP1-TASV 1D [href size=55279]
[11:30:35] MATU 5/10 MP1-TASV 1E
[11:30:37] [lrn p=1] 'MP1-TASV 1E'
[11:30:37] [lrn] tentative B: clic sur 'MP1-TASV 1E' dans la grille
[11:30:37] [pagination] -> page 2
[11:30:37] [lrn p=2] 'MP1-TASV 1E'
[11:30:37] [lrn] lien trouvé : ViewLernende.aspx?id=710080d2-d4d5-48a5-b00a-ccf946ce893d
[11:30:39] [lrn] OK bouton BN présent
[11:30:39] [MATU MP1-TASV 1E] href direct: https://escadaweb.vs.ch/Lehrpersonen/Reports/RptEscada.aspx?id=24e0cfe7-4e1d-4de
[11:30:41] OK MATU MP1-TASV 1E [href size=53597]
[11:30:41] MATU 6/10 MP1-TASV 2A
[11:30:42] [lrn p=1] 'MP1-TASV 2A'
[11:30:42] [lrn] tentative B: clic sur 'MP1-TASV 2A' dans la grille
[11:30:42] [pagination] -> page 2
[11:30:43] [lrn p=2] 'MP1-TASV 2A'
[11:30:43] [lrn] lien trouvé : ViewLernende.aspx?id=58830220-ee10-453a-a2ab-84c124faf31e
[11:30:44] [lrn] OK bouton BN présent
[11:30:44] [MATU MP1-TASV 2A] href direct: https://escadaweb.vs.ch/Lehrpersonen/Reports/RptEscada.aspx?id=98d089db-1004-499
[11:30:47] OK MATU MP1-TASV 2A [href size=54517]
[11:30:47] MATU 7/10 MP1-TASV 2B
[11:30:48] [lrn p=1] 'MP1-TASV 2B'
[11:30:48] [lrn] tentative B: clic sur 'MP1-TASV 2B' dans la grille
[11:30:48] [pagination] -> page 2
[11:30:49] [lrn p=2] 'MP1-TASV 2B'
[11:30:49] [lrn] lien trouvé : ViewLernende.aspx?id=28d96efc-5205-47b5-805e-c33f3342cad4
[11:30:50] [lrn] OK bouton BN présent
[11:30:50] [MATU MP1-TASV 2B] href direct: https://escadaweb.vs.ch/Lehrpersonen/Reports/RptEscada.aspx?id=19ebc4ff-0a4c-41f
[11:30:52] OK MATU MP1-TASV 2B [href size=55264]
[11:30:52] MATU 8/10 MP1-TASV 2C
[11:30:54] [lrn p=1] 'MP1-TASV 2C'
[11:30:54] [lrn] tentative B: clic sur 'MP1-TASV 2C' dans la grille
[11:30:54] [pagination] -> page 2
[11:30:54] [lrn p=2] 'MP1-TASV 2C'
[11:30:54] [lrn] lien trouvé : ViewLernende.aspx?id=2f587458-cf5f-45cb-8ae4-bbe6a6f6b396
[11:30:56] [lrn] OK bouton BN présent
[11:30:56] [MATU MP1-TASV 2C] href direct: https://escadaweb.vs.ch/Lehrpersonen/Reports/RptEscada.aspx?id=a33e3ccb-58d1-4e1
[11:30:58] OK MATU MP1-TASV 2C [href size=54478]
[11:30:58] MATU 9/10 MP1-TASV 2D
[11:30:59] [lrn p=1] 'MP1-TASV 2D'
[11:30:59] [lrn] tentative B: clic sur 'MP1-TASV 2D' dans la grille
[11:30:59] [pagination] -> page 2
[11:31:00] [lrn p=2] 'MP1-TASV 2D'
[11:31:00] [lrn] lien trouvé : ViewLernende.aspx?id=0321398e-5d8b-40ac-9459-8720d69452f6
[11:31:02] [lrn] OK bouton BN présent
[11:31:02] [MATU MP1-TASV 2D] href direct: https://escadaweb.vs.ch/Lehrpersonen/Reports/RptEscada.aspx?id=9e396013-68f4-43a
[11:31:04] OK MATU MP1-TASV 2D [href size=53305]
[11:31:04] MATU 10/10 MP1-TASV 2E
[11:31:05] [lrn p=1] 'MP1-TASV 2E'
[11:31:05] [lrn] tentative B: clic sur 'MP1-TASV 2E' dans la grille
[11:31:05] [pagination] -> page 2
[11:31:06] [lrn p=2] 'MP1-TASV 2E'
[11:31:06] [lrn] lien trouvé : ViewLernende.aspx?id=1ae0d5ac-6c30-4a19-bd2e-271bf1fb41e1
[11:31:07] [lrn] OK bouton BN présent
[11:31:07] [MATU MP1-TASV 2E] href direct: https://escadaweb.vs.ch/Lehrpersonen/Reports/RptEscada.aspx?id=3777eee2-7dcc-442
[11:31:09] OK MATU MP1-TASV 2E [href size=53863]
[11:31:09] sync_all_done.json ecrit par subprocess
[11:31:09] run_imports lance (pid=242)
[11:31:09] ALL_DONE {"abs": ["/app/data/pdfs/esacada_EM-AU_1.pdf", "/app/data/pdfs/esacada_EM-AU_2.pdf"], "bn": ["/app/data/pdfs/bn_EM-AU_1.pdf", "/app/data/pdfs/bn_EM-AU_2.pdf"], "matu": ["/app/data/pdfs/matu_MP1-TASV_1A.pdf", "/app/data/pdfs/matu_MP1-TASV_1B.pdf", "/app/data/pdfs/matu_MP1-TASV_1C.pdf", "/app/data/pdfs/matu_MP1-TASV_1D.pdf", "/app/data/pdfs/matu_MP1-TASV_1E.pdf", "/app/data/pdfs/matu_MP1-TASV_2A.pdf", "/app/data/pdfs/matu_MP1-TASV_2B.pdf", "/app/data/pdfs/matu_MP1-TASV_2C.pdf", "/app/data/pdfs/matu_MP1-TASV_2D.pdf", "/app/data/pdfs/matu_MP1-TASV_2E.pdf"], "notes": ["/app/data/pdfs/notes_EM-AU_1.pdf", "/app/data/pdfs/notes_EM-AU_2.pdf"], "fiches": {}, "errors": []}
[exit code = 0]

2
logs/cron_tick.log Normal file
View file

@ -0,0 +1,2 @@
[cron_tick] 2026-05-10T09:29:02 — 1 job(s) dûs
- #3 'Import absences toutes les 2h' kind=sync schedule=interval:120

464
scripts/cron_tick.py Executable file
View file

@ -0,0 +1,464 @@
#!/usr/bin/env python3
"""Cron tick — appelé toutes les minutes par cron OS via docker exec.
Logique :
1. Lire tous les CronJob.enabled = True
2. Pour chaque job, calculer s'il est dû maintenant (basé sur schedule + last_run_at)
3. Si :
- Si déjà running (PID alive) kill -9 (politique "kill")
- Lancer subprocess (push, sync, ou push_then_sync)
- Stream stdout+stderr dans un fichier log dédié
- Mettre à jour last_status / last_message / last_run_at / last_pid
4. Envoyer notification Telegram selon notify_on
Le timezone effectif est celui du container (Europe/Zurich attendu).
Le script est idempotent et safe : si déjà passé pour un job aujourd'hui,
ne le relance pas. Si trop tard (>5 min après le slot), saute (un cron raté
n'est pas rattrapé).
Usage :
python3 /app/scripts/cron_tick.py
python3 /app/scripts/cron_tick.py --dry-run # affiche ce qui serait lancé
python3 /app/scripts/cron_tick.py --job <id> # force un job précis
"""
from __future__ import annotations
import argparse
import json
import os
import signal
import subprocess
import sys
import time
from datetime import datetime, timedelta
from pathlib import Path
_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(_ROOT))
from src.db import CronJob, get_session, init_db, get_engine # noqa: E402
from src.notifier import notify_job_result # noqa: E402
# Logs cron : par défaut /logs/cron (bind mount persistent), override via env var.
LOG_DIR = Path(os.getenv("CRON_LOG_DIR", "/logs/cron"))
try:
LOG_DIR.mkdir(parents=True, exist_ok=True)
except Exception:
# Fallback si /logs n'existe pas (ex: exécution hors container)
LOG_DIR = _ROOT / "logs" / "cron"
LOG_DIR.mkdir(parents=True, exist_ok=True)
SCRIPT_SYNC = _ROOT / "scripts" / "sync_esacada.py"
SCRIPT_PUSH = _ROOT / "scripts" / "push_to_escada.py"
DATA_DIR = _ROOT / "data"
# Marqueur écrit par run_imports.py à la fin des imports en DB
RUN_IMPORTS_RESULT = DATA_DIR / "sync_last_result.json"
# Timeout d'attente de run_imports après que sync_esacada.py exit
RUN_IMPORTS_TIMEOUT_SEC = 15 * 60 # 15 min, large mais raisonnable
# Slot de tolérance : si le cron OS rate une minute (charge, restart),
# on accepte d'exécuter dans la fenêtre [HH:MM, HH:MM+5min].
_SLOT_TOLERANCE_MIN = 5
_DAY_NAMES = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"]
# ── Schedule logic ────────────────────────────────────────────────────────────
def _is_due(job: CronJob, now: datetime) -> bool:
"""Détermine si le job doit être lancé maintenant."""
if not job.enabled:
return False
last = job.last_run_at
if job.schedule_kind == "interval":
# schedule_value = nb minutes
try:
minutes = int(job.schedule_value)
except (TypeError, ValueError):
return False
if minutes < 1:
return False
if last is None:
return True
return (now - last).total_seconds() >= minutes * 60
if job.schedule_kind == "daily":
# schedule_value = "HH:MM"
return _due_time_of_day(job.schedule_value, last, now)
if job.schedule_kind == "weekly":
# schedule_value = "MON,WED,FRI:HH:MM"
try:
days_part, time_part = job.schedule_value.split(":", 1)
except ValueError:
return False
days = {d.strip().upper() for d in days_part.split(",") if d.strip()}
today_name = _DAY_NAMES[now.weekday()]
if today_name not in days:
return False
return _due_time_of_day(time_part, last, now)
return False
def _due_time_of_day(hhmm: str, last: datetime | None, now: datetime) -> bool:
"""True si l'heure actuelle est dans la fenêtre [HH:MM, HH:MM+tolerance]
et que le job n'a pas déjà tourné aujourd'hui."""
try:
hh, mm = hhmm.split(":")
target = now.replace(hour=int(hh), minute=int(mm), second=0, microsecond=0)
except (ValueError, AttributeError):
return False
delta = (now - target).total_seconds()
if delta < 0 or delta > _SLOT_TOLERANCE_MIN * 60:
return False
if last is not None and last.date() == now.date() and last >= target:
return False
return True
# ── Process management ───────────────────────────────────────────────────────
def _pid_alive(pid: int | None) -> bool:
if not pid:
return False
try:
os.kill(pid, 0)
return True
except (ProcessLookupError, PermissionError):
return False
except Exception:
return False
def _kill_pid(pid: int) -> None:
try:
os.killpg(os.getpgid(pid), signal.SIGKILL)
except Exception:
try:
os.kill(pid, signal.SIGKILL)
except Exception:
pass
# ── Build command lines ──────────────────────────────────────────────────────
def _classes_args(job: CronJob) -> list[str]:
"""Retourne la liste des classes à passer aux scripts. Vide = toutes."""
raw = (job.classes_json or "").strip()
if not raw or raw == "ALL":
return []
try:
lst = json.loads(raw)
if isinstance(lst, list):
return [str(c) for c in lst]
except Exception:
pass
return []
def _build_sync_cmd(job: CronJob) -> list[str]:
classes = _classes_args(job)
cmd = [sys.executable, str(SCRIPT_SYNC), "--sync-all", *classes]
if not job.sync_abs: cmd.append("--skip-abs")
if not job.sync_bn: cmd.append("--skip-bn")
if not job.sync_notes: cmd.append("--skip-notes")
if not job.sync_fiches: cmd.append("--skip-fiches")
if job.force_abs: cmd.append("--force-abs")
return cmd
def _build_push_cmd(job: CronJob) -> list[str]:
return [sys.executable, str(SCRIPT_PUSH)]
def _wait_for_run_imports(log_fp, mtime_before: float) -> tuple[bool, str, dict]:
"""Après que sync_esacada.py a fini, run_imports.py tourne en sous-process
détaché. Attend que sync_last_result.json soit mis à jour, puis log les
résultats détaillés. Retourne (ok, summary_message, raw_result_dict)."""
log_fp.write("\n━━━ Attente run_imports (subprocess détaché) ━━━\n")
log_fp.flush()
deadline = time.time() + RUN_IMPORTS_TIMEOUT_SEC
poll_count = 0
while time.time() < deadline:
if RUN_IMPORTS_RESULT.exists() and RUN_IMPORTS_RESULT.stat().st_mtime > mtime_before:
break
poll_count += 1
# Log un point tous les 30 polls (~1 min) pour montrer qu'on attend
if poll_count % 30 == 0:
elapsed = int(time.time() - (deadline - RUN_IMPORTS_TIMEOUT_SEC))
log_fp.write(f"[poll] {elapsed}s écoulés, en attente…\n")
log_fp.flush()
time.sleep(2)
else:
log_fp.write("⚠ TIMEOUT — sync_last_result.json non mis à jour dans le délai\n")
log_fp.flush()
return False, "run_imports timeout (>15min sans résultat)", {}
# Lire le résultat
try:
result = json.loads(RUN_IMPORTS_RESULT.read_text(encoding="utf-8"))
except Exception as e:
log_fp.write(f"⚠ Impossible de lire sync_last_result.json : {e}\n")
return False, f"sync_last_result.json illisible : {e}", {}
res_abs = result.get("res_abs", []) or []
res_bn = result.get("res_bn", []) or []
res_notes = result.get("res_notes", []) or []
res_matu = result.get("res_matu", []) or []
errors = result.get("errors", []) or []
ts = result.get("timestamp", "?")
log_fp.write(f"run_imports terminé (timestamp {ts})\n")
log_fp.write(f" Absences PDF importés : {len(res_abs)}\n")
log_fp.write(f" Bulletins de notes : {len(res_bn)}\n")
log_fp.write(f" Notes d'examen : {len(res_notes)}\n")
log_fp.write(f" Notes Matu : {len(res_matu)}\n")
log_fp.write(f" Erreurs : {len(errors)}\n")
# Détailler chaque catégorie si non vide
if res_abs:
log_fp.write("\n Détail absences :\n")
for r in res_abs:
log_fp.write(f" - {r}\n")
if res_bn:
log_fp.write("\n Détail BN :\n")
for r in res_bn:
log_fp.write(f" - {r}\n")
if res_notes:
log_fp.write("\n Détail notes d'examen :\n")
for r in res_notes:
log_fp.write(f" - {r}\n")
if res_matu:
log_fp.write("\n Détail Matu :\n")
for r in res_matu:
log_fp.write(f" - {r}\n")
if errors:
log_fp.write("\n ❌ ERREURS :\n")
for err in errors:
log_fp.write(f" - {err}\n")
log_fp.flush()
if errors:
summary = (
f"Imports : abs={len(res_abs)}, bn={len(res_bn)}, "
f"notes={len(res_notes)}, matu={len(res_matu)}, "
f"{len(errors)} erreur(s)"
)
return False, summary, result
summary = (
f"Imports OK : abs={len(res_abs)}, bn={len(res_bn)}, "
f"notes={len(res_notes)}, matu={len(res_matu)}"
)
return True, summary, result
# ── Run a single step ────────────────────────────────────────────────────────
def _run_step(cmd: list[str], log_fp, title: str) -> tuple[int, int]:
"""Lance une commande, stream stdout+stderr dans log_fp.
Retourne (returncode, pid)."""
log_fp.write(f"\n━━━ {title} ━━━\n")
log_fp.write(f"$ {' '.join(cmd)}\n")
log_fp.flush()
proc = subprocess.Popen(
cmd,
stdout=log_fp,
stderr=subprocess.STDOUT,
env={**os.environ, "PYTHONUNBUFFERED": "1"},
start_new_session=True,
)
pid = proc.pid
rc = proc.wait()
log_fp.write(f"\n[exit code = {rc}]\n")
log_fp.flush()
return rc, pid
# ── Run a job (full lifecycle) ───────────────────────────────────────────────
def run_job(job: CronJob, sess) -> None:
"""Exécute un job. Met à jour son état en DB et envoie notification."""
started = datetime.now()
ts = started.strftime("%Y%m%d-%H%M%S")
log_path = LOG_DIR / f"job_{job.id}_{ts}.log"
# Politique "kill" : si déjà running (selon DB) et PID alive, on kill avant.
if job.last_status == "running" and _pid_alive(job.last_pid):
_kill_pid(job.last_pid or 0)
# Trace dans le log
with log_path.open("w", encoding="utf-8") as fp:
fp.write(f"[{started}] PID précédent {job.last_pid} kill -9 (politique kill)\n")
# Marquer running
job.last_run_at = started
job.last_status = "running"
job.last_message = ""
job.last_log_path = str(log_path)
job.last_pid = None
sess.commit()
overall_rc = 0
final_msg = ""
last_pid: int | None = None
imports_result: dict = {}
try:
with log_path.open("a", encoding="utf-8") as fp:
fp.write(f"\n=== Job #{job.id} '{job.name}' — démarré {started.isoformat(timespec='seconds')} ===\n")
fp.write(f"task_kind={job.task_kind} classes={job.classes_json}\n")
steps: list[tuple[str, list[str]]] = []
if job.task_kind == "push":
steps = [("Push Escada", _build_push_cmd(job))]
elif job.task_kind == "sync":
steps = [("Sync Escada", _build_sync_cmd(job))]
elif job.task_kind == "push_then_sync":
steps = [
("Push Escada", _build_push_cmd(job)),
("Sync Escada", _build_sync_cmd(job)),
]
else:
fp.write(f"[error] task_kind inconnu : {job.task_kind}\n")
overall_rc = 99
final_msg = f"task_kind invalide : {job.task_kind}"
for title, cmd in steps:
# Capturer mtime du marqueur run_imports AVANT le sync
# (utilisé après pour détecter la fin de run_imports.py)
is_sync = title.startswith("Sync")
mtime_before = (
RUN_IMPORTS_RESULT.stat().st_mtime
if is_sync and RUN_IMPORTS_RESULT.exists() else 0.0
)
rc, pid = _run_step(cmd, fp, title)
last_pid = pid
if rc != 0:
overall_rc = rc
final_msg = f"{title} a échoué (code {rc})"
break
# Si c'était une étape sync, attendre que run_imports termine
if is_sync:
imports_ok, imports_msg, imports_result = _wait_for_run_imports(fp, mtime_before)
if not imports_ok:
overall_rc = 2
final_msg = imports_msg
break
# On garde le message du sub pour la notif finale
final_msg = imports_msg
if overall_rc == 0 and not final_msg:
final_msg = f"{len(steps)} étape(s) OK"
except Exception as e:
overall_rc = 1
final_msg = f"Exception : {e}"
try:
with log_path.open("a", encoding="utf-8") as fp:
import traceback
fp.write("\n[fatal exception]\n")
fp.write(traceback.format_exc())
except Exception:
pass
# État final en DB
finished = datetime.now()
duration = (finished - started).total_seconds()
job.last_status = "ok" if overall_rc == 0 else "fail"
job.last_message = final_msg
job.last_pid = last_pid
sess.commit()
# Notification
try:
notify_job_result(
job_name=job.name,
status=job.last_status,
message=final_msg,
log_path=log_path,
chat_id=job.notify_chat_id or None,
notify_on=job.notify_on,
notify_level=getattr(job, "notify_level", "normal"),
duration_s=duration,
details=imports_result,
job_options={
"task_kind": job.task_kind,
"sync_abs": job.sync_abs,
"sync_bn": job.sync_bn,
"sync_notes": job.sync_notes,
"sync_fiches": job.sync_fiches,
},
)
except Exception as e:
# Ne fait pas échouer le job pour une notif KO
try:
with log_path.open("a", encoding="utf-8") as fp:
fp.write(f"\n[notify] échec envoi : {e}\n")
except Exception:
pass
# ── Main loop ────────────────────────────────────────────────────────────────
def main() -> int:
parser = argparse.ArgumentParser(description="Cron tick — exécute les CronJob dûs.")
parser.add_argument("--dry-run", action="store_true",
help="Liste les jobs dûs sans les exécuter.")
parser.add_argument("--job", type=int, default=None,
help="Force l'exécution d'un job par son id (ignore schedule).")
args = parser.parse_args()
# Garantir que la table existe
try:
init_db()
except Exception as e:
print(f"[cron_tick] init_db error : {e}", file=sys.stderr)
return 1
sess = get_session()
now = datetime.now()
try:
if args.job is not None:
job = sess.get(CronJob, args.job)
if job is None:
print(f"[cron_tick] job id={args.job} introuvable")
return 1
print(f"[cron_tick] forçage job #{job.id} '{job.name}'")
if args.dry_run:
return 0
run_job(job, sess)
return 0
from sqlalchemy import select as _sel
jobs = sess.execute(_sel(CronJob).where(CronJob.enabled == True)).scalars().all() # noqa: E712
due_jobs = [j for j in jobs if _is_due(j, now)]
if not due_jobs:
return 0 # rien à faire
print(f"[cron_tick] {now.isoformat(timespec='seconds')}{len(due_jobs)} job(s) dûs")
for job in due_jobs:
print(f" - #{job.id} '{job.name}' kind={job.task_kind} schedule={job.schedule_kind}:{job.schedule_value}")
if args.dry_run:
continue
run_job(job, sess)
return 0
finally:
sess.close()
if __name__ == "__main__":
sys.exit(main())

View file

@ -51,7 +51,16 @@ for pdf_path in abs_pdfs:
detail = f"{r.nb_absences_nouvelles} nouvelles"
if r.nb_absences_mises_a_jour:
detail += f", {r.nb_absences_mises_a_jour} maj"
res_abs.append({"classe": r.classe, "detail": detail})
if r.nb_absences_pending_skipped:
detail += f", {r.nb_absences_pending_skipped} pending"
res_abs.append({
"classe": r.classe,
"detail": detail,
"nouvelles": r.nb_absences_nouvelles,
"mises_a_jour": r.nb_absences_mises_a_jour,
"pending_skipped": r.nb_absences_pending_skipped,
"doublons": r.nb_absences_doublons,
})
app_log(f"[run_imports] abs {r.classe}: {detail}")
except Exception as e:
errors.append(f"Import abs {Path(pdf_path).name}: {e}")

View file

@ -218,6 +218,52 @@ class SanctionExport(Base):
apprenti: Mapped["Apprenti"] = relationship()
class CronJob(Base):
"""Tâche planifiée (cron) pour pull/push Escada automatique."""
__tablename__ = "cron_jobs"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str]
enabled: Mapped[bool] = mapped_column(default=True)
# schedule_kind ∈ {"daily", "weekly", "interval"}
# daily : schedule_value="HH:MM"
# weekly : schedule_value="MON,TUE,WED,THU,FRI:HH:MM"
# interval: schedule_value="60" (minutes)
schedule_kind: Mapped[str] = mapped_column(default="daily")
schedule_value: Mapped[str] = mapped_column(default="03:00")
# task_kind ∈ {"push", "sync", "push_then_sync"}
task_kind: Mapped[str] = mapped_column(default="push_then_sync")
# Sous-options pour task sync
sync_abs: Mapped[bool] = mapped_column(default=True)
sync_bn: Mapped[bool] = mapped_column(default=True)
sync_notes: Mapped[bool] = mapped_column(default=True)
sync_fiches: Mapped[bool] = mapped_column(default=False)
force_abs: Mapped[bool] = mapped_column(default=False)
# Liste de classes en JSON, ou "ALL" pour toutes
classes_json: Mapped[str] = mapped_column(default="ALL")
# Notifications
# notify_on ∈ {"never", "always", "success", "failure"}
notify_on: Mapped[str] = mapped_column(default="failure")
# notify_level ∈ {"normal", "detailed"}
notify_level: Mapped[str] = mapped_column(default="normal")
notify_chat_id: Mapped[str] = mapped_column(default="") # override config global
# État de la dernière exécution
last_run_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
last_status: Mapped[str] = mapped_column(default="") # "ok"|"fail"|"running"|""
last_message: Mapped[str] = mapped_column(Text, default="")
last_log_path: Mapped[str] = mapped_column(default="")
last_pid: Mapped[Optional[int]] = mapped_column(nullable=True)
created_at: Mapped[datetime] = mapped_column(default=datetime.now)
updated_at: Mapped[datetime] = mapped_column(default=datetime.now)
def get_engine(db_url: str | None = None):
url = db_url or f"sqlite:///{DB_PATH}"
from sqlalchemy import event as _sa_event
@ -239,6 +285,7 @@ def init_db(engine=None):
with engine.connect() as _conn:
for stmt in (
"ALTER TABLE sanctions_export ADD COLUMN nb_absences INTEGER",
"ALTER TABLE cron_jobs ADD COLUMN notify_level TEXT DEFAULT 'normal'",
"""CREATE TABLE IF NOT EXISTS escada_pending (
id INTEGER PRIMARY KEY,
apprenti_id INTEGER NOT NULL REFERENCES apprentis(id),

View file

@ -24,6 +24,7 @@ class ImportResult:
nb_absences_doublons: int
nb_absences_mises_a_jour: int = 0
nb_absences_supprimees: int = 0
nb_absences_pending_skipped: int = 0 # absences non modifiées car pending vers Escada
details_nouvelles: list[str] = field(default_factory=list)
details_mises_a_jour: list[str] = field(default_factory=list)
@ -51,6 +52,7 @@ def import_pdf(
nb_doublons = 0
nb_mises_a_jour = 0
nb_supprimees = 0
nb_pending_skipped = 0
# Détails par apprenti : {apprenti_id: {"nom": str, "prenom": str, "dates": [str]}}
_nouv_by_ap: dict[int, dict] = {}
@ -113,6 +115,7 @@ def import_pdf(
elif ep_pending:
# Modification en attente de sync vers Escada → ne pas écraser
nb_doublons += 1
nb_pending_skipped += 1
elif existe.type_origine != ab["type_absence"]:
existe.type_origine = ab["type_absence"]
existe.statut = "excusee" if ab["type_absence"] == "E" else "a_traiter"
@ -188,6 +191,7 @@ def import_pdf(
nb_absences_doublons=nb_doublons,
nb_absences_mises_a_jour=nb_mises_a_jour,
nb_absences_supprimees=nb_supprimees,
nb_absences_pending_skipped=nb_pending_skipped,
details_nouvelles=[_fmt(d) for d in _nouv_by_ap.values()],
details_mises_a_jour=[_fmt(d) for d in _upd_by_ap.values()],
)

217
src/notifier.py Normal file
View file

@ -0,0 +1,217 @@
"""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)
parts.append(
f" • <b>{_escape_html(classe)}</b> : "
f"{nouv} nouv. · {maj} modif. · {pend} pending"
)
# 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)"