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:
parent
520541ba94
commit
7d3b6e9136
24 changed files with 525 additions and 184 deletions
|
|
@ -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/
|
||||||
|
|
|
||||||
11
.env.prod
11
.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
|
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
11
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
78
Dockerfile.prod
Normal 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
32
TODO.md
|
|
@ -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
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"47cf7919929b481bb5c083a4435f5383": {"username": "julbal", "name": "Julien Balet", "ts": 1778154736.649939}, "929ed6d889234f27bc54406b6eb617d6": {"username": "julbal", "name": "Julien Balet", "ts": 1778155854.202958}}
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
["AUTOMAT 1"]
|
|
||||||
|
|
@ -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}
|
|
||||||
|
|
@ -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
45
docker-compose.prod.yml
Normal 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
|
||||||
|
|
@ -9,6 +9,11 @@ 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")))
|
||||||
|
# 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"
|
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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,10 +1190,8 @@ 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
|
|
||||||
else:
|
|
||||||
today = date.today()
|
today = date.today()
|
||||||
self.cal_year = today.year
|
self.cal_year = today.year
|
||||||
self.cal_month = today.month
|
self.cal_month = today.month
|
||||||
|
|
@ -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,9 +2130,8 @@ 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(
|
||||||
|
|
@ -2153,7 +2176,7 @@ 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",
|
||||||
|
|
@ -2162,8 +2185,6 @@ def fiche_page() -> rx.Component:
|
||||||
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 ─────────────────────────────────────
|
||||||
rx.cond(
|
rx.cond(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
# 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:
|
try:
|
||||||
page.wait_for_selector(
|
page.wait_for_selector(
|
||||||
"a[href*='ViewAbsenzenErweitert']", state="attached", timeout=20_000
|
"a[href*='ViewAbsenzenErweitert']", state="attached", timeout=45_000
|
||||||
)
|
)
|
||||||
page.wait_for_timeout(500)
|
page.wait_for_timeout(500)
|
||||||
|
grille_ok = True
|
||||||
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
_log(f"WARN {class_name}: grille non chargée après 20s")
|
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).
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,10 @@ 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 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"
|
_TEMPLATE_PATH = _DATA_DIR / "templates" / "GF_FO_Avis_de_retenue.pdf"
|
||||||
|
|
||||||
_MOIS_FR = [
|
_MOIS_FR = [
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,10 @@ 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 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"
|
_TEMPLATE_PATH = _DATA_DIR / "templates" / "GF_FO_Avis_de_sanction.pdf"
|
||||||
_SETTINGS_PATH = _DATA_DIR / "settings.json"
|
_SETTINGS_PATH = _DATA_DIR / "settings.json"
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue