- 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>
251 lines
9.1 KiB
Python
Executable file
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()
|