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