From 7d3b6e9136b099187fa3be0987fc10779708721d Mon Sep 17 00:00:00 2001 From: Julien Balet Date: Wed, 13 May 2026 09:11:39 +0200 Subject: [PATCH] =?UTF-8?q?v1.1.0=20=E2=80=94=20fixes=20sync=20+=20UX=20de?= =?UTF-8?q?v/prod?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sync push_then_sync : préserve les absences 'publiee_escada' contre écrasement/orphelines après push (PDF Escada stale). UI reconnaît le statut (calendrier, éditeur, KPIs) au lieu d'afficher 'présent'. Sync_esacada : timeout grille 20s → 45s + retry après reload (AUTOMAT 1 échouait à la 1re classe après changement de langue). Telegram : ajoute liste d'erreurs + tail du log dans les notifs d'échec même en mode normal — avant on avait juste 'a échoué (code 1)'. UX : - Calendrier toujours visible (même sans absences) et démarre sur le mois courant (pas sur le 1er mois d'absence) ; tous les jours cliquables pour pouvoir ajouter une absence. - Date du jour pré-sélectionnée aussi via navigate_to (clic depuis /classe). - KPIs cards taggées kpi-card/kpi-value pour CSS responsive mobile. - Badge 'DEV' dans la sidebar (APP_ENV=dev) — invisible en prod. - Badge 'Built with Reflex' masqué. - KPIs retirés du dashboard /accueil. Prod : - Dockerfile.prod multi-stage (Reflex export bundle + runtime slim). - docker-compose.prod.yml séparé (port 3002, projet eptm-dashboard-prod). - .gitignore + .dockerignore nettoyés. Co-Authored-By: Claude Opus 4.7 (1M context) --- .dockerignore | 20 +++-- .env.prod | 11 +++ .gitignore | 11 +++ DEPLOY_PROD.md | 84 ++++++++++++++++++--- Dockerfile.prod | 78 +++++++++++++++++++ TODO.md | 32 +++----- assets/responsive.css | 23 ++++++ data/auth_tokens.json | 1 - data/class_href_cache.json | 46 ------------ data/esacada_classes.json | 1 - data/esacada_last_sel.json | 1 - data/last_sync.json | 1 - docker-compose.dev.yml | 10 +++ docker-compose.prod.yml | 45 +++++++++++ eptm_dashboard/components.py | 7 +- eptm_dashboard/pages/accueil.py | 21 +----- eptm_dashboard/pages/classe.py | 18 +++-- eptm_dashboard/pages/fiche.py | 129 +++++++++++++++++++------------- eptm_dashboard/sidebar.py | 43 +++++++++++ scripts/sync_esacada.py | 29 +++++-- src/importer.py | 23 ++++++ src/notifier.py | 63 ++++++++++++++-- src/retenue_pdf.py | 6 +- src/sanction_pdf.py | 6 +- 24 files changed, 525 insertions(+), 184 deletions(-) create mode 100644 Dockerfile.prod delete mode 100644 data/auth_tokens.json delete mode 100644 data/class_href_cache.json delete mode 100644 data/esacada_classes.json delete mode 100644 data/esacada_last_sel.json delete mode 100644 data/last_sync.json create mode 100644 docker-compose.prod.yml diff --git a/.dockerignore b/.dockerignore index 3eb161b..e4da021 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,19 @@ .web/ __pycache__/ .venv/ -data/browser_profile/ -data/cache/ -data/*.db -data/*.db-* -logs/ .git/ + +# Runtime — propre à chaque environnement (mounts compose) +data/ +data_prod/ +logs/ + +# Backups / fichiers locaux +*.back +docker-compose.yml.back +Dockerfile.back + +# Caches divers +**/__pycache__/ +**/*.pyc +**/.pytest_cache/ diff --git a/.env.prod b/.env.prod index 42512bd..e3420e0 100644 --- a/.env.prod +++ b/.env.prod @@ -1,3 +1,14 @@ +# Variables d'environnement partagées entre stack dev et stack prod. +# Les valeurs sensibles spécifiques (SMTP, Escada creds, etc.) vivent dans +# data/settings.json (dev) ou data_prod/settings.json (prod), pas ici. + +# Clé de signature des cookies / state Reflex. NE PAS partager / régénérer. REFLEX_SECRET_KEY=af16a3c0a6f2a94583ebd704f4e9716743abe27c10e8837633274d08441c20c2 + +# Bot Telegram pour les notifications cron (commun aux deux stacks par défaut ; +# le chat_id de chaque CronJob peut surcharger via la colonne notify_chat_id). TELEGRAM_BOT_TOKEN=8659950969:AAEpc3sl34txpsHyYC5-3rnfgVnkEuQoU_Q TELEGRAM_CHAT_ID=-4992234358 + +# Timezone — surchargée par docker-compose.* mais utile pour les scripts CLI. +TZ=Europe/Zurich diff --git a/.gitignore b/.gitignore index c8552ac..1beb198 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,17 @@ data/sync_*.json data/debug_*.png data/*.bak.* data/password_tokens.json +data/class_href_cache.json +data/esacada_*.json +data/last_sync.json +data/auth_tokens.json # Logs cron (runtime) logs/ + +# Stack prod — runtime +data_prod/ +logs-prod/ + +# Backups +*.back diff --git a/DEPLOY_PROD.md b/DEPLOY_PROD.md index ac7a252..26e2c1e 100644 --- a/DEPLOY_PROD.md +++ b/DEPLOY_PROD.md @@ -2,7 +2,10 @@ Procédure de bascule de l'app **Streamlit legacy** (`absences.service`, `dashboard.eptm-automation.ch`) vers l'app **Reflex** (en prod sur le -même sous-domaine). Document à retravailler avant exécution. +même sous-domaine). + +**État : stack prod construite et démarrée le 2026-05-12. Cutover NPM +encore à faire (cf. § « Étape manuelle restante »).** --- @@ -48,27 +51,86 @@ Internet :80/:443 → NPM - Streamlit `absences.service` arrêté + disabled (binaires gardés en backup quelques semaines, suppression plus tard). -- Deux stacks compose côte à côte sur `proxy_net`, ports internes - distincts (3001/8001 dev, 3002/8002 prod), aucun port host exposé. +- Deux stacks compose côte à côte sur `proxy_net` : + - dev : 3001 (frontend Vite) + 8001 (backend granian) — séparés car HMR + - prod : **3002** uniquement (Reflex 0.9+ exige frontend + backend même + port en prod ; granian sert tout depuis 3002, y compris `/_event` en WS) +- Projets compose nommés : `eptm-dashboard-dev` et `eptm-dashboard-prod` + (pour éviter que `compose up` d'une stack recrée le container de l'autre). +- Aucun port host exposé — tout passe par NPM sur `proxy_net`. - NPM continue de gérer les certs Let's Encrypt (déjà émis pour ce sous-domaine, juste à conserver — pas de renouvellement à forcer). --- -## ⚠️ À clarifier avant exécution +## ✅ Décisions prises (2026-05-12) | # | Question | Hypothèse par défaut | |---|---|---| -| 1 | **Données** : prod partage `./data` avec dev ou stack séparée `./data-prod` ? | **Séparer** (`./data-prod`) — sinon un test en dev peut planter la prod (WAL OK mais migrations destructives non) | -| 2 | **Premier remplissage de la DB prod** : repartir d'un import frais (run_imports + sync_escada) ou copier `data/eptm.db` actuel ? | **Copier** la DB de dev au moment du cutover (snapshot cohérent) | -| 3 | **Streamlit après cutover** : on garde le service en `disabled` qq semaines, ou on purge `/opt/absences` ? | **Garder disabled** ~1 mois (rollback plan B), purge en tâche TODO | -| 4 | **Build** : local sur le serveur ou registry (GHCR / Docker Hub) ? | **Local** sur le serveur — pas de CI pour démarrer, on n'est qu'une instance | -| 5 | **Downtime acceptable** au cutover ? | ~30-60s pendant que NPM bascule de proxy host. Pas de zero-downtime | -| 6 | **`.env.prod`** : tester avec env identique à dev ? | À auditer ensemble (peut contenir `escada_username/password`, SMTP, etc.) | +| # | Question | Décision | +|---|---|---| +| 1 | Données prod | **Isolées** : `./data_prod/` séparé de `./data/` | +| 2 | DB initiale prod | **Copie** de `data/absences.db` au cutover | +| 3 | Streamlit après cutover | **Disabled** ~1 mois (rollback plan B), purge en tâche TODO | +| 4 | Build | **Local** sur le serveur (pas de CI / registry pour démarrer) | +| 5 | Anciens `Dockerfile` + `docker-compose.yml` | **Renommés `.back`**, nouveaux fichiers `.prod` créés à côté | +| 6 | `.env.prod` | **Minimal** : `REFLEX_SECRET_KEY` + Telegram bot + `TZ`. SMTP/Escada/etc. restent dans `settings.json` | +| 7 | Emplacement `data_prod/` | `/opt/eptm-dashboard/data_prod/` (gitignored) | +| 8 | `browser_profile/` côté prod | **Non copié** — session SSO à refaire au 1er sync (Chromium non-headless une fois) | +| 9 | Templates AcroForm + docs `.md` | **Déménagés** depuis `data/` vers le repo (`./templates/` et `./docs/`) → propagés par `COPY .` du Dockerfile, plus besoin de sync manuel | +| 10 | Crontab prod | **Séparée** de la dev (lignes distinctes pour les deux containers) | --- -## Fichiers à créer +## Étape manuelle restante : cutover NPM + Streamlit + +L'image prod est buildée, le container `eptm-dashboard-prod-app-1` tourne sur +`proxy_net:3002`. Pour basculer `dashboard.eptm-automation.ch` : + +### 1. Reconfigurer NPM (UI sur `https://npm.eptm-automation.ch:81`) + +Sur le **proxy host** existant `dashboard.eptm-automation.ch` (qui pointe +actuellement sur Streamlit `172.17.0.1:8501`) : + +- **Forward Hostname / IP** : `eptm-dashboard-prod-app-1` +- **Forward Port** : `3002` +- **Cache Assets** : décoché (Reflex bundle Vite est déjà optimisé) +- **Block Common Exploits** : OK +- **Websockets Support** : ✅ **coché** (indispensable — Reflex utilise WS) +- **Onglet SSL** : conserver le certificat Let's Encrypt déjà émis, Force SSL + ON, HSTS ON, HTTP/2 ON + +> ⚠️ Pas besoin de custom location `/_event` cette fois (à la différence du +> dev) : en prod le backend et le frontend sont sur le même port (3002). + +### 2. Stopper Streamlit + +```bash +sudo systemctl stop absences +sudo systemctl disable absences # rollback toujours possible avec `enable + start` +``` + +### 3. Vérifier + +```bash +curl -fsS -o /dev/null -w "HTTP %{http_code}\n" https://dashboard.eptm-automation.ch/ +# → 200 attendu, server: granian dans les headers +``` + +### 4. Ajouter une ligne crontab pour les cron_tick prod + +```bash +crontab -e +# Ajouter : +* * * * * docker exec eptm-dashboard-prod-app-1 python /app/scripts/cron_tick.py >> /var/log/cron-prod.log 2>&1 +``` + +(La ligne dev existante reste — les deux containers exécutent leur propre +cron_tick avec leur propre table `cron_jobs`.) + +--- + +## Fichiers créés (déjà en place) ### 1. `Dockerfile.prod` (multi-stage) diff --git a/Dockerfile.prod b/Dockerfile.prod new file mode 100644 index 0000000..db9ff5a --- /dev/null +++ b/Dockerfile.prod @@ -0,0 +1,78 @@ +# Dockerfile.prod — image immuable pour la stack prod EPTM Dashboard. +# +# Multi-stage : +# 1. builder : installe deps Python + Node, exporte le frontend Reflex. +# 2. runtime : Python slim, sans Node, sans cache npm, prêt à servir. +# +# Build : docker compose -f docker-compose.prod.yml build app + +# ───────────────────────────────────────────────────────────────────────────── +# Stage 1 — builder +FROM python:3.13 AS builder +WORKDIR /app + +# Outils nécessaires au bundle frontend (Node + npm sont requis par `reflex export`). +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl gnupg unzip xvfb ca-certificates && \ + curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \ + apt-get install -y nodejs && \ + rm -rf /var/lib/apt/lists/* + +# Dépendances Python (installées dans le user-site pour pouvoir les copier +# proprement dans le stage 2 — évite de réinstaller pip dans le runtime). +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt && \ + pip install --no-cache-dir \ + pdfplumber sqlalchemy plotly pandas openpyxl bcrypt pyyaml pypdf \ + pyotp "qrcode[pil]" reportlab playwright markdown + +# Playwright : navigateur installé en system-wide pour le stage 2. +RUN playwright install --with-deps chromium + +# Code applicatif + assets statiques + docs + templates AcroForm. +COPY . . + +# `reflex init` prépare la conf locale (.web/, alembic, etc.). +# `reflex export --frontend-only --no-zip` génère le bundle Vite statique +# dans .web/build/ — c'est ce que servira le backend en prod. +RUN reflex init && \ + reflex export --frontend-only --no-zip + +# ───────────────────────────────────────────────────────────────────────────── +# Stage 2 — runtime +FROM python:3.13 +WORKDIR /app + +# Runtime allégé : Node n'est PAS réinstallé (`reflex export` a déjà créé le +# bundle). On garde curl pour les healthchecks et ca-certificates pour SMTP/HTTPS. +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl ca-certificates tzdata && \ + rm -rf /var/lib/apt/lists/* + +# Copie les Python deps installées dans le builder. +COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages +COPY --from=builder /usr/local/bin /usr/local/bin + +# Playwright browsers (cache global déjà téléchargé dans le builder). +COPY --from=builder /root/.cache/ms-playwright /root/.cache/ms-playwright + +# Libs système requises par Chromium headless (libnspr4, libnss3, fonts, etc.). +# Sans ça, le binaire chromium-headless-shell ne charge pas et la sync Escada +# meurt avec "error while loading shared libraries: libnspr4.so". +RUN apt-get update && \ + playwright install-deps chromium && \ + rm -rf /var/lib/apt/lists/* + +# Copie l'app entièrement déjà bundlée (avec .web/ + reflex.json + etc.). +COPY --from=builder /app /app + +# Reflex 0.9+ exige un seul port en prod (frontend statique + backend granian). +# 3002 choisi pour cohabiter avec dev (3001 frontend Vite + 8001 backend). +ENV FRONTEND_PORT=3002 BACKEND_PORT=3002 \ + TZ=Europe/Zurich \ + PYTHONUNBUFFERED=1 + +EXPOSE 3002 + +# `reflex run --env prod` lance backend granian + sert le frontend depuis .web/build. +CMD ["reflex", "run", "--env", "prod"] diff --git a/TODO.md b/TODO.md index a894f6d..245312e 100644 --- a/TODO.md +++ b/TODO.md @@ -6,39 +6,25 @@ en haut de la section concernée. ## Idées / fonctionnalités -- [X] Afficher toutes les notes du BN -- [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 -- [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) -- [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 + +- [ ] Rercher aussi les accentes dans les noms apprenti +- [ ] Permettre de faire haut/bas avec les flèches dans la liste classes/apprentis +- [ ] Ajouté envoie de mail au secrétariat pour les avis de retenue +- [ ] Ajouter l'envoie automatique des absences à l'apprenti + ## Bugs connus - [ ] Dans les logs, en mode Live, la fenêtre ne défile pas toute seule au fond. Mettre les dernières lignes en premier? -- [X] Liens entre apprenti sur classe vers apprenti ne fonctionne pas + ## Améliorations UX - [ ] 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 + +- [ ] Faire séléctionner la date du jour dans le tableau des absences diff --git a/assets/responsive.css b/assets/responsive.css index e13c516..70d011b 100644 --- a/assets/responsive.css +++ b/assets/responsive.css @@ -62,6 +62,12 @@ button[title="Signaler un bug ou proposer une idée"] svg { animation: feedback-pulse 1.5s ease-in-out infinite; } +/* Masque le badge "Built with Reflex" injecté par défaut en mode prod + (lien en bas-droite). On le retire pour l'app interne EPTM. */ +a[href="https://reflex.dev"][target="_blank"] { + display: none !important; +} + /* ── Brand tokens (thèmes utilisateur) ─────────────────────────────────────── Tokens utilisés par l'app pour les couleurs de marque. Chaque thème surcharge @@ -288,6 +294,23 @@ body, html { min-width: 0 !important; flex: 1 1 100% !important; } + + /* KPI cards (Périodes d'absence / à excuser / Absences) — sur mobile on + réduit la taille de la valeur, le padding et le min-width pour que les + 3 cartes tiennent sur la même ligne même sur un petit écran. */ + .kpi-card { + padding: 0.55rem 0.6rem !important; + min-width: 0 !important; + flex: 1 1 0 !important; + } + .kpi-value { + font-size: 1.15rem !important; + line-height: 1.2 !important; + } + .kpi-label { + font-size: 0.7rem !important; + line-height: 1 !important; + } } /* Tablet */ diff --git a/data/auth_tokens.json b/data/auth_tokens.json deleted file mode 100644 index dcfab47..0000000 --- a/data/auth_tokens.json +++ /dev/null @@ -1 +0,0 @@ -{"47cf7919929b481bb5c083a4435f5383": {"username": "julbal", "name": "Julien Balet", "ts": 1778154736.649939}, "929ed6d889234f27bc54406b6eb617d6": {"username": "julbal", "name": "Julien Balet", "ts": 1778155854.202958}} \ No newline at end of file diff --git a/data/class_href_cache.json b/data/class_href_cache.json deleted file mode 100644 index 3b1ad99..0000000 --- a/data/class_href_cache.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "CFTI-AU 1A:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=4ec9bbbd-7d12-4073-9fd3-ac275dd0894e", - "CFTI-AU 1B:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=e960b23a-088d-4b57-9a09-3955c899b264", - "CFTI-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=e62f261c-736f-4d71-9392-cd42b36088b2", - "EM-AU 1A:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=545536ad-71b5-45bd-81c9-408b4a75d6aa", - "EM-AU 1B:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=19c5ad0e-db24-437d-8976-b998f13da902", - "EM-AU 2A:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=e8a84837-ea42-4872-bbd1-362d0eb10775", - "EM-AU 2B:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=047a36ce-b8e1-40ae-9ca1-358edfee933c", - "EM-AU 3A:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=eef4f04b-7f26-4a4b-87d5-3129a22b4f15", - "EM-AU 3B:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=4f51056e-ec72-4101-b6de-a2b4246632fb", - "MI-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=fb412b92-9458-4ca9-8c76-718889c0bd23", - "MI-AU CG 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=314dcc1e-f4e3-43da-aa7d-f817c3db80be", - "MI-AU CG 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=435f31f9-4f3a-4755-825a-55df5ff8a571", - "MP1-TASV 1A:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=a15d17d9-2a6d-4872-9b78-8b51bbf7215a", - "MP1-TASV 1B:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=3192aa8f-6137-4187-811a-df247c4f3f14", - "MP1-TASV 1C:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=0242982b-45ae-4059-9cdb-6eda123f60e4", - "MP1-TASV 1D:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=7404bc4d-e559-4961-83f3-28d90dcb2112", - "MP1-TASV 1E:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=94f976f1-1d81-4097-9072-b8601c058cde", - "MP1-TASV 2A:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=df9859d8-5c04-4600-b73f-22b8a8e06992", - "MP1-TASV 2B:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=7f11d6d5-bef6-4d5d-a039-a0bb49b85de4", - "MP1-TASV 2C:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=82525d35-26a9-4320-9551-37b4dd0ed479", - "MP1-TASV 2D:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=21be0157-f248-47af-ab95-027995a0269e", - "MP1-TASV 2E:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=21e967af-520f-46d2-874f-5dbd61ef24b1", - "MP1-TASV 3A:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=80d5e7d6-ff54-4ce8-8b32-677b0fdf9a74", - "MP1-TASV 3B:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=84de9bd4-949c-4472-97e1-0508a3034c6f", - "MP1-TASV 3C:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=f1b20579-73dc-499a-a4cd-5709fcfc56ab", - "MP1-TASV 3D:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=88e89d2b-e754-4501-9ba5-c56e1c14818e", - "MP1-TASV 3E:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=14627c7d-1da2-45ce-b930-1c2de3af25bb", - "MP1-TASV 4A:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=f5fa281e-6a7b-4eed-b433-6e92f1ea61fc", - "MP1-TASV 4B:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=62596263-cbb3-442a-a390-9279cceed4df", - "MP1-TASV 4C:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=1fdeb3aa-4f39-4382-ae98-1784c99a7f51", - "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=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/esacada_classes.json b/data/esacada_classes.json deleted file mode 100644 index 66cf2bf..0000000 --- a/data/esacada_classes.json +++ /dev/null @@ -1 +0,0 @@ -["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", "Formation", "MI-AU 2", "MI-AU CG 3", "MI-AU CG 4", "MONTAUT 1", "MONTAUT 2", "MONTAUT 3", "MP1-TASV 1A", "MP1-TASV 1B", "MP1-TASV 1C", "MP1-TASV 1D", "MP1-TASV 1E", "MP1-TASV 2A", "MP1-TASV 2B", "MP1-TASV 2C", "MP1-TASV 2D", "MP1-TASV 2E", "MP1-TASV 3A", "MP1-TASV 3B", "MP1-TASV 3C", "MP1-TASV 3D", "MP1-TASV 3E", "MP1-TASV 4A", "MP1-TASV 4B", "MP1-TASV 4C", "MP1-TASV 4D", "MP1-TASV 4E", "Z-IT Test 1"] \ No newline at end of file diff --git a/data/esacada_last_sel.json b/data/esacada_last_sel.json deleted file mode 100644 index 9677bf7..0000000 --- a/data/esacada_last_sel.json +++ /dev/null @@ -1 +0,0 @@ -["AUTOMAT 1"] \ No newline at end of file diff --git a/data/last_sync.json b/data/last_sync.json deleted file mode 100644 index fcecb1c..0000000 --- a/data/last_sync.json +++ /dev/null @@ -1 +0,0 @@ -{"timestamp": "2026-05-07T13:27:56.158132", "files": ["esacada_AUTOMAT_1.pdf", "bn_AUTOMAT_1.pdf", "matu_MP1-TASV_1A.pdf", "matu_MP1-TASV_1B.pdf", "matu_MP1-TASV_1C.pdf", "matu_MP1-TASV_1D.pdf", "matu_MP1-TASV_1E.pdf", "notes_AUTOMAT_1.pdf"], "db_updated": true} \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 4683c78..fae460f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,8 +1,13 @@ +# Nom de projet distinct de la prod pour que les deux stacks cohabitent +# sans que `compose up` côté prod ne recrée le container dev (ou inverse). +name: eptm-dashboard-dev + services: app: build: context: . dockerfile: Dockerfile.dev + container_name: eptm-dashboard-app-1 init: true restart: "no" # Pas de ports exposés sur le host : accès uniquement via NPM (proxy_net) @@ -15,12 +20,17 @@ services: - ./assets:/app/assets - ./scripts:/app/scripts - ./src:/app/src + - ./docs:/app/docs:ro + - ./templates:/app/templates:ro env_file: - .env.prod environment: - FRONTEND_PORT=3001 - BACKEND_PORT=8001 - API_URL=https://dev.dashboard.eptm-automation.ch + # Active le badge "DEV" dans la sidebar (sidebar.py:_IS_DEV). + # En prod, cette variable n'est pas définie → pas de badge. + - APP_ENV=dev # Évite la boucle infinie de hot-reload causée par SQLite WAL/SHM dans data/ - REFLEX_HOT_RELOAD_EXCLUDE_PATHS=/app/data # Timezone du container : aligne avec le host (cohérence cron + logs) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..d9781eb --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,45 @@ +# Stack PROD — EPTM Dashboard. +# +# Cohabite avec docker-compose.dev.yml : +# - Port interne 3002 (frontend + backend même port en prod Reflex) ; +# dev : 3001 frontend Vite + 8001 backend granian, séparés. +# - Volumes runtime distincts : ./data_prod et ./logs-prod +# - Image immuable buildée depuis Dockerfile.prod (pas de mount code) +# - NPM (proxy_net) dispatche dashboard.eptm-automation.ch → app:3002 + +# Nom de projet distinct du dev pour éviter que les deux compose se +# marchent dessus (sinon Compose recrée le container dev quand on lance prod). +name: eptm-dashboard-prod + +services: + app: + build: + context: . + dockerfile: Dockerfile.prod + image: eptm-dashboard-prod:latest + container_name: eptm-dashboard-prod-app-1 + init: true + restart: unless-stopped + # Pas de port exposé sur le host : accès uniquement via NPM (proxy_net). + volumes: + # Runtime isolé de dev — DB, settings, auth, logs, etc. + - ./data_prod:/app/data + - ./logs-prod:/logs + env_file: + - .env.prod + environment: + # Frontend et backend sur le même port en prod (exigence Reflex 0.9+). + - FRONTEND_PORT=3002 + - BACKEND_PORT=3002 + - API_URL=https://dashboard.eptm-automation.ch + # Le hot reload n'est pas censé tourner en prod, mais on garde la + # même exclusion pour cohérence si jamais quelqu'un toggle dev mode. + - REFLEX_HOT_RELOAD_EXCLUDE_PATHS=/app/data + - TZ=Europe/Zurich + networks: + - default + - proxy_net + +networks: + proxy_net: + external: true diff --git a/eptm_dashboard/components.py b/eptm_dashboard/components.py index cea04d3..81726f9 100644 --- a/eptm_dashboard/components.py +++ b/eptm_dashboard/components.py @@ -9,7 +9,12 @@ from pathlib import Path import reflex as rx DATA_DIR = Path(os.getenv("DATA_DIR", str(Path(__file__).resolve().parent.parent / "data"))) -DOCS_DIR = DATA_DIR / "docs" +# Documentation utilisateur — sous le repo (propagée par le COPY du Dockerfile). +# Fallback sur data/docs si une vieille config externe pointe encore là. +_REPO_ROOT = Path(__file__).resolve().parent.parent +DOCS_DIR = _REPO_ROOT / "docs" +if not DOCS_DIR.exists(): + DOCS_DIR = DATA_DIR / "docs" _RE_DOC_TITLE = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE) diff --git a/eptm_dashboard/pages/accueil.py b/eptm_dashboard/pages/accueil.py index 25b3308..7f0b7d8 100644 --- a/eptm_dashboard/pages/accueil.py +++ b/eptm_dashboard/pages/accueil.py @@ -178,8 +178,9 @@ class AccueilState(AuthState): def _kpi_card(label: str, value: rx.Var) -> rx.Component: return rx.box( - rx.text(label, size="1", color="#555555"), - rx.text(value, size="8", font_weight="700", line_height="1.1", class_name="tabular"), + rx.text(label, size="1", color="#555555", class_name="kpi-label"), + rx.text(value, size="8", font_weight="700", line_height="1.1", + class_name="tabular kpi-value"), background_color="var(--surface)", border="1px solid var(--border)", border_radius="8px", @@ -187,7 +188,7 @@ def _kpi_card(label: str, value: rx.Var) -> rx.Component: flex="1", min_width="80px", width="100%", - class_name="hover-lift", + class_name="hover-lift kpi-card", ) @@ -401,20 +402,6 @@ def accueil_page() -> rx.Component: rx.vstack( rx.heading("Tableau de bord", size="7"), - # KPIs - rx.hstack( - _kpi_card("Avis de sanction pour absences", AccueilState.sanctions_total), - _kpi_card("Notes insuffisantes (BN/Matu)", AccueilState.notes_insuf_total), - _kpi_card("Total périodes d'absence", AccueilState.kpi_total), - _kpi_card("Périodes à traiter", AccueilState.kpi_traiter), - spacing="3", - width="100%", - wrap="wrap", - align_items="stretch", - ), - - rx.divider(), - rx.flex( rx.icon("triangle-alert", size=20, color="#c62828"), rx.heading("Avis de sanction (> de 5 absences)", size="5"), diff --git a/eptm_dashboard/pages/classe.py b/eptm_dashboard/pages/classe.py index 43b43eb..6b04651 100644 --- a/eptm_dashboard/pages/classe.py +++ b/eptm_dashboard/pages/classe.py @@ -241,7 +241,11 @@ def _absence_pdf_apprenti(sess, apprenti) -> bytes: by_date: dict = {} for ab in absences: - by_date.setdefault(ab.date, {})[ab.periode] = "E" if ab.statut == "excusee" else "N" + # publiee_escada conserve son type via type_origine. + is_e = ab.statut == "excusee" or ( + ab.statut == "publiee_escada" and ab.type_origine == "E" + ) + by_date.setdefault(ab.date, {})[ab.periode] = "E" if is_e else "N" sorted_dates = sorted(by_date) blocs: list = [] @@ -733,15 +737,16 @@ 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"), + rx.text(label, size="1", color="#666", class_name="kpi-label"), + rx.text(value, size="7", font_weight="700", color=color, + class_name="tabular kpi-value"), padding="1rem", background_color="var(--surface)", border_radius="8px", border="1px solid var(--border)", flex="1", min_width="120px", - class_name="hover-lift", + class_name="hover-lift kpi-card", ) @@ -779,12 +784,12 @@ def _apprenti_card(item) -> rx.Component: _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("Absences", size="1", color="#666", class_name="kpi-label"), rx.text( item["blocs"], size="7", font_weight="700", color=rx.cond(item["quota_atteint"], "#c62828", "#37474f"), - class_name="tabular", + class_name="tabular kpi-value", ), rx.cond( item["quota_atteint"], @@ -803,6 +808,7 @@ def _apprenti_card(item) -> rx.Component: ), flex="1", min_width="120px", + class_name="kpi-card", ), gap="1rem", flex_wrap="wrap", diff --git a/eptm_dashboard/pages/fiche.py b/eptm_dashboard/pages/fiche.py index cd7d4fb..33f79cf 100644 --- a/eptm_dashboard/pages/fiche.py +++ b/eptm_dashboard/pages/fiche.py @@ -275,7 +275,12 @@ def _absence_pdf_apprenti(sess, apprenti) -> bytes: by_date: dict = {} for ab in absences: - by_date.setdefault(ab.date, {})[ab.periode] = "E" if ab.statut == "excusee" else "N" + # publiee_escada conserve son type via type_origine ; sinon les abs + # pushées seraient toutes marquées "N" même si elles étaient excusées. + is_e = ab.statut == "excusee" or ( + ab.statut == "publiee_escada" and ab.type_origine == "E" + ) + by_date.setdefault(ab.date, {})[ab.periode] = "E" if is_e else "N" sorted_dates = sorted(by_date) blocs: list = [] @@ -638,8 +643,8 @@ class FicheState(AuthState): idx = self.apprenti_ids.index(apprenti_id) self.selected_id = apprenti_id self.selected_label = self.apprenti_labels[idx] - self.edit_date = "" self._reload(reset_email=True) + self._select_today() # ── Calendar navigation ─────────────────────────────────────────────────── def prev_month(self): @@ -689,12 +694,23 @@ class FicheState(AuthState): "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} + # On garde statut + type_origine pour pouvoir distinguer une absence + # déjà publiée sur Escada (statut="publiee_escada") qui doit s'afficher + # selon son type d'origine (E ou N), sinon elle apparaîtrait comme + # "présent" après chaque push_then_sync. + pm = {ab.periode: (ab.statut, ab.type_origine) for ab in absences} def _choice(p: int) -> str: - s = pm.get(p) - if s == "excusee": return "excusee" - if s == "a_traiter": return "non_excusee" + item = pm.get(p) + if item is None: + return "present" + s, t = item + if s == "excusee": + return "excusee" + if s == "a_traiter": + return "non_excusee" + if s == "publiee_escada": + return "excusee" if t == "E" else "non_excusee" return "present" self.edit_p1 = _choice(1) @@ -1072,9 +1088,19 @@ class FicheState(AuthState): .order_by(Absence.date, Absence.periode) ).scalars().all() + # Une absence "publiee_escada" garde sa nature E/N via type_origine + # (sinon disparaît des compteurs après push_then_sync). + def _is_e(a): + return a.statut == "excusee" or ( + a.statut == "publiee_escada" and a.type_origine == "E" + ) + def _is_n(a): + return a.statut == "a_traiter" or ( + a.statut == "publiee_escada" and a.type_origine == "N" + ) self.kpi_total = len(absences) - self.kpi_excusees = sum(1 for a in absences if a.statut == "excusee") - self.kpi_non_excusees = sum(1 for a in absences if a.statut == "a_traiter") + self.kpi_excusees = sum(1 for a in absences if _is_e(a)) + self.kpi_non_excusees = sum(1 for a in absences if _is_n(a)) self.kpi_blocs = nb_blocs_absences(sess, self.selected_id) # Le quota de 5 absences ne s'applique qu'aux classes EM. apprenti = sess.get(Apprenti, self.selected_id) @@ -1164,13 +1190,11 @@ class FicheState(AuthState): self._build_bn(sess) - if absences: - self.cal_year = absences[0].date.year - self.cal_month = absences[0].date.month - else: - today = date.today() - self.cal_year = today.year - self.cal_month = today.month + # Toujours démarrer sur le mois courant (et non sur le 1er mois d'abs) + # pour que la date du jour soit immédiatement visible + sélectionnable. + today = date.today() + self.cal_year = today.year + self.cal_month = today.month self._build_calendar_from(absences) if reset_email: @@ -1422,15 +1446,16 @@ def _apprenti_searchable_select() -> rx.Component: def _kpi_card(label: str, value, color: str = "#37474f") -> rx.Component: return rx.box( - rx.text(label, size="1", color="#666"), - rx.text(value, size="7", font_weight="700", color=color, class_name="tabular"), + rx.text(label, size="1", color="#666", class_name="kpi-label"), + rx.text(value, size="7", font_weight="700", color=color, + class_name="tabular kpi-value"), padding="1rem", background_color="var(--surface)", border_radius="8px", border="1px solid var(--border)", flex="1", min_width="120px", - class_name="hover-lift", + class_name="hover-lift kpi-card", ) @@ -1540,14 +1565,10 @@ def _cal_day_cell(d) -> rx.Component: display="flex", align_items="center", justify_content="center", - cursor=rx.cond(d["has_abs"], "pointer", "default"), + cursor="pointer", on_click=FicheState.select_day(d["date_str"]), class_name="smooth-transition", - _hover=rx.cond( - d["has_abs"], - {"transform": "scale(1.05)", "box_shadow": "0 2px 6px rgba(0,0,0,0.1)"}, - {}, - ), + _hover={"transform": "scale(1.05)", "box_shadow": "0 2px 6px rgba(0,0,0,0.1)"}, ), ) @@ -1937,11 +1958,13 @@ def fiche_page() -> rx.Component: _kpi_card("Périodes d'absence", FicheState.kpi_total), _kpi_card("Périodes à excuser", FicheState.kpi_non_excusees, "var(--brand-primary-dark)"), rx.box( - rx.text("Absences", size="1", color="#666"), + rx.text("Absences", size="1", color="#666", + class_name="kpi-label"), rx.text( FicheState.kpi_blocs, size="7", font_weight="700", color=rx.cond(FicheState.quota_atteint, "#c62828", "#37474f"), + class_name="tabular kpi-value", ), rx.cond( FicheState.quota_atteint, @@ -1960,6 +1983,7 @@ def fiche_page() -> rx.Component: ), flex="1", min_width="120px", + class_name="kpi-card", ), gap="1rem", flex_wrap="wrap", width="100%", ), @@ -2106,28 +2130,27 @@ def fiche_page() -> rx.Component: width="100%", ), - # ── Calendrier mensuel ──────────────────────────────────── - rx.cond( - FicheState.kpi_total > 0, - rx.box( - rx.hstack( - rx.button( - rx.icon("chevron-left", size=14), FicheState.cal_prev_name, - on_click=FicheState.prev_month, - variant="outline", color_scheme="gray", size="2", - ), - rx.text( - FicheState.cal_month_name, - size="4", font_weight="700", color="var(--text-strong)", - flex="1", text_align="center", - ), - rx.button( - FicheState.cal_next_name, rx.icon("chevron-right", size=14), - on_click=FicheState.next_month, - variant="outline", color_scheme="gray", size="2", - ), - width="100%", align="center", margin_bottom="0.5rem", + # ── Calendrier mensuel (toujours visible pour pouvoir + # ajouter une absence sur un jour vierge) ────────────────── + rx.box( + rx.hstack( + rx.button( + rx.icon("chevron-left", size=14), FicheState.cal_prev_name, + on_click=FicheState.prev_month, + variant="outline", color_scheme="gray", size="2", ), + rx.text( + FicheState.cal_month_name, + size="4", font_weight="700", color="var(--text-strong)", + flex="1", text_align="center", + ), + rx.button( + FicheState.cal_next_name, rx.icon("chevron-right", size=14), + on_click=FicheState.next_month, + variant="outline", color_scheme="gray", size="2", + ), + width="100%", align="center", margin_bottom="0.5rem", + ), rx.grid( *[ rx.text(h, size="1", color="#9e9e9e", @@ -2153,16 +2176,14 @@ def fiche_page() -> rx.Component: spacing="2", align="center", margin_top="0.5rem", ), rx.text( - "Cliquez sur un jour avec absences pour éditer les périodes.", + "Cliquez sur un jour pour ajouter ou éditer les absences.", size="1", color="#9e9e9e", margin_top="0.25rem", ), - padding="1rem", - background_color="var(--surface)", - border_radius="8px", - border="1px solid var(--border)", - width="100%", - ), - rx.text("Aucune absence enregistrée.", size="2", color="#666"), + padding="1rem", + background_color="var(--surface)", + border_radius="8px", + border="1px solid var(--border)", + width="100%", ), # ── Panneau d'édition ───────────────────────────────────── diff --git a/eptm_dashboard/sidebar.py b/eptm_dashboard/sidebar.py index 9ca33bc..dd77efa 100644 --- a/eptm_dashboard/sidebar.py +++ b/eptm_dashboard/sidebar.py @@ -1,3 +1,4 @@ +import os import subprocess from pathlib import Path @@ -5,6 +6,10 @@ import reflex as rx from .state import AuthState from .components import scan_docs +# Banner DEV visible uniquement dans la stack dev. Activé par APP_ENV=dev +# dans docker-compose.dev.yml. Prod n'a pas cette variable → pas de bandeau. +_IS_DEV = os.getenv("APP_ENV", "").lower() == "dev" + # Liste des sections de doc (scan au module-load — un restart suffit pour # détecter de nouveaux fichiers). _DOC_SECTIONS = scan_docs() @@ -48,6 +53,27 @@ def _version_badge() -> rx.Component: padding_y="0.25rem", width="100%", ) + +def _dev_banner() -> rx.Component: + """Gros bandeau « DEV » visible uniquement quand APP_ENV=dev. + Empêche de confondre l'env dev avec la prod (data différentes).""" + if not _IS_DEV: + return rx.fragment() + return rx.box( + rx.text( + "DEV", + size="5", + font_weight="900", + color="white", + text_align="center", + width="100%", + letter_spacing="0.15em", + ), + background_color="#dc2626", + padding_y="0.4rem", + width="100%", + ) + FULL_W = "240px" RAIL_W = "68px" TOPBAR_H = "56px" @@ -461,6 +487,9 @@ def sidebar() -> rx.Component: padding_x=rx.cond(AuthState.sidebar_collapsed, "0.5rem", "0.75rem"), ), + # Bandeau DEV (uniquement si APP_ENV=dev) + _dev_banner(), + rx.box(height="1px", width="100%", background_color=_BORDER), # Nav @@ -511,6 +540,19 @@ def sidebar() -> rx.Component: # ── Mobile top bar ─────────────────────────────────────────────────────────── +def _dev_pill() -> rx.Component: + """Variante compacte du bandeau DEV pour le topbar mobile.""" + if not _IS_DEV: + return rx.fragment() + return rx.box( + rx.text("DEV", size="1", font_weight="900", color="white", + letter_spacing="0.1em"), + background_color="#dc2626", + padding_x="0.5rem", padding_y="0.15rem", + border_radius="4px", + ) + + def _mobile_topbar() -> rx.Component: return rx.box( # Bar row @@ -521,6 +563,7 @@ def _mobile_topbar() -> rx.Component: align_items="center", justify_content="center", ), + _dev_pill(), rx.spacer(), rx.icon_button( rx.cond( diff --git a/scripts/sync_esacada.py b/scripts/sync_esacada.py index 5b94e19..8d5dda6 100644 --- a/scripts/sync_esacada.py +++ b/scripts/sync_esacada.py @@ -438,14 +438,27 @@ def _go_to_class_page(page: Page, class_name: str, cache_type: str = "abs") -> " page.goto(CLASSES_URL) _ensure_logged_in(page) # gère expiration de session / 2FA - # Attendre que la grille soit rendue (au moins un lien ViewAbsenzenErweitert visible) - try: - page.wait_for_selector( - "a[href*='ViewAbsenzenErweitert']", state="attached", timeout=20_000 - ) - page.wait_for_timeout(500) - except Exception: - _log(f"WARN {class_name}: grille non chargée après 20s") + # Attendre que la grille soit rendue (au moins un lien ViewAbsenzenErweitert visible). + # Première classe après changement de langue : Escada peut prendre 30-40 s + # à rafraîchir la grille → un retry avec page.reload() est ajouté avant abandon. + grille_ok = False + for attempt in range(2): + try: + page.wait_for_selector( + "a[href*='ViewAbsenzenErweitert']", state="attached", timeout=45_000 + ) + page.wait_for_timeout(500) + grille_ok = True + break + except Exception: + if attempt == 0: + _log(f" [scan] grille non chargée après 45s — reload + retry") + try: + page.reload(wait_until="domcontentloaded", timeout=15_000) + except Exception: + pass + if not grille_ok: + _log(f"WARN {class_name}: grille non chargée après 2 tentatives (45s+45s)") return None # DevExpress restaure le dernier état du grid (pagination incluse). diff --git a/src/importer.py b/src/importer.py index 62e7fbd..6ab2b07 100644 --- a/src/importer.py +++ b/src/importer.py @@ -134,6 +134,20 @@ def import_pdf( # Modification en attente de sync vers Escada → ne pas écraser nb_doublons += 1 nb_pending_skipped += 1 + elif existe.statut == "publiee_escada": + # Vient d'être poussée vers Escada. Si le PDF confirme la + # valeur (type identique), on transitionne vers le statut + # "stable" pour que la prochaine modif locale crée bien un + # pending. Si le PDF ne confirme pas encore (stale), on + # préserve le marqueur en attendant un PDF rafraîchi. + if existe.type_origine == ab["type_absence"]: + existe.statut = ( + "excusee" if ab["type_absence"] == "E" else "a_traiter" + ) + nb_doublons += 1 + else: + nb_doublons += 1 + nb_pending_skipped += 1 elif existe.type_origine != ab["type_absence"]: existe.type_origine = ab["type_absence"] existe.statut = "excusee" if ab["type_absence"] == "E" else "a_traiter" @@ -222,6 +236,15 @@ def import_pdf( nb_pending_skipped += 1 continue + # Orphelin déjà poussé sur Escada (statut="publiee_escada") : + # Escada peut servir un PDF stale ne reflétant pas encore notre + # push. Préserver l'absence pour éviter de la supprimer juste + # après l'avoir poussée. Un sync ultérieur avec PDF rafraîchi + # ramènera l'absence à "excusee"/"a_traiter". + if ab.statut == "publiee_escada" and not force: + nb_pending_skipped += 1 + continue + if ep: session.delete(ep) nb_pendings_orphelins += 1 diff --git a/src/notifier.py b/src/notifier.py index 544d311..4ad88d1 100644 --- a/src/notifier.py +++ b/src/notifier.py @@ -112,14 +112,34 @@ def notify_job_result( if duration_s is not None: parts.append(f"⏱ Durée : {_fmt_duration(duration_s)}") - # Niveau normal — message court uniquement + # Erreurs détaillées : toujours affichées en cas d'échec, indépendamment du + # niveau (normal/detailed). Sinon on a juste « Sync absences a échoué (code 1) » + # ou « ⚠ N erreur(s) » sans savoir lesquelles. + err_list = (details or {}).get("errors") if details else None + err_list = err_list or [] + + # Niveau normal — message court (+ détail erreurs si échec) if notify_level != "detailed": - if message and status != "ok": - # En cas d'échec, on garde le message d'erreur même en normal - msg = message.strip() - if len(msg) > 500: - msg = msg[:500] + "…" - parts.append(f"
{_escape_html(msg)}
") + if status != "ok": + if message: + msg = message.strip() + if len(msg) > 300: + msg = msg[:300] + "…" + parts.append(f"
{_escape_html(msg)}
") + if err_list: + parts.append("⚠ Erreurs") + for err in err_list[:10]: + parts.append(f" • {_escape_html(str(err)[:200])}") + if len(err_list) > 10: + parts.append(f" … +{len(err_list) - 10} autre(s)") + elif log_path: + # Pas d'errors structurées (script tombé avant run_imports) : + # on extrait les dernières lignes significatives du log pour que + # l'utilisateur sache pourquoi sans avoir à se connecter au serveur. + tail = _tail_significant(log_path, max_lines=12, max_chars=1500) + if tail: + parts.append("⚠ Extrait du log") + parts.append(f"
{_escape_html(tail)}
") return send_telegram("\n".join(parts), chat_id=chat_id) # Niveau detailed — détails par classe et catégorie @@ -134,6 +154,15 @@ def notify_job_result( parts.append(f" • {_escape_html(str(err)[:200])}") if len(errors) > 10: parts.append(f" … +{len(errors) - 10} autre(s)") + elif status != "ok" and log_path: + # Échec sans errors structurées (script tombé avant run_imports) : + # on extrait la queue du log pour donner le contexte. + if message: + parts.append(f"\n
{_escape_html(message.strip()[:300])}
") + tail = _tail_significant(log_path, max_lines=12, max_chars=1500) + if tail: + parts.append("⚠ Extrait du log") + parts.append(f"
{_escape_html(tail)}
") # Absences (toujours affichées si présentes) res_abs = details.get("res_abs") or [] @@ -199,6 +228,26 @@ def notify_job_result( return send_telegram("\n".join(parts), chat_id=chat_id) +def _tail_significant(log_path, max_lines: int = 12, max_chars: int = 1500) -> str: + """Lit la fin d'un fichier log et renvoie les lignes les plus utiles pour + diagnostiquer un échec : derniers messages stderr Python, traceback, lignes + d'erreur. Renvoie chaîne vide si log inaccessible.""" + try: + p = Path(log_path) if not isinstance(log_path, Path) else log_path + if not p.exists(): + return "" + content = p.read_text(encoding="utf-8", errors="replace") + except Exception: + return "" + # Tail simple sur les `max_lines` dernières lignes non vides. + lines = [ln.rstrip() for ln in content.splitlines() if ln.strip()] + tail = lines[-max_lines:] if len(lines) > max_lines else lines + out = "\n".join(tail) + if len(out) > max_chars: + out = "…\n" + out[-max_chars:] + return out + + def _fmt_duration(seconds: float) -> str: s = int(seconds) if s < 60: diff --git a/src/retenue_pdf.py b/src/retenue_pdf.py index cc67326..db1719c 100644 --- a/src/retenue_pdf.py +++ b/src/retenue_pdf.py @@ -24,7 +24,11 @@ from src.db import Apprenti, ApprentiFiche _ROOT = Path(__file__).resolve().parent.parent _DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data"))) -_TEMPLATE_PATH = _DATA_DIR / "templates" / "GF_FO_Avis_de_retenue.pdf" +# Template AcroForm — sous le repo (propagé par le COPY du Dockerfile). +# Fallback sur data/templates pour les anciennes installations. +_TEMPLATE_PATH = _ROOT / "templates" / "GF_FO_Avis_de_retenue.pdf" +if not _TEMPLATE_PATH.exists(): + _TEMPLATE_PATH = _DATA_DIR / "templates" / "GF_FO_Avis_de_retenue.pdf" _MOIS_FR = [ "janvier", "février", "mars", "avril", "mai", "juin", diff --git a/src/sanction_pdf.py b/src/sanction_pdf.py index 3a35ccf..685abb1 100644 --- a/src/sanction_pdf.py +++ b/src/sanction_pdf.py @@ -21,7 +21,11 @@ from src.db import Apprenti, ApprentiFiche _ROOT = Path(__file__).resolve().parent.parent _DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data"))) -_TEMPLATE_PATH = _DATA_DIR / "templates" / "GF_FO_Avis_de_sanction.pdf" +# Template AcroForm — sous le repo (propagé par le COPY du Dockerfile). +# Fallback sur data/templates pour les anciennes installations. +_TEMPLATE_PATH = _ROOT / "templates" / "GF_FO_Avis_de_sanction.pdf" +if not _TEMPLATE_PATH.exists(): + _TEMPLATE_PATH = _DATA_DIR / "templates" / "GF_FO_Avis_de_sanction.pdf" _SETTINGS_PATH = _DATA_DIR / "settings.json" # Mêmes valeurs par défaut que la page Paramètres (pages/params.py).