clean script

This commit is contained in:
Julien Balet 2026-05-11 15:45:44 +02:00
parent 610e37d2a1
commit 38189deb0f
3 changed files with 371 additions and 33 deletions

331
ESCADAWEB_AUTH.md Normal file
View file

@ -0,0 +1,331 @@
# Escadaweb — Login + 2FA + Forçage langue (Playwright)
Documentation des briques réutilisables pour automatiser une session
Escadaweb (`escadaweb.vs.ch`) : login Keycloak, code TOTP, forçage du
français côté UI. Issu de [scripts/sync_esacada.py](scripts/sync_esacada.py).
## Pré-requis
```
pip install playwright pyotp
playwright install chromium
```
## Configuration (`data/settings.json`)
Les identifiants et secrets ne sont pas hardcodés — ils viennent du
fichier `data/settings.json` (édité depuis `/params` dans l'app).
```json
{
"escada_username": "prenom.nom@vs.ch",
"escada_password": "•••",
"totp_secret": "BASE32SECRET"
}
```
Sans `escada_username` / `escada_password` → le script affiche la
fenêtre Chromium et attend que tu te connectes à la main (utile pour le
1er run, où le profil persistant Chromium se construit).
Sans `totp_secret` → le code TOTP doit être saisi à la main. Pour le
récupérer la 1ère fois, scanne le QR proposé par Escadaweb avec
[Aegis/Authy/etc.] **ET** copie le secret BASE32 sous-jacent (souvent
caché derrière un lien "Saisie manuelle").
## URLs Escadaweb
```python
BASE_URL = "https://escadaweb.vs.ch"
LEHRPERSONEN_URL = f"{BASE_URL}/Lehrpersonen"
CLASSES_URL = f"{BASE_URL}/Lehrpersonen/ViewKlassen.aspx"
EINSTELLUNGEN_URL = f"{BASE_URL}/Lehrpersonen/Dialogs/DlgEinstellungen.aspx"
```
Le subpath `/Lehrpersonen/` est **obligatoire** — sans lui le SSO ne
sert pas la bonne app et le login échoue silencieusement.
## Profil Chromium persistant
Indispensable : permet de garder le cookie de session Keycloak entre
les runs, sinon tu refais 2FA à chaque fois.
```python
from pathlib import Path
from playwright.sync_api import sync_playwright
PROFILE_DIR = Path("data/browser_profile") # adapter au projet
def _launch_context():
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
pw = sync_playwright().start()
ctx = pw.chromium.launch_persistent_context(
str(PROFILE_DIR),
headless=True, # False pour debug visuel
args=["--start-maximized", "--disable-popup-blocking"],
accept_downloads=True,
)
page = ctx.pages[0] if ctx.pages else ctx.new_page()
return pw, ctx, page
```
Note : `launch_persistent_context` ouvre toujours un seul context ; on
récupère le premier page existant pour éviter d'avoir un onglet vide en
plus du nôtre.
## Code à coller
### Helpers settings
```python
import json
from pathlib import Path
_ROOT = Path(__file__).resolve().parent.parent # adapter au projet
def _load_settings() -> dict:
p = _ROOT / "data" / "settings.json"
if not p.exists():
return {}
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception:
return {}
def _load_totp_secret() -> str | None:
return _load_settings().get("totp_secret") or None
def _load_escada_creds() -> tuple[str, str]:
s = _load_settings()
return s.get("escada_username", ""), s.get("escada_password", "")
```
### 1) Remplir le formulaire Keycloak
Sélecteurs exacts du formulaire SSO (edusso.apps.vs.ch) :
```html
<input id="username" name="username" type="text">
<input id="password" name="password" type="password">
<input id="kc-login" name="login" type="submit">
```
```python
from playwright.sync_api import Page
def _try_fill_login(page: Page) -> bool:
username, password = _load_escada_creds()
if not username or not password:
return False
try:
page.wait_for_selector("input#username", state="visible", timeout=5_000)
page.wait_for_selector("input#password", state="visible", timeout=2_000)
page.locator("input#username").fill(username)
page.locator("input#password").fill(password)
try:
page.locator("input#kc-login").click(timeout=2_000)
except Exception:
page.locator("input#password").press("Enter")
return True
except Exception:
return False
```
### 2) Remplir le code TOTP
Le champ OTP de Keycloak est rendu **caché** par CSS avant la
vérification — un `page.fill()` Playwright standard échoue
("locator not visible"). On contourne via `page.evaluate()` (JS pur)
qui injecte la valeur et déclenche les events.
```python
import pyotp
def _try_fill_totp(page: Page, secret: str) -> bool:
try:
code = pyotp.TOTP(secret).now()
result = page.evaluate("""(code) => {
const inp = document.querySelector('#otp')
|| document.querySelector('[name="otp"]')
|| document.querySelector('[autocomplete="one-time-code"]')
|| document.querySelector('input[type="text"]:not([type="hidden"])');
if (!inp) return 'not_found';
inp.value = code;
inp.dispatchEvent(new Event('input', {bubbles: true}));
inp.dispatchEvent(new Event('change', {bubbles: true}));
return 'filled';
}""", code)
if result != "filled":
return False
submitted = page.evaluate("""() => {
const btn = document.querySelector('input[type="submit"]')
|| document.querySelector('button[type="submit"]');
if (btn) { btn.click(); return 'clicked'; }
const form = document.querySelector('form');
if (form) { form.submit(); return 'submitted'; }
return 'no_submit';
}""")
return submitted in ("clicked", "submitted")
except Exception:
return False
```
### 3) Forcer le français côté UI
Tout le parsing du HTML (libellés boutons, en-têtes de tableau, etc.)
suppose le français. Si l'utilisateur a la session en allemand, on
bascule via `DlgEinstellungen` :
```python
import sys
_lang_ok = False # mémo process : évite de re-naviguer à chaque appel
def _ensure_french_language(page: Page) -> None:
global _lang_ok
if _lang_ok:
return
page.goto(EINSTELLUNGEN_URL, wait_until="domcontentloaded", timeout=15_000)
try:
page.wait_for_load_state("networkidle", timeout=8_000)
except Exception:
pass
inp_loc = page.locator("#ContentPlaceHolderSite_DropDownList_sprache_I")
try:
inp_loc.wait_for(state="visible", timeout=8_000)
except Exception:
print("ERR [LANG] Dropdown langue introuvable — arrêt.")
sys.exit(1)
cur_val = inp_loc.input_value()
if cur_val != "français":
page.evaluate("""() => {
const inp = document.querySelector('#ContentPlaceHolderSite_DropDownList_sprache_I');
if (inp) {
inp.value = 'français';
ASPx.ETextChanged('ContentPlaceHolderSite_DropDownList_sprache');
}
}""")
page.locator("span.dx-vam:has-text('Speichern')").first.click()
try:
page.wait_for_load_state("networkidle", timeout=10_000)
except Exception:
pass
# Attendre que le grid soit prêt avant de rendre la main
try:
page.wait_for_selector(
"a[href*='ViewAbsenzenErweitert']", state="attached", timeout=30_000
)
except Exception:
pass
else:
page.goto(CLASSES_URL, wait_until="domcontentloaded", timeout=15_000)
_lang_ok = True
```
⚠️ Le bouton "Enregistrer" est resté libellé `Speichern` (allemand)
même après changement de langue côté UI — bug d'Escadaweb. Le sélecteur
ci-dessus matche ce label tel quel.
### 4) Orchestrateur
```python
import time
from playwright.sync_api import TimeoutError as PWTimeout, Error as PWError
def _ensure_logged_in(page: Page) -> None:
"""Si on est déjà sur ViewKlassen, juste vérifier la langue. Sinon,
boucle pendant 5 min : remplit login + TOTP automatiquement (si
secrets configurés), ou laisse l'utilisateur taper en manuel."""
if "ViewKlassen" in page.url:
_ensure_french_language(page)
return
_totp_secret = _load_totp_secret()
_username, _password = _load_escada_creds()
cur = page.url.lower()
if "login" not in cur and "logon" not in cur and "viewklassen" not in cur:
page.goto(LEHRPERSONEN_URL)
deadline = time.time() + 300 # 5 min
_last_login = 0.0
_last_totp = 0.0
while time.time() < deadline:
try:
if "ViewKlassen" in page.url:
_ensure_french_language(page)
return
if _username and _password and (time.time() - _last_login) > 5:
if _try_fill_login(page):
_last_login = time.time()
try:
page.wait_for_load_state("networkidle", timeout=8_000)
except (PWTimeout, PWError):
pass
if _totp_secret and (time.time() - _last_totp) > 5:
if _try_fill_totp(page, _totp_secret):
_last_totp = time.time()
try:
page.wait_for_url("**ViewKlassen**", timeout=10_000)
return
except (PWTimeout, PWError):
pass
page.wait_for_timeout(800)
except PWError:
if "ViewKlassen" in page.url:
_ensure_french_language(page)
return
print("ERR Délai de connexion dépassé (5 min).")
sys.exit(1)
```
## Exemple minimal
```python
pw, ctx, page = _launch_context()
try:
page.goto(CLASSES_URL)
_ensure_logged_in(page)
# → à ce point : session active, langue = français, on est sur
# /Lehrpersonen/ViewKlassen.aspx. Le reste du script peut tourner.
print("Connecté :", page.url)
finally:
ctx.close()
pw.stop()
```
## Pièges et points d'attention
- **Toujours `page.goto(CLASSES_URL)` avant `_ensure_logged_in()`**. Si
on attaque une autre page (par ex. `ViewLernende`) sans session, on
rebondit sur une URL Keycloak où les sélecteurs de login ne sont pas
les mêmes, et `_try_fill_login` échoue.
- **Throttling** : on attend 5 s entre deux tentatives de fill login /
TOTP pour ne pas spammer le serveur ni déclencher un anti-bot.
- **Pas de `time.sleep()` long** : utiliser `page.wait_for_timeout(ms)`
(Playwright) ou `wait_for_load_state` pour laisser le navigateur
traiter ses événements.
- **`headless=True`** convient pour la prod ; pour debug, passer
`headless=False` permet de voir ce qui se passe.
- **Le profil Chromium persistant** garde la session vivante des jours
(cookie SSO long terme). Si tu supprimes `data/browser_profile/`, tu
repars de zéro et le 2FA sera redemandé.
- **Multi-instance** : un profil persistant Chromium ne peut être
ouvert que par **un seul process à la fois**. Si tu paralléllises,
copier le profil ou utiliser `launch()` + cookies exportés.
## Fichiers de référence
- [scripts/sync_esacada.py](scripts/sync_esacada.py) — implémentation
d'origine (sync des absences)
- [scripts/push_notices.py](scripts/push_notices.py) — réutilise
`_ensure_logged_in` via import
- [scripts/push_to_escada.py](scripts/push_to_escada.py) — idem

26
TODO.md Normal file
View file

@ -0,0 +1,26 @@
# TODO — EPTM Dashboard
Liste des fonctionnalités à implémenter plus tard. Format libre — ajouter
en haut de la section concernée.
## Idées / fonctionnalités
- [ ] Ajouter sur le dashboard l'affichage des notes insuffisantes
- [ ] Afficher toutes les notes du BN
- [ ] Mettre à jour les MD
- [ ] Ajouter l'indication des compensation des désavantages
## Bugs connus
- [ ] Dans les logs, en mode Live, la fenêtre ne défile pas toute seule au fond. Mettre les dernières lignes en premier?
## Améliorations UX
- [ ] Faire un thème avec fond foncé
- [ ] Lancer une optimisation des toasts
- [ ] Changer la couleur du bouton Générer l'avais de sanction
## Notes / réflexions
-

View file

@ -6,14 +6,13 @@ sys.path.insert(0, "/opt/eptm-dashboard")
import reflex as rx import reflex as rx
from src.db import get_session, Apprenti, Absence from src.db import get_session, Apprenti, Absence
from src.stats import kpis, alertes_quota_absences from src.stats import kpis, alertes_quota_absences
from src.sanction_pdf import generate_avis_pdf from src.user_access import get_allowed_classes
from src.logger import app_log
from src.user_access import get_allowed_classes, is_class_allowed
from sqlalchemy import select, func from sqlalchemy import select, func
from ..state import AuthState from ..state import AuthState
from ..sidebar import layout from ..sidebar import layout
from .fiche import FicheState from .fiche import FicheState
from .classe import ClasseState from .classe import ClasseState
from .sanction import SanctionState
class AccueilState(AuthState): class AccueilState(AuthState):
@ -125,32 +124,14 @@ class AccueilState(AuthState):
rx.redirect("/classe"), rx.redirect("/classe"),
] ]
# ── Téléchargement de l'avis de sanction ───────────────────────────────── def open_sanction(self, apprenti_id: int, nom: str, prenom: str, classe: str):
"""Ouvre la fiche de l'apprenti et pré-remplit le modal d'avis de sanction."""
def download_avis(self, apprenti_id: int, nom: str, prenom: str, classe: str): label = f"{nom} {prenom} ({classe})"
# Garde-fou : refuse si la classe n'est pas autorisée return [
if not is_class_allowed(self.username, classe): FicheState.navigate_to(apprenti_id),
return rx.toast.error("Accès refusé pour cette classe.") SanctionState.preload_apprenti(apprenti_id, label),
sess = get_session() rx.redirect("/fiche"),
try: ]
data = generate_avis_pdf(
sess, apprenti_id, prof_name=self.name or self.username,
)
finally:
sess.close()
if data is None:
return rx.toast.error(
"Template introuvable. Vérifiez data/templates/GF_FO_Avis_de_sanction.pdf"
)
app_log(
f"[avis] {self.username or '?'} : avis de sanction généré pour "
f"{nom} {prenom} ({classe})"
)
safe_nom = "".join(c if c.isalnum() else "_" for c in nom)
safe_prenom = "".join(c if c.isalnum() else "_" for c in prenom)
filename = f"Avis_sanction_{safe_nom}_{safe_prenom}.pdf"
return rx.download(data=data, filename=filename)
# ── UI ──────────────────────────────────────────────────────────────────────── # ── UI ────────────────────────────────────────────────────────────────────────
@ -195,13 +176,13 @@ def _sanction_tile(item: rx.Var) -> rx.Component:
width="100%", align="center", gap="0.5rem", wrap="wrap", width="100%", align="center", gap="0.5rem", wrap="wrap",
), ),
rx.button( rx.button(
rx.icon("file-down", size=13), rx.icon("file-plus", size=13),
"PDF avis de sanction", "Créer l'avis de sanction",
on_click=AccueilState.download_avis( on_click=AccueilState.open_sanction(
item["id"], item["nom"], item["prenom"], item["classe"], item["id"], item["nom"], item["prenom"], item["classe"],
).stop_propagation, ).stop_propagation,
size="1", size="1",
color_scheme="gray", color_scheme="red",
variant="soft", variant="soft",
), ),
spacing="2", spacing="2",