diff --git a/.gitignore b/.gitignore
index cff29f4..a1635f6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,6 @@ data/pdfs/
data/sync_*.json
data/debug_*.png
data/*.bak.*
+
+# Logs cron (runtime)
+logs/
diff --git a/data/class_href_cache.json b/data/class_href_cache.json
index 2182949..2d494ab 100644
--- a/data/class_href_cache.json
+++ b/data/class_href_cache.json
@@ -1,6 +1,13 @@
{
- "AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=687fa97d-1032-4078-94ae-1899fc1e6014",
- "AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=8cb48a35-290c-4488-b98c-437d2c9186a6",
- "EM-AU 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=da3e0b68-5559-4c0c-a8be-f764c68dbca9",
- "EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=ef32322a-8bd9-45d4-9583-c7c22cbc577d"
+ "AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=5aae2a26-4117-4722-aee2-0bd823fbcfc8",
+ "AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=23fb9e1d-adda-4399-accd-7a2f49e0cc93",
+ "AUTOMAT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=4587e860-dc1f-496a-84a6-dbf1f5d0a963",
+ "AUTOMAT 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=4d128fbe-f18c-4e3b-9ec2-beea462837be",
+ "EM-AU 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=30066257-3dad-4b00-857b-9ab60a5d8581",
+ "EM-AU 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=b9363b96-2d6e-4009-a495-f26c036cc088",
+ "MONTAUT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=b51ec970-5bf4-4982-a05e-80546bb7421f",
+ "MONTAUT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=aaacc343-c248-4f21-93f6-5d9e3079aa5d",
+ "MONTAUT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=18c1ddbe-471f-44f6-bde6-8619adc3b767",
+ "EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=c2c982af-b6f9-44ab-8ed5-7bc567b967cf",
+ "EM-AU 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=a4c4c187-920c-4c91-9620-7f153cf3738a"
}
\ No newline at end of file
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index 5b379bc..4683c78 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -23,6 +23,8 @@ services:
- API_URL=https://dev.dashboard.eptm-automation.ch
# Évite la boucle infinie de hot-reload causée par SQLite WAL/SHM dans data/
- REFLEX_HOT_RELOAD_EXCLUDE_PATHS=/app/data
+ # Timezone du container : aligne avec le host (cohérence cron + logs)
+ - TZ=Europe/Zurich
networks:
- default
- proxy_net
diff --git a/eptm_dashboard/pages/cron.py b/eptm_dashboard/pages/cron.py
index 9b6ce0d..a98d74e 100644
--- a/eptm_dashboard/pages/cron.py
+++ b/eptm_dashboard/pages/cron.py
@@ -405,8 +405,13 @@ class CronState(AuthState):
try:
job = sess.get(CronJob, job_id)
if job:
+ was_disabled = not job.enabled
job.enabled = not job.enabled
job.updated_at = datetime.now()
+ # Si on passe de désactivé → activé, reset last_run_at pour qu'il
+ # tourne au prochain tick (au lieu d'attendre last_run_at + interval).
+ if was_disabled and job.enabled:
+ job.last_run_at = None
sess.commit()
self._refresh()
finally:
diff --git a/eptm_dashboard/pages/escada.py b/eptm_dashboard/pages/escada.py
index 53183a1..1e055ca 100644
--- a/eptm_dashboard/pages/escada.py
+++ b/eptm_dashboard/pages/escada.py
@@ -81,7 +81,6 @@ class EscadaState(AuthState):
push_done: bool = False
push_ok: int = 0
push_errors: list[str] = []
- push_test: bool = False
@rx.var
def selected_count(self) -> int:
@@ -120,7 +119,6 @@ class EscadaState(AuthState):
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 set_push_test(self, v: bool): self.push_test = v
def _clear_results(self):
self.sync_done = False
@@ -176,14 +174,23 @@ class EscadaState(AuthState):
if CLASSES_CACHE.exists():
try:
cached = json.loads(CLASSES_CACHE.read_text(encoding="utf-8"))
- # Filtrer MP/MI (formations maturité, hors scope)
- cached = [c for c in cached if c and not c.startswith(("MP", "MI"))]
- self.classes_cache = cached
- for c in cached:
+ # 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
@@ -301,17 +308,28 @@ class EscadaState(AuthState):
new_classes: list[str] = []
for line in lines:
- if line.startswith("CLASSES_JSON:"):
+ # 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[len("CLASSES_JSON:"):])
+ new_classes = json.loads(line[idx + len("CLASSES_JSON:"):])
except Exception:
pass
- # Filtrer MP/MI (formations maturité, hors scope)
- new_classes = [c for c in new_classes if c and not c.startswith(("MP", "MI"))]
+ # 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]
- if new_classes:
- app_log(f"Classes recuperees : {', '.join(new_classes)}")
+ # 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)})")
@@ -321,13 +339,14 @@ class EscadaState(AuthState):
for _ in range(_t.cancelling()):
_t.uncancel()
async with self:
- if new_classes:
- self.classes_cache = new_classes
+ if ui_classes:
+ self.classes_cache = ui_classes
existing = dict(self.class_checked)
- self.class_checked = {c: existing.get(c, False) for c in new_classes}
+ 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(new_classes, ensure_ascii=False), encoding="utf-8"
+ json.dumps(all_classes, ensure_ascii=False), encoding="utf-8"
)
except Exception:
pass
@@ -550,10 +569,12 @@ class EscadaState(AuthState):
self.is_syncing = False
self.import_in_progress = True
- # Polling inline (max 20 × 3s = 60s) — aucune mise à jour d'état ici
+ # 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(20):
+ for _ in range(300):
try:
await asyncio.sleep(3)
except asyncio.CancelledError:
@@ -582,22 +603,21 @@ class EscadaState(AuthState):
self.sync_done = True
app_log("Resultats charges — sync terminee OK")
else:
- self.sync_errors = ["Import timeout — verifiez les logs (> 60s)."]
+ self.sync_errors = ["Import timeout — verifiez les logs (> 15min)."]
# ── Background: push vers Escada ───────────────────────────────────────────
@_background
async def push_escada(self):
async with self:
- push_test = self.push_test
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 demarre (test={push_test})")
- extra = ["--test"] if push_test else []
+ app_log("Push Escada demarre")
+ extra: list[str] = []
cmd = [sys.executable, str(_PUSH_SCRIPT), *extra]
lines: list[str] = []
_rc_holder = [0]
@@ -1069,7 +1089,7 @@ def escada_page() -> rx.Component:
),
" Actualiser",
on_click=EscadaState.refresh_classes,
- disabled=EscadaState.sync_disabled,
+ disabled=EscadaState.is_busy,
variant="outline",
color_scheme="gray",
size="1",
@@ -1235,15 +1255,6 @@ def escada_page() -> rx.Component:
),
rx.flex(
- rx.flex(
- rx.checkbox(
- checked=EscadaState.push_test,
- on_change=EscadaState.set_push_test,
- size="2",
- ),
- rx.text("Mode test", size="2", color="#555"),
- gap="0.4rem", align="center",
- ),
rx.button(
rx.cond(
EscadaState.is_pushing,
@@ -1293,12 +1304,6 @@ def escada_page() -> rx.Component:
),
),
- # Log push
- rx.cond(
- ~EscadaState.is_pushing,
- _log_box(),
- ),
-
padding="1.25rem",
background_color="white",
border_radius="8px",
diff --git a/logs/cron_tick.log b/logs/cron_tick.log
deleted file mode 100644
index cc471ce..0000000
--- a/logs/cron_tick.log
+++ /dev/null
@@ -1,2 +0,0 @@
-[cron_tick] 2026-05-10T09:29:02 — 1 job(s) dûs
- - #3 'Import absences toutes les 2h' kind=sync schedule=interval:120
diff --git a/scripts/run_imports.py b/scripts/run_imports.py
index f484377..221e6dd 100755
--- a/scripts/run_imports.py
+++ b/scripts/run_imports.py
@@ -53,15 +53,23 @@ for pdf_path in abs_pdfs:
detail += f", {r.nb_absences_mises_a_jour} maj"
if r.nb_absences_pending_skipped:
detail += f", {r.nb_absences_pending_skipped} pending"
+ if r.nb_absences_supprimees:
+ detail += f", {r.nb_absences_supprimees} orphelines"
res_abs.append({
- "classe": r.classe,
- "detail": detail,
- "nouvelles": r.nb_absences_nouvelles,
- "mises_a_jour": r.nb_absences_mises_a_jour,
- "pending_skipped": r.nb_absences_pending_skipped,
- "doublons": r.nb_absences_doublons,
+ "classe": r.classe,
+ "detail": detail,
+ "nouvelles": r.nb_absences_nouvelles,
+ "mises_a_jour": r.nb_absences_mises_a_jour,
+ "pending_skipped": r.nb_absences_pending_skipped,
+ "doublons": r.nb_absences_doublons,
+ "orphelines": r.nb_absences_supprimees,
+ "pendings_orphelins": r.nb_pendings_orphelins_supprimes,
+ "details_orphelines": r.details_orphelines,
})
app_log(f"[run_imports] abs {r.classe}: {detail}")
+ if r.details_orphelines:
+ for d in r.details_orphelines:
+ app_log(f"[run_imports] orpheline supprimée : {r.classe} | {d}")
except Exception as e:
errors.append(f"Import abs {Path(pdf_path).name}: {e}")
app_log(f"[run_imports] erreur abs: {e}")
diff --git a/src/importer.py b/src/importer.py
index a37501c..98b8c60 100644
--- a/src/importer.py
+++ b/src/importer.py
@@ -23,10 +23,12 @@ class ImportResult:
nb_absences_nouvelles: int
nb_absences_doublons: int
nb_absences_mises_a_jour: int = 0
- nb_absences_supprimees: int = 0
- nb_absences_pending_skipped: int = 0 # absences non modifiées car pending vers Escada
+ nb_absences_supprimees: int = 0 # orphelines : abs en DB plus présentes dans le PDF Escada
+ nb_pendings_orphelins_supprimes: int = 0 # pendings supprimés en cascade avec orphelines
+ nb_absences_pending_skipped: int = 0 # abs non modifiées car pending modif locale
details_nouvelles: list[str] = field(default_factory=list)
details_mises_a_jour: list[str] = field(default_factory=list)
+ details_orphelines: list[str] = field(default_factory=list)
def import_pdf(
@@ -89,15 +91,15 @@ def import_pdf(
)
).scalar_one_or_none()
- if existe is not None:
- ep_pending = session.execute(
- select(EscadaPending).where(
- EscadaPending.apprenti_id == existe.apprenti_id,
- EscadaPending.date == existe.date,
- EscadaPending.periode == existe.periode,
- )
- ).scalar_one_or_none()
+ ep_pending = session.execute(
+ select(EscadaPending).where(
+ EscadaPending.apprenti_id == apprenti.id,
+ EscadaPending.date == ab["date"],
+ EscadaPending.periode == ab["periode"],
+ )
+ ).scalar_one_or_none()
+ if existe is not None:
if force:
existe.type_origine = ab["type_absence"]
existe.statut = "excusee" if ab["type_absence"] == "E" else "a_traiter"
@@ -130,6 +132,16 @@ def import_pdf(
else:
nb_doublons += 1
else:
+ # Cas: l'absence est dans le PDF Escada mais pas en DB.
+ # Si un pending existe (typiquement action="clear" = suppression
+ # locale en attente de push), respecter le pending sauf en force.
+ if ep_pending and not force:
+ nb_pending_skipped += 1
+ continue
+ if ep_pending and force:
+ # Force : on écrase le pending et on (ré)insère l'absence
+ session.delete(ep_pending)
+
statut_initial = "excusee" if ab["type_absence"] == "E" else "a_traiter"
session.add(
Absence(
@@ -150,13 +162,33 @@ def import_pdf(
except Exception:
entry["dates"].append(str(ab["date"]))
- # ── Diff : absences supprimées dans Escada ────────────────────────────────
- prev_absences = session.execute(
- select(Absence)
- .join(Import, Absence.import_id == Import.id)
- .where(Import.classe == classe, Import.semestre == semestre)
- ).scalars().all()
+ # ── Diff : absences supprimées dans Escada (orphelines) ──────────────────
+ # On veut toutes les abs des apprentis de cette classe dans la fenêtre de
+ # dates couverte par le PDF — y compris les abs créées localement
+ # (import_id=None) qui ne joignent pas Import.
+ pdf_dates: set = set()
+ for a_data in apprentis_data:
+ for ab in a_data["absences"]:
+ pdf_dates.add(ab["date"])
+ if pdf_dates:
+ date_min = min(pdf_dates)
+ date_max = max(pdf_dates)
+ prev_absences = session.execute(
+ select(Absence)
+ .join(Apprenti, Apprenti.id == Absence.apprenti_id)
+ .where(
+ Apprenti.classe == classe,
+ Absence.date >= date_min,
+ Absence.date <= date_max,
+ )
+ ).scalars().all()
+ else:
+ prev_absences = []
+ details_orphelines: list[str] = []
+ nb_pendings_orphelins = 0
+ # Index des apprentis pour récupérer leur nom/prénom sans re-query par orphelin
+ apprenti_by_id: dict[int, Apprenti] = {}
for ab in prev_absences:
if (ab.apprenti_id, ab.date, ab.periode) not in seen_keys:
ep = session.execute(
@@ -166,8 +198,31 @@ def import_pdf(
EscadaPending.periode == ab.periode,
)
).scalar_one_or_none()
+
+ # Orphelin avec pending : préserve l'intent local (sauf force)
+ # — typique : utilisateur a ajouté localement (action="E") et n'a pas
+ # encore pushé vers Escada. Supprimer écraserait son travail.
+ if ep and not force:
+ nb_pending_skipped += 1
+ continue
+
if ep:
session.delete(ep)
+ nb_pendings_orphelins += 1
+
+ ap = apprenti_by_id.get(ab.apprenti_id)
+ if ap is None:
+ ap = session.get(Apprenti, ab.apprenti_id)
+ if ap is not None:
+ apprenti_by_id[ab.apprenti_id] = ap
+
+ try:
+ date_str = ab.date.strftime("%d.%m.%Y")
+ except Exception:
+ date_str = str(ab.date)
+ nom = f"{ap.prenom} {ap.nom}" if ap else f"apprenti#{ab.apprenti_id}"
+ details_orphelines.append(f"{nom} — {date_str} P{ab.periode}")
+
session.delete(ab)
nb_supprimees += 1
@@ -191,7 +246,9 @@ def import_pdf(
nb_absences_doublons=nb_doublons,
nb_absences_mises_a_jour=nb_mises_a_jour,
nb_absences_supprimees=nb_supprimees,
+ nb_pendings_orphelins_supprimes=nb_pendings_orphelins,
nb_absences_pending_skipped=nb_pending_skipped,
details_nouvelles=[_fmt(d) for d in _nouv_by_ap.values()],
details_mises_a_jour=[_fmt(d) for d in _upd_by_ap.values()],
+ details_orphelines=details_orphelines,
)
diff --git a/src/notifier.py b/src/notifier.py
index 4b78ea7..544d311 100644
--- a/src/notifier.py
+++ b/src/notifier.py
@@ -141,13 +141,24 @@ def notify_job_result(
parts.append("\n📋 Absences")
for r in res_abs:
classe = r.get("classe", "?")
- nouv = int(r.get("nouvelles", 0) or 0)
- maj = int(r.get("mises_a_jour", 0) or 0)
- pend = int(r.get("pending_skipped", 0) or 0)
- parts.append(
+ nouv = int(r.get("nouvelles", 0) or 0)
+ maj = int(r.get("mises_a_jour", 0) or 0)
+ pend = int(r.get("pending_skipped", 0) or 0)
+ orph = int(r.get("orphelines", 0) or 0)
+ line = (
f" • {_escape_html(classe)} : "
f"{nouv} nouv. · {maj} modif. · {pend} pending"
)
+ if orph:
+ line += f" · {orph} suppr."
+ parts.append(line)
+ # Liste des orphelines (cap à 5 par classe pour limiter la taille)
+ details_orph = r.get("details_orphelines") or []
+ if details_orph:
+ for d in details_orph[:5]:
+ parts.append(f" ↳ {_escape_html(str(d))}")
+ if len(details_orph) > 5:
+ parts.append(f" ↳ … +{len(details_orph) - 5} autre(s)")
# BN (seulement si sync_bn coché)
if job_options.get("sync_bn"):