Initial version

This commit is contained in:
Julien Balet 2026-05-12 15:30:28 +02:00
parent ea8954bc6f
commit f1190566a6
36 changed files with 2662 additions and 369 deletions

View file

@ -1,3 +1,9 @@
echo ".web/" > /opt/eptm-dashboard/.dockerignore
echo "__pycache__/" >> /opt/eptm-dashboard/.dockerignore
echo ".venv/" >> /opt/eptm-dashboard/.dockerignore
.web/
__pycache__/
.venv/
data/browser_profile/
data/cache/
data/*.db
data/*.db-*
logs/
.git/

25
TODO.md
View file

@ -5,16 +5,25 @@ en haut de la section concernée.
## Idées / fonctionnalités
- [ ] Ajouter sur le dashboard l'affichage des notes insuffisantes
- [X] Afficher toutes les notes du BN
- [ ] Mettre à jour les MD
- [X] Mettre à jour les MD (réalisé le 2026-05-12, doc complète incl. nouveaux chapitres 11-avis, 12-feedback, 13-parametres)
- [X] Ajouter l'indication des compensation des désavantages
- [X] Ajouter le TAB notices aussi sur la vue classe
- [ ] Réussir à récupérer le fichier session Esacada d'un utilisateur pour l'utiliser sur le serveur afin de récupérer la liste des classes dont il a accès et de pouvoir uploader les notices avec son nom propre
- [X] Réussir à récupérer le fichier session Esacada d'un utilisateur pour l'utiliser sur le serveur afin de récupérer la liste des classes dont il a accès et de pouvoir uploader les notices avec son nom propre
- [X] Filtrer que les classes EM pour les avis de sanction
- [ ] Ajouter sur la page apprenti sa situation au niveau des sanctions (barre de progression en fonction des notices)
- [ ] Ajouter dans le texte des notices qui a créé la notice (USER) en attendant d'avoir une identification spécifique escada à chaque utilisateur.
- [X] Ajouter dans le texte des notices qui a créé la notice (USER) en attendant d'avoir une identification spécifique escada à chaque utilisateur.
- [X] Mettre dans les tâches CRON les heures et pas chaque x minutes
- [X] Modifier l'adresse du destinataire des avis de sanction/absences -> représentant légal pour les mineurs, apprenti pour les majeurs
- [X] Changer le texte de l'objet dans les mails apprentis
- [X] Ajouter dans le sidebar la version GIT du document.
- [X] Ajouter bouton "Absent toute la journée" avec filtre des périodes en fonction des classes
- [X] Ajouter dans l'export des absences s'il s'agit dun jour de théorie/pratique/matu
- [X] Renommer les pages : « Vue classe » → « Classes », « Fiche apprenti » → « Apprentis » + réordonner sidebar (Classes au-dessus d'Apprentis)
- [X] Cron : supprimer les schedules `daily` et `interval`, ne garder que `daily_multi` (grille 24 cases) + `weekly`. Migration auto au boot.
- [X] Bouton « Absent toute la journée » : griser + libellé « (Données chronoplan manquantes) » si pas de mapping configuré
- [X] Ajouter dans le panneau d'édition d'absences un badge couleur Théorie / Pratique / Matu selon le jour
## Bugs connus
@ -26,6 +35,12 @@ en haut de la section concernée.
- [ ] Faire un thème avec fond foncé
- [ ] Lancer une optimisation des toasts
- [X] Changer la couleur du bouton Générer l'avais de sanction
- [X] rendre plus petit la bulle dans le logo chat et changer le titre (enlever EPTM)
- [X] Utiliser les mêmes PKIs, boutons télécharger et création des avis sur la page classe que sur la page apprenti
- [X] Simplifier les cards apprentis sur la page classe (infos principales)
- [X] Ajouter sur le dashboard l'affichage des notes insuffisantes
## Notes / réflexions

View file

@ -44,6 +44,15 @@
.no-scrollbar { scrollbar-width: none; -ms-overflow-style: none; }
.no-scrollbar::-webkit-scrollbar { display: none; }
/* Bouton flottant de feedback (FAB) : on garde le cercle bleu (taille Radix
"3") mais on réduit l'icône à l'intérieur de 20% (36 29 px). Radix
sur-écrit la prop size de rx.icon donc on force via CSS, en ciblant par
l'attribut title du bouton (propagé au DOM contrairement à class_name). */
button[title="Signaler un bug ou proposer une idée"] svg {
width: 23px !important;
height: 23px !important;
}
/* Badge avec animation pulse — utilisé pour indiquer les messages non lus. */
@keyframes feedback-pulse {
0%, 100% { transform: scale(1); opacity: 1; }

1
data/VERSION Normal file
View file

@ -0,0 +1 @@
1.0.0

View file

@ -16,8 +16,29 @@ credentials:
test:
allowed_classes:
- AUTOMAT 1
- AUTOMAT 2
- AUTOMAT 3
- AUTOMAT 4
- CFTI-AU 1A
- CFTI-AU 1B
- CFTI-AU 2
- EM-AU 1
- EM-AU 1A
- EM-AU 1B
- EM-AU 2
- EM-AU 2A
- EM-AU 2B
- EM-AU 3
- EM-AU 3A
- EM-AU 3B
- EM-AU 4
- MONTAUT 1
- MONTAUT 2
- MONTAUT 3
- Z-IT Test 1
email: julien@balet-vs.ch
escada_password: Lauryne2023!
escada_username: julien.balet@edu.vs.ch
name: test
password: $2b$12$dAvkehPvcU5zokLhUzjswu7APcRi1e4C1IeR6Gc7/51wh9vGTl4MS
role: user

View file

@ -32,15 +32,15 @@
"MP1-TASV 4D:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=3bc53c77-bda8-43a4-ab47-08c2eb84a917",
"MP1-TASV 4E:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=7673335b-a2aa-42de-9cab-6b95ffc90d7c",
"Z-IT Test 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=f0707cc3-4511-403b-a2a6-d79f2da4fd99",
"AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=326850f9-08ad-413c-ad15-1fa079f5058b",
"AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=8b5035a7-f0b6-41dd-b203-4c5d540b1e64",
"AUTOMAT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=93c187b8-7bd9-4361-82ef-cac3a5658c6c",
"AUTOMAT 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=83a6684e-a2be-4148-8bd4-faaea872698d",
"EM-AU 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=cf80e939-1c49-484b-b8ed-1357a1a51c2b",
"EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=12a70ca4-1410-4c7a-85b8-36eeabbe7cda",
"EM-AU 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=eb8d612b-0929-47bc-8af5-ffebc7ed2432",
"EM-AU 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=d0781d15-f260-40b5-9bac-e3c919533422",
"MONTAUT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=69c49e37-d184-4ab4-a463-914ed635d237",
"MONTAUT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=6d07e078-38b1-49da-a513-91b482dbf2a6",
"MONTAUT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=6705457b-b544-4c74-8de1-eb61cf45511d"
"AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=017d74aa-47c3-4ad8-8dd3-79520a126a1a",
"AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=e2911ac8-e7e5-4f5c-8eaf-6f884308c73b",
"AUTOMAT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=b915369f-7391-4b12-9ec1-f0d3db24be88",
"AUTOMAT 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=5468c887-961e-4da7-90f8-73b5575402a6",
"EM-AU 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=b1ad1496-5b42-40ec-9db3-6b5360cb0784",
"EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=24755378-e2f5-4a0c-ba16-4c5c8dfbb48d",
"EM-AU 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=5288ce4a-512b-42e6-b7d0-24291b37283c",
"EM-AU 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=d3fbc0f4-9b00-4a98-8679-54bfc498bce6",
"MONTAUT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=88e8bfcf-f784-42f6-95ef-2197b5991f08",
"MONTAUT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=3e291e9a-1307-4786-907b-a1c2cfe0e490",
"MONTAUT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=a09c3cf3-a741-4b41-9026-d9065b125b8a"
}

View file

@ -1,42 +1,75 @@
# 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.
EPTM Dashboard est une application de gestion des absences, notes, bulletins, avis (retenue / sanction) et notices pour l'École professionnelle technique et des métiers (EPTM Sion). 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.
- **Visualiser** les absences, BN, notes d'examen, notices et fiches personnelles par apprenti ou par classe.
- **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.
- **Créer des avis** de retenue et de sanction au format PDF (templates AcroForm pré-remplis) — l'avis est aussi envoyé en notice vers Escada.
- **Récupérer / pousser** les notices Escada (création, lecture, statut).
- **Automatiser** les imports/exports via des tâches planifiées (cron) avec notifications Telegram.
- **Tracer** qui a modifié quoi (audit log complet).
- **Envoyer par email** un récap d'absences (+ bulletin + notes) à l'apprenti, au formateur ou à une adresse libre.
- **Tracer** qui a modifié quoi (audit log complet + champ `updated_by` sur chaque entité).
- **Collecter du feedback** in-app via un widget chat (admin reçoit un email + dialogue de réponse).
- **Gérer les droits utilisateur** : restriction d'accès par classe, self-service via identifiants Escada de l'utilisateur.
## Modèle de données simplifié
```
Apprenti ── Absence (avec statut: a_traiter, excusee, ...)
├── ApprentiFiche (données personnelles : adresse, entreprise, formateur)
Apprenti ── Absence (statut : a_traiter, excusee, ...)
├── ApprentiFiche (adresse, entreprise, formateur, représentant
│ légal, compensation des désavantages, majeur/mineur)
├── NotesBulletin (BN par semestre)
├── NotesMatu (Matu pro)
└── NotesExamen (notes d'examen finales)
├── NotesExamen (notes d'examen)
├── ApprentiNotice (notices importées depuis Escada)
└── Notice (notices locales, file de push vers Escada)
EscadaPending : file d'attente des modifications locales à pousser vers Escada
(action ∈ {"E", "N", "clear"})
EscadaPending : file d'attente des modifications d'absences 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)
CronJob : tâches planifiées (push, sync, push+sync) — schedules
daily_multi (plusieurs heures/jour) ou weekly
FeedbackMessage : feedback in-app (bug / proposition) — statut new /
in_progress / resolved + réponse admin
```
## 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`
- **Frontend** : Reflex 0.9.2 (Python full-stack, Radix Themes, lucide-react icons)
- **DB** : SQLite mode WAL, à `data/absences.db`
- **Scraping Escada** : Playwright (sync API), dans `scripts/sync_esacada.py`, `scripts/push_to_escada.py`, `scripts/push_notices.py`, `scripts/pull_notices.py`, `scripts/fetch_user_classes.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
- **Génération avis PDF** : `src/sanction_pdf.py`, `src/retenue_pdf.py` (templates AcroForm dans `data/templates/`)
- **Conteneurisation** : Docker Compose, derrière nginx-proxy-manager (NPM, containerisé depuis mai 2026)
- **Cron** : OS cron déclenche `scripts/cron_tick.py` toutes les minutes, qui consulte la table `CronJob`
- **Emails** : SMTP (Brevo en prod), templates configurables depuis `/params`
## Rôles utilisateurs
## Pages disponibles
- **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.
| Page | Route | user | admin |
|----------------------------|--------------|------|-------|
| Tableau de bord | `/accueil` | ✅ | ✅ |
| Classes | `/classe` | ✅* | ✅ |
| Apprentis | `/fiche` | ✅* | ✅ |
| Documentation | `/doc` | ✅ | ✅ |
| Mon profil | `/profile` | ✅ | ✅ |
| Escada (sync / push) | `/escada` | ❌ | ✅ |
| Tâches planifiées | `/cron` | ❌ | ✅ |
| Logs | `/logs` | ❌ | ✅ |
| Utilisateurs | `/users` | ❌ | ✅ |
| Paramètres | `/params` | ❌ | ✅ |
| Feedback | `/feedback` | ❌ | ✅ |
| Purger classe | `/purge` | ❌ | ✅ |
✅* : restriction par `allowed_classes` (l'utilisateur ne voit que ses classes autorisées). Si la liste est vide, un dialogue d'enrôlement obligatoire s'affiche à chaque navigation (cf. doc « Authentification »).
## Sidebar — version & profil
- La **version** (dernier tag git ou contenu de `data/VERSION`) s'affiche au-dessus du widget profil. À mettre à jour manuellement après un nouveau tag : éditer `data/VERSION` puis `docker restart eptm-dashboard-app-1`.
- Le **widget profil** ouvre un popover avec « Mon profil » + « Déconnexion ».
- Le **bouton feedback** flotte en bas-droite de l'écran (FAB) : ouvre la modale chat pour signaler un bug ou proposer une idée.

View file

@ -1,14 +1,14 @@
# 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.
La synchronisation depuis Escada télécharge les PDFs / vues (absences, BN, Matu, notes, fiches, notices) 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.
La liste des classes vient d'un cache disque (`data/esacada_classes.json`) rempli via le bouton **Actualiser**. Le rafraîchissement lance une session Playwright 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).
> 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
@ -17,11 +17,12 @@ La liste des classes vient d'un cache disque (`data/esacada_classes.json`) rempl
| 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 |
| Données apprentis | Fiches personnelles : adresse, entreprise, formateur, représentant légal, statut compensation des désavantages, majeur/mineur |
| Notices | Importe l'historique des notices Escada (table `apprenti_notices`) |
### 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.
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).
@ -32,39 +33,56 @@ Sans ce flag :
## Phases d'exécution
### Phase 1 : Scraping (Selenium)
### Phase 1 : Scraping (Playwright)
`scripts/sync_esacada.py --sync-all CLASSE1 CLASSE2 ...`
1. Selenium ouvre Escadaweb avec un profil persistant (`data/browser_profile/`)
1. Playwright 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).
- Scrape les fiches personnelles (vue ViewLernende — y compris représentant légal + flag compensation)
- Si l'option « Notices » est cochée : pull l'historique via `pull_notices.py`
3. À la fin, écrit `ALL_DONE` dans la sortie standard et `data/sync_last_result.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`
1. Parse chaque PDF d'absences → upsert des `Absence` (déduplication sur (apprenti, date, période))
2. Parse les BN → insère `NotesBulletin` (toutes les notes du BN sont stockées, pas seulement les moyennes)
3. Parse les notes → insère `NotesExamen`
4. Parse les fiches → upsert `ApprentiFiche`
4. Parse les fiches → upsert `ApprentiFiche` (adresse perso, entreprise, **représentant légal**, **compensation_desavantages**, **majeur**)
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.
### Re-parsing sans re-téléchargement
`scripts/run_imports.py --reparse-bn-only` permet de re-traiter tous les PDFs déjà téléchargés (utile après un changement dans le parser BN, sans relancer Playwright).
## 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 :
Quand un utilisateur modifie une absence dans l'application (page Apprentis), 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".
Ces pendings sont visibles sur la page `/escada` dans la section « Modifications en attente ». Le bouton **Pousser vers Escada** les vide en envoyant chaque modification.
## Notices Escada (import et création)
Les notices sont les remarques rattachées à un apprenti dans Escada (avis de sanction, retenue, remarque libre, etc.).
### Import (pull)
`scripts/pull_notices.py` lit la vue Escada de chaque classe, scrape les notices, et upsert dans la table `apprenti_notices` (clé `(apprenti, date_event, titre)`). Affichage en lecture seule dans l'onglet « Notices » de la fiche apprenti et de la vue classe.
### Création (push)
Voir le chapitre dédié [Push vers Escada](#) — la création locale d'un avis de sanction ou de retenue crée une `Notice` (table locale), qui est ensuite poussée vers Escada par `scripts/push_notices.py`.
## Cas particuliers gérés
@ -77,6 +95,6 @@ Ces pendings sont visibles sur la page `/escada` dans la section "Modifications
## 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.
- **Timeout > 15 min** : vérifier les logs `data/logs/operations.log`. Souvent un problème Playwright (captcha, session expirée).
- **Aucune classe récupérée** : token de session Escada expiré → relancer le rafraîchissement (re-login automatique avec les identifiants stockés en /params, code 2FA généré via `totp_secret`).
- **Logs détaillés** : page `/logs` affiche `operations.log` en temps réel.

View file

@ -1,23 +1,24 @@
# Push vers Escada
Le push envoie les modifications locales (table `EscadaPending`) vers Escadaweb via Selenium.
Le push envoie les modifications locales (absences en `EscadaPending` + notices en attente dans `Notice`) vers Escadaweb via Playwright.
## Page : `/escada`"Pousser vers Escada"
## Page : `/escada`« Pousser vers Escada »
### Quand un pending est créé ?
### Quand un pending d'absence 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 |
| Excuse rapide d'une journée (page Apprentis) | `action=E` × n |
| « Absent toute la journée » (selon horaire classe)| `action=N` × n (sur enregistrement) |
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
## Phases du push d'absences
### Phase 1 : Préparation
@ -25,21 +26,21 @@ La contrainte d'unicité `(apprenti_id, date, periode)` garantit qu'une période
1. Lit toutes les entrées de `EscadaPending`
2. Groupe par classe pour minimiser les navigations Escada
3. Lance Selenium
3. Lance Playwright
### Phase 2 : Exécution Selenium
### Phase 2 : Exécution Playwright
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"
- `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`
6. Si erreur → conserve l'entrée et la liste dans `PUSH_DONE`
### Phase 3 : Rapport
@ -47,6 +48,27 @@ Le script imprime une ligne `PUSH_DONE {"ok": N, "err": [...]}` à la fin. L'app
- Nombre d'envois OK
- Liste des erreurs (chaque erreur mentionne l'apprenti, la date et la période)
## Push de notices
Les notices créées localement (création d'avis de retenue ou de sanction depuis l'app) sont enregistrées dans la table `Notice` (statut `pending`), puis poussées par `scripts/push_notices.py`.
### Workflow
1. L'utilisateur clique sur « Générer l'avis » dans une modale d'avis sanction/retenue. Cela :
- Génère le PDF (téléchargement)
- Crée une `Notice` avec `source="sanction"` ou `"retenue"`, `status="pending"`, et le préfixe `(<username>)` est ajouté en début de la remarque pour traçabilité
2. La file `Notice (status=pending)` est visible côté admin sur `/escada` ou via les tâches cron.
3. `push_notices.py` :
- Lit les notices `pending`
- Pour chaque, navigue dans Escada (page de l'apprenti → onglet Notices) et crée la notice avec son titre + remarque + date
- Marque comme `synced` si OK, `error` (+ `error_msg`) sinon
### task_kind cron
- `task_kind=push` + `sync_abs=1` → pousse les absences
- `task_kind=push` + `sync_notices=1` → pousse les notices
- `task_kind=push` + les deux → push absences puis notices (séquentiel)
## Que faire si un push échoue ?
1. **Vérifier les logs** (`/logs`) — l'erreur exacte est tracée.
@ -54,13 +76,19 @@ Le script imprime une ligne `PUSH_DONE {"ok": N, "err": [...]}` à la fin. L'app
- 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.
3. **Re-tenter** : les pendings (et notices `pending`/`error`) 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 :
Chaque push manuel logue qui l'a déclenché :
```
[abs] {user} : Push Escada démarré par {username}
[notice] {user} : création (sanction) pour {apprenti}
[notice] {user} : création (retenue) pour {apprenti} — case=devoir
```
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](#).
Les tâches planifiées de type `push` ou `push_then_sync` exécutent les mêmes scripts. Voir la section [Tâches planifiées](#).

View file

@ -1,6 +1,6 @@
# Édition des absences
## Page : `/fiche` (Apprentis)
## Page : « Apprentis » (`/fiche`)
### Sélectionner un apprenti
@ -11,6 +11,20 @@ Le sélecteur en haut de la page propose une recherche en direct : tape une part
- `Entrée` sélectionne le premier résultat filtré
- `Échap` ferme la recherche
### KPIs et bandeau d'actions
Sous le sélecteur, 3 cartes KPI :
- **Périodes d'absence** : total
- **Périodes à excuser** : non encore traitées
- **Absences** : nombre de blocs ; rouge avec libellé « Avis de sanction » dès le quota EM atteint
Sous les KPIs, un bandeau d'actions :
- **PDF absences / PDF bulletin / PDF notes** (téléchargement)
- **Créer un avis de retenue** (orange) → ouvre la modale retenue pré-remplie
- **Créer un avis de sanction** (rouge) → ouvre la modale sanction pré-remplie
Ces boutons sont identiques sur la page « Classes », par carte apprenti.
### Calendrier mensuel
Chaque cellule représente un jour du mois. Les couleurs indiquent l'état :
@ -24,13 +38,24 @@ Chaque cellule représente un jour du mois. Les couleurs indiquent l'état :
| 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
- « 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.
Cliquer sur un jour avec absences (ou un autre jour) ouvre le panneau d'édition.
### Panneau d'édition
#### Badge type de jour
À côté du titre « Édition du {date} » s'affiche un badge coloré indiquant le type de jour pour cette classe (selon le mapping défini en /params) :
- 🔵 **Théorie** (bleu)
- 🟠 **Pratique** (orange)
- 🟣 **Matu** (violet)
- (rien) si aucun type configuré
#### Périodes
10 lignes (P1 à P10) avec un **segmented control** à 3 boutons :
- **Présent** (gris) — l'apprenti était là
- **E** (orange) — Excusée
@ -38,11 +63,42 @@ Cliquer sur un jour avec absences ouvre le panneau d'édition.
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")
#### Actions rapides
- **Absent toute la journée** (rouge) — met à `N` uniquement les périodes définies dans l'horaire de la classe pour le jour de la semaine sélectionné (cf. ci-dessous).
- Bouton **grisé** + libellé « Absent toute la journée (Données chronoplan manquantes) » si l'horaire n'est pas configuré pour ce (classe × jour).
- **Excuser toutes les périodes** (vert) — bascule visuellement toutes les `N` en `E`. N'enregistre pas en DB tant qu'on ne clique pas sur **Enregistrer**.
### 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)
### Envoyer par email
Un bloc « Envoyer par email » permet d'envoyer le récap (et éventuellement le bulletin / les notes en pièces jointes) à l'apprenti, au formateur ou à une adresse libre. Objet et corps utilisent le template configurable en `/params → Template email` (variables `{prenom}`, `{nom_complet}`, `{classe}`, etc.).
## Horaire de classe (« chronoplan »)
Configuré en **Paramètres → Horaires de classe** :
- Sélection d'une classe (dropdown alimenté par les classes en base)
- Pour chaque jour (Lun → Ven) :
- Sélecteur de **type de jour** : Théorie / Pratique / Matu / —
- Grille de **10 cases** (P1 → P10), cliquables (rouge = active)
- Bouton « Enregistrer l'horaire »
Stocké dans `data/settings.json` sous la clé `class_schedule` :
```json
"AUTOMAT 1": {
"MON": { "type": "theorie", "periods": [1, 2, 3, 4] },
"TUE": { "type": "pratique", "periods": [5, 6, 7, 8] },
"WED": { "type": "matu", "periods": [1, 2] }
}
```
Le bouton « Absent toute la journée » dans la fiche apprenti lit cette config en fonction de `apprenti.classe` + jour de la semaine de la date sélectionnée.
## Page : « Classes » (`/classe`)
### Sélection de classe
@ -52,10 +108,10 @@ Même principe que pour les apprentis : recherche en direct avec `/`, `Entrée`,
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
- Badge « Sanction » si quota atteint (≥5 absences brutes en blocs, classes EM uniquement)
- KPIs identiques à la fiche apprenti (3 cartes : Périodes d'absence, Périodes à excuser, Absences)
- Bandeau d'actions identique : PDF absences/bulletin/notes + Créer avis de retenue + Créer avis de sanction
- Onglets BN / Notes d'examen / Notices pour visualiser
## Audit des modifications

View file

@ -2,7 +2,7 @@
## Page : `/cron` (admin uniquement)
Permet de créer des tâches automatiques de synchronisation et/ou de push.
Permet de créer des tâches automatiques de synchronisation et/ou de push, avec notifications Telegram.
## Architecture
@ -14,7 +14,7 @@ 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
- Lance push_to_escada.py, push_notices.py, sync_esacada.py + run_imports.py
- Met à jour last_run_at, last_status, last_message
- Envoie une notification Telegram (selon notify_on)
```
@ -25,24 +25,32 @@ Le tick s'exécute toutes les minutes via la crontab du host. Le timezone du con
| 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 |
| `push` | Pousse les pendings d'absences et/ou notices vers Escada |
| `sync` | Récupère depuis Escada (selon options abs/BN/notes/fiches/notices) |
| `push_then_sync` | Pousse puis récupère |
## Schedules
## Planifications
Trois types de planning sont disponibles :
Deux types de planning seulement (les anciens `interval` et `daily` ont été remplacés par `daily_multi` qui couvre les deux cas) :
- **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.
- **Hebdo (`weekly`)** : à une heure fixe certains jours.
- `schedule_value` = `"MON,WED,FRI:08:30"`
- Sélection des jours en UI : pastilles rouges Lun..Dim.
- **Plusieurs heures par jour (`daily_multi`)** : à un ensemble d'heures pleines, tous les jours.
- `schedule_value` = `"00:00,06:00,12:00,18:00"`
- Sélection en UI : grille 24 cases (00h23h) cliquables.
- Remplace l'ancien mode `interval` : pour reproduire « toutes les 6 h » on coche 4 cases (00, 06, 12, 18).
## Options de sync (pour task_kind=sync ou push_then_sync)
> Les anciens jobs en format `daily` (`HH:MM`) sont automatiquement convertis en `daily_multi` au boot via la migration de `src/db.py`.
> Les anciens jobs en format `interval` (`N minutes`) sont également migrés en déroulant l'intervalle sur 24 h depuis minuit.
## 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
- `sync_bn` : récupère les BN + Matu
- `sync_notes` : récupère les notes d'examen
- `sync_fiches` : récupère les données apprentis (avec représentant légal + compensation)
- `sync_notices` : récupère les notices Escada
- `force_abs` : forçage (cf. doc Sync Escada)
- `classes_json` : `"ALL"` ou liste de classes spécifiques
@ -58,7 +66,9 @@ 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).
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 créneau.
**Ouverture rapide** : cliquer n'importe où sur la ligne d'une tâche ouvre directement le panneau d'édition.
## Logs persistants
@ -69,10 +79,10 @@ Chaque exécution écrit son log détaillé dans `/logs/cron/cron-{job_id}-{time
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)
[09:14:22] [cron] prof.demo : création tâche 'Sync nocturne' (id=4) — push_then_sync / 00:00,06:00,12:00,18:00 / activée
[09:30:05] [cron] prof.demo : désactivation tâche 'Push horaire' (id=2)
```
## Bouton "Tester Telegram"
## Bouton « Tester Telegram »
Bas de page : envoie un message de test au `chat_id` global pour vérifier la config bot.

View file

@ -1,4 +1,4 @@
# Authentification & rôles
# Authentification, droits & profil
## Login
@ -12,11 +12,15 @@ Format `auth.yaml` :
credentials:
usernames:
prof.demo:
password: "$2b$12$..."
password: "$2b$12$..." # bcrypt
name: "Prof Demo"
role: "admin" # ou "user"
email: "prof.demo@eptm.ch" # destinataire pour reset mdp / enrôlement
avatar_url: "/avatars/prof_demo.png"
totp_secret: "ABCD1234..." # rempli automatiquement à la 1ère 2FA
allowed_classes: ["AUTOMAT 1", "EM-AU 2"] # restriction d'accès
escada_username: "prenom.nom@eptm.ch" # email Escada (clé login)
escada_password: "..." # mot de passe Escada (stocké clair)
```
## 2FA TOTP (obligatoire)
@ -34,37 +38,100 @@ Aux connexions suivantes :
Le code est valide ±30 s (paramètre `valid_window=1` de `pyotp`) pour tolérer la dérive d'horloge.
## Email de bienvenue / réinitialisation de mot de passe
Sur création d'un user depuis `/users` ou sur reset, un **email** est envoyé contenant un lien `<APP_URL>/password_set?token=...` qui expire après 24 h.
- L'URL de base est lue depuis `settings.app_base_url` (configurée en `/params → Application`).
- L'expéditeur et le SMTP sont configurés en `/params → Configuration email`.
## 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.
L'authentification est stockée dans le **`localStorage` du navigateur** (champs `username`, `name`, `role`, `photo_url`, `theme`). 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`.
À chaque page protégée, `AuthState.check_auth` re-vérifie que l'utilisateur existe toujours dans `auth.yaml` et rafraîchit `allowed_classes`, `escada_username`, `escada_has_password`, etc. ; sinon, redirection forcée vers `/login`.
## Droits d'accès par classe
Chaque user a une clé `allowed_classes: list[str] | null` :
- **`null` ou absente** → restriction non encore appliquée (user nouveau)
- **`[]` vide** → aucun accès → popup d'enrôlement obligatoire (cf. ci-dessous)
- **liste non vide** → l'user ne voit que ces classes dans /classe, /fiche, et les filtres de stats sur /accueil
- **role=admin** → voit tout (bypass de la restriction)
`src/user_access.py:get_allowed_classes(username)` est l'API canonique. Toutes les pages user-facing l'appellent pour filtrer.
## Self-service enrôlement (popup obligatoire)
Quand un user se connecte et que `allowed_classes` est `[]` (ou non défini), un **dialog forcé** s'ouvre sur toutes les pages :
> **Configurez votre accès**
>
> Pour récupérer la liste des classes auxquelles vous avez accès dans Escadaweb,
> saisissez vos identifiants Escada + un code TOTP courant.
Champs :
- Email Escada (= `escada_username`)
- Mot de passe Escada (stocké clair dans `auth.yaml`)
- Code 2FA courant (utilisé une fois — non stocké)
À la soumission, le script `scripts/fetch_user_classes.py` est lancé **en arrière-plan** (`@rx.event(background=True)` + `asyncio.create_subprocess_exec`) pour ne pas bloquer l'app pour les autres users :
1. Playwright headless lance un profil temporaire isolé
2. Login Keycloak avec les creds + TOTP fourni
3. Scrape la liste des classes accessibles dans Escada
4. Filtre MP / MI / classes « Formation »
5. Sauvegarde dans `auth.yaml` : `allowed_classes`, `escada_username`, `escada_password`
6. Log live dans `operations.log` (préfixe `[fetch_classes:<username>]`) → visible dans `/logs`
Le popup peut être fermé avec « Plus tard » (`enroll_dismissed=True`), il réapparaîtra au prochain login.
## Page « Mon profil » (`/profile`)
Accessible via le popover sidebar :
- Avatar
- Liste actuelle des `allowed_classes`
- Bouton « Relancer la synchronisation » pour rafraîchir la liste (même script que le popup)
- Modifier les identifiants Escada (sans relancer la sync immédiatement)
## Réinitialisation des droits (admin)
Sur la page `/users`, un bouton « Réinitialiser les droits » (par user) :
- Efface `allowed_classes`
- Efface `escada_username` + `escada_password`
→ Au prochain login de cet user, le popup d'enrôlement réapparaît.
## Rôles
| Page | user | admin |
|-------------------|------|-------|
| `/accueil` | ✅ | ✅ |
| `/fiche` | ✅ | ✅ |
| `/classe` | ✅ | ✅ |
| `/doc` | ✅ | ✅ |
| `/escada` | ❌ | ✅ |
| `/cron` | ❌ | ✅ |
| `/logs` | ❌ | ✅ |
| `/users` | ❌ | ✅ |
| `/params` | ❌ | ✅ |
| Page | user (sans allowed_classes) | user (avec allowed_classes) | admin |
|-------------------|----------------------------|------------------------------|-------|
| `/accueil` | ✅ (filtré) | ✅ (filtré) | ✅ |
| `/classe` | ✅ (filtré) | ✅ (filtré) | ✅ |
| `/fiche` | ✅ (filtré) | ✅ (filtré) | ✅ |
| `/doc` | ✅ | ✅ | ✅ |
| `/profile` | ✅ | ✅ | ✅ |
| `/escada` | ❌ | ❌ | ✅ |
| `/cron` | ❌ | ❌ | ✅ |
| `/logs` | ❌ | ❌ | ✅ |
| `/users` | ❌ | ❌ | ✅ |
| `/params` | ❌ | ❌ | ✅ |
| `/feedback` | ❌ | ❌ | ✅ |
## Gestion des utilisateurs
## Gestion des utilisateurs (admin)
Page `/users` (admin) :
Page `/users` :
- Créer / supprimer des utilisateurs
- Changer le rôle
- Réinitialiser le 2FA (efface `totp_secret` → forcera une nouvelle config au prochain login)
- Réinitialiser les droits (cf. ci-dessus)
- Définir / changer un avatar
- Clic sur une ligne ouvre directement le panneau d'édition
## Logout
Bouton "Déconnexion" en bas de la sidebar. Vide le `localStorage` et redirige vers `/login`.
Bouton « Déconnexion » dans le popover profil de la sidebar. Vide le `localStorage` (y compris `enroll_dismissed`) et redirige vers `/login`.
## Stockage des avatars

View file

@ -2,25 +2,25 @@
## Synchronisation Escada
### "Import timeout — vérifiez les logs (> 15min)"
### « Import timeout — vérifiez les logs (> 15 min) »
Le subprocess Selenium n'a pas répondu dans le temps imparti. Causes possibles :
Le subprocess Playwright 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)
2. Si Playwright 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"
### « Aucune classe récupérée »
Le scraping Selenium a échoué — souvent token de session expiré.
Le scraping Playwright a échoué — souvent token de session expiré.
**Que faire** : recliquer sur "Actualiser" (ça force un re-login propre).
**Que faire** : recliquer sur « Actualiser » (force un re-login propre).
### "Le push échoue toujours sur le même apprenti"
### « 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
@ -32,7 +32,7 @@ Possibles causes :
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 ?"
### « 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)
@ -42,12 +42,12 @@ Pas dangereux mais **destructif** :
## Tâches planifiées (cron)
### "J'ai créé une tâche, elle ne se déclenche pas"
### « 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`)
2. Les heures choisies sont-elles dans le bon fuseau horaire ? (l'app fonctionne en `Europe/Zurich`)
3. La crontab du host appelle-t-elle bien `cron_tick.py` ?
```bash
@ -55,58 +55,109 @@ Vérifier dans l'ordre :
# 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/`
4. Regarder le log d'exécution : `ls /logs/cron/` et `tail` sur le dernier fichier.
### "La tâche ne notifie pas sur Telegram"
### « J'avais un job 'toutes les X minutes', il a disparu »
L'ancien mode `interval` a été remplacé par `daily_multi`. Au boot du container, les jobs `interval` sont **automatiquement migrés** en `daily_multi` (l'intervalle est déroulé sur 24 h depuis minuit). Idem pour les anciens jobs `daily` (`HH:MM`) → convertis en `daily_multi` avec une seule heure.
Si tu veux modifier un de ces jobs : ouvre-le, tu verras la grille 24 cases avec les heures qui étaient configurées.
### « 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"
- Le `bot_token` et `chat_id` doivent être valides → tester via le bouton « Tester Telegram »
## Édition d'absences
### « Le bouton 'Absent toute la journée' est grisé »
Affichage « Absent toute la journée (Données chronoplan manquantes) » → aucun horaire n'est configuré pour cette classe et ce jour de la semaine.
**Que faire** : admin → `/params → Horaires de classe`, sélectionner la classe, cocher les périodes du jour concerné, enregistrer.
### « Quel est le type de jour affiché en badge ? »
Théorie / Pratique / Matu — défini par classe et par jour dans `/params → Horaires de classe`. Sert d'indication contextuelle dans le panneau d'édition.
## Authentification
### "J'ai perdu mon téléphone avec mon code 2FA"
### « Mon utilisateur n'a accès à aucune classe »
Un admin peut réinitialiser le 2FA via la page `/users` : "Réinitialiser 2FA". Au prochain login, l'utilisateur reverra le QR code.
À sa première connexion, un dialog s'ouvre pour configurer l'accès. Il doit fournir :
- son email Escada (identifiant Keycloak)
- son mot de passe Escada
- un code TOTP courant
### "Mon utilisateur est bloqué après plusieurs tentatives"
Un script Playwright tourne en arrière-plan (visible dans `/logs` avec préfixe `[fetch_classes:<username>]`) et remplit automatiquement `allowed_classes` dans `auth.yaml`.
Pas de blocage automatique pour le moment. Si on veut en ajouter un : voir `state.py:handle_login`.
Si le dialog est fermé (« Plus tard »), il réapparaîtra au prochain login.
### « J'ai perdu mon téléphone avec mon code 2FA »
Un admin peut réinitialiser le 2FA via `/users` : bouton « Réinitialiser 2FA ». Au prochain login, l'utilisateur reverra le QR code.
### « Comment révoquer l'accès d'un user »
Admin → `/users` → bouton « Réinitialiser les droits » : efface `allowed_classes` + identifiants Escada. À sa prochaine connexion, le popup d'enrôlement réapparaîtra (s'il ne le reconfigure pas, il n'aura accès à rien).
## Données
### "Les BN affichent des trous (cellules vides)"
### « 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.
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. Toutes les notes (pas juste les moyennes) sont stockées et affichées.
### "Les notes Matu n'apparaissent pas"
### « 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**.
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**.
### « L'adresse sur les avis sanction/retenue est fausse »
Depuis mai 2026, l'app n'utilise **plus l'adresse de l'entreprise**. Elle prend :
- l'adresse du **représentant légal** si l'apprenti est mineur (`majeur=False`)
- l'adresse perso de **l'apprenti** sinon
Vérifier que les champs `ApprentiFiche.resp_legal_*` et `ApprentiFiche.adresse/code_postal/localite` sont bien remplis (sync option « Données apprentis »).
## Performance
### "L'app rame quand je change de classe avec beaucoup d'apprentis"
### « 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).
### « L'app est bloquée pour les autres users quand je lance la synchro de mes classes »
C'était un bug : le subprocess Playwright bloquait l'event loop. Corrigé en passant à `@rx.event(background=True)` + `asyncio.create_subprocess_exec`. Si ça revient, vérifier que `fetch_my_classes` est bien décoré `background=True` dans `profile.py`.
## Conteneur Docker
### "Le conteneur consomme 100% CPU à l'idle"
### « 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`.
### « La version dans le sidebar ne change pas après un nouveau tag git »
### "Comment redémarrer proprement"
`data/VERSION` doit être mis à jour manuellement (le `.git` du container dev n'est pas synchronisé avec celui du hôte). Édite le fichier, puis :
```bash
docker restart eptm-dashboard-app-1
```
### « 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"
### « Comment voir les logs du serveur Reflex »
```bash
docker logs -f eptm-dashboard-app-1
```
## SSL / Accès depuis le web
### « Mon navigateur affiche un avertissement sécurité sur dev.dashboard.eptm-automation.ch »
Vérifier le certificat affiché : si l'émetteur est « Fortinet CA » (et non Let's Encrypt), c'est ton firewall qui fait du SSL inspection / IPS — pas un problème du serveur. À demander à l'IT pour whitelister le domaine.

View file

@ -0,0 +1,83 @@
# Avis de sanction & retenue
L'application génère des PDFs officiels d'avis de sanction et d'avis de retenue à partir des templates AcroForm fournis (`data/templates/GF_FO_Avis_de_sanction.pdf` et `GF_FO_Avis_de_retenue.pdf`). Les champs du formulaire restent éditables après téléchargement.
## Où créer un avis
Bouton **« Créer un avis de retenue »** (orange) ou **« Créer un avis de sanction »** (rouge) :
- Sur `/fiche` (Apprentis) — dans le bandeau d'actions sous les KPIs
- Sur `/classe` (Classes) — sur chaque carte apprenti, même bandeau
Cliquer ouvre une modale dédiée pré-remplie avec l'apprenti sélectionné.
## Modale Avis de sanction
Champs :
- **Apprenti** (verrouillé, pré-rempli)
- **Texte de description** : pré-rempli depuis `settings.texte_sanction` (configurable en /params)
- **Chef de section** : pré-rempli depuis `settings.chef_section`
- **Préfixe utilisateur** : `(<username>) ` est ajouté en début de la remarque enregistrée en notice (traçabilité)
3 actions :
- **Télécharger uniquement** — génère le PDF + crée la notice Escada en `pending`
- **Envoyer par email** — choix destinataire (apprenti / formateur / autre adresse libre)
- **Détecte les notices doublons** : si une notice du même type a déjà été créée aujourd'hui, l'app le signale avec un toast et propose « Créer quand même »
Filtre : **uniquement les classes EM** côté UI (les classes DUAL ne peuvent pas générer d'avis de sanction).
## Modale Avis de retenue
Champs :
- **Apprenti**
- **Profession** (auto-calculée depuis le préfixe de classe via `prof_mapping` configuré en /params)
- **Date de retenue** (date d'envoi)
- **Date du problème** (date à laquelle l'incident s'est produit)
- **Case cochée** : Devoir non rendu / Comportement / Retard
- **Branche** (uniquement si « Devoir non rendu »)
- **Remarque libre** (préfixée par `(<username>) `)
- **Vos initiales** (champ `Profs` du template)
Le template a un champ `Date` partagé entre 3 lignes ; le code [src/retenue_pdf.py:_split_date_field](src/retenue_pdf.py) sépare les widgets pour ne remplir que la date correspondant à la case cochée.
## Destinataire de l'avis (adresse imprimée sur le PDF)
Depuis mai 2026, l'adresse de l'**entreprise n'est plus utilisée**. Logique unifiée (`_destinataire(apprenti, fiche)` dans les deux modules PDF) :
| Statut apprenti | Destinataire (NomParents / NomEntreprise + Adresse + NPA-Ville) |
|---------------------------|------------------------------------------------------------------|
| Mineur (`majeur=False`) | Représentant légal (`resp_legal_*`) |
| Majeur (`majeur=True`) | Apprenti lui-même (`fiche.adresse/code_postal/localite`) |
| Inconnu / pas de fiche | Apprenti lui-même |
> Pré-requis : la sync Escada avec option « Données apprentis » doit avoir été lancée pour que `ApprentiFiche.majeur` + `resp_legal_*` soient remplis.
## Notice Escada associée
Chaque avis téléchargé crée une `Notice` (table locale) avec :
- `source` = `"sanction"` ou `"retenue"`
- `status` = `"pending"`
- `titre` = « Avis de sanction » / « Est arrivé en retard aux cours » / etc.
- `remarque` = `(<username>) <texte saisi>` — le préfixe sert d'identification de l'auteur en attendant un compte Escada par utilisateur
- `date_event` = aujourd'hui
Ces notices sont poussées vers Escada par `scripts/push_notices.py` (cf. [Push vers Escada](#)).
## Configuration des défauts
`/params → Avis de sanction` :
- Texte de description par défaut
- Chef de section (CS) par défaut
`/params → Correspondances classe → profession` :
- Mapping `préfixe de classe → profession` utilisé sur les avis de retenue. Ex : `AUTOMAT``Automaticien CFC`.
- Liste des classes « orphelines » (sans mapping) en chips jaunes — clic pour pré-remplir le formulaire.
- Bouton « Appliquer aux fiches existantes » : recalcule `ApprentiFiche.profession` pour tous les apprentis selon le mapping actuel.
## Audit
```
[notice] prof.demo : création (sanction) pour Dupont Marc (1EM1)
[notice] prof.demo : notice doublon évitée pour Dupont Marc (existante : Avis de sanction — créée le 12.05.2026 10:23)
[notice] prof.demo : création (retenue) pour Martin Léa (2EM2) — case=retard
```

80
data/docs/12-feedback.md Normal file
View file

@ -0,0 +1,80 @@
# Feedback in-app (chat widget)
L'application embarque un widget de feedback permettant à n'importe quel utilisateur de signaler un bug ou de proposer une idée d'amélioration, sans quitter l'app.
## Côté utilisateur
### Bouton flottant (FAB)
Un bouton circulaire bleu flottant en **bas-droite** de l'écran (icône bulle), visible sur toutes les pages. Au clic, ouvre une modale chat.
### Modale chat
- **Champ de type** : Bug / Proposition (radio)
- **Champ message** : textarea multiligne, auto-scroll en bas après chaque envoi
- **Bouton « Envoyer »**
- **Historique** : les messages précédents (envoyés par l'user) et les réponses admin sont affichés sous forme de bulles type chat
### Notification visuelle
Si un admin a répondu mais que l'user n'a pas encore consulté, l'**icône du FAB** change de couleur (orange) pour indiquer un nouveau message.
## Côté admin
### Page `/feedback`
Liste de tous les feedbacks reçus, triés par date desc :
- **Statut** : new (bleu) / in_progress (orange) / resolved (vert)
- **Type** : Bug / Proposition
- **Auteur** : nom complet + email
- **Message**
- **Page d'origine** (URL de l'app où l'user était au moment du clic)
- **Réponse admin** (textarea)
- **3 boutons d'envoi** :
- **Envoyer uniquement** : envoie le message à l'user (visible dans son chat) sans changer le statut
- **Envoyer + Marquer en cours** : `status → in_progress`
- **Envoyer + Marquer résolu** : `status → resolved`
Cliquer sur une ligne ouvre directement le panneau d'édition.
### Email de notification
À la création d'un feedback, un email est envoyé à l'adresse configurée en **/params → Configuration email → Email admin (feedback in-app)**. Si cette adresse est vide, aucun email n'est envoyé.
L'email contient :
- Type + message
- Page d'origine
- Lien direct vers `/feedback` pour répondre
### Réponse → email vers l'auteur
Quand l'admin clique sur « Envoyer + … », l'app :
1. Met à jour `FeedbackMessage.admin_response` + `response_sent_at`
2. Envoie un email à `FeedbackMessage.user_email` avec la réponse
3. Met à jour le statut selon le bouton choisi
4. Côté user, la réponse apparaît en bulle dans le chat à la prochaine ouverture
## Modèle de données
Table `FeedbackMessage` (`src/db.py`) :
```
id, created_at, created_by (username), user_email, type ("bug"|"feature"),
message, context_url, status ("new"|"in_progress"|"resolved"),
admin_response, response_sent_at
```
## Configuration
Tout est centralisé dans `/params → Configuration email` :
- SMTP (hôte, port, login, password, sender) — partagé avec l'envoi de récap d'absences
- **Email admin (feedback in-app)** — destinataire des notifs feedback
## Notes techniques
- Le titre de la modale est `"Feedback"` (anciennement « Aide & feedback EPTM », renommé après que Edge traduisait l'objet automatiquement).
- Pour éviter la traduction auto du navigateur sur les textes critiques, l'app utilise :
- `<meta name="google" content="notranslate">` global
- `<html lang="fr">` + `translate="no"` injecté au boot
- Classe `notranslate` + `custom_attrs={"translate": "no"}` sur les composants concernés

View file

@ -0,0 +1,99 @@
# Paramètres (`/params`)
Page admin centralisant toute la configuration applicative. Toutes les valeurs sont persistées dans `data/settings.json` sauf la section « Correspondances classe → profession » qui vit dans son propre fichier `data/profession_mapping.json`.
## Sections
### Application
- **URL de base** : utilisée pour générer les liens dans les emails (reset mot de passe, enrôlement). Ex : `https://dashboard.eptm-automation.ch`. Stocké dans `settings.app_base_url`.
### Correspondances classe → profession
Mapping `préfixe de classe → profession` utilisé pour pré-remplir le champ « Profession » sur les avis de retenue, et pour `ApprentiFiche.profession`.
- Tableau des mappings actuels (suppression possible)
- Chips jaunes listant les classes en base **sans correspondance** — clic pour pré-remplir le formulaire
- Bouton « Ajouter / mettre à jour » : insère ou remplace
- Bouton « Appliquer aux fiches existantes » : recalcule `ApprentiFiche.profession` pour tous les apprentis selon le mapping actuel (logging dans `operations.log`)
### Horaires de classe (« Absent toute la journée »)
Définit pour chaque classe + chaque jour de la semaine :
- Le **type de jour** : Théorie / Pratique / Matu / —
- Les **périodes de cours** (1 à 10)
UI : dropdown classe + grille 5 colonnes (Lun → Ven) × 10 cases.
Le bouton « Absent toute la journée » sur la fiche apprenti utilise ce mapping pour marquer comme `N` uniquement les périodes correspondantes au jour de la semaine sélectionné. Le **badge** (Théorie/Pratique/Matu) s'affiche aussi dans le panneau d'édition.
Stocké dans `settings.class_schedule` :
```json
{
"AUTOMAT 1": {
"MON": { "type": "theorie", "periods": [1, 2, 3, 4] },
"TUE": { "type": "pratique", "periods": [5, 6, 7, 8] }
}
}
```
### Avis de sanction
- **Texte de description par défaut** (champ `TexteDescription` du PDF)
- **Chef de section** par défaut (champ `CS`)
Repris à la création de chaque avis de sanction si l'utilisateur ne saisit rien d'autre.
### Configuration email
- **Serveur SMTP** + **port**
- **Login** + **mot de passe** SMTP
- **Expéditeur** (header From)
- **Email admin (feedback in-app)** : destinataire des notifications du chat feedback
Brevo (smtp-relay.brevo.com) est utilisé en prod.
### Connexion Escada (synchro automatique)
- **Identifiant Escada** (email Keycloak)
- **Mot de passe Escada**
- **Clé secrète 2FA (TOTP)** — format Base32
Permettent à la sync automatique (cron) et à la sync manuelle de se connecter sans intervention. Le code TOTP est généré à la volée par `pyotp.TOTP(secret).now()`.
> Ces identifiants servent uniquement aux tâches automatiques. Pour l'enrôlement self-service d'un user, c'est l'user qui saisit ses propres creds dans le popup de profil (cf. doc Auth).
### Template email
Template appliqué à l'envoi de récap d'absences depuis la fiche apprenti :
- **Objet** : par défaut `Document EPTM — {nom_complet} ({classe})`
- **Corps** : par défaut un message court avec `{prenom}` + `{classe}`
Variables disponibles : `{prenom}`, `{nom}`, `{nom_complet}`, `{classe}`, `{nb_absences}`, `{nb_excusees}`, `{nb_non_excusees}`, `{nb_a_traiter}`, `{semestre}`, `{date_du_jour}`.
## Fichier `data/settings.json`
Structure typique :
```json
{
"app_base_url": "https://dashboard.eptm-automation.ch",
"texte_sanction": "Selon le règlement de l'EM, ...",
"chef_section": "Patrick Rausis",
"smtp_host": "smtp-relay.brevo.com",
"smtp_port": 587,
"smtp_login": "...",
"smtp_password": "...",
"smtp_sender": "EPTM Automation <noreply@eptm-automation.ch>",
"feedback_admin_email": "admin@eptm-automation.ch",
"escada_username": "...",
"escada_password": "...",
"totp_secret": "...",
"email_subject": "Document EPTM — {nom_complet} ({classe})",
"email_body": "Bonjour {prenom}, ...",
"class_schedule": { ... }
}
```
Audit minimal : chaque modification depuis `/params` est sauvegardée d'un coup (toute la clé concernée). Pas de versioning ; un backup ponctuel de `data/settings.json` suffit.

View file

@ -8,5 +8,499 @@
"escada_password": "Lauryne2023!",
"totp_secret": "KQZVCQLXGNAU22KRKNCHCYSUIRAXAUSR",
"app_base_url": "https://dev.dashboard.eptm-automation.ch",
"feedback_admin_email": "julien.balet@edu.vs.ch"
"feedback_admin_email": "julien.balet@edu.vs.ch",
"email_subject": "Document EPTM — {nom_complet} ({classe})",
"email_body": "Bonjour {nom_complet},\n\nVeuillez trouver ci-joint vos documents.\n\nCordialement,\nL'équipe EPTM",
"class_schedule": {
"AUTOMAT 1": {
"MON": {
"type": "theorie",
"periods": [
1,
2,
3,
4,
5,
7,
8,
9,
10
]
},
"TUE": {
"type": "",
"periods": []
},
"WED": {
"type": "",
"periods": []
},
"THU": {
"type": "theorie",
"periods": [
1,
2,
3,
4,
5,
6,
8,
9,
10
]
},
"FRI": {
"type": "",
"periods": []
}
},
"AUTOMAT 2": {
"MON": {
"type": "",
"periods": []
},
"TUE": {
"type": "",
"periods": []
},
"WED": {
"type": "",
"periods": []
},
"THU": {
"type": "theorie",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9,
10
]
},
"FRI": {
"type": "theorie",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9,
10
]
}
},
"AUTOMAT 3": {
"MON": {
"type": "",
"periods": []
},
"TUE": {
"type": "theorie",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9,
10
]
},
"WED": {
"type": "",
"periods": []
},
"THU": {
"type": "",
"periods": []
},
"FRI": {
"type": "",
"periods": []
}
},
"AUTOMAT 4": {
"MON": {
"type": "theorie",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9
]
},
"TUE": {
"type": "",
"periods": []
},
"WED": {
"type": "",
"periods": []
},
"THU": {
"type": "",
"periods": []
},
"FRI": {
"type": "",
"periods": []
}
},
"EM-AU 1": {
"MON": {
"type": "theorie",
"periods": [
1,
2,
3,
4,
5,
6,
8,
9
]
},
"TUE": {
"type": "theorie",
"periods": [
1,
2,
3,
4,
5,
7,
8,
9,
10
]
},
"WED": {
"type": "pratique",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9
]
},
"THU": {
"type": "matu",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9,
10
]
},
"FRI": {
"type": "pratique",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9
]
}
},
"EM-AU 2": {
"MON": {
"type": "pratique",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9
]
},
"TUE": {
"type": "pratique",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9
]
},
"WED": {
"type": "matu",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9,
10
]
},
"THU": {
"type": "theorie",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9
]
},
"FRI": {
"type": "pratique",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9
]
}
},
"EM-AU 3": {
"MON": {
"type": "matu",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9,
10
]
},
"TUE": {
"type": "theorie",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9
]
},
"WED": {
"type": "pratique",
"periods": [
1,
2,
3,
4,
8,
9,
10
]
},
"THU": {
"type": "pratique",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9,
10
]
},
"FRI": {
"type": "pratique",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9,
10
]
}
},
"EM-AU 4": {
"MON": {
"type": "theorie",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9
]
},
"TUE": {
"type": "matu",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9,
10
]
},
"WED": {
"type": "",
"periods": []
},
"THU": {
"type": "",
"periods": []
},
"FRI": {
"type": "",
"periods": []
}
},
"MONTAUT 1": {
"MON": {
"type": "",
"periods": []
},
"TUE": {
"type": "",
"periods": []
},
"WED": {
"type": "",
"periods": []
},
"THU": {
"type": "",
"periods": []
},
"FRI": {
"type": "theorie",
"periods": [
1,
2,
3,
4,
5,
7,
8,
9,
10
]
}
},
"MONTAUT 2": {
"MON": {
"type": "",
"periods": []
},
"TUE": {
"type": "",
"periods": []
},
"WED": {
"type": "",
"periods": []
},
"THU": {
"type": "",
"periods": []
},
"FRI": {
"type": "theorie",
"periods": [
1,
2,
3,
4,
5,
7,
8,
9,
10
]
}
},
"MONTAUT 3": {
"MON": {
"type": "",
"periods": []
},
"TUE": {
"type": "",
"periods": []
},
"WED": {
"type": "",
"periods": []
},
"THU": {
"type": "theorie",
"periods": [
1,
2,
3,
4,
5,
7,
8,
9,
10
]
},
"FRI": {
"type": "",
"periods": []
}
}
}
}

View file

@ -34,6 +34,9 @@ app = rx.App(
# Force le rendu light du browser (form controls, scrollbars, etc.)
# même quand l'OS est en dark mode. Le thème "sombre" override via CSS.
rx.el.meta(name="color-scheme", content="light"),
# Empêche la traduction automatique du navigateur (Chrome/Edge traduisaient
# certains libellés français selon la locale OS de l'utilisateur).
rx.el.meta(name="google", content="notranslate"),
# Couleur de la barre d'adresse (Android) + barre de statut (iOS standalone)
rx.el.meta(name="theme-color", content="#dc000e"),
rx.el.meta(name="apple-mobile-web-app-capable", content="yes"),
@ -49,7 +52,8 @@ app = rx.App(
),
# Applique le thème stocké en localStorage avant le premier render —
# évite un flash au défaut EPTM puis bascule. Force aussi colorScheme
# pour empêcher le browser de bascule dark sur OS dark.
# pour empêcher le browser de bascule dark sur OS dark. Force aussi
# lang=fr et translate=no pour neutraliser la traduction automatique.
rx.el.script(
"""
(function() {
@ -61,6 +65,8 @@ app = rx.App(
}
document.documentElement.style.colorScheme =
(t === 'sombre') ? 'dark' : 'light';
document.documentElement.setAttribute('lang', 'fr');
document.documentElement.setAttribute('translate', 'no');
} catch(e) {}
})();
"""

View file

@ -255,18 +255,20 @@ def _notes_badge(badge: rx.Var) -> rx.Component:
def _notes_insuf_tile(item: rx.Var) -> rx.Component:
"""Tuile compacte 1 ligne : nom + badges moyennes. Click → fiche apprenti."""
return rx.flex(
"""Tuile compacte 2 lignes : nom puis badges moyennes. Click → fiche apprenti."""
return rx.vstack(
# Ligne 1 : nom
rx.text(
item["nom"], " ", item["prenom"],
size="2", color="#1a237e",
white_space="nowrap", overflow="hidden",
text_overflow="ellipsis",
flex="1", min_width="0",
width="100%",
),
# Ligne 2 : badges moyennes
rx.flex(
rx.foreach(item["badges"].to(list[dict]), _notes_badge),
gap="0.3rem", flex_wrap="wrap", flex_shrink="0",
gap="0.3rem", flex_wrap="wrap",
),
on_click=AccueilState.open_fiche(item["id"]),
cursor="pointer",
@ -274,11 +276,11 @@ def _notes_insuf_tile(item: rx.Var) -> rx.Component:
background_color="var(--surface)",
border="1px solid var(--border)",
border_radius="6px",
flex="1 1 280px",
min_width="280px",
max_width="400px",
align="center",
gap="0.5rem",
flex="1 1 220px",
min_width="220px",
max_width="280px",
spacing="2",
align="start",
class_name="hover-lift",
)

View file

@ -12,6 +12,8 @@ from ..state import AuthState
from ..sidebar import layout
from ..components import empty_state, skeleton_apprenti_card
from .fiche import FicheState, _notice_row
from .retenue import RetenueState, retenue_modal
from .sanction import SanctionState, sanction_modal
from src.db import (
get_session, Apprenti, Absence, ApprentiNotice,
NotesBulletin, NotesMatu, NotesExamen, ImportBN, ImportMatu,
@ -622,6 +624,7 @@ class ClasseState(AuthState):
"id": apprenti.id,
"nom": apprenti.nom,
"prenom": apprenti.prenom,
"label": f"{apprenti.prenom} {apprenti.nom}",
"total": total,
"excusees": excusees,
"non_exc": non_exc,
@ -727,6 +730,21 @@ def _kpi_mini(label: str, value, color: str = "#37474f") -> rx.Component:
)
def _kpi_card(label: str, value, color: str = "#37474f") -> rx.Component:
"""Carte KPI identique à fiche.py (taille 7, fond surface)."""
return rx.box(
rx.text(label, size="1", color="#666"),
rx.text(value, size="7", font_weight="700", color=color, class_name="tabular"),
padding="1rem",
background_color="var(--surface)",
border_radius="8px",
border="1px solid var(--border)",
flex="1",
min_width="120px",
class_name="hover-lift",
)
def _apprenti_card(item) -> rx.Component:
return rx.box(
# ── En-tête : nom + badge quota ───────────────────────────────────────
@ -756,55 +774,102 @@ def _apprenti_card(item) -> rx.Component:
margin_bottom="0.75rem",
),
# ── KPIs absences ─────────────────────────────────────────────────────
# ── KPI cards (identiques à la fiche apprenti) ────────────────────────
rx.flex(
_kpi_mini("Total", item["total"]),
_kpi_mini("Excusees", item["excusees"], "#2e7d32"),
_kpi_mini("Non excusees", item["non_exc"], "var(--brand-primary-dark)"),
_kpi_card("Périodes d'absence", item["total"]),
_kpi_card("Périodes à excuser", item["non_exc"], "var(--brand-primary-dark)"),
rx.box(
rx.text("Absences", size="1", color="#666"),
rx.text(
item["blocs"],
size="7", font_weight="700",
color=rx.cond(item["quota_atteint"], "#c62828", "#37474f"),
class_name="tabular",
),
rx.cond(
item["quota_atteint"],
_kpi_mini("Absences", item["blocs"], "var(--brand-primary-dark)"),
_kpi_mini("Absences", item["blocs"]),
rx.text(
"Avis de sanction",
size="1", weight="bold", color="#c62828",
),
gap="0.5rem",
),
padding="1rem",
background_color=rx.cond(item["quota_atteint"], "#fff0f0", "var(--surface)"),
border_radius="8px",
border=rx.cond(
item["quota_atteint"],
"1px solid #ffcdd2",
"1px solid var(--border)",
),
flex="1",
min_width="120px",
),
gap="1rem",
flex_wrap="wrap",
width="100%",
margin_bottom="0.75rem",
),
# ── Boutons téléchargement PDF ────────────────────────────────────────
# ── Actions (PDF exports + créations d'avis) ──────────────────────────
rx.box(
rx.flex(
rx.button(
rx.icon("download", size=13),
"PDF absences",
on_click=ClasseState.download_abs_pdf(item["id"]),
variant="outline",
color_scheme="gray",
size="1",
variant="outline", color_scheme="gray", size="2",
),
rx.cond(
item["has_pdf_bn"],
rx.button(
rx.icon("file-text", size=13),
rx.icon("download", size=13),
"PDF bulletin",
on_click=ClasseState.download_bn_pdf(item["id"]),
variant="outline",
color_scheme="blue",
size="1",
variant="outline", color_scheme="blue", size="2",
),
),
rx.cond(
item["has_pdf_notes"],
rx.button(
rx.icon("file-text", size=13),
rx.icon("download", size=13),
"PDF notes",
on_click=ClasseState.download_notes_pdf(item["id"]),
variant="outline",
color_scheme="violet",
size="1",
variant="outline", color_scheme="violet", size="2",
),
),
flex_wrap="wrap",
# Séparateur visuel
rx.box(
width="1px",
background_color="var(--gray-6)",
margin_x="0.25rem",
align_self="stretch",
),
rx.button(
rx.icon("file-warning", size=14),
"Créer un avis de retenue",
on_click=RetenueState.preload_apprenti(
item["id"], item["label"],
),
color_scheme="orange", variant="soft", size="2",
),
rx.button(
rx.icon("triangle-alert", size=14),
"Créer un avis de sanction",
on_click=SanctionState.preload_apprenti(
item["id"], item["label"],
),
color_scheme="red", variant="soft", size="2",
),
gap="0.5rem",
flex_wrap="wrap",
align="center",
width="100%",
),
padding="0.75rem 1rem",
background_color="var(--surface)",
border_radius="8px",
border="1px solid var(--border)",
width="100%",
margin_bottom="0.75rem",
),
@ -901,7 +966,10 @@ def _apprenti_card(item) -> rx.Component:
def classe_page() -> rx.Component:
return layout(
rx.vstack(
rx.heading("Vue classe", size="7"),
# Modals (rendus une fois, contrôlés par leur state respectif)
retenue_modal(),
sanction_modal(),
rx.heading("Classes", size="7"),
rx.cond(
ClasseState.has_classes,

View file

@ -54,10 +54,10 @@ class CronState(AuthState):
f_name: str = ""
f_enabled: bool = True
f_schedule_kind: str = "daily" # "daily" | "weekly" | "interval"
f_schedule_kind: str = "daily_multi" # "weekly" | "daily_multi"
f_time_hh: str = "03"
f_time_mm: str = "00"
f_interval_min: str = "60"
f_hours: list[str] = [] # ["00:00","06:00",...] pour daily_multi
f_days: list[str] = [] # ["MON","WED",...]
f_task_kind: str = "push_then_sync"
f_sync_abs: bool = True
@ -104,8 +104,6 @@ class CronState(AuthState):
@staticmethod
def _human_schedule(kind: str, value: str) -> str:
if kind == "daily":
return f"Tous les jours à {value}"
if kind == "weekly":
try:
days_part, time_part = value.split(":", 1)
@ -114,14 +112,13 @@ class CronState(AuthState):
return f"{labels} à {time_part}"
except ValueError:
return value
if kind == "interval":
try:
m = int(value)
if m % 60 == 0:
return f"Toutes les {m // 60} h"
return f"Toutes les {m} min"
except (TypeError, ValueError):
return value
if kind == "daily_multi":
hours = [h.strip() for h in (value or "").split(",") if h.strip()]
if not hours:
return "Aucune heure définie"
if len(hours) <= 6:
return "Tous les jours à " + ", ".join(hours)
return f"Tous les jours — {len(hours)} créneaux ({hours[0]}{hours[-1]})"
return value
@staticmethod
@ -130,27 +127,25 @@ class CronState(AuthState):
if not job.enabled:
return ""
now = datetime.now()
if job.schedule_kind == "interval":
if job.schedule_kind == "daily_multi":
hours = [h.strip() for h in (job.schedule_value or "").split(",") if h.strip()]
best: datetime | None = None
for hhmm in hours:
try:
m = int(job.schedule_value)
except (TypeError, ValueError):
return ""
if job.last_run_at is None:
return "Au prochain tick"
nxt = job.last_run_at + timedelta(minutes=m)
return nxt.strftime("%d.%m %H:%M")
if job.schedule_kind == "daily":
try:
hh, mm = job.schedule_value.split(":")
target = now.replace(hour=int(hh), minute=int(mm), second=0, microsecond=0)
hh, mm = hhmm.split(":")
target = now.replace(hour=int(hh), minute=int(mm),
second=0, microsecond=0)
except (ValueError, AttributeError):
continue
# Si déjà exécuté à ce créneau aujourd'hui, on le pousse au lendemain.
if (job.last_run_at and job.last_run_at.date() == now.date()
and job.last_run_at >= target):
target += timedelta(days=1)
elif target < now:
target += timedelta(days=1)
return target.strftime("%d.%m %H:%M")
except (ValueError, AttributeError):
return ""
if best is None or target < best:
best = target
return best.strftime("%d.%m %H:%M") if best else ""
if job.schedule_kind == "weekly":
return "Selon planning"
return ""
@ -190,10 +185,10 @@ class CronState(AuthState):
self.edit_open = True
self.f_name = ""
self.f_enabled = True
self.f_schedule_kind = "daily"
self.f_schedule_kind = "daily_multi"
self.f_time_hh = "03"
self.f_time_mm = "00"
self.f_interval_min = "60"
self.f_hours = ["03:00"]
self.f_days = ["MON", "TUE", "WED", "THU", "FRI"]
self.f_task_kind = "push_then_sync"
self.f_sync_abs = True
@ -221,13 +216,7 @@ class CronState(AuthState):
self.f_name = job.name
self.f_enabled = job.enabled
self.f_schedule_kind = job.schedule_kind
if job.schedule_kind == "daily":
hh, _, mm = (job.schedule_value or "03:00").partition(":")
self.f_time_hh = hh.zfill(2)
self.f_time_mm = mm.zfill(2)
self.f_interval_min = "60"
self.f_days = ["MON", "TUE", "WED", "THU", "FRI"]
elif job.schedule_kind == "weekly":
if job.schedule_kind == "weekly":
try:
days_part, time_part = job.schedule_value.split(":", 1)
hh, _, mm = time_part.partition(":")
@ -238,9 +227,21 @@ class CronState(AuthState):
self.f_time_hh = "03"
self.f_time_mm = "00"
self.f_days = ["MON", "TUE", "WED", "THU", "FRI"]
self.f_interval_min = "60"
else: # interval
self.f_interval_min = job.schedule_value or "60"
self.f_hours = []
else: # daily_multi
hours_norm: list[str] = []
for h in (job.schedule_value or "").split(","):
h = h.strip()
if not h:
continue
parts = h.split(":")
if len(parts) >= 2 and parts[0].isdigit():
# On garde uniquement les heures pleines (00:00, 01:00, ...).
hh_i = int(parts[0])
if 0 <= hh_i < 24:
hours_norm.append(f"{hh_i:02d}:00")
# Dédoublonnage + tri
self.f_hours = sorted(set(hours_norm))
self.f_time_hh = "03"
self.f_time_mm = "00"
self.f_days = ["MON", "TUE", "WED", "THU", "FRI"]
@ -288,9 +289,11 @@ class CronState(AuthState):
def set_f_time_mm(self, v: str):
v = "".join(ch for ch in v if ch.isdigit())[:2]
self.f_time_mm = v
def set_f_interval_min(self, v: str):
v = "".join(ch for ch in v if ch.isdigit())[:5]
self.f_interval_min = v
def toggle_f_hour(self, h: str):
if h in self.f_hours:
self.f_hours = [x for x in self.f_hours if x != h]
else:
self.f_hours = sorted(self.f_hours + [h])
def toggle_f_day(self, day: str):
if day in self.f_days:
self.f_days = [d for d in self.f_days if d != day]
@ -322,16 +325,7 @@ class CronState(AuthState):
return
# Construire schedule_value selon kind
if self.f_schedule_kind == "daily":
try:
hh = int(self.f_time_hh or "0"); mm = int(self.f_time_mm or "0")
if not (0 <= hh < 24 and 0 <= mm < 60):
raise ValueError
except ValueError:
self.save_error = "Heure invalide."
return
schedule_value = f"{hh:02d}:{mm:02d}"
elif self.f_schedule_kind == "weekly":
if self.f_schedule_kind == "weekly":
if not self.f_days:
self.save_error = "Sélectionne au moins un jour de la semaine."
return
@ -344,15 +338,11 @@ class CronState(AuthState):
return
ordered = [d for d in _DAY_NAMES if d in self.f_days]
schedule_value = f"{','.join(ordered)}:{hh:02d}:{mm:02d}"
else: # interval
try:
m = int(self.f_interval_min or "0")
if m < 1:
raise ValueError
except ValueError:
self.save_error = "Intervalle invalide (minutes > 0)."
else: # daily_multi
if not self.f_hours:
self.save_error = "Sélectionne au moins une heure d'exécution."
return
schedule_value = str(m)
schedule_value = ",".join(sorted(set(self.f_hours)))
if self.f_classes_all:
classes_json = "ALL"
@ -531,12 +521,12 @@ def _job_row(job: rx.Var) -> rx.Component:
rx.hstack(
rx.button(
rx.icon(rx.cond(job["enabled"], "pause", "play"), size=14),
on_click=CronState.toggle_enabled(job["id"]),
on_click=CronState.toggle_enabled(job["id"]).stop_propagation,
variant="ghost", size="1", color_scheme="gray",
),
rx.button(
rx.icon("pencil", size=14),
on_click=CronState.open_edit(job["id"]),
on_click=CronState.open_edit(job["id"]).stop_propagation,
variant="ghost", size="1", color_scheme="gray",
),
rx.alert_dialog.root(
@ -578,6 +568,53 @@ def _job_row(job: rx.Var) -> rx.Component:
border="1px solid var(--gray-5)",
border_radius="6px",
width="100%",
# Click sur la row entière ouvre le panneau d'édition.
on_click=CronState.open_edit(job["id"]),
cursor="pointer",
_hover={"background_color": "var(--surface-hover)"},
)
def _hours_grid() -> rx.Component:
"""Grille 24 cases (00h23h) pour le mode daily_multi."""
cells = []
for h in range(24):
hhmm = f"{h:02d}:00"
cells.append(
rx.box(
rx.text(f"{h:02d}", size="1", weight="bold"),
on_click=CronState.toggle_f_hour(hhmm),
cursor="pointer",
padding="0.4rem 0",
border_radius="6px",
border="2px solid",
text_align="center",
border_color=rx.cond(
CronState.f_hours.contains(hhmm),
"var(--red-9)", "var(--gray-6)",
),
background_color=rx.cond(
CronState.f_hours.contains(hhmm),
"var(--red-9)", "transparent",
),
color=rx.cond(
CronState.f_hours.contains(hhmm),
"white", "var(--gray-12)",
),
)
)
return rx.vstack(
rx.text(
"Heures d'exécution (clic pour activer / désactiver)",
size="1", color="var(--gray-10)",
),
rx.grid(
*cells,
columns="6",
gap="0.3rem",
width="100%",
),
spacing="2", width="100%",
)
@ -585,23 +622,11 @@ def _form_schedule_picker() -> rx.Component:
return rx.vstack(
rx.text("Planification", size="2", font_weight="600"),
rx.radio(
["daily", "weekly", "interval"],
["daily_multi", "weekly"],
value=CronState.f_schedule_kind,
on_change=CronState.set_f_schedule_kind,
direction="row",
),
rx.cond(
CronState.f_schedule_kind == "interval",
rx.hstack(
rx.text("Toutes les", size="2"),
rx.input(
value=CronState.f_interval_min,
on_change=CronState.set_f_interval_min,
width="80px",
),
rx.text("minutes", size="2"),
spacing="2", align="center",
),
rx.cond(
CronState.f_schedule_kind == "weekly",
rx.vstack(
@ -643,17 +668,7 @@ def _form_schedule_picker() -> rx.Component:
),
spacing="2",
),
# daily
rx.hstack(
rx.text("Heure :", size="2"),
rx.input(value=CronState.f_time_hh,
on_change=CronState.set_f_time_hh, width="60px"),
rx.text(":", size="3"),
rx.input(value=CronState.f_time_mm,
on_change=CronState.set_f_time_mm, width="60px"),
spacing="2", align="center",
),
),
_hours_grid(),
),
spacing="2", width="100%",
)

View file

@ -204,12 +204,13 @@ def _bubble(msg: rx.Var) -> rx.Component:
is_user,
rx.fragment(),
rx.flex(
rx.icon("bot", size=14, color="white"),
rx.icon("bot", color="white"),
background_color="var(--brand-accent)",
border_radius="50%",
width="28px", height="28px",
align="center", justify="center",
flex_shrink="0",
class_name="feedback-bot-bubble",
),
),
rx.box(
@ -341,8 +342,11 @@ def feedback_widget() -> rx.Component:
rx.flex(
rx.icon("message-square", size=18, color="white"),
rx.text(
"Aide & feedback EPTM",
"Feedback",
size="3", weight="bold", color="white",
# translate="no" empêche la traduction auto du browser.
class_name="notranslate",
custom_attrs={"translate": "no"},
),
rx.spacer(),
rx.dialog.close(

View file

@ -447,6 +447,9 @@ class FicheState(AuthState):
# ── Calendar day edit ─────────────────────────────────────────────────────
edit_date: str = ""
edit_date_label: str = ""
edit_day_type: str = "" # "theorie" | "pratique" | "matu" | ""
edit_day_type_label: str = "" # "Théorie" | "Pratique" | "Matu" | ""
edit_day_has_schedule: bool = False # True si périodes configurées pour ce jour
edit_p1: str = "present"
edit_p2: str = "present"
edit_p3: str = "present"
@ -666,6 +669,26 @@ class FicheState(AuthState):
Absence.date == d,
)
).scalars().all()
# Horaire de classe (settings.json) : type + périodes pour ce jour.
ap = sess.get(Apprenti, self.selected_id) if self.selected_id else None
day_names = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"]
day_key = day_names[d.weekday()]
d_type = ""
d_periods: list[int] = []
if ap:
settings = _read_settings()
class_sch = (settings.get("class_schedule") or {}).get(ap.classe) or {}
entry = class_sch.get(day_key)
if isinstance(entry, dict):
d_type = (entry.get("type") or "").strip()
d_periods = list(entry.get("periods") or [])
elif isinstance(entry, list):
d_periods = list(entry)
self.edit_day_type = d_type
self.edit_day_type_label = {
"theorie": "Théorie", "pratique": "Pratique", "matu": "Matu",
}.get(d_type, "")
self.edit_day_has_schedule = bool(d_periods)
pm = {ab.periode: ab.statut for ab in absences}
def _choice(p: int) -> str:
@ -738,6 +761,49 @@ class FicheState(AuthState):
self.edit_p10 == "non_excusee"
)
def mark_school_day_absent(self):
"""Marque toutes les périodes de cours de la journée comme N (non excusées)
dans le panneau. Utilise le mapping classe / jour / périodes configuré
dans /params. Ne touche pas la DB l'enregistrement passe par
« Enregistrer »."""
if not self.edit_date or not self.selected_id:
return
sess = get_session()
try:
ap = sess.get(Apprenti, self.selected_id)
finally:
sess.close()
if not ap:
return rx.toast.error("Apprenti introuvable.")
d = date.fromisoformat(self.edit_date)
day_names = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"]
day_key = day_names[d.weekday()]
settings = _read_settings()
class_sch = (settings.get("class_schedule") or {}).get(ap.classe) or {}
entry = class_sch.get(day_key)
# Nouveau format {type, periods} ; ancien format = list[int] (compat).
if isinstance(entry, dict):
periods = set(entry.get("periods") or [])
elif isinstance(entry, list):
periods = set(entry)
else:
periods = set()
if not periods:
return rx.toast.warning(
f"Aucun horaire configuré pour {ap.classe} le {day_key}. "
f"Configure-le dans Paramètres → Horaires de classe."
)
if 1 in periods: self.edit_p1 = "non_excusee"
if 2 in periods: self.edit_p2 = "non_excusee"
if 3 in periods: self.edit_p3 = "non_excusee"
if 4 in periods: self.edit_p4 = "non_excusee"
if 5 in periods: self.edit_p5 = "non_excusee"
if 6 in periods: self.edit_p6 = "non_excusee"
if 7 in periods: self.edit_p7 = "non_excusee"
if 8 in periods: self.edit_p8 = "non_excusee"
if 9 in periods: self.edit_p9 = "non_excusee"
if 10 in periods: self.edit_p10 = "non_excusee"
def excuse_all_visual(self):
"""Bascule toutes les N → E dans le panneau (sans toucher la DB).
L'enregistrement passe par le bouton « Enregistrer »."""
@ -1116,10 +1182,12 @@ class FicheState(AuthState):
apprenti = sess.get(Apprenti, self.selected_id)
if apprenti:
tvars = build_template_vars(apprenti, list(absences))
_def_subj = "Relevé d'absences — {nom_complet} ({classe})"
# Mêmes valeurs par défaut que la page Paramètres
# (DEFAULT_TEMPLATE_SUBJ / DEFAULT_TEMPLATE_BODY).
_def_subj = "Document EPTM — {nom_complet} ({classe})"
_def_body = (
"Bonjour {prenom},\n\n"
"Veuillez trouver ci-joint votre document.\n\n"
"Veuillez trouver ci-joint votre document pour la classe {classe}.\n\n"
"Cordialement,\nL'équipe EPTM"
)
self.email_subject = render_template(
@ -1517,6 +1585,20 @@ def _edit_panel() -> rx.Component:
"Édition du ", FicheState.edit_date_label,
size="3", weight="bold", color="var(--text-strong)",
),
rx.cond(
FicheState.edit_day_type_label != "",
rx.badge(
FicheState.edit_day_type_label,
color_scheme=rx.match(
FicheState.edit_day_type,
("theorie", "blue"),
("pratique", "orange"),
("matu", "violet"),
"gray",
),
variant="soft", size="1",
),
),
rx.spacer(),
rx.button(
rx.icon("x", size=14),
@ -1547,9 +1629,21 @@ def _edit_panel() -> rx.Component:
flex_wrap="wrap",
width="100%",
),
# Action rapide : excuser visuellement toutes les N → E.
# N'enregistre pas en DB — il faut cliquer sur « Enregistrer ».
# Actions rapides : marquer toute la journée N (selon horaire classe)
# ou excuser toutes les N → E. Aucune touche la DB — l'enregistrement
# passe par « Enregistrer ».
rx.flex(
rx.button(
rx.icon("calendar-x", size=14),
rx.cond(
FicheState.edit_day_has_schedule,
rx.text("Absent toute la journée"),
rx.text("Absent toute la journée (Données chronoplan manquantes)"),
),
on_click=FicheState.mark_school_day_absent,
disabled=~FicheState.edit_day_has_schedule,
variant="soft", color_scheme="red", size="2",
),
rx.button(
rx.icon("check-check", size=14),
"Excuser toutes les périodes",
@ -1557,7 +1651,7 @@ def _edit_panel() -> rx.Component:
disabled=~FicheState.edit_has_non_excusee,
variant="soft", color_scheme="green", size="2",
),
width="100%",
gap="0.5rem", flex_wrap="wrap", width="100%",
),
rx.divider(),
rx.flex(
@ -1829,7 +1923,7 @@ def fiche_page() -> rx.Component:
# Modals (rendus une fois, contrôlés par leur state respectif)
retenue_modal(),
sanction_modal(),
rx.heading("Fiche apprenti", size="7"),
rx.heading("Apprentis", size="7"),
rx.cond(
FicheState.has_apprentis,

View file

@ -10,7 +10,8 @@ if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
from src.profession import load_mapping, save_mapping, find_unmapped_classes, refresh_all_professions # noqa: E402
from src.db import get_session # noqa: E402
from src.db import get_session, Apprenti # noqa: E402
from sqlalchemy import select # noqa: E402
from ..sidebar import layout
from ..state import AuthState
@ -22,6 +23,16 @@ _SETTINGS_FILE = DATA_DIR / "settings.json"
_DEFAULT_SANCTION = (
"Selon le règlement de l'EM, l'apprenti a dépassé le nombre d'absences limite."
)
# Horaire de classe : 5 jours ouvrés × 10 périodes max. Stocké dans settings.json
# sous la clé "class_schedule" → dict[classe, dict[jour, {type, periods}]].
# Compatible aussi avec l'ancien format list[int] (auto-migré au load).
_SCH_DAYS = ["MON", "TUE", "WED", "THU", "FRI"]
_SCH_DAY_LABELS = {"MON": "Lun", "TUE": "Mar", "WED": "Mer", "THU": "Jeu", "FRI": "Ven"}
_SCH_PERIODS = list(range(1, 11))
# Types de jour : Théorie / Pratique / Matu. "" = non défini (fallback neutre).
_SCH_TYPES = ["theorie", "pratique", "matu"]
_SCH_TYPE_LABELS = {"theorie": "Théorie", "pratique": "Pratique", "matu": "Matu", "": ""}
_DEFAULT_TEMPLATE_SUBJ = "Document EPTM — {nom_complet} ({classe})"
_DEFAULT_TEMPLATE_BODY = (
"Bonjour {prenom},\n\n"
@ -84,6 +95,14 @@ class ParamsState(AuthState):
save_ok_prof: bool = False
refresh_msg: str = ""
# ── Horaires de classe (mapping classe / jour / périodes + type) ──────────
sch_classes_avail: list[str] = []
sch_class_selected: str = ""
# État courant pour la classe sélectionnée. Chargé / sauvegardé en bloc.
sch_periods: dict[str, list[int]] = {}
sch_types: dict[str, str] = {} # day → "theorie"|"pratique"|"matu"|""
save_ok_schedule: bool = False
# ── Setters ───────────────────────────────────────────────────────────────
def set_texte_sanction(self, v: str): self.texte_sanction = v
def set_chef_section(self, v: str): self.chef_section = v
@ -126,6 +145,7 @@ class ParamsState(AuthState):
self.save_ok_template = False
self.save_ok_app = False
self._reload_prof_mapping()
self._reload_schedule_list()
def _reload_prof_mapping(self):
self.prof_mapping = load_mapping()
@ -237,6 +257,103 @@ class ParamsState(AuthState):
sess.close()
self.refresh_msg = f"{n} fiche(s) mise(s) à jour."
# ── Horaires de classe ───────────────────────────────────────────────────
def _reload_schedule_list(self):
"""Charge la liste des classes connues + sélectionne la 1re par défaut."""
sess = get_session()
try:
rows = sess.execute(
select(Apprenti.classe).distinct().order_by(Apprenti.classe)
).scalars().all()
finally:
sess.close()
# Filtre MP/MI/Formation (cohérent avec le reste de l'app).
self.sch_classes_avail = [
c for c in rows
if c and not (c.startswith("MP") or c.startswith("MI")
or c.lower().startswith("formation"))
]
if not self.sch_class_selected and self.sch_classes_avail:
self.sch_class_selected = self.sch_classes_avail[0]
if self.sch_class_selected:
self._load_schedule_for(self.sch_class_selected)
else:
self.sch_periods = {d: [] for d in _SCH_DAYS}
self.sch_types = {d: "" for d in _SCH_DAYS}
def _load_schedule_for(self, classe: str):
s = _read_settings()
all_sch = s.get("class_schedule") or {}
class_sch = all_sch.get(classe) or {}
periods: dict[str, list[int]] = {}
types: dict[str, str] = {}
for d in _SCH_DAYS:
raw = class_sch.get(d)
# Compat ascendante : ancien format = list[int], nouveau = {type, periods}
if isinstance(raw, list):
p_list = raw
d_type = ""
elif isinstance(raw, dict):
p_list = raw.get("periods") or []
d_type = raw.get("type") or ""
else:
p_list = []
d_type = ""
periods[d] = sorted({int(p) for p in p_list
if isinstance(p, int) or str(p).isdigit()})
types[d] = d_type if d_type in _SCH_TYPES else ""
self.sch_periods = periods
self.sch_types = types
self.save_ok_schedule = False
def set_sch_class_selected(self, classe: str):
self.sch_class_selected = classe
self._load_schedule_for(classe)
def toggle_sch_cell(self, day: str, period: int):
cur = dict(self.sch_periods)
lst = list(cur.get(day, []))
if period in lst:
lst = [p for p in lst if p != period]
else:
lst = sorted(lst + [period])
cur[day] = lst
self.sch_periods = cur
self.save_ok_schedule = False
def set_sch_type(self, day: str, day_type: str):
# Sentinelle "none" (Radix Select) → vide.
if day_type == "none":
day_type = ""
if day_type not in _SCH_TYPES and day_type != "":
return
cur = dict(self.sch_types)
cur[day] = day_type
self.sch_types = cur
self.save_ok_schedule = False
def save_schedule(self):
if not self.sch_class_selected:
return
s = _read_settings()
all_sch = dict(s.get("class_schedule") or {})
# Vide si aucune période ET aucun type configuré → on retire l'entrée.
has_any = any(self.sch_periods.get(d) for d in _SCH_DAYS) \
or any(self.sch_types.get(d) for d in _SCH_DAYS)
if has_any:
all_sch[self.sch_class_selected] = {
d: {
"type": self.sch_types.get(d) or "",
"periods": list(self.sch_periods.get(d) or []),
}
for d in _SCH_DAYS
}
else:
all_sch.pop(self.sch_class_selected, None)
s["class_schedule"] = all_sch
_write_settings(s)
self.save_ok_schedule = True
# ── UI helpers ────────────────────────────────────────────────────────────────
@ -649,6 +766,98 @@ def _section_profession() -> rx.Component:
)
def _sch_cell(day: str, period: int) -> rx.Component:
"""Une case de la grille horaire (jour × période). Cliquable."""
is_on = ParamsState.sch_periods[day].contains(period)
return rx.box(
rx.text(period, size="1", weight="bold"),
on_click=ParamsState.toggle_sch_cell(day, period),
cursor="pointer",
padding="0.35rem 0",
border_radius="6px",
border="2px solid",
text_align="center",
min_width="36px",
border_color=rx.cond(is_on, "var(--red-9)", "var(--gray-6)"),
background_color=rx.cond(is_on, "var(--red-9)", "transparent"),
color=rx.cond(is_on, "white", "var(--gray-12)"),
)
def _sch_type_select(day: str) -> rx.Component:
"""Petit dropdown pour choisir le type de jour. — = pas de type."""
return rx.select.root(
rx.select.trigger(placeholder="", width="100%"),
rx.select.content(
rx.select.item("", value="none"),
*[rx.select.item(_SCH_TYPE_LABELS[t], value=t) for t in _SCH_TYPES],
),
# Empty string n'est pas une value valide pour Radix Select → "none"
# sert de sentinelle lue/écrite via les handlers.
value=rx.cond(ParamsState.sch_types[day] == "", "none", ParamsState.sch_types[day]),
on_change=lambda v: ParamsState.set_sch_type(day, v),
size="1",
)
def _sch_day_column(day: str) -> rx.Component:
return rx.vstack(
rx.text(_SCH_DAY_LABELS[day], size="2", weight="bold",
color="var(--gray-11)", text_align="center", width="100%"),
_sch_type_select(day),
*[_sch_cell(day, p) for p in _SCH_PERIODS],
spacing="1",
align="center",
flex="1",
min_width="70px",
)
def _section_class_schedule() -> rx.Component:
return _section(
"Horaires de classe (Absent toute la journée)",
rx.text(
"Définit pour chaque classe les périodes de cours par jour de la semaine. "
"Le bouton « Absent toute la journée » de la fiche apprenti marque ces "
"périodes comme non excusées (N) en fonction du jour sélectionné.",
size="2", color="var(--gray-11)",
),
rx.cond(
ParamsState.sch_classes_avail.length() == 0,
rx.text("Aucune classe en base.", size="2", color="var(--gray-10)"),
rx.vstack(
_field(
"Classe",
rx.select(
ParamsState.sch_classes_avail,
value=ParamsState.sch_class_selected,
on_change=ParamsState.set_sch_class_selected,
width="220px",
),
),
rx.flex(
*[_sch_day_column(d) for d in _SCH_DAYS],
gap="0.75rem",
flex_wrap="wrap",
width="100%",
align="start",
),
rx.hstack(
rx.button(
rx.icon("save", size=16),
"Enregistrer l'horaire",
on_click=ParamsState.save_schedule,
color_scheme="blue", variant="solid", size="2",
),
_save_ok_callout(ParamsState.save_ok_schedule),
spacing="3", align="center", flex_wrap="wrap",
),
spacing="3", width="100%",
),
),
)
def _section_app() -> rx.Component:
return _section(
"Application",
@ -687,6 +896,7 @@ def params_page() -> rx.Component:
rx.heading("Paramètres", size="7"),
_section_app(),
_section_profession(),
_section_class_schedule(),
_section_sanction(),
_section_smtp(),
_section_escada(),

View file

@ -65,11 +65,29 @@ class ProfileState(AuthState):
# Avatar
upload_ok: bool = False
# Mes classes Escada (enrôlement self-service).
# NB: my_classes / classes_unknown / escada_username / escada_has_password
# sont dans AuthState (rechargés à chaque check_auth depuis auth.yaml pour
# éviter pollution inter-sessions). Ici on garde uniquement les vars qui
# sont propres au formulaire / au cycle de soumission.
classes_ok: bool = False
classes_error: str = ""
classes_loading: bool = False
form_escada_user: str = ""
form_escada_pass: str = ""
form_totp_code: str = ""
def set_edit_name(self, v: str): self.edit_name = v
def set_edit_email(self, v: str): self.edit_email = v
def set_pwd_current(self, v: str): self.pwd_current = v
def set_pwd_new(self, v: str): self.pwd_new = v
def set_pwd_confirm(self, v: str): self.pwd_confirm = v
def set_form_escada_user(self, v: str): self.form_escada_user = v
def set_form_escada_pass(self, v: str): self.form_escada_pass = v
def set_form_totp_code(self, v: str):
# ne garde que les chiffres, max 6
self.form_totp_code = "".join(c for c in v if c.isdigit())[:6]
self.classes_error = ""
def load_data(self):
if not self.authenticated:
@ -82,6 +100,14 @@ class ProfileState(AuthState):
self.profile_role = u.get("role", "user")
self.profile_has_totp = bool(u.get("totp_secret"))
self.profile_avatar = u.get("avatar_url", "")
# Mes classes Escada — les vars de données sont dans AuthState
# (rechargées par check_auth). Ici on initialise seulement le formulaire.
self.classes_ok = False
self.classes_error = ""
self.classes_loading = False
self.form_escada_user = u.get("escada_username") or ""
self.form_escada_pass = ""
self.form_totp_code = ""
self.edit_name = self.profile_name
self.edit_email = self.profile_email
self.info_ok = False
@ -194,6 +220,131 @@ class ProfileState(AuthState):
self.profile_avatar = ""
self.upload_ok = False
# ── Mes classes Escada (enrôlement) ───────────────────────────────────────
@rx.event(background=True)
async def fetch_my_classes(self):
"""Lance le scrape Escada en arrière-plan (async subprocess) pour ne
pas bloquer l'event loop Reflex pendant les ~10-30s du Playwright.
Les autres users continuent d'utiliser l'app pendant ce temps, et les
logs sont accessibles en live sur /logs.
"""
import asyncio as _aio
import json as _json
from pathlib import Path as _P
# Reset état + validation (sous lock state)
async with self:
self.classes_error = ""
self.classes_ok = False
e_user = (self.form_escada_user or "").strip()
e_pass = (self.form_escada_pass or "").strip()
totp = (self.form_totp_code or "").strip()
if not e_user or "@" not in e_user:
self.classes_error = "Email Escada invalide."
return
if not self.escada_has_password and not e_pass:
self.classes_error = "Mot de passe Escada requis pour la première connexion."
return
if len(totp) != 6 or not totp.isdigit():
self.classes_error = "Code 2FA invalide (6 chiffres)."
return
# Persistance des creds (le password n'est ré-écrit que s'il est fourni)
cfg = _load_auth()
users = cfg.get("credentials", {}).get("usernames", {})
u = users.get(self.username)
if not u:
self.classes_error = "Compte introuvable."
return
u["escada_username"] = e_user
if e_pass:
u["escada_password"] = e_pass
_save_auth(cfg)
self.classes_loading = True
current_user = self.username # capture pour la phase async
result_file = _P(DATA_DIR) / f"sync_user_classes_{current_user}.json"
result_file.unlink(missing_ok=True)
cwd = str(_P(__file__).resolve().parent.parent.parent)
# Subprocess async — ne bloque pas l'event loop Reflex
try:
proc = await _aio.create_subprocess_exec(
"python", "scripts/fetch_user_classes.py", current_user,
env={**os.environ, "TOTP_CODE": totp},
cwd=cwd,
stdout=_aio.subprocess.PIPE,
stderr=_aio.subprocess.PIPE,
)
try:
stdout_b, stderr_b = await _aio.wait_for(proc.communicate(), timeout=180)
except _aio.TimeoutError:
proc.kill()
async with self:
self.classes_loading = False
self.classes_error = "Délai dépassé (3 min)."
return
stdout = (stdout_b or b"").decode("utf-8", errors="replace")
stderr = (stderr_b or b"").decode("utf-8", errors="replace")
rc = proc.returncode
except Exception as e:
async with self:
self.classes_loading = False
self.classes_error = f"Erreur subprocess : {e}"
return
# Lecture résultat (hors lock)
app_log(
f"[profile/escada] {current_user} : subprocess rc={rc} "
f"stdout_tail={stdout[-800:]!r} stderr_tail={stderr[-400:]!r}"
)
data = None
if result_file.exists():
try:
data = _json.loads(result_file.read_text(encoding="utf-8"))
except Exception:
pass
# Update state final (sous lock)
async with self:
self.classes_loading = False
if not data:
self.classes_error = "Pas de résultat — voir logs serveur."
return
if not data.get("ok"):
self.classes_error = data.get("error") or "Échec inconnu."
app_log(f"[profile/escada] {current_user} : échec : {self.classes_error}")
return
new_classes = list(data.get("classes") or [])
cache_path = _P(DATA_DIR) / "esacada_classes.json"
try:
known = set(_json.loads(cache_path.read_text(encoding="utf-8")))
except Exception:
known = set()
self.classes_unknown = sorted([c for c in new_classes if c not in known])
cfg = _load_auth()
cfg["credentials"]["usernames"][current_user]["allowed_classes"] = new_classes
_save_auth(cfg)
self.my_classes = new_classes
self.escada_username = e_user
self.escada_has_password = True
self.form_escada_pass = ""
self.form_totp_code = ""
self.classes_ok = True
# Si l'enrôlement vient d'être finalisé, le popup doit se fermer
if new_classes:
self.must_enroll = False
app_log(
f"[profile/escada] {current_user} : {len(new_classes)} classes "
f"accordées ({len(self.classes_unknown)} inconnues du cache)"
)
# ── UI helpers ────────────────────────────────────────────────────────────────
@ -466,6 +617,154 @@ def _theme_section() -> rx.Component:
)
def _enroll_form_content() -> rx.Component:
"""Contenu pure du formulaire d'enrôlement Escada (sans wrapper box).
Réutilisé dans la carte /profile et dans le dialog popup global."""
return rx.vstack(
_label("Email Escada"),
rx.input(
value=ProfileState.form_escada_user,
on_change=ProfileState.set_form_escada_user,
placeholder="prenom.nom@vs.ch",
type="email", width="100%",
),
_label(rx.cond(
AuthState.escada_has_password,
"Mot de passe Escada (laisser vide pour réutiliser celui enregistré)",
"Mot de passe Escada",
)),
rx.input(
value=ProfileState.form_escada_pass,
on_change=ProfileState.set_form_escada_pass,
type="password", width="100%",
),
_label("Code 2FA Escada (6 chiffres)"),
rx.input(
value=ProfileState.form_totp_code,
on_change=ProfileState.set_form_totp_code,
placeholder="123456",
max_length=6,
inputmode="numeric",
width="160px",
),
rx.hstack(
rx.button(
rx.cond(
ProfileState.classes_loading,
rx.spinner(size="2"),
rx.icon("refresh-ccw", size=16),
),
rx.cond(
ProfileState.classes_loading,
rx.text("Connexion Escada en cours…"),
rx.cond(
AuthState.escada_has_password,
rx.text("Rafraîchir mes classes"),
rx.text("Récupérer mes classes"),
),
),
on_click=ProfileState.fetch_my_classes,
disabled=ProfileState.classes_loading,
color_scheme="blue", size="2",
),
_ok(ProfileState.classes_ok, "Liste à jour. Voir ci-dessous."),
_err(ProfileState.classes_error),
spacing="3", align="center", flex_wrap="wrap",
),
rx.divider(),
rx.text(
"Classes actuellement accordées :",
size="2", weight="medium", color="var(--gray-11)",
),
rx.cond(
AuthState.my_classes.length() == 0,
rx.text(
"Aucune classe accordée pour l'instant. Lancez une récupération ci-dessus.",
size="2", color="var(--text-soft)",
),
rx.flex(
rx.foreach(
AuthState.my_classes,
lambda c: rx.badge(c, color_scheme="blue", variant="soft", size="2"),
),
gap="0.4rem", flex_wrap="wrap",
),
),
rx.cond(
AuthState.classes_unknown.length() > 0,
rx.callout.root(
rx.callout.icon(rx.icon("triangle-alert", size=16)),
rx.callout.text(
"Les classes suivantes sont accordées mais pas encore "
"synchronisées dans le système. Demandez à un admin de "
"lancer une sync globale Escada : ",
rx.foreach(
AuthState.classes_unknown,
lambda c: rx.badge(c, color_scheme="amber", variant="soft", margin_right="0.25rem"),
),
),
color_scheme="amber", variant="soft", size="1",
),
),
spacing="3", width="100%",
)
def _classes_section() -> rx.Component:
"""Carte enrôlement Escada pour la page /profile (non-admin uniquement)."""
return rx.cond(
ProfileState.profile_role == "admin",
rx.fragment(),
rx.box(
rx.vstack(
rx.text("Mes classes Escada", size="3", weight="bold"),
rx.text(
"Vos accès aux classes sont déterminés par votre compte Escada. "
"Renseignez vos identifiants et un code 2FA pour récupérer votre liste.",
size="1", color="var(--text-soft)",
),
_enroll_form_content(),
spacing="3", width="100%",
),
padding="1.25rem",
background_color="var(--surface)",
border_radius="8px",
border="1px solid var(--border)",
width="100%",
),
)
def enroll_required_dialog() -> rx.Component:
"""Popup forcé pour les users sans aucun accès (must_enroll=True)."""
return rx.dialog.root(
rx.dialog.content(
rx.flex(
rx.heading("Bienvenue ! Configurez votre accès Escada", size="4"),
rx.spacer(),
rx.icon_button(
rx.icon("x", size=14),
on_click=AuthState.dismiss_enroll,
variant="ghost", size="1",
),
align="center", width="100%",
),
rx.text(
"Vous n'avez encore accès à aucune classe. Renseignez vos identifiants "
"Escada et un code 2FA fraîchement généré (validité ~30s) pour récupérer "
"la liste des classes auxquelles votre compte Escada a accès.",
size="2", color="var(--text-soft)",
margin_y="0.75rem",
),
_enroll_form_content(),
max_width="540px",
max_height="90vh",
overflow_y="auto",
),
open=AuthState.must_enroll & ~AuthState.enroll_dismissed,
)
def _totp_section() -> rx.Component:
return rx.box(
rx.vstack(
@ -526,6 +825,7 @@ def profile_page() -> rx.Component:
_avatar_section(),
_info_section(),
_password_section(),
_classes_section(),
_theme_section(),
_totp_section(),
spacing="4",

View file

@ -331,13 +331,18 @@ class RetenueState(AuthState):
f"{self.selected_label} (existante : {self.existing_notice_label})"
)
return
remarque = (self.remarque or "").strip()
user = (self.username or "").strip()
if user:
remarque = f"({user}) {remarque}".rstrip()
remarque = remarque or None
sess = get_session()
try:
sess.add(Notice(
apprenti_id=self.selected_id,
date_event=_date.today(),
titre=self._build_notice_titre(),
remarque=(self.remarque or "").strip() or None,
remarque=remarque,
type_notice=None,
matiere=None,
source="retenue",

View file

@ -154,7 +154,11 @@ class SanctionState(AuthState):
f"{self.selected_label} (existante : {self.existing_notice_label})"
)
return
remarque = (self.texte_description or "").strip() or None
remarque = (self.texte_description or "").strip()
user = (self.username or "").strip()
if user:
remarque = f"({user}) {remarque}".rstrip()
remarque = remarque or None
sess = get_session()
try:
sess.add(Notice(

View file

@ -245,6 +245,30 @@ class UsersState(AuthState):
self.totp_ok = True
app_log(f"[users] {self.username} : 2FA réinitialisé pour {self.edit_target}")
def reset_access(self):
"""Efface tous les droits du user : allowed_classes=[] + creds Escada."""
cfg = _load_auth()
users = cfg.get("credentials", {}).get("usernames", {})
uname = self.edit_target
if uname not in users:
self.access_error = "Utilisateur introuvable."
return
users[uname]["allowed_classes"] = []
users[uname].pop("escada_username", None)
users[uname].pop("escada_password", None)
_save_auth(cfg)
# Sync l'état local de l'édition
self.edit_restrict = True
self.edit_classes = []
self.access_ok = True
self.access_error = ""
self._refresh_list()
app_log(
f"[users] {self.username} : RÉINITIALISATION accès pour {uname} "
f"(allowed_classes vidée + creds Escada effacés)"
)
return rx.toast.success(f"Droits réinitialisés pour {uname}")
async def handle_avatar_upload(self, files: list[rx.UploadFile]):
if not files:
return
@ -570,7 +594,7 @@ def _user_row(user: dict) -> rx.Component:
rx.button(
rx.cond(is_selected, rx.icon("chevron-up", size=14), rx.icon("pencil", size=14)),
rx.cond(is_selected, "Fermer", "Éditer"),
on_click=UsersState.select_user(user["username"]),
on_click=UsersState.select_user(user["username"]).stop_propagation,
variant=rx.cond(is_selected, "solid", "outline"),
color_scheme="blue",
size="1",
@ -591,6 +615,10 @@ def _user_row(user: dict) -> rx.Component:
border_radius="6px",
border=rx.cond(is_selected, "1px solid var(--blue-6)", "1px solid #e0e0e0"),
width="100%",
# Click sur la row entière ouvre / ferme le panneau d'édition.
on_click=UsersState.select_user(user["username"]),
cursor="pointer",
_hover={"background_color": rx.cond(is_selected, "var(--blue-2)", "var(--surface-hover)")},
)
@ -882,6 +910,40 @@ def _edit_panel_access() -> rx.Component:
on_click=UsersState.save_access,
color_scheme="blue", size="2",
),
rx.alert_dialog.root(
rx.alert_dialog.trigger(
rx.button(
rx.icon("trash-2", size=14),
"Réinitialiser les droits",
variant="outline", color_scheme="red", size="2",
),
),
rx.alert_dialog.content(
rx.alert_dialog.title(
"Réinitialiser les droits de ", UsersState.edit_target, " ?",
),
rx.alert_dialog.description(
"Cette action efface allowed_classes (= aucun accès) "
"ET les identifiants Escada stockés. L'utilisateur devra "
"refaire un enrôlement complet depuis sa page profil.",
size="2",
),
rx.flex(
rx.alert_dialog.cancel(
rx.button("Annuler", variant="soft", color_scheme="gray"),
),
rx.alert_dialog.action(
rx.button(
"Réinitialiser",
color_scheme="red",
on_click=UsersState.reset_access,
),
),
gap="0.5rem", justify="end", margin_top="1rem",
),
max_width="480px",
),
),
_ok_callout(UsersState.access_ok, "Accès mis à jour."),
_err_callout(UsersState.access_error),
spacing="3", align="center", flex_wrap="wrap",

View file

@ -1,3 +1,6 @@
import subprocess
from pathlib import Path
import reflex as rx
from .state import AuthState
from .components import scan_docs
@ -6,6 +9,45 @@ from .components import scan_docs
# détecter de nouveaux fichiers).
_DOC_SECTIONS = scan_docs()
def _resolve_version() -> str:
"""Renvoie le dernier tag git, ou le contenu de data/VERSION en fallback.
Lu une fois au démarrage du module un restart suffit pour refléter un
nouveau tag (utile en prod le .git du container peut être figé)."""
root = Path(__file__).resolve().parent.parent
try:
r = subprocess.run(
["git", "describe", "--tags", "--abbrev=0"],
cwd=root, capture_output=True, text=True, timeout=3,
)
if r.returncode == 0 and r.stdout.strip():
return r.stdout.strip()
except Exception:
pass
for candidate in (root / "data" / "VERSION", root / "VERSION"):
try:
return candidate.read_text(encoding="utf-8").strip()
except Exception:
continue
return ""
_VERSION = _resolve_version()
def _version_badge() -> rx.Component:
"""Petit libellé centré affichant la version sous forme 'v<tag>'.
Renvoie un fragment vide si aucune version n'est disponible."""
if not _VERSION:
return rx.fragment()
return rx.box(
rx.text(
"v" + _VERSION, size="1", color=_TEXT_MUTED,
text_align="center", width="100%",
),
padding_y="0.25rem", width="100%",
)
FULL_W = "240px"
RAIL_W = "68px"
TOPBAR_H = "56px"
@ -22,8 +64,8 @@ _ACTIVE_CLR = "var(--brand-primary-light)"
_PAGES = [
("Tableau de bord", "/accueil", "layout-dashboard"),
("Apprentis", "/fiche", "user"),
("Classes", "/classe", "users"),
("Apprentis", "/fiche", "user"),
]
_ADMIN_PAGES = [
@ -433,6 +475,9 @@ def sidebar() -> rx.Component:
_doc_section(),
rx.spacer(),
# Version (dernier tag git) — au-dessus du profil
_version_badge(),
# User
rx.box(height="1px", width="100%", background_color=_BORDER),
rx.box(
@ -556,8 +601,9 @@ _KEYBOARD_SHORTCUTS_JS = """
def layout(content: rx.Component) -> rx.Component:
# Import local pour éviter le cycle sidebar ↔ pages.feedback
# Imports locaux pour éviter les cycles sidebar ↔ pages.*
from .pages.feedback import feedback_widget
from .pages.profile import enroll_required_dialog
return rx.box(
sidebar(),
_mobile_topbar(),
@ -577,6 +623,7 @@ def layout(content: rx.Component) -> rx.Component:
box_sizing="border-box",
),
feedback_widget(),
enroll_required_dialog(),
rx.script(_KEYBOARD_SHORTCUTS_JS),
width="100%",
height="100vh",

View file

@ -67,6 +67,18 @@ class AuthState(rx.State):
doc_expanded: bool = False
# Compteur de messages feedback "new" (admin uniquement)
feedback_new_count: int = 0
# Flag : True si le user (non-admin) n'a aucune classe accordée et doit
# passer par l'enrôlement Escada. Auto-set par check_auth.
must_enroll: bool = False
# L'user a fermé le popup manuellement (pour cette session) — on ne le
# réaffiche pas automatiquement même si must_enroll reste True.
enroll_dismissed: bool = False
# Données de l'utilisateur connecté pour la section enrôlement Escada
# (rechargées à chaque check_auth pour éviter la pollution entre sessions).
my_classes: list[str] = []
classes_unknown: list[str] = []
escada_username: str = ""
escada_has_password: bool = False
@rx.var
def authenticated(self) -> bool:
@ -130,8 +142,24 @@ class AuthState(rx.State):
self.theme = stored_theme
# Compteur feedback (admin uniquement) — pour le badge sidebar
self._refresh_feedback_count()
# Recharge les données d'enrôlement depuis auth.yaml à chaque page —
# évite la pollution si un autre user était dans la session avant.
u = users.get(self.username) or {}
self.my_classes = list(u.get("allowed_classes") or [])
self.escada_username = u.get("escada_username") or ""
self.escada_has_password = bool(u.get("escada_password"))
self.classes_unknown = [] # reset l'avertissement à chaque page
# Détecte si l'user doit s'enrôler (non-admin sans classes accordées)
if u.get("role") == "admin":
self.must_enroll = False
else:
self.must_enroll = not self.my_classes
return self._apply_theme_script(self.theme)
def dismiss_enroll(self):
"""Ferme le popup d'enrôlement pour la session courante."""
self.enroll_dismissed = True
def _refresh_feedback_count(self):
if self.role != "admin":
self.feedback_new_count = 0
@ -272,6 +300,9 @@ class AuthState(rx.State):
self.role = user.get("role", "user")
self.photo_url = user.get("avatar_url", "")
self.theme = user.get("theme") or "eptm"
# Reset le flag de dismiss du popup d'enrôlement à chaque login —
# si l'user n'a toujours pas de classes, le popup doit ré-apparaître.
self.enroll_dismissed = False
self._reset_totp_flow()
return [self._apply_theme_script(self.theme), rx.redirect("/accueil")]
@ -297,6 +328,12 @@ class AuthState(rx.State):
self.role = "user"
self.photo_url = ""
self.theme = "eptm"
self.must_enroll = False
self.enroll_dismissed = False
self.my_classes = []
self.classes_unknown = []
self.escada_username = ""
self.escada_has_password = False
self.login_user = ""
self.login_pass = ""
self.login_error = ""

View file

@ -78,21 +78,15 @@ def _is_due(job: CronJob, now: datetime) -> bool:
last = job.last_run_at
if job.schedule_kind == "interval":
# schedule_value = nb minutes
try:
minutes = int(job.schedule_value)
except (TypeError, ValueError):
return False
if minutes < 1:
return False
if last is None:
if job.schedule_kind == "daily_multi":
# schedule_value = "HH:MM,HH:MM,HH:MM,..." (plusieurs heures par jour)
for hhmm in (job.schedule_value or "").split(","):
hhmm = hhmm.strip()
if not hhmm:
continue
if _due_time_of_day(hhmm, last, now):
return True
return (now - last).total_seconds() >= minutes * 60
if job.schedule_kind == "daily":
# schedule_value = "HH:MM"
return _due_time_of_day(job.schedule_value, last, now)
return False
if job.schedule_kind == "weekly":
# schedule_value = "MON,WED,FRI:HH:MM"

View file

@ -0,0 +1,278 @@
#!/usr/bin/env python3
"""Scrape la liste des classes accessibles à un utilisateur dans Escadaweb.
Usage :
python scripts/fetch_user_classes.py <username>
Lit `escada_username` et `escada_password` depuis `auth.yaml` pour le user.
Le code TOTP (6 chiffres) est lu depuis la variable d'environnement TOTP_CODE.
Écrit le résultat dans data/sync_user_classes_<username>.json sous la forme :
{"ok": true, "classes": [...], "duration_s": 12.3}
ou en cas d'échec :
{"ok": false, "error": "...", "classes": []}
Le browser tourne en mode headless. Profil Chromium éphémère (pas de
persistance entre sessions chaque user a sa propre session indépendante
de celle de l'admin).
"""
from __future__ import annotations
import json
import os
import sys
import time
import tempfile
from pathlib import Path
_ROOT = Path(__file__).resolve().parent.parent
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
import yaml
from playwright.sync_api import sync_playwright, TimeoutError as PWTimeout, Error as PWError
from scripts.sync_esacada import (
BASE_URL, LEHRPERSONEN_URL, CLASSES_URL,
_ensure_french_language, _scrape_classes,
)
from src.logger import app_log
DATA_DIR = _ROOT / "data"
AUTH_FILE = DATA_DIR / "auth.yaml"
_USERNAME = "" # set par main() pour préfixer les logs
def _log(msg: str) -> None:
from datetime import datetime
line = f"[{datetime.now().strftime('%H:%M:%S')}] {msg}"
print(line, flush=True)
# Log aussi dans operations.log (visible en live depuis /logs)
try:
app_log(f"[fetch_classes:{_USERNAME or '?'}] {msg}")
except Exception:
pass
def _load_user_creds(username: str) -> tuple[str, str]:
"""Lit (escada_username, escada_password) depuis auth.yaml."""
if not AUTH_FILE.exists():
raise RuntimeError("auth.yaml introuvable")
cfg = yaml.safe_load(AUTH_FILE.read_text(encoding="utf-8")) or {}
user = cfg.get("credentials", {}).get("usernames", {}).get(username)
if not user:
raise RuntimeError(f"Utilisateur {username!r} introuvable dans auth.yaml")
e_user = (user.get("escada_username") or "").strip()
e_pass = (user.get("escada_password") or "").strip()
if not e_user or not e_pass:
raise RuntimeError(
f"Identifiants Escada manquants pour {username!r} "
"(escada_username / escada_password)"
)
return e_user, e_pass
def _fill_login(page, escada_user: str, escada_pass: str) -> bool:
"""Remplit le formulaire Keycloak avec les creds passés."""
try:
page.wait_for_selector("input#username", state="visible", timeout=5_000)
page.wait_for_selector("input#password", state="visible", timeout=2_000)
_log(" [LOGIN] Formulaire Keycloak détecté")
page.locator("input#username").fill(escada_user)
page.locator("input#password").fill(escada_pass)
try:
page.locator("input#kc-login").click(timeout=2_000)
except Exception:
page.locator("input#password").press("Enter")
return True
except Exception as e:
_log(f" [LOGIN] ERR : {e}")
return False
def _fill_totp(page, code: str) -> bool:
"""Saisie du code TOTP via JS (le champ est caché par CSS)."""
_log(f" [2FA] Saisie du code")
try:
result = page.evaluate("""(code) => {
const inp = document.querySelector('#otp')
|| document.querySelector('[name="otp"]')
|| document.querySelector('[autocomplete="one-time-code"]')
|| document.querySelector('input[type="text"]:not([type="hidden"])');
if (!inp) return 'not_found';
inp.value = code;
inp.dispatchEvent(new Event('input', {bubbles: true}));
inp.dispatchEvent(new Event('change', {bubbles: true}));
return 'filled';
}""", code)
if result != "filled":
_log(f" [2FA] champ introuvable ({result})")
return False
submitted = page.evaluate("""() => {
const btn = document.querySelector('input[type="submit"]')
|| document.querySelector('button[type="submit"]');
if (btn) { btn.click(); return 'clicked'; }
const form = document.querySelector('form');
if (form) { form.submit(); return 'submitted'; }
return 'no_submit';
}""")
return submitted in ("clicked", "submitted")
except Exception as e:
_log(f" [2FA] err : {e}")
return False
def fetch_classes(username: str, totp_code: str) -> dict:
"""Fait login + scrape ViewKlassen et retourne le résultat."""
e_user, e_pass = _load_user_creds(username)
t_start = time.time()
profile_dir = tempfile.mkdtemp(prefix=f"escada_{username}_")
pw = sync_playwright().start()
try:
ctx = pw.chromium.launch_persistent_context(
profile_dir,
headless=True,
args=["--disable-popup-blocking"],
)
page = ctx.pages[0] if ctx.pages else ctx.new_page()
try:
_log(f"GOTO {CLASSES_URL}")
page.goto(CLASSES_URL)
# Boucle login + 2FA (timeout 90s)
deadline = time.time() + 90
login_done = False
totp_done = False
last_url = ""
stuck_counter = 0
while time.time() < deadline:
cur = page.url.lower()
if page.url != last_url:
_log(f" url: {page.url[:120]}")
last_url = page.url
stuck_counter = 0
if "viewklassen" in cur:
_log("LOGIN_OK")
break
# Si on est sur une page hors flux (Timeout.aspx, root EPTM,
# erreur DevExpress), forcer un goto vers Lehrpersonen pour
# déclencher le redirect Keycloak.
if not any(k in cur for k in (
"edusso", "login", "authenticate", "logon", "otp",
"lehrpersonen/viewklassen",
)):
_log(f" hors flux ({cur[:80]}…) → goto Lehrpersonen")
try:
page.goto(LEHRPERSONEN_URL, timeout=15_000)
except (PWTimeout, PWError) as _e:
_log(f" goto err : {_e}")
page.wait_for_timeout(1_000)
continue
if not login_done:
if _fill_login(page, e_user, e_pass):
login_done = True
_log(" login submitted, wait for redirect…")
try:
page.wait_for_load_state("networkidle", timeout=8_000)
except (PWTimeout, PWError):
pass
if not totp_done and (
"authenticate" in cur
or "otp" in cur
or page.locator("input#otp").count() > 0
):
if _fill_totp(page, totp_code):
totp_done = True
_log(" otp submitted, wait for redirect to ViewKlassen…")
try:
page.wait_for_url("**ViewKlassen**", timeout=15_000)
except (PWTimeout, PWError):
_log(f" wait_for_url failed, url={page.url[:120]}")
page.wait_for_timeout(800)
stuck_counter += 1
# Sortie anticipée si totp validé mais redirect ne vient pas
# (probablement code OTP invalide ou expiré)
if totp_done and stuck_counter > 15 and "viewklassen" not in cur:
_log(f" TOTP submitted mais pas de redirect → code peut-être invalide")
break
else:
# Diagnostic supplémentaire
_log(f"TIMEOUT url={page.url[:120]} login_done={login_done} totp_done={totp_done}")
try:
# Pages d'erreur Keycloak fréquentes
body_txt = page.evaluate("() => (document.body && document.body.innerText || '').slice(0, 500)")
_log(f" body_preview: {body_txt!r}")
except Exception:
pass
raise RuntimeError(
f"Timeout login (90s) — login_done={login_done} totp_done={totp_done} url={page.url[:80]}"
)
# Force le français + scrape
_ensure_french_language(page)
page.goto(CLASSES_URL, wait_until="domcontentloaded", timeout=15_000)
try:
page.wait_for_load_state("networkidle", timeout=10_000)
except (PWTimeout, PWError):
pass
classes = _scrape_classes(page)
# Filtre : exclure les classes MP* (Matu Pro), MI* (Maîtrise),
# "Formation*" (modules de formation continue, hors flux régulier).
filtered = [
c for c in classes
if not (
c.startswith("MP")
or c.startswith("MI")
or c.lower().startswith("formation")
)
]
removed = sorted(set(classes) - set(filtered))
_log(f"DONE {len(filtered)} classes (filtré {len(removed)} : {removed})")
return {
"ok": True,
"classes": filtered,
"duration_s": round(time.time() - t_start, 1),
}
finally:
try: ctx.close()
except Exception: pass
finally:
try: pw.stop()
except Exception: pass
# cleanup du profile temporaire
import shutil
shutil.rmtree(profile_dir, ignore_errors=True)
def main():
global _USERNAME
if len(sys.argv) < 2:
print("Usage: fetch_user_classes.py <username>", file=sys.stderr)
sys.exit(2)
username = sys.argv[1].strip()
_USERNAME = username
totp_code = (os.getenv("TOTP_CODE") or "").strip()
if not totp_code or not totp_code.isdigit() or len(totp_code) != 6:
result = {"ok": False, "error": "TOTP_CODE manquant ou invalide (6 chiffres requis)", "classes": []}
else:
try:
result = fetch_classes(username, totp_code)
except Exception as e:
result = {"ok": False, "error": str(e), "classes": []}
out_file = DATA_DIR / f"sync_user_classes_{username}.json"
out_file.parent.mkdir(parents=True, exist_ok=True)
out_file.write_text(json.dumps(result, ensure_ascii=False), encoding="utf-8")
print(json.dumps(result, ensure_ascii=False))
sys.exit(0 if result.get("ok") else 1)
if __name__ == "__main__":
main()

View file

@ -287,11 +287,10 @@ class CronJob(Base):
name: Mapped[str]
enabled: Mapped[bool] = mapped_column(default=True)
# schedule_kind ∈ {"daily", "weekly", "interval"}
# daily : schedule_value="HH:MM"
# schedule_kind ∈ {"daily_multi", "weekly"}
# daily_multi : schedule_value="HH:MM,HH:MM,..." (1..N heures par jour)
# weekly : schedule_value="MON,TUE,WED,THU,FRI:HH:MM"
# interval: schedule_value="60" (minutes)
schedule_kind: Mapped[str] = mapped_column(default="daily")
schedule_kind: Mapped[str] = mapped_column(default="daily_multi")
schedule_value: Mapped[str] = mapped_column(default="03:00")
# task_kind ∈ {"push", "sync", "push_then_sync"}
@ -433,6 +432,48 @@ def init_db(engine=None):
_conn.commit()
except Exception:
pass
# Migration cron schedule_kind : 'interval' (minutes) → 'daily_multi' (HH:MM,…)
# On déroule l'intervalle sur 24 h à partir de 00:00 et on enregistre la liste.
try:
with engine.connect() as _conn:
rows = _conn.execute(text(
"SELECT id, schedule_value FROM cron_jobs WHERE schedule_kind='interval'"
)).all()
for jid, val in rows:
try:
interval = int(val)
except (TypeError, ValueError):
interval = 0
if interval <= 0 or interval >= 1440:
# valeur invalide → on bascule sur une exécution quotidienne à minuit
new_value = "00:00"
else:
hours: list[str] = []
m = 0
while m < 1440:
hours.append(f"{m // 60:02d}:{m % 60:02d}")
m += interval
new_value = ",".join(hours)
_conn.execute(text(
"UPDATE cron_jobs SET schedule_kind='daily_multi', schedule_value=:v "
"WHERE id=:i"
), {"v": new_value, "i": jid})
_conn.commit()
except Exception:
pass
# Migration 'daily' (HH:MM) → 'daily_multi' (HH:MM unique). 'daily' devient
# un cas particulier de daily_multi avec une seule heure.
try:
with engine.connect() as _conn:
_conn.execute(text(
"UPDATE cron_jobs SET schedule_kind='daily_multi' WHERE schedule_kind='daily'"
))
_conn.commit()
except Exception:
pass
return engine

View file

@ -44,6 +44,33 @@ def format_date_long(d: _date) -> str:
return f"{d.day} {_MOIS_FR[d.month - 1]} {d.year}"
def _destinataire(
apprenti: "Apprenti", fiche: Optional["ApprentiFiche"]
) -> tuple[str, str, str]:
"""Renvoie (nom, adresse, "NPA Localité") du destinataire de l'avis.
Apprenti mineur représentant légal. Sinon apprenti lui-même.
L'adresse de l'entreprise n'est jamais utilisée.
"""
if not fiche:
return f"{apprenti.prenom} {apprenti.nom}".strip(), "", ""
if fiche.majeur is False:
cp = (fiche.resp_legal_code_postal or "").strip()
loc = (fiche.resp_legal_localite or "").strip()
return (
(fiche.resp_legal_nom or "").strip(),
(fiche.resp_legal_adresse or "").strip(),
f"{cp} {loc}".strip(),
)
cp = (fiche.code_postal or "").strip()
loc = (fiche.localite or "").strip()
return (
f"{apprenti.prenom} {apprenti.nom}".strip(),
(fiche.adresse or "").strip(),
f"{cp} {loc}".strip(),
)
def generate_retenue_pdf(
sess: Session,
apprenti_id: int,
@ -69,11 +96,10 @@ def generate_retenue_pdf(
f"{profession.strip()} {apprenti.classe}".strip()
if profession else apprenti.classe
)
npa_ville = ""
if fiche:
cp = (fiche.entreprise_code_postal or "").strip()
loc = (fiche.entreprise_localite or "").strip()
npa_ville = f"{cp} {loc}".strip()
# Destinataire : représentant légal si mineur, sinon l'apprenti lui-même.
# L'adresse de l'entreprise n'est plus utilisée.
dest_nom, dest_adresse, dest_npa_ville = _destinataire(apprenti, fiche)
# 1. Lecture template + clone
reader = pypdf.PdfReader(str(_TEMPLATE_PATH))
@ -88,9 +114,9 @@ def generate_retenue_pdf(
field_values: dict[str, str] = {
"NomApprenti": f"{apprenti.prenom} {apprenti.nom}".strip(),
"Classe": classe_full,
"NomEntreprise": (fiche.entreprise_nom if fiche else "") or "",
"Adresse": (fiche.entreprise_adresse if fiche else "") or "",
"NPA-Ville": npa_ville,
"NomEntreprise": dest_nom,
"Adresse": dest_adresse,
"NPA-Ville": dest_npa_ville,
"RetenueDateHeure": retenue_date.strftime("%d.%m.%Y"),
"Branche": branche if case == "devoir" else "",
"Remarque": remarque,

View file

@ -40,6 +40,35 @@ def _load_settings() -> dict:
return {}
def _destinataire(
apprenti: "Apprenti", fiche: Optional["ApprentiFiche"]
) -> tuple[str, str, str]:
"""Renvoie (nom, adresse, "NPA Localité") du destinataire de l'avis.
Apprenti mineur représentant légal (resp_legal_*).
Apprenti majeur (ou statut inconnu) adresse personnelle de l'apprenti.
L'adresse de l'entreprise n'est jamais utilisée.
"""
if not fiche:
nom = f"{apprenti.prenom} {apprenti.nom}".strip()
return nom, "", ""
if fiche.majeur is False:
cp = (fiche.resp_legal_code_postal or "").strip()
loc = (fiche.resp_legal_localite or "").strip()
return (
(fiche.resp_legal_nom or "").strip(),
(fiche.resp_legal_adresse or "").strip(),
f"{cp} {loc}".strip(),
)
cp = (fiche.code_postal or "").strip()
loc = (fiche.localite or "").strip()
return (
f"{apprenti.prenom} {apprenti.nom}".strip(),
(fiche.adresse or "").strip(),
f"{cp} {loc}".strip(),
)
def generate_avis_pdf(
sess: Session,
apprenti_id: int,
@ -68,19 +97,16 @@ def generate_avis_pdf(
fiche: Optional[ApprentiFiche] = apprenti.fiche
settings = _load_settings()
# Construction des valeurs
npa_ville = ""
if fiche:
cp = (fiche.entreprise_code_postal or "").strip()
loc = (fiche.entreprise_localite or "").strip()
npa_ville = f"{cp} {loc}".strip()
# Destinataire : représentant légal si mineur, sinon l'apprenti lui-même.
# L'adresse de l'entreprise n'est plus utilisée.
dest_nom, dest_adresse, dest_npa_ville = _destinataire(apprenti, fiche)
field_values: dict[str, str] = {
"NomApprenti": f"{apprenti.prenom} {apprenti.nom}".strip(),
"Classe": apprenti.classe or "",
"NomParents": (fiche.entreprise_nom if fiche else "") or "",
"Adresse": (fiche.entreprise_adresse if fiche else "") or "",
"NPA-Ville": npa_ville,
"NomParents": dest_nom,
"Adresse": dest_adresse,
"NPA-Ville": dest_npa_ville,
"Date": date.today().strftime("%d.%m.%Y"),
"TexteDescription": (
(texte_override or "").strip()

View file

@ -28,9 +28,13 @@ def _load_user(username: str) -> Optional[dict]:
def get_allowed_classes(username: str) -> Optional[list[str]]:
"""Retourne la liste des classes autorisées pour l'utilisateur.
- None : aucune restriction (admin, ou champ vide / absent)
- [] : restriction explicite à zéro classe (= ne voit rien)
- None : aucune restriction (admin uniquement)
- [] : restriction à zéro classe (= ne voit rien) défaut pour user
- [...] : restreint à ces classes
Sémantique 2026-05-11 : un user (rôle != admin) sans `allowed_classes`
configuré n'a accès à AUCUNE classe. Il doit s'enrôler via /profile
ou recevoir un accès manuel via /users.
"""
user = _load_user(username)
if not user:
@ -39,8 +43,7 @@ def get_allowed_classes(username: str) -> Optional[list[str]]:
return None
allowed = user.get("allowed_classes")
if allowed is None:
return None
# `allowed_classes: []` (présent mais vide) signifie « aucun accès »
return [] # ← user sans config = aucun accès
return list(allowed)