#!/usr/bin/env python3 """Push des notices en attente vers Escadaweb. Workflow par notice : Classes → Élèves (de la classe) → Notices (de l'apprenti) → Ajouter → Date / Titre / Remarques → Mettre à jour → retour Élèves Réutilise les helpers de `sync_esacada.py` : - `_launch_context()` : navigateur headless avec profil persistant - `_ensure_logged_in(page)` : login SSO + 2FA + langue FR - `_go_to_students_page(page, class_name)` : ouvre ViewLernende d'une classe Sortie standard (parsée par `cron_tick.py` et la page /escada) : PUSH_NOTICES_DONE {"ok": N, "err": [...], "remaining": N} Behaviour DB : - status='pending' → tentative - succès → suppression de la Notice de la DB - échec → status='failed' + error_msg """ from __future__ import annotations import json import sys import traceback from pathlib import Path _ROOT = Path(__file__).resolve().parent.parent if str(_ROOT) not in sys.path: sys.path.insert(0, str(_ROOT)) from sqlalchemy import select # noqa: E402 from playwright.sync_api import Page # noqa: E402 from src.db import get_session, Notice # noqa: E402 from src.logger import app_log # noqa: E402 from scripts.sync_esacada import ( # noqa: E402 _launch_context, _ensure_logged_in, _go_to_students_page, _log, CLASSES_URL, ) def _fill_date(page: Page, date_str: str) -> None: """Remplit le champ Date du formulaire notice (DevExpress). On vise l'input texte directement (`id$="_DXEditor1_I"`), plus stable que le calendrier popup. """ date_input = page.locator("input[id$='_DXEditor1_I']").first date_input.wait_for(state="visible", timeout=10_000) date_input.click() # Sélectionne tout l'ancien contenu (date pré-remplie d'aujourd'hui) puis tape date_input.press("Control+A") date_input.type(date_str) date_input.press("Tab") # commit la valeur def _push_one_notice(page: Page, notice: Notice, students_url: str) -> tuple[bool, str]: """Pousse une notice. Renvoie (ok, error_message). Pré : `page` est sur la liste Élèves de la classe de l'apprenti. Post (succès ou échec) : `page` est de retour sur la liste Élèves. """ ap = notice.apprenti nom = ap.nom prenom = ap.prenom # 1. Trouver la ligne de l'apprenti et cliquer "Notices" try: # On filtre par nom ET prénom pour éviter les homonymes student_row = page.locator("tr").filter(has_text=nom).filter(has_text=prenom).first if not student_row.count(): return False, f"Apprenti '{nom} {prenom}' introuvable dans la grille" student_row.get_by_role("link", name="Notices").first.click() page.wait_for_load_state("networkidle", timeout=15_000) except Exception as e: return False, f"Navigation Notices : {e}" # 2. Cliquer "Ajouter" try: page.locator("a").filter(has_text="Ajouter").first.click() page.wait_for_timeout(800) except Exception as e: return False, f"Bouton Ajouter introuvable : {e}" # 3. Remplir Date / Titre / Remarques try: _fill_date(page, notice.date_event.strftime("%d.%m.%Y")) except Exception as e: return False, f"Remplissage date : {e}" try: page.get_by_role("textbox", name="Titre:").fill(notice.titre) except Exception as e: return False, f"Remplissage titre : {e}" if notice.remarque: try: page.get_by_role("textbox", name="Remarques:").fill(notice.remarque) except Exception: pass # Non bloquant # 4. Sauver try: page.get_by_role("link", name="Mettre à jour").click() page.wait_for_load_state("networkidle", timeout=15_000) page.wait_for_timeout(500) # laisse le temps à la grille de se rafraîchir except Exception as e: return False, f"Échec Mettre à jour : {e}" # 5. Vérifier que la notice est bien dans la grille de l'apprenti try: # On cherche le titre dans la grille des notices (max 30 chars pour éviter # les soucis de longueur / wrapping). needle = (notice.titre or "").strip()[:30] if needle: cell = page.locator("td").filter(has_text=needle).first cell.wait_for(state="visible", timeout=8_000) except Exception: # Vérification échouée — on retourne quand même à la liste Élèves # avant de signaler l'échec. try: page.goto(students_url) page.wait_for_load_state("networkidle", timeout=15_000) except Exception: pass return False, "Notice non retrouvée dans la grille après save (échec probable)" # 6. Retour à la liste Élèves de la même classe (option a : navigation directe) try: page.goto(students_url) page.wait_for_load_state("networkidle", timeout=15_000) except Exception as e: return False, f"Retour grille élèves : {e}" return True, "" def main(): sess = get_session() ok_count = 0 errors: list[str] = [] try: notices = sess.execute( select(Notice).where(Notice.status == "pending") ).scalars().all() app_log(f"[push_notices] {len(notices)} notice(s) en attente") if not notices: print( 'PUSH_NOTICES_DONE ' + json.dumps({"ok": 0, "err": [], "remaining": 0}), flush=True, ) return # Groupe par classe pour minimiser les navigations by_class: dict[str, list[Notice]] = {} for n in notices: by_class.setdefault(n.apprenti.classe, []).append(n) pw, ctx, page = _launch_context() try: # Navigation initiale vers ViewKlassen : redirige vers le login # si la session est expirée, et permet à _ensure_logged_in # de détecter le succès (ViewKlassen dans l'URL). page.goto(CLASSES_URL) _ensure_logged_in(page) for classe, class_notices in by_class.items(): _log(f"[push_notices] classe={classe} ({len(class_notices)} notices)") try: students_page = _go_to_students_page(page, classe) except Exception as e: students_page = None _log(f"[push_notices] erreur navigation {classe}: {e}") if not students_page: msg = f"classe '{classe}' introuvable sur Escada" for n in class_notices: n.status = "failed" n.error_msg = msg errors.append( f"id={n.id} ({n.apprenti.nom} {n.apprenti.prenom}): {msg}" ) sess.commit() continue students_url = students_page.url for notice in class_notices: label = f"{notice.apprenti.nom} {notice.apprenti.prenom}" try: ok, err = _push_one_notice(students_page, notice, students_url) if ok: sess.delete(notice) sess.commit() ok_count += 1 _log(f"[push_notices] OK id={notice.id} ({label})") else: notice.status = "failed" notice.error_msg = err[:500] sess.commit() errors.append(f"id={notice.id} ({label}): {err}") _log(f"[push_notices] FAIL id={notice.id}: {err}") # Si on est paumé, tenter un retour propre try: students_page.goto(students_url) students_page.wait_for_load_state( "networkidle", timeout=10_000 ) except Exception: break # impossible de recover, on passe à la classe suivante except Exception as e: notice.status = "failed" notice.error_msg = str(e)[:500] sess.commit() errors.append(f"id={notice.id} ({label}): {e}") _log(f"[push_notices] EX id={notice.id}: {e}\n{traceback.format_exc()}") finally: try: ctx.close() except Exception: pass try: pw.stop() except Exception: pass finally: # Compte les notices encore pending (n'incluant pas les "failed") try: remaining = sess.execute( select(Notice).where(Notice.status == "pending") ).all() remaining_count = len(remaining) except Exception: remaining_count = 0 sess.close() print( 'PUSH_NOTICES_DONE ' + json.dumps({ "ok": ok_count, "err": errors, "remaining": remaining_count, }, ensure_ascii=False), flush=True, ) if __name__ == "__main__": main()