diff --git a/assets/responsive.css b/assets/responsive.css index 83e7d29..82da637 100644 --- a/assets/responsive.css +++ b/assets/responsive.css @@ -70,3 +70,173 @@ img { min-width: 0; max-width: 100%; } + +/* ── Animations ─────────────────────────────────────────────────────────── */ + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideDown { + from { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes slideUp { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes scaleIn { + from { opacity: 0; transform: scale(0.96); } + to { opacity: 1; transform: scale(1); } +} + +.anim-fade { + animation: fadeIn 0.2s ease-out; +} + +.anim-slide-down { + animation: slideDown 0.22s ease-out; +} + +.anim-slide-up { + animation: slideUp 0.22s ease-out; +} + +.anim-scale-in { + animation: scaleIn 0.18s ease-out; +} + +/* ── Interactive hover lift ─────────────────────────────────────────────── */ + +.hover-lift { + transition: transform 0.15s ease, box-shadow 0.15s ease; +} +.hover-lift:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.55; } +} + +.anim-pulse { + animation: pulse 1.4s ease-in-out infinite; +} + +/* Subtle press effect on cards/buttons */ +.hover-press { + transition: transform 0.12s ease, background-color 0.15s ease; +} +.hover-press:hover { + background-color: rgba(0, 0, 0, 0.02); +} +.hover-press:active { + transform: scale(0.985); +} + +/* Smooth transition default for buttons and cards */ +.smooth-transition { + transition: all 0.15s ease; +} + +/* ── Documentation rendered markdown ───────────────────────────────────── */ + +.doc-content { line-height: 1.65; color: #1f2937; } +.doc-content h2 { + font-size: 1.4rem; + margin-top: 1.5rem; + margin-bottom: 0.75rem; + color: #1e293b; + font-weight: 700; +} +.doc-content h3 { + font-size: 1.15rem; + margin-top: 1.2rem; + margin-bottom: 0.5rem; + color: #334155; + font-weight: 600; +} +.doc-content p { margin: 0 0 0.75rem 0; } +.doc-content ul, .doc-content ol { + padding-left: 1.5rem; + margin: 0 0 0.75rem 0; +} +.doc-content li { margin-bottom: 0.25rem; } +.doc-content code { + background-color: var(--gray-3); + padding: 0.1rem 0.35rem; + border-radius: 4px; + font-size: 0.88em; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} +.doc-content pre { + background-color: #1e293b; + color: #e2e8f0; + padding: 0.85rem 1rem; + border-radius: 6px; + overflow-x: auto; + font-size: 0.85em; + margin: 0.75rem 0; + line-height: 1.5; +} +.doc-content pre code { + background-color: transparent; + padding: 0; + color: inherit; +} +.doc-content table { + border-collapse: collapse; + width: 100%; + margin: 0.75rem 0; + font-size: 0.93em; +} +.doc-content th, .doc-content td { + border: 1px solid var(--gray-6); + padding: 0.4rem 0.75rem; + text-align: left; +} +.doc-content th { + background-color: var(--gray-3); + font-weight: 600; +} +.doc-content blockquote { + border-left: 3px solid var(--red-7); + padding: 0.25rem 0.75rem; + color: var(--gray-11); + margin: 0.75rem 0; + background-color: var(--gray-2); + border-radius: 0 4px 4px 0; +} +.doc-content a { + color: var(--red-11); + text-decoration: none; +} +.doc-content a:hover { text-decoration: underline; } +.doc-content hr { + border: none; + border-top: 1px solid var(--gray-5); + margin: 1.25rem 0; +} + +/* Respect users who prefer reduced motion */ +@media (prefers-reduced-motion: reduce) { + .anim-fade, + .anim-slide-down, + .anim-slide-up, + .anim-scale-in { + animation: none; + } + .hover-lift, + .hover-press, + .smooth-transition { + transition: none; + } + .anim-pulse { + animation: none; + } +} diff --git a/data/class_href_cache.json b/data/class_href_cache.json index 364dd22..293a6b3 100644 --- a/data/class_href_cache.json +++ b/data/class_href_cache.json @@ -1,13 +1,14 @@ { - "AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=5aae2a26-4117-4722-aee2-0bd823fbcfc8", - "AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=23fb9e1d-adda-4399-accd-7a2f49e0cc93", - "AUTOMAT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=4587e860-dc1f-496a-84a6-dbf1f5d0a963", - "AUTOMAT 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=4d128fbe-f18c-4e3b-9ec2-beea462837be", "EM-AU 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=30066257-3dad-4b00-857b-9ab60a5d8581", "EM-AU 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=b9363b96-2d6e-4009-a495-f26c036cc088", "MONTAUT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=b51ec970-5bf4-4982-a05e-80546bb7421f", "MONTAUT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=aaacc343-c248-4f21-93f6-5d9e3079aa5d", "MONTAUT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=18c1ddbe-471f-44f6-bde6-8619adc3b767", "EM-AU 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=a4c4c187-920c-4c91-9620-7f153cf3738a", - "EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=7bddb158-278f-4258-934d-eddb7de88af3" + "EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=901ba9c8-5bb8-4170-a28e-ad1bdc8dccac", + "AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=553e320e-a5e6-484f-bbf3-989301a15449", + "AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=dd16c8ed-fde7-4aa6-bbce-9cba960b2863", + "AUTOMAT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=dcae994d-7c4e-4843-aa1c-0d44929b277c", + "AUTOMAT 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=77f8b395-5054-4414-97d1-96d9d1cba981", + "CFTI-AU 1A:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=1e858971-1c2f-4d8a-9a41-63c66716ee45" } \ No newline at end of file diff --git a/data/docs/01-overview.md b/data/docs/01-overview.md new file mode 100644 index 0000000..787231d --- /dev/null +++ b/data/docs/01-overview.md @@ -0,0 +1,42 @@ +# Vue d'ensemble + +EPTM Dashboard est une application de gestion des absences, notes et bulletins pour l'École professionnelle technique et des métiers (EPTM Sion / Monthey). Elle se synchronise avec **Escadaweb** (le système de notation cantonal) pour récupérer et pousser les données. + +## À quoi sert l'application + +- **Visualiser les absences** par apprenti ou par classe, avec calendrier mensuel et statistiques. +- **Excuser ou modifier** les absences manuellement (les changements sont mis en file d'attente avant d'être renvoyés à Escada). +- **Récupérer les bulletins de notes (BN)**, les notes d'examen et les fiches personnelles depuis Escada. +- **Automatiser** les imports/exports via des tâches planifiées (cron) avec notifications Telegram. +- **Tracer** qui a modifié quoi (audit log complet). + +## Modèle de données simplifié + +``` +Apprenti ── Absence (avec statut: a_traiter, excusee, ...) + ├── ApprentiFiche (données personnelles : adresse, entreprise, formateur) + ├── NotesBulletin (BN par semestre) + ├── NotesMatu (Matu pro) + └── NotesExamen (notes d'examen finales) + +EscadaPending : file d'attente des modifications locales à pousser vers Escada + (action ∈ {"E", "N", "clear"}) + +Import / ImportBN / ImportMatu : trace des imports PDF effectués + +CronJob : tâches planifiées (push, sync, push+sync) +``` + +## Architecture technique + +- **Frontend** : Reflex 0.9.2 (Python full-stack avec Radix Themes + Tailwind-friendly) +- **DB** : SQLite en mode WAL, à `data/absences.db` +- **Scraping Escada** : Selenium / Playwright, dans `scripts/sync_esacada.py` et `scripts/push_to_escada.py` +- **Parsing PDF** : `src/parser.py` (absences), `src/parser_bn.py` (bulletins), `src/parser_matu.py` (matu) +- **Conteneurisation** : Docker Compose, derrière nginx-proxy-manager +- **Cron** : OS cron déclenche `scripts/cron_tick.py` toutes les minutes, qui consulte la table `CronJob` + +## Rôles utilisateurs + +- **user** : accès aux pages Tableau de bord, Apprentis, Classes (lecture + édition d'absences). +- **admin** : tout ce qui précède + Escada, Cron, Logs, Utilisateurs, Paramètres. diff --git a/data/docs/02-sync-escada.md b/data/docs/02-sync-escada.md new file mode 100644 index 0000000..f670885 --- /dev/null +++ b/data/docs/02-sync-escada.md @@ -0,0 +1,82 @@ +# Synchronisation Escada (pull) + +La synchronisation depuis Escada télécharge les PDFs (absences, BN, notes, fiches) et les importe en base. C'est l'opération la plus complexe de l'application. + +## Page : `/escada` + +### Sélection des classes + +La liste des classes vient d'un cache disque (`data/esacada_classes.json`) rempli via le bouton **Actualiser**. Le rafraîchissement lance une session Selenium qui se connecte à Escadaweb et récupère la liste complète des classes. + +> Note : MP, MI et "Formation" sont **filtrées de l'affichage UI** mais conservées dans le cache (elles servent au matching Matu pro). + +### Options de synchronisation + +| Option | Effet | +|---------------------|----------------------------------------------------------------------| +| Absences | Télécharge les PDFs d'absences + parse + import | +| BN | Bulletins de notes + moyennes Matu (semestres complets) | +| Notes | Notes d'examens finales | +| Données apprentis | Fiches personnelles : adresse, entreprise, formateur | + +### Force réimportation complète + +La case "Forcer la réimportation complète" (signalée en jaune) **écrase tous les statuts d'absences** côté local par les valeurs du PDF, et **vide les pendings** des absences concernées. + +À utiliser uniquement quand on veut **reprendre l'état complet d'Escada** (par exemple après un test ou une corruption locale). + +Sans ce flag : +- Les absences modifiées localement et **pas encore poussées** (= en pending) sont **préservées**. +- Les nouvelles absences du PDF sont importées normalement. +- Les absences orphelines (présentes en DB mais absentes du PDF) sont supprimées. + +## Phases d'exécution + +### Phase 1 : Scraping (Selenium) + +`scripts/sync_esacada.py --sync-all CLASSE1 CLASSE2 ...` + +1. Selenium ouvre Escadaweb avec un profil persistant (`data/browser_profile/`) +2. Pour chaque classe sélectionnée : + - Télécharge le PDF d'absences + - Télécharge le PDF de bulletin + - Télécharge le PDF de notes + - Pour les apprentis Matu : télécharge le PDF Matu de la classe MP correspondante + - Scrape les fiches personnelles (vue ViewLernende) +3. À la fin, écrit `ALL_DONE` dans la sortie standard et `data/sync_all_done.json` (timestamp). + +### Phase 2 : Import en DB + +`scripts/run_imports.py` est lancé par le wrapper après réception du signal `ALL_DONE` : + +1. Parse chaque PDF d'absences → upsert des `Absence` (avec déduplication sur (apprenti, date, période)) +2. Parse les BN → insère `NotesBulletin` +3. Parse les notes → insère `NotesExamen` +4. Parse les fiches → upsert `ApprentiFiche` +5. Détecte les **orphelines** (absences en DB mais absentes du PDF dans la fenêtre temporelle) et les supprime (sauf si elles ont un pending, sans force). +6. Écrit `data/sync_last_result.json` avec les compteurs détaillés. + +## Pendings : modifications locales en attente + +Quand un utilisateur modifie une absence dans l'application (page Apprenti), une entrée est créée dans la table `EscadaPending` avec une action : + +- **`E`** : marquer comme excusée sur Escada +- **`N`** : marquer comme non excusée sur Escada +- **`clear`** : retirer l'absence sur Escada (= remettre l'apprenti présent) + +Ces pendings sont visibles sur la page `/escada` dans la section "Modifications en attente". + +## Cas particuliers gérés + +| Situation | Sans force | Avec force | +|----------------------------------------------------|---------------------------|---------------------------| +| Absence dans PDF + pending sur la même | Pending préservé | Pending écrasé | +| Absence dans PDF + DB sans pending | Mise à jour si différent | Mise à jour systématique | +| Absence dans DB, absente du PDF, sans pending | Supprimée (orpheline) | Supprimée | +| Absence dans DB, absente du PDF, avec pending=clear | Conservée (le clear gagne)| Supprimée | + +## Diagnostic + +- **Timeout > 15 min** : vérifier les logs `data/logs/operations.log`. Souvent un problème Selenium (captcha, session expirée). +- **Aucune classe récupérée** : token de session Escada expiré → relancer le rafraîchissement. +- **Logs détaillés** : page `/logs` affiche `operations.log` en temps réel. diff --git a/data/docs/03-push-escada.md b/data/docs/03-push-escada.md new file mode 100644 index 0000000..1b9c4e3 --- /dev/null +++ b/data/docs/03-push-escada.md @@ -0,0 +1,66 @@ +# Push vers Escada + +Le push envoie les modifications locales (table `EscadaPending`) vers Escadaweb via Selenium. + +## Page : `/escada` → "Pousser vers Escada" + +### Quand un pending est créé ? + +Chaque modification d'absence dans l'application crée ou met à jour une entrée dans `EscadaPending` : + +| Action utilisateur | Pending créé | +|------------------------------------------|---------------------| +| Marquer P3 comme excusée | `action=E` | +| Marquer P5 comme non excusée | `action=N` | +| Retirer une absence (présent) | `action=clear` | +| Excuse rapide d'une journée (page Fiche) | `action=E` × n | + +La contrainte d'unicité `(apprenti_id, date, periode)` garantit qu'une période a au plus un pending. Si on modifie deux fois la même période, le dernier pending écrase le précédent. + +## Phases du push + +### Phase 1 : Préparation + +`scripts/push_to_escada.py` : + +1. Lit toutes les entrées de `EscadaPending` +2. Groupe par classe pour minimiser les navigations Escada +3. Lance Selenium + +### Phase 2 : Exécution Selenium + +Pour chaque pending : + +1. Navigue jusqu'à la page d'absences de l'apprenti dans Escadaweb +2. Trouve la cellule (date × période) +3. Selon l'action : + - `E` : sélectionne "Excusée" dans le dropdown + - `N` : sélectionne "Non excusée" + - `clear` : remet à blanc (= apprenti présent) +4. Clique sur **Speichern** (Enregistrer) +5. Si OK → supprime l'entrée du `EscadaPending` +6. Si erreur → conserve l'entrée et la liste les erreurs dans `PUSH_DONE` + +### Phase 3 : Rapport + +Le script imprime une ligne `PUSH_DONE {"ok": N, "err": [...]}` à la fin. L'app la parse et affiche : +- Nombre d'envois OK +- Liste des erreurs (chaque erreur mentionne l'apprenti, la date et la période) + +## Que faire si un push échoue ? + +1. **Vérifier les logs** (`/logs`) — l'erreur exacte est tracée. +2. **Causes fréquentes** : + - Session Escada expirée → relancer un Actualiser sur la page Escada (re-login automatique) + - Apprenti avec un nom différent dans Escada → renommage à faire dans la DB ou côté Escada + - Page de notation verrouillée par un collègue (Escada utilise des locks pessimistes) +3. **Re-tenter** : les pendings restent en file d'attente, un nouveau push les retraitera. + +## Audit + +Chaque push manuel logue qui l'a déclenché : `[abs] {user} : Push Escada démarré par {username}`. Côté résultat : +- `Push terminé — ok:N erreurs:M` dans `operations.log` + +## Push automatique via cron + +La tâche planifiée de type `push` ou `push_then_sync` exécute le même script. Voir la section [Tâches planifiées](#). diff --git a/data/docs/04-edition-absences.md b/data/docs/04-edition-absences.md new file mode 100644 index 0000000..e083199 --- /dev/null +++ b/data/docs/04-edition-absences.md @@ -0,0 +1,76 @@ +# Édition des absences + +## Page : `/fiche` (Apprentis) + +### Sélectionner un apprenti + +Le sélecteur en haut de la page propose une recherche en direct : tape une partie du nom, prénom ou classe pour filtrer. + +**Raccourcis** : +- `/` ouvre directement le sélecteur +- `Entrée` sélectionne le premier résultat filtré +- `Échap` ferme la recherche + +### Calendrier mensuel + +Chaque cellule représente un jour du mois. Les couleurs indiquent l'état : + +| Couleur de fond | Signification | +|-----------------|------------------------------------------| +| Blanc | Aucune absence | +| Vert clair | Toutes les absences sont excusées | +| Rose | Au moins une absence non excusée | +| Bleu | Jour sélectionné en édition | +| Bleu pâle | Aujourd'hui | + +Les nombres dans les cellules : +- "2 ⚠️ 1" → 2 absences au total dont 1 non excusée +- "5" → 5 absences toutes excusées + +Cliquer sur un jour avec absences ouvre le panneau d'édition. + +### Panneau d'édition + +10 lignes (P1 à P10) avec un **segmented control** à 3 boutons : +- **Présent** (gris) — l'apprenti était là +- **E** (orange) — Excusée +- **N** (rouge) — Non excusée + +Un seul clic suffit. Le bouton **Enregistrer** sauve toutes les modifications de la journée d'un coup. Le panneau reste ouvert après l'enregistrement pour permettre un éventuel ajustement. + +### Excuse rapide ("Valider toutes les absences d'une journée") + +Sous le calendrier, un bandeau jaune liste les jours qui ont au moins une absence non encore traitée (statut `a_traiter`). Cliquer sur un de ces boutons excuse **toutes les absences à traiter de ce jour-là** en une seule action. + +## Page : `/classe` (Vue classe) + +### Sélection de classe + +Même principe que pour les apprentis : recherche en direct avec `/`, `Entrée`, `Échap`. + +### Cartes apprenti + +Chaque apprenti de la classe a une carte avec : +- Nom + lien vers sa fiche complète +- Badge "Sanction" si quota atteint (≥5 absences brutes en blocs) +- KPIs : Total / Excusées / Non excusées / Blocs d'absences +- Boutons de téléchargement PDF (Absences, Bulletin, Notes) +- Onglets BN / Notes d'examen pour visualiser + +## Audit des modifications + +Chaque modification d'absence est tracée dans `data/logs/operations.log` : + +``` +[14:32:01] [abs] prof.demo : Dupont Marc (1MAB1) — 04.02.2026 P6 : N → E +[14:32:01] [abs] prof.demo : Dupont Marc (1MAB1) — 04.02.2026 P6 : N → E +``` + +Visible aussi sur la page `/logs`. Le champ `updated_by` de la table `Absence` enregistre le dernier utilisateur ayant modifié. + +## Effet de chaque modification + +1. La table `Absence` est mise à jour (ou créée/supprimée) +2. Une entrée `EscadaPending` est ajoutée pour le push ultérieur vers Escada +3. Un toast de confirmation s'affiche +4. Une ligne est ajoutée à `operations.log` diff --git a/data/docs/05-cron.md b/data/docs/05-cron.md new file mode 100644 index 0000000..c064ebb --- /dev/null +++ b/data/docs/05-cron.md @@ -0,0 +1,78 @@ +# Tâches planifiées (cron) + +## Page : `/cron` (admin uniquement) + +Permet de créer des tâches automatiques de synchronisation et/ou de push. + +## Architecture + +``` +OS cron (toutes les minutes) + ↓ +docker exec eptm-dashboard-app-1 python scripts/cron_tick.py + ↓ +Lit la table CronJob → identifie les tâches à exécuter maintenant + ↓ +Pour chaque tâche due : + - Lance push_to_escada.py et/ou sync_esacada.py + run_imports.py + - Met à jour last_run_at, last_status, last_message + - Envoie une notification Telegram (selon notify_on) +``` + +Le tick s'exécute toutes les minutes via la crontab du host. Le timezone du conteneur est aligné sur `Europe/Zurich` pour correspondre aux horaires saisis dans l'UI. + +## Types de tâches + +| Type | Action | +|------------------|----------------------------------------------------------------------| +| `push` | Pousse les pendings vers Escada uniquement | +| `sync` | Récupère depuis Escada uniquement (selon options abs/BN/notes/fiches)| +| `push_then_sync` | Pousse les pendings, puis récupère | + +## Schedules + +Trois types de planning sont disponibles : + +- **Quotidien (daily)** : à une heure fixe chaque jour. Ex : `03:00`. +- **Hebdo (weekly)** : à une heure fixe certains jours. Ex : `MON,WED,FRI:08:30`. +- **Intervalle (interval)** : toutes les N minutes. Ex : `30` = toutes les 30 minutes. + +## Options de sync (pour task_kind=sync ou push_then_sync) + +- `sync_abs` : récupère les absences +- `sync_bn` : récupère les BN +- `sync_notes` : récupère les notes +- `sync_fiches` : récupère les données apprentis +- `force_abs` : forçage (cf. doc Sync Escada) +- `classes_json` : `"ALL"` ou liste de classes spécifiques + +## Notifications Telegram + +Pour chaque tâche, on configure : + +- `notify_on ∈ {"never", "always", "success", "failure"}` +- `notify_level ∈ {"normal", "detailed"}` +- `notify_chat_id` : pour cibler un chat différent du chat global (vide = utiliser le défaut) + +Voir la section [Notifications Telegram](#) pour les détails. + +## Activation / désactivation + +Le toggle dans la liste des tâches active ou désactive sans supprimer. Quand on **réactive** une tâche, son `last_run_at` est remis à `None` pour qu'elle se déclenche au prochain tick (sinon elle attendrait la fin de l'intervalle complet). + +## Logs persistants + +Chaque exécution écrit son log détaillé dans `/logs/cron/cron-{job_id}-{timestamp}.log`. Ce dossier est en bind mount Docker → les logs survivent à la recréation du conteneur. + +## Audit + +Toute modification (création/édition/activation/suppression) est tracée : + +``` +[09:14:22] [cron] prof.demo : création tâche 'Sync nocturne' (id=4) — push_then_sync / 03:00 / activée +[09:30:05] [cron] prof.demo : désactivation tâche 'Push 30min' (id=2) +``` + +## Bouton "Tester Telegram" + +Bas de page : envoie un message de test au `chat_id` global pour vérifier la config bot. diff --git a/data/docs/06-telegram.md b/data/docs/06-telegram.md new file mode 100644 index 0000000..9cd50f5 --- /dev/null +++ b/data/docs/06-telegram.md @@ -0,0 +1,66 @@ +# Notifications Telegram + +L'application peut envoyer des notifications sur les résultats des tâches planifiées et des opérations manuelles importantes. + +## Configuration + +La configuration vit dans `data/auth.yaml` (section dédiée) ou via variables d'environnement : + +```yaml +telegram: + bot_token: "1234567890:AABBCC..." + chat_id: "-4992234358" +``` + +`bot_token` s'obtient en parlant à [@BotFather](https://t.me/BotFather) sur Telegram : +1. `/newbot` +2. Choisir un nom et un identifiant +3. Récupérer le token + +`chat_id` se récupère : +- **Pour un chat 1-1** : envoyer un message au bot puis appeler l'API `https://api.telegram.org/bot/getUpdates`. L'ID est dans `result[].message.chat.id`. +- **Pour un groupe** : ajouter le bot au groupe, envoyer un message, puis même API. L'ID des groupes commence par `-`. + +## Niveaux de notification + +### Normal + +Message court, une ligne : + +``` +✅ Sync nocturne — terminée + 12 classes, 247 nouvelles absences, 0 erreur +``` + +### Détaillé + +Avec breakdown par classe et détails des orphelines : + +``` +✅ Sync nocturne — terminée + +Absences : + • 1MAB1: 23 nouv / 5 modif / 2 pending / 1 suppr + • 2EM1: 18 nouv / 12 modif / 0 pending / 0 suppr + ... + +Orphelines supprimées : + • Dupont Marc — 12.03.2026 P3 + ... + +Pendings ignorés (sans force) : 4 +``` + +## Chat ID par tâche + +Chaque tâche cron peut surcharger le `chat_id` global avec son propre `notify_chat_id`. Utile pour router certaines notifs vers un canal admin et d'autres vers un canal utilisateurs. + +## Ce qui est notifié manuellement + +Pas grand-chose pour l'instant — Telegram est principalement réservé aux **résultats des tâches cron**. Les opérations manuelles (sync, push) émettent uniquement des **toasts** dans l'app. + +## Diagnostic + +- **"Bouton Tester Telegram" répond une erreur 401** : token invalide ou révoqué. +- **"Erreur 400 Bad Request"** : `chat_id` incorrect ou bot pas membre du groupe. +- **Notification jamais reçue** : vérifier que le bot a bien été ajouté au groupe et qu'il y a au moins un message envoyé (sinon Telegram bloque). diff --git a/data/docs/07-auth.md b/data/docs/07-auth.md new file mode 100644 index 0000000..df3208f --- /dev/null +++ b/data/docs/07-auth.md @@ -0,0 +1,71 @@ +# Authentification & rôles + +## Login + +Page : `/login` + +L'identifiant + mot de passe sont vérifiés contre `data/auth.yaml` (mot de passe haché avec **bcrypt**). + +Format `auth.yaml` : + +```yaml +credentials: + usernames: + prof.demo: + password: "$2b$12$..." + name: "Prof Demo" + role: "admin" # ou "user" + avatar_url: "/avatars/prof_demo.png" + totp_secret: "ABCD1234..." # rempli automatiquement à la 1ère 2FA +``` + +## 2FA TOTP (obligatoire) + +À la **première connexion** d'un nouvel utilisateur : +1. Login + mot de passe corrects +2. L'app génère un secret TOTP et affiche un **QR code** +3. L'utilisateur scanne avec Google Authenticator / Authy / 1Password / etc. +4. Il saisit le code à 6 chiffres pour confirmer +5. Le secret est sauvé dans `auth.yaml` + +Aux connexions suivantes : +1. Login + mot de passe → demande directe du code TOTP +2. Code à 6 chiffres → connexion finalisée + +Le code est valide ±30s (paramètre `valid_window=1` de `pyotp`) pour tolérer la dérive d'horloge. + +## Session persistante + +L'authentification est stockée dans le **`localStorage` du navigateur** (champs `username`, `name`, `role`, `photo_url`). La session survit aux rechargements de page et aux redémarrages du conteneur. + +À chaque page protégée, `AuthState.check_auth` re-vérifie que l'utilisateur existe toujours dans `auth.yaml` ; sinon, redirection forcée vers `/login`. + +## Rôles + +| Page | user | admin | +|-------------------|------|-------| +| `/accueil` | ✅ | ✅ | +| `/fiche` | ✅ | ✅ | +| `/classe` | ✅ | ✅ | +| `/doc` | ✅ | ✅ | +| `/escada` | ❌ | ✅ | +| `/cron` | ❌ | ✅ | +| `/logs` | ❌ | ✅ | +| `/users` | ❌ | ✅ | +| `/params` | ❌ | ✅ | + +## Gestion des utilisateurs + +Page `/users` (admin) : +- Créer / supprimer des utilisateurs +- Changer le rôle +- Réinitialiser le 2FA (efface `totp_secret` → forcera une nouvelle config au prochain login) +- Définir / changer un avatar + +## Logout + +Bouton "Déconnexion" en bas de la sidebar. Vide le `localStorage` et redirige vers `/login`. + +## Stockage des avatars + +Les fichiers sont sous `assets/avatars/`. Le chemin est référencé dans `auth.yaml` via `avatar_url`. Si `avatar_url` est vide, l'app affiche les initiales de `name`. diff --git a/data/docs/08-logs.md b/data/docs/08-logs.md new file mode 100644 index 0000000..8d3d296 --- /dev/null +++ b/data/docs/08-logs.md @@ -0,0 +1,85 @@ +# Logs & audit + +## Page : `/logs` (admin) + +Affiche en temps réel le contenu de `data/logs/operations.log` avec un mode **prod** (lignes principales) et un mode **debug** (lignes indentées détaillées). + +## Format des logs + +``` +[14:32:01] [abs] prof.demo : Dupont Marc (1MAB1) — 04.02.2026 P6 : N → E +[14:32:08] Sync Escada démarrée par prof.demo — 3 classe(s) [abs/forcé, BN] : 1MAB1, 2EM1, 1MAB2 +[14:32:15] [sync] Connexion Escadaweb OK +[14:34:42] [sync] Téléchargement absences 1MAB1 (4.2 MB) +[14:35:01] ALL_DONE confirme — phase import +[14:35:30] Résultats chargés — sync terminée OK +``` + +- Lignes **non indentées** : événements importants (sync démarrée/terminée, modif d'absence, action admin) +- Lignes **indentées** (avec deux espaces) : sortie verbeuse des sous-processus Selenium / parser PDF — visibles uniquement en mode debug + +## Catégories d'événements tracés + +### Modifications de données + +| Préfixe | Événement | +|-----------|----------------------------------------| +| `[abs]` | Modification d'absence (qui, quel apprenti, quelle période, ancien → nouveau type) | +| `[cron]` | Création / édition / activation / suppression d'une tâche planifiée | + +### Opérations Escada manuelles + +- `Rafraîchissement liste classes Escada par {user}` +- `Sync Escada démarrée par {user} — N classe(s) [options] : ...` +- `Push Escada démarré par {user}` +- `Push terminé — ok:N erreurs:M` + +### Sous-processus + +- `[refresh]`, `[sync]`, `[push]` : sortie standard du subprocess Selenium correspondant + +### Cron automatique + +- `[run_imports] démarré` +- `[run_imports] abs CLASSE: N nouv / N modif / ...` +- `[run_imports] terminé OK` + +## Localisation physique + +- **App** : `/opt/eptm-dashboard/data/logs/operations.log` (ligne par ligne, append-only) +- **Cron jobs** : `/logs/cron/cron-{job_id}-{timestamp}.log` (un fichier par exécution, persistant via bind mount Docker) + +## Rotation + +Pas de rotation automatique pour l'instant. À long terme : +```bash +# Garder les 30 derniers jours +find data/logs/ -name "operations.log" -mtime +30 -delete +``` + +## Filtrer / chercher + +Sur la page `/logs` : +- Recherche texte simple +- Filtre par date +- Toggle prod/debug + +En CLI : + +```bash +docker exec eptm-dashboard-app-1 grep -i "force" data/logs/operations.log | tail -50 +docker exec eptm-dashboard-app-1 grep "\[abs\] prof.demo" data/logs/operations.log +``` + +## Champ `updated_by` côté DB + +Indépendamment de `operations.log`, chaque table sensible stocke directement qui a fait quoi : + +| Table | Champ | +|--------------|--------------------------------| +| `Absence` | `updated_by`, `updated_at` | +| `Import` | `imported_by` | +| `ImportBN` | `imported_by` | +| `ImportMatu` | `imported_by` | + +Permet de retrouver l'historique même si le log fichier est supprimé. diff --git a/data/docs/09-shortcuts.md b/data/docs/09-shortcuts.md new file mode 100644 index 0000000..ae2896e --- /dev/null +++ b/data/docs/09-shortcuts.md @@ -0,0 +1,24 @@ +# Raccourcis clavier + +## Globaux + +| Touche | Action | +|--------|-----------------------------------------------------------------| +| `/` | Ouvre le sélecteur de recherche de la page courante (apprenti sur `/fiche`, classe sur `/classe`). Désactivé si le focus est dans un champ de saisie. | + +## Dans les sélecteurs (popovers de recherche) + +| Touche | Action | +|---------|---------------------------------------------------| +| `Entrée`| Sélectionne le 1er résultat de la liste filtrée | +| `Échap` | Ferme le sélecteur et vide le filtre | + +## Édition d'absences (panneau d'édition) + +Pas de raccourci spécifique pour l'instant. Pour fermer le panneau : clic sur le `×` ou bouton "Annuler". + +## Pages de connexion + +| Touche | Action | +|---------|---------------------------------------------------| +| `Entrée`| Soumet le formulaire (login ou code TOTP) | diff --git a/data/docs/10-faq.md b/data/docs/10-faq.md new file mode 100644 index 0000000..564eee0 --- /dev/null +++ b/data/docs/10-faq.md @@ -0,0 +1,112 @@ +# FAQ / Dépannage + +## Synchronisation Escada + +### "Import timeout — vérifiez les logs (> 15min)" + +Le subprocess Selenium n'a pas répondu dans le temps imparti. Causes possibles : +- Escadaweb répond très lentement (en pic de charge) +- Captcha / re-login imposé par Escada +- Container Docker en surcharge + +**Que faire** : +1. Aller dans `/logs` et chercher le dernier `[sync]` actif +2. Si Selenium est bloqué sur un écran de login : lancer un Actualiser des classes (re-login) +3. Si gros volume de classes : lancer la sync en plusieurs lots de 5-6 classes + +### "Aucune classe récupérée" + +Le scraping Selenium a échoué — souvent token de session expiré. + +**Que faire** : recliquer sur "Actualiser" (ça force un re-login propre). + +### "Le push échoue toujours sur le même apprenti" + +Possibles causes : +- L'apprenti existe en local mais pas (ou plus) sur Escada → le pending est obsolète, à supprimer +- Le nom diffère entre local et Escada (ex: prénom composé partiel) +- La page Escada de cet apprenti est verrouillée par un autre éditeur (lock pessimiste Escada) + +**Que faire** : +1. Vérifier les logs pour le message d'erreur exact +2. Si l'apprenti n'existe plus : supprimer le pending manuellement en DB +3. Sinon : retenter plus tard + +### "L'option 'Forcer la réimportation complète' est en rouge — c'est dangereux ?" + +Pas dangereux mais **destructif** : +- Tous les pendings concernés sont écrasés (les modifs locales pas encore poussées sont perdues) +- Tous les statuts d'absences sont remis à ce qu'Escada dit + +À utiliser **uniquement** quand on veut sciemment reprendre l'état complet d'Escada. + +## Tâches planifiées (cron) + +### "J'ai créé une tâche, elle ne se déclenche pas" + +Vérifier dans l'ordre : + +1. La tâche est-elle **activée** ? (toggle vert) +2. L'horaire est-il dans le bon fuseau horaire ? (l'app fonctionne en `Europe/Zurich`) +3. La crontab du host appelle-t-elle bien `cron_tick.py` ? + + ```bash + crontab -l | grep cron_tick + # Doit avoir : * * * * * docker exec eptm-dashboard-app-1 python scripts/cron_tick.py + ``` + +4. Regarder le log : `docker exec eptm-dashboard-app-1 cat /logs/cron/` + +### "La tâche ne notifie pas sur Telegram" + +- `notify_on` doit être `always`, `success` ou `failure` (pas `never`) +- Le `bot_token` et `chat_id` doivent être valides → tester via le bouton "Tester Telegram" + +## Authentification + +### "J'ai perdu mon téléphone avec mon code 2FA" + +Un admin peut réinitialiser le 2FA via la page `/users` : "Réinitialiser 2FA". Au prochain login, l'utilisateur reverra le QR code. + +### "Mon utilisateur est bloqué après plusieurs tentatives" + +Pas de blocage automatique pour le moment. Si on veut en ajouter un : voir `state.py:handle_login`. + +## Données + +### "Les BN affichent des trous (cellules vides)" + +C'est normal : un apprenti peut avoir commencé sa formation au S2 ou S3 → les premiers semestres restent vides. L'extraction depuis le PDF Escada respecte ces trous. + +### "Les notes Matu n'apparaissent pas" + +Pré-requis : l'apprenti est dans une classe MP/MI correspondante. Le matching se fait via la classe MP1, MP2, etc. La sync Matu cherche le PDF correspondant **si la case "BN" est cochée**. + +## Performance + +### "L'app rame quand je change de classe avec beaucoup d'apprentis" + +Le `_reload` reconstruit les tableaux HTML BN/Notes pour chaque apprenti — peut prendre quelques secondes pour 25+ apprentis. Un skeleton s'affiche pendant le chargement pour donner du feedback visuel. + +Si vraiment lent : envisager une mise en cache des HTML rendus dans la DB (à voir avec un dev). + +## Conteneur Docker + +### "Le conteneur consomme 100% CPU à l'idle" + +Bug historique lié au hot-reload qui détectait les fichiers WAL/SHM de SQLite comme des modifications source. Corrigé via `REFLEX_HOT_RELOAD_EXCLUDE_PATHS=/app/data` dans le docker-compose. + +Si ça revient : vérifier que cette variable est bien présente dans `docker-compose.dev.yml`. + +### "Comment redémarrer proprement" + +```bash +cd /opt/eptm-dashboard +docker compose -f docker-compose.dev.yml restart app +``` + +### "Comment voir les logs du serveur Reflex" + +```bash +docker logs -f eptm-dashboard-app-1 +``` diff --git a/eptm_dashboard/components.py b/eptm_dashboard/components.py new file mode 100644 index 0000000..cea04d3 --- /dev/null +++ b/eptm_dashboard/components.py @@ -0,0 +1,135 @@ +"""Composants UI réutilisables (empty states, skeletons, etc.).""" + +from __future__ import annotations + +import os +import re +from pathlib import Path + +import reflex as rx + +DATA_DIR = Path(os.getenv("DATA_DIR", str(Path(__file__).resolve().parent.parent / "data"))) +DOCS_DIR = DATA_DIR / "docs" +_RE_DOC_TITLE = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE) + + +def scan_docs() -> list[dict]: + """Liste les .md du dossier docs, triés par nom de fichier (préfixe numérique). + + Retourne une liste de dicts {slug, title, path}. + """ + if not DOCS_DIR.exists(): + return [] + out: list[dict] = [] + for p in sorted(DOCS_DIR.glob("*.md")): + try: + text = p.read_text(encoding="utf-8") + except Exception: + continue + m = _RE_DOC_TITLE.search(text) + title = m.group(1) if m else p.stem + out.append({"slug": p.stem, "title": title, "path": str(p)}) + return out + + +def empty_state( + icon: str, + title: str, + description: str = "", + action_label: str = "", + action_href: str = "", + icon_color: str = "var(--gray-9)", + bg_color: str = "white", +) -> rx.Component: + """Empty state stylé avec icône, titre, description et action optionnelle.""" + children = [ + rx.box( + rx.icon(icon, size=42, color=icon_color), + display="flex", + align_items="center", + justify_content="center", + width="64px", + height="64px", + border_radius="9999px", + background_color="var(--gray-3)", + margin_bottom="0.75rem", + ), + rx.text(title, size="4", weight="bold", color="#37474f"), + ] + if description: + children.append( + rx.text( + description, + size="2", + color="#666", + text_align="center", + max_width="380px", + ) + ) + if action_label and action_href: + children.append( + rx.link( + rx.button(action_label, color_scheme="red", size="2"), + href=action_href, + margin_top="0.5rem", + ) + ) + return rx.vstack( + *children, + spacing="2", + align="center", + padding="2.5rem 1.5rem", + background_color=bg_color, + border="1px dashed var(--gray-6)", + border_radius="10px", + width="100%", + class_name="anim-fade", + ) + + +def skeleton_card(height: str = "80px") -> rx.Component: + """Carte placeholder pour chargement.""" + return rx.box( + height=height, + background_color="var(--gray-3)", + border_radius="8px", + width="100%", + class_name="anim-pulse", + ) + + +def skeleton_line(width: str = "100%", height: str = "12px") -> rx.Component: + """Ligne placeholder.""" + return rx.box( + background_color="var(--gray-4)", + border_radius="4px", + width=width, + height=height, + class_name="anim-pulse", + ) + + +def skeleton_apprenti_card() -> rx.Component: + """Skeleton pour une carte apprenti (page classe).""" + return rx.box( + rx.vstack( + skeleton_line(width="40%", height="18px"), + rx.flex( + skeleton_card(height="58px"), + skeleton_card(height="58px"), + skeleton_card(height="58px"), + skeleton_card(height="58px"), + gap="0.5rem", + width="100%", + flex_wrap="wrap", + ), + skeleton_line(width="60%"), + spacing="3", + width="100%", + ), + padding="1.25rem", + background_color="white", + border_radius="8px", + border="1px solid #e0e0e0", + width="100%", + ) diff --git a/eptm_dashboard/eptm_dashboard.py b/eptm_dashboard/eptm_dashboard.py index 9d9b115..970c4a1 100644 --- a/eptm_dashboard/eptm_dashboard.py +++ b/eptm_dashboard/eptm_dashboard.py @@ -9,6 +9,7 @@ from .pages.logs import logs_page, LogsState from .pages.cron import cron_page, CronState from .pages.users import users_page, UsersState from .pages.params import params_page, ParamsState +from .pages.doc import doc_page, DocState TITLE = "EPTM Dashboard" @@ -34,3 +35,4 @@ app.add_page(logs_page, route="/logs", on_load=[AuthState.check_auth, app.add_page(cron_page, route="/cron", on_load=[AuthState.check_auth, CronState.load_data], title=TITLE) app.add_page(users_page, route="/users", on_load=[AuthState.check_auth, UsersState.load_data], title=TITLE) app.add_page(params_page, route="/params", on_load=[AuthState.check_auth, ParamsState.load_data], title=TITLE) +app.add_page(doc_page, route="/doc", on_load=[AuthState.check_auth, DocState.load_data], title=TITLE) diff --git a/eptm_dashboard/pages/accueil.py b/eptm_dashboard/pages/accueil.py index aad777d..ddf2a57 100644 --- a/eptm_dashboard/pages/accueil.py +++ b/eptm_dashboard/pages/accueil.py @@ -54,6 +54,7 @@ def _kpi_card(label: str, value: rx.Var) -> rx.Component: flex="1", min_width="80px", width="100%", + class_name="hover-lift", ) @@ -90,6 +91,7 @@ def _sanction_card(s: dict) -> rx.Component: padding="0.625rem 0.875rem", margin_y="0.15rem", width="100%", + class_name="hover-lift", ) diff --git a/eptm_dashboard/pages/classe.py b/eptm_dashboard/pages/classe.py index 282f5c3..57d0f50 100644 --- a/eptm_dashboard/pages/classe.py +++ b/eptm_dashboard/pages/classe.py @@ -10,12 +10,13 @@ DATA_DIR = Path(os.getenv("DATA_DIR", "data")) from ..state import AuthState from ..sidebar import layout +from ..components import empty_state, skeleton_apprenti_card from src.db import ( get_session, Apprenti, Absence, NotesBulletin, NotesMatu, NotesExamen, ImportBN, ImportMatu, ) from src.stats import nb_blocs_absences, synthese_classe -from src.parser_bn import sem_short_label +from src.parser_bn import sem_short_label, sem_year_only QUOTA = 5 @@ -60,7 +61,12 @@ def _bn_html_table(d: dict, sem_labels: list, groups_order: list) -> str: for i in range(N): raw = sem_labels[i] if i < len(sem_labels) else None short = sem_short_label(raw, i) - header += f'{short}' + year = sem_year_only(raw) + year_html = ( + f'
{year}
' + if year else "" + ) + header += f'
{short}
{year_html}' def _moy_sem_row(label, gd, label_style, sep=False): s = SEP if sep else "" @@ -355,6 +361,7 @@ class ClasseState(AuthState): apprentis_data: list[dict] = [] class_search: str = "" class_select_open: bool = False + is_loading_apprentis: bool = False @rx.var def filtered_classes(self) -> list[str]: @@ -388,7 +395,11 @@ class ClasseState(AuthState): self.selected_class = classe self.class_select_open = False self.class_search = "" + self.is_loading_apprentis = True + self.apprentis_data = [] + yield # pousse l'état "loading" au client avant le _reload bloquant self._reload() + self.is_loading_apprentis = False def set_class_search(self, v: str): self.class_search = v @@ -398,6 +409,16 @@ class ClasseState(AuthState): if not v: self.class_search = "" + def class_search_keydown(self, key: str): + """Enter → sélectionne le 1er résultat. Esc → ferme.""" + if key == "Enter": + results = self.filtered_classes + if results: + return ClasseState.set_class(results[0]) + elif key == "Escape": + self.class_select_open = False + self.class_search = "" + def download_abs_pdf(self, apprenti_id: int): sess = get_session() apprenti = sess.get(Apprenti, apprenti_id) @@ -595,6 +616,7 @@ def _classe_searchable_select() -> rx.Component: background_color="white", cursor="pointer", width="100%", + custom_attrs={"data-shortcut": "class-search"}, ), ), rx.popover.content( @@ -603,6 +625,7 @@ def _classe_searchable_select() -> rx.Component: placeholder="Rechercher une classe…", value=ClasseState.class_search, on_change=ClasseState.set_class_search, + on_key_down=ClasseState.class_search_keydown, size="2", width="100%", auto_focus=True, @@ -779,6 +802,7 @@ def _apprenti_card(item) -> rx.Component: border="1px solid #e0e0e0", width="100%", overflow="hidden", + class_name="hover-lift anim-fade", ) @@ -793,31 +817,40 @@ def classe_page() -> rx.Component: _classe_searchable_select(), rx.cond( - ClasseState.apprentis_data.length() > 0, + ClasseState.is_loading_apprentis, rx.vstack( - rx.foreach(ClasseState.apprentis_data, _apprenti_card), + skeleton_apprenti_card(), + skeleton_apprenti_card(), + skeleton_apprenti_card(), spacing="4", width="100%", ), - rx.text( - "Aucun apprenti dans cette classe.", - size="2", color="#666", + rx.cond( + ClasseState.apprentis_data.length() > 0, + rx.vstack( + rx.foreach(ClasseState.apprentis_data, _apprenti_card), + spacing="4", + width="100%", + ), + empty_state( + icon="user-x", + title="Aucun apprenti dans cette classe", + description="Sélectionne une autre classe ou lance une synchronisation.", + action_label="Synchroniser", + action_href="/escada", + ), ), ), spacing="4", width="100%", ), - rx.box( - rx.text( - "Aucune donnee. Importez d'abord un PDF.", - size="2", color="#666", - ), - padding="1rem", - background_color="#e3f2fd", - border_radius="6px", - border="1px solid #90caf9", - width="100%", + empty_state( + icon="database", + title="Aucune donnée", + description="Aucune classe n'est encore en base. Lance une synchronisation depuis Escadaweb.", + action_label="Lancer un import", + action_href="/escada", ), ), diff --git a/eptm_dashboard/pages/cron.py b/eptm_dashboard/pages/cron.py index c1646f6..3332b25 100644 --- a/eptm_dashboard/pages/cron.py +++ b/eptm_dashboard/pages/cron.py @@ -22,6 +22,7 @@ from src.logger import app_log # noqa: E402 from ..state import AuthState from ..sidebar import layout +from ..components import empty_state _DAY_NAMES = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"] @@ -400,18 +401,25 @@ class CronState(AuthState): f"{job.task_kind} / {schedule_value} / " f"{'activée' if job.enabled else 'désactivée'}" ) + job_name = job.name self.save_ok = True self._refresh() self.edit_open = False + return rx.toast.success( + f"Tâche '{job_name}' " + + ("créée" if is_new else "enregistrée") + ) except Exception as e: sess.rollback() self.save_error = f"Erreur DB : {e}" + return rx.toast.error(f"Erreur lors de l'enregistrement : {e}") finally: sess.close() def toggle_enabled(self, job_id: int): sess = get_session() user = self.username or "?" + toast_msg = None try: job = sess.get(CronJob, job_id) if job: @@ -428,13 +436,20 @@ class CronState(AuthState): f"{'activation' if job.enabled else 'désactivation'} " f"tâche '{job.name}' (id={job.id})" ) + toast_msg = ( + f"Tâche '{job.name}' " + + ("activée" if job.enabled else "désactivée") + ) self._refresh() finally: sess.close() + if toast_msg: + return rx.toast.success(toast_msg) def delete_job(self, job_id: int): sess = get_session() user = self.username or "?" + toast_msg = None try: job = sess.get(CronJob, job_id) if job: @@ -442,9 +457,12 @@ class CronState(AuthState): sess.delete(job) sess.commit() app_log(f"[cron] {user} : suppression tâche '{job_name}' (id={job_id})") + toast_msg = f"Tâche '{job_name}' supprimée" self._refresh() finally: sess.close() + if toast_msg: + return rx.toast.success(toast_msg) def test_telegram(self): ok, msg = test_telegram() @@ -885,16 +903,10 @@ def cron_page() -> rx.Component: ), rx.cond( CronState.jobs.length() == 0, - rx.box( - rx.text( - "Aucun job planifié. Clique sur \"Nouveau job\" pour en créer un.", - size="2", color="#666", - ), - padding="1rem", - background_color="#e3f2fd", - border_radius="6px", - border="1px solid #90caf9", - width="100%", + empty_state( + icon="clock", + title="Aucune tâche planifiée", + description="Crée une tâche pour automatiser la synchronisation Escada (push, pull ou les deux).", ), rx.vstack( rx.foreach(CronState.jobs, _job_row), diff --git a/eptm_dashboard/pages/doc.py b/eptm_dashboard/pages/doc.py new file mode 100644 index 0000000..41d642f --- /dev/null +++ b/eptm_dashboard/pages/doc.py @@ -0,0 +1,99 @@ +"""Page /doc — documentation interne (markdown rendu en HTML côté Python).""" + +from __future__ import annotations + +import os +import re +from pathlib import Path + +import markdown as md +import reflex as rx + +from ..state import AuthState +from ..sidebar import layout +from ..components import scan_docs + +_RE_TITLE = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE) + +# Une seule instance de Markdown réutilisée (extensions activées). +_MD = md.Markdown(extensions=["tables", "fenced_code", "attr_list", "sane_lists"]) + + +def _render_md(text: str) -> str: + _MD.reset() + return _MD.convert(text) + + +class DocState(AuthState): + sections: list[dict] = [] + selected_slug: str = "" + selected_title: str = "" + selected_html: str = "" + + def load_data(self): + if not self.authenticated: + return rx.redirect("/login") + self.sections = scan_docs() + if not self.sections: + self.selected_slug = "" + self.selected_title = "" + self.selected_html = "

Aucune documentation disponible. Ajoutez des fichiers .md dans data/docs/.

" + return + slugs = [s["slug"] for s in self.sections] + if self.selected_slug not in slugs: + self.selected_slug = slugs[0] + self._load_selected() + + def select_section(self, slug: str): + self.selected_slug = slug + self._load_selected() + + def _load_selected(self): + section = next((s for s in self.sections if s["slug"] == self.selected_slug), None) + if not section: + self.selected_title = "" + self.selected_html = "" + return + try: + text = Path(section["path"]).read_text(encoding="utf-8") + except Exception as e: + self.selected_title = section["title"] + self.selected_html = f"

Erreur de lecture : {e}

" + return + # Retirer le H1 (déjà affiché en titre) + m = _RE_TITLE.search(text) + if m: + text = text[m.end():].lstrip("\n") + self.selected_title = section["title"] + self.selected_html = _render_md(text) + + +# ── UI ──────────────────────────────────────────────────────────────────────── + + +def _content() -> rx.Component: + return rx.box( + rx.heading(DocState.selected_title, size="6", margin_bottom="1rem"), + rx.html(DocState.selected_html, class_name="doc-content"), + padding="1.5rem 2rem", + background_color="white", + border="1px solid var(--gray-5)", + border_radius="8px", + width="100%", + class_name="anim-fade", + ) + + +def doc_page() -> rx.Component: + return layout( + rx.vstack( + rx.heading("Documentation", size="7"), + rx.text( + "Guide d'utilisation et fonctionnement interne de l'application.", + size="2", color="#666", + ), + _content(), + spacing="3", + width="100%", + ) + ) diff --git a/eptm_dashboard/pages/escada.py b/eptm_dashboard/pages/escada.py index 8a82539..98e184b 100644 --- a/eptm_dashboard/pages/escada.py +++ b/eptm_dashboard/pages/escada.py @@ -353,6 +353,10 @@ class EscadaState(AuthState): pass self.op_log = "\n".join(lines[-60:]) self.is_refreshing = False + if ui_classes: + yield rx.toast.success(f"{len(ui_classes)} classe(s) récupérée(s)") + else: + yield rx.toast.error("Aucune classe récupérée — vérifiez les logs") except Exception as _e: app_log(f"Erreur mise à jour état refresh : {_e}") try: @@ -360,6 +364,7 @@ class EscadaState(AuthState): self.is_refreshing = False except Exception: pass + yield rx.toast.error("Erreur lors du rafraîchissement") # ── Background: sync depuis Escada ───────────────────────────────────────── # UN SEUL async with self: (au début) — transitions via yield vers regular handlers. @@ -570,6 +575,7 @@ class EscadaState(AuthState): async with self: self.is_syncing = False self.sync_errors = ["Synchronisation echouee — aucune donnee recue depuis Escadaweb."] + yield rx.toast.error("Sync échouée — aucune donnée reçue depuis Escadaweb") return # ── Phase 2 : import subprocess en cours — async with self #2 ──────────── @@ -612,8 +618,19 @@ class EscadaState(AuthState): self.sync_errors = _result_data.get("errors", []) self.sync_done = True app_log("Résultats chargés — sync terminée OK") + _nb_err = len(self.sync_errors) else: self.sync_errors = ["Import timeout — vérifiez les logs (> 15min)."] + _nb_err = 1 + if _result_ready: + if _nb_err == 0: + yield rx.toast.success("Synchronisation Escada terminée") + else: + yield rx.toast.warning( + f"Synchronisation terminée avec {_nb_err} erreur(s)" + ) + else: + yield rx.toast.error("Import timeout — vérifiez les logs (> 15min)") # ── Background: push vers Escada ─────────────────────────────────────────── @@ -733,6 +750,15 @@ class EscadaState(AuthState): self.op_log = "\n".join(lines[-60:]) self.is_pushing = False self._reload_pending() + if push_done: + if push_errors: + yield rx.toast.warning( + f"Push terminé : {push_ok} OK, {len(push_errors)} erreur(s)" + ) + else: + yield rx.toast.success(f"Push terminé — {push_ok} envoyé(s)") + else: + yield rx.toast.error("Push échoué — vérifiez les logs") except Exception as _e: app_log(f"Erreur mise à jour état push : {_e}") try: diff --git a/eptm_dashboard/pages/fiche.py b/eptm_dashboard/pages/fiche.py index 0dc1c81..16982f9 100644 --- a/eptm_dashboard/pages/fiche.py +++ b/eptm_dashboard/pages/fiche.py @@ -19,9 +19,10 @@ from src.db import ( upsert_escada_pending, ) from src.stats import nb_blocs_absences -from src.parser_bn import sem_short_label +from src.parser_bn import sem_short_label, sem_year_only from src.email_sender import build_template_vars, render_template from src.logger import app_log +from ..components import empty_state MOIS_FR = [ "janvier", "fevrier", "mars", "avril", "mai", "juin", @@ -79,7 +80,12 @@ def _bn_html_table(d: dict, sem_labels: list, groups_order: list) -> str: for i in range(N): raw = sem_labels[i] if i < len(sem_labels) else None short = sem_short_label(raw, i) - header += f'{short}' + year = sem_year_only(raw) + year_html = ( + f'
{year}
' + if year else "" + ) + header += f'
{short}
{year_html}' def _moy_sem_row(label, gd, label_style, sep=False): s = SEP if sep else "" @@ -535,6 +541,16 @@ class FicheState(AuthState): if not v: self.apprenti_search = "" + def apprenti_search_keydown(self, key: str): + """Enter → sélectionne le 1er résultat. Esc → ferme.""" + if key == "Enter": + results = self.filtered_apprenti_labels + if results: + return FicheState.handle_select(results[0]) + elif key == "Escape": + self.apprenti_select_open = False + self.apprenti_search = "" + def navigate_to(self, apprenti_id: int): if apprenti_id in self.apprenti_ids: idx = self.apprenti_ids.index(apprenti_id) @@ -624,12 +640,14 @@ class FicheState(AuthState): 10: self.edit_p10, } d_str = d.strftime("%d.%m.%Y") + nb_changes = 0 for p, choice in choices.items(): ab = pm.get(p) if choice == "present": if ab: upsert_escada_pending(sess, self.selected_id, d, p, "clear") sess.delete(ab) + nb_changes += 1 app_log( f"[abs] {user} : {appr_label} — {d_str} P{p} : " f"{ab.type_origine} → présent (suppression)" @@ -644,6 +662,7 @@ class FicheState(AuthState): ab.statut = statut ab.updated_by = self.username upsert_escada_pending(sess, self.selected_id, d, p, type_o) + nb_changes += 1 app_log( f"[abs] {user} : {appr_label} — {d_str} P{p} : " f"{old_type} → {type_o}" @@ -656,13 +675,21 @@ class FicheState(AuthState): updated_by=self.username, import_id=None, )) upsert_escada_pending(sess, self.selected_id, d, p, type_o) + nb_changes += 1 app_log( f"[abs] {user} : {appr_label} — {d_str} P{p} : " f"présent → {type_o} (création)" ) sess.commit() - self.edit_date = "" self._reload(reset_email=False) + if nb_changes == 0: + return rx.toast.info("Aucune modification") + msg = ( + f"{nb_changes} période modifiée pour {d_str}" + if nb_changes == 1 + else f"{nb_changes} périodes modifiées pour {d_str}" + ) + return rx.toast.success(msg) # ── Quick excuse ────────────────────────────────────────────────────────── def excuse_day(self, date_str: str): @@ -682,12 +709,14 @@ class FicheState(AuthState): Absence.statut == "a_traiter", ) ).scalars().all() + nb = 0 for ab in absences: old_type = ab.type_origine ab.statut = "excusee" ab.type_origine = "E" ab.updated_by = self.username upsert_escada_pending(sess, self.selected_id, d, ab.periode, "E") + nb += 1 app_log( f"[abs] {user} : {appr_label} — {d_str} P{ab.periode} : " f"{old_type} → E (excuse rapide)" @@ -696,6 +725,14 @@ class FicheState(AuthState): if self.edit_date == date_str: self.edit_date = "" self._reload(reset_email=False) + if nb == 0: + return rx.toast.info("Aucune absence à excuser") + msg = ( + f"1 période excusée pour {d_str}" + if nb == 1 + else f"{nb} périodes excusées pour {d_str}" + ) + return rx.toast.success(msg) # ── Downloads ───────────────────────────────────────────────────────────── def download_abs_pdf(self): @@ -857,7 +894,8 @@ class FicheState(AuthState): self.fiche_email_val = fiche.email or "" self.fiche_date_naissance = fiche.date_naissance or "" self.fiche_majeur = ( - ("oui" if fiche.majeur else "non") if fiche.majeur is not None else "" + ("Majeur : oui" if fiche.majeur else "Majeur : non") + if fiche.majeur is not None else "" ) self.fiche_entreprise_nom = fiche.entreprise_nom or "" self.fiche_entreprise_adresse = fiche.entreprise_adresse or "" @@ -1082,6 +1120,7 @@ def _apprenti_searchable_select() -> rx.Component: background_color="white", cursor="pointer", width="100%", + custom_attrs={"data-shortcut": "apprenti-search"}, ), ), rx.popover.content( @@ -1090,6 +1129,7 @@ def _apprenti_searchable_select() -> rx.Component: placeholder="Rechercher un apprenti…", value=FicheState.apprenti_search, on_change=FicheState.set_apprenti_search, + on_key_down=FicheState.apprenti_search_keydown, size="2", width="100%", auto_focus=True, @@ -1129,6 +1169,7 @@ def _kpi_card(label: str, value, color: str = "#37474f") -> rx.Component: border="1px solid #e0e0e0", flex="1", min_width="120px", + class_name="hover-lift", ) @@ -1184,6 +1225,12 @@ def _cal_day_cell(d) -> rx.Component: justify_content="center", cursor=rx.cond(d["has_abs"], "pointer", "default"), on_click=FicheState.select_day(d["date_str"]), + class_name="smooth-transition", + _hover=rx.cond( + d["has_abs"], + {"transform": "scale(1.05)", "box_shadow": "0 2px 6px rgba(0,0,0,0.1)"}, + {}, + ), ), ) @@ -1265,6 +1312,7 @@ def _edit_panel() -> rx.Component: border_radius="8px", border="1px solid #bfdbfe", width="100%", + class_name="anim-slide-down", ) @@ -1717,16 +1765,12 @@ def fiche_page() -> rx.Component: spacing="4", width="100%", ), - rx.box( - rx.text( - "Aucun apprenti. Faites d'abord un import.", - size="2", color="#666", - ), - padding="1rem", - background_color="#e3f2fd", - border_radius="6px", - border="1px solid #90caf9", - width="100%", + empty_state( + icon="users", + title="Aucun apprenti", + description="Importe les classes depuis Escadaweb pour commencer à gérer les absences.", + action_label="Lancer un import", + action_href="/escada", ), ), diff --git a/eptm_dashboard/sidebar.py b/eptm_dashboard/sidebar.py index 4e8a956..84e10d9 100644 --- a/eptm_dashboard/sidebar.py +++ b/eptm_dashboard/sidebar.py @@ -1,5 +1,10 @@ import reflex as rx from .state import AuthState +from .components import scan_docs + +# Liste des sections de doc (scan au module-load — un restart suffit pour +# détecter de nouveaux fichiers). +_DOC_SECTIONS = scan_docs() FULL_W = "240px" RAIL_W = "68px" @@ -110,6 +115,83 @@ def _nav_item(label: str, href: str, icon_name: str) -> rx.Component: ) +def _doc_subitem(title: str, slug: str, mobile: bool = False) -> rx.Component: + """Lien vers une section de doc — navigue vers /doc + sélectionne la section.""" + # Import local pour éviter le cycle sidebar ↔ pages.doc + from .pages.doc import DocState + is_active = ( + (AuthState.router.page.path == "/doc") + & (DocState.selected_slug == slug) + ) + on_click_actions = [DocState.select_section(slug), rx.redirect("/doc")] + if mobile: + on_click_actions.append(AuthState.close_mobile_menu) + return rx.box( + rx.text( + title, + size="2", + color=rx.cond(is_active, "#ffffff", _TEXT), + font_weight=rx.cond(is_active, "600", "400"), + ), + on_click=on_click_actions, + cursor="pointer", + padding="0.4rem 0.75rem 0.4rem 2rem", + border_radius="0 6px 6px 0", + background_color=rx.cond(is_active, _ACTIVE_BG, "transparent"), + _hover={"background_color": _HOVER_BG}, + width="100%", + class_name="smooth-transition", + ) + + +def _doc_section(mobile: bool = False) -> rx.Component: + if not _DOC_SECTIONS: + return rx.fragment() + return rx.cond( + AuthState.sidebar_collapsed if not mobile else rx.Var.create(False), + # Rail mode : icône simple vers /doc, sans sous-menu + rx.box( + _nav_rail("Documentation", "/doc", "book-open"), + padding_x="0.5rem", padding_y="0.25rem", + width="100%", + ), + # Full mode : header cliquable + sous-items + rx.vstack( + rx.button( + rx.hstack( + rx.icon("book-open", size=17, color=_TEXT, flex_shrink="0"), + rx.text("Documentation", size="2", color=_TEXT, weight="medium"), + rx.spacer(), + rx.icon( + rx.cond(AuthState.doc_expanded, "chevron-up", "chevron-down"), + size=14, color=_TEXT_MUTED, + ), + spacing="3", align="center", width="100%", + ), + on_click=AuthState.toggle_doc, + variant="ghost", + width="100%", + size="2", + padding_x="0.75rem", + padding_y="0.5rem", + color=_TEXT, + _hover={"background_color": _HOVER_BG}, + cursor="pointer", + justify="start", + ), + rx.cond( + AuthState.doc_expanded, + rx.vstack( + *[_doc_subitem(s["title"], s["slug"], mobile) for s in _DOC_SECTIONS], + spacing="0", width="100%", + ), + ), + spacing="0", width="100%", + padding_x="0.5rem", padding_y="0.1rem", + ), + ) + + def _admin_section(mobile: bool = False) -> rx.Component: return rx.cond( AuthState.role == "admin", @@ -266,6 +348,7 @@ def sidebar() -> rx.Component: padding_y="0.5rem", ), + _doc_section(), _admin_section(), rx.spacer(), @@ -342,6 +425,7 @@ def _mobile_topbar() -> rx.Component: spacing="1", width="100%", padding_x="0", padding_y="0.5rem", ), + _doc_section(mobile=True), _admin_section(mobile=True), rx.box(height="1px", width="100%", background_color=_BORDER), rx.box( @@ -370,6 +454,29 @@ def _mobile_topbar() -> rx.Component: # ── Layout wrapper ─────────────────────────────────────────────────────────── +_KEYBOARD_SHORTCUTS_JS = """ +(() => { + if (window.__eptmShortcutsInstalled) return; + window.__eptmShortcutsInstalled = true; + document.addEventListener('keydown', (e) => { + const tag = (document.activeElement && document.activeElement.tagName) || ''; + const isTyping = ['INPUT', 'TEXTAREA', 'SELECT'].includes(tag) + || document.activeElement?.isContentEditable; + // '/' = focus le sélecteur de recherche (apprenti/classe) sur la page courante + if (e.key === '/' && !isTyping) { + const trigger = document.querySelector( + '[data-shortcut="apprenti-search"], [data-shortcut="class-search"]' + ); + if (trigger) { + e.preventDefault(); + trigger.click(); + } + } + }); +})(); +""" + + def layout(content: rx.Component) -> rx.Component: return rx.box( sidebar(), @@ -394,6 +501,7 @@ def layout(content: rx.Component) -> rx.Component: transition="margin-left 0.22s ease, width 0.22s ease", box_sizing="border-box", ), + rx.script(_KEYBOARD_SHORTCUTS_JS), width="100%", height="100vh", overflow="hidden", diff --git a/eptm_dashboard/state.py b/eptm_dashboard/state.py index 7270b67..77f1a9f 100644 --- a/eptm_dashboard/state.py +++ b/eptm_dashboard/state.py @@ -61,6 +61,7 @@ class AuthState(rx.State): sidebar_collapsed: bool = False mobile_menu_open: bool = False admin_expanded: bool = True + doc_expanded: bool = False @rx.var def authenticated(self) -> bool: @@ -87,6 +88,9 @@ class AuthState(rx.State): def toggle_admin(self): self.admin_expanded = not self.admin_expanded + def toggle_doc(self): + self.doc_expanded = not self.doc_expanded + def set_login_user(self, value: str): self.login_user = value diff --git a/requirements.txt b/requirements.txt index 8138105..16a3b1c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ -reflex==0.9.2 \ No newline at end of file +reflex==0.9.2 +markdown==3.10.2 \ No newline at end of file diff --git a/src/parser_bn.py b/src/parser_bn.py index 0fa2fb2..25a955d 100644 --- a/src/parser_bn.py +++ b/src/parser_bn.py @@ -243,6 +243,25 @@ def sem_short_label(raw: str | None, idx: int) -> str: return f"S{idx + 1}" +def sem_full_label(raw: str | None, idx: int) -> str: + """'Sem. 1\\n23-24 1' → 'S1 23-24'. Fallback: 'S{idx+1}' si pas d'année.""" + short = sem_short_label(raw, idx) + if raw: + m = _RE_YEAR.search(str(raw)) + if m: + return f"{short} {m.group(1)}" + return short + + +def sem_year_only(raw: str | None) -> str: + """Extrait juste l'année '23-24' depuis le label brut, '' si absent.""" + if raw: + m = _RE_YEAR.search(str(raw)) + if m: + return m.group(1) + return "" + + def ann_short_label(sem_labels: list[str | None], idx: int) -> str: """Return 'Moy.23-24' using the year embedded in the label at *idx*.""" raw = sem_labels[idx] if idx < len(sem_labels) else None