diff --git a/ESCADAWEB_AUTH.md b/ESCADAWEB_AUTH.md new file mode 100644 index 0000000..2bf4846 --- /dev/null +++ b/ESCADAWEB_AUTH.md @@ -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 + + + +``` + +```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 diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..323f19d --- /dev/null +++ b/TODO.md @@ -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 + +- diff --git a/eptm_dashboard/pages/accueil.py b/eptm_dashboard/pages/accueil.py index 56f841a..22f2cba 100644 --- a/eptm_dashboard/pages/accueil.py +++ b/eptm_dashboard/pages/accueil.py @@ -6,14 +6,13 @@ sys.path.insert(0, "/opt/eptm-dashboard") import reflex as rx from src.db import get_session, Apprenti, Absence from src.stats import kpis, alertes_quota_absences -from src.sanction_pdf import generate_avis_pdf -from src.logger import app_log -from src.user_access import get_allowed_classes, is_class_allowed +from src.user_access import get_allowed_classes from sqlalchemy import select, func from ..state import AuthState from ..sidebar import layout from .fiche import FicheState from .classe import ClasseState +from .sanction import SanctionState class AccueilState(AuthState): @@ -125,32 +124,14 @@ class AccueilState(AuthState): rx.redirect("/classe"), ] - # ── Téléchargement de l'avis de sanction ───────────────────────────────── - - def download_avis(self, apprenti_id: int, nom: str, prenom: str, classe: str): - # Garde-fou : refuse si la classe n'est pas autorisée - if not is_class_allowed(self.username, classe): - return rx.toast.error("Accès refusé pour cette classe.") - sess = get_session() - 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) - + 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.""" + label = f"{nom} {prenom} ({classe})" + return [ + FicheState.navigate_to(apprenti_id), + SanctionState.preload_apprenti(apprenti_id, label), + rx.redirect("/fiche"), + ] # ── UI ──────────────────────────────────────────────────────────────────────── @@ -195,13 +176,13 @@ def _sanction_tile(item: rx.Var) -> rx.Component: width="100%", align="center", gap="0.5rem", wrap="wrap", ), rx.button( - rx.icon("file-down", size=13), - "PDF avis de sanction", - on_click=AccueilState.download_avis( + rx.icon("file-plus", size=13), + "Créer l'avis de sanction", + on_click=AccueilState.open_sanction( item["id"], item["nom"], item["prenom"], item["classe"], ).stop_propagation, size="1", - color_scheme="gray", + color_scheme="red", variant="soft", ), spacing="2",