eptm_dashboard/scripts/push_notices.py
Julien Balet 6d1b7c8044 retenue: avis PDF + notices Escada + mapping profession
- nouvelle page /retenue : sélection apprenti, date retenue, date du
  problème, motif (3 cases mutex), branche (autocomplete + saisie libre
  depuis NotesExamen), remarque. Génération PDF basée sur le template
  AcroForm officiel, séparation des 3 widgets Date partagés en 3 champs
  distincts pour ne remplir que celui de la case cochée. Téléchargement
  ou envoi par email (3 destinataires).
- profession : nouveau champ ApprentiFiche.profession, dérivé du préfixe
  de classe via mapping configurable dans Paramètres
  ("AUTOMAT" → "Automaticien CFC" par défaut). Section dédiée avec
  classes orphelines détectées automatiquement.
- notices Escada : nouvelle table Notice (apprenti, titre, remarque,
  date, status). Checkbox "Ajouter automatiquement une notice sur
  Escada" sur /retenue qui crée une entrée pending. Bloc dédié sur
  /escada listant les pending, bouton "Pousser les notices" qui lance
  scripts/push_notices.py (Playwright : navigation Classes → Élèves →
  Notices → Ajouter, fill date / titre / remarque, vérification post-save,
  suppression DB si OK, marquage failed sinon). Nouveau task_kind "push_notices"
  dans le cron pour exécution planifiée.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:24:15 +02:00

251 lines
9.1 KiB
Python
Executable file

#!/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()