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
|
||||
echo "__pycache__/" >> /opt/eptm-dashboard/.dockerignore
|
||||
echo ".venv/" >> /opt/eptm-dashboard/.dockerignore
|
||||
.web/
|
||||
__pycache__/
|
||||
.venv/
|
||||
data/browser_profile/
|
||||
data/cache/
|
||||
data/*.db
|
||||
data/*.db-*
|
||||
logs/
|
||||
.git/
|
||||
|
|
|
|||
25
TODO.md
25
TODO.md
|
|
@ -5,16 +5,25 @@ en haut de la section concernée.
|
|||
|
||||
## Idées / fonctionnalités
|
||||
|
||||
- [ ] Ajouter sur le dashboard l'affichage des notes insuffisantes
|
||||
|
||||
- [X] Afficher toutes les notes du BN
|
||||
- [ ] Mettre à jour les MD
|
||||
- [X] Mettre à jour les MD (réalisé le 2026-05-12, doc complète incl. nouveaux chapitres 11-avis, 12-feedback, 13-parametres)
|
||||
- [X] Ajouter l'indication des compensation des désavantages
|
||||
- [X] Ajouter le TAB notices aussi sur la vue classe
|
||||
- [ ] Réussir à récupérer le fichier session Esacada d'un utilisateur pour l'utiliser sur le serveur afin de récupérer la liste des classes dont il a accès et de pouvoir uploader les notices avec son nom propre
|
||||
- [X] Réussir à récupérer le fichier session Esacada d'un utilisateur pour l'utiliser sur le serveur afin de récupérer la liste des classes dont il a accès et de pouvoir uploader les notices avec son nom propre
|
||||
- [X] Filtrer que les classes EM pour les avis de sanction
|
||||
- [ ] Ajouter sur la page apprenti sa situation au niveau des sanctions (barre de progression en fonction des notices)
|
||||
- [ ] Ajouter dans le texte des notices qui a créé la notice (USER) en attendant d'avoir une identification spécifique escada à chaque utilisateur.
|
||||
|
||||
- [X] Ajouter dans le texte des notices qui a créé la notice (USER) en attendant d'avoir une identification spécifique escada à chaque utilisateur.
|
||||
- [X] Mettre dans les tâches CRON les heures et pas chaque x minutes
|
||||
- [X] Modifier l'adresse du destinataire des avis de sanction/absences -> représentant légal pour les mineurs, apprenti pour les majeurs
|
||||
- [X] Changer le texte de l'objet dans les mails apprentis
|
||||
- [X] Ajouter dans le sidebar la version GIT du document.
|
||||
- [X] Ajouter bouton "Absent toute la journée" avec filtre des périodes en fonction des classes
|
||||
- [X] Ajouter dans l'export des absences s'il s'agit dun jour de théorie/pratique/matu
|
||||
- [X] Renommer les pages : « Vue classe » → « Classes », « Fiche apprenti » → « Apprentis » + réordonner sidebar (Classes au-dessus d'Apprentis)
|
||||
- [X] Cron : supprimer les schedules `daily` et `interval`, ne garder que `daily_multi` (grille 24 cases) + `weekly`. Migration auto au boot.
|
||||
- [X] Bouton « Absent toute la journée » : griser + libellé « (Données chronoplan manquantes) » si pas de mapping configuré
|
||||
- [X] Ajouter dans le panneau d'édition d'absences un badge couleur Théorie / Pratique / Matu selon le jour
|
||||
|
||||
## Bugs connus
|
||||
|
||||
|
|
@ -26,6 +35,12 @@ en haut de la section concernée.
|
|||
- [ ] Faire un thème avec fond foncé
|
||||
- [ ] Lancer une optimisation des toasts
|
||||
- [X] Changer la couleur du bouton Générer l'avais de sanction
|
||||
- [X] rendre plus petit la bulle dans le logo chat et changer le titre (enlever EPTM)
|
||||
- [X] Utiliser les mêmes PKIs, boutons télécharger et création des avis sur la page classe que sur la page apprenti
|
||||
- [X] Simplifier les cards apprentis sur la page classe (infos principales)
|
||||
- [X] Ajouter sur le dashboard l'affichage des notes insuffisantes
|
||||
|
||||
|
||||
|
||||
## Notes / réflexions
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,15 @@
|
|||
.no-scrollbar { scrollbar-width: none; -ms-overflow-style: none; }
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* Bouton flottant de feedback (FAB) : on garde le cercle bleu (taille Radix
|
||||
"3") mais on réduit l'icône à l'intérieur de 20% (36 → 29 px). Radix
|
||||
sur-écrit la prop size de rx.icon donc on force via CSS, en ciblant par
|
||||
l'attribut title du bouton (propagé au DOM contrairement à class_name). */
|
||||
button[title="Signaler un bug ou proposer une idée"] svg {
|
||||
width: 23px !important;
|
||||
height: 23px !important;
|
||||
}
|
||||
|
||||
/* Badge avec animation pulse — utilisé pour indiquer les messages non lus. */
|
||||
@keyframes feedback-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
|
|
|
|||
1
data/VERSION
Normal file
1
data/VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
1.0.0
|
||||
|
|
@ -16,8 +16,29 @@ credentials:
|
|||
test:
|
||||
allowed_classes:
|
||||
- AUTOMAT 1
|
||||
- AUTOMAT 2
|
||||
- AUTOMAT 3
|
||||
- AUTOMAT 4
|
||||
- CFTI-AU 1A
|
||||
- CFTI-AU 1B
|
||||
- CFTI-AU 2
|
||||
- EM-AU 1
|
||||
- EM-AU 1A
|
||||
- EM-AU 1B
|
||||
- EM-AU 2
|
||||
- EM-AU 2A
|
||||
- EM-AU 2B
|
||||
- EM-AU 3
|
||||
- EM-AU 3A
|
||||
- EM-AU 3B
|
||||
- EM-AU 4
|
||||
- MONTAUT 1
|
||||
- MONTAUT 2
|
||||
- MONTAUT 3
|
||||
- Z-IT Test 1
|
||||
email: julien@balet-vs.ch
|
||||
escada_password: Lauryne2023!
|
||||
escada_username: julien.balet@edu.vs.ch
|
||||
name: test
|
||||
password: $2b$12$dAvkehPvcU5zokLhUzjswu7APcRi1e4C1IeR6Gc7/51wh9vGTl4MS
|
||||
role: user
|
||||
|
|
|
|||
|
|
@ -32,15 +32,15 @@
|
|||
"MP1-TASV 4D:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=3bc53c77-bda8-43a4-ab47-08c2eb84a917",
|
||||
"MP1-TASV 4E:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=7673335b-a2aa-42de-9cab-6b95ffc90d7c",
|
||||
"Z-IT Test 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=f0707cc3-4511-403b-a2a6-d79f2da4fd99",
|
||||
"AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=326850f9-08ad-413c-ad15-1fa079f5058b",
|
||||
"AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=8b5035a7-f0b6-41dd-b203-4c5d540b1e64",
|
||||
"AUTOMAT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=93c187b8-7bd9-4361-82ef-cac3a5658c6c",
|
||||
"AUTOMAT 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=83a6684e-a2be-4148-8bd4-faaea872698d",
|
||||
"EM-AU 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=cf80e939-1c49-484b-b8ed-1357a1a51c2b",
|
||||
"EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=12a70ca4-1410-4c7a-85b8-36eeabbe7cda",
|
||||
"EM-AU 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=eb8d612b-0929-47bc-8af5-ffebc7ed2432",
|
||||
"EM-AU 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=d0781d15-f260-40b5-9bac-e3c919533422",
|
||||
"MONTAUT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=69c49e37-d184-4ab4-a463-914ed635d237",
|
||||
"MONTAUT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=6d07e078-38b1-49da-a513-91b482dbf2a6",
|
||||
"MONTAUT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=6705457b-b544-4c74-8de1-eb61cf45511d"
|
||||
"AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=017d74aa-47c3-4ad8-8dd3-79520a126a1a",
|
||||
"AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=e2911ac8-e7e5-4f5c-8eaf-6f884308c73b",
|
||||
"AUTOMAT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=b915369f-7391-4b12-9ec1-f0d3db24be88",
|
||||
"AUTOMAT 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=5468c887-961e-4da7-90f8-73b5575402a6",
|
||||
"EM-AU 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=b1ad1496-5b42-40ec-9db3-6b5360cb0784",
|
||||
"EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=24755378-e2f5-4a0c-ba16-4c5c8dfbb48d",
|
||||
"EM-AU 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=5288ce4a-512b-42e6-b7d0-24291b37283c",
|
||||
"EM-AU 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=d3fbc0f4-9b00-4a98-8679-54bfc498bce6",
|
||||
"MONTAUT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=88e8bfcf-f784-42f6-95ef-2197b5991f08",
|
||||
"MONTAUT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=3e291e9a-1307-4786-907b-a1c2cfe0e490",
|
||||
"MONTAUT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=a09c3cf3-a741-4b41-9026-d9065b125b8a"
|
||||
}
|
||||
|
|
@ -1,42 +1,75 @@
|
|||
# Vue d'ensemble
|
||||
|
||||
EPTM Dashboard est une application de gestion des absences, notes et bulletins pour l'École professionnelle technique et des métiers (EPTM Sion / Monthey). Elle se synchronise avec **Escadaweb** (le système de notation cantonal) pour récupérer et pousser les données.
|
||||
EPTM Dashboard est une application de gestion des absences, notes, bulletins, avis (retenue / sanction) et notices pour l'École professionnelle technique et des métiers (EPTM Sion). Elle se synchronise avec **Escadaweb** (le système de notation cantonal) pour récupérer et pousser les données.
|
||||
|
||||
## À quoi sert l'application
|
||||
|
||||
- **Visualiser les absences** par apprenti ou par classe, avec calendrier mensuel et statistiques.
|
||||
- **Visualiser** les absences, BN, notes d'examen, notices et fiches personnelles par apprenti ou par classe.
|
||||
- **Excuser ou modifier** les absences manuellement (les changements sont mis en file d'attente avant d'être renvoyés à Escada).
|
||||
- **Récupérer les bulletins de notes (BN)**, les notes d'examen et les fiches personnelles depuis Escada.
|
||||
- **Créer des avis** de retenue et de sanction au format PDF (templates AcroForm pré-remplis) — l'avis est aussi envoyé en notice vers Escada.
|
||||
- **Récupérer / pousser** les notices Escada (création, lecture, statut).
|
||||
- **Automatiser** les imports/exports via des tâches planifiées (cron) avec notifications Telegram.
|
||||
- **Tracer** qui a modifié quoi (audit log complet).
|
||||
- **Envoyer par email** un récap d'absences (+ bulletin + notes) à l'apprenti, au formateur ou à une adresse libre.
|
||||
- **Tracer** qui a modifié quoi (audit log complet + champ `updated_by` sur chaque entité).
|
||||
- **Collecter du feedback** in-app via un widget chat (admin reçoit un email + dialogue de réponse).
|
||||
- **Gérer les droits utilisateur** : restriction d'accès par classe, self-service via identifiants Escada de l'utilisateur.
|
||||
|
||||
## Modèle de données simplifié
|
||||
|
||||
```
|
||||
Apprenti ── Absence (avec statut: a_traiter, excusee, ...)
|
||||
├── ApprentiFiche (données personnelles : adresse, entreprise, formateur)
|
||||
Apprenti ── Absence (statut : a_traiter, excusee, ...)
|
||||
├── ApprentiFiche (adresse, entreprise, formateur, représentant
|
||||
│ légal, compensation des désavantages, majeur/mineur)
|
||||
├── NotesBulletin (BN par semestre)
|
||||
├── NotesMatu (Matu pro)
|
||||
└── NotesExamen (notes d'examen finales)
|
||||
├── NotesExamen (notes d'examen)
|
||||
├── ApprentiNotice (notices importées depuis Escada)
|
||||
└── Notice (notices locales, file de push vers Escada)
|
||||
|
||||
EscadaPending : file d'attente des modifications locales à pousser vers Escada
|
||||
(action ∈ {"E", "N", "clear"})
|
||||
EscadaPending : file d'attente des modifications d'absences locales à
|
||||
pousser vers Escada (action ∈ {"E", "N", "clear"})
|
||||
|
||||
Import / ImportBN / ImportMatu : trace des imports PDF effectués
|
||||
|
||||
CronJob : tâches planifiées (push, sync, push+sync)
|
||||
CronJob : tâches planifiées (push, sync, push+sync) — schedules
|
||||
daily_multi (plusieurs heures/jour) ou weekly
|
||||
|
||||
FeedbackMessage : feedback in-app (bug / proposition) — statut new /
|
||||
in_progress / resolved + réponse admin
|
||||
```
|
||||
|
||||
## Architecture technique
|
||||
|
||||
- **Frontend** : Reflex 0.9.2 (Python full-stack avec Radix Themes + Tailwind-friendly)
|
||||
- **DB** : SQLite en mode WAL, à `data/absences.db`
|
||||
- **Scraping Escada** : Selenium / Playwright, dans `scripts/sync_esacada.py` et `scripts/push_to_escada.py`
|
||||
- **Frontend** : Reflex 0.9.2 (Python full-stack, Radix Themes, lucide-react icons)
|
||||
- **DB** : SQLite mode WAL, à `data/absences.db`
|
||||
- **Scraping Escada** : Playwright (sync API), dans `scripts/sync_esacada.py`, `scripts/push_to_escada.py`, `scripts/push_notices.py`, `scripts/pull_notices.py`, `scripts/fetch_user_classes.py`
|
||||
- **Parsing PDF** : `src/parser.py` (absences), `src/parser_bn.py` (bulletins), `src/parser_matu.py` (matu)
|
||||
- **Conteneurisation** : Docker Compose, derrière nginx-proxy-manager
|
||||
- **Génération avis PDF** : `src/sanction_pdf.py`, `src/retenue_pdf.py` (templates AcroForm dans `data/templates/`)
|
||||
- **Conteneurisation** : Docker Compose, derrière nginx-proxy-manager (NPM, containerisé depuis mai 2026)
|
||||
- **Cron** : OS cron déclenche `scripts/cron_tick.py` toutes les minutes, qui consulte la table `CronJob`
|
||||
- **Emails** : SMTP (Brevo en prod), templates configurables depuis `/params`
|
||||
|
||||
## Rôles utilisateurs
|
||||
## Pages disponibles
|
||||
|
||||
- **user** : accès aux pages Tableau de bord, Apprentis, Classes (lecture + édition d'absences).
|
||||
- **admin** : tout ce qui précède + Escada, Cron, Logs, Utilisateurs, Paramètres.
|
||||
| Page | Route | user | admin |
|
||||
|----------------------------|--------------|------|-------|
|
||||
| Tableau de bord | `/accueil` | ✅ | ✅ |
|
||||
| Classes | `/classe` | ✅* | ✅ |
|
||||
| Apprentis | `/fiche` | ✅* | ✅ |
|
||||
| Documentation | `/doc` | ✅ | ✅ |
|
||||
| Mon profil | `/profile` | ✅ | ✅ |
|
||||
| Escada (sync / push) | `/escada` | ❌ | ✅ |
|
||||
| Tâches planifiées | `/cron` | ❌ | ✅ |
|
||||
| Logs | `/logs` | ❌ | ✅ |
|
||||
| Utilisateurs | `/users` | ❌ | ✅ |
|
||||
| Paramètres | `/params` | ❌ | ✅ |
|
||||
| Feedback | `/feedback` | ❌ | ✅ |
|
||||
| Purger classe | `/purge` | ❌ | ✅ |
|
||||
|
||||
✅* : restriction par `allowed_classes` (l'utilisateur ne voit que ses classes autorisées). Si la liste est vide, un dialogue d'enrôlement obligatoire s'affiche à chaque navigation (cf. doc « Authentification »).
|
||||
|
||||
## Sidebar — version & profil
|
||||
|
||||
- La **version** (dernier tag git ou contenu de `data/VERSION`) s'affiche au-dessus du widget profil. À mettre à jour manuellement après un nouveau tag : éditer `data/VERSION` puis `docker restart eptm-dashboard-app-1`.
|
||||
- Le **widget profil** ouvre un popover avec « Mon profil » + « Déconnexion ».
|
||||
- Le **bouton feedback** flotte en bas-droite de l'écran (FAB) : ouvre la modale chat pour signaler un bug ou proposer une idée.
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
# Synchronisation Escada (pull)
|
||||
|
||||
La synchronisation depuis Escada télécharge les PDFs (absences, BN, notes, fiches) et les importe en base. C'est l'opération la plus complexe de l'application.
|
||||
La synchronisation depuis Escada télécharge les PDFs / vues (absences, BN, Matu, notes, fiches, notices) et les importe en base. C'est l'opération la plus complexe de l'application.
|
||||
|
||||
## Page : `/escada`
|
||||
|
||||
### Sélection des classes
|
||||
|
||||
La liste des classes vient d'un cache disque (`data/esacada_classes.json`) rempli via le bouton **Actualiser**. Le rafraîchissement lance une session Selenium qui se connecte à Escadaweb et récupère la liste complète des classes.
|
||||
La liste des classes vient d'un cache disque (`data/esacada_classes.json`) rempli via le bouton **Actualiser**. Le rafraîchissement lance une session Playwright qui se connecte à Escadaweb et récupère la liste complète des classes.
|
||||
|
||||
> Note : MP, MI et "Formation" sont **filtrées de l'affichage UI** mais conservées dans le cache (elles servent au matching Matu pro).
|
||||
> Note : MP, MI et « Formation » sont **filtrées de l'affichage UI** mais conservées dans le cache (elles servent au matching Matu pro).
|
||||
|
||||
### Options de synchronisation
|
||||
|
||||
|
|
@ -17,11 +17,12 @@ La liste des classes vient d'un cache disque (`data/esacada_classes.json`) rempl
|
|||
| Absences | Télécharge les PDFs d'absences + parse + import |
|
||||
| BN | Bulletins de notes + moyennes Matu (semestres complets) |
|
||||
| Notes | Notes d'examens finales |
|
||||
| Données apprentis | Fiches personnelles : adresse, entreprise, formateur |
|
||||
| Données apprentis | Fiches personnelles : adresse, entreprise, formateur, représentant légal, statut compensation des désavantages, majeur/mineur |
|
||||
| Notices | Importe l'historique des notices Escada (table `apprenti_notices`) |
|
||||
|
||||
### Force réimportation complète
|
||||
|
||||
La case "Forcer la réimportation complète" (signalée en jaune) **écrase tous les statuts d'absences** côté local par les valeurs du PDF, et **vide les pendings** des absences concernées.
|
||||
La case « Forcer la réimportation complète » (signalée en jaune) **écrase tous les statuts d'absences** côté local par les valeurs du PDF, et **vide les pendings** des absences concernées.
|
||||
|
||||
À utiliser uniquement quand on veut **reprendre l'état complet d'Escada** (par exemple après un test ou une corruption locale).
|
||||
|
||||
|
|
@ -32,39 +33,56 @@ Sans ce flag :
|
|||
|
||||
## Phases d'exécution
|
||||
|
||||
### Phase 1 : Scraping (Selenium)
|
||||
### Phase 1 : Scraping (Playwright)
|
||||
|
||||
`scripts/sync_esacada.py --sync-all CLASSE1 CLASSE2 ...`
|
||||
|
||||
1. Selenium ouvre Escadaweb avec un profil persistant (`data/browser_profile/`)
|
||||
1. Playwright ouvre Escadaweb avec un profil persistant (`data/browser_profile/`).
|
||||
2. Pour chaque classe sélectionnée :
|
||||
- Télécharge le PDF d'absences
|
||||
- Télécharge le PDF de bulletin
|
||||
- Télécharge le PDF de notes
|
||||
- Pour les apprentis Matu : télécharge le PDF Matu de la classe MP correspondante
|
||||
- Scrape les fiches personnelles (vue ViewLernende)
|
||||
3. À la fin, écrit `ALL_DONE` dans la sortie standard et `data/sync_all_done.json` (timestamp).
|
||||
- Scrape les fiches personnelles (vue ViewLernende — y compris représentant légal + flag compensation)
|
||||
- Si l'option « Notices » est cochée : pull l'historique via `pull_notices.py`
|
||||
3. À la fin, écrit `ALL_DONE` dans la sortie standard et `data/sync_last_result.json` (timestamp).
|
||||
|
||||
### Phase 2 : Import en DB
|
||||
|
||||
`scripts/run_imports.py` est lancé par le wrapper après réception du signal `ALL_DONE` :
|
||||
|
||||
1. Parse chaque PDF d'absences → upsert des `Absence` (avec déduplication sur (apprenti, date, période))
|
||||
2. Parse les BN → insère `NotesBulletin`
|
||||
1. Parse chaque PDF d'absences → upsert des `Absence` (déduplication sur (apprenti, date, période))
|
||||
2. Parse les BN → insère `NotesBulletin` (toutes les notes du BN sont stockées, pas seulement les moyennes)
|
||||
3. Parse les notes → insère `NotesExamen`
|
||||
4. Parse les fiches → upsert `ApprentiFiche`
|
||||
4. Parse les fiches → upsert `ApprentiFiche` (adresse perso, entreprise, **représentant légal**, **compensation_desavantages**, **majeur**)
|
||||
5. Détecte les **orphelines** (absences en DB mais absentes du PDF dans la fenêtre temporelle) et les supprime (sauf si elles ont un pending, sans force).
|
||||
6. Écrit `data/sync_last_result.json` avec les compteurs détaillés.
|
||||
|
||||
### Re-parsing sans re-téléchargement
|
||||
|
||||
`scripts/run_imports.py --reparse-bn-only` permet de re-traiter tous les PDFs déjà téléchargés (utile après un changement dans le parser BN, sans relancer Playwright).
|
||||
|
||||
## Pendings : modifications locales en attente
|
||||
|
||||
Quand un utilisateur modifie une absence dans l'application (page Apprenti), une entrée est créée dans la table `EscadaPending` avec une action :
|
||||
Quand un utilisateur modifie une absence dans l'application (page Apprentis), une entrée est créée dans la table `EscadaPending` avec une action :
|
||||
|
||||
- **`E`** : marquer comme excusée sur Escada
|
||||
- **`N`** : marquer comme non excusée sur Escada
|
||||
- **`clear`** : retirer l'absence sur Escada (= remettre l'apprenti présent)
|
||||
|
||||
Ces pendings sont visibles sur la page `/escada` dans la section "Modifications en attente".
|
||||
Ces pendings sont visibles sur la page `/escada` dans la section « Modifications en attente ». Le bouton **Pousser vers Escada** les vide en envoyant chaque modification.
|
||||
|
||||
## Notices Escada (import et création)
|
||||
|
||||
Les notices sont les remarques rattachées à un apprenti dans Escada (avis de sanction, retenue, remarque libre, etc.).
|
||||
|
||||
### Import (pull)
|
||||
|
||||
`scripts/pull_notices.py` lit la vue Escada de chaque classe, scrape les notices, et upsert dans la table `apprenti_notices` (clé `(apprenti, date_event, titre)`). Affichage en lecture seule dans l'onglet « Notices » de la fiche apprenti et de la vue classe.
|
||||
|
||||
### Création (push)
|
||||
|
||||
Voir le chapitre dédié [Push vers Escada](#) — la création locale d'un avis de sanction ou de retenue crée une `Notice` (table locale), qui est ensuite poussée vers Escada par `scripts/push_notices.py`.
|
||||
|
||||
## Cas particuliers gérés
|
||||
|
||||
|
|
@ -77,6 +95,6 @@ Ces pendings sont visibles sur la page `/escada` dans la section "Modifications
|
|||
|
||||
## Diagnostic
|
||||
|
||||
- **Timeout > 15 min** : vérifier les logs `data/logs/operations.log`. Souvent un problème Selenium (captcha, session expirée).
|
||||
- **Aucune classe récupérée** : token de session Escada expiré → relancer le rafraîchissement.
|
||||
- **Timeout > 15 min** : vérifier les logs `data/logs/operations.log`. Souvent un problème Playwright (captcha, session expirée).
|
||||
- **Aucune classe récupérée** : token de session Escada expiré → relancer le rafraîchissement (re-login automatique avec les identifiants stockés en /params, code 2FA généré via `totp_secret`).
|
||||
- **Logs détaillés** : page `/logs` affiche `operations.log` en temps réel.
|
||||
|
|
|
|||
|
|
@ -1,23 +1,24 @@
|
|||
# Push vers Escada
|
||||
|
||||
Le push envoie les modifications locales (table `EscadaPending`) vers Escadaweb via Selenium.
|
||||
Le push envoie les modifications locales (absences en `EscadaPending` + notices en attente dans `Notice`) vers Escadaweb via Playwright.
|
||||
|
||||
## Page : `/escada` → "Pousser vers Escada"
|
||||
## Page : `/escada` → « Pousser vers Escada »
|
||||
|
||||
### Quand un pending est créé ?
|
||||
### Quand un pending d'absence est créé ?
|
||||
|
||||
Chaque modification d'absence dans l'application crée ou met à jour une entrée dans `EscadaPending` :
|
||||
|
||||
| Action utilisateur | Pending créé |
|
||||
|------------------------------------------|---------------------|
|
||||
|---------------------------------------------------|---------------------|
|
||||
| Marquer P3 comme excusée | `action=E` |
|
||||
| Marquer P5 comme non excusée | `action=N` |
|
||||
| Retirer une absence (présent) | `action=clear` |
|
||||
| Excuse rapide d'une journée (page Fiche) | `action=E` × n |
|
||||
| Excuse rapide d'une journée (page Apprentis) | `action=E` × n |
|
||||
| « Absent toute la journée » (selon horaire classe)| `action=N` × n (sur enregistrement) |
|
||||
|
||||
La contrainte d'unicité `(apprenti_id, date, periode)` garantit qu'une période a au plus un pending. Si on modifie deux fois la même période, le dernier pending écrase le précédent.
|
||||
|
||||
## Phases du push
|
||||
## Phases du push d'absences
|
||||
|
||||
### Phase 1 : Préparation
|
||||
|
||||
|
|
@ -25,21 +26,21 @@ La contrainte d'unicité `(apprenti_id, date, periode)` garantit qu'une période
|
|||
|
||||
1. Lit toutes les entrées de `EscadaPending`
|
||||
2. Groupe par classe pour minimiser les navigations Escada
|
||||
3. Lance Selenium
|
||||
3. Lance Playwright
|
||||
|
||||
### Phase 2 : Exécution Selenium
|
||||
### Phase 2 : Exécution Playwright
|
||||
|
||||
Pour chaque pending :
|
||||
|
||||
1. Navigue jusqu'à la page d'absences de l'apprenti dans Escadaweb
|
||||
2. Trouve la cellule (date × période)
|
||||
3. Selon l'action :
|
||||
- `E` : sélectionne "Excusée" dans le dropdown
|
||||
- `N` : sélectionne "Non excusée"
|
||||
- `E` : sélectionne « Excusée » dans le dropdown
|
||||
- `N` : sélectionne « Non excusée »
|
||||
- `clear` : remet à blanc (= apprenti présent)
|
||||
4. Clique sur **Speichern** (Enregistrer)
|
||||
5. Si OK → supprime l'entrée du `EscadaPending`
|
||||
6. Si erreur → conserve l'entrée et la liste les erreurs dans `PUSH_DONE`
|
||||
6. Si erreur → conserve l'entrée et la liste dans `PUSH_DONE`
|
||||
|
||||
### Phase 3 : Rapport
|
||||
|
||||
|
|
@ -47,6 +48,27 @@ Le script imprime une ligne `PUSH_DONE {"ok": N, "err": [...]}` à la fin. L'app
|
|||
- Nombre d'envois OK
|
||||
- Liste des erreurs (chaque erreur mentionne l'apprenti, la date et la période)
|
||||
|
||||
## Push de notices
|
||||
|
||||
Les notices créées localement (création d'avis de retenue ou de sanction depuis l'app) sont enregistrées dans la table `Notice` (statut `pending`), puis poussées par `scripts/push_notices.py`.
|
||||
|
||||
### Workflow
|
||||
|
||||
1. L'utilisateur clique sur « Générer l'avis » dans une modale d'avis sanction/retenue. Cela :
|
||||
- Génère le PDF (téléchargement)
|
||||
- Crée une `Notice` avec `source="sanction"` ou `"retenue"`, `status="pending"`, et le préfixe `(<username>)` est ajouté en début de la remarque pour traçabilité
|
||||
2. La file `Notice (status=pending)` est visible côté admin sur `/escada` ou via les tâches cron.
|
||||
3. `push_notices.py` :
|
||||
- Lit les notices `pending`
|
||||
- Pour chaque, navigue dans Escada (page de l'apprenti → onglet Notices) et crée la notice avec son titre + remarque + date
|
||||
- Marque comme `synced` si OK, `error` (+ `error_msg`) sinon
|
||||
|
||||
### task_kind cron
|
||||
|
||||
- `task_kind=push` + `sync_abs=1` → pousse les absences
|
||||
- `task_kind=push` + `sync_notices=1` → pousse les notices
|
||||
- `task_kind=push` + les deux → push absences puis notices (séquentiel)
|
||||
|
||||
## Que faire si un push échoue ?
|
||||
|
||||
1. **Vérifier les logs** (`/logs`) — l'erreur exacte est tracée.
|
||||
|
|
@ -54,13 +76,19 @@ Le script imprime une ligne `PUSH_DONE {"ok": N, "err": [...]}` à la fin. L'app
|
|||
- Session Escada expirée → relancer un Actualiser sur la page Escada (re-login automatique)
|
||||
- Apprenti avec un nom différent dans Escada → renommage à faire dans la DB ou côté Escada
|
||||
- Page de notation verrouillée par un collègue (Escada utilise des locks pessimistes)
|
||||
3. **Re-tenter** : les pendings restent en file d'attente, un nouveau push les retraitera.
|
||||
3. **Re-tenter** : les pendings (et notices `pending`/`error`) restent en file d'attente, un nouveau push les retraitera.
|
||||
|
||||
## Audit
|
||||
|
||||
Chaque push manuel logue qui l'a déclenché : `[abs] {user} : Push Escada démarré par {username}`. Côté résultat :
|
||||
Chaque push manuel logue qui l'a déclenché :
|
||||
```
|
||||
[abs] {user} : Push Escada démarré par {username}
|
||||
[notice] {user} : création (sanction) pour {apprenti}
|
||||
[notice] {user} : création (retenue) pour {apprenti} — case=devoir
|
||||
```
|
||||
Côté résultat :
|
||||
- `Push terminé — ok:N erreurs:M` dans `operations.log`
|
||||
|
||||
## Push automatique via cron
|
||||
|
||||
La tâche planifiée de type `push` ou `push_then_sync` exécute le même script. Voir la section [Tâches planifiées](#).
|
||||
Les tâches planifiées de type `push` ou `push_then_sync` exécutent les mêmes scripts. Voir la section [Tâches planifiées](#).
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Édition des absences
|
||||
|
||||
## Page : `/fiche` (Apprentis)
|
||||
## Page : « Apprentis » (`/fiche`)
|
||||
|
||||
### Sélectionner un apprenti
|
||||
|
||||
|
|
@ -11,6 +11,20 @@ Le sélecteur en haut de la page propose une recherche en direct : tape une part
|
|||
- `Entrée` sélectionne le premier résultat filtré
|
||||
- `Échap` ferme la recherche
|
||||
|
||||
### KPIs et bandeau d'actions
|
||||
|
||||
Sous le sélecteur, 3 cartes KPI :
|
||||
- **Périodes d'absence** : total
|
||||
- **Périodes à excuser** : non encore traitées
|
||||
- **Absences** : nombre de blocs ; rouge avec libellé « Avis de sanction » dès le quota EM atteint
|
||||
|
||||
Sous les KPIs, un bandeau d'actions :
|
||||
- **PDF absences / PDF bulletin / PDF notes** (téléchargement)
|
||||
- **Créer un avis de retenue** (orange) → ouvre la modale retenue pré-remplie
|
||||
- **Créer un avis de sanction** (rouge) → ouvre la modale sanction pré-remplie
|
||||
|
||||
Ces boutons sont identiques sur la page « Classes », par carte apprenti.
|
||||
|
||||
### Calendrier mensuel
|
||||
|
||||
Chaque cellule représente un jour du mois. Les couleurs indiquent l'état :
|
||||
|
|
@ -24,13 +38,24 @@ Chaque cellule représente un jour du mois. Les couleurs indiquent l'état :
|
|||
| Bleu pâle | Aujourd'hui |
|
||||
|
||||
Les nombres dans les cellules :
|
||||
- "2 ⚠️ 1" → 2 absences au total dont 1 non excusée
|
||||
- "5" → 5 absences toutes excusées
|
||||
- « 2 ⚠️ 1 » → 2 absences au total dont 1 non excusée
|
||||
- « 5 » → 5 absences toutes excusées
|
||||
|
||||
Cliquer sur un jour avec absences ouvre le panneau d'édition.
|
||||
Cliquer sur un jour avec absences (ou un autre jour) ouvre le panneau d'édition.
|
||||
|
||||
### Panneau d'édition
|
||||
|
||||
#### Badge type de jour
|
||||
|
||||
À côté du titre « Édition du {date} » s'affiche un badge coloré indiquant le type de jour pour cette classe (selon le mapping défini en /params) :
|
||||
|
||||
- 🔵 **Théorie** (bleu)
|
||||
- 🟠 **Pratique** (orange)
|
||||
- 🟣 **Matu** (violet)
|
||||
- (rien) si aucun type configuré
|
||||
|
||||
#### Périodes
|
||||
|
||||
10 lignes (P1 à P10) avec un **segmented control** à 3 boutons :
|
||||
- **Présent** (gris) — l'apprenti était là
|
||||
- **E** (orange) — Excusée
|
||||
|
|
@ -38,11 +63,42 @@ Cliquer sur un jour avec absences ouvre le panneau d'édition.
|
|||
|
||||
Un seul clic suffit. Le bouton **Enregistrer** sauve toutes les modifications de la journée d'un coup. Le panneau reste ouvert après l'enregistrement pour permettre un éventuel ajustement.
|
||||
|
||||
### Excuse rapide ("Valider toutes les absences d'une journée")
|
||||
#### Actions rapides
|
||||
|
||||
- **Absent toute la journée** (rouge) — met à `N` uniquement les périodes définies dans l'horaire de la classe pour le jour de la semaine sélectionné (cf. ci-dessous).
|
||||
- Bouton **grisé** + libellé « Absent toute la journée (Données chronoplan manquantes) » si l'horaire n'est pas configuré pour ce (classe × jour).
|
||||
- **Excuser toutes les périodes** (vert) — bascule visuellement toutes les `N` en `E`. N'enregistre pas en DB tant qu'on ne clique pas sur **Enregistrer**.
|
||||
|
||||
### Excuse rapide (« Valider toutes les absences d'une journée »)
|
||||
|
||||
Sous le calendrier, un bandeau jaune liste les jours qui ont au moins une absence non encore traitée (statut `a_traiter`). Cliquer sur un de ces boutons excuse **toutes les absences à traiter de ce jour-là** en une seule action.
|
||||
|
||||
## Page : `/classe` (Vue classe)
|
||||
### Envoyer par email
|
||||
|
||||
Un bloc « Envoyer par email » permet d'envoyer le récap (et éventuellement le bulletin / les notes en pièces jointes) à l'apprenti, au formateur ou à une adresse libre. Objet et corps utilisent le template configurable en `/params → Template email` (variables `{prenom}`, `{nom_complet}`, `{classe}`, etc.).
|
||||
|
||||
## Horaire de classe (« chronoplan »)
|
||||
|
||||
Configuré en **Paramètres → Horaires de classe** :
|
||||
|
||||
- Sélection d'une classe (dropdown alimenté par les classes en base)
|
||||
- Pour chaque jour (Lun → Ven) :
|
||||
- Sélecteur de **type de jour** : Théorie / Pratique / Matu / —
|
||||
- Grille de **10 cases** (P1 → P10), cliquables (rouge = active)
|
||||
- Bouton « Enregistrer l'horaire »
|
||||
|
||||
Stocké dans `data/settings.json` sous la clé `class_schedule` :
|
||||
```json
|
||||
"AUTOMAT 1": {
|
||||
"MON": { "type": "theorie", "periods": [1, 2, 3, 4] },
|
||||
"TUE": { "type": "pratique", "periods": [5, 6, 7, 8] },
|
||||
"WED": { "type": "matu", "periods": [1, 2] }
|
||||
}
|
||||
```
|
||||
|
||||
Le bouton « Absent toute la journée » dans la fiche apprenti lit cette config en fonction de `apprenti.classe` + jour de la semaine de la date sélectionnée.
|
||||
|
||||
## Page : « Classes » (`/classe`)
|
||||
|
||||
### Sélection de classe
|
||||
|
||||
|
|
@ -52,10 +108,10 @@ Même principe que pour les apprentis : recherche en direct avec `/`, `Entrée`,
|
|||
|
||||
Chaque apprenti de la classe a une carte avec :
|
||||
- Nom + lien vers sa fiche complète
|
||||
- Badge "Sanction" si quota atteint (≥5 absences brutes en blocs)
|
||||
- KPIs : Total / Excusées / Non excusées / Blocs d'absences
|
||||
- Boutons de téléchargement PDF (Absences, Bulletin, Notes)
|
||||
- Onglets BN / Notes d'examen pour visualiser
|
||||
- Badge « Sanction » si quota atteint (≥5 absences brutes en blocs, classes EM uniquement)
|
||||
- KPIs identiques à la fiche apprenti (3 cartes : Périodes d'absence, Périodes à excuser, Absences)
|
||||
- Bandeau d'actions identique : PDF absences/bulletin/notes + Créer avis de retenue + Créer avis de sanction
|
||||
- Onglets BN / Notes d'examen / Notices pour visualiser
|
||||
|
||||
## Audit des modifications
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Page : `/cron` (admin uniquement)
|
||||
|
||||
Permet de créer des tâches automatiques de synchronisation et/ou de push.
|
||||
Permet de créer des tâches automatiques de synchronisation et/ou de push, avec notifications Telegram.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
|
@ -14,7 +14,7 @@ docker exec eptm-dashboard-app-1 python scripts/cron_tick.py
|
|||
Lit la table CronJob → identifie les tâches à exécuter maintenant
|
||||
↓
|
||||
Pour chaque tâche due :
|
||||
- Lance push_to_escada.py et/ou sync_esacada.py + run_imports.py
|
||||
- Lance push_to_escada.py, push_notices.py, sync_esacada.py + run_imports.py
|
||||
- Met à jour last_run_at, last_status, last_message
|
||||
- Envoie une notification Telegram (selon notify_on)
|
||||
```
|
||||
|
|
@ -25,24 +25,32 @@ Le tick s'exécute toutes les minutes via la crontab du host. Le timezone du con
|
|||
|
||||
| Type | Action |
|
||||
|------------------|----------------------------------------------------------------------|
|
||||
| `push` | Pousse les pendings vers Escada uniquement |
|
||||
| `sync` | Récupère depuis Escada uniquement (selon options abs/BN/notes/fiches)|
|
||||
| `push_then_sync` | Pousse les pendings, puis récupère |
|
||||
| `push` | Pousse les pendings d'absences et/ou notices vers Escada |
|
||||
| `sync` | Récupère depuis Escada (selon options abs/BN/notes/fiches/notices) |
|
||||
| `push_then_sync` | Pousse puis récupère |
|
||||
|
||||
## Schedules
|
||||
## Planifications
|
||||
|
||||
Trois types de planning sont disponibles :
|
||||
Deux types de planning seulement (les anciens `interval` et `daily` ont été remplacés par `daily_multi` qui couvre les deux cas) :
|
||||
|
||||
- **Quotidien (daily)** : à une heure fixe chaque jour. Ex : `03:00`.
|
||||
- **Hebdo (weekly)** : à une heure fixe certains jours. Ex : `MON,WED,FRI:08:30`.
|
||||
- **Intervalle (interval)** : toutes les N minutes. Ex : `30` = toutes les 30 minutes.
|
||||
- **Hebdo (`weekly`)** : à une heure fixe certains jours.
|
||||
- `schedule_value` = `"MON,WED,FRI:08:30"`
|
||||
- Sélection des jours en UI : pastilles rouges Lun..Dim.
|
||||
- **Plusieurs heures par jour (`daily_multi`)** : à un ensemble d'heures pleines, tous les jours.
|
||||
- `schedule_value` = `"00:00,06:00,12:00,18:00"`
|
||||
- Sélection en UI : grille 24 cases (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_bn` : récupère les BN
|
||||
- `sync_notes` : récupère les notes
|
||||
- `sync_fiches` : récupère les données apprentis
|
||||
- `sync_bn` : récupère les BN + Matu
|
||||
- `sync_notes` : récupère les notes d'examen
|
||||
- `sync_fiches` : récupère les données apprentis (avec représentant légal + compensation)
|
||||
- `sync_notices` : récupère les notices Escada
|
||||
- `force_abs` : forçage (cf. doc Sync Escada)
|
||||
- `classes_json` : `"ALL"` ou liste de classes spécifiques
|
||||
|
||||
|
|
@ -58,7 +66,9 @@ Voir la section [Notifications Telegram](#) pour les détails.
|
|||
|
||||
## Activation / désactivation
|
||||
|
||||
Le toggle dans la liste des tâches active ou désactive sans supprimer. Quand on **réactive** une tâche, son `last_run_at` est remis à `None` pour qu'elle se déclenche au prochain tick (sinon elle attendrait la fin de l'intervalle complet).
|
||||
Le toggle dans la liste des tâches active ou désactive sans supprimer. Quand on **réactive** une tâche, son `last_run_at` est remis à `None` pour qu'elle se déclenche au prochain créneau.
|
||||
|
||||
**Ouverture rapide** : cliquer n'importe où sur la ligne d'une tâche ouvre directement le panneau d'édition.
|
||||
|
||||
## Logs persistants
|
||||
|
||||
|
|
@ -69,10 +79,10 @@ Chaque exécution écrit son log détaillé dans `/logs/cron/cron-{job_id}-{time
|
|||
Toute modification (création/édition/activation/suppression) est tracée :
|
||||
|
||||
```
|
||||
[09:14:22] [cron] prof.demo : création tâche 'Sync nocturne' (id=4) — push_then_sync / 03:00 / activée
|
||||
[09:30:05] [cron] prof.demo : désactivation tâche 'Push 30min' (id=2)
|
||||
[09:14:22] [cron] prof.demo : création tâche 'Sync nocturne' (id=4) — push_then_sync / 00:00,06:00,12:00,18:00 / activée
|
||||
[09:30:05] [cron] prof.demo : désactivation tâche 'Push horaire' (id=2)
|
||||
```
|
||||
|
||||
## Bouton "Tester Telegram"
|
||||
## Bouton « Tester Telegram »
|
||||
|
||||
Bas de page : envoie un message de test au `chat_id` global pour vérifier la config bot.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Authentification & rôles
|
||||
# Authentification, droits & profil
|
||||
|
||||
## Login
|
||||
|
||||
|
|
@ -12,11 +12,15 @@ Format `auth.yaml` :
|
|||
credentials:
|
||||
usernames:
|
||||
prof.demo:
|
||||
password: "$2b$12$..."
|
||||
password: "$2b$12$..." # bcrypt
|
||||
name: "Prof Demo"
|
||||
role: "admin" # ou "user"
|
||||
email: "prof.demo@eptm.ch" # destinataire pour reset mdp / enrôlement
|
||||
avatar_url: "/avatars/prof_demo.png"
|
||||
totp_secret: "ABCD1234..." # rempli automatiquement à la 1ère 2FA
|
||||
allowed_classes: ["AUTOMAT 1", "EM-AU 2"] # restriction d'accès
|
||||
escada_username: "prenom.nom@eptm.ch" # email Escada (clé login)
|
||||
escada_password: "..." # mot de passe Escada (stocké clair)
|
||||
```
|
||||
|
||||
## 2FA TOTP (obligatoire)
|
||||
|
|
@ -32,39 +36,102 @@ Aux connexions suivantes :
|
|||
1. Login + mot de passe → demande directe du code TOTP
|
||||
2. Code à 6 chiffres → connexion finalisée
|
||||
|
||||
Le code est valide ±30s (paramètre `valid_window=1` de `pyotp`) pour tolérer la dérive d'horloge.
|
||||
Le code est valide ±30 s (paramètre `valid_window=1` de `pyotp`) pour tolérer la dérive d'horloge.
|
||||
|
||||
## Email de bienvenue / réinitialisation de mot de passe
|
||||
|
||||
Sur création d'un user depuis `/users` ou sur reset, un **email** est envoyé contenant un lien `<APP_URL>/password_set?token=...` qui expire après 24 h.
|
||||
|
||||
- L'URL de base est lue depuis `settings.app_base_url` (configurée en `/params → Application`).
|
||||
- L'expéditeur et le SMTP sont configurés en `/params → Configuration email`.
|
||||
|
||||
## Session persistante
|
||||
|
||||
L'authentification est stockée dans le **`localStorage` du navigateur** (champs `username`, `name`, `role`, `photo_url`). La session survit aux rechargements de page et aux redémarrages du conteneur.
|
||||
L'authentification est stockée dans le **`localStorage` du navigateur** (champs `username`, `name`, `role`, `photo_url`, `theme`). La session survit aux rechargements de page et aux redémarrages du conteneur.
|
||||
|
||||
À chaque page protégée, `AuthState.check_auth` re-vérifie que l'utilisateur existe toujours dans `auth.yaml` ; sinon, redirection forcée vers `/login`.
|
||||
À chaque page protégée, `AuthState.check_auth` re-vérifie que l'utilisateur existe toujours dans `auth.yaml` et rafraîchit `allowed_classes`, `escada_username`, `escada_has_password`, etc. ; sinon, redirection forcée vers `/login`.
|
||||
|
||||
## Droits d'accès par classe
|
||||
|
||||
Chaque user a une clé `allowed_classes: list[str] | null` :
|
||||
|
||||
- **`null` ou absente** → restriction non encore appliquée (user nouveau)
|
||||
- **`[]` vide** → aucun accès → popup d'enrôlement obligatoire (cf. ci-dessous)
|
||||
- **liste non vide** → l'user ne voit que ces classes dans /classe, /fiche, et les filtres de stats sur /accueil
|
||||
- **role=admin** → voit tout (bypass de la restriction)
|
||||
|
||||
`src/user_access.py:get_allowed_classes(username)` est l'API canonique. Toutes les pages user-facing l'appellent pour filtrer.
|
||||
|
||||
## Self-service enrôlement (popup obligatoire)
|
||||
|
||||
Quand un user se connecte et que `allowed_classes` est `[]` (ou non défini), un **dialog forcé** s'ouvre sur toutes les pages :
|
||||
|
||||
> **Configurez votre accès**
|
||||
>
|
||||
> Pour récupérer la liste des classes auxquelles vous avez accès dans Escadaweb,
|
||||
> saisissez vos identifiants Escada + un code TOTP courant.
|
||||
|
||||
Champs :
|
||||
- Email Escada (= `escada_username`)
|
||||
- Mot de passe Escada (stocké clair dans `auth.yaml`)
|
||||
- Code 2FA courant (utilisé une fois — non stocké)
|
||||
|
||||
À la soumission, le script `scripts/fetch_user_classes.py` est lancé **en arrière-plan** (`@rx.event(background=True)` + `asyncio.create_subprocess_exec`) pour ne pas bloquer l'app pour les autres users :
|
||||
|
||||
1. Playwright headless lance un profil temporaire isolé
|
||||
2. Login Keycloak avec les creds + TOTP fourni
|
||||
3. Scrape la liste des classes accessibles dans Escada
|
||||
4. Filtre MP / MI / classes « Formation »
|
||||
5. Sauvegarde dans `auth.yaml` : `allowed_classes`, `escada_username`, `escada_password`
|
||||
6. Log live dans `operations.log` (préfixe `[fetch_classes:<username>]`) → visible dans `/logs`
|
||||
|
||||
Le popup peut être fermé avec « Plus tard » (`enroll_dismissed=True`), il réapparaîtra au prochain login.
|
||||
|
||||
## Page « Mon profil » (`/profile`)
|
||||
|
||||
Accessible via le popover sidebar :
|
||||
- Avatar
|
||||
- Liste actuelle des `allowed_classes`
|
||||
- Bouton « Relancer la synchronisation » pour rafraîchir la liste (même script que le popup)
|
||||
- Modifier les identifiants Escada (sans relancer la sync immédiatement)
|
||||
|
||||
## Réinitialisation des droits (admin)
|
||||
|
||||
Sur la page `/users`, un bouton « Réinitialiser les droits » (par user) :
|
||||
- Efface `allowed_classes`
|
||||
- Efface `escada_username` + `escada_password`
|
||||
|
||||
→ Au prochain login de cet user, le popup d'enrôlement réapparaît.
|
||||
|
||||
## Rôles
|
||||
|
||||
| Page | user | admin |
|
||||
|-------------------|------|-------|
|
||||
| `/accueil` | ✅ | ✅ |
|
||||
| `/fiche` | ✅ | ✅ |
|
||||
| `/classe` | ✅ | ✅ |
|
||||
| `/doc` | ✅ | ✅ |
|
||||
| `/escada` | ❌ | ✅ |
|
||||
| `/cron` | ❌ | ✅ |
|
||||
| `/logs` | ❌ | ✅ |
|
||||
| `/users` | ❌ | ✅ |
|
||||
| `/params` | ❌ | ✅ |
|
||||
| Page | user (sans allowed_classes) | user (avec allowed_classes) | admin |
|
||||
|-------------------|----------------------------|------------------------------|-------|
|
||||
| `/accueil` | ✅ (filtré) | ✅ (filtré) | ✅ |
|
||||
| `/classe` | ✅ (filtré) | ✅ (filtré) | ✅ |
|
||||
| `/fiche` | ✅ (filtré) | ✅ (filtré) | ✅ |
|
||||
| `/doc` | ✅ | ✅ | ✅ |
|
||||
| `/profile` | ✅ | ✅ | ✅ |
|
||||
| `/escada` | ❌ | ❌ | ✅ |
|
||||
| `/cron` | ❌ | ❌ | ✅ |
|
||||
| `/logs` | ❌ | ❌ | ✅ |
|
||||
| `/users` | ❌ | ❌ | ✅ |
|
||||
| `/params` | ❌ | ❌ | ✅ |
|
||||
| `/feedback` | ❌ | ❌ | ✅ |
|
||||
|
||||
## Gestion des utilisateurs
|
||||
## Gestion des utilisateurs (admin)
|
||||
|
||||
Page `/users` (admin) :
|
||||
Page `/users` :
|
||||
- Créer / supprimer des utilisateurs
|
||||
- Changer le rôle
|
||||
- Réinitialiser le 2FA (efface `totp_secret` → forcera une nouvelle config au prochain login)
|
||||
- Réinitialiser les droits (cf. ci-dessus)
|
||||
- Définir / changer un avatar
|
||||
- Clic sur une ligne ouvre directement le panneau d'édition
|
||||
|
||||
## Logout
|
||||
|
||||
Bouton "Déconnexion" en bas de la sidebar. Vide le `localStorage` et redirige vers `/login`.
|
||||
Bouton « Déconnexion » dans le popover profil de la sidebar. Vide le `localStorage` (y compris `enroll_dismissed`) et redirige vers `/login`.
|
||||
|
||||
## Stockage des avatars
|
||||
|
||||
|
|
|
|||
|
|
@ -2,29 +2,29 @@
|
|||
|
||||
## Synchronisation Escada
|
||||
|
||||
### "Import timeout — vérifiez les logs (> 15min)"
|
||||
### « Import timeout — vérifiez les logs (> 15 min) »
|
||||
|
||||
Le subprocess Selenium n'a pas répondu dans le temps imparti. Causes possibles :
|
||||
Le subprocess Playwright n'a pas répondu dans le temps imparti. Causes possibles :
|
||||
- Escadaweb répond très lentement (en pic de charge)
|
||||
- Captcha / re-login imposé par Escada
|
||||
- Container Docker en surcharge
|
||||
|
||||
**Que faire** :
|
||||
1. Aller dans `/logs` et chercher le dernier `[sync]` actif
|
||||
2. Si Selenium est bloqué sur un écran de login : lancer un Actualiser des classes (re-login)
|
||||
2. Si Playwright est bloqué sur un écran de login : lancer un Actualiser des classes (re-login)
|
||||
3. Si gros volume de classes : lancer la sync en plusieurs lots de 5-6 classes
|
||||
|
||||
### "Aucune classe récupérée"
|
||||
### « Aucune classe récupérée »
|
||||
|
||||
Le scraping Selenium a échoué — souvent token de session expiré.
|
||||
Le scraping Playwright a échoué — souvent token de session expiré.
|
||||
|
||||
**Que faire** : recliquer sur "Actualiser" (ça force un re-login propre).
|
||||
**Que faire** : recliquer sur « Actualiser » (force un re-login propre).
|
||||
|
||||
### "Le push échoue toujours sur le même apprenti"
|
||||
### « Le push échoue toujours sur le même apprenti »
|
||||
|
||||
Possibles causes :
|
||||
- L'apprenti existe en local mais pas (ou plus) sur Escada → le pending est obsolète, à supprimer
|
||||
- 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)
|
||||
|
||||
**Que faire** :
|
||||
|
|
@ -32,7 +32,7 @@ Possibles causes :
|
|||
2. Si l'apprenti n'existe plus : supprimer le pending manuellement en DB
|
||||
3. Sinon : retenter plus tard
|
||||
|
||||
### "L'option 'Forcer la réimportation complète' est en rouge — c'est dangereux ?"
|
||||
### « L'option 'Forcer la réimportation complète' est en rouge — c'est dangereux ? »
|
||||
|
||||
Pas dangereux mais **destructif** :
|
||||
- Tous les pendings concernés sont écrasés (les modifs locales pas encore poussées sont perdues)
|
||||
|
|
@ -42,12 +42,12 @@ Pas dangereux mais **destructif** :
|
|||
|
||||
## Tâches planifiées (cron)
|
||||
|
||||
### "J'ai créé une tâche, elle ne se déclenche pas"
|
||||
### « J'ai créé une tâche, elle ne se déclenche pas »
|
||||
|
||||
Vérifier dans l'ordre :
|
||||
|
||||
1. La tâche est-elle **activée** ? (toggle vert)
|
||||
2. L'horaire est-il dans le bon fuseau horaire ? (l'app fonctionne en `Europe/Zurich`)
|
||||
2. Les heures choisies sont-elles dans le bon fuseau horaire ? (l'app fonctionne en `Europe/Zurich`)
|
||||
3. La crontab du host appelle-t-elle bien `cron_tick.py` ?
|
||||
|
||||
```bash
|
||||
|
|
@ -55,58 +55,109 @@ Vérifier dans l'ordre :
|
|||
# Doit avoir : * * * * * docker exec eptm-dashboard-app-1 python scripts/cron_tick.py
|
||||
```
|
||||
|
||||
4. Regarder le log : `docker exec eptm-dashboard-app-1 cat /logs/cron/`
|
||||
4. Regarder le log d'exécution : `ls /logs/cron/` et `tail` sur le dernier fichier.
|
||||
|
||||
### "La tâche ne notifie pas sur Telegram"
|
||||
### « J'avais un job 'toutes les X minutes', il a disparu »
|
||||
|
||||
L'ancien mode `interval` a été remplacé par `daily_multi`. Au boot du container, les jobs `interval` sont **automatiquement migrés** en `daily_multi` (l'intervalle est déroulé sur 24 h depuis minuit). Idem pour les anciens jobs `daily` (`HH:MM`) → convertis en `daily_multi` avec une seule heure.
|
||||
|
||||
Si tu veux modifier un de ces jobs : ouvre-le, tu verras la grille 24 cases avec les heures qui étaient configurées.
|
||||
|
||||
### « La tâche ne notifie pas sur Telegram »
|
||||
|
||||
- `notify_on` doit être `always`, `success` ou `failure` (pas `never`)
|
||||
- Le `bot_token` et `chat_id` doivent être valides → tester via le bouton "Tester Telegram"
|
||||
- Le `bot_token` et `chat_id` doivent être valides → tester via le bouton « Tester Telegram »
|
||||
|
||||
## Édition d'absences
|
||||
|
||||
### « Le bouton 'Absent toute la journée' est grisé »
|
||||
|
||||
Affichage « Absent toute la journée (Données chronoplan manquantes) » → aucun horaire n'est configuré pour cette classe et ce jour de la semaine.
|
||||
|
||||
**Que faire** : admin → `/params → Horaires de classe`, sélectionner la classe, cocher les périodes du jour concerné, enregistrer.
|
||||
|
||||
### « Quel est le type de jour affiché en badge ? »
|
||||
|
||||
Théorie / Pratique / Matu — défini par classe et par jour dans `/params → Horaires de classe`. Sert d'indication contextuelle dans le panneau d'édition.
|
||||
|
||||
## Authentification
|
||||
|
||||
### "J'ai perdu mon téléphone avec mon code 2FA"
|
||||
### « Mon utilisateur n'a accès à aucune classe »
|
||||
|
||||
Un admin peut réinitialiser le 2FA via la page `/users` : "Réinitialiser 2FA". Au prochain login, l'utilisateur reverra le QR code.
|
||||
À sa première connexion, un dialog s'ouvre pour configurer l'accès. Il doit fournir :
|
||||
- son email Escada (identifiant Keycloak)
|
||||
- son mot de passe Escada
|
||||
- un code TOTP courant
|
||||
|
||||
### "Mon utilisateur est bloqué après plusieurs tentatives"
|
||||
Un script Playwright tourne en arrière-plan (visible dans `/logs` avec préfixe `[fetch_classes:<username>]`) et remplit automatiquement `allowed_classes` dans `auth.yaml`.
|
||||
|
||||
Pas de blocage automatique pour le moment. Si on veut en ajouter un : voir `state.py:handle_login`.
|
||||
Si le dialog est fermé (« Plus tard »), il réapparaîtra au prochain login.
|
||||
|
||||
### « J'ai perdu mon téléphone avec mon code 2FA »
|
||||
|
||||
Un admin peut réinitialiser le 2FA via `/users` : bouton « Réinitialiser 2FA ». Au prochain login, l'utilisateur reverra le QR code.
|
||||
|
||||
### « Comment révoquer l'accès d'un user »
|
||||
|
||||
Admin → `/users` → bouton « Réinitialiser les droits » : efface `allowed_classes` + identifiants Escada. À sa prochaine connexion, le popup d'enrôlement réapparaîtra (s'il ne le reconfigure pas, il n'aura accès à rien).
|
||||
|
||||
## Données
|
||||
|
||||
### "Les BN affichent des trous (cellules vides)"
|
||||
### « Les BN affichent des trous (cellules vides) »
|
||||
|
||||
C'est normal : un apprenti peut avoir commencé sa formation au S2 ou S3 → les premiers semestres restent vides. L'extraction depuis le PDF Escada respecte ces trous.
|
||||
C'est normal : un apprenti peut avoir commencé sa formation au S2 ou S3 → les premiers semestres restent vides. L'extraction depuis le PDF Escada respecte ces trous. Toutes les notes (pas juste les moyennes) sont stockées et affichées.
|
||||
|
||||
### "Les notes Matu n'apparaissent pas"
|
||||
### « Les notes Matu n'apparaissent pas »
|
||||
|
||||
Pré-requis : l'apprenti est dans une classe MP/MI correspondante. Le matching se fait via la classe MP1, MP2, etc. La sync Matu cherche le PDF correspondant **si la case "BN" est cochée**.
|
||||
Pré-requis : l'apprenti est dans une classe MP / MI correspondante. Le matching se fait via la classe MP1, MP2, etc. La sync Matu cherche le PDF correspondant **si la case 'BN' est cochée**.
|
||||
|
||||
### « L'adresse sur les avis sanction/retenue est fausse »
|
||||
|
||||
Depuis mai 2026, l'app n'utilise **plus l'adresse de l'entreprise**. Elle prend :
|
||||
- l'adresse du **représentant légal** si l'apprenti est mineur (`majeur=False`)
|
||||
- l'adresse perso de **l'apprenti** sinon
|
||||
|
||||
Vérifier que les champs `ApprentiFiche.resp_legal_*` et `ApprentiFiche.adresse/code_postal/localite` sont bien remplis (sync option « Données apprentis »).
|
||||
|
||||
## Performance
|
||||
|
||||
### "L'app rame quand je change de classe avec beaucoup d'apprentis"
|
||||
### « L'app rame quand je change de classe avec beaucoup d'apprentis »
|
||||
|
||||
Le `_reload` reconstruit les tableaux HTML BN/Notes pour chaque apprenti — peut prendre quelques secondes pour 25+ apprentis. Un skeleton s'affiche pendant le chargement pour donner du feedback visuel.
|
||||
Le `_reload` reconstruit les tableaux HTML BN / Notes pour chaque apprenti — peut prendre quelques secondes pour 25+ apprentis. Un skeleton s'affiche pendant le chargement pour donner du feedback visuel.
|
||||
|
||||
Si vraiment lent : envisager une mise en cache des HTML rendus dans la DB (à voir avec un dev).
|
||||
### « L'app est bloquée pour les autres users quand je lance la synchro de mes classes »
|
||||
|
||||
C'était un bug : le subprocess Playwright bloquait l'event loop. Corrigé en passant à `@rx.event(background=True)` + `asyncio.create_subprocess_exec`. Si ça revient, vérifier que `fetch_my_classes` est bien décoré `background=True` dans `profile.py`.
|
||||
|
||||
## Conteneur Docker
|
||||
|
||||
### "Le conteneur consomme 100% CPU à l'idle"
|
||||
### « Le conteneur consomme 100 % CPU à l'idle »
|
||||
|
||||
Bug historique lié au hot-reload qui détectait les fichiers WAL/SHM de SQLite comme des modifications source. Corrigé via `REFLEX_HOT_RELOAD_EXCLUDE_PATHS=/app/data` dans le docker-compose.
|
||||
|
||||
Si ça revient : vérifier que cette variable est bien présente dans `docker-compose.dev.yml`.
|
||||
### « La version dans le sidebar ne change pas après un nouveau tag git »
|
||||
|
||||
### "Comment redémarrer proprement"
|
||||
`data/VERSION` doit être mis à jour manuellement (le `.git` du container dev n'est pas synchronisé avec celui du hôte). Édite le fichier, puis :
|
||||
|
||||
```bash
|
||||
docker restart eptm-dashboard-app-1
|
||||
```
|
||||
|
||||
### « Comment redémarrer proprement »
|
||||
|
||||
```bash
|
||||
cd /opt/eptm-dashboard
|
||||
docker compose -f docker-compose.dev.yml restart app
|
||||
```
|
||||
|
||||
### "Comment voir les logs du serveur Reflex"
|
||||
### « Comment voir les logs du serveur Reflex »
|
||||
|
||||
```bash
|
||||
docker logs -f eptm-dashboard-app-1
|
||||
```
|
||||
|
||||
## SSL / Accès depuis le web
|
||||
|
||||
### « Mon navigateur affiche un avertissement sécurité sur dev.dashboard.eptm-automation.ch »
|
||||
|
||||
Vérifier le certificat affiché : si l'émetteur est « Fortinet CA » (et non Let's Encrypt), c'est ton firewall qui fait du SSL inspection / IPS — pas un problème du serveur. À demander à l'IT pour whitelister le domaine.
|
||||
|
|
|
|||
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!",
|
||||
"totp_secret": "KQZVCQLXGNAU22KRKNCHCYSUIRAXAUSR",
|
||||
"app_base_url": "https://dev.dashboard.eptm-automation.ch",
|
||||
"feedback_admin_email": "julien.balet@edu.vs.ch"
|
||||
"feedback_admin_email": "julien.balet@edu.vs.ch",
|
||||
"email_subject": "Document EPTM — {nom_complet} ({classe})",
|
||||
"email_body": "Bonjour {nom_complet},\n\nVeuillez trouver ci-joint vos documents.\n\nCordialement,\nL'équipe EPTM",
|
||||
"class_schedule": {
|
||||
"AUTOMAT 1": {
|
||||
"MON": {
|
||||
"type": "theorie",
|
||||
"periods": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10
|
||||
]
|
||||
},
|
||||
"TUE": {
|
||||
"type": "",
|
||||
"periods": []
|
||||
},
|
||||
"WED": {
|
||||
"type": "",
|
||||
"periods": []
|
||||
},
|
||||
"THU": {
|
||||
"type": "theorie",
|
||||
"periods": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
8,
|
||||
9,
|
||||
10
|
||||
]
|
||||
},
|
||||
"FRI": {
|
||||
"type": "",
|
||||
"periods": []
|
||||
}
|
||||
},
|
||||
"AUTOMAT 2": {
|
||||
"MON": {
|
||||
"type": "",
|
||||
"periods": []
|
||||
},
|
||||
"TUE": {
|
||||
"type": "",
|
||||
"periods": []
|
||||
},
|
||||
"WED": {
|
||||
"type": "",
|
||||
"periods": []
|
||||
},
|
||||
"THU": {
|
||||
"type": "theorie",
|
||||
"periods": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10
|
||||
]
|
||||
},
|
||||
"FRI": {
|
||||
"type": "theorie",
|
||||
"periods": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10
|
||||
]
|
||||
}
|
||||
},
|
||||
"AUTOMAT 3": {
|
||||
"MON": {
|
||||
"type": "",
|
||||
"periods": []
|
||||
},
|
||||
"TUE": {
|
||||
"type": "theorie",
|
||||
"periods": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10
|
||||
]
|
||||
},
|
||||
"WED": {
|
||||
"type": "",
|
||||
"periods": []
|
||||
},
|
||||
"THU": {
|
||||
"type": "",
|
||||
"periods": []
|
||||
},
|
||||
"FRI": {
|
||||
"type": "",
|
||||
"periods": []
|
||||
}
|
||||
},
|
||||
"AUTOMAT 4": {
|
||||
"MON": {
|
||||
"type": "theorie",
|
||||
"periods": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9
|
||||
]
|
||||
},
|
||||
"TUE": {
|
||||
"type": "",
|
||||
"periods": []
|
||||
},
|
||||
"WED": {
|
||||
"type": "",
|
||||
"periods": []
|
||||
},
|
||||
"THU": {
|
||||
"type": "",
|
||||
"periods": []
|
||||
},
|
||||
"FRI": {
|
||||
"type": "",
|
||||
"periods": []
|
||||
}
|
||||
},
|
||||
"EM-AU 1": {
|
||||
"MON": {
|
||||
"type": "theorie",
|
||||
"periods": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
8,
|
||||
9
|
||||
]
|
||||
},
|
||||
"TUE": {
|
||||
"type": "theorie",
|
||||
"periods": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10
|
||||
]
|
||||
},
|
||||
"WED": {
|
||||
"type": "pratique",
|
||||
"periods": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9
|
||||
]
|
||||
},
|
||||
"THU": {
|
||||
"type": "matu",
|
||||
"periods": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10
|
||||
]
|
||||
},
|
||||
"FRI": {
|
||||
"type": "pratique",
|
||||
"periods": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9
|
||||
]
|
||||
}
|
||||
},
|
||||
"EM-AU 2": {
|
||||
"MON": {
|
||||
"type": "pratique",
|
||||
"periods": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9
|
||||
]
|
||||
},
|
||||
"TUE": {
|
||||
"type": "pratique",
|
||||
"periods": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9
|
||||
]
|
||||
},
|
||||
"WED": {
|
||||
"type": "matu",
|
||||
"periods": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10
|
||||
]
|
||||
},
|
||||
"THU": {
|
||||
"type": "theorie",
|
||||
"periods": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9
|
||||
]
|
||||
},
|
||||
"FRI": {
|
||||
"type": "pratique",
|
||||
"periods": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9
|
||||
]
|
||||
}
|
||||
},
|
||||
"EM-AU 3": {
|
||||
"MON": {
|
||||
"type": "matu",
|
||||
"periods": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10
|
||||
]
|
||||
},
|
||||
"TUE": {
|
||||
"type": "theorie",
|
||||
"periods": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9
|
||||
]
|
||||
},
|
||||
"WED": {
|
||||
"type": "pratique",
|
||||
"periods": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
8,
|
||||
9,
|
||||
10
|
||||
]
|
||||
},
|
||||
"THU": {
|
||||
"type": "pratique",
|
||||
"periods": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10
|
||||
]
|
||||
},
|
||||
"FRI": {
|
||||
"type": "pratique",
|
||||
"periods": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10
|
||||
]
|
||||
}
|
||||
},
|
||||
"EM-AU 4": {
|
||||
"MON": {
|
||||
"type": "theorie",
|
||||
"periods": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9
|
||||
]
|
||||
},
|
||||
"TUE": {
|
||||
"type": "matu",
|
||||
"periods": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10
|
||||
]
|
||||
},
|
||||
"WED": {
|
||||
"type": "",
|
||||
"periods": []
|
||||
},
|
||||
"THU": {
|
||||
"type": "",
|
||||
"periods": []
|
||||
},
|
||||
"FRI": {
|
||||
"type": "",
|
||||
"periods": []
|
||||
}
|
||||
},
|
||||
"MONTAUT 1": {
|
||||
"MON": {
|
||||
"type": "",
|
||||
"periods": []
|
||||
},
|
||||
"TUE": {
|
||||
"type": "",
|
||||
"periods": []
|
||||
},
|
||||
"WED": {
|
||||
"type": "",
|
||||
"periods": []
|
||||
},
|
||||
"THU": {
|
||||
"type": "",
|
||||
"periods": []
|
||||
},
|
||||
"FRI": {
|
||||
"type": "theorie",
|
||||
"periods": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10
|
||||
]
|
||||
}
|
||||
},
|
||||
"MONTAUT 2": {
|
||||
"MON": {
|
||||
"type": "",
|
||||
"periods": []
|
||||
},
|
||||
"TUE": {
|
||||
"type": "",
|
||||
"periods": []
|
||||
},
|
||||
"WED": {
|
||||
"type": "",
|
||||
"periods": []
|
||||
},
|
||||
"THU": {
|
||||
"type": "",
|
||||
"periods": []
|
||||
},
|
||||
"FRI": {
|
||||
"type": "theorie",
|
||||
"periods": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10
|
||||
]
|
||||
}
|
||||
},
|
||||
"MONTAUT 3": {
|
||||
"MON": {
|
||||
"type": "",
|
||||
"periods": []
|
||||
},
|
||||
"TUE": {
|
||||
"type": "",
|
||||
"periods": []
|
||||
},
|
||||
"WED": {
|
||||
"type": "",
|
||||
"periods": []
|
||||
},
|
||||
"THU": {
|
||||
"type": "theorie",
|
||||
"periods": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10
|
||||
]
|
||||
},
|
||||
"FRI": {
|
||||
"type": "",
|
||||
"periods": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +34,9 @@ app = rx.App(
|
|||
# Force le rendu light du browser (form controls, scrollbars, etc.)
|
||||
# même quand l'OS est en dark mode. Le thème "sombre" override via CSS.
|
||||
rx.el.meta(name="color-scheme", content="light"),
|
||||
# Empêche la traduction automatique du navigateur (Chrome/Edge traduisaient
|
||||
# certains libellés français selon la locale OS de l'utilisateur).
|
||||
rx.el.meta(name="google", content="notranslate"),
|
||||
# Couleur de la barre d'adresse (Android) + barre de statut (iOS standalone)
|
||||
rx.el.meta(name="theme-color", content="#dc000e"),
|
||||
rx.el.meta(name="apple-mobile-web-app-capable", content="yes"),
|
||||
|
|
@ -49,7 +52,8 @@ app = rx.App(
|
|||
),
|
||||
# Applique le thème stocké en localStorage avant le premier render —
|
||||
# évite un flash au défaut EPTM puis bascule. Force aussi colorScheme
|
||||
# pour empêcher le browser de bascule dark sur OS dark.
|
||||
# pour empêcher le browser de bascule dark sur OS dark. Force aussi
|
||||
# lang=fr et translate=no pour neutraliser la traduction automatique.
|
||||
rx.el.script(
|
||||
"""
|
||||
(function() {
|
||||
|
|
@ -61,6 +65,8 @@ app = rx.App(
|
|||
}
|
||||
document.documentElement.style.colorScheme =
|
||||
(t === 'sombre') ? 'dark' : 'light';
|
||||
document.documentElement.setAttribute('lang', 'fr');
|
||||
document.documentElement.setAttribute('translate', 'no');
|
||||
} catch(e) {}
|
||||
})();
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -255,18 +255,20 @@ def _notes_badge(badge: rx.Var) -> rx.Component:
|
|||
|
||||
|
||||
def _notes_insuf_tile(item: rx.Var) -> rx.Component:
|
||||
"""Tuile compacte 1 ligne : nom + badges moyennes. Click → fiche apprenti."""
|
||||
return rx.flex(
|
||||
"""Tuile compacte 2 lignes : nom puis badges moyennes. Click → fiche apprenti."""
|
||||
return rx.vstack(
|
||||
# Ligne 1 : nom
|
||||
rx.text(
|
||||
item["nom"], " ", item["prenom"],
|
||||
size="2", color="#1a237e",
|
||||
white_space="nowrap", overflow="hidden",
|
||||
text_overflow="ellipsis",
|
||||
flex="1", min_width="0",
|
||||
width="100%",
|
||||
),
|
||||
# Ligne 2 : badges moyennes
|
||||
rx.flex(
|
||||
rx.foreach(item["badges"].to(list[dict]), _notes_badge),
|
||||
gap="0.3rem", flex_wrap="wrap", flex_shrink="0",
|
||||
gap="0.3rem", flex_wrap="wrap",
|
||||
),
|
||||
on_click=AccueilState.open_fiche(item["id"]),
|
||||
cursor="pointer",
|
||||
|
|
@ -274,11 +276,11 @@ def _notes_insuf_tile(item: rx.Var) -> rx.Component:
|
|||
background_color="var(--surface)",
|
||||
border="1px solid var(--border)",
|
||||
border_radius="6px",
|
||||
flex="1 1 280px",
|
||||
min_width="280px",
|
||||
max_width="400px",
|
||||
align="center",
|
||||
gap="0.5rem",
|
||||
flex="1 1 220px",
|
||||
min_width="220px",
|
||||
max_width="280px",
|
||||
spacing="2",
|
||||
align="start",
|
||||
class_name="hover-lift",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ from ..state import AuthState
|
|||
from ..sidebar import layout
|
||||
from ..components import empty_state, skeleton_apprenti_card
|
||||
from .fiche import FicheState, _notice_row
|
||||
from .retenue import RetenueState, retenue_modal
|
||||
from .sanction import SanctionState, sanction_modal
|
||||
from src.db import (
|
||||
get_session, Apprenti, Absence, ApprentiNotice,
|
||||
NotesBulletin, NotesMatu, NotesExamen, ImportBN, ImportMatu,
|
||||
|
|
@ -622,6 +624,7 @@ class ClasseState(AuthState):
|
|||
"id": apprenti.id,
|
||||
"nom": apprenti.nom,
|
||||
"prenom": apprenti.prenom,
|
||||
"label": f"{apprenti.prenom} {apprenti.nom}",
|
||||
"total": total,
|
||||
"excusees": excusees,
|
||||
"non_exc": non_exc,
|
||||
|
|
@ -727,6 +730,21 @@ def _kpi_mini(label: str, value, color: str = "#37474f") -> rx.Component:
|
|||
)
|
||||
|
||||
|
||||
def _kpi_card(label: str, value, color: str = "#37474f") -> rx.Component:
|
||||
"""Carte KPI identique à fiche.py (taille 7, fond surface)."""
|
||||
return rx.box(
|
||||
rx.text(label, size="1", color="#666"),
|
||||
rx.text(value, size="7", font_weight="700", color=color, class_name="tabular"),
|
||||
padding="1rem",
|
||||
background_color="var(--surface)",
|
||||
border_radius="8px",
|
||||
border="1px solid var(--border)",
|
||||
flex="1",
|
||||
min_width="120px",
|
||||
class_name="hover-lift",
|
||||
)
|
||||
|
||||
|
||||
def _apprenti_card(item) -> rx.Component:
|
||||
return rx.box(
|
||||
# ── En-tête : nom + badge quota ───────────────────────────────────────
|
||||
|
|
@ -756,55 +774,102 @@ def _apprenti_card(item) -> rx.Component:
|
|||
margin_bottom="0.75rem",
|
||||
),
|
||||
|
||||
# ── KPIs absences ─────────────────────────────────────────────────────
|
||||
# ── KPI cards (identiques à la fiche apprenti) ────────────────────────
|
||||
rx.flex(
|
||||
_kpi_mini("Total", item["total"]),
|
||||
_kpi_mini("Excusees", item["excusees"], "#2e7d32"),
|
||||
_kpi_mini("Non excusees", item["non_exc"], "var(--brand-primary-dark)"),
|
||||
_kpi_card("Périodes d'absence", item["total"]),
|
||||
_kpi_card("Périodes à excuser", item["non_exc"], "var(--brand-primary-dark)"),
|
||||
rx.box(
|
||||
rx.text("Absences", size="1", color="#666"),
|
||||
rx.text(
|
||||
item["blocs"],
|
||||
size="7", font_weight="700",
|
||||
color=rx.cond(item["quota_atteint"], "#c62828", "#37474f"),
|
||||
class_name="tabular",
|
||||
),
|
||||
rx.cond(
|
||||
item["quota_atteint"],
|
||||
_kpi_mini("Absences", item["blocs"], "var(--brand-primary-dark)"),
|
||||
_kpi_mini("Absences", item["blocs"]),
|
||||
rx.text(
|
||||
"Avis de sanction",
|
||||
size="1", weight="bold", color="#c62828",
|
||||
),
|
||||
gap="0.5rem",
|
||||
),
|
||||
padding="1rem",
|
||||
background_color=rx.cond(item["quota_atteint"], "#fff0f0", "var(--surface)"),
|
||||
border_radius="8px",
|
||||
border=rx.cond(
|
||||
item["quota_atteint"],
|
||||
"1px solid #ffcdd2",
|
||||
"1px solid var(--border)",
|
||||
),
|
||||
flex="1",
|
||||
min_width="120px",
|
||||
),
|
||||
gap="1rem",
|
||||
flex_wrap="wrap",
|
||||
width="100%",
|
||||
margin_bottom="0.75rem",
|
||||
),
|
||||
|
||||
# ── Boutons téléchargement PDF ────────────────────────────────────────
|
||||
# ── Actions (PDF exports + créations d'avis) ──────────────────────────
|
||||
rx.box(
|
||||
rx.flex(
|
||||
rx.button(
|
||||
rx.icon("download", size=13),
|
||||
"PDF absences",
|
||||
on_click=ClasseState.download_abs_pdf(item["id"]),
|
||||
variant="outline",
|
||||
color_scheme="gray",
|
||||
size="1",
|
||||
variant="outline", color_scheme="gray", size="2",
|
||||
),
|
||||
rx.cond(
|
||||
item["has_pdf_bn"],
|
||||
rx.button(
|
||||
rx.icon("file-text", size=13),
|
||||
rx.icon("download", size=13),
|
||||
"PDF bulletin",
|
||||
on_click=ClasseState.download_bn_pdf(item["id"]),
|
||||
variant="outline",
|
||||
color_scheme="blue",
|
||||
size="1",
|
||||
variant="outline", color_scheme="blue", size="2",
|
||||
),
|
||||
),
|
||||
rx.cond(
|
||||
item["has_pdf_notes"],
|
||||
rx.button(
|
||||
rx.icon("file-text", size=13),
|
||||
rx.icon("download", size=13),
|
||||
"PDF notes",
|
||||
on_click=ClasseState.download_notes_pdf(item["id"]),
|
||||
variant="outline",
|
||||
color_scheme="violet",
|
||||
size="1",
|
||||
variant="outline", color_scheme="violet", size="2",
|
||||
),
|
||||
),
|
||||
flex_wrap="wrap",
|
||||
# Séparateur visuel
|
||||
rx.box(
|
||||
width="1px",
|
||||
background_color="var(--gray-6)",
|
||||
margin_x="0.25rem",
|
||||
align_self="stretch",
|
||||
),
|
||||
rx.button(
|
||||
rx.icon("file-warning", size=14),
|
||||
"Créer un avis de retenue",
|
||||
on_click=RetenueState.preload_apprenti(
|
||||
item["id"], item["label"],
|
||||
),
|
||||
color_scheme="orange", variant="soft", size="2",
|
||||
),
|
||||
rx.button(
|
||||
rx.icon("triangle-alert", size=14),
|
||||
"Créer un avis de sanction",
|
||||
on_click=SanctionState.preload_apprenti(
|
||||
item["id"], item["label"],
|
||||
),
|
||||
color_scheme="red", variant="soft", size="2",
|
||||
),
|
||||
gap="0.5rem",
|
||||
flex_wrap="wrap",
|
||||
align="center",
|
||||
width="100%",
|
||||
),
|
||||
padding="0.75rem 1rem",
|
||||
background_color="var(--surface)",
|
||||
border_radius="8px",
|
||||
border="1px solid var(--border)",
|
||||
width="100%",
|
||||
margin_bottom="0.75rem",
|
||||
),
|
||||
|
||||
|
|
@ -901,7 +966,10 @@ def _apprenti_card(item) -> rx.Component:
|
|||
def classe_page() -> rx.Component:
|
||||
return layout(
|
||||
rx.vstack(
|
||||
rx.heading("Vue classe", size="7"),
|
||||
# Modals (rendus une fois, contrôlés par leur state respectif)
|
||||
retenue_modal(),
|
||||
sanction_modal(),
|
||||
rx.heading("Classes", size="7"),
|
||||
|
||||
rx.cond(
|
||||
ClasseState.has_classes,
|
||||
|
|
|
|||
|
|
@ -54,10 +54,10 @@ class CronState(AuthState):
|
|||
|
||||
f_name: str = ""
|
||||
f_enabled: bool = True
|
||||
f_schedule_kind: str = "daily" # "daily" | "weekly" | "interval"
|
||||
f_schedule_kind: str = "daily_multi" # "weekly" | "daily_multi"
|
||||
f_time_hh: str = "03"
|
||||
f_time_mm: str = "00"
|
||||
f_interval_min: str = "60"
|
||||
f_hours: list[str] = [] # ["00:00","06:00",...] pour daily_multi
|
||||
f_days: list[str] = [] # ["MON","WED",...]
|
||||
f_task_kind: str = "push_then_sync"
|
||||
f_sync_abs: bool = True
|
||||
|
|
@ -104,8 +104,6 @@ class CronState(AuthState):
|
|||
|
||||
@staticmethod
|
||||
def _human_schedule(kind: str, value: str) -> str:
|
||||
if kind == "daily":
|
||||
return f"Tous les jours à {value}"
|
||||
if kind == "weekly":
|
||||
try:
|
||||
days_part, time_part = value.split(":", 1)
|
||||
|
|
@ -114,14 +112,13 @@ class CronState(AuthState):
|
|||
return f"{labels} à {time_part}"
|
||||
except ValueError:
|
||||
return value
|
||||
if kind == "interval":
|
||||
try:
|
||||
m = int(value)
|
||||
if m % 60 == 0:
|
||||
return f"Toutes les {m // 60} h"
|
||||
return f"Toutes les {m} min"
|
||||
except (TypeError, ValueError):
|
||||
return value
|
||||
if kind == "daily_multi":
|
||||
hours = [h.strip() for h in (value or "").split(",") if h.strip()]
|
||||
if not hours:
|
||||
return "Aucune heure définie"
|
||||
if len(hours) <= 6:
|
||||
return "Tous les jours à " + ", ".join(hours)
|
||||
return f"Tous les jours — {len(hours)} créneaux ({hours[0]} … {hours[-1]})"
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -130,27 +127,25 @@ class CronState(AuthState):
|
|||
if not job.enabled:
|
||||
return "—"
|
||||
now = datetime.now()
|
||||
if job.schedule_kind == "interval":
|
||||
if job.schedule_kind == "daily_multi":
|
||||
hours = [h.strip() for h in (job.schedule_value or "").split(",") if h.strip()]
|
||||
best: datetime | None = None
|
||||
for hhmm in hours:
|
||||
try:
|
||||
m = int(job.schedule_value)
|
||||
except (TypeError, ValueError):
|
||||
return "—"
|
||||
if job.last_run_at is None:
|
||||
return "Au prochain tick"
|
||||
nxt = job.last_run_at + timedelta(minutes=m)
|
||||
return nxt.strftime("%d.%m %H:%M")
|
||||
if job.schedule_kind == "daily":
|
||||
try:
|
||||
hh, mm = job.schedule_value.split(":")
|
||||
target = now.replace(hour=int(hh), minute=int(mm), second=0, microsecond=0)
|
||||
hh, mm = hhmm.split(":")
|
||||
target = now.replace(hour=int(hh), minute=int(mm),
|
||||
second=0, microsecond=0)
|
||||
except (ValueError, AttributeError):
|
||||
continue
|
||||
# Si déjà exécuté à ce créneau aujourd'hui, on le pousse au lendemain.
|
||||
if (job.last_run_at and job.last_run_at.date() == now.date()
|
||||
and job.last_run_at >= target):
|
||||
target += timedelta(days=1)
|
||||
elif target < now:
|
||||
target += timedelta(days=1)
|
||||
return target.strftime("%d.%m %H:%M")
|
||||
except (ValueError, AttributeError):
|
||||
return "—"
|
||||
if best is None or target < best:
|
||||
best = target
|
||||
return best.strftime("%d.%m %H:%M") if best else "—"
|
||||
if job.schedule_kind == "weekly":
|
||||
return "Selon planning"
|
||||
return "—"
|
||||
|
|
@ -190,10 +185,10 @@ class CronState(AuthState):
|
|||
self.edit_open = True
|
||||
self.f_name = ""
|
||||
self.f_enabled = True
|
||||
self.f_schedule_kind = "daily"
|
||||
self.f_schedule_kind = "daily_multi"
|
||||
self.f_time_hh = "03"
|
||||
self.f_time_mm = "00"
|
||||
self.f_interval_min = "60"
|
||||
self.f_hours = ["03:00"]
|
||||
self.f_days = ["MON", "TUE", "WED", "THU", "FRI"]
|
||||
self.f_task_kind = "push_then_sync"
|
||||
self.f_sync_abs = True
|
||||
|
|
@ -221,13 +216,7 @@ class CronState(AuthState):
|
|||
self.f_name = job.name
|
||||
self.f_enabled = job.enabled
|
||||
self.f_schedule_kind = job.schedule_kind
|
||||
if job.schedule_kind == "daily":
|
||||
hh, _, mm = (job.schedule_value or "03:00").partition(":")
|
||||
self.f_time_hh = hh.zfill(2)
|
||||
self.f_time_mm = mm.zfill(2)
|
||||
self.f_interval_min = "60"
|
||||
self.f_days = ["MON", "TUE", "WED", "THU", "FRI"]
|
||||
elif job.schedule_kind == "weekly":
|
||||
if job.schedule_kind == "weekly":
|
||||
try:
|
||||
days_part, time_part = job.schedule_value.split(":", 1)
|
||||
hh, _, mm = time_part.partition(":")
|
||||
|
|
@ -238,9 +227,21 @@ class CronState(AuthState):
|
|||
self.f_time_hh = "03"
|
||||
self.f_time_mm = "00"
|
||||
self.f_days = ["MON", "TUE", "WED", "THU", "FRI"]
|
||||
self.f_interval_min = "60"
|
||||
else: # interval
|
||||
self.f_interval_min = job.schedule_value or "60"
|
||||
self.f_hours = []
|
||||
else: # daily_multi
|
||||
hours_norm: list[str] = []
|
||||
for h in (job.schedule_value or "").split(","):
|
||||
h = h.strip()
|
||||
if not h:
|
||||
continue
|
||||
parts = h.split(":")
|
||||
if len(parts) >= 2 and parts[0].isdigit():
|
||||
# On garde uniquement les heures pleines (00:00, 01:00, ...).
|
||||
hh_i = int(parts[0])
|
||||
if 0 <= hh_i < 24:
|
||||
hours_norm.append(f"{hh_i:02d}:00")
|
||||
# Dédoublonnage + tri
|
||||
self.f_hours = sorted(set(hours_norm))
|
||||
self.f_time_hh = "03"
|
||||
self.f_time_mm = "00"
|
||||
self.f_days = ["MON", "TUE", "WED", "THU", "FRI"]
|
||||
|
|
@ -288,9 +289,11 @@ class CronState(AuthState):
|
|||
def set_f_time_mm(self, v: str):
|
||||
v = "".join(ch for ch in v if ch.isdigit())[:2]
|
||||
self.f_time_mm = v
|
||||
def set_f_interval_min(self, v: str):
|
||||
v = "".join(ch for ch in v if ch.isdigit())[:5]
|
||||
self.f_interval_min = v
|
||||
def toggle_f_hour(self, h: str):
|
||||
if h in self.f_hours:
|
||||
self.f_hours = [x for x in self.f_hours if x != h]
|
||||
else:
|
||||
self.f_hours = sorted(self.f_hours + [h])
|
||||
def toggle_f_day(self, day: str):
|
||||
if day in self.f_days:
|
||||
self.f_days = [d for d in self.f_days if d != day]
|
||||
|
|
@ -322,16 +325,7 @@ class CronState(AuthState):
|
|||
return
|
||||
|
||||
# Construire schedule_value selon kind
|
||||
if self.f_schedule_kind == "daily":
|
||||
try:
|
||||
hh = int(self.f_time_hh or "0"); mm = int(self.f_time_mm or "0")
|
||||
if not (0 <= hh < 24 and 0 <= mm < 60):
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
self.save_error = "Heure invalide."
|
||||
return
|
||||
schedule_value = f"{hh:02d}:{mm:02d}"
|
||||
elif self.f_schedule_kind == "weekly":
|
||||
if self.f_schedule_kind == "weekly":
|
||||
if not self.f_days:
|
||||
self.save_error = "Sélectionne au moins un jour de la semaine."
|
||||
return
|
||||
|
|
@ -344,15 +338,11 @@ class CronState(AuthState):
|
|||
return
|
||||
ordered = [d for d in _DAY_NAMES if d in self.f_days]
|
||||
schedule_value = f"{','.join(ordered)}:{hh:02d}:{mm:02d}"
|
||||
else: # interval
|
||||
try:
|
||||
m = int(self.f_interval_min or "0")
|
||||
if m < 1:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
self.save_error = "Intervalle invalide (minutes > 0)."
|
||||
else: # daily_multi
|
||||
if not self.f_hours:
|
||||
self.save_error = "Sélectionne au moins une heure d'exécution."
|
||||
return
|
||||
schedule_value = str(m)
|
||||
schedule_value = ",".join(sorted(set(self.f_hours)))
|
||||
|
||||
if self.f_classes_all:
|
||||
classes_json = "ALL"
|
||||
|
|
@ -531,12 +521,12 @@ def _job_row(job: rx.Var) -> rx.Component:
|
|||
rx.hstack(
|
||||
rx.button(
|
||||
rx.icon(rx.cond(job["enabled"], "pause", "play"), size=14),
|
||||
on_click=CronState.toggle_enabled(job["id"]),
|
||||
on_click=CronState.toggle_enabled(job["id"]).stop_propagation,
|
||||
variant="ghost", size="1", color_scheme="gray",
|
||||
),
|
||||
rx.button(
|
||||
rx.icon("pencil", size=14),
|
||||
on_click=CronState.open_edit(job["id"]),
|
||||
on_click=CronState.open_edit(job["id"]).stop_propagation,
|
||||
variant="ghost", size="1", color_scheme="gray",
|
||||
),
|
||||
rx.alert_dialog.root(
|
||||
|
|
@ -578,6 +568,53 @@ def _job_row(job: rx.Var) -> rx.Component:
|
|||
border="1px solid var(--gray-5)",
|
||||
border_radius="6px",
|
||||
width="100%",
|
||||
# Click sur la row entière ouvre le panneau d'édition.
|
||||
on_click=CronState.open_edit(job["id"]),
|
||||
cursor="pointer",
|
||||
_hover={"background_color": "var(--surface-hover)"},
|
||||
)
|
||||
|
||||
|
||||
def _hours_grid() -> rx.Component:
|
||||
"""Grille 24 cases (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,23 +622,11 @@ def _form_schedule_picker() -> rx.Component:
|
|||
return rx.vstack(
|
||||
rx.text("Planification", size="2", font_weight="600"),
|
||||
rx.radio(
|
||||
["daily", "weekly", "interval"],
|
||||
["daily_multi", "weekly"],
|
||||
value=CronState.f_schedule_kind,
|
||||
on_change=CronState.set_f_schedule_kind,
|
||||
direction="row",
|
||||
),
|
||||
rx.cond(
|
||||
CronState.f_schedule_kind == "interval",
|
||||
rx.hstack(
|
||||
rx.text("Toutes les", size="2"),
|
||||
rx.input(
|
||||
value=CronState.f_interval_min,
|
||||
on_change=CronState.set_f_interval_min,
|
||||
width="80px",
|
||||
),
|
||||
rx.text("minutes", size="2"),
|
||||
spacing="2", align="center",
|
||||
),
|
||||
rx.cond(
|
||||
CronState.f_schedule_kind == "weekly",
|
||||
rx.vstack(
|
||||
|
|
@ -643,17 +668,7 @@ def _form_schedule_picker() -> rx.Component:
|
|||
),
|
||||
spacing="2",
|
||||
),
|
||||
# daily
|
||||
rx.hstack(
|
||||
rx.text("Heure :", size="2"),
|
||||
rx.input(value=CronState.f_time_hh,
|
||||
on_change=CronState.set_f_time_hh, width="60px"),
|
||||
rx.text(":", size="3"),
|
||||
rx.input(value=CronState.f_time_mm,
|
||||
on_change=CronState.set_f_time_mm, width="60px"),
|
||||
spacing="2", align="center",
|
||||
),
|
||||
),
|
||||
_hours_grid(),
|
||||
),
|
||||
spacing="2", width="100%",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -204,12 +204,13 @@ def _bubble(msg: rx.Var) -> rx.Component:
|
|||
is_user,
|
||||
rx.fragment(),
|
||||
rx.flex(
|
||||
rx.icon("bot", size=14, color="white"),
|
||||
rx.icon("bot", color="white"),
|
||||
background_color="var(--brand-accent)",
|
||||
border_radius="50%",
|
||||
width="28px", height="28px",
|
||||
align="center", justify="center",
|
||||
flex_shrink="0",
|
||||
class_name="feedback-bot-bubble",
|
||||
),
|
||||
),
|
||||
rx.box(
|
||||
|
|
@ -341,8 +342,11 @@ def feedback_widget() -> rx.Component:
|
|||
rx.flex(
|
||||
rx.icon("message-square", size=18, color="white"),
|
||||
rx.text(
|
||||
"Aide & feedback EPTM",
|
||||
"Feedback",
|
||||
size="3", weight="bold", color="white",
|
||||
# translate="no" empêche la traduction auto du browser.
|
||||
class_name="notranslate",
|
||||
custom_attrs={"translate": "no"},
|
||||
),
|
||||
rx.spacer(),
|
||||
rx.dialog.close(
|
||||
|
|
|
|||
|
|
@ -447,6 +447,9 @@ class FicheState(AuthState):
|
|||
# ── Calendar day edit ─────────────────────────────────────────────────────
|
||||
edit_date: str = ""
|
||||
edit_date_label: str = ""
|
||||
edit_day_type: str = "" # "theorie" | "pratique" | "matu" | ""
|
||||
edit_day_type_label: str = "" # "Théorie" | "Pratique" | "Matu" | ""
|
||||
edit_day_has_schedule: bool = False # True si périodes configurées pour ce jour
|
||||
edit_p1: str = "present"
|
||||
edit_p2: str = "present"
|
||||
edit_p3: str = "present"
|
||||
|
|
@ -666,6 +669,26 @@ class FicheState(AuthState):
|
|||
Absence.date == d,
|
||||
)
|
||||
).scalars().all()
|
||||
# Horaire de classe (settings.json) : type + périodes pour ce jour.
|
||||
ap = sess.get(Apprenti, self.selected_id) if self.selected_id else None
|
||||
day_names = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"]
|
||||
day_key = day_names[d.weekday()]
|
||||
d_type = ""
|
||||
d_periods: list[int] = []
|
||||
if ap:
|
||||
settings = _read_settings()
|
||||
class_sch = (settings.get("class_schedule") or {}).get(ap.classe) or {}
|
||||
entry = class_sch.get(day_key)
|
||||
if isinstance(entry, dict):
|
||||
d_type = (entry.get("type") or "").strip()
|
||||
d_periods = list(entry.get("periods") or [])
|
||||
elif isinstance(entry, list):
|
||||
d_periods = list(entry)
|
||||
self.edit_day_type = d_type
|
||||
self.edit_day_type_label = {
|
||||
"theorie": "Théorie", "pratique": "Pratique", "matu": "Matu",
|
||||
}.get(d_type, "")
|
||||
self.edit_day_has_schedule = bool(d_periods)
|
||||
pm = {ab.periode: ab.statut for ab in absences}
|
||||
|
||||
def _choice(p: int) -> str:
|
||||
|
|
@ -738,6 +761,49 @@ class FicheState(AuthState):
|
|||
self.edit_p10 == "non_excusee"
|
||||
)
|
||||
|
||||
def mark_school_day_absent(self):
|
||||
"""Marque toutes les périodes de cours de la journée comme N (non excusées)
|
||||
dans le panneau. Utilise le mapping classe / jour / périodes configuré
|
||||
dans /params. Ne touche pas la DB — l'enregistrement passe par
|
||||
« Enregistrer »."""
|
||||
if not self.edit_date or not self.selected_id:
|
||||
return
|
||||
sess = get_session()
|
||||
try:
|
||||
ap = sess.get(Apprenti, self.selected_id)
|
||||
finally:
|
||||
sess.close()
|
||||
if not ap:
|
||||
return rx.toast.error("Apprenti introuvable.")
|
||||
d = date.fromisoformat(self.edit_date)
|
||||
day_names = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"]
|
||||
day_key = day_names[d.weekday()]
|
||||
settings = _read_settings()
|
||||
class_sch = (settings.get("class_schedule") or {}).get(ap.classe) or {}
|
||||
entry = class_sch.get(day_key)
|
||||
# Nouveau format {type, periods} ; ancien format = list[int] (compat).
|
||||
if isinstance(entry, dict):
|
||||
periods = set(entry.get("periods") or [])
|
||||
elif isinstance(entry, list):
|
||||
periods = set(entry)
|
||||
else:
|
||||
periods = set()
|
||||
if not periods:
|
||||
return rx.toast.warning(
|
||||
f"Aucun horaire configuré pour {ap.classe} le {day_key}. "
|
||||
f"Configure-le dans Paramètres → Horaires de classe."
|
||||
)
|
||||
if 1 in periods: self.edit_p1 = "non_excusee"
|
||||
if 2 in periods: self.edit_p2 = "non_excusee"
|
||||
if 3 in periods: self.edit_p3 = "non_excusee"
|
||||
if 4 in periods: self.edit_p4 = "non_excusee"
|
||||
if 5 in periods: self.edit_p5 = "non_excusee"
|
||||
if 6 in periods: self.edit_p6 = "non_excusee"
|
||||
if 7 in periods: self.edit_p7 = "non_excusee"
|
||||
if 8 in periods: self.edit_p8 = "non_excusee"
|
||||
if 9 in periods: self.edit_p9 = "non_excusee"
|
||||
if 10 in periods: self.edit_p10 = "non_excusee"
|
||||
|
||||
def excuse_all_visual(self):
|
||||
"""Bascule toutes les N → E dans le panneau (sans toucher la DB).
|
||||
L'enregistrement passe par le bouton « Enregistrer »."""
|
||||
|
|
@ -1116,10 +1182,12 @@ class FicheState(AuthState):
|
|||
apprenti = sess.get(Apprenti, self.selected_id)
|
||||
if apprenti:
|
||||
tvars = build_template_vars(apprenti, list(absences))
|
||||
_def_subj = "Relevé d'absences — {nom_complet} ({classe})"
|
||||
# Mêmes valeurs par défaut que la page Paramètres
|
||||
# (DEFAULT_TEMPLATE_SUBJ / DEFAULT_TEMPLATE_BODY).
|
||||
_def_subj = "Document EPTM — {nom_complet} ({classe})"
|
||||
_def_body = (
|
||||
"Bonjour {prenom},\n\n"
|
||||
"Veuillez trouver ci-joint votre document.\n\n"
|
||||
"Veuillez trouver ci-joint votre document pour la classe {classe}.\n\n"
|
||||
"Cordialement,\nL'équipe EPTM"
|
||||
)
|
||||
self.email_subject = render_template(
|
||||
|
|
@ -1517,6 +1585,20 @@ def _edit_panel() -> rx.Component:
|
|||
"Édition du ", FicheState.edit_date_label,
|
||||
size="3", weight="bold", color="var(--text-strong)",
|
||||
),
|
||||
rx.cond(
|
||||
FicheState.edit_day_type_label != "",
|
||||
rx.badge(
|
||||
FicheState.edit_day_type_label,
|
||||
color_scheme=rx.match(
|
||||
FicheState.edit_day_type,
|
||||
("theorie", "blue"),
|
||||
("pratique", "orange"),
|
||||
("matu", "violet"),
|
||||
"gray",
|
||||
),
|
||||
variant="soft", size="1",
|
||||
),
|
||||
),
|
||||
rx.spacer(),
|
||||
rx.button(
|
||||
rx.icon("x", size=14),
|
||||
|
|
@ -1547,9 +1629,21 @@ def _edit_panel() -> rx.Component:
|
|||
flex_wrap="wrap",
|
||||
width="100%",
|
||||
),
|
||||
# Action rapide : excuser visuellement toutes les N → E.
|
||||
# N'enregistre pas en DB — il faut cliquer sur « Enregistrer ».
|
||||
# Actions rapides : marquer toute la journée N (selon horaire classe)
|
||||
# ou excuser toutes les N → E. Aucune touche la DB — l'enregistrement
|
||||
# passe par « Enregistrer ».
|
||||
rx.flex(
|
||||
rx.button(
|
||||
rx.icon("calendar-x", size=14),
|
||||
rx.cond(
|
||||
FicheState.edit_day_has_schedule,
|
||||
rx.text("Absent toute la journée"),
|
||||
rx.text("Absent toute la journée (Données chronoplan manquantes)"),
|
||||
),
|
||||
on_click=FicheState.mark_school_day_absent,
|
||||
disabled=~FicheState.edit_day_has_schedule,
|
||||
variant="soft", color_scheme="red", size="2",
|
||||
),
|
||||
rx.button(
|
||||
rx.icon("check-check", size=14),
|
||||
"Excuser toutes les périodes",
|
||||
|
|
@ -1557,7 +1651,7 @@ def _edit_panel() -> rx.Component:
|
|||
disabled=~FicheState.edit_has_non_excusee,
|
||||
variant="soft", color_scheme="green", size="2",
|
||||
),
|
||||
width="100%",
|
||||
gap="0.5rem", flex_wrap="wrap", width="100%",
|
||||
),
|
||||
rx.divider(),
|
||||
rx.flex(
|
||||
|
|
@ -1829,7 +1923,7 @@ def fiche_page() -> rx.Component:
|
|||
# Modals (rendus une fois, contrôlés par leur state respectif)
|
||||
retenue_modal(),
|
||||
sanction_modal(),
|
||||
rx.heading("Fiche apprenti", size="7"),
|
||||
rx.heading("Apprentis", size="7"),
|
||||
|
||||
rx.cond(
|
||||
FicheState.has_apprentis,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ if str(_ROOT) not in sys.path:
|
|||
sys.path.insert(0, str(_ROOT))
|
||||
|
||||
from src.profession import load_mapping, save_mapping, find_unmapped_classes, refresh_all_professions # noqa: E402
|
||||
from src.db import get_session # noqa: E402
|
||||
from src.db import get_session, Apprenti # noqa: E402
|
||||
from sqlalchemy import select # noqa: E402
|
||||
|
||||
from ..sidebar import layout
|
||||
from ..state import AuthState
|
||||
|
|
@ -22,6 +23,16 @@ _SETTINGS_FILE = DATA_DIR / "settings.json"
|
|||
_DEFAULT_SANCTION = (
|
||||
"Selon le règlement de l'EM, l'apprenti a dépassé le nombre d'absences limite."
|
||||
)
|
||||
|
||||
# Horaire de classe : 5 jours ouvrés × 10 périodes max. Stocké dans settings.json
|
||||
# sous la clé "class_schedule" → dict[classe, dict[jour, {type, periods}]].
|
||||
# Compatible aussi avec l'ancien format list[int] (auto-migré au load).
|
||||
_SCH_DAYS = ["MON", "TUE", "WED", "THU", "FRI"]
|
||||
_SCH_DAY_LABELS = {"MON": "Lun", "TUE": "Mar", "WED": "Mer", "THU": "Jeu", "FRI": "Ven"}
|
||||
_SCH_PERIODS = list(range(1, 11))
|
||||
# Types de jour : Théorie / Pratique / Matu. "" = non défini (fallback neutre).
|
||||
_SCH_TYPES = ["theorie", "pratique", "matu"]
|
||||
_SCH_TYPE_LABELS = {"theorie": "Théorie", "pratique": "Pratique", "matu": "Matu", "": "—"}
|
||||
_DEFAULT_TEMPLATE_SUBJ = "Document EPTM — {nom_complet} ({classe})"
|
||||
_DEFAULT_TEMPLATE_BODY = (
|
||||
"Bonjour {prenom},\n\n"
|
||||
|
|
@ -84,6 +95,14 @@ class ParamsState(AuthState):
|
|||
save_ok_prof: bool = False
|
||||
refresh_msg: str = ""
|
||||
|
||||
# ── Horaires de classe (mapping classe / jour / périodes + type) ──────────
|
||||
sch_classes_avail: list[str] = []
|
||||
sch_class_selected: str = ""
|
||||
# État courant pour la classe sélectionnée. Chargé / sauvegardé en bloc.
|
||||
sch_periods: dict[str, list[int]] = {}
|
||||
sch_types: dict[str, str] = {} # day → "theorie"|"pratique"|"matu"|""
|
||||
save_ok_schedule: bool = False
|
||||
|
||||
# ── Setters ───────────────────────────────────────────────────────────────
|
||||
def set_texte_sanction(self, v: str): self.texte_sanction = v
|
||||
def set_chef_section(self, v: str): self.chef_section = v
|
||||
|
|
@ -126,6 +145,7 @@ class ParamsState(AuthState):
|
|||
self.save_ok_template = False
|
||||
self.save_ok_app = False
|
||||
self._reload_prof_mapping()
|
||||
self._reload_schedule_list()
|
||||
|
||||
def _reload_prof_mapping(self):
|
||||
self.prof_mapping = load_mapping()
|
||||
|
|
@ -237,6 +257,103 @@ class ParamsState(AuthState):
|
|||
sess.close()
|
||||
self.refresh_msg = f"{n} fiche(s) mise(s) à jour."
|
||||
|
||||
# ── Horaires de classe ───────────────────────────────────────────────────
|
||||
def _reload_schedule_list(self):
|
||||
"""Charge la liste des classes connues + sélectionne la 1re par défaut."""
|
||||
sess = get_session()
|
||||
try:
|
||||
rows = sess.execute(
|
||||
select(Apprenti.classe).distinct().order_by(Apprenti.classe)
|
||||
).scalars().all()
|
||||
finally:
|
||||
sess.close()
|
||||
# Filtre MP/MI/Formation (cohérent avec le reste de l'app).
|
||||
self.sch_classes_avail = [
|
||||
c for c in rows
|
||||
if c and not (c.startswith("MP") or c.startswith("MI")
|
||||
or c.lower().startswith("formation"))
|
||||
]
|
||||
if not self.sch_class_selected and self.sch_classes_avail:
|
||||
self.sch_class_selected = self.sch_classes_avail[0]
|
||||
if self.sch_class_selected:
|
||||
self._load_schedule_for(self.sch_class_selected)
|
||||
else:
|
||||
self.sch_periods = {d: [] for d in _SCH_DAYS}
|
||||
self.sch_types = {d: "" for d in _SCH_DAYS}
|
||||
|
||||
def _load_schedule_for(self, classe: str):
|
||||
s = _read_settings()
|
||||
all_sch = s.get("class_schedule") or {}
|
||||
class_sch = all_sch.get(classe) or {}
|
||||
periods: dict[str, list[int]] = {}
|
||||
types: dict[str, str] = {}
|
||||
for d in _SCH_DAYS:
|
||||
raw = class_sch.get(d)
|
||||
# Compat ascendante : ancien format = list[int], nouveau = {type, periods}
|
||||
if isinstance(raw, list):
|
||||
p_list = raw
|
||||
d_type = ""
|
||||
elif isinstance(raw, dict):
|
||||
p_list = raw.get("periods") or []
|
||||
d_type = raw.get("type") or ""
|
||||
else:
|
||||
p_list = []
|
||||
d_type = ""
|
||||
periods[d] = sorted({int(p) for p in p_list
|
||||
if isinstance(p, int) or str(p).isdigit()})
|
||||
types[d] = d_type if d_type in _SCH_TYPES else ""
|
||||
self.sch_periods = periods
|
||||
self.sch_types = types
|
||||
self.save_ok_schedule = False
|
||||
|
||||
def set_sch_class_selected(self, classe: str):
|
||||
self.sch_class_selected = classe
|
||||
self._load_schedule_for(classe)
|
||||
|
||||
def toggle_sch_cell(self, day: str, period: int):
|
||||
cur = dict(self.sch_periods)
|
||||
lst = list(cur.get(day, []))
|
||||
if period in lst:
|
||||
lst = [p for p in lst if p != period]
|
||||
else:
|
||||
lst = sorted(lst + [period])
|
||||
cur[day] = lst
|
||||
self.sch_periods = cur
|
||||
self.save_ok_schedule = False
|
||||
|
||||
def set_sch_type(self, day: str, day_type: str):
|
||||
# Sentinelle "none" (Radix Select) → vide.
|
||||
if day_type == "none":
|
||||
day_type = ""
|
||||
if day_type not in _SCH_TYPES and day_type != "":
|
||||
return
|
||||
cur = dict(self.sch_types)
|
||||
cur[day] = day_type
|
||||
self.sch_types = cur
|
||||
self.save_ok_schedule = False
|
||||
|
||||
def save_schedule(self):
|
||||
if not self.sch_class_selected:
|
||||
return
|
||||
s = _read_settings()
|
||||
all_sch = dict(s.get("class_schedule") or {})
|
||||
# Vide si aucune période ET aucun type configuré → on retire l'entrée.
|
||||
has_any = any(self.sch_periods.get(d) for d in _SCH_DAYS) \
|
||||
or any(self.sch_types.get(d) for d in _SCH_DAYS)
|
||||
if has_any:
|
||||
all_sch[self.sch_class_selected] = {
|
||||
d: {
|
||||
"type": self.sch_types.get(d) or "",
|
||||
"periods": list(self.sch_periods.get(d) or []),
|
||||
}
|
||||
for d in _SCH_DAYS
|
||||
}
|
||||
else:
|
||||
all_sch.pop(self.sch_class_selected, None)
|
||||
s["class_schedule"] = all_sch
|
||||
_write_settings(s)
|
||||
self.save_ok_schedule = True
|
||||
|
||||
|
||||
# ── UI helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -649,6 +766,98 @@ def _section_profession() -> rx.Component:
|
|||
)
|
||||
|
||||
|
||||
def _sch_cell(day: str, period: int) -> rx.Component:
|
||||
"""Une case de la grille horaire (jour × période). Cliquable."""
|
||||
is_on = ParamsState.sch_periods[day].contains(period)
|
||||
return rx.box(
|
||||
rx.text(period, size="1", weight="bold"),
|
||||
on_click=ParamsState.toggle_sch_cell(day, period),
|
||||
cursor="pointer",
|
||||
padding="0.35rem 0",
|
||||
border_radius="6px",
|
||||
border="2px solid",
|
||||
text_align="center",
|
||||
min_width="36px",
|
||||
border_color=rx.cond(is_on, "var(--red-9)", "var(--gray-6)"),
|
||||
background_color=rx.cond(is_on, "var(--red-9)", "transparent"),
|
||||
color=rx.cond(is_on, "white", "var(--gray-12)"),
|
||||
)
|
||||
|
||||
|
||||
def _sch_type_select(day: str) -> rx.Component:
|
||||
"""Petit dropdown pour choisir le type de jour. — = pas de type."""
|
||||
return rx.select.root(
|
||||
rx.select.trigger(placeholder="—", width="100%"),
|
||||
rx.select.content(
|
||||
rx.select.item("—", value="none"),
|
||||
*[rx.select.item(_SCH_TYPE_LABELS[t], value=t) for t in _SCH_TYPES],
|
||||
),
|
||||
# Empty string n'est pas une value valide pour Radix Select → "none"
|
||||
# sert de sentinelle lue/écrite via les handlers.
|
||||
value=rx.cond(ParamsState.sch_types[day] == "", "none", ParamsState.sch_types[day]),
|
||||
on_change=lambda v: ParamsState.set_sch_type(day, v),
|
||||
size="1",
|
||||
)
|
||||
|
||||
|
||||
def _sch_day_column(day: str) -> rx.Component:
|
||||
return rx.vstack(
|
||||
rx.text(_SCH_DAY_LABELS[day], size="2", weight="bold",
|
||||
color="var(--gray-11)", text_align="center", width="100%"),
|
||||
_sch_type_select(day),
|
||||
*[_sch_cell(day, p) for p in _SCH_PERIODS],
|
||||
spacing="1",
|
||||
align="center",
|
||||
flex="1",
|
||||
min_width="70px",
|
||||
)
|
||||
|
||||
|
||||
def _section_class_schedule() -> rx.Component:
|
||||
return _section(
|
||||
"Horaires de classe (Absent toute la journée)",
|
||||
rx.text(
|
||||
"Définit pour chaque classe les périodes de cours par jour de la semaine. "
|
||||
"Le bouton « Absent toute la journée » de la fiche apprenti marque ces "
|
||||
"périodes comme non excusées (N) en fonction du jour sélectionné.",
|
||||
size="2", color="var(--gray-11)",
|
||||
),
|
||||
rx.cond(
|
||||
ParamsState.sch_classes_avail.length() == 0,
|
||||
rx.text("Aucune classe en base.", size="2", color="var(--gray-10)"),
|
||||
rx.vstack(
|
||||
_field(
|
||||
"Classe",
|
||||
rx.select(
|
||||
ParamsState.sch_classes_avail,
|
||||
value=ParamsState.sch_class_selected,
|
||||
on_change=ParamsState.set_sch_class_selected,
|
||||
width="220px",
|
||||
),
|
||||
),
|
||||
rx.flex(
|
||||
*[_sch_day_column(d) for d in _SCH_DAYS],
|
||||
gap="0.75rem",
|
||||
flex_wrap="wrap",
|
||||
width="100%",
|
||||
align="start",
|
||||
),
|
||||
rx.hstack(
|
||||
rx.button(
|
||||
rx.icon("save", size=16),
|
||||
"Enregistrer l'horaire",
|
||||
on_click=ParamsState.save_schedule,
|
||||
color_scheme="blue", variant="solid", size="2",
|
||||
),
|
||||
_save_ok_callout(ParamsState.save_ok_schedule),
|
||||
spacing="3", align="center", flex_wrap="wrap",
|
||||
),
|
||||
spacing="3", width="100%",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _section_app() -> rx.Component:
|
||||
return _section(
|
||||
"Application",
|
||||
|
|
@ -687,6 +896,7 @@ def params_page() -> rx.Component:
|
|||
rx.heading("Paramètres", size="7"),
|
||||
_section_app(),
|
||||
_section_profession(),
|
||||
_section_class_schedule(),
|
||||
_section_sanction(),
|
||||
_section_smtp(),
|
||||
_section_escada(),
|
||||
|
|
|
|||
|
|
@ -65,11 +65,29 @@ class ProfileState(AuthState):
|
|||
# Avatar
|
||||
upload_ok: bool = False
|
||||
|
||||
# Mes classes Escada (enrôlement self-service).
|
||||
# NB: my_classes / classes_unknown / escada_username / escada_has_password
|
||||
# sont dans AuthState (rechargés à chaque check_auth depuis auth.yaml pour
|
||||
# éviter pollution inter-sessions). Ici on garde uniquement les vars qui
|
||||
# sont propres au formulaire / au cycle de soumission.
|
||||
classes_ok: bool = False
|
||||
classes_error: str = ""
|
||||
classes_loading: bool = False
|
||||
form_escada_user: str = ""
|
||||
form_escada_pass: str = ""
|
||||
form_totp_code: str = ""
|
||||
|
||||
def set_edit_name(self, v: str): self.edit_name = v
|
||||
def set_edit_email(self, v: str): self.edit_email = v
|
||||
def set_pwd_current(self, v: str): self.pwd_current = v
|
||||
def set_pwd_new(self, v: str): self.pwd_new = v
|
||||
def set_pwd_confirm(self, v: str): self.pwd_confirm = v
|
||||
def set_form_escada_user(self, v: str): self.form_escada_user = v
|
||||
def set_form_escada_pass(self, v: str): self.form_escada_pass = v
|
||||
def set_form_totp_code(self, v: str):
|
||||
# ne garde que les chiffres, max 6
|
||||
self.form_totp_code = "".join(c for c in v if c.isdigit())[:6]
|
||||
self.classes_error = ""
|
||||
|
||||
def load_data(self):
|
||||
if not self.authenticated:
|
||||
|
|
@ -82,6 +100,14 @@ class ProfileState(AuthState):
|
|||
self.profile_role = u.get("role", "user")
|
||||
self.profile_has_totp = bool(u.get("totp_secret"))
|
||||
self.profile_avatar = u.get("avatar_url", "")
|
||||
# Mes classes Escada — les vars de données sont dans AuthState
|
||||
# (rechargées par check_auth). Ici on initialise seulement le formulaire.
|
||||
self.classes_ok = False
|
||||
self.classes_error = ""
|
||||
self.classes_loading = False
|
||||
self.form_escada_user = u.get("escada_username") or ""
|
||||
self.form_escada_pass = ""
|
||||
self.form_totp_code = ""
|
||||
self.edit_name = self.profile_name
|
||||
self.edit_email = self.profile_email
|
||||
self.info_ok = False
|
||||
|
|
@ -194,6 +220,131 @@ class ProfileState(AuthState):
|
|||
self.profile_avatar = ""
|
||||
self.upload_ok = False
|
||||
|
||||
# ── Mes classes Escada (enrôlement) ───────────────────────────────────────
|
||||
@rx.event(background=True)
|
||||
async def fetch_my_classes(self):
|
||||
"""Lance le scrape Escada en arrière-plan (async subprocess) pour ne
|
||||
pas bloquer l'event loop Reflex pendant les ~10-30s du Playwright.
|
||||
|
||||
Les autres users continuent d'utiliser l'app pendant ce temps, et les
|
||||
logs sont accessibles en live sur /logs.
|
||||
"""
|
||||
import asyncio as _aio
|
||||
import json as _json
|
||||
from pathlib import Path as _P
|
||||
|
||||
# Reset état + validation (sous lock state)
|
||||
async with self:
|
||||
self.classes_error = ""
|
||||
self.classes_ok = False
|
||||
e_user = (self.form_escada_user or "").strip()
|
||||
e_pass = (self.form_escada_pass or "").strip()
|
||||
totp = (self.form_totp_code or "").strip()
|
||||
if not e_user or "@" not in e_user:
|
||||
self.classes_error = "Email Escada invalide."
|
||||
return
|
||||
if not self.escada_has_password and not e_pass:
|
||||
self.classes_error = "Mot de passe Escada requis pour la première connexion."
|
||||
return
|
||||
if len(totp) != 6 or not totp.isdigit():
|
||||
self.classes_error = "Code 2FA invalide (6 chiffres)."
|
||||
return
|
||||
|
||||
# Persistance des creds (le password n'est ré-écrit que s'il est fourni)
|
||||
cfg = _load_auth()
|
||||
users = cfg.get("credentials", {}).get("usernames", {})
|
||||
u = users.get(self.username)
|
||||
if not u:
|
||||
self.classes_error = "Compte introuvable."
|
||||
return
|
||||
u["escada_username"] = e_user
|
||||
if e_pass:
|
||||
u["escada_password"] = e_pass
|
||||
_save_auth(cfg)
|
||||
|
||||
self.classes_loading = True
|
||||
current_user = self.username # capture pour la phase async
|
||||
|
||||
result_file = _P(DATA_DIR) / f"sync_user_classes_{current_user}.json"
|
||||
result_file.unlink(missing_ok=True)
|
||||
cwd = str(_P(__file__).resolve().parent.parent.parent)
|
||||
|
||||
# Subprocess async — ne bloque pas l'event loop Reflex
|
||||
try:
|
||||
proc = await _aio.create_subprocess_exec(
|
||||
"python", "scripts/fetch_user_classes.py", current_user,
|
||||
env={**os.environ, "TOTP_CODE": totp},
|
||||
cwd=cwd,
|
||||
stdout=_aio.subprocess.PIPE,
|
||||
stderr=_aio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
stdout_b, stderr_b = await _aio.wait_for(proc.communicate(), timeout=180)
|
||||
except _aio.TimeoutError:
|
||||
proc.kill()
|
||||
async with self:
|
||||
self.classes_loading = False
|
||||
self.classes_error = "Délai dépassé (3 min)."
|
||||
return
|
||||
stdout = (stdout_b or b"").decode("utf-8", errors="replace")
|
||||
stderr = (stderr_b or b"").decode("utf-8", errors="replace")
|
||||
rc = proc.returncode
|
||||
except Exception as e:
|
||||
async with self:
|
||||
self.classes_loading = False
|
||||
self.classes_error = f"Erreur subprocess : {e}"
|
||||
return
|
||||
|
||||
# Lecture résultat (hors lock)
|
||||
app_log(
|
||||
f"[profile/escada] {current_user} : subprocess rc={rc} "
|
||||
f"stdout_tail={stdout[-800:]!r} stderr_tail={stderr[-400:]!r}"
|
||||
)
|
||||
|
||||
data = None
|
||||
if result_file.exists():
|
||||
try:
|
||||
data = _json.loads(result_file.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Update state final (sous lock)
|
||||
async with self:
|
||||
self.classes_loading = False
|
||||
if not data:
|
||||
self.classes_error = "Pas de résultat — voir logs serveur."
|
||||
return
|
||||
if not data.get("ok"):
|
||||
self.classes_error = data.get("error") or "Échec inconnu."
|
||||
app_log(f"[profile/escada] {current_user} : échec : {self.classes_error}")
|
||||
return
|
||||
|
||||
new_classes = list(data.get("classes") or [])
|
||||
cache_path = _P(DATA_DIR) / "esacada_classes.json"
|
||||
try:
|
||||
known = set(_json.loads(cache_path.read_text(encoding="utf-8")))
|
||||
except Exception:
|
||||
known = set()
|
||||
self.classes_unknown = sorted([c for c in new_classes if c not in known])
|
||||
|
||||
cfg = _load_auth()
|
||||
cfg["credentials"]["usernames"][current_user]["allowed_classes"] = new_classes
|
||||
_save_auth(cfg)
|
||||
self.my_classes = new_classes
|
||||
self.escada_username = e_user
|
||||
self.escada_has_password = True
|
||||
self.form_escada_pass = ""
|
||||
self.form_totp_code = ""
|
||||
self.classes_ok = True
|
||||
# Si l'enrôlement vient d'être finalisé, le popup doit se fermer
|
||||
if new_classes:
|
||||
self.must_enroll = False
|
||||
|
||||
app_log(
|
||||
f"[profile/escada] {current_user} : {len(new_classes)} classes "
|
||||
f"accordées ({len(self.classes_unknown)} inconnues du cache)"
|
||||
)
|
||||
|
||||
|
||||
# ── UI helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -466,6 +617,154 @@ def _theme_section() -> rx.Component:
|
|||
)
|
||||
|
||||
|
||||
def _enroll_form_content() -> rx.Component:
|
||||
"""Contenu pure du formulaire d'enrôlement Escada (sans wrapper box).
|
||||
Réutilisé dans la carte /profile et dans le dialog popup global."""
|
||||
return rx.vstack(
|
||||
_label("Email Escada"),
|
||||
rx.input(
|
||||
value=ProfileState.form_escada_user,
|
||||
on_change=ProfileState.set_form_escada_user,
|
||||
placeholder="prenom.nom@vs.ch",
|
||||
type="email", width="100%",
|
||||
),
|
||||
_label(rx.cond(
|
||||
AuthState.escada_has_password,
|
||||
"Mot de passe Escada (laisser vide pour réutiliser celui enregistré)",
|
||||
"Mot de passe Escada",
|
||||
)),
|
||||
rx.input(
|
||||
value=ProfileState.form_escada_pass,
|
||||
on_change=ProfileState.set_form_escada_pass,
|
||||
type="password", width="100%",
|
||||
),
|
||||
_label("Code 2FA Escada (6 chiffres)"),
|
||||
rx.input(
|
||||
value=ProfileState.form_totp_code,
|
||||
on_change=ProfileState.set_form_totp_code,
|
||||
placeholder="123456",
|
||||
max_length=6,
|
||||
inputmode="numeric",
|
||||
width="160px",
|
||||
),
|
||||
rx.hstack(
|
||||
rx.button(
|
||||
rx.cond(
|
||||
ProfileState.classes_loading,
|
||||
rx.spinner(size="2"),
|
||||
rx.icon("refresh-ccw", size=16),
|
||||
),
|
||||
rx.cond(
|
||||
ProfileState.classes_loading,
|
||||
rx.text("Connexion Escada en cours…"),
|
||||
rx.cond(
|
||||
AuthState.escada_has_password,
|
||||
rx.text("Rafraîchir mes classes"),
|
||||
rx.text("Récupérer mes classes"),
|
||||
),
|
||||
),
|
||||
on_click=ProfileState.fetch_my_classes,
|
||||
disabled=ProfileState.classes_loading,
|
||||
color_scheme="blue", size="2",
|
||||
),
|
||||
_ok(ProfileState.classes_ok, "Liste à jour. Voir ci-dessous."),
|
||||
_err(ProfileState.classes_error),
|
||||
spacing="3", align="center", flex_wrap="wrap",
|
||||
),
|
||||
rx.divider(),
|
||||
rx.text(
|
||||
"Classes actuellement accordées :",
|
||||
size="2", weight="medium", color="var(--gray-11)",
|
||||
),
|
||||
rx.cond(
|
||||
AuthState.my_classes.length() == 0,
|
||||
rx.text(
|
||||
"Aucune classe accordée pour l'instant. Lancez une récupération ci-dessus.",
|
||||
size="2", color="var(--text-soft)",
|
||||
),
|
||||
rx.flex(
|
||||
rx.foreach(
|
||||
AuthState.my_classes,
|
||||
lambda c: rx.badge(c, color_scheme="blue", variant="soft", size="2"),
|
||||
),
|
||||
gap="0.4rem", flex_wrap="wrap",
|
||||
),
|
||||
),
|
||||
rx.cond(
|
||||
AuthState.classes_unknown.length() > 0,
|
||||
rx.callout.root(
|
||||
rx.callout.icon(rx.icon("triangle-alert", size=16)),
|
||||
rx.callout.text(
|
||||
"Les classes suivantes sont accordées mais pas encore "
|
||||
"synchronisées dans le système. Demandez à un admin de "
|
||||
"lancer une sync globale Escada : ",
|
||||
rx.foreach(
|
||||
AuthState.classes_unknown,
|
||||
lambda c: rx.badge(c, color_scheme="amber", variant="soft", margin_right="0.25rem"),
|
||||
),
|
||||
),
|
||||
color_scheme="amber", variant="soft", size="1",
|
||||
),
|
||||
),
|
||||
spacing="3", width="100%",
|
||||
)
|
||||
|
||||
|
||||
def _classes_section() -> rx.Component:
|
||||
"""Carte enrôlement Escada pour la page /profile (non-admin uniquement)."""
|
||||
return rx.cond(
|
||||
ProfileState.profile_role == "admin",
|
||||
rx.fragment(),
|
||||
rx.box(
|
||||
rx.vstack(
|
||||
rx.text("Mes classes Escada", size="3", weight="bold"),
|
||||
rx.text(
|
||||
"Vos accès aux classes sont déterminés par votre compte Escada. "
|
||||
"Renseignez vos identifiants et un code 2FA pour récupérer votre liste.",
|
||||
size="1", color="var(--text-soft)",
|
||||
),
|
||||
_enroll_form_content(),
|
||||
spacing="3", width="100%",
|
||||
),
|
||||
padding="1.25rem",
|
||||
background_color="var(--surface)",
|
||||
border_radius="8px",
|
||||
border="1px solid var(--border)",
|
||||
width="100%",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def enroll_required_dialog() -> rx.Component:
|
||||
"""Popup forcé pour les users sans aucun accès (must_enroll=True)."""
|
||||
return rx.dialog.root(
|
||||
rx.dialog.content(
|
||||
rx.flex(
|
||||
rx.heading("Bienvenue ! Configurez votre accès Escada", size="4"),
|
||||
rx.spacer(),
|
||||
rx.icon_button(
|
||||
rx.icon("x", size=14),
|
||||
on_click=AuthState.dismiss_enroll,
|
||||
variant="ghost", size="1",
|
||||
),
|
||||
align="center", width="100%",
|
||||
),
|
||||
rx.text(
|
||||
"Vous n'avez encore accès à aucune classe. Renseignez vos identifiants "
|
||||
"Escada et un code 2FA fraîchement généré (validité ~30s) pour récupérer "
|
||||
"la liste des classes auxquelles votre compte Escada a accès.",
|
||||
size="2", color="var(--text-soft)",
|
||||
margin_y="0.75rem",
|
||||
),
|
||||
_enroll_form_content(),
|
||||
max_width="540px",
|
||||
max_height="90vh",
|
||||
overflow_y="auto",
|
||||
),
|
||||
open=AuthState.must_enroll & ~AuthState.enroll_dismissed,
|
||||
)
|
||||
|
||||
|
||||
def _totp_section() -> rx.Component:
|
||||
return rx.box(
|
||||
rx.vstack(
|
||||
|
|
@ -526,6 +825,7 @@ def profile_page() -> rx.Component:
|
|||
_avatar_section(),
|
||||
_info_section(),
|
||||
_password_section(),
|
||||
_classes_section(),
|
||||
_theme_section(),
|
||||
_totp_section(),
|
||||
spacing="4",
|
||||
|
|
|
|||
|
|
@ -331,13 +331,18 @@ class RetenueState(AuthState):
|
|||
f"{self.selected_label} (existante : {self.existing_notice_label})"
|
||||
)
|
||||
return
|
||||
remarque = (self.remarque or "").strip()
|
||||
user = (self.username or "").strip()
|
||||
if user:
|
||||
remarque = f"({user}) {remarque}".rstrip()
|
||||
remarque = remarque or None
|
||||
sess = get_session()
|
||||
try:
|
||||
sess.add(Notice(
|
||||
apprenti_id=self.selected_id,
|
||||
date_event=_date.today(),
|
||||
titre=self._build_notice_titre(),
|
||||
remarque=(self.remarque or "").strip() or None,
|
||||
remarque=remarque,
|
||||
type_notice=None,
|
||||
matiere=None,
|
||||
source="retenue",
|
||||
|
|
|
|||
|
|
@ -154,7 +154,11 @@ class SanctionState(AuthState):
|
|||
f"{self.selected_label} (existante : {self.existing_notice_label})"
|
||||
)
|
||||
return
|
||||
remarque = (self.texte_description or "").strip() or None
|
||||
remarque = (self.texte_description or "").strip()
|
||||
user = (self.username or "").strip()
|
||||
if user:
|
||||
remarque = f"({user}) {remarque}".rstrip()
|
||||
remarque = remarque or None
|
||||
sess = get_session()
|
||||
try:
|
||||
sess.add(Notice(
|
||||
|
|
|
|||
|
|
@ -245,6 +245,30 @@ class UsersState(AuthState):
|
|||
self.totp_ok = True
|
||||
app_log(f"[users] {self.username} : 2FA réinitialisé pour {self.edit_target}")
|
||||
|
||||
def reset_access(self):
|
||||
"""Efface tous les droits du user : allowed_classes=[] + creds Escada."""
|
||||
cfg = _load_auth()
|
||||
users = cfg.get("credentials", {}).get("usernames", {})
|
||||
uname = self.edit_target
|
||||
if uname not in users:
|
||||
self.access_error = "Utilisateur introuvable."
|
||||
return
|
||||
users[uname]["allowed_classes"] = []
|
||||
users[uname].pop("escada_username", None)
|
||||
users[uname].pop("escada_password", None)
|
||||
_save_auth(cfg)
|
||||
# Sync l'état local de l'édition
|
||||
self.edit_restrict = True
|
||||
self.edit_classes = []
|
||||
self.access_ok = True
|
||||
self.access_error = ""
|
||||
self._refresh_list()
|
||||
app_log(
|
||||
f"[users] {self.username} : RÉINITIALISATION accès pour {uname} "
|
||||
f"(allowed_classes vidée + creds Escada effacés)"
|
||||
)
|
||||
return rx.toast.success(f"Droits réinitialisés pour {uname}")
|
||||
|
||||
async def handle_avatar_upload(self, files: list[rx.UploadFile]):
|
||||
if not files:
|
||||
return
|
||||
|
|
@ -570,7 +594,7 @@ def _user_row(user: dict) -> rx.Component:
|
|||
rx.button(
|
||||
rx.cond(is_selected, rx.icon("chevron-up", size=14), rx.icon("pencil", size=14)),
|
||||
rx.cond(is_selected, "Fermer", "Éditer"),
|
||||
on_click=UsersState.select_user(user["username"]),
|
||||
on_click=UsersState.select_user(user["username"]).stop_propagation,
|
||||
variant=rx.cond(is_selected, "solid", "outline"),
|
||||
color_scheme="blue",
|
||||
size="1",
|
||||
|
|
@ -591,6 +615,10 @@ def _user_row(user: dict) -> rx.Component:
|
|||
border_radius="6px",
|
||||
border=rx.cond(is_selected, "1px solid var(--blue-6)", "1px solid #e0e0e0"),
|
||||
width="100%",
|
||||
# Click sur la row entière ouvre / ferme le panneau d'édition.
|
||||
on_click=UsersState.select_user(user["username"]),
|
||||
cursor="pointer",
|
||||
_hover={"background_color": rx.cond(is_selected, "var(--blue-2)", "var(--surface-hover)")},
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -882,6 +910,40 @@ def _edit_panel_access() -> rx.Component:
|
|||
on_click=UsersState.save_access,
|
||||
color_scheme="blue", size="2",
|
||||
),
|
||||
rx.alert_dialog.root(
|
||||
rx.alert_dialog.trigger(
|
||||
rx.button(
|
||||
rx.icon("trash-2", size=14),
|
||||
"Réinitialiser les droits",
|
||||
variant="outline", color_scheme="red", size="2",
|
||||
),
|
||||
),
|
||||
rx.alert_dialog.content(
|
||||
rx.alert_dialog.title(
|
||||
"Réinitialiser les droits de ", UsersState.edit_target, " ?",
|
||||
),
|
||||
rx.alert_dialog.description(
|
||||
"Cette action efface allowed_classes (= aucun accès) "
|
||||
"ET les identifiants Escada stockés. L'utilisateur devra "
|
||||
"refaire un enrôlement complet depuis sa page profil.",
|
||||
size="2",
|
||||
),
|
||||
rx.flex(
|
||||
rx.alert_dialog.cancel(
|
||||
rx.button("Annuler", variant="soft", color_scheme="gray"),
|
||||
),
|
||||
rx.alert_dialog.action(
|
||||
rx.button(
|
||||
"Réinitialiser",
|
||||
color_scheme="red",
|
||||
on_click=UsersState.reset_access,
|
||||
),
|
||||
),
|
||||
gap="0.5rem", justify="end", margin_top="1rem",
|
||||
),
|
||||
max_width="480px",
|
||||
),
|
||||
),
|
||||
_ok_callout(UsersState.access_ok, "Accès mis à jour."),
|
||||
_err_callout(UsersState.access_error),
|
||||
spacing="3", align="center", flex_wrap="wrap",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import reflex as rx
|
||||
from .state import AuthState
|
||||
from .components import scan_docs
|
||||
|
|
@ -6,6 +9,45 @@ from .components import scan_docs
|
|||
# détecter de nouveaux fichiers).
|
||||
_DOC_SECTIONS = scan_docs()
|
||||
|
||||
|
||||
def _resolve_version() -> str:
|
||||
"""Renvoie le dernier tag git, ou le contenu de data/VERSION en fallback.
|
||||
Lu une fois au démarrage du module — un restart suffit pour refléter un
|
||||
nouveau tag (utile en prod 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"
|
||||
RAIL_W = "68px"
|
||||
TOPBAR_H = "56px"
|
||||
|
|
@ -22,8 +64,8 @@ _ACTIVE_CLR = "var(--brand-primary-light)"
|
|||
|
||||
_PAGES = [
|
||||
("Tableau de bord", "/accueil", "layout-dashboard"),
|
||||
("Apprentis", "/fiche", "user"),
|
||||
("Classes", "/classe", "users"),
|
||||
("Apprentis", "/fiche", "user"),
|
||||
]
|
||||
|
||||
_ADMIN_PAGES = [
|
||||
|
|
@ -433,6 +475,9 @@ def sidebar() -> rx.Component:
|
|||
_doc_section(),
|
||||
rx.spacer(),
|
||||
|
||||
# Version (dernier tag git) — au-dessus du profil
|
||||
_version_badge(),
|
||||
|
||||
# User
|
||||
rx.box(height="1px", width="100%", background_color=_BORDER),
|
||||
rx.box(
|
||||
|
|
@ -556,8 +601,9 @@ _KEYBOARD_SHORTCUTS_JS = """
|
|||
|
||||
|
||||
def layout(content: rx.Component) -> rx.Component:
|
||||
# Import local pour éviter le cycle sidebar ↔ pages.feedback
|
||||
# Imports locaux pour éviter les cycles sidebar ↔ pages.*
|
||||
from .pages.feedback import feedback_widget
|
||||
from .pages.profile import enroll_required_dialog
|
||||
return rx.box(
|
||||
sidebar(),
|
||||
_mobile_topbar(),
|
||||
|
|
@ -577,6 +623,7 @@ def layout(content: rx.Component) -> rx.Component:
|
|||
box_sizing="border-box",
|
||||
),
|
||||
feedback_widget(),
|
||||
enroll_required_dialog(),
|
||||
rx.script(_KEYBOARD_SHORTCUTS_JS),
|
||||
width="100%",
|
||||
height="100vh",
|
||||
|
|
|
|||
|
|
@ -67,6 +67,18 @@ class AuthState(rx.State):
|
|||
doc_expanded: bool = False
|
||||
# Compteur de messages feedback "new" (admin uniquement)
|
||||
feedback_new_count: int = 0
|
||||
# Flag : True si le user (non-admin) n'a aucune classe accordée et doit
|
||||
# passer par l'enrôlement Escada. Auto-set par check_auth.
|
||||
must_enroll: bool = False
|
||||
# L'user a fermé le popup manuellement (pour cette session) — on ne le
|
||||
# réaffiche pas automatiquement même si must_enroll reste True.
|
||||
enroll_dismissed: bool = False
|
||||
# Données de l'utilisateur connecté pour la section enrôlement Escada
|
||||
# (rechargées à chaque check_auth pour éviter la pollution entre sessions).
|
||||
my_classes: list[str] = []
|
||||
classes_unknown: list[str] = []
|
||||
escada_username: str = ""
|
||||
escada_has_password: bool = False
|
||||
|
||||
@rx.var
|
||||
def authenticated(self) -> bool:
|
||||
|
|
@ -130,8 +142,24 @@ class AuthState(rx.State):
|
|||
self.theme = stored_theme
|
||||
# Compteur feedback (admin uniquement) — pour le badge sidebar
|
||||
self._refresh_feedback_count()
|
||||
# Recharge les données d'enrôlement depuis auth.yaml à chaque page —
|
||||
# évite la pollution si un autre user était dans la session avant.
|
||||
u = users.get(self.username) or {}
|
||||
self.my_classes = list(u.get("allowed_classes") or [])
|
||||
self.escada_username = u.get("escada_username") or ""
|
||||
self.escada_has_password = bool(u.get("escada_password"))
|
||||
self.classes_unknown = [] # reset l'avertissement à chaque page
|
||||
# Détecte si l'user doit s'enrôler (non-admin sans classes accordées)
|
||||
if u.get("role") == "admin":
|
||||
self.must_enroll = False
|
||||
else:
|
||||
self.must_enroll = not self.my_classes
|
||||
return self._apply_theme_script(self.theme)
|
||||
|
||||
def dismiss_enroll(self):
|
||||
"""Ferme le popup d'enrôlement pour la session courante."""
|
||||
self.enroll_dismissed = True
|
||||
|
||||
def _refresh_feedback_count(self):
|
||||
if self.role != "admin":
|
||||
self.feedback_new_count = 0
|
||||
|
|
@ -272,6 +300,9 @@ class AuthState(rx.State):
|
|||
self.role = user.get("role", "user")
|
||||
self.photo_url = user.get("avatar_url", "")
|
||||
self.theme = user.get("theme") or "eptm"
|
||||
# Reset le flag de dismiss du popup d'enrôlement à chaque login —
|
||||
# si l'user n'a toujours pas de classes, le popup doit ré-apparaître.
|
||||
self.enroll_dismissed = False
|
||||
self._reset_totp_flow()
|
||||
return [self._apply_theme_script(self.theme), rx.redirect("/accueil")]
|
||||
|
||||
|
|
@ -297,6 +328,12 @@ class AuthState(rx.State):
|
|||
self.role = "user"
|
||||
self.photo_url = ""
|
||||
self.theme = "eptm"
|
||||
self.must_enroll = False
|
||||
self.enroll_dismissed = False
|
||||
self.my_classes = []
|
||||
self.classes_unknown = []
|
||||
self.escada_username = ""
|
||||
self.escada_has_password = False
|
||||
self.login_user = ""
|
||||
self.login_pass = ""
|
||||
self.login_error = ""
|
||||
|
|
|
|||
|
|
@ -78,21 +78,15 @@ def _is_due(job: CronJob, now: datetime) -> bool:
|
|||
|
||||
last = job.last_run_at
|
||||
|
||||
if job.schedule_kind == "interval":
|
||||
# schedule_value = nb minutes
|
||||
try:
|
||||
minutes = int(job.schedule_value)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if minutes < 1:
|
||||
return False
|
||||
if last is None:
|
||||
if job.schedule_kind == "daily_multi":
|
||||
# schedule_value = "HH:MM,HH:MM,HH:MM,..." (plusieurs heures par jour)
|
||||
for hhmm in (job.schedule_value or "").split(","):
|
||||
hhmm = hhmm.strip()
|
||||
if not hhmm:
|
||||
continue
|
||||
if _due_time_of_day(hhmm, last, now):
|
||||
return True
|
||||
return (now - last).total_seconds() >= minutes * 60
|
||||
|
||||
if job.schedule_kind == "daily":
|
||||
# schedule_value = "HH:MM"
|
||||
return _due_time_of_day(job.schedule_value, last, now)
|
||||
return False
|
||||
|
||||
if job.schedule_kind == "weekly":
|
||||
# schedule_value = "MON,WED,FRI:HH:MM"
|
||||
|
|
|
|||
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()
|
||||
49
src/db.py
49
src/db.py
|
|
@ -287,11 +287,10 @@ class CronJob(Base):
|
|||
name: Mapped[str]
|
||||
enabled: Mapped[bool] = mapped_column(default=True)
|
||||
|
||||
# schedule_kind ∈ {"daily", "weekly", "interval"}
|
||||
# daily : schedule_value="HH:MM"
|
||||
# schedule_kind ∈ {"daily_multi", "weekly"}
|
||||
# daily_multi : schedule_value="HH:MM,HH:MM,..." (1..N heures par jour)
|
||||
# weekly : schedule_value="MON,TUE,WED,THU,FRI:HH:MM"
|
||||
# interval: schedule_value="60" (minutes)
|
||||
schedule_kind: Mapped[str] = mapped_column(default="daily")
|
||||
schedule_kind: Mapped[str] = mapped_column(default="daily_multi")
|
||||
schedule_value: Mapped[str] = mapped_column(default="03:00")
|
||||
|
||||
# task_kind ∈ {"push", "sync", "push_then_sync"}
|
||||
|
|
@ -433,6 +432,48 @@ def init_db(engine=None):
|
|||
_conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Migration cron schedule_kind : 'interval' (minutes) → 'daily_multi' (HH:MM,…)
|
||||
# On déroule l'intervalle sur 24 h à partir de 00:00 et on enregistre la liste.
|
||||
try:
|
||||
with engine.connect() as _conn:
|
||||
rows = _conn.execute(text(
|
||||
"SELECT id, schedule_value FROM cron_jobs WHERE schedule_kind='interval'"
|
||||
)).all()
|
||||
for jid, val in rows:
|
||||
try:
|
||||
interval = int(val)
|
||||
except (TypeError, ValueError):
|
||||
interval = 0
|
||||
if interval <= 0 or interval >= 1440:
|
||||
# valeur invalide → on bascule sur une exécution quotidienne à minuit
|
||||
new_value = "00:00"
|
||||
else:
|
||||
hours: list[str] = []
|
||||
m = 0
|
||||
while m < 1440:
|
||||
hours.append(f"{m // 60:02d}:{m % 60:02d}")
|
||||
m += interval
|
||||
new_value = ",".join(hours)
|
||||
_conn.execute(text(
|
||||
"UPDATE cron_jobs SET schedule_kind='daily_multi', schedule_value=:v "
|
||||
"WHERE id=:i"
|
||||
), {"v": new_value, "i": jid})
|
||||
_conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Migration 'daily' (HH:MM) → 'daily_multi' (HH:MM unique). 'daily' devient
|
||||
# un cas particulier de daily_multi avec une seule heure.
|
||||
try:
|
||||
with engine.connect() as _conn:
|
||||
_conn.execute(text(
|
||||
"UPDATE cron_jobs SET schedule_kind='daily_multi' WHERE schedule_kind='daily'"
|
||||
))
|
||||
_conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return engine
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,33 @@ def format_date_long(d: _date) -> str:
|
|||
return f"{d.day} {_MOIS_FR[d.month - 1]} {d.year}"
|
||||
|
||||
|
||||
def _destinataire(
|
||||
apprenti: "Apprenti", fiche: Optional["ApprentiFiche"]
|
||||
) -> tuple[str, str, str]:
|
||||
"""Renvoie (nom, adresse, "NPA Localité") du destinataire de l'avis.
|
||||
|
||||
Apprenti mineur → représentant légal. Sinon → apprenti lui-même.
|
||||
L'adresse de l'entreprise n'est jamais utilisée.
|
||||
"""
|
||||
if not fiche:
|
||||
return f"{apprenti.prenom} {apprenti.nom}".strip(), "", ""
|
||||
if fiche.majeur is False:
|
||||
cp = (fiche.resp_legal_code_postal or "").strip()
|
||||
loc = (fiche.resp_legal_localite or "").strip()
|
||||
return (
|
||||
(fiche.resp_legal_nom or "").strip(),
|
||||
(fiche.resp_legal_adresse or "").strip(),
|
||||
f"{cp} {loc}".strip(),
|
||||
)
|
||||
cp = (fiche.code_postal or "").strip()
|
||||
loc = (fiche.localite or "").strip()
|
||||
return (
|
||||
f"{apprenti.prenom} {apprenti.nom}".strip(),
|
||||
(fiche.adresse or "").strip(),
|
||||
f"{cp} {loc}".strip(),
|
||||
)
|
||||
|
||||
|
||||
def generate_retenue_pdf(
|
||||
sess: Session,
|
||||
apprenti_id: int,
|
||||
|
|
@ -69,11 +96,10 @@ def generate_retenue_pdf(
|
|||
f"{profession.strip()} {apprenti.classe}".strip()
|
||||
if profession else apprenti.classe
|
||||
)
|
||||
npa_ville = ""
|
||||
if fiche:
|
||||
cp = (fiche.entreprise_code_postal or "").strip()
|
||||
loc = (fiche.entreprise_localite or "").strip()
|
||||
npa_ville = f"{cp} {loc}".strip()
|
||||
|
||||
# Destinataire : représentant légal si mineur, sinon l'apprenti lui-même.
|
||||
# L'adresse de l'entreprise n'est plus utilisée.
|
||||
dest_nom, dest_adresse, dest_npa_ville = _destinataire(apprenti, fiche)
|
||||
|
||||
# 1. Lecture template + clone
|
||||
reader = pypdf.PdfReader(str(_TEMPLATE_PATH))
|
||||
|
|
@ -88,9 +114,9 @@ def generate_retenue_pdf(
|
|||
field_values: dict[str, str] = {
|
||||
"NomApprenti": f"{apprenti.prenom} {apprenti.nom}".strip(),
|
||||
"Classe": classe_full,
|
||||
"NomEntreprise": (fiche.entreprise_nom if fiche else "") or "",
|
||||
"Adresse": (fiche.entreprise_adresse if fiche else "") or "",
|
||||
"NPA-Ville": npa_ville,
|
||||
"NomEntreprise": dest_nom,
|
||||
"Adresse": dest_adresse,
|
||||
"NPA-Ville": dest_npa_ville,
|
||||
"RetenueDateHeure": retenue_date.strftime("%d.%m.%Y"),
|
||||
"Branche": branche if case == "devoir" else "",
|
||||
"Remarque": remarque,
|
||||
|
|
|
|||
|
|
@ -40,6 +40,35 @@ def _load_settings() -> dict:
|
|||
return {}
|
||||
|
||||
|
||||
def _destinataire(
|
||||
apprenti: "Apprenti", fiche: Optional["ApprentiFiche"]
|
||||
) -> tuple[str, str, str]:
|
||||
"""Renvoie (nom, adresse, "NPA Localité") du destinataire de l'avis.
|
||||
|
||||
Apprenti mineur → représentant légal (resp_legal_*).
|
||||
Apprenti majeur (ou statut inconnu) → adresse personnelle de l'apprenti.
|
||||
L'adresse de l'entreprise n'est jamais utilisée.
|
||||
"""
|
||||
if not fiche:
|
||||
nom = f"{apprenti.prenom} {apprenti.nom}".strip()
|
||||
return nom, "", ""
|
||||
if fiche.majeur is False:
|
||||
cp = (fiche.resp_legal_code_postal or "").strip()
|
||||
loc = (fiche.resp_legal_localite or "").strip()
|
||||
return (
|
||||
(fiche.resp_legal_nom or "").strip(),
|
||||
(fiche.resp_legal_adresse or "").strip(),
|
||||
f"{cp} {loc}".strip(),
|
||||
)
|
||||
cp = (fiche.code_postal or "").strip()
|
||||
loc = (fiche.localite or "").strip()
|
||||
return (
|
||||
f"{apprenti.prenom} {apprenti.nom}".strip(),
|
||||
(fiche.adresse or "").strip(),
|
||||
f"{cp} {loc}".strip(),
|
||||
)
|
||||
|
||||
|
||||
def generate_avis_pdf(
|
||||
sess: Session,
|
||||
apprenti_id: int,
|
||||
|
|
@ -68,19 +97,16 @@ def generate_avis_pdf(
|
|||
fiche: Optional[ApprentiFiche] = apprenti.fiche
|
||||
settings = _load_settings()
|
||||
|
||||
# Construction des valeurs
|
||||
npa_ville = ""
|
||||
if fiche:
|
||||
cp = (fiche.entreprise_code_postal or "").strip()
|
||||
loc = (fiche.entreprise_localite or "").strip()
|
||||
npa_ville = f"{cp} {loc}".strip()
|
||||
# Destinataire : représentant légal si mineur, sinon l'apprenti lui-même.
|
||||
# L'adresse de l'entreprise n'est plus utilisée.
|
||||
dest_nom, dest_adresse, dest_npa_ville = _destinataire(apprenti, fiche)
|
||||
|
||||
field_values: dict[str, str] = {
|
||||
"NomApprenti": f"{apprenti.prenom} {apprenti.nom}".strip(),
|
||||
"Classe": apprenti.classe or "",
|
||||
"NomParents": (fiche.entreprise_nom if fiche else "") or "",
|
||||
"Adresse": (fiche.entreprise_adresse if fiche else "") or "",
|
||||
"NPA-Ville": npa_ville,
|
||||
"NomParents": dest_nom,
|
||||
"Adresse": dest_adresse,
|
||||
"NPA-Ville": dest_npa_ville,
|
||||
"Date": date.today().strftime("%d.%m.%Y"),
|
||||
"TexteDescription": (
|
||||
(texte_override or "").strip()
|
||||
|
|
|
|||
|
|
@ -28,9 +28,13 @@ def _load_user(username: str) -> Optional[dict]:
|
|||
def get_allowed_classes(username: str) -> Optional[list[str]]:
|
||||
"""Retourne la liste des classes autorisées pour l'utilisateur.
|
||||
|
||||
- None : aucune restriction (admin, ou champ vide / absent)
|
||||
- [] : restriction explicite à zéro classe (= ne voit rien)
|
||||
- None : aucune restriction (admin uniquement)
|
||||
- [] : restriction à zéro classe (= ne voit rien) — défaut pour user
|
||||
- [...] : restreint à ces classes
|
||||
|
||||
Sémantique 2026-05-11 : un user (rôle != admin) sans `allowed_classes`
|
||||
configuré n'a accès à AUCUNE classe. Il doit s'enrôler via /profile
|
||||
ou recevoir un accès manuel via /users.
|
||||
"""
|
||||
user = _load_user(username)
|
||||
if not user:
|
||||
|
|
@ -39,8 +43,7 @@ def get_allowed_classes(username: str) -> Optional[list[str]]:
|
|||
return None
|
||||
allowed = user.get("allowed_classes")
|
||||
if allowed is None:
|
||||
return None
|
||||
# `allowed_classes: []` (présent mais vide) signifie « aucun accès »
|
||||
return [] # ← user sans config = aucun accès
|
||||
return list(allowed)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue