clean script
This commit is contained in:
parent
610e37d2a1
commit
38189deb0f
3 changed files with 371 additions and 33 deletions
331
ESCADAWEB_AUTH.md
Normal file
331
ESCADAWEB_AUTH.md
Normal 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
26
TODO.md
Normal 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
|
||||||
|
|
||||||
|
-
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue