From f1190566a66b54310782343ebf83b2804212dbe7 Mon Sep 17 00:00:00 2001 From: Julien Balet Date: Tue, 12 May 2026 15:30:28 +0200 Subject: [PATCH] Initial version --- .dockerignore | 12 +- TODO.md | 25 +- assets/responsive.css | 9 + data/VERSION | 1 + data/auth.yaml | 21 ++ data/class_href_cache.json | 22 +- data/docs/01-overview.md | 67 +++- data/docs/02-sync-escada.md | 50 ++- data/docs/03-push-escada.md | 64 +++- data/docs/04-edition-absences.md | 76 +++- data/docs/05-cron.md | 46 ++- data/docs/07-auth.md | 109 ++++-- data/docs/10-faq.md | 109 ++++-- data/docs/11-avis-sanction-retenue.md | 83 +++++ data/docs/12-feedback.md | 80 +++++ data/docs/13-parametres.md | 99 +++++ data/settings.json | 496 +++++++++++++++++++++++++- eptm_dashboard/eptm_dashboard.py | 8 +- eptm_dashboard/pages/accueil.py | 20 +- eptm_dashboard/pages/classe.py | 146 ++++++-- eptm_dashboard/pages/cron.py | 247 +++++++------ eptm_dashboard/pages/feedback.py | 8 +- eptm_dashboard/pages/fiche.py | 106 +++++- eptm_dashboard/pages/params.py | 212 ++++++++++- eptm_dashboard/pages/profile.py | 300 ++++++++++++++++ eptm_dashboard/pages/retenue.py | 7 +- eptm_dashboard/pages/sanction.py | 6 +- eptm_dashboard/pages/users.py | 64 +++- eptm_dashboard/sidebar.py | 51 ++- eptm_dashboard/state.py | 37 ++ scripts/cron_tick.py | 24 +- scripts/fetch_user_classes.py | 278 +++++++++++++++ src/db.py | 51 ++- src/retenue_pdf.py | 42 ++- src/sanction_pdf.py | 44 ++- src/user_access.py | 11 +- 36 files changed, 2662 insertions(+), 369 deletions(-) create mode 100644 data/VERSION create mode 100644 data/docs/11-avis-sanction-retenue.md create mode 100644 data/docs/12-feedback.md create mode 100644 data/docs/13-parametres.md create mode 100644 scripts/fetch_user_classes.py diff --git a/.dockerignore b/.dockerignore index 7a019ab..3eb161b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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/ diff --git a/TODO.md b/TODO.md index 9b0205e..a894f6d 100644 --- a/TODO.md +++ b/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 diff --git a/assets/responsive.css b/assets/responsive.css index 7dfd3e3..e13c516 100644 --- a/assets/responsive.css +++ b/assets/responsive.css @@ -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; } diff --git a/data/VERSION b/data/VERSION new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/data/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/data/auth.yaml b/data/auth.yaml index 852d755..0e95f70 100644 --- a/data/auth.yaml +++ b/data/auth.yaml @@ -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 diff --git a/data/class_href_cache.json b/data/class_href_cache.json index 6b0065a..3b1ad99 100644 --- a/data/class_href_cache.json +++ b/data/class_href_cache.json @@ -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" } \ No newline at end of file diff --git a/data/docs/01-overview.md b/data/docs/01-overview.md index 787231d..327f93d 100644 --- a/data/docs/01-overview.md +++ b/data/docs/01-overview.md @@ -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. diff --git a/data/docs/02-sync-escada.md b/data/docs/02-sync-escada.md index f670885..a913a0d 100644 --- a/data/docs/02-sync-escada.md +++ b/data/docs/02-sync-escada.md @@ -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. diff --git a/data/docs/03-push-escada.md b/data/docs/03-push-escada.md index 1b9c4e3..2779467 100644 --- a/data/docs/03-push-escada.md +++ b/data/docs/03-push-escada.md @@ -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 | +| 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 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 `()` 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](#). diff --git a/data/docs/04-edition-absences.md b/data/docs/04-edition-absences.md index e083199..ba9767a 100644 --- a/data/docs/04-edition-absences.md +++ b/data/docs/04-edition-absences.md @@ -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 diff --git a/data/docs/05-cron.md b/data/docs/05-cron.md index c064ebb..92b58b4 100644 --- a/data/docs/05-cron.md +++ b/data/docs/05-cron.md @@ -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. diff --git a/data/docs/07-auth.md b/data/docs/07-auth.md index df3208f..cea599c 100644 --- a/data/docs/07-auth.md +++ b/data/docs/07-auth.md @@ -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" + 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 + 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 `/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:]`) → 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 diff --git a/data/docs/10-faq.md b/data/docs/10-faq.md index 564eee0..695eedd 100644 --- a/data/docs/10-faq.md +++ b/data/docs/10-faq.md @@ -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:]`) 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. diff --git a/data/docs/11-avis-sanction-retenue.md b/data/docs/11-avis-sanction-retenue.md new file mode 100644 index 0000000..fad5bc1 --- /dev/null +++ b/data/docs/11-avis-sanction-retenue.md @@ -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** : `() ` 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 `() `) +- **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` = `() ` — 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 +``` diff --git a/data/docs/12-feedback.md b/data/docs/12-feedback.md new file mode 100644 index 0000000..8b497a1 --- /dev/null +++ b/data/docs/12-feedback.md @@ -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 : + - `` global + - `` + `translate="no"` injecté au boot + - Classe `notranslate` + `custom_attrs={"translate": "no"}` sur les composants concernés diff --git a/data/docs/13-parametres.md b/data/docs/13-parametres.md new file mode 100644 index 0000000..330b796 --- /dev/null +++ b/data/docs/13-parametres.md @@ -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 ", + "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. diff --git a/data/settings.json b/data/settings.json index 8510c13..1e03206 100644 --- a/data/settings.json +++ b/data/settings.json @@ -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": [] + } + } + } } \ No newline at end of file diff --git a/eptm_dashboard/eptm_dashboard.py b/eptm_dashboard/eptm_dashboard.py index 0076528..95ee450 100644 --- a/eptm_dashboard/eptm_dashboard.py +++ b/eptm_dashboard/eptm_dashboard.py @@ -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) {} })(); """ diff --git a/eptm_dashboard/pages/accueil.py b/eptm_dashboard/pages/accueil.py index 9c19128..25b3308 100644 --- a/eptm_dashboard/pages/accueil.py +++ b/eptm_dashboard/pages/accueil.py @@ -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", ) diff --git a/eptm_dashboard/pages/classe.py b/eptm_dashboard/pages/classe.py index 33945d5..43b43eb 100644 --- a/eptm_dashboard/pages/classe.py +++ b/eptm_dashboard/pages/classe.py @@ -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)"), - rx.cond( - item["quota_atteint"], - _kpi_mini("Absences", item["blocs"], "var(--brand-primary-dark)"), - _kpi_mini("Absences", item["blocs"]), + _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"], + rx.text( + "Avis de sanction", + size="1", weight="bold", color="#c62828", + ), + ), + padding="1rem", + background_color=rx.cond(item["quota_atteint"], "#fff0f0", "var(--surface)"), + border_radius="8px", + border=rx.cond( + item["quota_atteint"], + "1px solid #ffcdd2", + "1px solid var(--border)", + ), + flex="1", + min_width="120px", ), - gap="0.5rem", + gap="1rem", flex_wrap="wrap", + width="100%", margin_bottom="0.75rem", ), - # ── Boutons téléchargement PDF ──────────────────────────────────────── - 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", - ), - rx.cond( - item["has_pdf_bn"], + # ── Actions (PDF exports + créations d'avis) ────────────────────────── + rx.box( + rx.flex( rx.button( - rx.icon("file-text", size=13), - "PDF bulletin", - on_click=ClasseState.download_bn_pdf(item["id"]), - variant="outline", - color_scheme="blue", - size="1", + rx.icon("download", size=13), + "PDF absences", + on_click=ClasseState.download_abs_pdf(item["id"]), + variant="outline", color_scheme="gray", size="2", + ), + rx.cond( + item["has_pdf_bn"], + rx.button( + rx.icon("download", size=13), + "PDF bulletin", + on_click=ClasseState.download_bn_pdf(item["id"]), + variant="outline", color_scheme="blue", size="2", + ), + ), + rx.cond( + item["has_pdf_notes"], + rx.button( + rx.icon("download", size=13), + "PDF notes", + on_click=ClasseState.download_notes_pdf(item["id"]), + variant="outline", color_scheme="violet", size="2", + ), + ), + # Séparateur visuel + rx.box( + width="1px", + background_color="var(--gray-6)", + margin_x="0.25rem", + align_self="stretch", ), - ), - rx.cond( - item["has_pdf_notes"], rx.button( - rx.icon("file-text", size=13), - "PDF notes", - on_click=ClasseState.download_notes_pdf(item["id"]), - variant="outline", - color_scheme="violet", - size="1", + 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%", ), - flex_wrap="wrap", - gap="0.5rem", + 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, diff --git a/eptm_dashboard/pages/cron.py b/eptm_dashboard/pages/cron.py index 3601038..f4ddfaf 100644 --- a/eptm_dashboard/pages/cron.py +++ b/eptm_dashboard/pages/cron.py @@ -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": - 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) + 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: + 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,65 +622,41 @@ 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", + CronState.f_schedule_kind == "weekly", + rx.vstack( + rx.flex( + *[ + rx.box( + rx.text(_DAY_LABELS[d], size="1", weight="bold"), + on_click=CronState.toggle_f_day(d), + cursor="pointer", + padding="0.35rem 0.7rem", + border_radius="6px", + border="2px solid", + border_color=rx.cond( + CronState.f_days.contains(d), + "var(--red-9)", "var(--gray-6)", + ), + background_color=rx.cond( + CronState.f_days.contains(d), + "var(--red-9)", "transparent", + ), + color=rx.cond( + CronState.f_days.contains(d), + "white", "var(--gray-12)", + ), + ) + for d in _DAY_NAMES + ], + gap="0.3rem", + wrap="wrap", ), - rx.text("minutes", size="2"), - spacing="2", align="center", - ), - rx.cond( - CronState.f_schedule_kind == "weekly", - rx.vstack( - rx.flex( - *[ - rx.box( - rx.text(_DAY_LABELS[d], size="1", weight="bold"), - on_click=CronState.toggle_f_day(d), - cursor="pointer", - padding="0.35rem 0.7rem", - border_radius="6px", - border="2px solid", - border_color=rx.cond( - CronState.f_days.contains(d), - "var(--red-9)", "var(--gray-6)", - ), - background_color=rx.cond( - CronState.f_days.contains(d), - "var(--red-9)", "transparent", - ), - color=rx.cond( - CronState.f_days.contains(d), - "white", "var(--gray-12)", - ), - ) - for d in _DAY_NAMES - ], - gap="0.3rem", - wrap="wrap", - ), - rx.hstack( - rx.text("Heure :", size="2"), - rx.input(value=CronState.f_time_hh, - on_change=CronState.set_f_time_hh, width="60px"), - rx.text(":", size="3"), - rx.input(value=CronState.f_time_mm, - on_change=CronState.set_f_time_mm, width="60px"), - spacing="2", align="center", - ), - spacing="2", - ), - # daily rx.hstack( rx.text("Heure :", size="2"), rx.input(value=CronState.f_time_hh, @@ -653,7 +666,9 @@ def _form_schedule_picker() -> rx.Component: on_change=CronState.set_f_time_mm, width="60px"), spacing="2", align="center", ), + spacing="2", ), + _hours_grid(), ), spacing="2", width="100%", ) diff --git a/eptm_dashboard/pages/feedback.py b/eptm_dashboard/pages/feedback.py index ced6ff0..b72e84f 100644 --- a/eptm_dashboard/pages/feedback.py +++ b/eptm_dashboard/pages/feedback.py @@ -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( diff --git a/eptm_dashboard/pages/fiche.py b/eptm_dashboard/pages/fiche.py index c9c53ee..cd7d4fb 100644 --- a/eptm_dashboard/pages/fiche.py +++ b/eptm_dashboard/pages/fiche.py @@ -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, diff --git a/eptm_dashboard/pages/params.py b/eptm_dashboard/pages/params.py index 9b57639..387f7c3 100644 --- a/eptm_dashboard/pages/params.py +++ b/eptm_dashboard/pages/params.py @@ -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(), diff --git a/eptm_dashboard/pages/profile.py b/eptm_dashboard/pages/profile.py index 9838b38..57113f2 100644 --- a/eptm_dashboard/pages/profile.py +++ b/eptm_dashboard/pages/profile.py @@ -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", diff --git a/eptm_dashboard/pages/retenue.py b/eptm_dashboard/pages/retenue.py index e9af4c5..7e472ab 100644 --- a/eptm_dashboard/pages/retenue.py +++ b/eptm_dashboard/pages/retenue.py @@ -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", diff --git a/eptm_dashboard/pages/sanction.py b/eptm_dashboard/pages/sanction.py index e2f7407..e05450a 100644 --- a/eptm_dashboard/pages/sanction.py +++ b/eptm_dashboard/pages/sanction.py @@ -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( diff --git a/eptm_dashboard/pages/users.py b/eptm_dashboard/pages/users.py index c4cdebb..d1290e6 100644 --- a/eptm_dashboard/pages/users.py +++ b/eptm_dashboard/pages/users.py @@ -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", diff --git a/eptm_dashboard/sidebar.py b/eptm_dashboard/sidebar.py index 78258af..9ca33bc 100644 --- a/eptm_dashboard/sidebar.py +++ b/eptm_dashboard/sidebar.py @@ -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'. + 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", diff --git a/eptm_dashboard/state.py b/eptm_dashboard/state.py index 5cb8d14..21fa7ee 100644 --- a/eptm_dashboard/state.py +++ b/eptm_dashboard/state.py @@ -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 = "" diff --git a/scripts/cron_tick.py b/scripts/cron_tick.py index 4a1b858..8f9aeab 100755 --- a/scripts/cron_tick.py +++ b/scripts/cron_tick.py @@ -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: - return True - return (now - last).total_seconds() >= minutes * 60 - - if job.schedule_kind == "daily": - # schedule_value = "HH:MM" - return _due_time_of_day(job.schedule_value, last, now) + if job.schedule_kind == "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 False if job.schedule_kind == "weekly": # schedule_value = "MON,WED,FRI:HH:MM" diff --git a/scripts/fetch_user_classes.py b/scripts/fetch_user_classes.py new file mode 100644 index 0000000..9e35e54 --- /dev/null +++ b/scripts/fetch_user_classes.py @@ -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 + +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_.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 ", 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() diff --git a/src/db.py b/src/db.py index 2889997..1ee351e 100644 --- a/src/db.py +++ b/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" - # 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 ∈ {"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" + 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 diff --git a/src/retenue_pdf.py b/src/retenue_pdf.py index 1c7bb73..cc67326 100644 --- a/src/retenue_pdf.py +++ b/src/retenue_pdf.py @@ -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, diff --git a/src/sanction_pdf.py b/src/sanction_pdf.py index 5f5a9bc..3a35ccf 100644 --- a/src/sanction_pdf.py +++ b/src/sanction_pdf.py @@ -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() diff --git a/src/user_access.py b/src/user_access.py index 16274c4..b05c175 100644 --- a/src/user_access.py +++ b/src/user_access.py @@ -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)