upgrade css + ajout documentation

This commit is contained in:
Julien Balet 2026-05-10 17:02:21 +02:00
parent ee4e212f7d
commit f17041be18
24 changed files with 1405 additions and 47 deletions

View file

@ -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;
}
}

View file

@ -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"
}

42
data/docs/01-overview.md Normal file
View file

@ -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.

View file

@ -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.

View file

@ -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](#).

View file

@ -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`

78
data/docs/05-cron.md Normal file
View file

@ -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.

66
data/docs/06-telegram.md Normal file
View file

@ -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<TOKEN>/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).

71
data/docs/07-auth.md Normal file
View file

@ -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`.

85
data/docs/08-logs.md Normal file
View file

@ -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é.

24
data/docs/09-shortcuts.md Normal file
View file

@ -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) |

112
data/docs/10-faq.md Normal file
View file

@ -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
```

View file

@ -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%",
)

View file

@ -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)

View file

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

View file

@ -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'<th style="{TH}">{short}</th>'
year = sem_year_only(raw)
year_html = (
f'<div style="font-size:0.78em;color:#666;font-weight:normal;margin-top:1px">{year}</div>'
if year else ""
)
header += f'<th style="{TH}"><div style="font-weight:700">{short}</div>{year_html}</th>'
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",
)
@ -792,6 +816,15 @@ def classe_page() -> rx.Component:
rx.vstack(
_classe_searchable_select(),
rx.cond(
ClasseState.is_loading_apprentis,
rx.vstack(
skeleton_apprenti_card(),
skeleton_apprenti_card(),
skeleton_apprenti_card(),
spacing="4",
width="100%",
),
rx.cond(
ClasseState.apprentis_data.length() > 0,
rx.vstack(
@ -799,25 +832,25 @@ def classe_page() -> rx.Component:
spacing="4",
width="100%",
),
rx.text(
"Aucun apprenti dans cette classe.",
size="2", color="#666",
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",
),
),

View file

@ -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),

View file

@ -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 = "<p><em>Aucune documentation disponible. Ajoutez des fichiers <code>.md</code> dans <code>data/docs/</code>.</em></p>"
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"<p><em>Erreur de lecture : {e}</em></p>"
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%",
)
)

View file

@ -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:

View file

@ -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'<th style="{TH}">{short}</th>'
year = sem_year_only(raw)
year_html = (
f'<div style="font-size:0.78em;color:#666;font-weight:normal;margin-top:1px">{year}</div>'
if year else ""
)
header += f'<th style="{TH}"><div style="font-weight:700">{short}</div>{year_html}</th>'
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",
),
),

View file

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

View file

@ -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

View file

@ -1,2 +1,3 @@
reflex==0.9.2
markdown==3.10.2

View file

@ -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