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",