eptm_dashboard/scripts/push_to_escada.py
2026-05-09 23:27:46 +02:00

467 lines
19 KiB
Python

"""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 <td> directs courts
// (les lignes-containers du grid DevExpress ont des <td> 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)