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

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

1317 lines
52 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import asyncio
import concurrent.futures as _cf
import json
import os
import re
import subprocess
import sys
import tempfile
import time as _time
from datetime import datetime
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import joinedload
import reflex as rx
def _background(fn):
fn._reflex_background_task = True
return fn
from ..state import AuthState
from ..sidebar import layout
from src.db import get_session, Apprenti, EscadaPending
from src.logger import app_log
_RE_SYNC_PROD = re.compile(r"^\[\d{2}:\d{2}:\d{2}\] ([^ ].*)$")
_RE_SYNC_DEBUG = re.compile(r"^\[\d{2}:\d{2}:\d{2}\]\s+(.+)$")
def _log_sync_line(line: str, prefix: str = "sync") -> None:
m = _RE_SYNC_PROD.match(line)
if m:
app_log(f"[{prefix}] {m.group(1)}")
return
m2 = _RE_SYNC_DEBUG.match(line)
if m2:
app_log(f" [{prefix}] {m2.group(1)}", debug=True)
elif line.strip():
app_log(f" [{prefix}] {line}", debug=True)
_ROOT = Path(__file__).resolve().parent.parent.parent
DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data")))
CLASSES_CACHE = DATA_DIR / "esacada_classes.json"
_SYNC_SCRIPT = _ROOT / "scripts" / "sync_esacada.py"
_PUSH_SCRIPT = _ROOT / "scripts" / "push_to_escada.py"
_SYNC_RESULT_FILE = DATA_DIR / "sync_last_result.json"
_SYNC_ALL_DONE_FILE = DATA_DIR / "sync_all_done.json"
# ── State ─────────────────────────────────────────────────────────────────────
class EscadaState(AuthState):
classes_cache: list[str] = []
class_checked: dict[str, bool] = {}
sync_abs: bool = True
sync_bn: bool = True
sync_notes: bool = True
sync_fiches: bool = False
force_abs: bool = False
is_refreshing: bool = False
is_syncing: bool = False
is_pushing: bool = False
import_in_progress: bool = False
op_log: str = ""
sync_done: bool = False
sync_res_abs: list[dict] = []
sync_res_bn: list[dict] = []
sync_res_notes: list[dict] = []
sync_res_matu: list[dict] = []
sync_errors: list[str] = []
pending_count: int = 0
pending_data: list[dict] = []
push_done: bool = False
push_ok: int = 0
push_errors: list[str] = []
@rx.var
def selected_count(self) -> int:
return sum(1 for v in self.class_checked.values() if v)
@rx.var
def selected_classes_list(self) -> list[str]:
return [c for c in self.classes_cache if self.class_checked.get(c, False)]
@rx.var
def all_selected(self) -> bool:
return (
len(self.classes_cache) > 0
and self.selected_count >= len(self.classes_cache)
)
@rx.var
def has_classes(self) -> bool:
return len(self.classes_cache) > 0
@rx.var
def sync_disabled(self) -> bool:
return (
self.is_refreshing or self.is_syncing or self.is_pushing
or self.import_in_progress or self.selected_count == 0
)
@rx.var
def is_busy(self) -> bool:
return self.is_refreshing or self.is_syncing or self.is_pushing
# ── Simple setters ─────────────────────────────────────────────────────────
def set_sync_abs(self, v: bool): self.sync_abs = v
def set_sync_bn(self, v: bool): self.sync_bn = v
def set_sync_notes(self, v: bool): self.sync_notes = v
def set_sync_fiches(self, v: bool): self.sync_fiches = v
def set_force_abs(self, v: bool): self.force_abs = v
def _clear_results(self):
self.sync_done = False
self.sync_res_abs = []
self.sync_res_bn = []
self.sync_res_notes = []
self.sync_res_matu = []
self.sync_errors = []
def toggle_class(self, classe: str, checked: bool):
self.class_checked[classe] = checked
self._clear_results()
def pick_class(self, classe: str):
"""Toggle binaire (utilisé par le multi-select chip widget)."""
self.class_checked[classe] = not self.class_checked.get(classe, False)
self._clear_results()
def toggle_all_classes(self):
"""Si tout sélectionné → tout désélectionner, sinon → tout sélectionner."""
if self.all_selected:
self.class_checked = {c: False for c in self.classes_cache}
else:
self.class_checked = {c: True for c in self.classes_cache}
self._clear_results()
def select_all(self):
self.class_checked = {c: True for c in self.classes_cache}
self._clear_results()
def clear_all(self):
self.class_checked = {c: False for c in self.classes_cache}
self._clear_results()
def reset_sync(self):
self.is_syncing = False
self.is_refreshing = False
self.is_pushing = False
self.import_in_progress = False
app_log("Sync annulee")
# ── load_data ──────────────────────────────────────────────────────────────
def load_data(self):
if not self.authenticated:
return rx.redirect("/login")
# Réinitialiser les états bloqués (crash worker)
self.is_syncing = False
self.is_refreshing = False
self.is_pushing = False
self.import_in_progress = False
if CLASSES_CACHE.exists():
try:
cached = json.loads(CLASSES_CACHE.read_text(encoding="utf-8"))
# Le fichier cache contient TOUTES les classes (y compris MP/MI/
# Formation) — utilisé par sync_esacada.py pour le matching Matu.
# Pour l'affichage UI, on filtre.
ui_classes = [
c for c in cached
if c and not c.startswith(("MP", "MI")) and c != "Formation"
]
self.classes_cache = ui_classes
for c in ui_classes:
if c not in self.class_checked:
self.class_checked[c] = False
except Exception:
pass
else:
# Cache file absent — reset state mémoire (clé pour vider via UI)
self.classes_cache = []
self.class_checked = {}
self._reload_pending()
# Vider les résultats à chaque visite de la page
self.sync_done = False
self.sync_res_abs = []
self.sync_res_bn = []
self.sync_res_notes = []
self.sync_res_matu = []
self.sync_errors = []
def _reload_pending(self):
sess = get_session()
try:
pending = sess.execute(
select(EscadaPending)
.options(joinedload(EscadaPending.apprenti))
.join(Apprenti, EscadaPending.apprenti_id == Apprenti.id)
.order_by(Apprenti.classe, EscadaPending.date, Apprenti.nom)
).scalars().all()
self.pending_count = len(pending)
self.pending_data = [
{
"classe": ep.apprenti.classe,
"nom": ep.apprenti.nom,
"prenom": ep.apprenti.prenom,
"date": ep.date.strftime("%d.%m.%Y"),
"periode": str(ep.periode),
"action": ep.action,
}
for ep in pending
]
finally:
sess.close()
# ── Background: refresh classes ────────────────────────────────────────────
@_background
async def refresh_classes(self):
app_log("Rafraichissement liste classes Escada")
async with self:
self.is_refreshing = True
self.op_log = "Connexion a Escadaweb..."
cmd = [sys.executable, str(_SYNC_SCRIPT), "--list-classes"]
lines: list[str] = []
_rc_holder = [0]
def _run_refresh() -> None:
_fd, _tmp = tempfile.mkstemp(suffix="_refresh.log")
os.close(_fd)
try:
with open(_tmp, "wb") as _fout:
_proc = subprocess.Popen(
cmd, stdout=_fout, stderr=subprocess.STDOUT,
env={**os.environ, "PYTHONUNBUFFERED": "1"},
start_new_session=True,
)
_offset, _buf = 0, b""
while True:
_time.sleep(0.5)
try:
with open(_tmp, "rb") as _fin:
_fin.seek(_offset); _chunk = _fin.read(65536)
except Exception:
_chunk = b""
if _chunk:
_buf += _chunk; _offset += len(_chunk)
while b"\n" in _buf:
_raw, _buf = _buf.split(b"\n", 1)
_ln = _raw.decode("utf-8", errors="replace").rstrip()
if _ln:
lines.append(_ln); _log_sync_line(_ln, prefix="refresh")
if _proc.poll() is not None:
_time.sleep(0.5)
try:
with open(_tmp, "rb") as _fin:
_fin.seek(_offset); _chunk = _fin.read()
if _chunk:
_buf += _chunk
while b"\n" in _buf:
_raw, _buf = _buf.split(b"\n", 1)
_ln = _raw.decode("utf-8", errors="replace").rstrip()
if _ln:
lines.append(_ln); _log_sync_line(_ln, prefix="refresh")
if _buf.strip():
_ln = _buf.decode("utf-8", errors="replace").rstrip()
if _ln:
lines.append(_ln); _log_sync_line(_ln, prefix="refresh")
except Exception:
pass
_rc_holder[0] = _proc.wait() or 0
break
except Exception as _exc:
app_log(f"Erreur refresh subprocess : {_exc}")
finally:
try: os.unlink(_tmp)
except Exception: pass
_pool = _cf.ThreadPoolExecutor(max_workers=1)
_fut = _pool.submit(_run_refresh)
try:
while not _fut.done():
try:
await asyncio.sleep(1.0)
except asyncio.CancelledError:
_t = asyncio.current_task()
if _t is not None:
for _ in range(_t.cancelling()):
_t.uncancel()
try:
_fut.result()
except Exception as _te:
app_log(f"[refresh] thread exception : {_te}")
finally:
_pool.shutdown(wait=False)
new_classes: list[str] = []
for line in lines:
# Le script préfixe parfois ses lignes avec un timestamp [HH:MM:SS],
# donc on cherche la sous-chaîne au lieu de checker startswith.
idx = line.find("CLASSES_JSON:")
if idx >= 0:
try:
new_classes = json.loads(line[idx + len("CLASSES_JSON:"):])
except Exception:
pass
# Garder la liste BRUTE (toutes classes y compris MP/MI/Formation)
# pour le cache disque : sync_esacada.py s'en sert pour trouver les
# classes Matu (MP1-TASV) correspondant aux années des classes sélectionnées.
all_classes = [c for c in new_classes if c]
# Filtrer MP/MI/Formation pour l'affichage UI (multi-select)
ui_classes = [
c for c in all_classes
if not c.startswith(("MP", "MI")) and c != "Formation"
]
if ui_classes:
app_log(f"Classes recuperees : {', '.join(ui_classes)}")
else:
app_log(f"Aucune classe recuperee (code={_rc_holder[0]}, lignes={len(lines)})")
try:
_t = asyncio.current_task()
if _t is not None:
for _ in range(_t.cancelling()):
_t.uncancel()
async with self:
if ui_classes:
self.classes_cache = ui_classes
existing = dict(self.class_checked)
self.class_checked = {c: existing.get(c, False) for c in ui_classes}
try:
# Cache disque : liste COMPLÈTE pour le matching Matu
CLASSES_CACHE.write_text(
json.dumps(all_classes, ensure_ascii=False), encoding="utf-8"
)
except Exception:
pass
self.op_log = "\n".join(lines[-60:])
self.is_refreshing = False
except Exception as _e:
app_log(f"Erreur mise a jour etat refresh : {_e}")
try:
async with self:
self.is_refreshing = False
except Exception:
pass
# ── Background: sync depuis Escada ─────────────────────────────────────────
# UN SEUL async with self: (au début) — transitions via yield vers regular handlers.
@_background
async def sync_escada(self):
async with self: # SEUL async with self: de cette background task
selected = [c for c, v in self.class_checked.items() if v]
sync_abs = self.sync_abs
sync_bn = self.sync_bn
sync_notes = self.sync_notes
sync_fiches = self.sync_fiches
force_abs = self.force_abs
username = self.username or "escada"
if not selected:
return
self.is_syncing = True
self.import_in_progress = False
self.sync_done = False
self.sync_errors = []
self.sync_res_abs = []
self.sync_res_bn = []
self.sync_res_notes = []
self.sync_res_matu = []
app_log(f"Sync Escada — {len(selected)} classe(s) : {', '.join(selected)}")
args = ["--sync-all"] + selected
if not sync_abs: args.append("--skip-abs")
if not sync_bn: args.append("--skip-bn")
if not sync_notes: args.append("--skip-notes")
if not sync_fiches: args.append("--skip-fiches")
if force_abs: args.append("--force-abs")
cmd = [sys.executable, str(_SYNC_SCRIPT), *args]
lines: list[str] = []
_rc_holder = [0]
_sync_start = datetime.now()
def _run_subprocess() -> None:
_fd, _tmp = tempfile.mkstemp(suffix="_sync.log")
os.close(_fd)
_all_done_payload: dict = {}
try:
with open(_tmp, "wb") as _fout:
_proc = subprocess.Popen(
cmd, stdout=_fout, stderr=subprocess.STDOUT,
env={**os.environ, "PYTHONUNBUFFERED": "1"},
start_new_session=True,
)
app_log(f" Popen pid={_proc.pid}", debug=True)
_offset, _buf = 0, b""
while True:
_time.sleep(0.5)
try:
with open(_tmp, "rb") as _fin:
_fin.seek(_offset); _chunk = _fin.read(65536)
except Exception:
_chunk = b""
if _chunk:
_buf += _chunk; _offset += len(_chunk)
while b"\n" in _buf:
_raw, _buf = _buf.split(b"\n", 1)
_ln = _raw.decode("utf-8", errors="replace").rstrip()
if _ln:
lines.append(_ln); _log_sync_line(_ln, prefix="sync")
if not _all_done_payload and "ALL_DONE " in _ln:
try:
_all_done_payload = json.loads(
_ln[_ln.index("ALL_DONE ") + len("ALL_DONE "):]
)
except Exception as _je:
app_log(f" ERREUR parse ALL_DONE JSON : {_je}")
if _all_done_payload:
try:
DATA_DIR.mkdir(parents=True, exist_ok=True)
_SYNC_ALL_DONE_FILE.write_text(
json.dumps({
"timestamp": datetime.now().isoformat(),
"payload": _all_done_payload,
}, ensure_ascii=False),
encoding="utf-8",
)
app_log(f"sync_all_done.json ecrit")
except Exception as _we:
app_log(f" ERREUR ecriture sync_all_done.json : {_we}")
if _proc.poll() is not None:
_time.sleep(0.5)
try:
with open(_tmp, "rb") as _fin:
_fin.seek(_offset); _chunk = _fin.read()
if _chunk:
_buf += _chunk
while b"\n" in _buf:
_raw, _buf = _buf.split(b"\n", 1)
_ln = _raw.decode("utf-8", errors="replace").rstrip()
if _ln:
lines.append(_ln); _log_sync_line(_ln, prefix="sync")
if not _all_done_payload and "ALL_DONE " in _ln:
try:
_all_done_payload = json.loads(
_ln[_ln.index("ALL_DONE ") + len("ALL_DONE "):]
)
except Exception as _je:
app_log(f" ERREUR parse ALL_DONE JSON (drain) : {_je}")
if _all_done_payload:
try:
DATA_DIR.mkdir(parents=True, exist_ok=True)
_SYNC_ALL_DONE_FILE.write_text(
json.dumps({
"timestamp": datetime.now().isoformat(),
"payload": _all_done_payload,
}, ensure_ascii=False),
encoding="utf-8",
)
app_log(f"sync_all_done.json ecrit (drain)")
except Exception as _we:
app_log(f" ERREUR ecriture sync_all_done.json (drain) : {_we}")
if _buf.strip():
_ln = _buf.decode("utf-8", errors="replace").rstrip()
if _ln:
lines.append(_ln); _log_sync_line(_ln, prefix="sync")
except Exception:
pass
_rc_holder[0] = _proc.wait() or 0
app_log(f" subprocess termine, code={_rc_holder[0]}", debug=True)
break
elif _all_done_payload:
app_log(" ALL_DONE recu — arret force subprocess", debug=True)
try:
os.killpg(os.getpgid(_proc.pid), 9)
except Exception:
try: _proc.kill()
except Exception: pass
try: _proc.wait(timeout=2)
except Exception: pass
_rc_holder[0] = 0
break
except Exception as _exc:
app_log(f"Erreur sync subprocess : {type(_exc).__name__}: {_exc}")
finally:
try: os.unlink(_tmp)
except Exception: pass
_rc = _rc_holder[0]
app_log(f"Sync script termine — code={_rc}, lignes={len(lines)}")
if not _all_done_payload:
app_log(f"ALL_DONE non trouve (code={_rc})")
_pool = _cf.ThreadPoolExecutor(max_workers=1)
_fut = _pool.submit(_run_subprocess)
try:
while not _fut.done():
try:
await asyncio.sleep(1.0)
except asyncio.CancelledError:
_t = asyncio.current_task()
if _t is not None:
for _ in range(_t.cancelling()):
_t.uncancel()
try:
_fut.result()
except Exception as _te:
app_log(f"[sync] thread exception : {_te}")
finally:
_pool.shutdown(wait=False)
# ── Vérifier ALL_DONE ────────────────────────────────────────────────────
def _read_ts(p):
try:
return datetime.fromisoformat(
json.loads(p.read_text(encoding="utf-8")).get("timestamp", "") or ""
)
except Exception:
return datetime(2000, 1, 1)
_all_done_ts = datetime(2000, 1, 1)
try:
if _SYNC_ALL_DONE_FILE.exists():
_adf_t = _read_ts(_SYNC_ALL_DONE_FILE)
if _adf_t > _sync_start:
_all_done_ts = _adf_t
except Exception:
pass
def _uncancel():
_cur = asyncio.current_task()
if _cur is not None:
for _ in range(_cur.cancelling()):
_cur.uncancel()
if _all_done_ts == datetime(2000, 1, 1):
# Sync échouée — async with self #2
app_log("ALL_DONE absent — sync echouee")
_uncancel()
async with self:
self.is_syncing = False
self.sync_errors = ["Synchronisation echouee — aucune donnee recue depuis Escadaweb."]
return
# ── Phase 2 : import subprocess en cours — async with self #2 ────────────
app_log("ALL_DONE confirme — phase import")
_uncancel()
async with self:
self.is_syncing = False
self.import_in_progress = True
# Polling inline (max 300 × 3s = 15 min) — aucune mise à jour d'état ici
# Un import complet (toutes classes + BN + Matu + Notes via Selenium)
# peut facilement prendre plusieurs minutes.
_result_data: dict = {}
_result_ready = False
for _ in range(300):
try:
await asyncio.sleep(3)
except asyncio.CancelledError:
_uncancel()
if _SYNC_RESULT_FILE.exists():
_res_ts = _read_ts(_SYNC_RESULT_FILE)
if _res_ts > _all_done_ts:
try:
_result_data = json.loads(_SYNC_RESULT_FILE.read_text(encoding="utf-8"))
except Exception:
pass
_result_ready = True
break
# ── État final — async with self #3 ──────────────────────────────────────
app_log(f"Poll termine — result_ready={_result_ready}")
_uncancel()
async with self:
self.import_in_progress = False
if _result_ready:
self.sync_res_abs = _result_data.get("res_abs", [])
self.sync_res_bn = _result_data.get("res_bn", [])
self.sync_res_notes = _result_data.get("res_notes", [])
self.sync_res_matu = _result_data.get("res_matu", [])
self.sync_errors = _result_data.get("errors", [])
self.sync_done = True
app_log("Resultats charges — sync terminee OK")
else:
self.sync_errors = ["Import timeout — verifiez les logs (> 15min)."]
# ── Background: push vers Escada ───────────────────────────────────────────
@_background
async def push_escada(self):
async with self:
self.is_pushing = True
self.op_log = "Envoi vers Escadaweb..."
self.push_done = False
self.push_ok = 0
self.push_errors = []
app_log("Push Escada demarre")
extra: list[str] = []
cmd = [sys.executable, str(_PUSH_SCRIPT), *extra]
lines: list[str] = []
_rc_holder = [0]
def _run_push() -> None:
_fd, _tmp = tempfile.mkstemp(suffix="_push.log")
os.close(_fd)
try:
with open(_tmp, "wb") as _fout:
_proc = subprocess.Popen(
cmd, stdout=_fout, stderr=subprocess.STDOUT,
env={**os.environ, "PYTHONUNBUFFERED": "1"},
start_new_session=True,
)
_offset, _buf = 0, b""
while True:
_time.sleep(0.5)
try:
with open(_tmp, "rb") as _fin:
_fin.seek(_offset); _chunk = _fin.read(65536)
except Exception:
_chunk = b""
if _chunk:
_buf += _chunk; _offset += len(_chunk)
while b"\n" in _buf:
_raw, _buf = _buf.split(b"\n", 1)
_ln = _raw.decode("utf-8", errors="replace").rstrip()
if _ln:
lines.append(_ln); _log_sync_line(_ln, prefix="push")
if _proc.poll() is not None:
_time.sleep(0.5)
try:
with open(_tmp, "rb") as _fin:
_fin.seek(_offset); _chunk = _fin.read()
if _chunk:
_buf += _chunk
while b"\n" in _buf:
_raw, _buf = _buf.split(b"\n", 1)
_ln = _raw.decode("utf-8", errors="replace").rstrip()
if _ln:
lines.append(_ln); _log_sync_line(_ln, prefix="push")
if _buf.strip():
_ln = _buf.decode("utf-8", errors="replace").rstrip()
if _ln:
lines.append(_ln); _log_sync_line(_ln, prefix="push")
except Exception:
pass
_rc_holder[0] = _proc.wait() or 0
break
except Exception as _exc:
app_log(f"Erreur push subprocess : {_exc}")
finally:
try: os.unlink(_tmp)
except Exception: pass
_pool = _cf.ThreadPoolExecutor(max_workers=1)
_fut = _pool.submit(_run_push)
try:
while not _fut.done():
try:
await asyncio.sleep(1.0)
except asyncio.CancelledError:
_t = asyncio.current_task()
if _t is not None:
for _ in range(_t.cancelling()):
_t.uncancel()
try:
_fut.result()
except Exception as _te:
app_log(f"[push] thread exception : {_te}")
finally:
_pool.shutdown(wait=False)
_rc = _rc_holder[0]
push_ok = 0
push_errors: list[str] = []
push_done = False
for line in lines:
if "PUSH_DONE " in line:
push_done = True
try:
p = json.loads(line[line.index("PUSH_DONE ") + len("PUSH_DONE "):])
push_ok = p.get("ok", 0)
push_errors = p.get("err", [])
except Exception as _e:
app_log(f" Erreur parse PUSH_DONE : {_e}", debug=True)
if push_done:
app_log(f"Push termine — ok:{push_ok} erreurs:{len(push_errors)}")
else:
app_log(f"Push : PUSH_DONE non trouve (code={_rc}, lignes={len(lines)})")
try:
_t = asyncio.current_task()
if _t is not None:
for _ in range(_t.cancelling()):
_t.uncancel()
async with self:
self.push_done = push_done
self.push_ok = push_ok
self.push_errors = push_errors
self.op_log = "\n".join(lines[-60:])
self.is_pushing = False
self._reload_pending()
except Exception as _e:
app_log(f"Erreur mise a jour etat push : {_e}")
try:
async with self:
self.is_pushing = False
except Exception:
pass
# ── UI helpers ────────────────────────────────────────────────────────────────
def _escada_chip(classe: rx.Var) -> rx.Component:
"""Chip rouge avec X pour retirer une classe sélectionnée."""
return rx.flex(
rx.text(classe, size="1", color="white", font_weight="500"),
rx.icon(
"x",
size=12,
color="white",
cursor="pointer",
on_click=EscadaState.pick_class(classe).stop_propagation,
),
align="center",
gap="0.25rem",
padding="0.15rem 0.4rem 0.15rem 0.6rem",
background_color="var(--red-9)",
border_radius="9999px",
flex_shrink="0",
)
def _escada_option(classe: rx.Var) -> rx.Component:
"""Ligne de la dropdown avec checkmark si la classe est sélectionnée."""
return rx.box(
rx.flex(
rx.cond(
EscadaState.class_checked[classe],
rx.icon("check", size=14, color="var(--red-9)"),
rx.box(width="14px", height="14px"),
),
rx.text(classe, size="2"),
align="center",
gap="0.5rem",
),
padding="0.45rem 0.75rem",
cursor="pointer",
on_click=EscadaState.pick_class(classe),
_hover={"background_color": "var(--gray-3)"},
width="100%",
)
def _classe_multi_select_escada() -> rx.Component:
"""Multi-select chips + dropdown style Streamlit."""
return rx.popover.root(
rx.popover.trigger(
rx.box(
rx.flex(
rx.cond(
EscadaState.selected_count == 0,
rx.text(
"Sélectionner une ou plusieurs classes…",
color="var(--gray-9)",
size="2",
),
rx.foreach(EscadaState.selected_classes_list, _escada_chip),
),
wrap="wrap",
gap="0.3rem",
flex="1",
min_height="28px",
align="center",
),
rx.icon("chevron-down", size=18, color="var(--gray-9)"),
display="flex",
align_items="center",
gap="0.5rem",
padding="0.45rem 0.6rem",
border="2px solid var(--red-7)",
border_radius="6px",
background_color="white",
cursor="pointer",
width="100%",
max_width="640px",
),
),
rx.popover.content(
rx.vstack(
rx.box(
rx.text(
rx.cond(
EscadaState.all_selected,
"Tout désélectionner",
"Tout sélectionner",
),
size="2",
font_weight="500",
color="var(--red-9)",
),
on_click=EscadaState.toggle_all_classes,
padding="0.5rem 0.75rem",
cursor="pointer",
_hover={"background_color": "var(--gray-3)"},
width="100%",
),
rx.divider(margin="0"),
rx.foreach(EscadaState.classes_cache, _escada_option),
spacing="0",
align="stretch",
width="100%",
),
max_height="320px",
overflow_y="auto",
min_width="280px",
padding="0",
),
)
def _log_box() -> rx.Component:
return rx.cond(
EscadaState.op_log != "",
rx.box(
rx.text(
EscadaState.op_log,
size="1",
color="#37474f",
white_space="pre",
font_family="'Courier New', monospace",
),
max_height="240px",
overflow_y="auto",
overflow_x="auto",
background_color="#f8f9fa",
border_radius="6px",
border="1px solid #dee2e6",
padding="0.75rem",
width="100%",
margin_top="0.75rem",
),
)
def _result_list(label: str, items, row_fn) -> rx.Component:
return rx.cond(
items.length() > 0,
rx.vstack(
rx.text(label, size="2", font_weight="700", color="#37474f"),
rx.foreach(items, row_fn),
spacing="1",
),
)
def _pending_row(item) -> rx.Component:
return rx.table.row(
rx.table.cell(item["classe"]),
rx.table.cell(rx.text(item["nom"], " ", item["prenom"])),
rx.table.cell(item["date"]),
rx.table.cell(item["periode"], text_align="center"),
rx.table.cell(
rx.badge(
item["action"],
color_scheme=rx.cond(
item["action"] == "E", "green",
rx.cond(item["action"] == "clear", "gray", "red"),
),
variant="soft",
)
),
)
def _sync_progress() -> rx.Component:
"""Indicateurs de progression — remplace l'ancien op_log dans la section sync."""
return rx.vstack(
# Phase 1 : scraping Escadaweb
rx.cond(
EscadaState.is_syncing,
rx.box(
rx.hstack(
rx.spinner(size="3"),
rx.vstack(
rx.text(
"Synchronisation Escadaweb en cours...",
size="3", font_weight="600", color="#1565c0",
),
rx.text(
"Telechargement depuis escadaweb.vs.ch (1-3 min)",
size="2", color="#555",
),
spacing="0",
),
align="center",
spacing="3",
),
rx.button(
rx.icon("x", size=13),
" Annuler",
on_click=EscadaState.reset_sync,
variant="outline",
color_scheme="gray",
size="1",
margin_top="0.75rem",
),
padding="1rem",
background_color="#e3f2fd",
border_radius="8px",
border="1px solid #90caf9",
width="100%",
),
),
# Phase 2 : import en base
rx.cond(
EscadaState.import_in_progress,
rx.box(
rx.hstack(
rx.spinner(size="3"),
rx.vstack(
rx.text(
"Import des donnees en cours...",
size="3", font_weight="600", color="#e65100",
),
rx.text(
"Insertion en base de donnees (~30s)",
size="2", color="#555",
),
spacing="0",
),
align="center",
spacing="3",
),
padding="1rem",
background_color="#fff3e0",
border_radius="8px",
border="1px solid #ffb74d",
width="100%",
),
),
# Résultats
rx.cond(
EscadaState.sync_done,
rx.vstack(
rx.callout.root(
rx.callout.icon(rx.icon("check", size=16)),
rx.callout.text("Synchronisation et import termines."),
color_scheme="green",
variant="soft",
size="1",
),
_result_list(
"Absences",
EscadaState.sync_res_abs,
lambda r: rx.text(
"", r["classe"], "", r["detail"],
size="2", color="#2e7d32",
),
),
_result_list(
"Bulletins de notes",
EscadaState.sync_res_bn,
lambda r: rx.text(
"", r["classe"], "", r["nb"], " apprenti(e)s",
size="2", color="#2e7d32",
),
),
_result_list(
"Notes d'examen",
EscadaState.sync_res_notes,
lambda r: rx.text(
"", r["classe"], "", r["nb"], " apprenti(e)s",
size="2", color="#2e7d32",
),
),
_result_list(
"Notes Matu",
EscadaState.sync_res_matu,
lambda r: rx.text(
"", r["classe"], "", r["nb"], " matches",
size="2", color="#2e7d32",
),
),
rx.cond(
EscadaState.sync_errors.length() > 0,
rx.vstack(
rx.text("Erreurs", size="2", font_weight="700", color="#c62828"),
rx.foreach(
EscadaState.sync_errors,
lambda e: rx.text("", e, size="2", color="#c62828"),
),
spacing="1",
),
),
spacing="3",
width="100%",
),
),
# Erreur (sync échouée, pas de résultats)
rx.cond(
EscadaState.sync_errors.length() > 0,
rx.cond(
~EscadaState.sync_done,
rx.cond(
~EscadaState.is_syncing,
rx.cond(
~EscadaState.import_in_progress,
rx.callout.root(
rx.callout.icon(rx.icon("alert-circle", size=16)),
rx.callout.text(
rx.foreach(
EscadaState.sync_errors,
lambda e: rx.text(e, size="2"),
),
),
color_scheme="red",
variant="soft",
size="1",
),
),
),
),
),
spacing="3",
width="100%",
margin_top="0.75rem",
)
# ── Page ──────────────────────────────────────────────────────────────────────
def escada_page() -> rx.Component:
return layout(
rx.vstack(
rx.heading("Synchronisation Escada", size="7"),
rx.text(
"Telecharge absences, BN, notes et fiches depuis escadaweb.vs.ch "
"et les importe directement en base.",
size="2", color="#666",
),
# ── Section sync depuis Escada ─────────────────────────────────────
rx.box(
rx.text(
"Synchronisation depuis Escada",
size="3", font_weight="700", color="#37474f",
margin_bottom="0.75rem",
),
# Cache info + bouton refresh
rx.flex(
rx.cond(
EscadaState.has_classes,
rx.text(EscadaState.classes_cache.length(), " classe(s) en cache.", size="2", color="#666"),
rx.text("Aucun cache de classes.", size="2", color="#666"),
),
rx.button(
rx.cond(
EscadaState.is_refreshing,
rx.spinner(size="1"),
rx.icon("refresh-cw", size=13),
),
" Actualiser",
on_click=EscadaState.refresh_classes,
disabled=EscadaState.is_busy,
variant="outline",
color_scheme="gray",
size="1",
),
justify="between",
align="center",
width="100%",
flex_wrap="wrap",
gap="0.5rem",
margin_bottom="0.75rem",
),
rx.cond(
~EscadaState.has_classes,
rx.box(
rx.text(
"Cliquez sur Actualiser pour recuperer la liste des classes depuis Escadaweb.",
size="2", color="#555",
),
padding="0.75rem",
background_color="#e3f2fd",
border_radius="6px",
border="1px solid #90caf9",
),
# ── Formulaire sync ────────────────────────────────────────
rx.vstack(
# Sélection des classes — multi-select style Streamlit
rx.text("Classes", size="2", font_weight="700", color="#37474f"),
_classe_multi_select_escada(),
# Options de sync
rx.text("Options", size="2", font_weight="700", color="#37474f"),
rx.flex(
rx.flex(
rx.checkbox(checked=EscadaState.sync_abs,
on_change=EscadaState.set_sync_abs, size="2"),
rx.text("Absences", size="2"),
gap="0.4rem", align="center",
),
rx.flex(
rx.checkbox(checked=EscadaState.sync_bn,
on_change=EscadaState.set_sync_bn, size="2"),
rx.text("BN + Matu", size="2"),
gap="0.4rem", align="center",
),
rx.flex(
rx.checkbox(checked=EscadaState.sync_notes,
on_change=EscadaState.set_sync_notes, size="2"),
rx.text("Notes", size="2"),
gap="0.4rem", align="center",
),
rx.flex(
rx.checkbox(checked=EscadaState.sync_fiches,
on_change=EscadaState.set_sync_fiches, size="2"),
rx.text("Fiches", size="2"),
gap="0.4rem", align="center",
),
gap="1rem",
flex_wrap="wrap",
),
rx.cond(
EscadaState.sync_abs,
rx.flex(
rx.checkbox(
checked=EscadaState.force_abs,
on_change=EscadaState.set_force_abs,
size="2",
),
rx.text(
"Forcer la reimportation des absences existantes",
size="2", color="#555",
),
gap="0.4rem", align="center",
),
),
# Bouton Synchroniser
rx.button(
rx.cond(
EscadaState.is_syncing,
rx.spinner(size="2"),
rx.icon("refresh-cw", size=14),
),
rx.cond(
EscadaState.is_syncing,
rx.text("Synchronisation en cours..."),
rx.text(
"Synchroniser ",
EscadaState.selected_count,
" classe(s)",
),
),
on_click=EscadaState.sync_escada,
disabled=EscadaState.sync_disabled,
color_scheme="blue",
size="2",
),
spacing="3",
width="100%",
),
),
# Indicateurs de progression (phase 1 → phase 2 → résultats / erreur)
_sync_progress(),
# Log refresh (uniquement pour la commande Actualiser)
rx.cond(
EscadaState.is_refreshing,
_log_box(),
),
padding="1.25rem",
background_color="white",
border_radius="8px",
border="1px solid #e0e0e0",
width="100%",
),
# ── Section push vers Escada ───────────────────────────────────────
rx.box(
rx.text(
"Pousser vers Escada",
size="3", font_weight="700", color="#37474f",
margin_bottom="0.75rem",
),
rx.cond(
EscadaState.pending_count == 0,
rx.text("Aucun changement en attente.", size="2", color="#666"),
rx.vstack(
rx.text(
EscadaState.pending_count,
" changement(s) en attente d'envoi vers Escada.",
size="2", color="#e65100", font_weight="600",
),
rx.box(
rx.table.root(
rx.table.header(
rx.table.row(
rx.table.column_header_cell("Classe"),
rx.table.column_header_cell("Apprenti"),
rx.table.column_header_cell("Date"),
rx.table.column_header_cell("P."),
rx.table.column_header_cell("Action"),
)
),
rx.table.body(
rx.foreach(EscadaState.pending_data, _pending_row),
),
width="100%",
size="1",
),
overflow_x="auto",
width="100%",
),
spacing="2",
width="100%",
margin_bottom="0.75rem",
),
),
rx.flex(
rx.button(
rx.cond(
EscadaState.is_pushing,
rx.spinner(size="2"),
rx.icon("send", size=14),
),
rx.cond(
EscadaState.is_pushing,
rx.text("Envoi en cours..."),
rx.text("Pousser vers Escada"),
),
on_click=EscadaState.push_escada,
disabled=EscadaState.is_busy,
color_scheme="red",
size="2",
),
gap="1rem",
align="center",
flex_wrap="wrap",
margin_top="0.75rem",
),
rx.cond(
EscadaState.push_done,
rx.vstack(
rx.cond(
EscadaState.push_ok > 0,
rx.text(
EscadaState.push_ok,
" changement(s) envoye(s) avec succes.",
size="2", color="#2e7d32", font_weight="600",
),
),
rx.cond(
EscadaState.push_errors.length() > 0,
rx.vstack(
rx.foreach(
EscadaState.push_errors,
lambda e: rx.text("", e, size="2", color="#c62828"),
),
spacing="1",
),
),
spacing="2",
margin_top="0.75rem",
width="100%",
),
),
padding="1.25rem",
background_color="white",
border_radius="8px",
border="1px solid #e0e0e0",
width="100%",
),
spacing="4",
width="100%",
)
)