"""Pousse vers Escada les changements de statut effectués dans l'app. Usage : python scripts/push_to_escada.py # tous les changements en attente python scripts/push_to_escada.py --test # test limité à Poidevin Alexandre / EM-AU 1 python scripts/push_to_escada.py --count # affiche le nombre de changements en attente python scripts/push_to_escada.py --no-pull # ne pas récupérer le serveur avant push """ from __future__ import annotations import json import subprocess import sys from datetime import date from pathlib import Path _root = Path(__file__).resolve().parent.parent if str(_root) not in sys.path: sys.path.insert(0, str(_root)) if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8", errors="replace") if hasattr(sys.stderr, "reconfigure"): sys.stderr.reconfigure(encoding="utf-8", errors="replace") from playwright.sync_api import sync_playwright, TimeoutError as PWTimeout from src.db import Absence, Apprenti, EscadaPending, get_engine, init_db, upsert_escada_pending from sqlalchemy.orm import sessionmaker, Session from sqlalchemy import select # Réutilise les utilitaires de navigation depuis sync_esacada from scripts.sync_esacada import ( BASE_URL, CLASSES_URL, PROFILE_DIR, _log, _ensure_logged_in, _launch_context, _go_to_absence_page, _cache_load, ) # ── Coordonnées du serveur ──────────────────────────────────────────────────── _SSH_HOST = "julbal@20.199.136.37" _SSH_REMOTE = "/opt/absences" # ── Interaction avec la page d'absences ─────────────────────────────────────── _JS_SET_DROPDOWN = """([nom, prenom, idx, val]) => { for (const tr of document.querySelectorAll('tr')) { // Vérifier que nom et prénom apparaissent dans des directs courts // (les lignes-containers du grid DevExpress ont des très longs // contenant tous les élèves — on les exclut via la limite 200 chars) const directTds = Array.from(tr.querySelectorAll(':scope > td')); if (!directTds.length) continue; const hasNom = directTds.some(td => { const t = (td.innerText || td.textContent || '').trim(); return t.includes(nom) && t.length < 200; }); const hasPrenom = directTds.some(td => { const t = (td.innerText || td.textContent || '').trim(); return t.includes(prenom) && t.length < 200; }); if (!hasNom || !hasPrenom) continue; const sels = Array.from(tr.querySelectorAll('select')); // Exclure les containers (trop de selects = plusieurs élèves fusionnés) if (sels.length > 25) continue; if (sels.length <= idx) { return {ok: false, reason: 'seulement ' + sels.length + ' selects, besoin de ' + (idx+1)}; } const prev = sels[idx].value; sels[idx].value = val; if (sels[idx].value !== val) { const opts = Array.from(sels[idx].options).map(o => '"' + o.value + '"="' + o.text.trim() + '"').join(', '); return {ok: false, reason: 'valeur "' + val + '" absente — options: {' + opts + '}'}; } sels[idx].dispatchEvent(new Event('change', {bubbles: true})); return {ok: true, prev: prev}; } return {ok: false, reason: 'ligne introuvable pour ' + nom + ' ' + prenom}; }""" _JS_SET_DATE = """([dateStr]) => { // Cherche le DevExpress DateEdit : id se termine par _I const candidates = [ document.querySelector("input[id*='kalender_I']"), document.querySelector("input[id*='DateEdit_I']"), document.querySelector("input[id*='_Date_I']"), (() => { const all = document.querySelectorAll("table input[type='text']"); return all.length ? all[0] : null; })(), ]; const inp = candidates.find(Boolean); if (!inp) return {ok: false, reason: 'input introuvable'}; inp.value = dateStr; // Déclenche l'événement ASPx (postback DevExpress) const ctrlName = inp.id.endsWith('_I') ? inp.id.slice(0, -2) : inp.id; try { if (typeof ASPx !== 'undefined' && ASPx.ETextChanged) { ASPx.ETextChanged(ctrlName); return {ok: true, method: 'ASPx', id: inp.id}; } } catch(e) {} // Fallback : événements DOM standards inp.dispatchEvent(new Event('change', {bubbles: true})); inp.dispatchEvent(new Event('input', {bubbles: true})); return {ok: true, method: 'DOM', id: inp.id}; }""" def _set_date(page, target_date: date) -> bool: """Change la date dans le sélecteur DevExpress et attend le rechargement.""" date_str = target_date.strftime("%d/%m/%Y") try: result = page.evaluate(_JS_SET_DATE, [date_str]) if not result.get("ok"): _log(f" [set_date] ERR : {result.get('reason', '?')}") return False _log(f" [set_date] date={date_str} via {result.get('method')} id={result.get('id')}") page.wait_for_timeout(400) try: page.wait_for_load_state("networkidle", timeout=20_000) except Exception: pass page.wait_for_timeout(600) # Vérification : comparer la valeur affichée dans l'input (pas innerText) cur_val = page.evaluate( "() => { const i = document.getElementById('ContentPlaceHolder_site_kalender_I'); return i ? i.value : ''; }" ) if cur_val == date_str: _log(f" [set_date] OK (input value = {cur_val})") return True # Fallback : interaction directe Playwright _log(f" [set_date] input value='{cur_val}' ≠ '{date_str}', tentative fill+Tab…") try: inp2 = page.locator("input[id*='kalender_I']").first if not inp2.count(): inp2 = page.locator("table input[type='text']").first if inp2.count(): inp2.click(click_count=3) inp2.fill(date_str) page.keyboard.press("Tab") page.wait_for_timeout(400) try: page.wait_for_load_state("networkidle", timeout=20_000) except Exception: pass page.wait_for_timeout(600) cur_val2 = page.evaluate( "() => { const i = document.getElementById('ContentPlaceHolder_site_kalender_I'); return i ? i.value : ''; }" ) if cur_val2 == date_str: _log(f" [set_date] OK via fill+Tab (input value = {cur_val2})") return True _log(f" [set_date] ERR : input value='{cur_val2}' après fill+Tab") except Exception as e2: _log(f" [set_date] fill+Tab ERR : {e2}") return False except Exception as e: _log(f" [set_date] ERR : {e}") return False _ESCADA_CODES = {"E": "68", "N": "56", "clear": "0"} def _set_dropdown(page, nom: str, prenom: str, periode: int, action: str) -> bool: """Positionne le dropdown d'une période pour un apprenti. Colonnes selects dans la ligne : Remarques(0) | Journée entière(1) | Excuser(2) | P1(3)…P10(12) → target_idx = periode + 2 """ val = _ESCADA_CODES.get(action, "0") target_idx = periode + 1 try: result = page.evaluate(_JS_SET_DROPDOWN, [nom, prenom, target_idx, val]) if result.get("ok"): prev = result.get("prev", "?") _log(f" SET {nom} {prenom} P{periode} : '{prev}' → '{val}'") return True else: _log(f" WARN {nom} {prenom} P{periode} : {result.get('reason', '?')}") return False except Exception as e: _log(f" ERR {nom} {prenom} P{periode} : {e}") return False def _save(page) -> bool: """Clique sur le bouton Enregistrer via JS (bypass visibilité DevExpress).""" try: clicked = page.evaluate("""() => { // Cherche tous les inputs Enregistrer (visibles ou non) const btns = Array.from(document.querySelectorAll( 'input[value="Enregistrer"], input[value*="nregistrer"]' )); if (!btns.length) return {ok: false, reason: 'bouton introuvable'}; // Préférer le visible, sinon prendre le premier const btn = btns.find(b => b.offsetParent !== null) || btns[0]; btn.click(); return {ok: true, id: btn.id}; }""") if not clicked.get("ok"): _log(f" [save] ERR : {clicked.get('reason')}") return False _log(f" [save] cliqué id={clicked.get('id')}") page.wait_for_timeout(400) try: page.wait_for_load_state("networkidle", timeout=15_000) except Exception: pass page.wait_for_timeout(500) return True except Exception as e: _log(f" [save] ERR : {e}") return False # ── Synchronisation avec le serveur ────────────────────────────────────────── def _pull_from_server(session: Session) -> dict[tuple, int]: """SSH → serveur, exporte EscadaPending en JSON, upsert en local. Retourne un mapping (nom, prenom, classe, date_iso, periode) → server_id pour permettre le nettoyage côté serveur après push réussi. """ _log("PULL Récupération des modifications en attente depuis le serveur…") cmd = ( f'ssh {_SSH_HOST} ' f'"cd {_SSH_REMOTE} && .venv/bin/python scripts/export_pending.py"' ) try: result = subprocess.run( cmd, capture_output=True, text=True, timeout=30, shell=True ) if result.returncode != 0: _log(f" WARN SSH export_pending échoué : {result.stderr.strip()}") return {} raw = result.stdout.strip() if not raw: _log(" INFO Aucune modification en attente sur le serveur.") return {} entries = json.loads(raw) except Exception as e: _log(f" WARN Impossible de récupérer depuis le serveur : {e}") return {} if not entries: _log(" INFO Aucune modification en attente sur le serveur.") return {} _log(f" {len(entries)} entrée(s) récupérée(s) du serveur") server_id_map: dict[tuple, int] = {} for entry in entries: ap = session.execute( select(Apprenti).where( Apprenti.nom == entry["nom"], Apprenti.prenom == entry["prenom"], Apprenti.classe == entry["classe"], ) ).scalar_one_or_none() if ap is None: _log( f" WARN apprenti introuvable localement : " f"{entry['nom']} {entry['prenom']} / {entry['classe']}" ) continue d = date.fromisoformat(entry["date"]) upsert_escada_pending(session, ap.id, d, entry["periode"], entry["action"]) key = (entry["nom"], entry["prenom"], entry["classe"], entry["date"], entry["periode"]) server_id_map[key] = entry["id"] session.commit() _log(f" {len(server_id_map)} entrée(s) fusionnée(s) dans la DB locale") return server_id_map def _clear_server_pending(server_ids: list[int]) -> None: """SSH → serveur pour supprimer les EscadaPending par IDs.""" if not server_ids: return ids_str = " ".join(str(i) for i in server_ids) cmd = ( f'ssh {_SSH_HOST} ' f'"cd {_SSH_REMOTE} && .venv/bin/python scripts/clear_pending.py {ids_str}"' ) try: result = subprocess.run( cmd, capture_output=True, text=True, timeout=30, shell=True ) if result.returncode != 0: _log(f" WARN SSH clear_pending échoué : {result.stderr.strip()}") else: _log(f" OK serveur nettoyé ({result.stdout.strip()})") except Exception as e: _log(f" WARN Impossible de nettoyer le serveur : {e}") # ── Commande principale ─────────────────────────────────────────────────────── def cmd_count(session: Session) -> None: """Affiche le nombre de changements en attente.""" n = session.execute(select(EscadaPending)).scalars().all() _log(f"PENDING_COUNT {len(n)}") for ep in n: ap = ep.apprenti _log(f" {ap.classe} | {ap.nom} {ap.prenom} | {ep.date} P{ep.periode} → {ep.action}") def cmd_push(session: Session, test_mode: bool = False, no_pull: bool = False, debug: bool = False) -> None: """Pousse tous les changements en attente vers Escada. 1. Pull depuis le serveur (sauf --no-pull). 2. Lecture des EscadaPending locaux. 3. Navigation Playwright + mise à jour des dropdowns. 4. Nettoyage côté serveur pour les entrées syncées avec succès. """ # ── 1. Pull depuis le serveur ───────────────────────────────────────────── server_id_map: dict[tuple, int] = {} if not no_pull: server_id_map = _pull_from_server(session) else: _log("INFO --no-pull : synchronisation serveur ignorée") # ── 2. Lecture des EscadaPending locaux ─────────────────────────────────── q = select(EscadaPending).join(Apprenti, EscadaPending.apprenti_id == Apprenti.id) if test_mode: _log("INFO Mode test : Poidevin Alexandre / EM-AU 1 uniquement") q = q.where(Apprenti.nom == "Poidevin", Apprenti.prenom == "Alexandre") pending = session.execute(q).scalars().all() if not pending: _log("INFO Aucun changement en attente.") return # Grouper par (classe, date) groups: dict[tuple, list] = {} for ep in pending: key = (ep.apprenti.classe, ep.date) groups.setdefault(key, []).append(ep) _log(f"TOTAL {len(groups)} groupe(s) à synchroniser ({len(pending)} changement(s))") pw, ctx, page = _launch_context() try: _cache_load() page.goto(CLASSES_URL) _ensure_logged_in(page) results = {"ok": [], "err": []} # EscadaPending IDs locaux syncés avec succès → pour retrouver les server_ids synced_eps: list[EscadaPending] = [] for i, ((classe, target_date), entries) in enumerate(sorted(groups.items()), 1): _log(f"PROGRESS {i}/{len(groups)} {classe} {target_date}") abs_page = _go_to_absence_page(page, classe) if abs_page is None: _log(f"ERR {classe} : page absences introuvable") for ep in entries: results["err"].append(f"{classe} {target_date} : navigation échouée") continue if not _set_date(abs_page, target_date): _log(f"ERR {classe} {target_date} : changement de date échoué") for ep in entries: results["err"].append(f"{classe} {target_date} : date non changée") continue synced_ids = [] synced_ep_objs = [] for ep in entries: ap = ep.apprenti ok = _set_dropdown(abs_page, ap.nom, ap.prenom, ep.periode, ep.action) if ok: synced_ids.append(ep.id) synced_ep_objs.append(ep) else: results["err"].append( f"{classe} {target_date} {ap.nom} {ap.prenom} P{ep.periode}" ) if not synced_ids: _log(f" SKIP {classe} {target_date} : aucun dropdown modifié") continue if _save(abs_page): for ep in synced_ep_objs: obj = session.get(EscadaPending, ep.id) if obj: session.delete(obj) # Marquer l'absence locale comme publiée sur Escada ab = session.execute( select(Absence).where( Absence.apprenti_id == ep.apprenti_id, Absence.date == ep.date, Absence.periode == ep.periode, ) ).scalar_one_or_none() if ab: ab.statut = "publiee_escada" session.commit() _log(f"OK {classe} {target_date} : {len(synced_ids)} changement(s) sauvegardé(s)") results["ok"].extend(synced_ids) synced_eps.extend(synced_ep_objs) else: _log(f"ERR {classe} {target_date} : sauvegarde échouée") results["err"].append(f"{classe} {target_date} : enregistrement échoué") _log(f"PUSH_DONE {json.dumps({'ok': len(results['ok']), 'err': results['err']}, ensure_ascii=False)}") # ── 4. Nettoyage côté serveur ───────────────────────────────────────── if server_id_map and synced_eps: server_ids_to_clear: list[int] = [] for ep in synced_eps: ap = ep.apprenti key = (ap.nom, ap.prenom, ap.classe, ep.date.isoformat(), ep.periode) srv_id = server_id_map.get(key) if srv_id is not None: server_ids_to_clear.append(srv_id) if server_ids_to_clear: _clear_server_pending(server_ids_to_clear) finally: ctx.close() pw.stop() # ── Point d'entrée ──────────────────────────────────────────────────────────── if __name__ == "__main__": import argparse ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--test", action="store_true", help="Limite au test Poidevin Alexandre") ap.add_argument("--count", action="store_true", help="Affiche les changements en attente") ap.add_argument("--no-pull", action="store_true", help="Ne pas récupérer les données du serveur avant push") ap.add_argument("--pull-only", action="store_true", help="Récupère depuis le serveur sans pousser vers Escada") ap.add_argument("--debug", action="store_true", help="Pause interactive après ouverture de la page absences") args = ap.parse_args() engine = init_db() Session_ = sessionmaker(bind=engine) with Session_() as sess: if args.count: cmd_count(sess) elif args.pull_only: _pull_from_server(sess) else: cmd_push(sess, test_mode=args.test, no_pull=args.no_pull, debug=args.debug)