eptm_dashboard/eptm_dashboard/pages/cron.py
Julien Balet 6d1b7c8044 retenue: avis PDF + notices Escada + mapping profession
- nouvelle page /retenue : sélection apprenti, date retenue, date du
  problème, motif (3 cases mutex), branche (autocomplete + saisie libre
  depuis NotesExamen), remarque. Génération PDF basée sur le template
  AcroForm officiel, séparation des 3 widgets Date partagés en 3 champs
  distincts pour ne remplir que celui de la case cochée. Téléchargement
  ou envoi par email (3 destinataires).
- profession : nouveau champ ApprentiFiche.profession, dérivé du préfixe
  de classe via mapping configurable dans Paramètres
  ("AUTOMAT" → "Automaticien CFC" par défaut). Section dédiée avec
  classes orphelines détectées automatiquement.
- notices Escada : nouvelle table Notice (apprenti, titre, remarque,
  date, status). Checkbox "Ajouter automatiquement une notice sur
  Escada" sur /retenue qui crée une entrée pending. Bloc dédié sur
  /escada listant les pending, bouton "Pousser les notices" qui lance
  scripts/push_notices.py (Playwright : navigation Classes → Élèves →
  Notices → Ajouter, fill date / titre / remarque, vérification post-save,
  suppression DB si OK, marquage failed sinon). Nouveau task_kind "push_notices"
  dans le cron pour exécution planifiée.

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

921 lines
36 KiB
Python

"""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 src.logger import app_log # noqa: E402
from ..state import AuthState
from ..sidebar import layout
from ..components import empty_state
_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",
"push_notices": "Push notices"}.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()
user = self.username or "?"
try:
now = datetime.now()
is_new = self.editing_id == 0
if is_new:
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()
verb = "création" if is_new else "modification"
app_log(
f"[cron] {user} : {verb} tâche '{job.name}' (id={job.id}) — "
f"{job.task_kind} / {schedule_value} / "
f"{'activée' if job.enabled else 'désactivée'}"
)
job_name = job.name
self.save_ok = True
self._refresh()
self.edit_open = False
return rx.toast.success(
f"Tâche '{job_name}' "
+ ("créée" if is_new else "enregistrée")
)
except Exception as e:
sess.rollback()
self.save_error = f"Erreur DB : {e}"
return rx.toast.error(f"Erreur lors de l'enregistrement : {e}")
finally:
sess.close()
def toggle_enabled(self, job_id: int):
sess = get_session()
user = self.username or "?"
toast_msg = None
try:
job = sess.get(CronJob, job_id)
if job:
was_disabled = not job.enabled
job.enabled = not job.enabled
job.updated_at = datetime.now()
# Si on passe de désactivé → activé, reset last_run_at pour qu'il
# tourne au prochain tick (au lieu d'attendre last_run_at + interval).
if was_disabled and job.enabled:
job.last_run_at = None
sess.commit()
app_log(
f"[cron] {user} : "
f"{'activation' if job.enabled else 'désactivation'} "
f"tâche '{job.name}' (id={job.id})"
)
toast_msg = (
f"Tâche '{job.name}' "
+ ("activée" if job.enabled else "désactivée")
)
self._refresh()
finally:
sess.close()
if toast_msg:
return rx.toast.success(toast_msg)
def delete_job(self, job_id: int):
sess = get_session()
user = self.username or "?"
toast_msg = None
try:
job = sess.get(CronJob, job_id)
if job:
job_name = job.name
sess.delete(job)
sess.commit()
app_log(f"[cron] {user} : suppression tâche '{job_name}' (id={job_id})")
toast_msg = f"Tâche '{job_name}' supprimée"
self._refresh()
finally:
sess.close()
if toast_msg:
return rx.toast.success(toast_msg)
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", "push_notices"],
value=CronState.f_task_kind,
on_change=CronState.set_f_task_kind,
direction="column",
),
rx.cond(
(CronState.f_task_kind != "push") & (CronState.f_task_kind != "push_notices"),
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,
empty_state(
icon="clock",
title="Aucune tâche planifiée",
description="Crée une tâche pour automatiser la synchronisation Escada (push, pull ou les deux).",
),
rx.vstack(
rx.foreach(CronState.jobs, _job_row),
spacing="2",
width="100%",
),
),
spacing="4",
width="100%",
)
)