957 lines
37 KiB
Python
957 lines
37 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",
|
|
}
|
|
|
|
# Libellés des task_kinds. Le choix _quoi traiter_ (Absences, BN+Matu, Notes,
|
|
# Fiches, Notices) est porté par des cases à cocher séparées, pas par le
|
|
# task_kind lui-même.
|
|
_TASK_KINDS = ["push", "sync", "push_then_sync"]
|
|
_TASK_LABELS = {
|
|
"push": "Push (envoyer vers Escada)",
|
|
"sync": "Sync (télécharger depuis Escada)",
|
|
"push_then_sync": "Push puis Sync",
|
|
}
|
|
|
|
|
|
# ── 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_sync_notices: 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": _TASK_LABELS.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_sync_notices = 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_sync_notices = bool(getattr(job, "sync_notices", False))
|
|
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_sync_notices(self, v: bool): self.f_sync_notices = 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,
|
|
sync_notices=self.f_sync_notices,
|
|
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.sync_notices = self.f_sync_notices
|
|
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="var(--surface)",
|
|
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_group.root(
|
|
rx.vstack(
|
|
*[
|
|
rx.flex(
|
|
rx.radio_group.item(value=k),
|
|
rx.text(_TASK_LABELS[k], size="2"),
|
|
gap="0.5rem", align="center",
|
|
)
|
|
for k in _TASK_KINDS
|
|
],
|
|
spacing="2",
|
|
),
|
|
value=CronState.f_task_kind,
|
|
on_change=CronState.set_f_task_kind,
|
|
),
|
|
rx.vstack(
|
|
rx.text("Données concernées", 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_notices,
|
|
on_change=CronState.set_f_sync_notices, size="2"),
|
|
rx.text("Notices", size="2"),
|
|
spacing="2", align="center",
|
|
),
|
|
# BN+Matu / Notes / Fiches : pertinent uniquement pour sync.
|
|
rx.cond(
|
|
CronState.f_task_kind != "push",
|
|
rx.flex(
|
|
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",
|
|
),
|
|
),
|
|
gap="0.5rem 1.25rem",
|
|
flex_wrap="wrap",
|
|
),
|
|
rx.cond(
|
|
CronState.f_task_kind != "push",
|
|
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%",
|
|
)
|
|
)
|