v1.1.0 — fixes sync + UX dev/prod

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) <noreply@anthropic.com>
This commit is contained in:
Julien Balet 2026-05-13 09:11:39 +02:00
parent 520541ba94
commit 7d3b6e9136
24 changed files with 525 additions and 184 deletions

View file

@ -1,9 +1,19 @@
.web/ .web/
__pycache__/ __pycache__/
.venv/ .venv/
data/browser_profile/
data/cache/
data/*.db
data/*.db-*
logs/
.git/ .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/

View file

@ -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 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_BOT_TOKEN=8659950969:AAEpc3sl34txpsHyYC5-3rnfgVnkEuQoU_Q
TELEGRAM_CHAT_ID=-4992234358 TELEGRAM_CHAT_ID=-4992234358
# Timezone — surchargée par docker-compose.* mais utile pour les scripts CLI.
TZ=Europe/Zurich

11
.gitignore vendored
View file

@ -15,6 +15,17 @@ data/sync_*.json
data/debug_*.png data/debug_*.png
data/*.bak.* data/*.bak.*
data/password_tokens.json data/password_tokens.json
data/class_href_cache.json
data/esacada_*.json
data/last_sync.json
data/auth_tokens.json
# Logs cron (runtime) # Logs cron (runtime)
logs/ logs/
# Stack prod — runtime
data_prod/
logs-prod/
# Backups
*.back

View file

@ -2,7 +2,10 @@
Procédure de bascule de l'app **Streamlit legacy** (`absences.service`, Procédure de bascule de l'app **Streamlit legacy** (`absences.service`,
`dashboard.eptm-automation.ch`) vers l'app **Reflex** (en prod sur le `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 - Streamlit `absences.service` arrêté + disabled (binaires gardés en backup
quelques semaines, suppression plus tard). quelques semaines, suppression plus tard).
- Deux stacks compose côte à côte sur `proxy_net`, ports internes - Deux stacks compose côte à côte sur `proxy_net` :
distincts (3001/8001 dev, 3002/8002 prod), aucun port host exposé. - 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 - NPM continue de gérer les certs Let's Encrypt (déjà émis pour ce
sous-domaine, juste à conserver — pas de renouvellement à forcer). sous-domaine, juste à conserver — pas de renouvellement à forcer).
--- ---
## ⚠️ À clarifier avant exécution ## ✅ Décisions prises (2026-05-12)
| # | Question | Hypothèse par défaut | | # | 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) | | # | Question | Décision |
| 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 | | 1 | Données prod | **Isolées** : `./data_prod/` séparé de `./data/` |
| 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 | | 2 | DB initiale prod | **Copie** de `data/absences.db` au cutover |
| 5 | **Downtime acceptable** au cutover ? | ~30-60s pendant que NPM bascule de proxy host. Pas de zero-downtime | | 3 | Streamlit après cutover | **Disabled** ~1 mois (rollback plan B), purge en tâche TODO |
| 6 | **`.env.prod`** : tester avec env identique à dev ? | À auditer ensemble (peut contenir `escada_username/password`, SMTP, etc.) | | 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) ### 1. `Dockerfile.prod` (multi-stage)

78
Dockerfile.prod Normal file
View file

@ -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"]

32
TODO.md
View file

@ -6,39 +6,25 @@ en haut de la section concernée.
## Idées / fonctionnalités ## 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) - [ ] 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 - [ ] Rercher aussi les accentes dans les noms apprenti
- [X] Modifier l'adresse du destinataire des avis de sanction/absences -> représentant légal pour les mineurs, apprenti pour les majeurs - [ ] Permettre de faire haut/bas avec les flèches dans la liste classes/apprentis
- [X] Changer le texte de l'objet dans les mails apprentis - [ ] Ajouté envoie de mail au secrétariat pour les avis de retenue
- [X] Ajouter dans le sidebar la version GIT du document. - [ ] Ajouter l'envoie automatique des absences à l'apprenti
- [X] Ajouter bouton "Absent toute la journée" avec filtre des périodes en fonction des classes
- [X] Ajouter dans l'export des absences s'il s'agit dun jour de théorie/pratique/matu
- [X] Renommer les pages : « Vue classe » → « Classes », « Fiche apprenti » → « Apprentis » + réordonner sidebar (Classes au-dessus d'Apprentis)
- [X] Cron : supprimer les schedules `daily` et `interval`, ne garder que `daily_multi` (grille 24 cases) + `weekly`. Migration auto au boot.
- [X] Bouton « Absent toute la journée » : griser + libellé « (Données chronoplan manquantes) » si pas de mapping configuré
- [X] Ajouter dans le panneau d'édition d'absences un badge couleur Théorie / Pratique / Matu selon le jour
## Bugs connus ## Bugs connus
- [ ] Dans les logs, en mode Live, la fenêtre ne défile pas toute seule au fond. Mettre les dernières lignes en premier? - [ ] 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 ## Améliorations UX
- [ ] Faire un thème avec fond foncé - [ ] Faire un thème avec fond foncé
- [ ] Lancer une optimisation des toasts - [ ] Lancer une optimisation des toasts
- [X] Changer la couleur du bouton Générer l'avais de sanction
- [X] rendre plus petit la bulle dans le logo chat et changer le titre (enlever EPTM) - [ ] Faire séléctionner la date du jour dans le tableau des absences
- [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

View file

@ -62,6 +62,12 @@ button[title="Signaler un bug ou proposer une idée"] svg {
animation: feedback-pulse 1.5s ease-in-out infinite; 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) /* Brand tokens (thèmes utilisateur)
Tokens utilisés par l'app pour les couleurs de marque. Chaque thème surcharge 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; min-width: 0 !important;
flex: 1 1 100% !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 */ /* Tablet */

View file

@ -1 +0,0 @@
{"47cf7919929b481bb5c083a4435f5383": {"username": "julbal", "name": "Julien Balet", "ts": 1778154736.649939}, "929ed6d889234f27bc54406b6eb617d6": {"username": "julbal", "name": "Julien Balet", "ts": 1778155854.202958}}

View file

@ -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"
}

View file

@ -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"]

View file

@ -1 +0,0 @@
["AUTOMAT 1"]

View file

@ -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}

View file

@ -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: services:
app: app:
build: build:
context: . context: .
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev
container_name: eptm-dashboard-app-1
init: true init: true
restart: "no" restart: "no"
# Pas de ports exposés sur le host : accès uniquement via NPM (proxy_net) # Pas de ports exposés sur le host : accès uniquement via NPM (proxy_net)
@ -15,12 +20,17 @@ services:
- ./assets:/app/assets - ./assets:/app/assets
- ./scripts:/app/scripts - ./scripts:/app/scripts
- ./src:/app/src - ./src:/app/src
- ./docs:/app/docs:ro
- ./templates:/app/templates:ro
env_file: env_file:
- .env.prod - .env.prod
environment: environment:
- FRONTEND_PORT=3001 - FRONTEND_PORT=3001
- BACKEND_PORT=8001 - BACKEND_PORT=8001
- API_URL=https://dev.dashboard.eptm-automation.ch - 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/ # Évite la boucle infinie de hot-reload causée par SQLite WAL/SHM dans data/
- REFLEX_HOT_RELOAD_EXCLUDE_PATHS=/app/data - REFLEX_HOT_RELOAD_EXCLUDE_PATHS=/app/data
# Timezone du container : aligne avec le host (cohérence cron + logs) # Timezone du container : aligne avec le host (cohérence cron + logs)

45
docker-compose.prod.yml Normal file
View file

@ -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

View file

@ -9,7 +9,12 @@ from pathlib import Path
import reflex as rx import reflex as rx
DATA_DIR = Path(os.getenv("DATA_DIR", str(Path(__file__).resolve().parent.parent / "data"))) 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) _RE_DOC_TITLE = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE)

View file

@ -178,8 +178,9 @@ class AccueilState(AuthState):
def _kpi_card(label: str, value: rx.Var) -> rx.Component: def _kpi_card(label: str, value: rx.Var) -> rx.Component:
return rx.box( return rx.box(
rx.text(label, size="1", color="#555555"), 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"), rx.text(value, size="8", font_weight="700", line_height="1.1",
class_name="tabular kpi-value"),
background_color="var(--surface)", background_color="var(--surface)",
border="1px solid var(--border)", border="1px solid var(--border)",
border_radius="8px", border_radius="8px",
@ -187,7 +188,7 @@ def _kpi_card(label: str, value: rx.Var) -> rx.Component:
flex="1", flex="1",
min_width="80px", min_width="80px",
width="100%", width="100%",
class_name="hover-lift", class_name="hover-lift kpi-card",
) )
@ -401,20 +402,6 @@ def accueil_page() -> rx.Component:
rx.vstack( rx.vstack(
rx.heading("Tableau de bord", size="7"), 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.flex(
rx.icon("triangle-alert", size=20, color="#c62828"), rx.icon("triangle-alert", size=20, color="#c62828"),
rx.heading("Avis de sanction (> de 5 absences)", size="5"), rx.heading("Avis de sanction (> de 5 absences)", size="5"),

View file

@ -241,7 +241,11 @@ def _absence_pdf_apprenti(sess, apprenti) -> bytes:
by_date: dict = {} by_date: dict = {}
for ab in absences: 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) sorted_dates = sorted(by_date)
blocs: list = [] 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: def _kpi_card(label: str, value, color: str = "#37474f") -> rx.Component:
"""Carte KPI identique à fiche.py (taille 7, fond surface).""" """Carte KPI identique à fiche.py (taille 7, fond surface)."""
return rx.box( return rx.box(
rx.text(label, size="1", color="#666"), rx.text(label, size="1", color="#666", class_name="kpi-label"),
rx.text(value, size="7", font_weight="700", color=color, class_name="tabular"), rx.text(value, size="7", font_weight="700", color=color,
class_name="tabular kpi-value"),
padding="1rem", padding="1rem",
background_color="var(--surface)", background_color="var(--surface)",
border_radius="8px", border_radius="8px",
border="1px solid var(--border)", border="1px solid var(--border)",
flex="1", flex="1",
min_width="120px", 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 d'absence", item["total"]),
_kpi_card("Périodes à excuser", item["non_exc"], "var(--brand-primary-dark)"), _kpi_card("Périodes à excuser", item["non_exc"], "var(--brand-primary-dark)"),
rx.box( rx.box(
rx.text("Absences", size="1", color="#666"), rx.text("Absences", size="1", color="#666", class_name="kpi-label"),
rx.text( rx.text(
item["blocs"], item["blocs"],
size="7", font_weight="700", size="7", font_weight="700",
color=rx.cond(item["quota_atteint"], "#c62828", "#37474f"), color=rx.cond(item["quota_atteint"], "#c62828", "#37474f"),
class_name="tabular", class_name="tabular kpi-value",
), ),
rx.cond( rx.cond(
item["quota_atteint"], item["quota_atteint"],
@ -803,6 +808,7 @@ def _apprenti_card(item) -> rx.Component:
), ),
flex="1", flex="1",
min_width="120px", min_width="120px",
class_name="kpi-card",
), ),
gap="1rem", gap="1rem",
flex_wrap="wrap", flex_wrap="wrap",

View file

@ -275,7 +275,12 @@ def _absence_pdf_apprenti(sess, apprenti) -> bytes:
by_date: dict = {} by_date: dict = {}
for ab in absences: 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) sorted_dates = sorted(by_date)
blocs: list = [] blocs: list = []
@ -638,8 +643,8 @@ class FicheState(AuthState):
idx = self.apprenti_ids.index(apprenti_id) idx = self.apprenti_ids.index(apprenti_id)
self.selected_id = apprenti_id self.selected_id = apprenti_id
self.selected_label = self.apprenti_labels[idx] self.selected_label = self.apprenti_labels[idx]
self.edit_date = ""
self._reload(reset_email=True) self._reload(reset_email=True)
self._select_today()
# ── Calendar navigation ─────────────────────────────────────────────────── # ── Calendar navigation ───────────────────────────────────────────────────
def prev_month(self): def prev_month(self):
@ -689,12 +694,23 @@ class FicheState(AuthState):
"theorie": "Théorie", "pratique": "Pratique", "matu": "Matu", "theorie": "Théorie", "pratique": "Pratique", "matu": "Matu",
}.get(d_type, "") }.get(d_type, "")
self.edit_day_has_schedule = bool(d_periods) 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: def _choice(p: int) -> str:
s = pm.get(p) item = pm.get(p)
if s == "excusee": return "excusee" if item is None:
if s == "a_traiter": return "non_excusee" 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" return "present"
self.edit_p1 = _choice(1) self.edit_p1 = _choice(1)
@ -1072,9 +1088,19 @@ class FicheState(AuthState):
.order_by(Absence.date, Absence.periode) .order_by(Absence.date, Absence.periode)
).scalars().all() ).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_total = len(absences)
self.kpi_excusees = sum(1 for a in absences if a.statut == "excusee") self.kpi_excusees = sum(1 for a in absences if _is_e(a))
self.kpi_non_excusees = sum(1 for a in absences if a.statut == "a_traiter") self.kpi_non_excusees = sum(1 for a in absences if _is_n(a))
self.kpi_blocs = nb_blocs_absences(sess, self.selected_id) self.kpi_blocs = nb_blocs_absences(sess, self.selected_id)
# Le quota de 5 absences ne s'applique qu'aux classes EM. # Le quota de 5 absences ne s'applique qu'aux classes EM.
apprenti = sess.get(Apprenti, self.selected_id) apprenti = sess.get(Apprenti, self.selected_id)
@ -1164,13 +1190,11 @@ class FicheState(AuthState):
self._build_bn(sess) self._build_bn(sess)
if absences: # Toujours démarrer sur le mois courant (et non sur le 1er mois d'abs)
self.cal_year = absences[0].date.year # pour que la date du jour soit immédiatement visible + sélectionnable.
self.cal_month = absences[0].date.month today = date.today()
else: self.cal_year = today.year
today = date.today() self.cal_month = today.month
self.cal_year = today.year
self.cal_month = today.month
self._build_calendar_from(absences) self._build_calendar_from(absences)
if reset_email: if reset_email:
@ -1422,15 +1446,16 @@ def _apprenti_searchable_select() -> rx.Component:
def _kpi_card(label: str, value, color: str = "#37474f") -> rx.Component: def _kpi_card(label: str, value, color: str = "#37474f") -> rx.Component:
return rx.box( return rx.box(
rx.text(label, size="1", color="#666"), rx.text(label, size="1", color="#666", class_name="kpi-label"),
rx.text(value, size="7", font_weight="700", color=color, class_name="tabular"), rx.text(value, size="7", font_weight="700", color=color,
class_name="tabular kpi-value"),
padding="1rem", padding="1rem",
background_color="var(--surface)", background_color="var(--surface)",
border_radius="8px", border_radius="8px",
border="1px solid var(--border)", border="1px solid var(--border)",
flex="1", flex="1",
min_width="120px", 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", display="flex",
align_items="center", align_items="center",
justify_content="center", justify_content="center",
cursor=rx.cond(d["has_abs"], "pointer", "default"), cursor="pointer",
on_click=FicheState.select_day(d["date_str"]), on_click=FicheState.select_day(d["date_str"]),
class_name="smooth-transition", class_name="smooth-transition",
_hover=rx.cond( _hover={"transform": "scale(1.05)", "box_shadow": "0 2px 6px rgba(0,0,0,0.1)"},
d["has_abs"],
{"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 d'absence", FicheState.kpi_total),
_kpi_card("Périodes à excuser", FicheState.kpi_non_excusees, "var(--brand-primary-dark)"), _kpi_card("Périodes à excuser", FicheState.kpi_non_excusees, "var(--brand-primary-dark)"),
rx.box( rx.box(
rx.text("Absences", size="1", color="#666"), rx.text("Absences", size="1", color="#666",
class_name="kpi-label"),
rx.text( rx.text(
FicheState.kpi_blocs, FicheState.kpi_blocs,
size="7", font_weight="700", size="7", font_weight="700",
color=rx.cond(FicheState.quota_atteint, "#c62828", "#37474f"), color=rx.cond(FicheState.quota_atteint, "#c62828", "#37474f"),
class_name="tabular kpi-value",
), ),
rx.cond( rx.cond(
FicheState.quota_atteint, FicheState.quota_atteint,
@ -1960,6 +1983,7 @@ def fiche_page() -> rx.Component:
), ),
flex="1", flex="1",
min_width="120px", min_width="120px",
class_name="kpi-card",
), ),
gap="1rem", flex_wrap="wrap", width="100%", gap="1rem", flex_wrap="wrap", width="100%",
), ),
@ -2106,28 +2130,27 @@ def fiche_page() -> rx.Component:
width="100%", width="100%",
), ),
# ── Calendrier mensuel ──────────────────────────────────── # ── Calendrier mensuel (toujours visible pour pouvoir
rx.cond( # ajouter une absence sur un jour vierge) ──────────────────
FicheState.kpi_total > 0, rx.box(
rx.box( rx.hstack(
rx.hstack( rx.button(
rx.button( rx.icon("chevron-left", size=14), FicheState.cal_prev_name,
rx.icon("chevron-left", size=14), FicheState.cal_prev_name, on_click=FicheState.prev_month,
on_click=FicheState.prev_month, variant="outline", color_scheme="gray", size="2",
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.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.grid(
*[ *[
rx.text(h, size="1", color="#9e9e9e", rx.text(h, size="1", color="#9e9e9e",
@ -2153,16 +2176,14 @@ def fiche_page() -> rx.Component:
spacing="2", align="center", margin_top="0.5rem", spacing="2", align="center", margin_top="0.5rem",
), ),
rx.text( 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", size="1", color="#9e9e9e", margin_top="0.25rem",
), ),
padding="1rem", padding="1rem",
background_color="var(--surface)", background_color="var(--surface)",
border_radius="8px", border_radius="8px",
border="1px solid var(--border)", border="1px solid var(--border)",
width="100%", width="100%",
),
rx.text("Aucune absence enregistrée.", size="2", color="#666"),
), ),
# ── Panneau d'édition ───────────────────────────────────── # ── Panneau d'édition ─────────────────────────────────────

View file

@ -1,3 +1,4 @@
import os
import subprocess import subprocess
from pathlib import Path from pathlib import Path
@ -5,6 +6,10 @@ import reflex as rx
from .state import AuthState from .state import AuthState
from .components import scan_docs 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 # Liste des sections de doc (scan au module-load — un restart suffit pour
# détecter de nouveaux fichiers). # détecter de nouveaux fichiers).
_DOC_SECTIONS = scan_docs() _DOC_SECTIONS = scan_docs()
@ -48,6 +53,27 @@ def _version_badge() -> rx.Component:
padding_y="0.25rem", width="100%", 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" FULL_W = "240px"
RAIL_W = "68px" RAIL_W = "68px"
TOPBAR_H = "56px" TOPBAR_H = "56px"
@ -461,6 +487,9 @@ def sidebar() -> rx.Component:
padding_x=rx.cond(AuthState.sidebar_collapsed, "0.5rem", "0.75rem"), 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), rx.box(height="1px", width="100%", background_color=_BORDER),
# Nav # Nav
@ -511,6 +540,19 @@ def sidebar() -> rx.Component:
# ── Mobile top bar ─────────────────────────────────────────────────────────── # ── 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: def _mobile_topbar() -> rx.Component:
return rx.box( return rx.box(
# Bar row # Bar row
@ -521,6 +563,7 @@ def _mobile_topbar() -> rx.Component:
align_items="center", align_items="center",
justify_content="center", justify_content="center",
), ),
_dev_pill(),
rx.spacer(), rx.spacer(),
rx.icon_button( rx.icon_button(
rx.cond( rx.cond(

View file

@ -438,14 +438,27 @@ def _go_to_class_page(page: Page, class_name: str, cache_type: str = "abs") -> "
page.goto(CLASSES_URL) page.goto(CLASSES_URL)
_ensure_logged_in(page) # gère expiration de session / 2FA _ensure_logged_in(page) # gère expiration de session / 2FA
# Attendre que la grille soit rendue (au moins un lien ViewAbsenzenErweitert visible) # Attendre que la grille soit rendue (au moins un lien ViewAbsenzenErweitert visible).
try: # Première classe après changement de langue : Escada peut prendre 30-40 s
page.wait_for_selector( # à rafraîchir la grille → un retry avec page.reload() est ajouté avant abandon.
"a[href*='ViewAbsenzenErweitert']", state="attached", timeout=20_000 grille_ok = False
) for attempt in range(2):
page.wait_for_timeout(500) try:
except Exception: page.wait_for_selector(
_log(f"WARN {class_name}: grille non chargée après 20s") "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 return None
# DevExpress restaure le dernier état du grid (pagination incluse). # DevExpress restaure le dernier état du grid (pagination incluse).

View file

@ -134,6 +134,20 @@ def import_pdf(
# Modification en attente de sync vers Escada → ne pas écraser # Modification en attente de sync vers Escada → ne pas écraser
nb_doublons += 1 nb_doublons += 1
nb_pending_skipped += 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"]: elif existe.type_origine != ab["type_absence"]:
existe.type_origine = ab["type_absence"] existe.type_origine = ab["type_absence"]
existe.statut = "excusee" if ab["type_absence"] == "E" else "a_traiter" existe.statut = "excusee" if ab["type_absence"] == "E" else "a_traiter"
@ -222,6 +236,15 @@ def import_pdf(
nb_pending_skipped += 1 nb_pending_skipped += 1
continue 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: if ep:
session.delete(ep) session.delete(ep)
nb_pendings_orphelins += 1 nb_pendings_orphelins += 1

View file

@ -112,14 +112,34 @@ def notify_job_result(
if duration_s is not None: if duration_s is not None:
parts.append(f"⏱ Durée : {_fmt_duration(duration_s)}") 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 notify_level != "detailed":
if message and status != "ok": if status != "ok":
# En cas d'échec, on garde le message d'erreur même en normal if message:
msg = message.strip() msg = message.strip()
if len(msg) > 500: if len(msg) > 300:
msg = msg[:500] + "" msg = msg[:300] + ""
parts.append(f"<pre>{_escape_html(msg)}</pre>") parts.append(f"<pre>{_escape_html(msg)}</pre>")
if err_list:
parts.append("<b>⚠ Erreurs</b>")
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("<b>⚠ Extrait du log</b>")
parts.append(f"<pre>{_escape_html(tail)}</pre>")
return send_telegram("\n".join(parts), chat_id=chat_id) return send_telegram("\n".join(parts), chat_id=chat_id)
# Niveau detailed — détails par classe et catégorie # 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])}") parts.append(f"{_escape_html(str(err)[:200])}")
if len(errors) > 10: if len(errors) > 10:
parts.append(f" … +{len(errors) - 10} autre(s)") 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<pre>{_escape_html(message.strip()[:300])}</pre>")
tail = _tail_significant(log_path, max_lines=12, max_chars=1500)
if tail:
parts.append("<b>⚠ Extrait du log</b>")
parts.append(f"<pre>{_escape_html(tail)}</pre>")
# Absences (toujours affichées si présentes) # Absences (toujours affichées si présentes)
res_abs = details.get("res_abs") or [] 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) 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: def _fmt_duration(seconds: float) -> str:
s = int(seconds) s = int(seconds)
if s < 60: if s < 60:

View file

@ -24,7 +24,11 @@ from src.db import Apprenti, ApprentiFiche
_ROOT = Path(__file__).resolve().parent.parent _ROOT = Path(__file__).resolve().parent.parent
_DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data"))) _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 = [ _MOIS_FR = [
"janvier", "février", "mars", "avril", "mai", "juin", "janvier", "février", "mars", "avril", "mai", "juin",

View file

@ -21,7 +21,11 @@ from src.db import Apprenti, ApprentiFiche
_ROOT = Path(__file__).resolve().parent.parent _ROOT = Path(__file__).resolve().parent.parent
_DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data"))) _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" _SETTINGS_PATH = _DATA_DIR / "settings.json"
# Mêmes valeurs par défaut que la page Paramètres (pages/params.py). # Mêmes valeurs par défaut que la page Paramètres (pages/params.py).