eptm_dashboard/eptm_dashboard/pages/escada.py

1367 lines
55 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 annulée")
# ── 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):
async with self:
user = self.username or "?"
self.is_refreshing = True
self.op_log = "Connexion à Escadaweb…"
app_log(f"Rafraîchissement liste classes Escada par {user}")
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 récupérées : {', '.join(ui_classes)}")
else:
app_log(f"Aucune classe récupérée (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
if ui_classes:
yield rx.toast.success(f"{len(ui_classes)} classe(s) récupérée(s)")
else:
yield rx.toast.error("Aucune classe récupérée — vérifiez les logs")
except Exception as _e:
app_log(f"Erreur mise à jour état refresh : {_e}")
try:
async with self:
self.is_refreshing = False
except Exception:
pass
yield rx.toast.error("Erreur lors du rafraîchissement")
# ── 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 = []
_types = []
if sync_abs: _types.append("abs" + ("/forcé" if force_abs else ""))
if sync_bn: _types.append("BN")
if sync_notes: _types.append("notes")
if sync_fiches: _types.append("fiches")
_types_label = ", ".join(_types) or ""
app_log(
f"Sync Escada démarrée par {username}"
f"{len(selected)} classe(s) [{_types_label}] : {', '.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 terminé, 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 terminé — 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."]
yield rx.toast.error("Sync échouée — aucune donnée reçue 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 terminé — 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("Résultats chargés — sync terminée OK")
_nb_err = len(self.sync_errors)
else:
self.sync_errors = ["Import timeout — vérifiez les logs (> 15min)."]
_nb_err = 1
if _result_ready:
if _nb_err == 0:
yield rx.toast.success("Synchronisation Escada terminée")
else:
yield rx.toast.warning(
f"Synchronisation terminée avec {_nb_err} erreur(s)"
)
else:
yield rx.toast.error("Import timeout — vérifiez les logs (> 15min)")
# ── Background: push vers Escada ───────────────────────────────────────────
@_background
async def push_escada(self):
async with self:
user = self.username or "?"
self.is_pushing = True
self.op_log = "Envoi vers Escadaweb…"
self.push_done = False
self.push_ok = 0
self.push_errors = []
app_log(f"Push Escada démarré par {user}")
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 terminé — 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()
if push_done:
if push_errors:
yield rx.toast.warning(
f"Push terminé : {push_ok} OK, {len(push_errors)} erreur(s)"
)
else:
yield rx.toast.success(f"Push terminé — {push_ok} envoyé(s)")
else:
yield rx.toast.error("Push échoué — vérifiez les logs")
except Exception as _e:
app_log(f"Erreur mise à jour état 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(
"Téléchargement 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
rx.cond(
EscadaState.import_in_progress,
rx.box(
rx.hstack(
rx.spinner(size="3"),
rx.vstack(
rx.text(
"Import des données en cours…",
size="3", font_weight="600", color="#e65100",
),
rx.text(
"Insertion dans la DB (~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 terminés."),
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(
"Télécharge absences, BN, notes et données apprentis depuis Escadaweb.",
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 récupérer 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 + moyennes 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("Données apprentis", size="2"),
gap="0.4rem", align="center",
),
gap="1rem",
flex_wrap="wrap",
),
rx.cond(
EscadaState.sync_abs,
rx.flex(
rx.icon(
"triangle-alert",
size=14,
color="#b45309",
),
rx.checkbox(
checked=EscadaState.force_abs,
on_change=EscadaState.set_force_abs,
size="2",
color_scheme="amber",
),
rx.text(
"Les modifications non uploadées sur Escada lors de l'import sont conservées. Forcer la ré-importation complète des absences pour reprendre l'état complet des absences sur Escada.",
size="2",
color="#92400e",
font_weight="600",
),
gap="0.5rem",
align="center",
padding="0.5rem 0.75rem",
background_color="#fef3c7",
border="1px solid #fcd34d",
border_radius="6px",
flex_wrap="wrap",
),
),
# 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) envoyé(s) avec succès.",
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%",
)
)