2033 lines
83 KiB
Python
2033 lines
83 KiB
Python
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%",
|
||
)
|
||
)
|