Initial version
This commit is contained in:
parent
ea8954bc6f
commit
f1190566a6
36 changed files with 2662 additions and 369 deletions
|
|
@ -1,3 +1,9 @@
|
||||||
echo ".web/" > /opt/eptm-dashboard/.dockerignore
|
.web/
|
||||||
echo "__pycache__/" >> /opt/eptm-dashboard/.dockerignore
|
__pycache__/
|
||||||
echo ".venv/" >> /opt/eptm-dashboard/.dockerignore
|
.venv/
|
||||||
|
data/browser_profile/
|
||||||
|
data/cache/
|
||||||
|
data/*.db
|
||||||
|
data/*.db-*
|
||||||
|
logs/
|
||||||
|
.git/
|
||||||
|
|
|
||||||
25
TODO.md
25
TODO.md
|
|
@ -5,16 +5,25 @@ en haut de la section concernée.
|
||||||
|
|
||||||
## Idées / fonctionnalités
|
## Idées / fonctionnalités
|
||||||
|
|
||||||
- [ ] Ajouter sur le dashboard l'affichage des notes insuffisantes
|
|
||||||
- [X] Afficher toutes les notes du BN
|
- [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 l'indication des compensation des désavantages
|
||||||
- [X] Ajouter le TAB notices aussi sur la vue classe
|
- [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
|
- [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 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
|
## Bugs connus
|
||||||
|
|
||||||
|
|
@ -26,6 +35,12 @@ en haut de la section concernée.
|
||||||
- [ ] Faire un thème avec fond foncé
|
- [ ] Faire un thème avec fond foncé
|
||||||
- [ ] Lancer une optimisation des toasts
|
- [ ] Lancer une optimisation des toasts
|
||||||
- [X] Changer la couleur du bouton Générer l'avais de sanction
|
- [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
|
## Notes / réflexions
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,15 @@
|
||||||
.no-scrollbar { scrollbar-width: none; -ms-overflow-style: none; }
|
.no-scrollbar { scrollbar-width: none; -ms-overflow-style: none; }
|
||||||
.no-scrollbar::-webkit-scrollbar { display: 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. */
|
/* Badge avec animation pulse — utilisé pour indiquer les messages non lus. */
|
||||||
@keyframes feedback-pulse {
|
@keyframes feedback-pulse {
|
||||||
0%, 100% { transform: scale(1); opacity: 1; }
|
0%, 100% { transform: scale(1); opacity: 1; }
|
||||||
|
|
|
||||||
1
data/VERSION
Normal file
1
data/VERSION
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
1.0.0
|
||||||
|
|
@ -16,8 +16,29 @@ credentials:
|
||||||
test:
|
test:
|
||||||
allowed_classes:
|
allowed_classes:
|
||||||
- AUTOMAT 1
|
- AUTOMAT 1
|
||||||
|
- AUTOMAT 2
|
||||||
|
- AUTOMAT 3
|
||||||
|
- AUTOMAT 4
|
||||||
|
- CFTI-AU 1A
|
||||||
|
- CFTI-AU 1B
|
||||||
|
- CFTI-AU 2
|
||||||
- EM-AU 1
|
- 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
|
email: julien@balet-vs.ch
|
||||||
|
escada_password: Lauryne2023!
|
||||||
|
escada_username: julien.balet@edu.vs.ch
|
||||||
name: test
|
name: test
|
||||||
password: $2b$12$dAvkehPvcU5zokLhUzjswu7APcRi1e4C1IeR6Gc7/51wh9vGTl4MS
|
password: $2b$12$dAvkehPvcU5zokLhUzjswu7APcRi1e4C1IeR6Gc7/51wh9vGTl4MS
|
||||||
role: user
|
role: user
|
||||||
|
|
|
||||||
|
|
@ -32,15 +32,15 @@
|
||||||
"MP1-TASV 4D:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=3bc53c77-bda8-43a4-ab47-08c2eb84a917",
|
"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",
|
"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",
|
"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 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=8b5035a7-f0b6-41dd-b203-4c5d540b1e64",
|
"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=93c187b8-7bd9-4361-82ef-cac3a5658c6c",
|
"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=83a6684e-a2be-4148-8bd4-faaea872698d",
|
"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=cf80e939-1c49-484b-b8ed-1357a1a51c2b",
|
"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=12a70ca4-1410-4c7a-85b8-36eeabbe7cda",
|
"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=eb8d612b-0929-47bc-8af5-ffebc7ed2432",
|
"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=d0781d15-f260-40b5-9bac-e3c919533422",
|
"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=69c49e37-d184-4ab4-a463-914ed635d237",
|
"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=6d07e078-38b1-49da-a513-91b482dbf2a6",
|
"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=6705457b-b544-4c74-8de1-eb61cf45511d"
|
"MONTAUT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=a09c3cf3-a741-4b41-9026-d9065b125b8a"
|
||||||
}
|
}
|
||||||
|
|
@ -1,42 +1,75 @@
|
||||||
# Vue d'ensemble
|
# 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
|
## À 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).
|
- **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.
|
- **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é
|
## Modèle de données simplifié
|
||||||
|
|
||||||
```
|
```
|
||||||
Apprenti ── Absence (avec statut: a_traiter, excusee, ...)
|
Apprenti ── Absence (statut : a_traiter, excusee, ...)
|
||||||
├── ApprentiFiche (données personnelles : adresse, entreprise, formateur)
|
├── ApprentiFiche (adresse, entreprise, formateur, représentant
|
||||||
|
│ légal, compensation des désavantages, majeur/mineur)
|
||||||
├── NotesBulletin (BN par semestre)
|
├── NotesBulletin (BN par semestre)
|
||||||
├── NotesMatu (Matu pro)
|
├── 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
|
EscadaPending : file d'attente des modifications d'absences locales à
|
||||||
(action ∈ {"E", "N", "clear"})
|
pousser vers Escada (action ∈ {"E", "N", "clear"})
|
||||||
|
|
||||||
Import / ImportBN / ImportMatu : trace des imports PDF effectués
|
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
|
## Architecture technique
|
||||||
|
|
||||||
- **Frontend** : Reflex 0.9.2 (Python full-stack avec Radix Themes + Tailwind-friendly)
|
- **Frontend** : Reflex 0.9.2 (Python full-stack, Radix Themes, lucide-react icons)
|
||||||
- **DB** : SQLite en mode WAL, à `data/absences.db`
|
- **DB** : SQLite mode WAL, à `data/absences.db`
|
||||||
- **Scraping Escada** : Selenium / Playwright, dans `scripts/sync_esacada.py` et `scripts/push_to_escada.py`
|
- **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)
|
- **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`
|
- **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).
|
| Page | Route | user | admin |
|
||||||
- **admin** : tout ce qui précède + Escada, Cron, Logs, Utilisateurs, Paramètres.
|
|----------------------------|--------------|------|-------|
|
||||||
|
| 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.
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
# Synchronisation Escada (pull)
|
# 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`
|
## Page : `/escada`
|
||||||
|
|
||||||
### Sélection des classes
|
### 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
|
### 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 |
|
| Absences | Télécharge les PDFs d'absences + parse + import |
|
||||||
| BN | Bulletins de notes + moyennes Matu (semestres complets) |
|
| BN | Bulletins de notes + moyennes Matu (semestres complets) |
|
||||||
| Notes | Notes d'examens finales |
|
| 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
|
### 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).
|
À 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
|
## Phases d'exécution
|
||||||
|
|
||||||
### Phase 1 : Scraping (Selenium)
|
### Phase 1 : Scraping (Playwright)
|
||||||
|
|
||||||
`scripts/sync_esacada.py --sync-all CLASSE1 CLASSE2 ...`
|
`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 :
|
2. Pour chaque classe sélectionnée :
|
||||||
- Télécharge le PDF d'absences
|
- Télécharge le PDF d'absences
|
||||||
- Télécharge le PDF de bulletin
|
- Télécharge le PDF de bulletin
|
||||||
- Télécharge le PDF de notes
|
- Télécharge le PDF de notes
|
||||||
- Pour les apprentis Matu : télécharge le PDF Matu de la classe MP correspondante
|
- Pour les apprentis Matu : télécharge le PDF Matu de la classe MP correspondante
|
||||||
- Scrape les fiches personnelles (vue ViewLernende)
|
- Scrape les fiches personnelles (vue ViewLernende — y compris représentant légal + flag compensation)
|
||||||
3. À la fin, écrit `ALL_DONE` dans la sortie standard et `data/sync_all_done.json` (timestamp).
|
- 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
|
### Phase 2 : Import en DB
|
||||||
|
|
||||||
`scripts/run_imports.py` est lancé par le wrapper après réception du signal `ALL_DONE` :
|
`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))
|
1. Parse chaque PDF d'absences → upsert des `Absence` (déduplication sur (apprenti, date, période))
|
||||||
2. Parse les BN → insère `NotesBulletin`
|
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`
|
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).
|
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.
|
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
|
## 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
|
- **`E`** : marquer comme excusée sur Escada
|
||||||
- **`N`** : marquer comme non excusée sur Escada
|
- **`N`** : marquer comme non excusée sur Escada
|
||||||
- **`clear`** : retirer l'absence sur Escada (= remettre l'apprenti présent)
|
- **`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
|
## Cas particuliers gérés
|
||||||
|
|
||||||
|
|
@ -77,6 +95,6 @@ Ces pendings sont visibles sur la page `/escada` dans la section "Modifications
|
||||||
|
|
||||||
## Diagnostic
|
## Diagnostic
|
||||||
|
|
||||||
- **Timeout > 15 min** : vérifier les logs `data/logs/operations.log`. Souvent un problème Selenium (captcha, session expirée).
|
- **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.
|
- **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.
|
- **Logs détaillés** : page `/logs` affiche `operations.log` en temps réel.
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,24 @@
|
||||||
# Push vers Escada
|
# 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` :
|
Chaque modification d'absence dans l'application crée ou met à jour une entrée dans `EscadaPending` :
|
||||||
|
|
||||||
| Action utilisateur | Pending créé |
|
| Action utilisateur | Pending créé |
|
||||||
|------------------------------------------|---------------------|
|
|---------------------------------------------------|---------------------|
|
||||||
| Marquer P3 comme excusée | `action=E` |
|
| Marquer P3 comme excusée | `action=E` |
|
||||||
| Marquer P5 comme non excusée | `action=N` |
|
| Marquer P5 comme non excusée | `action=N` |
|
||||||
| Retirer une absence (présent) | `action=clear` |
|
| 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.
|
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
|
### 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`
|
1. Lit toutes les entrées de `EscadaPending`
|
||||||
2. Groupe par classe pour minimiser les navigations Escada
|
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 :
|
Pour chaque pending :
|
||||||
|
|
||||||
1. Navigue jusqu'à la page d'absences de l'apprenti dans Escadaweb
|
1. Navigue jusqu'à la page d'absences de l'apprenti dans Escadaweb
|
||||||
2. Trouve la cellule (date × période)
|
2. Trouve la cellule (date × période)
|
||||||
3. Selon l'action :
|
3. Selon l'action :
|
||||||
- `E` : sélectionne "Excusée" dans le dropdown
|
- `E` : sélectionne « Excusée » dans le dropdown
|
||||||
- `N` : sélectionne "Non excusée"
|
- `N` : sélectionne « Non excusée »
|
||||||
- `clear` : remet à blanc (= apprenti présent)
|
- `clear` : remet à blanc (= apprenti présent)
|
||||||
4. Clique sur **Speichern** (Enregistrer)
|
4. Clique sur **Speichern** (Enregistrer)
|
||||||
5. Si OK → supprime l'entrée du `EscadaPending`
|
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
|
### 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
|
- Nombre d'envois OK
|
||||||
- Liste des erreurs (chaque erreur mentionne l'apprenti, la date et la période)
|
- 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 ?
|
## Que faire si un push échoue ?
|
||||||
|
|
||||||
1. **Vérifier les logs** (`/logs`) — l'erreur exacte est tracée.
|
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)
|
- 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
|
- 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)
|
- 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
|
## 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 terminé — ok:N erreurs:M` dans `operations.log`
|
||||||
|
|
||||||
## Push automatique via cron
|
## 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](#).
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Édition des absences
|
# Édition des absences
|
||||||
|
|
||||||
## Page : `/fiche` (Apprentis)
|
## Page : « Apprentis » (`/fiche`)
|
||||||
|
|
||||||
### Sélectionner un apprenti
|
### 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é
|
- `Entrée` sélectionne le premier résultat filtré
|
||||||
- `Échap` ferme la recherche
|
- `É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
|
### Calendrier mensuel
|
||||||
|
|
||||||
Chaque cellule représente un jour du mois. Les couleurs indiquent l'état :
|
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 |
|
| Bleu pâle | Aujourd'hui |
|
||||||
|
|
||||||
Les nombres dans les cellules :
|
Les nombres dans les cellules :
|
||||||
- "2 ⚠️ 1" → 2 absences au total dont 1 non excusée
|
- « 2 ⚠️ 1 » → 2 absences au total dont 1 non excusée
|
||||||
- "5" → 5 absences toutes excusées
|
- « 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
|
### 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 :
|
10 lignes (P1 à P10) avec un **segmented control** à 3 boutons :
|
||||||
- **Présent** (gris) — l'apprenti était là
|
- **Présent** (gris) — l'apprenti était là
|
||||||
- **E** (orange) — Excusée
|
- **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.
|
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.
|
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
|
### 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 :
|
Chaque apprenti de la classe a une carte avec :
|
||||||
- Nom + lien vers sa fiche complète
|
- Nom + lien vers sa fiche complète
|
||||||
- Badge "Sanction" si quota atteint (≥5 absences brutes en blocs)
|
- Badge « Sanction » si quota atteint (≥5 absences brutes en blocs, classes EM uniquement)
|
||||||
- KPIs : Total / Excusées / Non excusées / Blocs d'absences
|
- KPIs identiques à la fiche apprenti (3 cartes : Périodes d'absence, Périodes à excuser, Absences)
|
||||||
- Boutons de téléchargement PDF (Absences, Bulletin, Notes)
|
- Bandeau d'actions identique : PDF absences/bulletin/notes + Créer avis de retenue + Créer avis de sanction
|
||||||
- Onglets BN / Notes d'examen pour visualiser
|
- Onglets BN / Notes d'examen / Notices pour visualiser
|
||||||
|
|
||||||
## Audit des modifications
|
## Audit des modifications
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## Page : `/cron` (admin uniquement)
|
## 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
|
## 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
|
Lit la table CronJob → identifie les tâches à exécuter maintenant
|
||||||
↓
|
↓
|
||||||
Pour chaque tâche due :
|
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
|
- Met à jour last_run_at, last_status, last_message
|
||||||
- Envoie une notification Telegram (selon notify_on)
|
- 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 |
|
| Type | Action |
|
||||||
|------------------|----------------------------------------------------------------------|
|
|------------------|----------------------------------------------------------------------|
|
||||||
| `push` | Pousse les pendings vers Escada uniquement |
|
| `push` | Pousse les pendings d'absences et/ou notices vers Escada |
|
||||||
| `sync` | Récupère depuis Escada uniquement (selon options abs/BN/notes/fiches)|
|
| `sync` | Récupère depuis Escada (selon options abs/BN/notes/fiches/notices) |
|
||||||
| `push_then_sync` | Pousse les pendings, puis récupère |
|
| `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.
|
||||||
- **Hebdo (weekly)** : à une heure fixe certains jours. Ex : `MON,WED,FRI:08:30`.
|
- `schedule_value` = `"MON,WED,FRI:08:30"`
|
||||||
- **Intervalle (interval)** : toutes les N minutes. Ex : `30` = toutes les 30 minutes.
|
- 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 (00h–23h) 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_abs` : récupère les absences
|
||||||
- `sync_bn` : récupère les BN
|
- `sync_bn` : récupère les BN + Matu
|
||||||
- `sync_notes` : récupère les notes
|
- `sync_notes` : récupère les notes d'examen
|
||||||
- `sync_fiches` : récupère les données apprentis
|
- `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)
|
- `force_abs` : forçage (cf. doc Sync Escada)
|
||||||
- `classes_json` : `"ALL"` ou liste de classes spécifiques
|
- `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
|
## 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
|
## 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 :
|
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: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 30min' (id=2)
|
[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.
|
Bas de page : envoie un message de test au `chat_id` global pour vérifier la config bot.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Authentification & rôles
|
# Authentification, droits & profil
|
||||||
|
|
||||||
## Login
|
## Login
|
||||||
|
|
||||||
|
|
@ -12,11 +12,15 @@ Format `auth.yaml` :
|
||||||
credentials:
|
credentials:
|
||||||
usernames:
|
usernames:
|
||||||
prof.demo:
|
prof.demo:
|
||||||
password: "$2b$12$..."
|
password: "$2b$12$..." # bcrypt
|
||||||
name: "Prof Demo"
|
name: "Prof Demo"
|
||||||
role: "admin" # ou "user"
|
role: "admin" # ou "user"
|
||||||
|
email: "prof.demo@eptm.ch" # destinataire pour reset mdp / enrôlement
|
||||||
avatar_url: "/avatars/prof_demo.png"
|
avatar_url: "/avatars/prof_demo.png"
|
||||||
totp_secret: "ABCD1234..." # rempli automatiquement à la 1ère 2FA
|
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)
|
## 2FA TOTP (obligatoire)
|
||||||
|
|
@ -32,39 +36,102 @@ Aux connexions suivantes :
|
||||||
1. Login + mot de passe → demande directe du code TOTP
|
1. Login + mot de passe → demande directe du code TOTP
|
||||||
2. Code à 6 chiffres → connexion finalisée
|
2. Code à 6 chiffres → connexion finalisée
|
||||||
|
|
||||||
Le code est valide ±30s (paramètre `valid_window=1` de `pyotp`) pour tolérer la dérive d'horloge.
|
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
|
## 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
|
## Rôles
|
||||||
|
|
||||||
| Page | user | admin |
|
| Page | user (sans allowed_classes) | user (avec allowed_classes) | admin |
|
||||||
|-------------------|------|-------|
|
|-------------------|----------------------------|------------------------------|-------|
|
||||||
| `/accueil` | ✅ | ✅ |
|
| `/accueil` | ✅ (filtré) | ✅ (filtré) | ✅ |
|
||||||
| `/fiche` | ✅ | ✅ |
|
| `/classe` | ✅ (filtré) | ✅ (filtré) | ✅ |
|
||||||
| `/classe` | ✅ | ✅ |
|
| `/fiche` | ✅ (filtré) | ✅ (filtré) | ✅ |
|
||||||
| `/doc` | ✅ | ✅ |
|
| `/doc` | ✅ | ✅ | ✅ |
|
||||||
| `/escada` | ❌ | ✅ |
|
| `/profile` | ✅ | ✅ | ✅ |
|
||||||
| `/cron` | ❌ | ✅ |
|
| `/escada` | ❌ | ❌ | ✅ |
|
||||||
| `/logs` | ❌ | ✅ |
|
| `/cron` | ❌ | ❌ | ✅ |
|
||||||
| `/users` | ❌ | ✅ |
|
| `/logs` | ❌ | ❌ | ✅ |
|
||||||
| `/params` | ❌ | ✅ |
|
| `/users` | ❌ | ❌ | ✅ |
|
||||||
|
| `/params` | ❌ | ❌ | ✅ |
|
||||||
|
| `/feedback` | ❌ | ❌ | ✅ |
|
||||||
|
|
||||||
## Gestion des utilisateurs
|
## Gestion des utilisateurs (admin)
|
||||||
|
|
||||||
Page `/users` (admin) :
|
Page `/users` :
|
||||||
- Créer / supprimer des utilisateurs
|
- Créer / supprimer des utilisateurs
|
||||||
- Changer le rôle
|
- Changer le rôle
|
||||||
- Réinitialiser le 2FA (efface `totp_secret` → forcera une nouvelle config au prochain login)
|
- 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
|
- Définir / changer un avatar
|
||||||
|
- Clic sur une ligne ouvre directement le panneau d'édition
|
||||||
|
|
||||||
## Logout
|
## 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
|
## Stockage des avatars
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,29 +2,29 @@
|
||||||
|
|
||||||
## Synchronisation Escada
|
## 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)
|
- Escadaweb répond très lentement (en pic de charge)
|
||||||
- Captcha / re-login imposé par Escada
|
- Captcha / re-login imposé par Escada
|
||||||
- Container Docker en surcharge
|
- Container Docker en surcharge
|
||||||
|
|
||||||
**Que faire** :
|
**Que faire** :
|
||||||
1. Aller dans `/logs` et chercher le dernier `[sync]` actif
|
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
|
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 :
|
Possibles causes :
|
||||||
- L'apprenti existe en local mais pas (ou plus) sur Escada → le pending est obsolète, à supprimer
|
- L'apprenti existe en local mais pas (ou plus) sur Escada → le pending est obsolète, à supprimer
|
||||||
- Le nom diffère entre local et Escada (ex: prénom composé partiel)
|
- Le nom diffère entre local et Escada (ex : prénom composé partiel)
|
||||||
- La page Escada de cet apprenti est verrouillée par un autre éditeur (lock pessimiste Escada)
|
- La page Escada de cet apprenti est verrouillée par un autre éditeur (lock pessimiste Escada)
|
||||||
|
|
||||||
**Que faire** :
|
**Que faire** :
|
||||||
|
|
@ -32,7 +32,7 @@ Possibles causes :
|
||||||
2. Si l'apprenti n'existe plus : supprimer le pending manuellement en DB
|
2. Si l'apprenti n'existe plus : supprimer le pending manuellement en DB
|
||||||
3. Sinon : retenter plus tard
|
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** :
|
Pas dangereux mais **destructif** :
|
||||||
- Tous les pendings concernés sont écrasés (les modifs locales pas encore poussées sont perdues)
|
- 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)
|
## 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 :
|
Vérifier dans l'ordre :
|
||||||
|
|
||||||
1. La tâche est-elle **activée** ? (toggle vert)
|
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` ?
|
3. La crontab du host appelle-t-elle bien `cron_tick.py` ?
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -55,58 +55,109 @@ Vérifier dans l'ordre :
|
||||||
# Doit avoir : * * * * * docker exec eptm-dashboard-app-1 python scripts/cron_tick.py
|
# 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`)
|
- `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
|
## 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
|
## 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
|
## 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.
|
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
|
## 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.
|
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
|
```bash
|
||||||
cd /opt/eptm-dashboard
|
cd /opt/eptm-dashboard
|
||||||
docker compose -f docker-compose.dev.yml restart app
|
docker compose -f docker-compose.dev.yml restart app
|
||||||
```
|
```
|
||||||
|
|
||||||
### "Comment voir les logs du serveur Reflex"
|
### « Comment voir les logs du serveur Reflex »
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker logs -f eptm-dashboard-app-1
|
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.
|
||||||
|
|
|
||||||
83
data/docs/11-avis-sanction-retenue.md
Normal file
83
data/docs/11-avis-sanction-retenue.md
Normal 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
80
data/docs/12-feedback.md
Normal 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
|
||||||
99
data/docs/13-parametres.md
Normal file
99
data/docs/13-parametres.md
Normal 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.
|
||||||
|
|
@ -8,5 +8,499 @@
|
||||||
"escada_password": "Lauryne2023!",
|
"escada_password": "Lauryne2023!",
|
||||||
"totp_secret": "KQZVCQLXGNAU22KRKNCHCYSUIRAXAUSR",
|
"totp_secret": "KQZVCQLXGNAU22KRKNCHCYSUIRAXAUSR",
|
||||||
"app_base_url": "https://dev.dashboard.eptm-automation.ch",
|
"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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -34,6 +34,9 @@ app = rx.App(
|
||||||
# Force le rendu light du browser (form controls, scrollbars, etc.)
|
# 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.
|
# même quand l'OS est en dark mode. Le thème "sombre" override via CSS.
|
||||||
rx.el.meta(name="color-scheme", content="light"),
|
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)
|
# Couleur de la barre d'adresse (Android) + barre de statut (iOS standalone)
|
||||||
rx.el.meta(name="theme-color", content="#dc000e"),
|
rx.el.meta(name="theme-color", content="#dc000e"),
|
||||||
rx.el.meta(name="apple-mobile-web-app-capable", content="yes"),
|
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 —
|
# Applique le thème stocké en localStorage avant le premier render —
|
||||||
# évite un flash au défaut EPTM puis bascule. Force aussi colorScheme
|
# é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(
|
rx.el.script(
|
||||||
"""
|
"""
|
||||||
(function() {
|
(function() {
|
||||||
|
|
@ -61,6 +65,8 @@ app = rx.App(
|
||||||
}
|
}
|
||||||
document.documentElement.style.colorScheme =
|
document.documentElement.style.colorScheme =
|
||||||
(t === 'sombre') ? 'dark' : 'light';
|
(t === 'sombre') ? 'dark' : 'light';
|
||||||
|
document.documentElement.setAttribute('lang', 'fr');
|
||||||
|
document.documentElement.setAttribute('translate', 'no');
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
})();
|
})();
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -255,18 +255,20 @@ def _notes_badge(badge: rx.Var) -> rx.Component:
|
||||||
|
|
||||||
|
|
||||||
def _notes_insuf_tile(item: rx.Var) -> rx.Component:
|
def _notes_insuf_tile(item: rx.Var) -> rx.Component:
|
||||||
"""Tuile compacte 1 ligne : nom + badges moyennes. Click → fiche apprenti."""
|
"""Tuile compacte 2 lignes : nom puis badges moyennes. Click → fiche apprenti."""
|
||||||
return rx.flex(
|
return rx.vstack(
|
||||||
|
# Ligne 1 : nom
|
||||||
rx.text(
|
rx.text(
|
||||||
item["nom"], " ", item["prenom"],
|
item["nom"], " ", item["prenom"],
|
||||||
size="2", color="#1a237e",
|
size="2", color="#1a237e",
|
||||||
white_space="nowrap", overflow="hidden",
|
white_space="nowrap", overflow="hidden",
|
||||||
text_overflow="ellipsis",
|
text_overflow="ellipsis",
|
||||||
flex="1", min_width="0",
|
width="100%",
|
||||||
),
|
),
|
||||||
|
# Ligne 2 : badges moyennes
|
||||||
rx.flex(
|
rx.flex(
|
||||||
rx.foreach(item["badges"].to(list[dict]), _notes_badge),
|
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"]),
|
on_click=AccueilState.open_fiche(item["id"]),
|
||||||
cursor="pointer",
|
cursor="pointer",
|
||||||
|
|
@ -274,11 +276,11 @@ def _notes_insuf_tile(item: rx.Var) -> rx.Component:
|
||||||
background_color="var(--surface)",
|
background_color="var(--surface)",
|
||||||
border="1px solid var(--border)",
|
border="1px solid var(--border)",
|
||||||
border_radius="6px",
|
border_radius="6px",
|
||||||
flex="1 1 280px",
|
flex="1 1 220px",
|
||||||
min_width="280px",
|
min_width="220px",
|
||||||
max_width="400px",
|
max_width="280px",
|
||||||
align="center",
|
spacing="2",
|
||||||
gap="0.5rem",
|
align="start",
|
||||||
class_name="hover-lift",
|
class_name="hover-lift",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ from ..state import AuthState
|
||||||
from ..sidebar import layout
|
from ..sidebar import layout
|
||||||
from ..components import empty_state, skeleton_apprenti_card
|
from ..components import empty_state, skeleton_apprenti_card
|
||||||
from .fiche import FicheState, _notice_row
|
from .fiche import FicheState, _notice_row
|
||||||
|
from .retenue import RetenueState, retenue_modal
|
||||||
|
from .sanction import SanctionState, sanction_modal
|
||||||
from src.db import (
|
from src.db import (
|
||||||
get_session, Apprenti, Absence, ApprentiNotice,
|
get_session, Apprenti, Absence, ApprentiNotice,
|
||||||
NotesBulletin, NotesMatu, NotesExamen, ImportBN, ImportMatu,
|
NotesBulletin, NotesMatu, NotesExamen, ImportBN, ImportMatu,
|
||||||
|
|
@ -622,6 +624,7 @@ class ClasseState(AuthState):
|
||||||
"id": apprenti.id,
|
"id": apprenti.id,
|
||||||
"nom": apprenti.nom,
|
"nom": apprenti.nom,
|
||||||
"prenom": apprenti.prenom,
|
"prenom": apprenti.prenom,
|
||||||
|
"label": f"{apprenti.prenom} {apprenti.nom}",
|
||||||
"total": total,
|
"total": total,
|
||||||
"excusees": excusees,
|
"excusees": excusees,
|
||||||
"non_exc": non_exc,
|
"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:
|
def _apprenti_card(item) -> rx.Component:
|
||||||
return rx.box(
|
return rx.box(
|
||||||
# ── En-tête : nom + badge quota ───────────────────────────────────────
|
# ── En-tête : nom + badge quota ───────────────────────────────────────
|
||||||
|
|
@ -756,55 +774,102 @@ def _apprenti_card(item) -> rx.Component:
|
||||||
margin_bottom="0.75rem",
|
margin_bottom="0.75rem",
|
||||||
),
|
),
|
||||||
|
|
||||||
# ── KPIs absences ─────────────────────────────────────────────────────
|
# ── KPI cards (identiques à la fiche apprenti) ────────────────────────
|
||||||
rx.flex(
|
rx.flex(
|
||||||
_kpi_mini("Total", item["total"]),
|
_kpi_card("Périodes d'absence", item["total"]),
|
||||||
_kpi_mini("Excusees", item["excusees"], "#2e7d32"),
|
_kpi_card("Périodes à excuser", item["non_exc"], "var(--brand-primary-dark)"),
|
||||||
_kpi_mini("Non excusees", item["non_exc"], "var(--brand-primary-dark)"),
|
rx.box(
|
||||||
rx.cond(
|
rx.text("Absences", size="1", color="#666"),
|
||||||
item["quota_atteint"],
|
rx.text(
|
||||||
_kpi_mini("Absences", item["blocs"], "var(--brand-primary-dark)"),
|
item["blocs"],
|
||||||
_kpi_mini("Absences", item["blocs"]),
|
size="7", font_weight="700",
|
||||||
|
color=rx.cond(item["quota_atteint"], "#c62828", "#37474f"),
|
||||||
|
class_name="tabular",
|
||||||
|
),
|
||||||
|
rx.cond(
|
||||||
|
item["quota_atteint"],
|
||||||
|
rx.text(
|
||||||
|
"Avis de sanction",
|
||||||
|
size="1", weight="bold", color="#c62828",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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="0.5rem",
|
gap="1rem",
|
||||||
flex_wrap="wrap",
|
flex_wrap="wrap",
|
||||||
|
width="100%",
|
||||||
margin_bottom="0.75rem",
|
margin_bottom="0.75rem",
|
||||||
),
|
),
|
||||||
|
|
||||||
# ── Boutons téléchargement PDF ────────────────────────────────────────
|
# ── Actions (PDF exports + créations d'avis) ──────────────────────────
|
||||||
rx.flex(
|
rx.box(
|
||||||
rx.button(
|
rx.flex(
|
||||||
rx.icon("download", size=13),
|
|
||||||
"PDF absences",
|
|
||||||
on_click=ClasseState.download_abs_pdf(item["id"]),
|
|
||||||
variant="outline",
|
|
||||||
color_scheme="gray",
|
|
||||||
size="1",
|
|
||||||
),
|
|
||||||
rx.cond(
|
|
||||||
item["has_pdf_bn"],
|
|
||||||
rx.button(
|
rx.button(
|
||||||
rx.icon("file-text", size=13),
|
rx.icon("download", size=13),
|
||||||
"PDF bulletin",
|
"PDF absences",
|
||||||
on_click=ClasseState.download_bn_pdf(item["id"]),
|
on_click=ClasseState.download_abs_pdf(item["id"]),
|
||||||
variant="outline",
|
variant="outline", color_scheme="gray", size="2",
|
||||||
color_scheme="blue",
|
),
|
||||||
size="1",
|
rx.cond(
|
||||||
|
item["has_pdf_bn"],
|
||||||
|
rx.button(
|
||||||
|
rx.icon("download", size=13),
|
||||||
|
"PDF bulletin",
|
||||||
|
on_click=ClasseState.download_bn_pdf(item["id"]),
|
||||||
|
variant="outline", color_scheme="blue", size="2",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
rx.cond(
|
||||||
|
item["has_pdf_notes"],
|
||||||
|
rx.button(
|
||||||
|
rx.icon("download", size=13),
|
||||||
|
"PDF notes",
|
||||||
|
on_click=ClasseState.download_notes_pdf(item["id"]),
|
||||||
|
variant="outline", color_scheme="violet", size="2",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Séparateur visuel
|
||||||
|
rx.box(
|
||||||
|
width="1px",
|
||||||
|
background_color="var(--gray-6)",
|
||||||
|
margin_x="0.25rem",
|
||||||
|
align_self="stretch",
|
||||||
),
|
),
|
||||||
),
|
|
||||||
rx.cond(
|
|
||||||
item["has_pdf_notes"],
|
|
||||||
rx.button(
|
rx.button(
|
||||||
rx.icon("file-text", size=13),
|
rx.icon("file-warning", size=14),
|
||||||
"PDF notes",
|
"Créer un avis de retenue",
|
||||||
on_click=ClasseState.download_notes_pdf(item["id"]),
|
on_click=RetenueState.preload_apprenti(
|
||||||
variant="outline",
|
item["id"], item["label"],
|
||||||
color_scheme="violet",
|
),
|
||||||
size="1",
|
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%",
|
||||||
),
|
),
|
||||||
flex_wrap="wrap",
|
padding="0.75rem 1rem",
|
||||||
gap="0.5rem",
|
background_color="var(--surface)",
|
||||||
|
border_radius="8px",
|
||||||
|
border="1px solid var(--border)",
|
||||||
|
width="100%",
|
||||||
margin_bottom="0.75rem",
|
margin_bottom="0.75rem",
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
@ -901,7 +966,10 @@ def _apprenti_card(item) -> rx.Component:
|
||||||
def classe_page() -> rx.Component:
|
def classe_page() -> rx.Component:
|
||||||
return layout(
|
return layout(
|
||||||
rx.vstack(
|
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(
|
rx.cond(
|
||||||
ClasseState.has_classes,
|
ClasseState.has_classes,
|
||||||
|
|
|
||||||
|
|
@ -54,10 +54,10 @@ class CronState(AuthState):
|
||||||
|
|
||||||
f_name: str = ""
|
f_name: str = ""
|
||||||
f_enabled: bool = True
|
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_hh: str = "03"
|
||||||
f_time_mm: str = "00"
|
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_days: list[str] = [] # ["MON","WED",...]
|
||||||
f_task_kind: str = "push_then_sync"
|
f_task_kind: str = "push_then_sync"
|
||||||
f_sync_abs: bool = True
|
f_sync_abs: bool = True
|
||||||
|
|
@ -104,8 +104,6 @@ class CronState(AuthState):
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _human_schedule(kind: str, value: str) -> str:
|
def _human_schedule(kind: str, value: str) -> str:
|
||||||
if kind == "daily":
|
|
||||||
return f"Tous les jours à {value}"
|
|
||||||
if kind == "weekly":
|
if kind == "weekly":
|
||||||
try:
|
try:
|
||||||
days_part, time_part = value.split(":", 1)
|
days_part, time_part = value.split(":", 1)
|
||||||
|
|
@ -114,14 +112,13 @@ class CronState(AuthState):
|
||||||
return f"{labels} à {time_part}"
|
return f"{labels} à {time_part}"
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return value
|
return value
|
||||||
if kind == "interval":
|
if kind == "daily_multi":
|
||||||
try:
|
hours = [h.strip() for h in (value or "").split(",") if h.strip()]
|
||||||
m = int(value)
|
if not hours:
|
||||||
if m % 60 == 0:
|
return "Aucune heure définie"
|
||||||
return f"Toutes les {m // 60} h"
|
if len(hours) <= 6:
|
||||||
return f"Toutes les {m} min"
|
return "Tous les jours à " + ", ".join(hours)
|
||||||
except (TypeError, ValueError):
|
return f"Tous les jours — {len(hours)} créneaux ({hours[0]} … {hours[-1]})"
|
||||||
return value
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -130,27 +127,25 @@ class CronState(AuthState):
|
||||||
if not job.enabled:
|
if not job.enabled:
|
||||||
return "—"
|
return "—"
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
if job.schedule_kind == "interval":
|
if job.schedule_kind == "daily_multi":
|
||||||
try:
|
hours = [h.strip() for h in (job.schedule_value or "").split(",") if h.strip()]
|
||||||
m = int(job.schedule_value)
|
best: datetime | None = None
|
||||||
except (TypeError, ValueError):
|
for hhmm in hours:
|
||||||
return "—"
|
try:
|
||||||
if job.last_run_at is None:
|
hh, mm = hhmm.split(":")
|
||||||
return "Au prochain tick"
|
target = now.replace(hour=int(hh), minute=int(mm),
|
||||||
nxt = job.last_run_at + timedelta(minutes=m)
|
second=0, microsecond=0)
|
||||||
return nxt.strftime("%d.%m %H:%M")
|
except (ValueError, AttributeError):
|
||||||
if job.schedule_kind == "daily":
|
continue
|
||||||
try:
|
# Si déjà exécuté à ce créneau aujourd'hui, on le pousse au lendemain.
|
||||||
hh, mm = job.schedule_value.split(":")
|
|
||||||
target = now.replace(hour=int(hh), minute=int(mm), second=0, microsecond=0)
|
|
||||||
if (job.last_run_at and job.last_run_at.date() == now.date()
|
if (job.last_run_at and job.last_run_at.date() == now.date()
|
||||||
and job.last_run_at >= target):
|
and job.last_run_at >= target):
|
||||||
target += timedelta(days=1)
|
target += timedelta(days=1)
|
||||||
elif target < now:
|
elif target < now:
|
||||||
target += timedelta(days=1)
|
target += timedelta(days=1)
|
||||||
return target.strftime("%d.%m %H:%M")
|
if best is None or target < best:
|
||||||
except (ValueError, AttributeError):
|
best = target
|
||||||
return "—"
|
return best.strftime("%d.%m %H:%M") if best else "—"
|
||||||
if job.schedule_kind == "weekly":
|
if job.schedule_kind == "weekly":
|
||||||
return "Selon planning"
|
return "Selon planning"
|
||||||
return "—"
|
return "—"
|
||||||
|
|
@ -190,10 +185,10 @@ class CronState(AuthState):
|
||||||
self.edit_open = True
|
self.edit_open = True
|
||||||
self.f_name = ""
|
self.f_name = ""
|
||||||
self.f_enabled = True
|
self.f_enabled = True
|
||||||
self.f_schedule_kind = "daily"
|
self.f_schedule_kind = "daily_multi"
|
||||||
self.f_time_hh = "03"
|
self.f_time_hh = "03"
|
||||||
self.f_time_mm = "00"
|
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_days = ["MON", "TUE", "WED", "THU", "FRI"]
|
||||||
self.f_task_kind = "push_then_sync"
|
self.f_task_kind = "push_then_sync"
|
||||||
self.f_sync_abs = True
|
self.f_sync_abs = True
|
||||||
|
|
@ -221,13 +216,7 @@ class CronState(AuthState):
|
||||||
self.f_name = job.name
|
self.f_name = job.name
|
||||||
self.f_enabled = job.enabled
|
self.f_enabled = job.enabled
|
||||||
self.f_schedule_kind = job.schedule_kind
|
self.f_schedule_kind = job.schedule_kind
|
||||||
if job.schedule_kind == "daily":
|
if job.schedule_kind == "weekly":
|
||||||
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":
|
|
||||||
try:
|
try:
|
||||||
days_part, time_part = job.schedule_value.split(":", 1)
|
days_part, time_part = job.schedule_value.split(":", 1)
|
||||||
hh, _, mm = time_part.partition(":")
|
hh, _, mm = time_part.partition(":")
|
||||||
|
|
@ -238,9 +227,21 @@ class CronState(AuthState):
|
||||||
self.f_time_hh = "03"
|
self.f_time_hh = "03"
|
||||||
self.f_time_mm = "00"
|
self.f_time_mm = "00"
|
||||||
self.f_days = ["MON", "TUE", "WED", "THU", "FRI"]
|
self.f_days = ["MON", "TUE", "WED", "THU", "FRI"]
|
||||||
self.f_interval_min = "60"
|
self.f_hours = []
|
||||||
else: # interval
|
else: # daily_multi
|
||||||
self.f_interval_min = job.schedule_value or "60"
|
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_hh = "03"
|
||||||
self.f_time_mm = "00"
|
self.f_time_mm = "00"
|
||||||
self.f_days = ["MON", "TUE", "WED", "THU", "FRI"]
|
self.f_days = ["MON", "TUE", "WED", "THU", "FRI"]
|
||||||
|
|
@ -288,9 +289,11 @@ class CronState(AuthState):
|
||||||
def set_f_time_mm(self, v: str):
|
def set_f_time_mm(self, v: str):
|
||||||
v = "".join(ch for ch in v if ch.isdigit())[:2]
|
v = "".join(ch for ch in v if ch.isdigit())[:2]
|
||||||
self.f_time_mm = v
|
self.f_time_mm = v
|
||||||
def set_f_interval_min(self, v: str):
|
def toggle_f_hour(self, h: str):
|
||||||
v = "".join(ch for ch in v if ch.isdigit())[:5]
|
if h in self.f_hours:
|
||||||
self.f_interval_min = v
|
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):
|
def toggle_f_day(self, day: str):
|
||||||
if day in self.f_days:
|
if day in self.f_days:
|
||||||
self.f_days = [d for d in self.f_days if d != day]
|
self.f_days = [d for d in self.f_days if d != day]
|
||||||
|
|
@ -322,16 +325,7 @@ class CronState(AuthState):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Construire schedule_value selon kind
|
# Construire schedule_value selon kind
|
||||||
if self.f_schedule_kind == "daily":
|
if self.f_schedule_kind == "weekly":
|
||||||
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 not self.f_days:
|
if not self.f_days:
|
||||||
self.save_error = "Sélectionne au moins un jour de la semaine."
|
self.save_error = "Sélectionne au moins un jour de la semaine."
|
||||||
return
|
return
|
||||||
|
|
@ -344,15 +338,11 @@ class CronState(AuthState):
|
||||||
return
|
return
|
||||||
ordered = [d for d in _DAY_NAMES if d in self.f_days]
|
ordered = [d for d in _DAY_NAMES if d in self.f_days]
|
||||||
schedule_value = f"{','.join(ordered)}:{hh:02d}:{mm:02d}"
|
schedule_value = f"{','.join(ordered)}:{hh:02d}:{mm:02d}"
|
||||||
else: # interval
|
else: # daily_multi
|
||||||
try:
|
if not self.f_hours:
|
||||||
m = int(self.f_interval_min or "0")
|
self.save_error = "Sélectionne au moins une heure d'exécution."
|
||||||
if m < 1:
|
|
||||||
raise ValueError
|
|
||||||
except ValueError:
|
|
||||||
self.save_error = "Intervalle invalide (minutes > 0)."
|
|
||||||
return
|
return
|
||||||
schedule_value = str(m)
|
schedule_value = ",".join(sorted(set(self.f_hours)))
|
||||||
|
|
||||||
if self.f_classes_all:
|
if self.f_classes_all:
|
||||||
classes_json = "ALL"
|
classes_json = "ALL"
|
||||||
|
|
@ -531,12 +521,12 @@ def _job_row(job: rx.Var) -> rx.Component:
|
||||||
rx.hstack(
|
rx.hstack(
|
||||||
rx.button(
|
rx.button(
|
||||||
rx.icon(rx.cond(job["enabled"], "pause", "play"), size=14),
|
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",
|
variant="ghost", size="1", color_scheme="gray",
|
||||||
),
|
),
|
||||||
rx.button(
|
rx.button(
|
||||||
rx.icon("pencil", size=14),
|
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",
|
variant="ghost", size="1", color_scheme="gray",
|
||||||
),
|
),
|
||||||
rx.alert_dialog.root(
|
rx.alert_dialog.root(
|
||||||
|
|
@ -578,6 +568,53 @@ def _job_row(job: rx.Var) -> rx.Component:
|
||||||
border="1px solid var(--gray-5)",
|
border="1px solid var(--gray-5)",
|
||||||
border_radius="6px",
|
border_radius="6px",
|
||||||
width="100%",
|
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 (00h–23h) 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,65 +622,41 @@ def _form_schedule_picker() -> rx.Component:
|
||||||
return rx.vstack(
|
return rx.vstack(
|
||||||
rx.text("Planification", size="2", font_weight="600"),
|
rx.text("Planification", size="2", font_weight="600"),
|
||||||
rx.radio(
|
rx.radio(
|
||||||
["daily", "weekly", "interval"],
|
["daily_multi", "weekly"],
|
||||||
value=CronState.f_schedule_kind,
|
value=CronState.f_schedule_kind,
|
||||||
on_change=CronState.set_f_schedule_kind,
|
on_change=CronState.set_f_schedule_kind,
|
||||||
direction="row",
|
direction="row",
|
||||||
),
|
),
|
||||||
rx.cond(
|
rx.cond(
|
||||||
CronState.f_schedule_kind == "interval",
|
CronState.f_schedule_kind == "weekly",
|
||||||
rx.hstack(
|
rx.vstack(
|
||||||
rx.text("Toutes les", size="2"),
|
rx.flex(
|
||||||
rx.input(
|
*[
|
||||||
value=CronState.f_interval_min,
|
rx.box(
|
||||||
on_change=CronState.set_f_interval_min,
|
rx.text(_DAY_LABELS[d], size="1", weight="bold"),
|
||||||
width="80px",
|
on_click=CronState.toggle_f_day(d),
|
||||||
|
cursor="pointer",
|
||||||
|
padding="0.35rem 0.7rem",
|
||||||
|
border_radius="6px",
|
||||||
|
border="2px solid",
|
||||||
|
border_color=rx.cond(
|
||||||
|
CronState.f_days.contains(d),
|
||||||
|
"var(--red-9)", "var(--gray-6)",
|
||||||
|
),
|
||||||
|
background_color=rx.cond(
|
||||||
|
CronState.f_days.contains(d),
|
||||||
|
"var(--red-9)", "transparent",
|
||||||
|
),
|
||||||
|
color=rx.cond(
|
||||||
|
CronState.f_days.contains(d),
|
||||||
|
"white", "var(--gray-12)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for d in _DAY_NAMES
|
||||||
|
],
|
||||||
|
gap="0.3rem",
|
||||||
|
wrap="wrap",
|
||||||
),
|
),
|
||||||
rx.text("minutes", size="2"),
|
|
||||||
spacing="2", align="center",
|
|
||||||
),
|
|
||||||
rx.cond(
|
|
||||||
CronState.f_schedule_kind == "weekly",
|
|
||||||
rx.vstack(
|
|
||||||
rx.flex(
|
|
||||||
*[
|
|
||||||
rx.box(
|
|
||||||
rx.text(_DAY_LABELS[d], size="1", weight="bold"),
|
|
||||||
on_click=CronState.toggle_f_day(d),
|
|
||||||
cursor="pointer",
|
|
||||||
padding="0.35rem 0.7rem",
|
|
||||||
border_radius="6px",
|
|
||||||
border="2px solid",
|
|
||||||
border_color=rx.cond(
|
|
||||||
CronState.f_days.contains(d),
|
|
||||||
"var(--red-9)", "var(--gray-6)",
|
|
||||||
),
|
|
||||||
background_color=rx.cond(
|
|
||||||
CronState.f_days.contains(d),
|
|
||||||
"var(--red-9)", "transparent",
|
|
||||||
),
|
|
||||||
color=rx.cond(
|
|
||||||
CronState.f_days.contains(d),
|
|
||||||
"white", "var(--gray-12)",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for d in _DAY_NAMES
|
|
||||||
],
|
|
||||||
gap="0.3rem",
|
|
||||||
wrap="wrap",
|
|
||||||
),
|
|
||||||
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",
|
|
||||||
),
|
|
||||||
spacing="2",
|
|
||||||
),
|
|
||||||
# daily
|
|
||||||
rx.hstack(
|
rx.hstack(
|
||||||
rx.text("Heure :", size="2"),
|
rx.text("Heure :", size="2"),
|
||||||
rx.input(value=CronState.f_time_hh,
|
rx.input(value=CronState.f_time_hh,
|
||||||
|
|
@ -653,7 +666,9 @@ def _form_schedule_picker() -> rx.Component:
|
||||||
on_change=CronState.set_f_time_mm, width="60px"),
|
on_change=CronState.set_f_time_mm, width="60px"),
|
||||||
spacing="2", align="center",
|
spacing="2", align="center",
|
||||||
),
|
),
|
||||||
|
spacing="2",
|
||||||
),
|
),
|
||||||
|
_hours_grid(),
|
||||||
),
|
),
|
||||||
spacing="2", width="100%",
|
spacing="2", width="100%",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -204,12 +204,13 @@ def _bubble(msg: rx.Var) -> rx.Component:
|
||||||
is_user,
|
is_user,
|
||||||
rx.fragment(),
|
rx.fragment(),
|
||||||
rx.flex(
|
rx.flex(
|
||||||
rx.icon("bot", size=14, color="white"),
|
rx.icon("bot", color="white"),
|
||||||
background_color="var(--brand-accent)",
|
background_color="var(--brand-accent)",
|
||||||
border_radius="50%",
|
border_radius="50%",
|
||||||
width="28px", height="28px",
|
width="28px", height="28px",
|
||||||
align="center", justify="center",
|
align="center", justify="center",
|
||||||
flex_shrink="0",
|
flex_shrink="0",
|
||||||
|
class_name="feedback-bot-bubble",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
rx.box(
|
rx.box(
|
||||||
|
|
@ -341,8 +342,11 @@ def feedback_widget() -> rx.Component:
|
||||||
rx.flex(
|
rx.flex(
|
||||||
rx.icon("message-square", size=18, color="white"),
|
rx.icon("message-square", size=18, color="white"),
|
||||||
rx.text(
|
rx.text(
|
||||||
"Aide & feedback EPTM",
|
"Feedback",
|
||||||
size="3", weight="bold", color="white",
|
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.spacer(),
|
||||||
rx.dialog.close(
|
rx.dialog.close(
|
||||||
|
|
|
||||||
|
|
@ -447,6 +447,9 @@ class FicheState(AuthState):
|
||||||
# ── Calendar day edit ─────────────────────────────────────────────────────
|
# ── Calendar day edit ─────────────────────────────────────────────────────
|
||||||
edit_date: str = ""
|
edit_date: str = ""
|
||||||
edit_date_label: 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_p1: str = "present"
|
||||||
edit_p2: str = "present"
|
edit_p2: str = "present"
|
||||||
edit_p3: str = "present"
|
edit_p3: str = "present"
|
||||||
|
|
@ -666,6 +669,26 @@ class FicheState(AuthState):
|
||||||
Absence.date == d,
|
Absence.date == d,
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).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}
|
pm = {ab.periode: ab.statut for ab in absences}
|
||||||
|
|
||||||
def _choice(p: int) -> str:
|
def _choice(p: int) -> str:
|
||||||
|
|
@ -738,6 +761,49 @@ class FicheState(AuthState):
|
||||||
self.edit_p10 == "non_excusee"
|
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):
|
def excuse_all_visual(self):
|
||||||
"""Bascule toutes les N → E dans le panneau (sans toucher la DB).
|
"""Bascule toutes les N → E dans le panneau (sans toucher la DB).
|
||||||
L'enregistrement passe par le bouton « Enregistrer »."""
|
L'enregistrement passe par le bouton « Enregistrer »."""
|
||||||
|
|
@ -1116,10 +1182,12 @@ class FicheState(AuthState):
|
||||||
apprenti = sess.get(Apprenti, self.selected_id)
|
apprenti = sess.get(Apprenti, self.selected_id)
|
||||||
if apprenti:
|
if apprenti:
|
||||||
tvars = build_template_vars(apprenti, list(absences))
|
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 = (
|
_def_body = (
|
||||||
"Bonjour {prenom},\n\n"
|
"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"
|
"Cordialement,\nL'équipe EPTM"
|
||||||
)
|
)
|
||||||
self.email_subject = render_template(
|
self.email_subject = render_template(
|
||||||
|
|
@ -1517,6 +1585,20 @@ def _edit_panel() -> rx.Component:
|
||||||
"Édition du ", FicheState.edit_date_label,
|
"Édition du ", FicheState.edit_date_label,
|
||||||
size="3", weight="bold", color="var(--text-strong)",
|
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.spacer(),
|
||||||
rx.button(
|
rx.button(
|
||||||
rx.icon("x", size=14),
|
rx.icon("x", size=14),
|
||||||
|
|
@ -1547,9 +1629,21 @@ def _edit_panel() -> rx.Component:
|
||||||
flex_wrap="wrap",
|
flex_wrap="wrap",
|
||||||
width="100%",
|
width="100%",
|
||||||
),
|
),
|
||||||
# Action rapide : excuser visuellement toutes les N → E.
|
# Actions rapides : marquer toute la journée N (selon horaire classe)
|
||||||
# N'enregistre pas en DB — il faut cliquer sur « Enregistrer ».
|
# ou excuser toutes les N → E. Aucune touche la DB — l'enregistrement
|
||||||
|
# passe par « Enregistrer ».
|
||||||
rx.flex(
|
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.button(
|
||||||
rx.icon("check-check", size=14),
|
rx.icon("check-check", size=14),
|
||||||
"Excuser toutes les périodes",
|
"Excuser toutes les périodes",
|
||||||
|
|
@ -1557,7 +1651,7 @@ def _edit_panel() -> rx.Component:
|
||||||
disabled=~FicheState.edit_has_non_excusee,
|
disabled=~FicheState.edit_has_non_excusee,
|
||||||
variant="soft", color_scheme="green", size="2",
|
variant="soft", color_scheme="green", size="2",
|
||||||
),
|
),
|
||||||
width="100%",
|
gap="0.5rem", flex_wrap="wrap", width="100%",
|
||||||
),
|
),
|
||||||
rx.divider(),
|
rx.divider(),
|
||||||
rx.flex(
|
rx.flex(
|
||||||
|
|
@ -1829,7 +1923,7 @@ def fiche_page() -> rx.Component:
|
||||||
# Modals (rendus une fois, contrôlés par leur state respectif)
|
# Modals (rendus une fois, contrôlés par leur state respectif)
|
||||||
retenue_modal(),
|
retenue_modal(),
|
||||||
sanction_modal(),
|
sanction_modal(),
|
||||||
rx.heading("Fiche apprenti", size="7"),
|
rx.heading("Apprentis", size="7"),
|
||||||
|
|
||||||
rx.cond(
|
rx.cond(
|
||||||
FicheState.has_apprentis,
|
FicheState.has_apprentis,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@ if str(_ROOT) not in sys.path:
|
||||||
sys.path.insert(0, str(_ROOT))
|
sys.path.insert(0, str(_ROOT))
|
||||||
|
|
||||||
from src.profession import load_mapping, save_mapping, find_unmapped_classes, refresh_all_professions # noqa: E402
|
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 ..sidebar import layout
|
||||||
from ..state import AuthState
|
from ..state import AuthState
|
||||||
|
|
@ -22,6 +23,16 @@ _SETTINGS_FILE = DATA_DIR / "settings.json"
|
||||||
_DEFAULT_SANCTION = (
|
_DEFAULT_SANCTION = (
|
||||||
"Selon le règlement de l'EM, l'apprenti a dépassé le nombre d'absences limite."
|
"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_SUBJ = "Document EPTM — {nom_complet} ({classe})"
|
||||||
_DEFAULT_TEMPLATE_BODY = (
|
_DEFAULT_TEMPLATE_BODY = (
|
||||||
"Bonjour {prenom},\n\n"
|
"Bonjour {prenom},\n\n"
|
||||||
|
|
@ -84,6 +95,14 @@ class ParamsState(AuthState):
|
||||||
save_ok_prof: bool = False
|
save_ok_prof: bool = False
|
||||||
refresh_msg: str = ""
|
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 ───────────────────────────────────────────────────────────────
|
# ── Setters ───────────────────────────────────────────────────────────────
|
||||||
def set_texte_sanction(self, v: str): self.texte_sanction = v
|
def set_texte_sanction(self, v: str): self.texte_sanction = v
|
||||||
def set_chef_section(self, v: str): self.chef_section = 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_template = False
|
||||||
self.save_ok_app = False
|
self.save_ok_app = False
|
||||||
self._reload_prof_mapping()
|
self._reload_prof_mapping()
|
||||||
|
self._reload_schedule_list()
|
||||||
|
|
||||||
def _reload_prof_mapping(self):
|
def _reload_prof_mapping(self):
|
||||||
self.prof_mapping = load_mapping()
|
self.prof_mapping = load_mapping()
|
||||||
|
|
@ -237,6 +257,103 @@ class ParamsState(AuthState):
|
||||||
sess.close()
|
sess.close()
|
||||||
self.refresh_msg = f"{n} fiche(s) mise(s) à jour."
|
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 ────────────────────────────────────────────────────────────────
|
# ── 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:
|
def _section_app() -> rx.Component:
|
||||||
return _section(
|
return _section(
|
||||||
"Application",
|
"Application",
|
||||||
|
|
@ -687,6 +896,7 @@ def params_page() -> rx.Component:
|
||||||
rx.heading("Paramètres", size="7"),
|
rx.heading("Paramètres", size="7"),
|
||||||
_section_app(),
|
_section_app(),
|
||||||
_section_profession(),
|
_section_profession(),
|
||||||
|
_section_class_schedule(),
|
||||||
_section_sanction(),
|
_section_sanction(),
|
||||||
_section_smtp(),
|
_section_smtp(),
|
||||||
_section_escada(),
|
_section_escada(),
|
||||||
|
|
|
||||||
|
|
@ -65,11 +65,29 @@ class ProfileState(AuthState):
|
||||||
# Avatar
|
# Avatar
|
||||||
upload_ok: bool = False
|
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_name(self, v: str): self.edit_name = v
|
||||||
def set_edit_email(self, v: str): self.edit_email = 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_current(self, v: str): self.pwd_current = v
|
||||||
def set_pwd_new(self, v: str): self.pwd_new = 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_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):
|
def load_data(self):
|
||||||
if not self.authenticated:
|
if not self.authenticated:
|
||||||
|
|
@ -82,6 +100,14 @@ class ProfileState(AuthState):
|
||||||
self.profile_role = u.get("role", "user")
|
self.profile_role = u.get("role", "user")
|
||||||
self.profile_has_totp = bool(u.get("totp_secret"))
|
self.profile_has_totp = bool(u.get("totp_secret"))
|
||||||
self.profile_avatar = u.get("avatar_url", "")
|
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_name = self.profile_name
|
||||||
self.edit_email = self.profile_email
|
self.edit_email = self.profile_email
|
||||||
self.info_ok = False
|
self.info_ok = False
|
||||||
|
|
@ -194,6 +220,131 @@ class ProfileState(AuthState):
|
||||||
self.profile_avatar = ""
|
self.profile_avatar = ""
|
||||||
self.upload_ok = False
|
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 ────────────────────────────────────────────────────────────────
|
# ── 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:
|
def _totp_section() -> rx.Component:
|
||||||
return rx.box(
|
return rx.box(
|
||||||
rx.vstack(
|
rx.vstack(
|
||||||
|
|
@ -526,6 +825,7 @@ def profile_page() -> rx.Component:
|
||||||
_avatar_section(),
|
_avatar_section(),
|
||||||
_info_section(),
|
_info_section(),
|
||||||
_password_section(),
|
_password_section(),
|
||||||
|
_classes_section(),
|
||||||
_theme_section(),
|
_theme_section(),
|
||||||
_totp_section(),
|
_totp_section(),
|
||||||
spacing="4",
|
spacing="4",
|
||||||
|
|
|
||||||
|
|
@ -331,13 +331,18 @@ class RetenueState(AuthState):
|
||||||
f"{self.selected_label} (existante : {self.existing_notice_label})"
|
f"{self.selected_label} (existante : {self.existing_notice_label})"
|
||||||
)
|
)
|
||||||
return
|
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()
|
sess = get_session()
|
||||||
try:
|
try:
|
||||||
sess.add(Notice(
|
sess.add(Notice(
|
||||||
apprenti_id=self.selected_id,
|
apprenti_id=self.selected_id,
|
||||||
date_event=_date.today(),
|
date_event=_date.today(),
|
||||||
titre=self._build_notice_titre(),
|
titre=self._build_notice_titre(),
|
||||||
remarque=(self.remarque or "").strip() or None,
|
remarque=remarque,
|
||||||
type_notice=None,
|
type_notice=None,
|
||||||
matiere=None,
|
matiere=None,
|
||||||
source="retenue",
|
source="retenue",
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,11 @@ class SanctionState(AuthState):
|
||||||
f"{self.selected_label} (existante : {self.existing_notice_label})"
|
f"{self.selected_label} (existante : {self.existing_notice_label})"
|
||||||
)
|
)
|
||||||
return
|
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()
|
sess = get_session()
|
||||||
try:
|
try:
|
||||||
sess.add(Notice(
|
sess.add(Notice(
|
||||||
|
|
|
||||||
|
|
@ -245,6 +245,30 @@ class UsersState(AuthState):
|
||||||
self.totp_ok = True
|
self.totp_ok = True
|
||||||
app_log(f"[users] {self.username} : 2FA réinitialisé pour {self.edit_target}")
|
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]):
|
async def handle_avatar_upload(self, files: list[rx.UploadFile]):
|
||||||
if not files:
|
if not files:
|
||||||
return
|
return
|
||||||
|
|
@ -570,7 +594,7 @@ def _user_row(user: dict) -> rx.Component:
|
||||||
rx.button(
|
rx.button(
|
||||||
rx.cond(is_selected, rx.icon("chevron-up", size=14), rx.icon("pencil", size=14)),
|
rx.cond(is_selected, rx.icon("chevron-up", size=14), rx.icon("pencil", size=14)),
|
||||||
rx.cond(is_selected, "Fermer", "Éditer"),
|
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"),
|
variant=rx.cond(is_selected, "solid", "outline"),
|
||||||
color_scheme="blue",
|
color_scheme="blue",
|
||||||
size="1",
|
size="1",
|
||||||
|
|
@ -591,6 +615,10 @@ def _user_row(user: dict) -> rx.Component:
|
||||||
border_radius="6px",
|
border_radius="6px",
|
||||||
border=rx.cond(is_selected, "1px solid var(--blue-6)", "1px solid #e0e0e0"),
|
border=rx.cond(is_selected, "1px solid var(--blue-6)", "1px solid #e0e0e0"),
|
||||||
width="100%",
|
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,
|
on_click=UsersState.save_access,
|
||||||
color_scheme="blue", size="2",
|
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."),
|
_ok_callout(UsersState.access_ok, "Accès mis à jour."),
|
||||||
_err_callout(UsersState.access_error),
|
_err_callout(UsersState.access_error),
|
||||||
spacing="3", align="center", flex_wrap="wrap",
|
spacing="3", align="center", flex_wrap="wrap",
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import reflex as rx
|
import reflex as rx
|
||||||
from .state import AuthState
|
from .state import AuthState
|
||||||
from .components import scan_docs
|
from .components import scan_docs
|
||||||
|
|
@ -6,6 +9,45 @@ from .components import scan_docs
|
||||||
# détecter de nouveaux fichiers).
|
# détecter de nouveaux fichiers).
|
||||||
_DOC_SECTIONS = scan_docs()
|
_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 où 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"
|
FULL_W = "240px"
|
||||||
RAIL_W = "68px"
|
RAIL_W = "68px"
|
||||||
TOPBAR_H = "56px"
|
TOPBAR_H = "56px"
|
||||||
|
|
@ -22,8 +64,8 @@ _ACTIVE_CLR = "var(--brand-primary-light)"
|
||||||
|
|
||||||
_PAGES = [
|
_PAGES = [
|
||||||
("Tableau de bord", "/accueil", "layout-dashboard"),
|
("Tableau de bord", "/accueil", "layout-dashboard"),
|
||||||
("Apprentis", "/fiche", "user"),
|
|
||||||
("Classes", "/classe", "users"),
|
("Classes", "/classe", "users"),
|
||||||
|
("Apprentis", "/fiche", "user"),
|
||||||
]
|
]
|
||||||
|
|
||||||
_ADMIN_PAGES = [
|
_ADMIN_PAGES = [
|
||||||
|
|
@ -433,6 +475,9 @@ def sidebar() -> rx.Component:
|
||||||
_doc_section(),
|
_doc_section(),
|
||||||
rx.spacer(),
|
rx.spacer(),
|
||||||
|
|
||||||
|
# Version (dernier tag git) — au-dessus du profil
|
||||||
|
_version_badge(),
|
||||||
|
|
||||||
# User
|
# User
|
||||||
rx.box(height="1px", width="100%", background_color=_BORDER),
|
rx.box(height="1px", width="100%", background_color=_BORDER),
|
||||||
rx.box(
|
rx.box(
|
||||||
|
|
@ -556,8 +601,9 @@ _KEYBOARD_SHORTCUTS_JS = """
|
||||||
|
|
||||||
|
|
||||||
def layout(content: rx.Component) -> rx.Component:
|
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.feedback import feedback_widget
|
||||||
|
from .pages.profile import enroll_required_dialog
|
||||||
return rx.box(
|
return rx.box(
|
||||||
sidebar(),
|
sidebar(),
|
||||||
_mobile_topbar(),
|
_mobile_topbar(),
|
||||||
|
|
@ -577,6 +623,7 @@ def layout(content: rx.Component) -> rx.Component:
|
||||||
box_sizing="border-box",
|
box_sizing="border-box",
|
||||||
),
|
),
|
||||||
feedback_widget(),
|
feedback_widget(),
|
||||||
|
enroll_required_dialog(),
|
||||||
rx.script(_KEYBOARD_SHORTCUTS_JS),
|
rx.script(_KEYBOARD_SHORTCUTS_JS),
|
||||||
width="100%",
|
width="100%",
|
||||||
height="100vh",
|
height="100vh",
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,18 @@ class AuthState(rx.State):
|
||||||
doc_expanded: bool = False
|
doc_expanded: bool = False
|
||||||
# Compteur de messages feedback "new" (admin uniquement)
|
# Compteur de messages feedback "new" (admin uniquement)
|
||||||
feedback_new_count: int = 0
|
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
|
@rx.var
|
||||||
def authenticated(self) -> bool:
|
def authenticated(self) -> bool:
|
||||||
|
|
@ -130,8 +142,24 @@ class AuthState(rx.State):
|
||||||
self.theme = stored_theme
|
self.theme = stored_theme
|
||||||
# Compteur feedback (admin uniquement) — pour le badge sidebar
|
# Compteur feedback (admin uniquement) — pour le badge sidebar
|
||||||
self._refresh_feedback_count()
|
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)
|
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):
|
def _refresh_feedback_count(self):
|
||||||
if self.role != "admin":
|
if self.role != "admin":
|
||||||
self.feedback_new_count = 0
|
self.feedback_new_count = 0
|
||||||
|
|
@ -272,6 +300,9 @@ class AuthState(rx.State):
|
||||||
self.role = user.get("role", "user")
|
self.role = user.get("role", "user")
|
||||||
self.photo_url = user.get("avatar_url", "")
|
self.photo_url = user.get("avatar_url", "")
|
||||||
self.theme = user.get("theme") or "eptm"
|
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()
|
self._reset_totp_flow()
|
||||||
return [self._apply_theme_script(self.theme), rx.redirect("/accueil")]
|
return [self._apply_theme_script(self.theme), rx.redirect("/accueil")]
|
||||||
|
|
||||||
|
|
@ -297,6 +328,12 @@ class AuthState(rx.State):
|
||||||
self.role = "user"
|
self.role = "user"
|
||||||
self.photo_url = ""
|
self.photo_url = ""
|
||||||
self.theme = "eptm"
|
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_user = ""
|
||||||
self.login_pass = ""
|
self.login_pass = ""
|
||||||
self.login_error = ""
|
self.login_error = ""
|
||||||
|
|
|
||||||
|
|
@ -78,21 +78,15 @@ def _is_due(job: CronJob, now: datetime) -> bool:
|
||||||
|
|
||||||
last = job.last_run_at
|
last = job.last_run_at
|
||||||
|
|
||||||
if job.schedule_kind == "interval":
|
if job.schedule_kind == "daily_multi":
|
||||||
# schedule_value = nb minutes
|
# schedule_value = "HH:MM,HH:MM,HH:MM,..." (plusieurs heures par jour)
|
||||||
try:
|
for hhmm in (job.schedule_value or "").split(","):
|
||||||
minutes = int(job.schedule_value)
|
hhmm = hhmm.strip()
|
||||||
except (TypeError, ValueError):
|
if not hhmm:
|
||||||
return False
|
continue
|
||||||
if minutes < 1:
|
if _due_time_of_day(hhmm, last, now):
|
||||||
return False
|
return True
|
||||||
if last is None:
|
return False
|
||||||
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)
|
|
||||||
|
|
||||||
if job.schedule_kind == "weekly":
|
if job.schedule_kind == "weekly":
|
||||||
# schedule_value = "MON,WED,FRI:HH:MM"
|
# schedule_value = "MON,WED,FRI:HH:MM"
|
||||||
|
|
|
||||||
278
scripts/fetch_user_classes.py
Normal file
278
scripts/fetch_user_classes.py
Normal 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()
|
||||||
51
src/db.py
51
src/db.py
|
|
@ -287,11 +287,10 @@ class CronJob(Base):
|
||||||
name: Mapped[str]
|
name: Mapped[str]
|
||||||
enabled: Mapped[bool] = mapped_column(default=True)
|
enabled: Mapped[bool] = mapped_column(default=True)
|
||||||
|
|
||||||
# schedule_kind ∈ {"daily", "weekly", "interval"}
|
# schedule_kind ∈ {"daily_multi", "weekly"}
|
||||||
# daily : schedule_value="HH:MM"
|
# daily_multi : schedule_value="HH:MM,HH:MM,..." (1..N heures par jour)
|
||||||
# weekly : schedule_value="MON,TUE,WED,THU,FRI:HH:MM"
|
# weekly : schedule_value="MON,TUE,WED,THU,FRI:HH:MM"
|
||||||
# interval: schedule_value="60" (minutes)
|
schedule_kind: Mapped[str] = mapped_column(default="daily_multi")
|
||||||
schedule_kind: Mapped[str] = mapped_column(default="daily")
|
|
||||||
schedule_value: Mapped[str] = mapped_column(default="03:00")
|
schedule_value: Mapped[str] = mapped_column(default="03:00")
|
||||||
|
|
||||||
# task_kind ∈ {"push", "sync", "push_then_sync"}
|
# task_kind ∈ {"push", "sync", "push_then_sync"}
|
||||||
|
|
@ -433,6 +432,48 @@ def init_db(engine=None):
|
||||||
_conn.commit()
|
_conn.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,33 @@ def format_date_long(d: _date) -> str:
|
||||||
return f"{d.day} {_MOIS_FR[d.month - 1]} {d.year}"
|
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(
|
def generate_retenue_pdf(
|
||||||
sess: Session,
|
sess: Session,
|
||||||
apprenti_id: int,
|
apprenti_id: int,
|
||||||
|
|
@ -69,11 +96,10 @@ def generate_retenue_pdf(
|
||||||
f"{profession.strip()} {apprenti.classe}".strip()
|
f"{profession.strip()} {apprenti.classe}".strip()
|
||||||
if profession else apprenti.classe
|
if profession else apprenti.classe
|
||||||
)
|
)
|
||||||
npa_ville = ""
|
|
||||||
if fiche:
|
# Destinataire : représentant légal si mineur, sinon l'apprenti lui-même.
|
||||||
cp = (fiche.entreprise_code_postal or "").strip()
|
# L'adresse de l'entreprise n'est plus utilisée.
|
||||||
loc = (fiche.entreprise_localite or "").strip()
|
dest_nom, dest_adresse, dest_npa_ville = _destinataire(apprenti, fiche)
|
||||||
npa_ville = f"{cp} {loc}".strip()
|
|
||||||
|
|
||||||
# 1. Lecture template + clone
|
# 1. Lecture template + clone
|
||||||
reader = pypdf.PdfReader(str(_TEMPLATE_PATH))
|
reader = pypdf.PdfReader(str(_TEMPLATE_PATH))
|
||||||
|
|
@ -88,9 +114,9 @@ def generate_retenue_pdf(
|
||||||
field_values: dict[str, str] = {
|
field_values: dict[str, str] = {
|
||||||
"NomApprenti": f"{apprenti.prenom} {apprenti.nom}".strip(),
|
"NomApprenti": f"{apprenti.prenom} {apprenti.nom}".strip(),
|
||||||
"Classe": classe_full,
|
"Classe": classe_full,
|
||||||
"NomEntreprise": (fiche.entreprise_nom if fiche else "") or "",
|
"NomEntreprise": dest_nom,
|
||||||
"Adresse": (fiche.entreprise_adresse if fiche else "") or "",
|
"Adresse": dest_adresse,
|
||||||
"NPA-Ville": npa_ville,
|
"NPA-Ville": dest_npa_ville,
|
||||||
"RetenueDateHeure": retenue_date.strftime("%d.%m.%Y"),
|
"RetenueDateHeure": retenue_date.strftime("%d.%m.%Y"),
|
||||||
"Branche": branche if case == "devoir" else "",
|
"Branche": branche if case == "devoir" else "",
|
||||||
"Remarque": remarque,
|
"Remarque": remarque,
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,35 @@ def _load_settings() -> dict:
|
||||||
return {}
|
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(
|
def generate_avis_pdf(
|
||||||
sess: Session,
|
sess: Session,
|
||||||
apprenti_id: int,
|
apprenti_id: int,
|
||||||
|
|
@ -68,19 +97,16 @@ def generate_avis_pdf(
|
||||||
fiche: Optional[ApprentiFiche] = apprenti.fiche
|
fiche: Optional[ApprentiFiche] = apprenti.fiche
|
||||||
settings = _load_settings()
|
settings = _load_settings()
|
||||||
|
|
||||||
# Construction des valeurs
|
# Destinataire : représentant légal si mineur, sinon l'apprenti lui-même.
|
||||||
npa_ville = ""
|
# L'adresse de l'entreprise n'est plus utilisée.
|
||||||
if fiche:
|
dest_nom, dest_adresse, dest_npa_ville = _destinataire(apprenti, fiche)
|
||||||
cp = (fiche.entreprise_code_postal or "").strip()
|
|
||||||
loc = (fiche.entreprise_localite or "").strip()
|
|
||||||
npa_ville = f"{cp} {loc}".strip()
|
|
||||||
|
|
||||||
field_values: dict[str, str] = {
|
field_values: dict[str, str] = {
|
||||||
"NomApprenti": f"{apprenti.prenom} {apprenti.nom}".strip(),
|
"NomApprenti": f"{apprenti.prenom} {apprenti.nom}".strip(),
|
||||||
"Classe": apprenti.classe or "",
|
"Classe": apprenti.classe or "",
|
||||||
"NomParents": (fiche.entreprise_nom if fiche else "") or "",
|
"NomParents": dest_nom,
|
||||||
"Adresse": (fiche.entreprise_adresse if fiche else "") or "",
|
"Adresse": dest_adresse,
|
||||||
"NPA-Ville": npa_ville,
|
"NPA-Ville": dest_npa_ville,
|
||||||
"Date": date.today().strftime("%d.%m.%Y"),
|
"Date": date.today().strftime("%d.%m.%Y"),
|
||||||
"TexteDescription": (
|
"TexteDescription": (
|
||||||
(texte_override or "").strip()
|
(texte_override or "").strip()
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,13 @@ def _load_user(username: str) -> Optional[dict]:
|
||||||
def get_allowed_classes(username: str) -> Optional[list[str]]:
|
def get_allowed_classes(username: str) -> Optional[list[str]]:
|
||||||
"""Retourne la liste des classes autorisées pour l'utilisateur.
|
"""Retourne la liste des classes autorisées pour l'utilisateur.
|
||||||
|
|
||||||
- None : aucune restriction (admin, ou champ vide / absent)
|
- None : aucune restriction (admin uniquement)
|
||||||
- [] : restriction explicite à zéro classe (= ne voit rien)
|
- [] : restriction à zéro classe (= ne voit rien) — défaut pour user
|
||||||
- [...] : restreint à ces classes
|
- [...] : 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)
|
user = _load_user(username)
|
||||||
if not user:
|
if not user:
|
||||||
|
|
@ -39,8 +43,7 @@ def get_allowed_classes(username: str) -> Optional[list[str]]:
|
||||||
return None
|
return None
|
||||||
allowed = user.get("allowed_classes")
|
allowed = user.get("allowed_classes")
|
||||||
if allowed is None:
|
if allowed is None:
|
||||||
return None
|
return [] # ← user sans config = aucun accès
|
||||||
# `allowed_classes: []` (présent mais vide) signifie « aucun accès »
|
|
||||||
return list(allowed)
|
return list(allowed)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue