eptm_dashboard/eptm_dashboard/pages/escada.py

2033 lines
83 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, Notice
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"
_PUSH_NOTICES_SCRIPT = _ROOT / "scripts" / "push_notices.py"
_PULL_NOTICES_SCRIPT = _ROOT / "scripts" / "pull_notices.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
sync_notices: bool = False
force_abs: bool = False
force_notices: 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] = []
notices_count: int = 0
notices_data: list[dict] = []
push_done: bool = False
push_ok: int = 0
push_errors: list[str] = []
# Push notices
is_pushing_notices: bool = False
notices_push_ok: int = 0
notices_push_done: bool = False
notices_push_errors: list[str] = []
# Pull notices (depuis Escada vers DB)
is_pulling_notices: bool = False
notices_pull_done: bool = False
notices_pull_imported: int = 0
notices_pull_ok: int = 0
notices_pull_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_sync_notices(self, v: bool): self.sync_notices = v
def set_force_abs(self, v: bool): self.force_abs = v
def set_force_notices(self, v: bool): self.force_notices = 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
]
self._reload_notices(sess)
finally:
sess.close()
def _reload_notices(self, sess):
notices = sess.execute(
select(Notice)
.options(joinedload(Notice.apprenti))
.join(Apprenti, Notice.apprenti_id == Apprenti.id)
.where(Notice.status == "pending")
.order_by(Apprenti.classe, Notice.date_event, Apprenti.nom)
).scalars().all()
self.notices_count = len(notices)
self.notices_data = [
{
"id": n.id,
"classe": n.apprenti.classe,
"nom": n.apprenti.nom,
"prenom": n.apprenti.prenom,
"date": n.date_event.strftime("%d.%m.%Y"),
"titre": (n.titre or "")[:80] + ("" if len(n.titre or "") > 80 else ""),
"source": n.source,
}
for n in notices
]
def delete_notice(self, notice_id: int):
"""Supprime une notice pending de la file d'attente."""
sess = get_session()
label = ""
try:
n = sess.get(Notice, notice_id)
if n:
ap = n.apprenti
label = (
f"{ap.nom} {ap.prenom}" if ap else f"id={notice_id}"
)
sess.delete(n)
sess.commit()
self._reload_notices(sess)
self.notices_count = len(self.notices_data)
finally:
sess.close()
if label:
app_log(f"[notice] {self.username or '?'} : suppression manuelle pour {label}")
return rx.toast.success(f"Notice supprimée — {label}")
return rx.toast.info("Notice introuvable")
# ── 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
sync_notices = self.sync_notices
force_abs = self.force_abs
force_notices = self.force_notices
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")
if sync_notices: _types.append("notices")
_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()
# Le sync_done final est posé APRÈS le pull notices (si activé), pour
# que la UI affiche "Pull notices en cours" et pas "terminé" trop tôt.
_will_pull_notices = sync_notices and _result_ready
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", [])
# Pas encore sync_done=True : on attend le pull notices
if not _will_pull_notices:
self.sync_done = True
else:
self.is_pulling_notices = True
app_log("Résultats chargés — sync principal terminée OK")
_nb_err = len(self.sync_errors)
else:
self.sync_errors = ["Import timeout — vérifiez les logs (> 15min)."]
self.sync_done = True # finalisation (échec)
_nb_err = 1
if _result_ready and not _will_pull_notices:
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)"
)
elif not _result_ready:
yield rx.toast.error("Import timeout — vérifiez les logs (> 15min)")
# ── Étape supplémentaire : pull des notices ─────────────────────────
if sync_notices and _result_ready:
# Si forcer : supprime les notices pending (push queue) des apprentis
# des classes ciblées AVANT le pull.
if force_notices:
try:
from sqlalchemy import select as _sel, delete as _del
from src.db import get_session as _gs, Apprenti as _Ap, Notice as _Nt
_sess = _gs()
try:
_ap_ids = list(_sess.execute(
_sel(_Ap.id).where(_Ap.classe.in_(selected))
).scalars().all())
if _ap_ids:
_n = _sess.execute(
_del(_Nt).where(_Nt.apprenti_id.in_(_ap_ids))
).rowcount or 0
_sess.commit()
app_log(
f"[pull_notices] force=True → "
f"{_n} notice(s) pending supprimée(s) avant pull"
)
finally:
_sess.close()
except Exception as _e:
app_log(f"[pull_notices] erreur purge force : {_e}")
app_log(f"Pull notices Escada démarré (post-sync) — {len(selected)} classe(s)")
_notices_cmd = [sys.executable, str(_PULL_NOTICES_SCRIPT), *selected]
_notices_lines: list[str] = []
def _run_notices() -> None:
_fd, _tmp = tempfile.mkstemp(suffix="_pull_notices.log")
os.close(_fd)
try:
with open(_tmp, "wb") as _fout:
_proc = subprocess.Popen(
_notices_cmd, stdout=_fout, stderr=subprocess.STDOUT,
env={**os.environ, "PYTHONUNBUFFERED": "1"},
start_new_session=True,
)
_offset = 0
_buf = 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:
_notices_lines.append(_ln)
_log_sync_line(_ln, prefix="pull_notices")
if _proc.poll() is not None:
_proc.wait()
break
except Exception as _exc:
app_log(f"Erreur pull notices subprocess : {_exc}")
finally:
try: os.unlink(_tmp)
except Exception: pass
_pool2 = _cf.ThreadPoolExecutor(max_workers=1)
_fut2 = _pool2.submit(_run_notices)
try:
while not _fut2.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:
_fut2.result()
except Exception as _te:
app_log(f"[pull_notices] thread exception : {_te}")
finally:
_pool2.shutdown(wait=False)
_nb_imported = 0
_nb_ok = 0
_notices_err: list[str] = []
for _ln in _notices_lines:
if "PULL_NOTICES_DONE " in _ln:
try:
_p = json.loads(_ln[_ln.index("PULL_NOTICES_DONE ") + len("PULL_NOTICES_DONE "):])
_nb_ok = _p.get("ok", 0)
_nb_imported = _p.get("imported", 0)
_notices_err = _p.get("err", [])
except Exception:
pass
try:
_t = asyncio.current_task()
if _t is not None:
for _ in range(_t.cancelling()):
_t.uncancel()
async with self:
self.notices_pull_done = True
self.notices_pull_ok = _nb_ok
self.notices_pull_imported = _nb_imported
self.notices_pull_errors = _notices_err
# Le sync complet est maintenant terminé : on libère l'UI
self.is_pulling_notices = False
self.sync_done = True
except Exception:
pass
app_log(
f"Pull notices terminé — {_nb_ok} apprenti(s), "
f"{_nb_imported} notice(s), {len(_notices_err)} erreur(s)"
)
if _notices_err:
yield rx.toast.warning(
f"Notices : {_nb_imported} importée(s), {len(_notices_err)} erreur(s)"
)
else:
yield rx.toast.success(
f"Synchronisation Escada terminée — {_nb_imported} notice(s) "
f"importée(s) sur {_nb_ok} apprenti(s)"
)
# ── 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
# ── Background: push notices vers Escada ──────────────────────────────────
@_background
async def push_notices(self):
async with self:
user = self.username or "?"
self.is_pushing_notices = True
self.notices_push_done = False
self.notices_push_ok = 0
self.notices_push_errors = []
app_log(f"Push notices Escada démarré par {user}")
cmd = [sys.executable, str(_PUSH_NOTICES_SCRIPT)]
lines: list[str] = []
_rc_holder = [0]
def _run() -> None:
_fd, _tmp = tempfile.mkstemp(suffix="_push_notices.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_notices")
if _proc.poll() is not None:
_rc_holder[0] = _proc.wait() or 0
break
except Exception as _exc:
app_log(f"Erreur push notices subprocess : {_exc}")
finally:
try: os.unlink(_tmp)
except Exception: pass
_pool = _cf.ThreadPoolExecutor(max_workers=1)
_fut = _pool.submit(_run)
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_notices] thread exception : {_te}")
finally:
_pool.shutdown(wait=False)
_rc = _rc_holder[0]
nb_ok = 0
errors: list[str] = []
done = False
for line in lines:
if "PUSH_NOTICES_DONE " in line:
done = True
try:
p = json.loads(line[line.index("PUSH_NOTICES_DONE ") + len("PUSH_NOTICES_DONE "):])
nb_ok = p.get("ok", 0)
errors = p.get("err", [])
except Exception as _e:
app_log(f" Erreur parse PUSH_NOTICES_DONE : {_e}", debug=True)
if done:
app_log(f"Push notices terminé — ok:{nb_ok} erreurs:{len(errors)}")
else:
app_log(f"Push notices : PUSH_NOTICES_DONE non trouvé (code={_rc})")
try:
_t = asyncio.current_task()
if _t is not None:
for _ in range(_t.cancelling()):
_t.uncancel()
async with self:
self.notices_push_done = done
self.notices_push_ok = nb_ok
self.notices_push_errors = errors
self.is_pushing_notices = False
self._reload_pending()
if done:
if errors:
yield rx.toast.warning(
f"Push notices : {nb_ok} OK, {len(errors)} erreur(s)"
)
else:
yield rx.toast.success(f"Push notices terminé — {nb_ok} envoyée(s)")
else:
yield rx.toast.error("Push notices échoué — vérifiez les logs")
except Exception as _e:
app_log(f"Erreur mise à jour état push notices : {_e}")
try:
async with self:
self.is_pushing_notices = False
except Exception:
pass
# ── Background: pull notices depuis Escada ────────────────────────────────
@_background
async def pull_notices(self):
async with self:
selected = [c for c, v in self.class_checked.items() if v]
user = self.username or "?"
if not selected:
return
self.is_pulling_notices = True
self.notices_pull_done = False
self.notices_pull_imported = 0
self.notices_pull_ok = 0
self.notices_pull_errors = []
app_log(
f"Pull notices Escada démarré par {user}"
f"{len(selected)} classe(s) : {', '.join(selected)}"
)
cmd = [sys.executable, str(_PULL_NOTICES_SCRIPT), *selected]
lines: list[str] = []
_rc_holder = [0]
def _run() -> None:
_fd, _tmp = tempfile.mkstemp(suffix="_pull_notices.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="pull_notices")
if _proc.poll() is not None:
_rc_holder[0] = _proc.wait() or 0
break
except Exception as _exc:
app_log(f"Erreur pull notices subprocess : {_exc}")
finally:
try: os.unlink(_tmp)
except Exception: pass
_pool = _cf.ThreadPoolExecutor(max_workers=1)
_fut = _pool.submit(_run)
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"[pull_notices] thread exception : {_te}")
finally:
_pool.shutdown(wait=False)
_rc = _rc_holder[0]
nb_ok = 0
nb_imported = 0
errors: list[str] = []
done = False
for line in lines:
if "PULL_NOTICES_DONE " in line:
done = True
try:
p = json.loads(line[line.index("PULL_NOTICES_DONE ") + len("PULL_NOTICES_DONE "):])
nb_ok = p.get("ok", 0)
nb_imported = p.get("imported", 0)
errors = p.get("err", [])
except Exception as _e:
app_log(f" Erreur parse PULL_NOTICES_DONE : {_e}", debug=True)
if done:
app_log(
f"Pull notices terminé — {nb_ok} apprenti(s), "
f"{nb_imported} notice(s), {len(errors)} erreur(s)"
)
else:
app_log(f"Pull notices : PULL_NOTICES_DONE non trouvé (code={_rc})")
try:
_t = asyncio.current_task()
if _t is not None:
for _ in range(_t.cancelling()):
_t.uncancel()
async with self:
self.notices_pull_done = done
self.notices_pull_ok = nb_ok
self.notices_pull_imported = nb_imported
self.notices_pull_errors = errors
self.is_pulling_notices = False
if done:
if errors:
yield rx.toast.warning(
f"Pull notices : {nb_imported} importée(s), {len(errors)} erreur(s)"
)
else:
yield rx.toast.success(
f"Pull notices terminé — {nb_imported} notice(s) sur {nb_ok} apprenti(s)"
)
else:
yield rx.toast.error("Pull notices échoué — vérifiez les logs")
except Exception as _e:
app_log(f"Erreur mise à jour état pull notices : {_e}")
try:
async with self:
self.is_pulling_notices = 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="var(--surface)",
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="var(--text-strong)",
white_space="pre",
font_family="'Courier New', monospace",
),
max_height="240px",
overflow_y="auto",
overflow_x="auto",
background_color="var(--surface-muted)",
border_radius="6px",
border="1px solid var(--border)",
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="var(--text-strong)"),
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 _notice_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(rx.text(item["titre"], size="1")),
rx.table.cell(
rx.badge(item["source"], color_scheme="blue", variant="soft", size="1"),
),
rx.table.cell(
rx.alert_dialog.root(
rx.alert_dialog.trigger(
rx.icon_button(
rx.icon("trash-2", size=12),
color_scheme="red",
variant="ghost",
size="1",
),
),
rx.alert_dialog.content(
rx.alert_dialog.title("Supprimer cette notice ?"),
rx.alert_dialog.description(
rx.vstack(
rx.text(
rx.text.strong(item["nom"], " ", item["prenom"]),
"",
item["date"],
size="2",
),
rx.text(item["titre"], size="1", color="var(--gray-11)"),
spacing="1",
),
),
rx.flex(
rx.alert_dialog.cancel(
rx.button("Annuler", variant="soft", color_scheme="gray"),
),
rx.alert_dialog.action(
rx.button(
"Supprimer",
color_scheme="red",
on_click=EscadaState.delete_notice(item["id"]),
),
),
spacing="3", justify="end", margin_top="1rem",
),
max_width="420px",
),
),
),
)
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="var(--brand-accent)",
),
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%",
),
),
# Phase 3 : pull notices (uniquement si option Notices cochée)
rx.cond(
EscadaState.is_pulling_notices,
rx.box(
rx.hstack(
rx.spinner(size="3"),
rx.vstack(
rx.text(
"Récupération des notices Escada en cours…",
size="3", font_weight="600", color="#0891b2",
),
rx.text(
"Scrape des notices de chaque apprenti (peut prendre plusieurs minutes)",
size="2", color="#555",
),
spacing="0",
),
align="center",
spacing="3",
),
padding="1rem",
background_color="#ecfeff",
border_radius="8px",
border="1px solid #67e8f9",
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("triangle-alert", 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="var(--text-strong)",
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="var(--text-strong)"),
_classe_multi_select_escada(),
# Options de sync
rx.text("Options", size="2", font_weight="700", color="var(--text-strong)"),
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",
),
rx.flex(
rx.checkbox(checked=EscadaState.sync_notices,
on_change=EscadaState.set_sync_notices, size="2"),
rx.text("Notices", size="2"),
gap="0.4rem", align="center",
),
gap="1rem",
flex_wrap="wrap",
),
# Force re-importation — cases à cocher pour Absences / Notices
rx.box(
rx.flex(
rx.icon("triangle-alert", size=14, color="#b45309"),
rx.text(
"Lors de l'import, si des modifications sont en "
"attente (absences, notices) elles ne seront ni "
"écrasées, ni mises à jour. Cocher les cases "
"ci-dessous pour forcer l'import et supprimer "
"les modifications en attente.",
size="2", color="#92400e", font_weight="500",
),
gap="0.5rem", align="start",
margin_bottom="0.5rem",
),
rx.flex(
rx.flex(
rx.checkbox(
checked=EscadaState.force_abs,
on_change=EscadaState.set_force_abs,
size="2",
color_scheme="amber",
disabled=~EscadaState.sync_abs,
),
rx.text(
"Absences",
size="2",
color=rx.cond(
EscadaState.sync_abs, "#92400e", "#cbd5e1",
),
font_weight="600",
),
gap="0.4rem", align="center",
),
rx.flex(
rx.checkbox(
checked=EscadaState.force_notices,
on_change=EscadaState.set_force_notices,
size="2",
color_scheme="amber",
disabled=~EscadaState.sync_notices,
),
rx.text(
"Notices",
size="2",
color=rx.cond(
EscadaState.sync_notices, "#92400e", "#cbd5e1",
),
font_weight="600",
),
gap="0.4rem", align="center",
),
gap="1.5rem", flex_wrap="wrap",
),
padding="0.75rem",
background_color="#fef3c7",
border="1px solid #fcd34d",
border_radius="6px",
width="100%",
),
# 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="var(--surface)",
border_radius="8px",
border="1px solid var(--border)",
width="100%",
),
# ── Section push vers Escada ───────────────────────────────────────
rx.box(
rx.text(
"Pousser les absences en attente sur Escada",
size="3", font_weight="700", color="var(--text-strong)",
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 | (EscadaState.pending_count == 0),
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="var(--surface)",
border_radius="8px",
border="1px solid var(--border)",
width="100%",
),
# ── Section notices ───────────────────────────────────────────────
rx.box(
rx.text(
"Pousser les notices en attente sur Escada",
size="3", font_weight="700", color="var(--text-strong)",
margin_bottom="0.75rem",
),
rx.cond(
EscadaState.notices_count == 0,
rx.text("Aucune notice en attente.", size="2", color="#666"),
rx.vstack(
rx.text(
EscadaState.notices_count,
" notice(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("Titre"),
rx.table.column_header_cell("Source"),
rx.table.column_header_cell("", width="40px"),
)
),
rx.table.body(
rx.foreach(EscadaState.notices_data, _notice_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_notices,
rx.spinner(size="2"),
rx.icon("send", size=14),
),
rx.cond(
EscadaState.is_pushing_notices,
rx.text("Envoi en cours..."),
rx.text("Pousser les notices"),
),
on_click=EscadaState.push_notices,
disabled=(
EscadaState.is_pushing_notices
| (EscadaState.notices_count == 0)
),
color_scheme="blue",
size="2",
),
gap="1rem", align="center", flex_wrap="wrap",
margin_top="0.75rem",
),
rx.cond(
EscadaState.notices_push_done,
rx.vstack(
rx.cond(
EscadaState.notices_push_ok > 0,
rx.text(
EscadaState.notices_push_ok,
" notice(s) envoyée(s).",
size="2", color="#2e7d32", font_weight="600",
),
),
rx.cond(
EscadaState.notices_push_errors.length() > 0,
rx.vstack(
rx.foreach(
EscadaState.notices_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="var(--surface)",
border_radius="8px",
border="1px solid var(--border)",
width="100%",
),
spacing="4",
width="100%",
)
)