Compare commits

...

10 commits

Author SHA1 Message Date
f1190566a6 Initial version 2026-05-12 15:30:28 +02:00
ea8954bc6f ajout des tuiles notes insuf. 2026-05-12 09:46:18 +02:00
eb98ec273c ajout du chat 2026-05-12 09:09:07 +02:00
9188e6ba1e import représentant légal 2026-05-11 21:27:13 +02:00
7431339ce5 ajouté import du statut des désavantages, affichage de toutes les notes du BN. 2026-05-11 19:19:26 +02:00
38189deb0f clean script 2026-05-11 15:45:44 +02:00
610e37d2a1 avis de sanction dans fiche apprenti 2026-05-11 15:22:55 +02:00
ef6072112b update cron 2026-05-11 14:45:42 +02:00
6d1b7c8044 retenue: avis PDF + notices Escada + mapping profession
- nouvelle page /retenue : sélection apprenti, date retenue, date du
  problème, motif (3 cases mutex), branche (autocomplete + saisie libre
  depuis NotesExamen), remarque. Génération PDF basée sur le template
  AcroForm officiel, séparation des 3 widgets Date partagés en 3 champs
  distincts pour ne remplir que celui de la case cochée. Téléchargement
  ou envoi par email (3 destinataires).
- profession : nouveau champ ApprentiFiche.profession, dérivé du préfixe
  de classe via mapping configurable dans Paramètres
  ("AUTOMAT" → "Automaticien CFC" par défaut). Section dédiée avec
  classes orphelines détectées automatiquement.
- notices Escada : nouvelle table Notice (apprenti, titre, remarque,
  date, status). Checkbox "Ajouter automatiquement une notice sur
  Escada" sur /retenue qui crée une entrée pending. Bloc dédié sur
  /escada listant les pending, bouton "Pousser les notices" qui lance
  scripts/push_notices.py (Playwright : navigation Classes → Élèves →
  Notices → Ajouter, fill date / titre / remarque, vérification post-save,
  suppression DB si OK, marquage failed sinon). Nouveau task_kind "push_notices"
  dans le cron pour exécution planifiée.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:24:15 +02:00
6a69f36e83 update fonts 2026-05-11 09:00:56 +02:00
55 changed files with 9670 additions and 888 deletions

View file

@ -1,3 +1,9 @@
echo ".web/" > /opt/eptm-dashboard/.dockerignore
echo "__pycache__/" >> /opt/eptm-dashboard/.dockerignore
echo ".venv/" >> /opt/eptm-dashboard/.dockerignore
.web/
__pycache__/
.venv/
data/browser_profile/
data/cache/
data/*.db
data/*.db-*
logs/
.git/

295
DEPLOY_PROD.md Normal file
View file

@ -0,0 +1,295 @@
# Déploiement en production — EPTM Dashboard
Procédure de bascule de l'app **Streamlit legacy** (`absences.service`,
`dashboard.eptm-automation.ch`) vers l'app **Reflex** (en prod sur le
même sous-domaine). Document à retravailler avant exécution.
---
## État actuel (2026-05-11)
```
Internet :80/:443
┌────────────────────────────────────┐
│ NPM (nginx-proxy-manager) │
│ container "npm" sur proxy_net │
└────────────────────────────────────┘
├── dashboard.eptm-automation.ch
│ → 172.17.0.1:8501
│ → Streamlit legacy (systemd "absences.service")
│ /opt/absences/.venv/bin/streamlit run src/app.py
└── dev.dashboard.eptm-automation.ch
→ eptm-dashboard-app-1:3001 (Reflex dev)
+ /_event → :8001
(network proxy_net)
```
- Streamlit tourne en systemd `absences.service` (enabled, depuis le 2026-05-09).
- L'app Reflex dev est containerisée, déjà sur `proxy_net`, accessible via NPM.
- Pas encore de Dockerfile/compose **prod** : il faut les créer.
## État cible
```
Internet :80/:443 → NPM
├── dashboard.eptm-automation.ch
│ → eptm-dashboard-prod-app-1:3002 (Reflex prod)
│ + /_event → :8002
└── dev.dashboard.eptm-automation.ch
→ eptm-dashboard-app-1:3001 (Reflex dev, inchangé)
+ /_event → :8001
```
- Streamlit `absences.service` arrêté + disabled (binaires gardés en backup
quelques semaines, suppression plus tard).
- Deux stacks compose côte à côte sur `proxy_net`, ports internes
distincts (3001/8001 dev, 3002/8002 prod), aucun port host exposé.
- NPM continue de gérer les certs Let's Encrypt (déjà émis pour ce
sous-domaine, juste à conserver — pas de renouvellement à forcer).
---
## ⚠️ À clarifier avant exécution
| # | Question | Hypothèse par défaut |
|---|---|---|
| 1 | **Données** : prod partage `./data` avec dev ou stack séparée `./data-prod` ? | **Séparer** (`./data-prod`) — sinon un test en dev peut planter la prod (WAL OK mais migrations destructives non) |
| 2 | **Premier remplissage de la DB prod** : repartir d'un import frais (run_imports + sync_escada) ou copier `data/eptm.db` actuel ? | **Copier** la DB de dev au moment du cutover (snapshot cohérent) |
| 3 | **Streamlit après cutover** : on garde le service en `disabled` qq semaines, ou on purge `/opt/absences` ? | **Garder disabled** ~1 mois (rollback plan B), purge en tâche TODO |
| 4 | **Build** : local sur le serveur ou registry (GHCR / Docker Hub) ? | **Local** sur le serveur — pas de CI pour démarrer, on n'est qu'une instance |
| 5 | **Downtime acceptable** au cutover ? | ~30-60s pendant que NPM bascule de proxy host. Pas de zero-downtime |
| 6 | **`.env.prod`** : tester avec env identique à dev ? | À auditer ensemble (peut contenir `escada_username/password`, SMTP, etc.) |
---
## Fichiers à créer
### 1. `Dockerfile.prod` (multi-stage)
```dockerfile
# Stage 1 : builder — installe deps + export frontend
FROM python:3.13-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
nodejs npm curl unzip && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN reflex export --frontend-only --no-zip
# Stage 2 : runtime — backend granian uniquement
FROM python:3.13-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app /app
RUN pip install --no-cache-dir -r requirements.txt
ENV FRONTEND_PORT=3002 BACKEND_PORT=8002
EXPOSE 3002 8002
CMD ["reflex", "run", "--env", "prod"]
```
> NB : à vérifier — Reflex 0.9.x peut exiger `reflex run --backend-only` côté
> runtime si le frontend export est servi par le même process. **TODO :
> tester avec un build pilote avant cutover.**
### 2. `docker-compose.prod.yml`
```yaml
services:
app:
build:
context: .
dockerfile: Dockerfile.prod
image: eptm-dashboard-prod
container_name: eptm-dashboard-prod-app-1
init: true
restart: unless-stopped
volumes:
- ./data-prod:/app/data # ← séparé de dev (cf. Q1)
- ./logs-prod:/logs
env_file:
- .env.prod
environment:
- FRONTEND_PORT=3002
- BACKEND_PORT=8002
- API_URL=https://dashboard.eptm-automation.ch
- REFLEX_HOT_RELOAD_EXCLUDE_PATHS=/app/data
- TZ=Europe/Zurich
networks:
- default
- proxy_net
networks:
proxy_net:
external: true
```
### 3. `.env.prod`
À auditer — copier les valeurs sensibles depuis le `settings.json` plutôt
que de tout mettre en env (séparation cleane). Variables minimales :
- `DATA_DIR=/app/data` (déjà override probable)
- `(à voir avec l'user — TZ, secrets, etc.)`
---
## Procédure de cutover (jour J)
### J1 (préparation)
1. **Backup DB Streamlit** (si pertinent — vérifier si Streamlit a sa propre DB) :
```bash
sudo cp /opt/absences/data /opt/backups/absences-$(date +%F).tgz # à adapter
```
2. **Backup DB Reflex dev** (qui deviendra la prod) :
```bash
cp /opt/eptm-dashboard/data/eptm.db /opt/backups/eptm-pre-cutover-$(date +%F).db
```
3. Créer `Dockerfile.prod`, `docker-compose.prod.yml`, `.env.prod` (cf. ci-dessus).
4. Préparer `./data-prod/` (copie de `./data/` au moment opportun).
### J0 (cutover, ~10 min de fenêtre)
```bash
# 1. Build l'image prod (peut être fait avant la fenêtre — pas de downtime)
cd /opt/eptm-dashboard
docker compose -f docker-compose.prod.yml build app
# 2. Snapshot DB de dev → data-prod
cp -r data/ data-prod/
# (optionnel : purger data-prod/browser_profile et data-prod/pdfs si volumineux
# et resync-able depuis Escada)
# 3. Démarrer le container prod (encore inaccessible — NPM pointe encore sur Streamlit)
docker compose -f docker-compose.prod.yml up -d app
docker logs -f eptm-dashboard-prod-app-1 # vérifier "App running" puis Ctrl-C
# 4. Stopper Streamlit
sudo systemctl stop absences
sudo systemctl disable absences
# 5. Reconfigurer NPM proxy host #2 (dashboard.eptm-automation.ch)
# via UI https://npm.eptm-automation.ch :
# - Forward Hostname / IP : eptm-dashboard-prod-app-1
# - Forward Port : 3002
# - WebSocket support : ON
# - Custom location :
# Location : /_event
# Forward : eptm-dashboard-prod-app-1:8002
# Advanced : proxy_read_timeout 86400;
# - SSL : conserver le certificat Let's Encrypt déjà émis pour
# ce domaine, Force SSL, HSTS, HTTP/2
# 6. Recharger NPM si l'UI ne le fait pas auto :
docker exec npm nginx -s reload
# 7. Vérification
curl -I https://dashboard.eptm-automation.ch
# → doit retourner 200 + headers Reflex (Server: granian)
```
### J+1 → J+30 (stabilisation)
- Garder `absences.service` en `disabled` (rollback rapide possible).
- Surveiller `docker logs -f eptm-dashboard-prod-app-1` + `data-prod/logs/`.
- Si stable après ~1 mois : purger `/opt/absences/`, retirer le user systemd file.
---
## Rollback (si quelque chose plante après le cutover)
```bash
# 1. Rebasculer NPM sur Streamlit
# UI NPM → proxy host #2 → Forward : 172.17.0.1:8501
# 2. Relancer Streamlit
sudo systemctl start absences
# 3. Stopper le container prod Reflex
docker compose -f docker-compose.prod.yml down
# 4. Investiguer les logs Reflex tranquillement
docker logs eptm-dashboard-prod-app-1 > /tmp/cutover-fail.log
```
---
## Workflow déploiements suivants
Une fois la prod en place :
```bash
# 1. Dev : commit & push
git add -A && git commit -m "feat: xxx" && git push
# 2. Build nouvelle image prod
cd /opt/eptm-dashboard
docker compose -f docker-compose.prod.yml build app
# 3. Redémarrer le container (downtime ~10s)
docker compose -f docker-compose.prod.yml up -d app
# 4. Vérifier
docker logs -f eptm-dashboard-prod-app-1
curl -I https://dashboard.eptm-automation.ch
```
Optionnel : tagger les versions prod (`git tag -a prod-2026-05-15 && git push --tags`).
---
## Caveats / pièges à surveiller
- **WebSocket NPM** : déjà OK en dev (cf. memory NPM). Reproduire **exactement**
la même config sur proxy host #2 : WS support cocheé + custom location
`/_event` avec `proxy_read_timeout 86400`. Sans ça, le state Reflex ne
fonctionne pas.
- **`API_URL`** : doit matcher l'URL publique exacte (`https://dashboard.eptm-automation.ch`),
sinon le frontend ne joint pas le backend.
- **Ports internes prod** : `3002`/`8002` (pas `3001`/`8001` qui sont pris par dev).
- **`reflex export`** prend 1-5 min (npm install + bundle). À faire **avant**
la fenêtre de cutover.
- **`data-prod/browser_profile/`** : éviter de copier — le profil Chrome
contient une session SSO valide pour le compte de dev. La prod doit re-login
au premier sync Escada (ouvrir un Chromium visible avec `headless=False`
une fois, ou pré-importer le profile sur un compte service dédié).
- **localStorage des users connectés** : le LocalStorage du browser des
utilisateurs survit au cutover (clés `username`, `theme`, etc.) — ils ne
seront pas déconnectés.
- **DNS / certs** : `dashboard.eptm-automation.ch` pointe déjà sur l'IP
publique du serveur, le cert NPM est déjà émis et auto-renouvelé. Pas
d'action DNS/cert nécessaire.
- **Backups DB** : ajouter un cron quotidien après cutover :
```cron
0 3 * * * cp /opt/eptm-dashboard/data-prod/eptm.db /opt/backups/eptm-$(date +\%F).db
```
---
## Annexe : commandes de check post-déploiement
```bash
# Container prod up ?
docker ps --filter name=eptm-dashboard-prod-app-1
# Logs récents
docker logs --tail 50 eptm-dashboard-prod-app-1
# Proxy NPM répond ?
curl -fsS -o /dev/null -w "HTTP %{http_code}\n" https://dashboard.eptm-automation.ch/login
# WS handshake OK ?
curl -fsS -o /dev/null -w "HTTP %{http_code}\n" \
-H "Upgrade: websocket" -H "Connection: Upgrade" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Sec-WebSocket-Version: 13" \
https://dashboard.eptm-automation.ch/_event
# → attend HTTP 101 (Switching Protocols)
```

331
ESCADAWEB_AUTH.md Normal file
View file

@ -0,0 +1,331 @@
# Escadaweb — Login + 2FA + Forçage langue (Playwright)
Documentation des briques réutilisables pour automatiser une session
Escadaweb (`escadaweb.vs.ch`) : login Keycloak, code TOTP, forçage du
français côté UI. Issu de [scripts/sync_esacada.py](scripts/sync_esacada.py).
## Pré-requis
```
pip install playwright pyotp
playwright install chromium
```
## Configuration (`data/settings.json`)
Les identifiants et secrets ne sont pas hardcodés — ils viennent du
fichier `data/settings.json` (édité depuis `/params` dans l'app).
```json
{
"escada_username": "prenom.nom@vs.ch",
"escada_password": "•••",
"totp_secret": "BASE32SECRET"
}
```
Sans `escada_username` / `escada_password` → le script affiche la
fenêtre Chromium et attend que tu te connectes à la main (utile pour le
1er run, où le profil persistant Chromium se construit).
Sans `totp_secret` → le code TOTP doit être saisi à la main. Pour le
récupérer la 1ère fois, scanne le QR proposé par Escadaweb avec
[Aegis/Authy/etc.] **ET** copie le secret BASE32 sous-jacent (souvent
caché derrière un lien "Saisie manuelle").
## URLs Escadaweb
```python
BASE_URL = "https://escadaweb.vs.ch"
LEHRPERSONEN_URL = f"{BASE_URL}/Lehrpersonen"
CLASSES_URL = f"{BASE_URL}/Lehrpersonen/ViewKlassen.aspx"
EINSTELLUNGEN_URL = f"{BASE_URL}/Lehrpersonen/Dialogs/DlgEinstellungen.aspx"
```
Le subpath `/Lehrpersonen/` est **obligatoire** — sans lui le SSO ne
sert pas la bonne app et le login échoue silencieusement.
## Profil Chromium persistant
Indispensable : permet de garder le cookie de session Keycloak entre
les runs, sinon tu refais 2FA à chaque fois.
```python
from pathlib import Path
from playwright.sync_api import sync_playwright
PROFILE_DIR = Path("data/browser_profile") # adapter au projet
def _launch_context():
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
pw = sync_playwright().start()
ctx = pw.chromium.launch_persistent_context(
str(PROFILE_DIR),
headless=True, # False pour debug visuel
args=["--start-maximized", "--disable-popup-blocking"],
accept_downloads=True,
)
page = ctx.pages[0] if ctx.pages else ctx.new_page()
return pw, ctx, page
```
Note : `launch_persistent_context` ouvre toujours un seul context ; on
récupère le premier page existant pour éviter d'avoir un onglet vide en
plus du nôtre.
## Code à coller
### Helpers settings
```python
import json
from pathlib import Path
_ROOT = Path(__file__).resolve().parent.parent # adapter au projet
def _load_settings() -> dict:
p = _ROOT / "data" / "settings.json"
if not p.exists():
return {}
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception:
return {}
def _load_totp_secret() -> str | None:
return _load_settings().get("totp_secret") or None
def _load_escada_creds() -> tuple[str, str]:
s = _load_settings()
return s.get("escada_username", ""), s.get("escada_password", "")
```
### 1) Remplir le formulaire Keycloak
Sélecteurs exacts du formulaire SSO (edusso.apps.vs.ch) :
```html
<input id="username" name="username" type="text">
<input id="password" name="password" type="password">
<input id="kc-login" name="login" type="submit">
```
```python
from playwright.sync_api import Page
def _try_fill_login(page: Page) -> bool:
username, password = _load_escada_creds()
if not username or not password:
return False
try:
page.wait_for_selector("input#username", state="visible", timeout=5_000)
page.wait_for_selector("input#password", state="visible", timeout=2_000)
page.locator("input#username").fill(username)
page.locator("input#password").fill(password)
try:
page.locator("input#kc-login").click(timeout=2_000)
except Exception:
page.locator("input#password").press("Enter")
return True
except Exception:
return False
```
### 2) Remplir le code TOTP
Le champ OTP de Keycloak est rendu **caché** par CSS avant la
vérification — un `page.fill()` Playwright standard échoue
("locator not visible"). On contourne via `page.evaluate()` (JS pur)
qui injecte la valeur et déclenche les events.
```python
import pyotp
def _try_fill_totp(page: Page, secret: str) -> bool:
try:
code = pyotp.TOTP(secret).now()
result = page.evaluate("""(code) => {
const inp = document.querySelector('#otp')
|| document.querySelector('[name="otp"]')
|| document.querySelector('[autocomplete="one-time-code"]')
|| document.querySelector('input[type="text"]:not([type="hidden"])');
if (!inp) return 'not_found';
inp.value = code;
inp.dispatchEvent(new Event('input', {bubbles: true}));
inp.dispatchEvent(new Event('change', {bubbles: true}));
return 'filled';
}""", code)
if result != "filled":
return False
submitted = page.evaluate("""() => {
const btn = document.querySelector('input[type="submit"]')
|| document.querySelector('button[type="submit"]');
if (btn) { btn.click(); return 'clicked'; }
const form = document.querySelector('form');
if (form) { form.submit(); return 'submitted'; }
return 'no_submit';
}""")
return submitted in ("clicked", "submitted")
except Exception:
return False
```
### 3) Forcer le français côté UI
Tout le parsing du HTML (libellés boutons, en-têtes de tableau, etc.)
suppose le français. Si l'utilisateur a la session en allemand, on
bascule via `DlgEinstellungen` :
```python
import sys
_lang_ok = False # mémo process : évite de re-naviguer à chaque appel
def _ensure_french_language(page: Page) -> None:
global _lang_ok
if _lang_ok:
return
page.goto(EINSTELLUNGEN_URL, wait_until="domcontentloaded", timeout=15_000)
try:
page.wait_for_load_state("networkidle", timeout=8_000)
except Exception:
pass
inp_loc = page.locator("#ContentPlaceHolderSite_DropDownList_sprache_I")
try:
inp_loc.wait_for(state="visible", timeout=8_000)
except Exception:
print("ERR [LANG] Dropdown langue introuvable — arrêt.")
sys.exit(1)
cur_val = inp_loc.input_value()
if cur_val != "français":
page.evaluate("""() => {
const inp = document.querySelector('#ContentPlaceHolderSite_DropDownList_sprache_I');
if (inp) {
inp.value = 'français';
ASPx.ETextChanged('ContentPlaceHolderSite_DropDownList_sprache');
}
}""")
page.locator("span.dx-vam:has-text('Speichern')").first.click()
try:
page.wait_for_load_state("networkidle", timeout=10_000)
except Exception:
pass
# Attendre que le grid soit prêt avant de rendre la main
try:
page.wait_for_selector(
"a[href*='ViewAbsenzenErweitert']", state="attached", timeout=30_000
)
except Exception:
pass
else:
page.goto(CLASSES_URL, wait_until="domcontentloaded", timeout=15_000)
_lang_ok = True
```
⚠️ Le bouton "Enregistrer" est resté libellé `Speichern` (allemand)
même après changement de langue côté UI — bug d'Escadaweb. Le sélecteur
ci-dessus matche ce label tel quel.
### 4) Orchestrateur
```python
import time
from playwright.sync_api import TimeoutError as PWTimeout, Error as PWError
def _ensure_logged_in(page: Page) -> None:
"""Si on est déjà sur ViewKlassen, juste vérifier la langue. Sinon,
boucle pendant 5 min : remplit login + TOTP automatiquement (si
secrets configurés), ou laisse l'utilisateur taper en manuel."""
if "ViewKlassen" in page.url:
_ensure_french_language(page)
return
_totp_secret = _load_totp_secret()
_username, _password = _load_escada_creds()
cur = page.url.lower()
if "login" not in cur and "logon" not in cur and "viewklassen" not in cur:
page.goto(LEHRPERSONEN_URL)
deadline = time.time() + 300 # 5 min
_last_login = 0.0
_last_totp = 0.0
while time.time() < deadline:
try:
if "ViewKlassen" in page.url:
_ensure_french_language(page)
return
if _username and _password and (time.time() - _last_login) > 5:
if _try_fill_login(page):
_last_login = time.time()
try:
page.wait_for_load_state("networkidle", timeout=8_000)
except (PWTimeout, PWError):
pass
if _totp_secret and (time.time() - _last_totp) > 5:
if _try_fill_totp(page, _totp_secret):
_last_totp = time.time()
try:
page.wait_for_url("**ViewKlassen**", timeout=10_000)
return
except (PWTimeout, PWError):
pass
page.wait_for_timeout(800)
except PWError:
if "ViewKlassen" in page.url:
_ensure_french_language(page)
return
print("ERR Délai de connexion dépassé (5 min).")
sys.exit(1)
```
## Exemple minimal
```python
pw, ctx, page = _launch_context()
try:
page.goto(CLASSES_URL)
_ensure_logged_in(page)
# → à ce point : session active, langue = français, on est sur
# /Lehrpersonen/ViewKlassen.aspx. Le reste du script peut tourner.
print("Connecté :", page.url)
finally:
ctx.close()
pw.stop()
```
## Pièges et points d'attention
- **Toujours `page.goto(CLASSES_URL)` avant `_ensure_logged_in()`**. Si
on attaque une autre page (par ex. `ViewLernende`) sans session, on
rebondit sur une URL Keycloak où les sélecteurs de login ne sont pas
les mêmes, et `_try_fill_login` échoue.
- **Throttling** : on attend 5 s entre deux tentatives de fill login /
TOTP pour ne pas spammer le serveur ni déclencher un anti-bot.
- **Pas de `time.sleep()` long** : utiliser `page.wait_for_timeout(ms)`
(Playwright) ou `wait_for_load_state` pour laisser le navigateur
traiter ses événements.
- **`headless=True`** convient pour la prod ; pour debug, passer
`headless=False` permet de voir ce qui se passe.
- **Le profil Chromium persistant** garde la session vivante des jours
(cookie SSO long terme). Si tu supprimes `data/browser_profile/`, tu
repars de zéro et le 2FA sera redemandé.
- **Multi-instance** : un profil persistant Chromium ne peut être
ouvert que par **un seul process à la fois**. Si tu paralléllises,
copier le profil ou utiliser `launch()` + cookies exportés.
## Fichiers de référence
- [scripts/sync_esacada.py](scripts/sync_esacada.py) — implémentation
d'origine (sync des absences)
- [scripts/push_notices.py](scripts/push_notices.py) — réutilise
`_ensure_logged_in` via import
- [scripts/push_to_escada.py](scripts/push_to_escada.py) — idem

47
TODO.md Normal file
View file

@ -0,0 +1,47 @@
# TODO — EPTM Dashboard
Liste des fonctionnalités à implémenter plus tard. Format libre — ajouter
en haut de la section concernée.
## Idées / fonctionnalités
- [X] Afficher toutes les notes du BN
- [X] Mettre à jour les MD (réalisé le 2026-05-12, doc complète incl. nouveaux chapitres 11-avis, 12-feedback, 13-parametres)
- [X] Ajouter l'indication des compensation des désavantages
- [X] Ajouter le TAB notices aussi sur la vue classe
- [X] Réussir à récupérer le fichier session Esacada d'un utilisateur pour l'utiliser sur le serveur afin de récupérer la liste des classes dont il a accès et de pouvoir uploader les notices avec son nom propre
- [X] Filtrer que les classes EM pour les avis de sanction
- [ ] Ajouter sur la page apprenti sa situation au niveau des sanctions (barre de progression en fonction des notices)
- [X] Ajouter dans le texte des notices qui a créé la notice (USER) en attendant d'avoir une identification spécifique escada à chaque utilisateur.
- [X] Mettre dans les tâches CRON les heures et pas chaque x minutes
- [X] Modifier l'adresse du destinataire des avis de sanction/absences -> représentant légal pour les mineurs, apprenti pour les majeurs
- [X] Changer le texte de l'objet dans les mails apprentis
- [X] Ajouter dans le sidebar la version GIT du document.
- [X] Ajouter bouton "Absent toute la journée" avec filtre des périodes en fonction des classes
- [X] Ajouter dans l'export des absences s'il s'agit dun jour de théorie/pratique/matu
- [X] Renommer les pages : « Vue classe » → « Classes », « Fiche apprenti » → « Apprentis » + réordonner sidebar (Classes au-dessus d'Apprentis)
- [X] Cron : supprimer les schedules `daily` et `interval`, ne garder que `daily_multi` (grille 24 cases) + `weekly`. Migration auto au boot.
- [X] Bouton « Absent toute la journée » : griser + libellé « (Données chronoplan manquantes) » si pas de mapping configuré
- [X] Ajouter dans le panneau d'édition d'absences un badge couleur Théorie / Pratique / Matu selon le jour
## Bugs connus
- [ ] Dans les logs, en mode Live, la fenêtre ne défile pas toute seule au fond. Mettre les dernières lignes en premier?
- [X] Liens entre apprenti sur classe vers apprenti ne fonctionne pas
## Améliorations UX
- [ ] Faire un thème avec fond foncé
- [ ] Lancer une optimisation des toasts
- [X] Changer la couleur du bouton Générer l'avais de sanction
- [X] rendre plus petit la bulle dans le logo chat et changer le titre (enlever EPTM)
- [X] Utiliser les mêmes PKIs, boutons télécharger et création des avis sur la page classe que sur la page apprenti
- [X] Simplifier les cards apprentis sur la page classe (infos principales)
- [X] Ajouter sur le dashboard l'affichage des notes insuffisantes
## Notes / réflexions
-

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1,4 +1,214 @@
/* Reset default margins and padding */
/* ── Fonts (self-hosted) ─────────────────────────────────────────────────── */
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 100 900; /* variable font : toutes les graisses dans un fichier */
font-display: swap;
src: url("/fonts/InterVariable.woff2") format("woff2-variations"),
url("/fonts/InterVariable.woff2") format("woff2");
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/fonts/JetBrainsMono-Regular.woff2") format("woff2");
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/fonts/JetBrainsMono-Bold.woff2") format("woff2");
}
/* Override Radix Themes default font + smoothing global */
:root,
.radix-themes {
--default-font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--heading-font-family: "Inter", system-ui, sans-serif;
--code-font-family: "JetBrains Mono", ui-monospace, "SF Mono", Menlo,
Consolas, monospace;
--strong-font-family: var(--default-font-family);
--quote-font-family: var(--default-font-family);
}
/* ── Utility classes ─────────────────────────────────────────────────────── */
/* Scroll discret : pas de scrollbar visible mais la zone scroll quand même
(utilisé par le chat de feedback). */
.no-scrollbar { scrollbar-width: none; -ms-overflow-style: none; }
.no-scrollbar::-webkit-scrollbar { display: none; }
/* Bouton flottant de feedback (FAB) : on garde le cercle bleu (taille Radix
"3") mais on réduit l'icône à l'intérieur de 20% (36 29 px). Radix
sur-écrit la prop size de rx.icon donc on force via CSS, en ciblant par
l'attribut title du bouton (propagé au DOM contrairement à class_name). */
button[title="Signaler un bug ou proposer une idée"] svg {
width: 23px !important;
height: 23px !important;
}
/* Badge avec animation pulse — utilisé pour indiquer les messages non lus. */
@keyframes feedback-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.15); opacity: 0.85; }
}
.pulse-badge {
animation: feedback-pulse 1.5s ease-in-out infinite;
}
/* Brand tokens (thèmes utilisateur)
Tokens utilisés par l'app pour les couleurs de marque. Chaque thème surcharge
ces variables via [data-theme="..."] sur <html>.
Les couleurs sémantiques des notes (rouge<4 / orange<5 / vert>=5) restent
hardcodées dans fiche.py / classe.py et NE doivent PAS utiliser ces tokens. */
:root {
--brand-primary: #dc000e; /* EPTM red, theme-color meta */
--brand-primary-dark: #c62828; /* KPI rouges, sidebar active */
--brand-primary-tint: rgba(220, 0, 14, 0.18); /* sidebar active bg */
--brand-primary-light: #ff4a54; /* sidebar active text */
--brand-accent: #1565c0; /* liens, infos, sélection */
--brand-accent-soft: #e3f2fd; /* fond pâle pour bannières d'info */
/* Surfaces et texte (light par défaut) */
--surface: white; /* cartes, modales */
--surface-soft: #fafafa; /* fond de page secondaire */
--surface-muted: #f8f9fa; /* sidebar, sections grisées */
--surface-hover: #f3f4f6; /* survol */
--text-strong: #37474f; /* titres, texte fort */
--text-soft: #4b5563; /* texte courant */
--text-muted: #9ca3af; /* labels */
--border: #e0e0e0; /* borders cartes */
--border-soft: #e5e7eb; /* séparateurs subtils */
/* color-scheme: light empêche le browser d'appliquer le dark mode
système sur le body/form controls/scrollbars. Critique en clair forcé. */
color-scheme: light;
}
/* Fond explicite sur <html> et <body> sinon le browser tombe sur le
défaut système (noir si OS en dark mode). Sans ça, en bleu/indigo/vert
etc., le contenu hors radix-themes hérite du fond dark système. */
html, body {
background-color: white;
color-scheme: light;
}
[data-theme="bleu"] {
--brand-primary: #1565c0;
--brand-primary-dark: #0d47a1;
--brand-primary-tint: rgba(21, 101, 192, 0.18);
--brand-primary-light: #42a5f5;
--brand-accent: #1976d2;
--brand-accent-soft: #e3f2fd;
}
[data-theme="indigo"] {
--brand-primary: #3f51b5;
--brand-primary-dark: #283593;
--brand-primary-tint: rgba(63, 81, 181, 0.18);
--brand-primary-light: #7986cb;
--brand-accent: #5c6bc0;
--brand-accent-soft: #e8eaf6;
}
[data-theme="vert"] {
--brand-primary: #2e7d32;
--brand-primary-dark: #1b5e20;
--brand-primary-tint: rgba(46, 125, 50, 0.18);
--brand-primary-light: #66bb6a;
--brand-accent: #00695c;
--brand-accent-soft: #e8f5e9;
}
/* Thème sombre
Palette zinc + accent bleu unique. Override aussi les variables Radix
`--gray-*` pour que tous les composants Radix s'adaptent. */
[data-theme="sombre"] {
/* Accent unique (bleu) — remplace la couleur de marque rouge EPTM */
--brand-primary: #3B82F6;
--brand-primary-dark: #1E40AF;
--brand-primary-tint: rgba(59, 130, 246, 0.18);
--brand-primary-light: #60A5FA;
--brand-accent: #3B82F6;
--brand-accent-soft: #1E3A5F;
/* Surfaces */
--surface: #141416; /* cartes, panneaux */
--surface-soft: #0A0A0B; /* fond de page secondaire */
--surface-muted: #141416; /* sidebar, sections grisées */
--surface-hover: #26262A; /* survol / actif */
/* Texte */
--text-strong: #F5F5F7; /* texte principal */
--text-soft: #A1A1AA; /* texte secondaire */
--text-muted: #71717A; /* labels / metadata */
/* Borders */
--border: #33333A; /* visibles (séparateurs, inputs) */
--border-soft: #26262A; /* subtiles */
}
/* Override Radix gray scale (palette zinc-like cohérente avec ci-dessus). */
[data-theme="sombre"],
[data-theme="sombre"] .radix-themes {
--gray-1: #0A0A0B;
--gray-2: #141416;
--gray-3: #1C1C1F;
--gray-4: #26262A;
--gray-5: #33333A;
--gray-6: #3F3F46;
--gray-7: #52525B;
--gray-8: #71717A;
--gray-9: #A1A1AA;
--gray-10: #C4C4C9;
--gray-11: #D4D4D8;
--gray-12: #F5F5F7;
}
/* Page body en sombre + color-scheme dark */
[data-theme="sombre"], [data-theme="sombre"] body, html[data-theme="sombre"] {
color-scheme: dark;
}
[data-theme="sombre"] body {
background-color: #0A0A0B;
color: var(--text-strong);
}
body {
font-family: var(--default-font-family);
/* Activations Inter : cv11 = 1 sans empattement, ss01 = a/g modernes,
cv02 = G plus carré. Affiche un rendu plus net en UI. */
font-feature-settings: "cv11", "ss01", "cv02";
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
/* Chiffres à largeur fixe (tabular nums) KPI, tables de notes/absences,
* timestamps de logs. Aligne verticalement même en mélangeant 1 et 8. */
.tabular,
.log-content,
.log-ts,
.doc-content table td,
.doc-content table th {
font-variant-numeric: tabular-nums;
}
/* Tighten letter-spacing on headings — Inter looks crisper this way. */
h1, h2, h3, h4, h5, h6,
.rt-Heading {
letter-spacing: -0.011em;
font-feature-settings: "cv11", "ss01", "ss03";
}
/* ── Reset default margins and padding ───────────────────────────────────── */
* {
box-sizing: border-box;
}
@ -181,7 +391,7 @@ img {
/* ── Logs viewer (page /logs) ──────────────────────────────────────────── */
.log-content {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-family: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.78rem;
line-height: 1.55;
color: #cbd5e1;
@ -243,7 +453,7 @@ img {
padding: 0.1rem 0.35rem;
border-radius: 4px;
font-size: 0.88em;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
}
.doc-content pre {
background-color: #1e293b;

1
data/VERSION Normal file
View file

@ -0,0 +1 @@
1.0.0

View file

@ -11,12 +11,34 @@ credentials:
password: $2b$12$kigcAqfs9VIySuVHxenU6uTyk/8ef7DrzybCFCzw.iZOZTpzxVsOi
role: admin
smtp_password: 17acdfd671d8ab
theme: bleu
totp_secret: H6QDWOPHK4GBT447VCKI6VDKEEUVFQZY
test:
allowed_classes:
- AUTOMAT 1
- AUTOMAT 2
- AUTOMAT 3
- AUTOMAT 4
- CFTI-AU 1A
- CFTI-AU 1B
- CFTI-AU 2
- EM-AU 1
- EM-AU 1A
- EM-AU 1B
- EM-AU 2
- EM-AU 2A
- EM-AU 2B
- EM-AU 3
- EM-AU 3A
- EM-AU 3B
- EM-AU 4
- MONTAUT 1
- MONTAUT 2
- MONTAUT 3
- Z-IT Test 1
email: julien@balet-vs.ch
escada_password: Lauryne2023!
escada_username: julien.balet@edu.vs.ch
name: test
password: $2b$12$dAvkehPvcU5zokLhUzjswu7APcRi1e4C1IeR6Gc7/51wh9vGTl4MS
role: user

View file

@ -32,15 +32,15 @@
"MP1-TASV 4D:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=3bc53c77-bda8-43a4-ab47-08c2eb84a917",
"MP1-TASV 4E:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=7673335b-a2aa-42de-9cab-6b95ffc90d7c",
"Z-IT Test 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=f0707cc3-4511-403b-a2a6-d79f2da4fd99",
"AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=9f1ccf7f-d9fe-4618-bd54-b623c1f86e2b",
"AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=6ca387b9-988c-47fb-a172-4922653ccef7",
"AUTOMAT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=45e09633-a21f-4859-a994-a0cd643428de",
"AUTOMAT 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=f328bcb8-aa88-4383-b18f-11a829f6f755",
"EM-AU 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=aecd7352-f131-4395-a530-4a4551ab83c1",
"EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=2087d329-f44e-4a42-9621-b75a8f935a08",
"EM-AU 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=cddbebeb-d574-45dc-b0b1-a17ed4baf2cf",
"EM-AU 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=053894a2-174b-4716-9ca5-26ade1d21891",
"MONTAUT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=db1e76bd-c224-4cc3-bc80-0f5881a11550",
"MONTAUT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=0a2cf951-9097-4207-9560-1a72562c01a8",
"MONTAUT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=1c431c09-25de-4640-b887-930d028bfbb5"
"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,42 +1,75 @@
# Vue d'ensemble
EPTM Dashboard est une application de gestion des absences, notes et bulletins pour l'École professionnelle technique et des métiers (EPTM Sion / Monthey). Elle se synchronise avec **Escadaweb** (le système de notation cantonal) pour récupérer et pousser les données.
EPTM Dashboard est une application de gestion des absences, notes, bulletins, avis (retenue / sanction) et notices pour l'École professionnelle technique et des métiers (EPTM Sion). Elle se synchronise avec **Escadaweb** (le système de notation cantonal) pour récupérer et pousser les données.
## À quoi sert l'application
- **Visualiser les absences** par apprenti ou par classe, avec calendrier mensuel et statistiques.
- **Visualiser** les absences, BN, notes d'examen, notices et fiches personnelles par apprenti ou par classe.
- **Excuser ou modifier** les absences manuellement (les changements sont mis en file d'attente avant d'être renvoyés à Escada).
- **Récupérer les bulletins de notes (BN)**, les notes d'examen et les fiches personnelles depuis Escada.
- **Créer des avis** de retenue et de sanction au format PDF (templates AcroForm pré-remplis) — l'avis est aussi envoyé en notice vers Escada.
- **Récupérer / pousser** les notices Escada (création, lecture, statut).
- **Automatiser** les imports/exports via des tâches planifiées (cron) avec notifications Telegram.
- **Tracer** qui a modifié quoi (audit log complet).
- **Envoyer par email** un récap d'absences (+ bulletin + notes) à l'apprenti, au formateur ou à une adresse libre.
- **Tracer** qui a modifié quoi (audit log complet + champ `updated_by` sur chaque entité).
- **Collecter du feedback** in-app via un widget chat (admin reçoit un email + dialogue de réponse).
- **Gérer les droits utilisateur** : restriction d'accès par classe, self-service via identifiants Escada de l'utilisateur.
## Modèle de données simplifié
```
Apprenti ── Absence (avec statut: a_traiter, excusee, ...)
├── ApprentiFiche (données personnelles : adresse, entreprise, formateur)
Apprenti ── Absence (statut : a_traiter, excusee, ...)
├── ApprentiFiche (adresse, entreprise, formateur, représentant
│ légal, compensation des désavantages, majeur/mineur)
├── NotesBulletin (BN par semestre)
├── NotesMatu (Matu pro)
└── NotesExamen (notes d'examen finales)
├── NotesExamen (notes d'examen)
├── ApprentiNotice (notices importées depuis Escada)
└── Notice (notices locales, file de push vers Escada)
EscadaPending : file d'attente des modifications locales à pousser vers Escada
(action ∈ {"E", "N", "clear"})
EscadaPending : file d'attente des modifications d'absences locales à
pousser vers Escada (action ∈ {"E", "N", "clear"})
Import / ImportBN / ImportMatu : trace des imports PDF effectués
CronJob : tâches planifiées (push, sync, push+sync)
CronJob : tâches planifiées (push, sync, push+sync) — schedules
daily_multi (plusieurs heures/jour) ou weekly
FeedbackMessage : feedback in-app (bug / proposition) — statut new /
in_progress / resolved + réponse admin
```
## Architecture technique
- **Frontend** : Reflex 0.9.2 (Python full-stack avec Radix Themes + Tailwind-friendly)
- **DB** : SQLite en mode WAL, à `data/absences.db`
- **Scraping Escada** : Selenium / Playwright, dans `scripts/sync_esacada.py` et `scripts/push_to_escada.py`
- **Frontend** : Reflex 0.9.2 (Python full-stack, Radix Themes, lucide-react icons)
- **DB** : SQLite mode WAL, à `data/absences.db`
- **Scraping Escada** : Playwright (sync API), dans `scripts/sync_esacada.py`, `scripts/push_to_escada.py`, `scripts/push_notices.py`, `scripts/pull_notices.py`, `scripts/fetch_user_classes.py`
- **Parsing PDF** : `src/parser.py` (absences), `src/parser_bn.py` (bulletins), `src/parser_matu.py` (matu)
- **Conteneurisation** : Docker Compose, derrière nginx-proxy-manager
- **Génération avis PDF** : `src/sanction_pdf.py`, `src/retenue_pdf.py` (templates AcroForm dans `data/templates/`)
- **Conteneurisation** : Docker Compose, derrière nginx-proxy-manager (NPM, containerisé depuis mai 2026)
- **Cron** : OS cron déclenche `scripts/cron_tick.py` toutes les minutes, qui consulte la table `CronJob`
- **Emails** : SMTP (Brevo en prod), templates configurables depuis `/params`
## Rôles utilisateurs
## Pages disponibles
- **user** : accès aux pages Tableau de bord, Apprentis, Classes (lecture + édition d'absences).
- **admin** : tout ce qui précède + Escada, Cron, Logs, Utilisateurs, Paramètres.
| Page | Route | user | admin |
|----------------------------|--------------|------|-------|
| Tableau de bord | `/accueil` | ✅ | ✅ |
| Classes | `/classe` | ✅* | ✅ |
| Apprentis | `/fiche` | ✅* | ✅ |
| Documentation | `/doc` | ✅ | ✅ |
| Mon profil | `/profile` | ✅ | ✅ |
| Escada (sync / push) | `/escada` | ❌ | ✅ |
| Tâches planifiées | `/cron` | ❌ | ✅ |
| Logs | `/logs` | ❌ | ✅ |
| Utilisateurs | `/users` | ❌ | ✅ |
| Paramètres | `/params` | ❌ | ✅ |
| Feedback | `/feedback` | ❌ | ✅ |
| Purger classe | `/purge` | ❌ | ✅ |
✅* : restriction par `allowed_classes` (l'utilisateur ne voit que ses classes autorisées). Si la liste est vide, un dialogue d'enrôlement obligatoire s'affiche à chaque navigation (cf. doc « Authentification »).
## Sidebar — version & profil
- La **version** (dernier tag git ou contenu de `data/VERSION`) s'affiche au-dessus du widget profil. À mettre à jour manuellement après un nouveau tag : éditer `data/VERSION` puis `docker restart eptm-dashboard-app-1`.
- Le **widget profil** ouvre un popover avec « Mon profil » + « Déconnexion ».
- Le **bouton feedback** flotte en bas-droite de l'écran (FAB) : ouvre la modale chat pour signaler un bug ou proposer une idée.

View file

@ -1,14 +1,14 @@
# Synchronisation Escada (pull)
La synchronisation depuis Escada télécharge les PDFs (absences, BN, notes, fiches) et les importe en base. C'est l'opération la plus complexe de l'application.
La synchronisation depuis Escada télécharge les PDFs / vues (absences, BN, Matu, notes, fiches, notices) et les importe en base. C'est l'opération la plus complexe de l'application.
## Page : `/escada`
### Sélection des classes
La liste des classes vient d'un cache disque (`data/esacada_classes.json`) rempli via le bouton **Actualiser**. Le rafraîchissement lance une session Selenium qui se connecte à Escadaweb et récupère la liste complète des classes.
La liste des classes vient d'un cache disque (`data/esacada_classes.json`) rempli via le bouton **Actualiser**. Le rafraîchissement lance une session Playwright qui se connecte à Escadaweb et récupère la liste complète des classes.
> Note : MP, MI et "Formation" sont **filtrées de l'affichage UI** mais conservées dans le cache (elles servent au matching Matu pro).
> Note : MP, MI et « Formation » sont **filtrées de l'affichage UI** mais conservées dans le cache (elles servent au matching Matu pro).
### Options de synchronisation
@ -17,11 +17,12 @@ La liste des classes vient d'un cache disque (`data/esacada_classes.json`) rempl
| Absences | Télécharge les PDFs d'absences + parse + import |
| BN | Bulletins de notes + moyennes Matu (semestres complets) |
| Notes | Notes d'examens finales |
| Données apprentis | Fiches personnelles : adresse, entreprise, formateur |
| Données apprentis | Fiches personnelles : adresse, entreprise, formateur, représentant légal, statut compensation des désavantages, majeur/mineur |
| Notices | Importe l'historique des notices Escada (table `apprenti_notices`) |
### Force réimportation complète
La case "Forcer la réimportation complète" (signalée en jaune) **écrase tous les statuts d'absences** côté local par les valeurs du PDF, et **vide les pendings** des absences concernées.
La case « Forcer la réimportation complète » (signalée en jaune) **écrase tous les statuts d'absences** côté local par les valeurs du PDF, et **vide les pendings** des absences concernées.
À utiliser uniquement quand on veut **reprendre l'état complet d'Escada** (par exemple après un test ou une corruption locale).
@ -32,39 +33,56 @@ Sans ce flag :
## Phases d'exécution
### Phase 1 : Scraping (Selenium)
### Phase 1 : Scraping (Playwright)
`scripts/sync_esacada.py --sync-all CLASSE1 CLASSE2 ...`
1. Selenium ouvre Escadaweb avec un profil persistant (`data/browser_profile/`)
1. Playwright ouvre Escadaweb avec un profil persistant (`data/browser_profile/`).
2. Pour chaque classe sélectionnée :
- Télécharge le PDF d'absences
- Télécharge le PDF de bulletin
- Télécharge le PDF de notes
- Pour les apprentis Matu : télécharge le PDF Matu de la classe MP correspondante
- Scrape les fiches personnelles (vue ViewLernende)
3. À la fin, écrit `ALL_DONE` dans la sortie standard et `data/sync_all_done.json` (timestamp).
- Scrape les fiches personnelles (vue ViewLernende — y compris représentant légal + flag compensation)
- Si l'option « Notices » est cochée : pull l'historique via `pull_notices.py`
3. À la fin, écrit `ALL_DONE` dans la sortie standard et `data/sync_last_result.json` (timestamp).
### Phase 2 : Import en DB
`scripts/run_imports.py` est lancé par le wrapper après réception du signal `ALL_DONE` :
1. Parse chaque PDF d'absences → upsert des `Absence` (avec déduplication sur (apprenti, date, période))
2. Parse les BN → insère `NotesBulletin`
1. Parse chaque PDF d'absences → upsert des `Absence` (déduplication sur (apprenti, date, période))
2. Parse les BN → insère `NotesBulletin` (toutes les notes du BN sont stockées, pas seulement les moyennes)
3. Parse les notes → insère `NotesExamen`
4. Parse les fiches → upsert `ApprentiFiche`
4. Parse les fiches → upsert `ApprentiFiche` (adresse perso, entreprise, **représentant légal**, **compensation_desavantages**, **majeur**)
5. Détecte les **orphelines** (absences en DB mais absentes du PDF dans la fenêtre temporelle) et les supprime (sauf si elles ont un pending, sans force).
6. Écrit `data/sync_last_result.json` avec les compteurs détaillés.
### Re-parsing sans re-téléchargement
`scripts/run_imports.py --reparse-bn-only` permet de re-traiter tous les PDFs déjà téléchargés (utile après un changement dans le parser BN, sans relancer Playwright).
## Pendings : modifications locales en attente
Quand un utilisateur modifie une absence dans l'application (page Apprenti), une entrée est créée dans la table `EscadaPending` avec une action :
Quand un utilisateur modifie une absence dans l'application (page Apprentis), une entrée est créée dans la table `EscadaPending` avec une action :
- **`E`** : marquer comme excusée sur Escada
- **`N`** : marquer comme non excusée sur Escada
- **`clear`** : retirer l'absence sur Escada (= remettre l'apprenti présent)
Ces pendings sont visibles sur la page `/escada` dans la section "Modifications en attente".
Ces pendings sont visibles sur la page `/escada` dans la section « Modifications en attente ». Le bouton **Pousser vers Escada** les vide en envoyant chaque modification.
## Notices Escada (import et création)
Les notices sont les remarques rattachées à un apprenti dans Escada (avis de sanction, retenue, remarque libre, etc.).
### Import (pull)
`scripts/pull_notices.py` lit la vue Escada de chaque classe, scrape les notices, et upsert dans la table `apprenti_notices` (clé `(apprenti, date_event, titre)`). Affichage en lecture seule dans l'onglet « Notices » de la fiche apprenti et de la vue classe.
### Création (push)
Voir le chapitre dédié [Push vers Escada](#) — la création locale d'un avis de sanction ou de retenue crée une `Notice` (table locale), qui est ensuite poussée vers Escada par `scripts/push_notices.py`.
## Cas particuliers gérés
@ -77,6 +95,6 @@ Ces pendings sont visibles sur la page `/escada` dans la section "Modifications
## Diagnostic
- **Timeout > 15 min** : vérifier les logs `data/logs/operations.log`. Souvent un problème Selenium (captcha, session expirée).
- **Aucune classe récupérée** : token de session Escada expiré → relancer le rafraîchissement.
- **Timeout > 15 min** : vérifier les logs `data/logs/operations.log`. Souvent un problème Playwright (captcha, session expirée).
- **Aucune classe récupérée** : token de session Escada expiré → relancer le rafraîchissement (re-login automatique avec les identifiants stockés en /params, code 2FA généré via `totp_secret`).
- **Logs détaillés** : page `/logs` affiche `operations.log` en temps réel.

View file

@ -1,23 +1,24 @@
# Push vers Escada
Le push envoie les modifications locales (table `EscadaPending`) vers Escadaweb via Selenium.
Le push envoie les modifications locales (absences en `EscadaPending` + notices en attente dans `Notice`) vers Escadaweb via Playwright.
## Page : `/escada`"Pousser vers Escada"
## Page : `/escada`« Pousser vers Escada »
### Quand un pending est créé ?
### Quand un pending d'absence est créé ?
Chaque modification d'absence dans l'application crée ou met à jour une entrée dans `EscadaPending` :
| Action utilisateur | Pending créé |
|------------------------------------------|---------------------|
|---------------------------------------------------|---------------------|
| Marquer P3 comme excusée | `action=E` |
| Marquer P5 comme non excusée | `action=N` |
| Retirer une absence (présent) | `action=clear` |
| Excuse rapide d'une journée (page Fiche) | `action=E` × n |
| Excuse rapide d'une journée (page Apprentis) | `action=E` × n |
| « Absent toute la journée » (selon horaire classe)| `action=N` × n (sur enregistrement) |
La contrainte d'unicité `(apprenti_id, date, periode)` garantit qu'une période a au plus un pending. Si on modifie deux fois la même période, le dernier pending écrase le précédent.
## Phases du push
## Phases du push d'absences
### Phase 1 : Préparation
@ -25,21 +26,21 @@ La contrainte d'unicité `(apprenti_id, date, periode)` garantit qu'une période
1. Lit toutes les entrées de `EscadaPending`
2. Groupe par classe pour minimiser les navigations Escada
3. Lance Selenium
3. Lance Playwright
### Phase 2 : Exécution Selenium
### Phase 2 : Exécution Playwright
Pour chaque pending :
1. Navigue jusqu'à la page d'absences de l'apprenti dans Escadaweb
2. Trouve la cellule (date × période)
3. Selon l'action :
- `E` : sélectionne "Excusée" dans le dropdown
- `N` : sélectionne "Non excusée"
- `E` : sélectionne « Excusée » dans le dropdown
- `N` : sélectionne « Non excusée »
- `clear` : remet à blanc (= apprenti présent)
4. Clique sur **Speichern** (Enregistrer)
5. Si OK → supprime l'entrée du `EscadaPending`
6. Si erreur → conserve l'entrée et la liste les erreurs dans `PUSH_DONE`
6. Si erreur → conserve l'entrée et la liste dans `PUSH_DONE`
### Phase 3 : Rapport
@ -47,6 +48,27 @@ Le script imprime une ligne `PUSH_DONE {"ok": N, "err": [...]}` à la fin. L'app
- Nombre d'envois OK
- Liste des erreurs (chaque erreur mentionne l'apprenti, la date et la période)
## Push de notices
Les notices créées localement (création d'avis de retenue ou de sanction depuis l'app) sont enregistrées dans la table `Notice` (statut `pending`), puis poussées par `scripts/push_notices.py`.
### Workflow
1. L'utilisateur clique sur « Générer l'avis » dans une modale d'avis sanction/retenue. Cela :
- Génère le PDF (téléchargement)
- Crée une `Notice` avec `source="sanction"` ou `"retenue"`, `status="pending"`, et le préfixe `(<username>)` est ajouté en début de la remarque pour traçabilité
2. La file `Notice (status=pending)` est visible côté admin sur `/escada` ou via les tâches cron.
3. `push_notices.py` :
- Lit les notices `pending`
- Pour chaque, navigue dans Escada (page de l'apprenti → onglet Notices) et crée la notice avec son titre + remarque + date
- Marque comme `synced` si OK, `error` (+ `error_msg`) sinon
### task_kind cron
- `task_kind=push` + `sync_abs=1` → pousse les absences
- `task_kind=push` + `sync_notices=1` → pousse les notices
- `task_kind=push` + les deux → push absences puis notices (séquentiel)
## Que faire si un push échoue ?
1. **Vérifier les logs** (`/logs`) — l'erreur exacte est tracée.
@ -54,13 +76,19 @@ Le script imprime une ligne `PUSH_DONE {"ok": N, "err": [...]}` à la fin. L'app
- Session Escada expirée → relancer un Actualiser sur la page Escada (re-login automatique)
- Apprenti avec un nom différent dans Escada → renommage à faire dans la DB ou côté Escada
- Page de notation verrouillée par un collègue (Escada utilise des locks pessimistes)
3. **Re-tenter** : les pendings restent en file d'attente, un nouveau push les retraitera.
3. **Re-tenter** : les pendings (et notices `pending`/`error`) restent en file d'attente, un nouveau push les retraitera.
## Audit
Chaque push manuel logue qui l'a déclenché : `[abs] {user} : Push Escada démarré par {username}`. Côté résultat :
Chaque push manuel logue qui l'a déclenché :
```
[abs] {user} : Push Escada démarré par {username}
[notice] {user} : création (sanction) pour {apprenti}
[notice] {user} : création (retenue) pour {apprenti} — case=devoir
```
Côté résultat :
- `Push terminé — ok:N erreurs:M` dans `operations.log`
## Push automatique via cron
La tâche planifiée de type `push` ou `push_then_sync` exécute le même script. Voir la section [Tâches planifiées](#).
Les tâches planifiées de type `push` ou `push_then_sync` exécutent les mêmes scripts. Voir la section [Tâches planifiées](#).

View file

@ -1,6 +1,6 @@
# Édition des absences
## Page : `/fiche` (Apprentis)
## Page : « Apprentis » (`/fiche`)
### Sélectionner un apprenti
@ -11,6 +11,20 @@ Le sélecteur en haut de la page propose une recherche en direct : tape une part
- `Entrée` sélectionne le premier résultat filtré
- `Échap` ferme la recherche
### KPIs et bandeau d'actions
Sous le sélecteur, 3 cartes KPI :
- **Périodes d'absence** : total
- **Périodes à excuser** : non encore traitées
- **Absences** : nombre de blocs ; rouge avec libellé « Avis de sanction » dès le quota EM atteint
Sous les KPIs, un bandeau d'actions :
- **PDF absences / PDF bulletin / PDF notes** (téléchargement)
- **Créer un avis de retenue** (orange) → ouvre la modale retenue pré-remplie
- **Créer un avis de sanction** (rouge) → ouvre la modale sanction pré-remplie
Ces boutons sont identiques sur la page « Classes », par carte apprenti.
### Calendrier mensuel
Chaque cellule représente un jour du mois. Les couleurs indiquent l'état :
@ -24,13 +38,24 @@ Chaque cellule représente un jour du mois. Les couleurs indiquent l'état :
| Bleu pâle | Aujourd'hui |
Les nombres dans les cellules :
- "2 ⚠️ 1" → 2 absences au total dont 1 non excusée
- "5" → 5 absences toutes excusées
- « 2 ⚠️ 1 » → 2 absences au total dont 1 non excusée
- « 5 » → 5 absences toutes excusées
Cliquer sur un jour avec absences ouvre le panneau d'édition.
Cliquer sur un jour avec absences (ou un autre jour) ouvre le panneau d'édition.
### Panneau d'édition
#### Badge type de jour
À côté du titre « Édition du {date} » s'affiche un badge coloré indiquant le type de jour pour cette classe (selon le mapping défini en /params) :
- 🔵 **Théorie** (bleu)
- 🟠 **Pratique** (orange)
- 🟣 **Matu** (violet)
- (rien) si aucun type configuré
#### Périodes
10 lignes (P1 à P10) avec un **segmented control** à 3 boutons :
- **Présent** (gris) — l'apprenti était là
- **E** (orange) — Excusée
@ -38,11 +63,42 @@ Cliquer sur un jour avec absences ouvre le panneau d'édition.
Un seul clic suffit. Le bouton **Enregistrer** sauve toutes les modifications de la journée d'un coup. Le panneau reste ouvert après l'enregistrement pour permettre un éventuel ajustement.
### Excuse rapide ("Valider toutes les absences d'une journée")
#### Actions rapides
- **Absent toute la journée** (rouge) — met à `N` uniquement les périodes définies dans l'horaire de la classe pour le jour de la semaine sélectionné (cf. ci-dessous).
- Bouton **grisé** + libellé « Absent toute la journée (Données chronoplan manquantes) » si l'horaire n'est pas configuré pour ce (classe × jour).
- **Excuser toutes les périodes** (vert) — bascule visuellement toutes les `N` en `E`. N'enregistre pas en DB tant qu'on ne clique pas sur **Enregistrer**.
### Excuse rapide (« Valider toutes les absences d'une journée »)
Sous le calendrier, un bandeau jaune liste les jours qui ont au moins une absence non encore traitée (statut `a_traiter`). Cliquer sur un de ces boutons excuse **toutes les absences à traiter de ce jour-là** en une seule action.
## Page : `/classe` (Vue classe)
### Envoyer par email
Un bloc « Envoyer par email » permet d'envoyer le récap (et éventuellement le bulletin / les notes en pièces jointes) à l'apprenti, au formateur ou à une adresse libre. Objet et corps utilisent le template configurable en `/params → Template email` (variables `{prenom}`, `{nom_complet}`, `{classe}`, etc.).
## Horaire de classe (« chronoplan »)
Configuré en **Paramètres → Horaires de classe** :
- Sélection d'une classe (dropdown alimenté par les classes en base)
- Pour chaque jour (Lun → Ven) :
- Sélecteur de **type de jour** : Théorie / Pratique / Matu / —
- Grille de **10 cases** (P1 → P10), cliquables (rouge = active)
- Bouton « Enregistrer l'horaire »
Stocké dans `data/settings.json` sous la clé `class_schedule` :
```json
"AUTOMAT 1": {
"MON": { "type": "theorie", "periods": [1, 2, 3, 4] },
"TUE": { "type": "pratique", "periods": [5, 6, 7, 8] },
"WED": { "type": "matu", "periods": [1, 2] }
}
```
Le bouton « Absent toute la journée » dans la fiche apprenti lit cette config en fonction de `apprenti.classe` + jour de la semaine de la date sélectionnée.
## Page : « Classes » (`/classe`)
### Sélection de classe
@ -52,10 +108,10 @@ Même principe que pour les apprentis : recherche en direct avec `/`, `Entrée`,
Chaque apprenti de la classe a une carte avec :
- Nom + lien vers sa fiche complète
- Badge "Sanction" si quota atteint (≥5 absences brutes en blocs)
- KPIs : Total / Excusées / Non excusées / Blocs d'absences
- Boutons de téléchargement PDF (Absences, Bulletin, Notes)
- Onglets BN / Notes d'examen pour visualiser
- Badge « Sanction » si quota atteint (≥5 absences brutes en blocs, classes EM uniquement)
- KPIs identiques à la fiche apprenti (3 cartes : Périodes d'absence, Périodes à excuser, Absences)
- Bandeau d'actions identique : PDF absences/bulletin/notes + Créer avis de retenue + Créer avis de sanction
- Onglets BN / Notes d'examen / Notices pour visualiser
## Audit des modifications

View file

@ -2,7 +2,7 @@
## Page : `/cron` (admin uniquement)
Permet de créer des tâches automatiques de synchronisation et/ou de push.
Permet de créer des tâches automatiques de synchronisation et/ou de push, avec notifications Telegram.
## Architecture
@ -14,7 +14,7 @@ docker exec eptm-dashboard-app-1 python scripts/cron_tick.py
Lit la table CronJob → identifie les tâches à exécuter maintenant
Pour chaque tâche due :
- Lance push_to_escada.py et/ou sync_esacada.py + run_imports.py
- Lance push_to_escada.py, push_notices.py, sync_esacada.py + run_imports.py
- Met à jour last_run_at, last_status, last_message
- Envoie une notification Telegram (selon notify_on)
```
@ -25,24 +25,32 @@ Le tick s'exécute toutes les minutes via la crontab du host. Le timezone du con
| Type | Action |
|------------------|----------------------------------------------------------------------|
| `push` | Pousse les pendings vers Escada uniquement |
| `sync` | Récupère depuis Escada uniquement (selon options abs/BN/notes/fiches)|
| `push_then_sync` | Pousse les pendings, puis récupère |
| `push` | Pousse les pendings d'absences et/ou notices vers Escada |
| `sync` | Récupère depuis Escada (selon options abs/BN/notes/fiches/notices) |
| `push_then_sync` | Pousse puis récupère |
## Schedules
## Planifications
Trois types de planning sont disponibles :
Deux types de planning seulement (les anciens `interval` et `daily` ont été remplacés par `daily_multi` qui couvre les deux cas) :
- **Quotidien (daily)** : à une heure fixe chaque jour. Ex : `03:00`.
- **Hebdo (weekly)** : à une heure fixe certains jours. Ex : `MON,WED,FRI:08:30`.
- **Intervalle (interval)** : toutes les N minutes. Ex : `30` = toutes les 30 minutes.
- **Hebdo (`weekly`)** : à une heure fixe certains jours.
- `schedule_value` = `"MON,WED,FRI:08:30"`
- Sélection des jours en UI : pastilles rouges Lun..Dim.
- **Plusieurs heures par jour (`daily_multi`)** : à un ensemble d'heures pleines, tous les jours.
- `schedule_value` = `"00:00,06:00,12:00,18:00"`
- Sélection en UI : grille 24 cases (00h23h) cliquables.
- Remplace l'ancien mode `interval` : pour reproduire « toutes les 6 h » on coche 4 cases (00, 06, 12, 18).
## Options de sync (pour task_kind=sync ou push_then_sync)
> Les anciens jobs en format `daily` (`HH:MM`) sont automatiquement convertis en `daily_multi` au boot via la migration de `src/db.py`.
> Les anciens jobs en format `interval` (`N minutes`) sont également migrés en déroulant l'intervalle sur 24 h depuis minuit.
## Options de sync (pour `task_kind=sync` ou `push_then_sync`)
- `sync_abs` : récupère les absences
- `sync_bn` : récupère les BN
- `sync_notes` : récupère les notes
- `sync_fiches` : récupère les données apprentis
- `sync_bn` : récupère les BN + Matu
- `sync_notes` : récupère les notes d'examen
- `sync_fiches` : récupère les données apprentis (avec représentant légal + compensation)
- `sync_notices` : récupère les notices Escada
- `force_abs` : forçage (cf. doc Sync Escada)
- `classes_json` : `"ALL"` ou liste de classes spécifiques
@ -58,7 +66,9 @@ Voir la section [Notifications Telegram](#) pour les détails.
## Activation / désactivation
Le toggle dans la liste des tâches active ou désactive sans supprimer. Quand on **réactive** une tâche, son `last_run_at` est remis à `None` pour qu'elle se déclenche au prochain tick (sinon elle attendrait la fin de l'intervalle complet).
Le toggle dans la liste des tâches active ou désactive sans supprimer. Quand on **réactive** une tâche, son `last_run_at` est remis à `None` pour qu'elle se déclenche au prochain créneau.
**Ouverture rapide** : cliquer n'importe où sur la ligne d'une tâche ouvre directement le panneau d'édition.
## Logs persistants
@ -69,10 +79,10 @@ Chaque exécution écrit son log détaillé dans `/logs/cron/cron-{job_id}-{time
Toute modification (création/édition/activation/suppression) est tracée :
```
[09:14:22] [cron] prof.demo : création tâche 'Sync nocturne' (id=4) — push_then_sync / 03:00 / activée
[09:30:05] [cron] prof.demo : désactivation tâche 'Push 30min' (id=2)
[09:14:22] [cron] prof.demo : création tâche 'Sync nocturne' (id=4) — push_then_sync / 00:00,06:00,12:00,18:00 / activée
[09:30:05] [cron] prof.demo : désactivation tâche 'Push horaire' (id=2)
```
## Bouton "Tester Telegram"
## Bouton « Tester Telegram »
Bas de page : envoie un message de test au `chat_id` global pour vérifier la config bot.

View file

@ -1,4 +1,4 @@
# Authentification & rôles
# Authentification, droits & profil
## Login
@ -12,11 +12,15 @@ Format `auth.yaml` :
credentials:
usernames:
prof.demo:
password: "$2b$12$..."
password: "$2b$12$..." # bcrypt
name: "Prof Demo"
role: "admin" # ou "user"
email: "prof.demo@eptm.ch" # destinataire pour reset mdp / enrôlement
avatar_url: "/avatars/prof_demo.png"
totp_secret: "ABCD1234..." # rempli automatiquement à la 1ère 2FA
allowed_classes: ["AUTOMAT 1", "EM-AU 2"] # restriction d'accès
escada_username: "prenom.nom@eptm.ch" # email Escada (clé login)
escada_password: "..." # mot de passe Escada (stocké clair)
```
## 2FA TOTP (obligatoire)
@ -32,39 +36,102 @@ Aux connexions suivantes :
1. Login + mot de passe → demande directe du code TOTP
2. Code à 6 chiffres → connexion finalisée
Le code est valide ±30s (paramètre `valid_window=1` de `pyotp`) pour tolérer la dérive d'horloge.
Le code est valide ±30 s (paramètre `valid_window=1` de `pyotp`) pour tolérer la dérive d'horloge.
## Email de bienvenue / réinitialisation de mot de passe
Sur création d'un user depuis `/users` ou sur reset, un **email** est envoyé contenant un lien `<APP_URL>/password_set?token=...` qui expire après 24 h.
- L'URL de base est lue depuis `settings.app_base_url` (configurée en `/params → Application`).
- L'expéditeur et le SMTP sont configurés en `/params → Configuration email`.
## Session persistante
L'authentification est stockée dans le **`localStorage` du navigateur** (champs `username`, `name`, `role`, `photo_url`). La session survit aux rechargements de page et aux redémarrages du conteneur.
L'authentification est stockée dans le **`localStorage` du navigateur** (champs `username`, `name`, `role`, `photo_url`, `theme`). La session survit aux rechargements de page et aux redémarrages du conteneur.
À chaque page protégée, `AuthState.check_auth` re-vérifie que l'utilisateur existe toujours dans `auth.yaml` ; sinon, redirection forcée vers `/login`.
À chaque page protégée, `AuthState.check_auth` re-vérifie que l'utilisateur existe toujours dans `auth.yaml` et rafraîchit `allowed_classes`, `escada_username`, `escada_has_password`, etc. ; sinon, redirection forcée vers `/login`.
## Droits d'accès par classe
Chaque user a une clé `allowed_classes: list[str] | null` :
- **`null` ou absente** → restriction non encore appliquée (user nouveau)
- **`[]` vide** → aucun accès → popup d'enrôlement obligatoire (cf. ci-dessous)
- **liste non vide** → l'user ne voit que ces classes dans /classe, /fiche, et les filtres de stats sur /accueil
- **role=admin** → voit tout (bypass de la restriction)
`src/user_access.py:get_allowed_classes(username)` est l'API canonique. Toutes les pages user-facing l'appellent pour filtrer.
## Self-service enrôlement (popup obligatoire)
Quand un user se connecte et que `allowed_classes` est `[]` (ou non défini), un **dialog forcé** s'ouvre sur toutes les pages :
> **Configurez votre accès**
>
> Pour récupérer la liste des classes auxquelles vous avez accès dans Escadaweb,
> saisissez vos identifiants Escada + un code TOTP courant.
Champs :
- Email Escada (= `escada_username`)
- Mot de passe Escada (stocké clair dans `auth.yaml`)
- Code 2FA courant (utilisé une fois — non stocké)
À la soumission, le script `scripts/fetch_user_classes.py` est lancé **en arrière-plan** (`@rx.event(background=True)` + `asyncio.create_subprocess_exec`) pour ne pas bloquer l'app pour les autres users :
1. Playwright headless lance un profil temporaire isolé
2. Login Keycloak avec les creds + TOTP fourni
3. Scrape la liste des classes accessibles dans Escada
4. Filtre MP / MI / classes « Formation »
5. Sauvegarde dans `auth.yaml` : `allowed_classes`, `escada_username`, `escada_password`
6. Log live dans `operations.log` (préfixe `[fetch_classes:<username>]`) → visible dans `/logs`
Le popup peut être fermé avec « Plus tard » (`enroll_dismissed=True`), il réapparaîtra au prochain login.
## Page « Mon profil » (`/profile`)
Accessible via le popover sidebar :
- Avatar
- Liste actuelle des `allowed_classes`
- Bouton « Relancer la synchronisation » pour rafraîchir la liste (même script que le popup)
- Modifier les identifiants Escada (sans relancer la sync immédiatement)
## Réinitialisation des droits (admin)
Sur la page `/users`, un bouton « Réinitialiser les droits » (par user) :
- Efface `allowed_classes`
- Efface `escada_username` + `escada_password`
→ Au prochain login de cet user, le popup d'enrôlement réapparaît.
## Rôles
| Page | user | admin |
|-------------------|------|-------|
| `/accueil` | ✅ | ✅ |
| `/fiche` | ✅ | ✅ |
| `/classe` | ✅ | ✅ |
| `/doc` | ✅ | ✅ |
| `/escada` | ❌ | ✅ |
| `/cron` | ❌ | ✅ |
| `/logs` | ❌ | ✅ |
| `/users` | ❌ | ✅ |
| `/params` | ❌ | ✅ |
| Page | user (sans allowed_classes) | user (avec allowed_classes) | admin |
|-------------------|----------------------------|------------------------------|-------|
| `/accueil` | ✅ (filtré) | ✅ (filtré) | ✅ |
| `/classe` | ✅ (filtré) | ✅ (filtré) | ✅ |
| `/fiche` | ✅ (filtré) | ✅ (filtré) | ✅ |
| `/doc` | ✅ | ✅ | ✅ |
| `/profile` | ✅ | ✅ | ✅ |
| `/escada` | ❌ | ❌ | ✅ |
| `/cron` | ❌ | ❌ | ✅ |
| `/logs` | ❌ | ❌ | ✅ |
| `/users` | ❌ | ❌ | ✅ |
| `/params` | ❌ | ❌ | ✅ |
| `/feedback` | ❌ | ❌ | ✅ |
## Gestion des utilisateurs
## Gestion des utilisateurs (admin)
Page `/users` (admin) :
Page `/users` :
- Créer / supprimer des utilisateurs
- Changer le rôle
- Réinitialiser le 2FA (efface `totp_secret` → forcera une nouvelle config au prochain login)
- Réinitialiser les droits (cf. ci-dessus)
- Définir / changer un avatar
- Clic sur une ligne ouvre directement le panneau d'édition
## Logout
Bouton "Déconnexion" en bas de la sidebar. Vide le `localStorage` et redirige vers `/login`.
Bouton « Déconnexion » dans le popover profil de la sidebar. Vide le `localStorage` (y compris `enroll_dismissed`) et redirige vers `/login`.
## Stockage des avatars

View file

@ -2,29 +2,29 @@
## Synchronisation Escada
### "Import timeout — vérifiez les logs (> 15min)"
### « Import timeout — vérifiez les logs (> 15 min) »
Le subprocess Selenium n'a pas répondu dans le temps imparti. Causes possibles :
Le subprocess Playwright n'a pas répondu dans le temps imparti. Causes possibles :
- Escadaweb répond très lentement (en pic de charge)
- Captcha / re-login imposé par Escada
- Container Docker en surcharge
**Que faire** :
1. Aller dans `/logs` et chercher le dernier `[sync]` actif
2. Si Selenium est bloqué sur un écran de login : lancer un Actualiser des classes (re-login)
2. Si Playwright est bloqué sur un écran de login : lancer un Actualiser des classes (re-login)
3. Si gros volume de classes : lancer la sync en plusieurs lots de 5-6 classes
### "Aucune classe récupérée"
### « Aucune classe récupérée »
Le scraping Selenium a échoué — souvent token de session expiré.
Le scraping Playwright a échoué — souvent token de session expiré.
**Que faire** : recliquer sur "Actualiser" (ça force un re-login propre).
**Que faire** : recliquer sur « Actualiser » (force un re-login propre).
### "Le push échoue toujours sur le même apprenti"
### « Le push échoue toujours sur le même apprenti »
Possibles causes :
- L'apprenti existe en local mais pas (ou plus) sur Escada → le pending est obsolète, à supprimer
- Le nom diffère entre local et Escada (ex: prénom composé partiel)
- Le nom diffère entre local et Escada (ex : prénom composé partiel)
- La page Escada de cet apprenti est verrouillée par un autre éditeur (lock pessimiste Escada)
**Que faire** :
@ -32,7 +32,7 @@ Possibles causes :
2. Si l'apprenti n'existe plus : supprimer le pending manuellement en DB
3. Sinon : retenter plus tard
### "L'option 'Forcer la réimportation complète' est en rouge — c'est dangereux ?"
### « L'option 'Forcer la réimportation complète' est en rouge — c'est dangereux ? »
Pas dangereux mais **destructif** :
- Tous les pendings concernés sont écrasés (les modifs locales pas encore poussées sont perdues)
@ -42,12 +42,12 @@ Pas dangereux mais **destructif** :
## Tâches planifiées (cron)
### "J'ai créé une tâche, elle ne se déclenche pas"
### « J'ai créé une tâche, elle ne se déclenche pas »
Vérifier dans l'ordre :
1. La tâche est-elle **activée** ? (toggle vert)
2. L'horaire est-il dans le bon fuseau horaire ? (l'app fonctionne en `Europe/Zurich`)
2. Les heures choisies sont-elles dans le bon fuseau horaire ? (l'app fonctionne en `Europe/Zurich`)
3. La crontab du host appelle-t-elle bien `cron_tick.py` ?
```bash
@ -55,58 +55,109 @@ Vérifier dans l'ordre :
# Doit avoir : * * * * * docker exec eptm-dashboard-app-1 python scripts/cron_tick.py
```
4. Regarder le log : `docker exec eptm-dashboard-app-1 cat /logs/cron/`
4. Regarder le log d'exécution : `ls /logs/cron/` et `tail` sur le dernier fichier.
### "La tâche ne notifie pas sur Telegram"
### « J'avais un job 'toutes les X minutes', il a disparu »
L'ancien mode `interval` a été remplacé par `daily_multi`. Au boot du container, les jobs `interval` sont **automatiquement migrés** en `daily_multi` (l'intervalle est déroulé sur 24 h depuis minuit). Idem pour les anciens jobs `daily` (`HH:MM`) → convertis en `daily_multi` avec une seule heure.
Si tu veux modifier un de ces jobs : ouvre-le, tu verras la grille 24 cases avec les heures qui étaient configurées.
### « La tâche ne notifie pas sur Telegram »
- `notify_on` doit être `always`, `success` ou `failure` (pas `never`)
- Le `bot_token` et `chat_id` doivent être valides → tester via le bouton "Tester Telegram"
- Le `bot_token` et `chat_id` doivent être valides → tester via le bouton « Tester Telegram »
## Édition d'absences
### « Le bouton 'Absent toute la journée' est grisé »
Affichage « Absent toute la journée (Données chronoplan manquantes) » → aucun horaire n'est configuré pour cette classe et ce jour de la semaine.
**Que faire** : admin → `/params → Horaires de classe`, sélectionner la classe, cocher les périodes du jour concerné, enregistrer.
### « Quel est le type de jour affiché en badge ? »
Théorie / Pratique / Matu — défini par classe et par jour dans `/params → Horaires de classe`. Sert d'indication contextuelle dans le panneau d'édition.
## Authentification
### "J'ai perdu mon téléphone avec mon code 2FA"
### « Mon utilisateur n'a accès à aucune classe »
Un admin peut réinitialiser le 2FA via la page `/users` : "Réinitialiser 2FA". Au prochain login, l'utilisateur reverra le QR code.
À sa première connexion, un dialog s'ouvre pour configurer l'accès. Il doit fournir :
- son email Escada (identifiant Keycloak)
- son mot de passe Escada
- un code TOTP courant
### "Mon utilisateur est bloqué après plusieurs tentatives"
Un script Playwright tourne en arrière-plan (visible dans `/logs` avec préfixe `[fetch_classes:<username>]`) et remplit automatiquement `allowed_classes` dans `auth.yaml`.
Pas de blocage automatique pour le moment. Si on veut en ajouter un : voir `state.py:handle_login`.
Si le dialog est fermé (« Plus tard »), il réapparaîtra au prochain login.
### « J'ai perdu mon téléphone avec mon code 2FA »
Un admin peut réinitialiser le 2FA via `/users` : bouton « Réinitialiser 2FA ». Au prochain login, l'utilisateur reverra le QR code.
### « Comment révoquer l'accès d'un user »
Admin → `/users` → bouton « Réinitialiser les droits » : efface `allowed_classes` + identifiants Escada. À sa prochaine connexion, le popup d'enrôlement réapparaîtra (s'il ne le reconfigure pas, il n'aura accès à rien).
## Données
### "Les BN affichent des trous (cellules vides)"
### « Les BN affichent des trous (cellules vides) »
C'est normal : un apprenti peut avoir commencé sa formation au S2 ou S3 → les premiers semestres restent vides. L'extraction depuis le PDF Escada respecte ces trous.
C'est normal : un apprenti peut avoir commencé sa formation au S2 ou S3 → les premiers semestres restent vides. L'extraction depuis le PDF Escada respecte ces trous. Toutes les notes (pas juste les moyennes) sont stockées et affichées.
### "Les notes Matu n'apparaissent pas"
### « Les notes Matu n'apparaissent pas »
Pré-requis : l'apprenti est dans une classe MP/MI correspondante. Le matching se fait via la classe MP1, MP2, etc. La sync Matu cherche le PDF correspondant **si la case "BN" est cochée**.
Pré-requis : l'apprenti est dans une classe MP / MI correspondante. Le matching se fait via la classe MP1, MP2, etc. La sync Matu cherche le PDF correspondant **si la case 'BN' est cochée**.
### « L'adresse sur les avis sanction/retenue est fausse »
Depuis mai 2026, l'app n'utilise **plus l'adresse de l'entreprise**. Elle prend :
- l'adresse du **représentant légal** si l'apprenti est mineur (`majeur=False`)
- l'adresse perso de **l'apprenti** sinon
Vérifier que les champs `ApprentiFiche.resp_legal_*` et `ApprentiFiche.adresse/code_postal/localite` sont bien remplis (sync option « Données apprentis »).
## Performance
### "L'app rame quand je change de classe avec beaucoup d'apprentis"
### « L'app rame quand je change de classe avec beaucoup d'apprentis »
Le `_reload` reconstruit les tableaux HTML BN/Notes pour chaque apprenti — peut prendre quelques secondes pour 25+ apprentis. Un skeleton s'affiche pendant le chargement pour donner du feedback visuel.
Le `_reload` reconstruit les tableaux HTML BN / Notes pour chaque apprenti — peut prendre quelques secondes pour 25+ apprentis. Un skeleton s'affiche pendant le chargement pour donner du feedback visuel.
Si vraiment lent : envisager une mise en cache des HTML rendus dans la DB (à voir avec un dev).
### « L'app est bloquée pour les autres users quand je lance la synchro de mes classes »
C'était un bug : le subprocess Playwright bloquait l'event loop. Corrigé en passant à `@rx.event(background=True)` + `asyncio.create_subprocess_exec`. Si ça revient, vérifier que `fetch_my_classes` est bien décoré `background=True` dans `profile.py`.
## Conteneur Docker
### "Le conteneur consomme 100% CPU à l'idle"
### « Le conteneur consomme 100 % CPU à l'idle »
Bug historique lié au hot-reload qui détectait les fichiers WAL/SHM de SQLite comme des modifications source. Corrigé via `REFLEX_HOT_RELOAD_EXCLUDE_PATHS=/app/data` dans le docker-compose.
Si ça revient : vérifier que cette variable est bien présente dans `docker-compose.dev.yml`.
### « La version dans le sidebar ne change pas après un nouveau tag git »
### "Comment redémarrer proprement"
`data/VERSION` doit être mis à jour manuellement (le `.git` du container dev n'est pas synchronisé avec celui du hôte). Édite le fichier, puis :
```bash
docker restart eptm-dashboard-app-1
```
### « Comment redémarrer proprement »
```bash
cd /opt/eptm-dashboard
docker compose -f docker-compose.dev.yml restart app
```
### "Comment voir les logs du serveur Reflex"
### « Comment voir les logs du serveur Reflex »
```bash
docker logs -f eptm-dashboard-app-1
```
## SSL / Accès depuis le web
### « Mon navigateur affiche un avertissement sécurité sur dev.dashboard.eptm-automation.ch »
Vérifier le certificat affiché : si l'émetteur est « Fortinet CA » (et non Let's Encrypt), c'est ton firewall qui fait du SSL inspection / IPS — pas un problème du serveur. À demander à l'IT pour whitelister le domaine.

View file

@ -0,0 +1,83 @@
# Avis de sanction & retenue
L'application génère des PDFs officiels d'avis de sanction et d'avis de retenue à partir des templates AcroForm fournis (`data/templates/GF_FO_Avis_de_sanction.pdf` et `GF_FO_Avis_de_retenue.pdf`). Les champs du formulaire restent éditables après téléchargement.
## Où créer un avis
Bouton **« Créer un avis de retenue »** (orange) ou **« Créer un avis de sanction »** (rouge) :
- Sur `/fiche` (Apprentis) — dans le bandeau d'actions sous les KPIs
- Sur `/classe` (Classes) — sur chaque carte apprenti, même bandeau
Cliquer ouvre une modale dédiée pré-remplie avec l'apprenti sélectionné.
## Modale Avis de sanction
Champs :
- **Apprenti** (verrouillé, pré-rempli)
- **Texte de description** : pré-rempli depuis `settings.texte_sanction` (configurable en /params)
- **Chef de section** : pré-rempli depuis `settings.chef_section`
- **Préfixe utilisateur** : `(<username>) ` est ajouté en début de la remarque enregistrée en notice (traçabilité)
3 actions :
- **Télécharger uniquement** — génère le PDF + crée la notice Escada en `pending`
- **Envoyer par email** — choix destinataire (apprenti / formateur / autre adresse libre)
- **Détecte les notices doublons** : si une notice du même type a déjà été créée aujourd'hui, l'app le signale avec un toast et propose « Créer quand même »
Filtre : **uniquement les classes EM** côté UI (les classes DUAL ne peuvent pas générer d'avis de sanction).
## Modale Avis de retenue
Champs :
- **Apprenti**
- **Profession** (auto-calculée depuis le préfixe de classe via `prof_mapping` configuré en /params)
- **Date de retenue** (date d'envoi)
- **Date du problème** (date à laquelle l'incident s'est produit)
- **Case cochée** : Devoir non rendu / Comportement / Retard
- **Branche** (uniquement si « Devoir non rendu »)
- **Remarque libre** (préfixée par `(<username>) `)
- **Vos initiales** (champ `Profs` du template)
Le template a un champ `Date` partagé entre 3 lignes ; le code [src/retenue_pdf.py:_split_date_field](src/retenue_pdf.py) sépare les widgets pour ne remplir que la date correspondant à la case cochée.
## Destinataire de l'avis (adresse imprimée sur le PDF)
Depuis mai 2026, l'adresse de l'**entreprise n'est plus utilisée**. Logique unifiée (`_destinataire(apprenti, fiche)` dans les deux modules PDF) :
| Statut apprenti | Destinataire (NomParents / NomEntreprise + Adresse + NPA-Ville) |
|---------------------------|------------------------------------------------------------------|
| Mineur (`majeur=False`) | Représentant légal (`resp_legal_*`) |
| Majeur (`majeur=True`) | Apprenti lui-même (`fiche.adresse/code_postal/localite`) |
| Inconnu / pas de fiche | Apprenti lui-même |
> Pré-requis : la sync Escada avec option « Données apprentis » doit avoir été lancée pour que `ApprentiFiche.majeur` + `resp_legal_*` soient remplis.
## Notice Escada associée
Chaque avis téléchargé crée une `Notice` (table locale) avec :
- `source` = `"sanction"` ou `"retenue"`
- `status` = `"pending"`
- `titre` = « Avis de sanction » / « Est arrivé en retard aux cours » / etc.
- `remarque` = `(<username>) <texte saisi>` — le préfixe sert d'identification de l'auteur en attendant un compte Escada par utilisateur
- `date_event` = aujourd'hui
Ces notices sont poussées vers Escada par `scripts/push_notices.py` (cf. [Push vers Escada](#)).
## Configuration des défauts
`/params → Avis de sanction` :
- Texte de description par défaut
- Chef de section (CS) par défaut
`/params → Correspondances classe → profession` :
- Mapping `préfixe de classe → profession` utilisé sur les avis de retenue. Ex : `AUTOMAT``Automaticien CFC`.
- Liste des classes « orphelines » (sans mapping) en chips jaunes — clic pour pré-remplir le formulaire.
- Bouton « Appliquer aux fiches existantes » : recalcule `ApprentiFiche.profession` pour tous les apprentis selon le mapping actuel.
## Audit
```
[notice] prof.demo : création (sanction) pour Dupont Marc (1EM1)
[notice] prof.demo : notice doublon évitée pour Dupont Marc (existante : Avis de sanction — créée le 12.05.2026 10:23)
[notice] prof.demo : création (retenue) pour Martin Léa (2EM2) — case=retard
```

80
data/docs/12-feedback.md Normal file
View file

@ -0,0 +1,80 @@
# Feedback in-app (chat widget)
L'application embarque un widget de feedback permettant à n'importe quel utilisateur de signaler un bug ou de proposer une idée d'amélioration, sans quitter l'app.
## Côté utilisateur
### Bouton flottant (FAB)
Un bouton circulaire bleu flottant en **bas-droite** de l'écran (icône bulle), visible sur toutes les pages. Au clic, ouvre une modale chat.
### Modale chat
- **Champ de type** : Bug / Proposition (radio)
- **Champ message** : textarea multiligne, auto-scroll en bas après chaque envoi
- **Bouton « Envoyer »**
- **Historique** : les messages précédents (envoyés par l'user) et les réponses admin sont affichés sous forme de bulles type chat
### Notification visuelle
Si un admin a répondu mais que l'user n'a pas encore consulté, l'**icône du FAB** change de couleur (orange) pour indiquer un nouveau message.
## Côté admin
### Page `/feedback`
Liste de tous les feedbacks reçus, triés par date desc :
- **Statut** : new (bleu) / in_progress (orange) / resolved (vert)
- **Type** : Bug / Proposition
- **Auteur** : nom complet + email
- **Message**
- **Page d'origine** (URL de l'app où l'user était au moment du clic)
- **Réponse admin** (textarea)
- **3 boutons d'envoi** :
- **Envoyer uniquement** : envoie le message à l'user (visible dans son chat) sans changer le statut
- **Envoyer + Marquer en cours** : `status → in_progress`
- **Envoyer + Marquer résolu** : `status → resolved`
Cliquer sur une ligne ouvre directement le panneau d'édition.
### Email de notification
À la création d'un feedback, un email est envoyé à l'adresse configurée en **/params → Configuration email → Email admin (feedback in-app)**. Si cette adresse est vide, aucun email n'est envoyé.
L'email contient :
- Type + message
- Page d'origine
- Lien direct vers `/feedback` pour répondre
### Réponse → email vers l'auteur
Quand l'admin clique sur « Envoyer + … », l'app :
1. Met à jour `FeedbackMessage.admin_response` + `response_sent_at`
2. Envoie un email à `FeedbackMessage.user_email` avec la réponse
3. Met à jour le statut selon le bouton choisi
4. Côté user, la réponse apparaît en bulle dans le chat à la prochaine ouverture
## Modèle de données
Table `FeedbackMessage` (`src/db.py`) :
```
id, created_at, created_by (username), user_email, type ("bug"|"feature"),
message, context_url, status ("new"|"in_progress"|"resolved"),
admin_response, response_sent_at
```
## Configuration
Tout est centralisé dans `/params → Configuration email` :
- SMTP (hôte, port, login, password, sender) — partagé avec l'envoi de récap d'absences
- **Email admin (feedback in-app)** — destinataire des notifs feedback
## Notes techniques
- Le titre de la modale est `"Feedback"` (anciennement « Aide & feedback EPTM », renommé après que Edge traduisait l'objet automatiquement).
- Pour éviter la traduction auto du navigateur sur les textes critiques, l'app utilise :
- `<meta name="google" content="notranslate">` global
- `<html lang="fr">` + `translate="no"` injecté au boot
- Classe `notranslate` + `custom_attrs={"translate": "no"}` sur les composants concernés

View file

@ -0,0 +1,99 @@
# Paramètres (`/params`)
Page admin centralisant toute la configuration applicative. Toutes les valeurs sont persistées dans `data/settings.json` sauf la section « Correspondances classe → profession » qui vit dans son propre fichier `data/profession_mapping.json`.
## Sections
### Application
- **URL de base** : utilisée pour générer les liens dans les emails (reset mot de passe, enrôlement). Ex : `https://dashboard.eptm-automation.ch`. Stocké dans `settings.app_base_url`.
### Correspondances classe → profession
Mapping `préfixe de classe → profession` utilisé pour pré-remplir le champ « Profession » sur les avis de retenue, et pour `ApprentiFiche.profession`.
- Tableau des mappings actuels (suppression possible)
- Chips jaunes listant les classes en base **sans correspondance** — clic pour pré-remplir le formulaire
- Bouton « Ajouter / mettre à jour » : insère ou remplace
- Bouton « Appliquer aux fiches existantes » : recalcule `ApprentiFiche.profession` pour tous les apprentis selon le mapping actuel (logging dans `operations.log`)
### Horaires de classe (« Absent toute la journée »)
Définit pour chaque classe + chaque jour de la semaine :
- Le **type de jour** : Théorie / Pratique / Matu / —
- Les **périodes de cours** (1 à 10)
UI : dropdown classe + grille 5 colonnes (Lun → Ven) × 10 cases.
Le bouton « Absent toute la journée » sur la fiche apprenti utilise ce mapping pour marquer comme `N` uniquement les périodes correspondantes au jour de la semaine sélectionné. Le **badge** (Théorie/Pratique/Matu) s'affiche aussi dans le panneau d'édition.
Stocké dans `settings.class_schedule` :
```json
{
"AUTOMAT 1": {
"MON": { "type": "theorie", "periods": [1, 2, 3, 4] },
"TUE": { "type": "pratique", "periods": [5, 6, 7, 8] }
}
}
```
### Avis de sanction
- **Texte de description par défaut** (champ `TexteDescription` du PDF)
- **Chef de section** par défaut (champ `CS`)
Repris à la création de chaque avis de sanction si l'utilisateur ne saisit rien d'autre.
### Configuration email
- **Serveur SMTP** + **port**
- **Login** + **mot de passe** SMTP
- **Expéditeur** (header From)
- **Email admin (feedback in-app)** : destinataire des notifications du chat feedback
Brevo (smtp-relay.brevo.com) est utilisé en prod.
### Connexion Escada (synchro automatique)
- **Identifiant Escada** (email Keycloak)
- **Mot de passe Escada**
- **Clé secrète 2FA (TOTP)** — format Base32
Permettent à la sync automatique (cron) et à la sync manuelle de se connecter sans intervention. Le code TOTP est généré à la volée par `pyotp.TOTP(secret).now()`.
> Ces identifiants servent uniquement aux tâches automatiques. Pour l'enrôlement self-service d'un user, c'est l'user qui saisit ses propres creds dans le popup de profil (cf. doc Auth).
### Template email
Template appliqué à l'envoi de récap d'absences depuis la fiche apprenti :
- **Objet** : par défaut `Document EPTM — {nom_complet} ({classe})`
- **Corps** : par défaut un message court avec `{prenom}` + `{classe}`
Variables disponibles : `{prenom}`, `{nom}`, `{nom_complet}`, `{classe}`, `{nb_absences}`, `{nb_excusees}`, `{nb_non_excusees}`, `{nb_a_traiter}`, `{semestre}`, `{date_du_jour}`.
## Fichier `data/settings.json`
Structure typique :
```json
{
"app_base_url": "https://dashboard.eptm-automation.ch",
"texte_sanction": "Selon le règlement de l'EM, ...",
"chef_section": "Patrick Rausis",
"smtp_host": "smtp-relay.brevo.com",
"smtp_port": 587,
"smtp_login": "...",
"smtp_password": "...",
"smtp_sender": "EPTM Automation <noreply@eptm-automation.ch>",
"feedback_admin_email": "admin@eptm-automation.ch",
"escada_username": "...",
"escada_password": "...",
"totp_secret": "...",
"email_subject": "Document EPTM — {nom_complet} ({classe})",
"email_body": "Bonjour {prenom}, ...",
"class_schedule": { ... }
}
```
Audit minimal : chaque modification depuis `/params` est sauvegardée d'un coup (toute la clé concernée). Pas de versioning ; un backup ponctuel de `data/settings.json` suffit.

View file

@ -7,5 +7,500 @@
"escada_username": "julien.balet",
"escada_password": "Lauryne2023!",
"totp_secret": "KQZVCQLXGNAU22KRKNCHCYSUIRAXAUSR",
"app_base_url": "https://dev.dashboard.eptm-automation.ch"
"app_base_url": "https://dev.dashboard.eptm-automation.ch",
"feedback_admin_email": "julien.balet@edu.vs.ch",
"email_subject": "Document EPTM — {nom_complet} ({classe})",
"email_body": "Bonjour {nom_complet},\n\nVeuillez trouver ci-joint vos documents.\n\nCordialement,\nL'équipe EPTM",
"class_schedule": {
"AUTOMAT 1": {
"MON": {
"type": "theorie",
"periods": [
1,
2,
3,
4,
5,
7,
8,
9,
10
]
},
"TUE": {
"type": "",
"periods": []
},
"WED": {
"type": "",
"periods": []
},
"THU": {
"type": "theorie",
"periods": [
1,
2,
3,
4,
5,
6,
8,
9,
10
]
},
"FRI": {
"type": "",
"periods": []
}
},
"AUTOMAT 2": {
"MON": {
"type": "",
"periods": []
},
"TUE": {
"type": "",
"periods": []
},
"WED": {
"type": "",
"periods": []
},
"THU": {
"type": "theorie",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9,
10
]
},
"FRI": {
"type": "theorie",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9,
10
]
}
},
"AUTOMAT 3": {
"MON": {
"type": "",
"periods": []
},
"TUE": {
"type": "theorie",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9,
10
]
},
"WED": {
"type": "",
"periods": []
},
"THU": {
"type": "",
"periods": []
},
"FRI": {
"type": "",
"periods": []
}
},
"AUTOMAT 4": {
"MON": {
"type": "theorie",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9
]
},
"TUE": {
"type": "",
"periods": []
},
"WED": {
"type": "",
"periods": []
},
"THU": {
"type": "",
"periods": []
},
"FRI": {
"type": "",
"periods": []
}
},
"EM-AU 1": {
"MON": {
"type": "theorie",
"periods": [
1,
2,
3,
4,
5,
6,
8,
9
]
},
"TUE": {
"type": "theorie",
"periods": [
1,
2,
3,
4,
5,
7,
8,
9,
10
]
},
"WED": {
"type": "pratique",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9
]
},
"THU": {
"type": "matu",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9,
10
]
},
"FRI": {
"type": "pratique",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9
]
}
},
"EM-AU 2": {
"MON": {
"type": "pratique",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9
]
},
"TUE": {
"type": "pratique",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9
]
},
"WED": {
"type": "matu",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9,
10
]
},
"THU": {
"type": "theorie",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9
]
},
"FRI": {
"type": "pratique",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9
]
}
},
"EM-AU 3": {
"MON": {
"type": "matu",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9,
10
]
},
"TUE": {
"type": "theorie",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9
]
},
"WED": {
"type": "pratique",
"periods": [
1,
2,
3,
4,
8,
9,
10
]
},
"THU": {
"type": "pratique",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9,
10
]
},
"FRI": {
"type": "pratique",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9,
10
]
}
},
"EM-AU 4": {
"MON": {
"type": "theorie",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9
]
},
"TUE": {
"type": "matu",
"periods": [
1,
2,
3,
4,
6,
7,
8,
9,
10
]
},
"WED": {
"type": "",
"periods": []
},
"THU": {
"type": "",
"periods": []
},
"FRI": {
"type": "",
"periods": []
}
},
"MONTAUT 1": {
"MON": {
"type": "",
"periods": []
},
"TUE": {
"type": "",
"periods": []
},
"WED": {
"type": "",
"periods": []
},
"THU": {
"type": "",
"periods": []
},
"FRI": {
"type": "theorie",
"periods": [
1,
2,
3,
4,
5,
7,
8,
9,
10
]
}
},
"MONTAUT 2": {
"MON": {
"type": "",
"periods": []
},
"TUE": {
"type": "",
"periods": []
},
"WED": {
"type": "",
"periods": []
},
"THU": {
"type": "",
"periods": []
},
"FRI": {
"type": "theorie",
"periods": [
1,
2,
3,
4,
5,
7,
8,
9,
10
]
}
},
"MONTAUT 3": {
"MON": {
"type": "",
"periods": []
},
"TUE": {
"type": "",
"periods": []
},
"WED": {
"type": "",
"periods": []
},
"THU": {
"type": "theorie",
"periods": [
1,
2,
3,
4,
5,
7,
8,
9,
10
]
},
"FRI": {
"type": "",
"periods": []
}
}
}
}

Binary file not shown.

View file

@ -13,10 +13,17 @@ from .pages.purge import purge_page, PurgeState
from .pages.doc import doc_page, DocState
from .pages.profile import profile_page, ProfileState
from .pages.password_set import password_set_page, PasswordSetState
from .pages.feedback import feedback_page, FeedbackAdminState
# RetenueState et SanctionState sont utilisés via modal dans /fiche
from .pages.retenue import RetenueState
from .pages.sanction import SanctionState
TITLE = "EPTM Dashboard"
app = rx.App(
# Note: theme=... est configuré dans rxconfig.py via RadixThemesPlugin
# (force appearance="light", ignore dark mode OS). Les thèmes user sont
# gérés via tokens CSS dans responsive.css.
stylesheets=["/responsive.css"],
head_components=[
rx.el.link(rel="icon", type="image/png", href="/favicon.png"),
@ -24,11 +31,46 @@ app = rx.App(
rx.el.link(rel="apple-touch-icon", href="/apple-touch-icon.png"),
# Android Chrome / PWA : manifest avec icônes 192/512
rx.el.link(rel="manifest", href="/manifest.webmanifest"),
# Force le rendu light du browser (form controls, scrollbars, etc.)
# même quand l'OS est en dark mode. Le thème "sombre" override via CSS.
rx.el.meta(name="color-scheme", content="light"),
# Empêche la traduction automatique du navigateur (Chrome/Edge traduisaient
# certains libellés français selon la locale OS de l'utilisateur).
rx.el.meta(name="google", content="notranslate"),
# Couleur de la barre d'adresse (Android) + barre de statut (iOS standalone)
rx.el.meta(name="theme-color", content="#dc000e"),
rx.el.meta(name="apple-mobile-web-app-capable", content="yes"),
rx.el.meta(name="apple-mobile-web-app-status-bar-style", content="default"),
rx.el.meta(name="apple-mobile-web-app-title", content="EPTM"),
# Préchargement des fonts (évite le FOIT, rendu instantané)
rx.el.link(
rel="preload",
href="/fonts/InterVariable.woff2",
as_="font",
type="font/woff2",
crossorigin="anonymous",
),
# Applique le thème stocké en localStorage avant le premier render —
# évite un flash au défaut EPTM puis bascule. Force aussi colorScheme
# pour empêcher le browser de bascule dark sur OS dark. Force aussi
# lang=fr et translate=no pour neutraliser la traduction automatique.
rx.el.script(
"""
(function() {
try {
var t = localStorage.getItem('theme');
if (t && t !== 'eptm') {
document.documentElement.setAttribute('data-theme', t);
document.body && document.body.setAttribute('data-theme', t);
}
document.documentElement.style.colorScheme =
(t === 'sombre') ? 'dark' : 'light';
document.documentElement.setAttribute('lang', 'fr');
document.documentElement.setAttribute('translate', 'no');
} catch(e) {}
})();
"""
),
],
)
@ -50,5 +92,6 @@ app.add_page(params_page, route="/params", on_load=[AuthState.check_auth,
app.add_page(purge_page, route="/purge", on_load=[AuthState.check_auth, PurgeState.load_data], title=TITLE)
app.add_page(doc_page, route="/doc", on_load=[AuthState.check_auth, DocState.load_data], title=TITLE)
app.add_page(profile_page, route="/profile", on_load=[AuthState.check_auth, ProfileState.load_data], title=TITLE)
app.add_page(feedback_page, route="/feedback",on_load=[AuthState.check_auth, FeedbackAdminState.load_data], title=TITLE)
# Page publique (pas de check_auth — accessible via lien email)
app.add_page(password_set_page, route="/password-set", on_load=PasswordSetState.load_data, title=TITLE)

View file

@ -5,15 +5,14 @@ sys.path.insert(0, "/opt/eptm-dashboard")
import reflex as rx
from src.db import get_session, Apprenti, Absence
from src.stats import kpis, alertes_quota_absences
from src.sanction_pdf import generate_avis_pdf
from src.logger import app_log
from src.user_access import get_allowed_classes, is_class_allowed
from src.stats import kpis, alertes_quota_absences, alertes_notes_insuffisantes
from src.user_access import get_allowed_classes
from sqlalchemy import select, func
from ..state import AuthState
from ..sidebar import layout
from .fiche import FicheState
from .classe import ClasseState
from .sanction import SanctionState
class AccueilState(AuthState):
@ -25,6 +24,9 @@ class AccueilState(AuthState):
classes_total: int = 0
# Groupement par classe pour l'affichage en tuiles
sanctions_groups: list[dict] = []
# Notes insuffisantes (BN / Matu < 4.0)
notes_insuf_total: int = 0
notes_insuf_groups: list[dict] = []
def load_data(self):
if not self.authenticated:
@ -56,6 +58,8 @@ class AccueilState(AuthState):
}
for _, row in df.iterrows()
]
# Le seuil de 5 absences ne s'applique qu'aux classes EM.
items = [it for it in items if it["classe"].startswith("EM")]
# Filtrage selon les classes autorisées
if allowed is not None:
items = [it for it in items if it["classe"] in allowed]
@ -74,6 +78,42 @@ class AccueilState(AuthState):
]
self.sanctions_total = len(items)
self.classes_total = len(self.sanctions_groups)
# ── Notes insuffisantes (BN / Matu < 4.0) ───────────────────
notes_alerts = alertes_notes_insuffisantes(sess, allowed)
# Construit les labels d'affichage des badges :
# - "BN sem. 3,5" / "BN ann. 3,7" / "Matu 3,5"
# - Si bn_sem insuf : on AJOUTE l'annuelle en contexte
# (même si ≥ 4.0) pour donner la vision complète.
def _fmt(v):
return f"{v:.1f}".replace(".", ",") if v is not None else ""
for a in notes_alerts:
bn_badges = []
if a["bn_sem_insuf"]:
bn_badges.append((True, f"BN sem. {_fmt(a['bn_sem'])}"))
# Contexte annuel quand sem insuf — affiché grisé si OK
if a["bn_ann"] is not None and not a["bn_ann_insuf"]:
bn_badges.append((False, f"ann. {_fmt(a['bn_ann'])}"))
if a["bn_ann_insuf"]:
bn_badges.append((True, f"BN ann. {_fmt(a['bn_ann'])}"))
matu_badge = None
if a["matu_insuf"]:
matu_badge = f"Matu {_fmt(a['matu'])}"
a["badges"] = [{"text": t, "insuf": insuf} for insuf, t in bn_badges]
if matu_badge:
a["badges"].append({"text": matu_badge, "insuf": True})
ni_grouped: dict[str, list[dict]] = defaultdict(list)
for a in notes_alerts:
ni_grouped[a["classe"]].append(a)
self.notes_insuf_groups = [
{
"classe": c,
"count": len(ni_grouped[c]),
"items": sorted(ni_grouped[c], key=lambda x: x["worst"] or 99),
}
for c in sorted(ni_grouped.keys())
]
self.notes_insuf_total = len(notes_alerts)
finally:
sess.close()
except Exception as e:
@ -125,41 +165,23 @@ class AccueilState(AuthState):
rx.redirect("/classe"),
]
# ── Téléchargement de l'avis de sanction ─────────────────────────────────
def download_avis(self, apprenti_id: int, nom: str, prenom: str, classe: str):
# Garde-fou : refuse si la classe n'est pas autorisée
if not is_class_allowed(self.username, classe):
return rx.toast.error("Accès refusé pour cette classe.")
sess = get_session()
try:
data = generate_avis_pdf(
sess, apprenti_id, prof_name=self.name or self.username,
)
finally:
sess.close()
if data is None:
return rx.toast.error(
"Template introuvable. Vérifiez data/templates/GF_FO_Avis_de_sanction.pdf"
)
app_log(
f"[avis] {self.username or '?'} : avis de sanction généré pour "
f"{nom} {prenom} ({classe})"
)
safe_nom = "".join(c if c.isalnum() else "_" for c in nom)
safe_prenom = "".join(c if c.isalnum() else "_" for c in prenom)
filename = f"Avis_sanction_{safe_nom}_{safe_prenom}.pdf"
return rx.download(data=data, filename=filename)
def open_sanction(self, apprenti_id: int, nom: str, prenom: str, classe: str):
"""Ouvre la fiche de l'apprenti et pré-remplit le modal d'avis de sanction."""
label = f"{nom} {prenom} ({classe})"
return [
FicheState.navigate_to(apprenti_id),
SanctionState.preload_apprenti(apprenti_id, label),
rx.redirect("/fiche"),
]
# ── UI ────────────────────────────────────────────────────────────────────────
def _kpi_card(label: str, value: rx.Var) -> rx.Component:
return rx.box(
rx.text(label, size="1", color="#555555"),
rx.text(value, size="8", font_weight="700", line_height="1.1"),
background_color="white",
border="1px solid #dee2e6",
rx.text(value, size="8", font_weight="700", line_height="1.1", class_name="tabular"),
background_color="var(--surface)",
border="1px solid var(--border)",
border_radius="8px",
padding="0.75rem 1rem",
flex="1",
@ -170,68 +192,165 @@ def _kpi_card(label: str, value: rx.Var) -> rx.Component:
def _sanction_tile(item: rx.Var) -> rx.Component:
return rx.box(
rx.vstack(
return rx.vstack(
# Ligne 1 : nom + badge absences
rx.flex(
rx.text(
item["nom"], " ", item["prenom"],
size="3", weight="bold", color="#1a237e",
size="2", color="#1a237e",
white_space="nowrap", overflow="hidden",
text_overflow="ellipsis",
flex="1", min_width="0",
),
rx.spacer(),
rx.box(
rx.flex(
rx.icon("triangle-alert", size=12, color="#B71C1C"),
rx.text(
item["absences"], " abs.",
size="1", color="#B71C1C", weight="bold",
),
gap="0.25rem", align="center",
),
rx.icon("triangle-alert", size=11, color="#B71C1C"),
rx.text(item["absences"], size="1", color="#B71C1C", weight="bold"),
gap="0.2rem", align="center",
background_color="#ffe5e5",
padding="0.15rem 0.5rem",
padding="0.1rem 0.4rem",
border_radius="9999px",
flex_shrink="0",
),
width="100%", align="center", gap="0.5rem", wrap="wrap",
width="100%", align="center", gap="0.5rem",
),
# Ligne 2 : bouton créer l'avis
rx.button(
rx.icon("file-down", size=13),
"PDF avis de sanction",
on_click=AccueilState.download_avis(
rx.icon("file-plus", size=13),
"Créer l'avis de sanction",
on_click=AccueilState.open_sanction(
item["id"], item["nom"], item["prenom"], item["classe"],
).stop_propagation,
size="1",
color_scheme="gray",
variant="soft",
),
spacing="2",
align="start",
width="100%",
),
on_click=AccueilState.open_fiche(item["id"]),
cursor="pointer",
padding="0.85rem 1rem",
background_color="white",
border="1px solid #e0e0e0",
border_radius="8px",
flex="1 1 240px",
padding="0.5rem 0.65rem",
background_color="var(--surface)",
border="1px solid var(--border)",
border_radius="6px",
flex="1 1 220px",
min_width="220px",
max_width="320px",
max_width="280px",
spacing="2",
align="start",
class_name="hover-lift sanction-tile",
)
def _notes_badge(badge: rx.Var) -> rx.Component:
"""Badge moyenne : rouge si insuffisant (<4), gris si en contexte (≥4)."""
return rx.flex(
rx.text(
badge["text"], size="1", weight="bold",
color=rx.cond(badge["insuf"], "#B71C1C", "#555"),
),
background_color=rx.cond(badge["insuf"], "#ffe5e5", "var(--surface-hover)"),
padding="0.1rem 0.4rem",
border_radius="9999px",
flex_shrink="0",
)
def _notes_insuf_tile(item: rx.Var) -> rx.Component:
"""Tuile compacte 2 lignes : nom puis badges moyennes. Click → fiche apprenti."""
return rx.vstack(
# Ligne 1 : nom
rx.text(
item["nom"], " ", item["prenom"],
size="2", color="#1a237e",
white_space="nowrap", overflow="hidden",
text_overflow="ellipsis",
width="100%",
),
# Ligne 2 : badges moyennes
rx.flex(
rx.foreach(item["badges"].to(list[dict]), _notes_badge),
gap="0.3rem", flex_wrap="wrap",
),
on_click=AccueilState.open_fiche(item["id"]),
cursor="pointer",
padding="0.5rem 0.65rem",
background_color="var(--surface)",
border="1px solid var(--border)",
border_radius="6px",
flex="1 1 220px",
min_width="220px",
max_width="280px",
spacing="2",
align="start",
class_name="hover-lift",
)
def _notes_class_group(group: rx.Var) -> rx.Component:
"""Groupe de classe pour notes insuffisantes — même pattern que _class_group."""
return rx.box(
rx.flex(
rx.icon("users", size=15, color="var(--text-strong)"),
rx.text(group["classe"], size="3", weight="bold", color="var(--text-strong)"),
on_click=AccueilState.open_classe(group["classe"]),
cursor="pointer",
padding="0.5rem 0.75rem",
border_radius="6px",
background_color="var(--surface-muted)",
border="1px solid #e9ecef",
_hover={"background_color": "#eef2f6"},
width="100%",
align="center",
gap="0.5rem",
class_name="smooth-transition",
margin_bottom="0.6rem",
),
rx.flex(
rx.foreach(group["items"].to(list[dict]), _notes_insuf_tile),
gap="0.6rem",
flex_wrap="wrap",
width="100%",
),
width="100%",
)
def _notes_insuf_section() -> rx.Component:
return rx.cond(
AccueilState.notes_insuf_total == 0,
rx.box(
rx.flex(
rx.icon("circle-check-big", size=18, color="#2e7d32"),
rx.text(
"Aucun apprenti avec moyenne BN ou Matu insuffisante.",
size="2", color="#2e7d32",
),
gap="0.5rem", align="center",
),
background_color="#f1f8f1",
border="1px solid #c8e6c9",
border_radius="6px",
padding="0.85rem 1rem",
width="100%",
),
rx.vstack(
rx.foreach(AccueilState.notes_insuf_groups, _notes_class_group),
spacing="4",
width="100%",
),
)
def _class_group(group: rx.Var) -> rx.Component:
return rx.box(
# En-tête de classe (cliquable → page Classes pré-sélectionnée)
rx.flex(
rx.icon("users", size=15, color="#37474f"),
rx.text(group["classe"], size="3", weight="bold", color="#37474f"),
rx.icon("users", size=15, color="var(--text-strong)"),
rx.text(group["classe"], size="3", weight="bold", color="var(--text-strong)"),
on_click=AccueilState.open_classe(group["classe"]),
cursor="pointer",
padding="0.5rem 0.75rem",
border_radius="6px",
background_color="#f8f9fa",
background_color="var(--surface-muted)",
border="1px solid #e9ecef",
_hover={"background_color": "#eef2f6"},
width="100%",
@ -285,6 +404,7 @@ def accueil_page() -> rx.Component:
# 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",
@ -304,22 +424,12 @@ def accueil_page() -> rx.Component:
rx.divider(),
rx.heading("Notes insuffisantes (BN / Matu < 4.0)", size="5"),
rx.box(
rx.flex(
rx.icon("info", size=16, color="#1565c0"),
rx.text(
"Migration en cours — disponible prochainement.",
color="#1565c0", size="2",
),
rx.icon("triangle-alert", size=20, color="#c62828"),
rx.heading("Notes insuffisantes (BN / Matu < 4.0)", size="5"),
gap="0.5rem", align="center",
),
background_color="#e3f2fd",
border="1px solid #90caf9",
border_radius="6px",
padding="0.75rem 1rem",
width="100%",
),
_notes_insuf_section(),
spacing="5",
width="100%",

View file

@ -11,8 +11,11 @@ DATA_DIR = Path(os.getenv("DATA_DIR", "data"))
from ..state import AuthState
from ..sidebar import layout
from ..components import empty_state, skeleton_apprenti_card
from .fiche import FicheState, _notice_row
from .retenue import RetenueState, retenue_modal
from .sanction import SanctionState, sanction_modal
from src.db import (
get_session, Apprenti, Absence,
get_session, Apprenti, Absence, ApprentiNotice,
NotesBulletin, NotesMatu, NotesExamen, ImportBN, ImportMatu,
)
from src.stats import nb_blocs_absences, synthese_classe
@ -57,6 +60,7 @@ def _bn_html_table(d: dict, sem_labels: list, groups_order: list) -> str:
TD = "border:1px solid #dee2e6;padding:5px 10px"
TH = "border:1px solid #dee2e6;padding:5px 10px;text-align:center;background:#f8f9fa"
SEP = ";border-top:3px solid #9e9e9e"
MOY_BG = "background:#f0f7ff"
header = f'<th style="{TH};text-align:left;min-width:230px"></th>'
for i in range(N):
@ -71,25 +75,44 @@ def _bn_html_table(d: dict, sem_labels: list, groups_order: list) -> str:
def _moy_sem_row(label, gd, label_style, sep=False):
s = SEP if sep else ""
cells = f'<td style="{label_style}{s}">{label}</td>'
cells = f'<td style="{label_style};{MOY_BG}{s}">{label}</td>'
for i in range(N):
v = gd["moy_sem"][i] if i < len(gd.get("moy_sem", [])) else None
cells += f'<td style="{_bn_cell_style(v)}{s}">{_bn_fmt(v)}</td>'
cells += f'<td style="{_bn_cell_style(v)};{MOY_BG}{s}">{_bn_fmt(v)}</td>'
return f"<tr>{cells}</tr>"
def _moy_ann_row(label, gd, label_style, sep=False):
s = SEP if sep else ""
cells = f'<td style="{label_style}{s}">{label}</td>'
cells = f'<td style="{label_style};{MOY_BG}{s}">{label}</td>'
for year_start in range(0, N, 2):
v = gd["moy_ann"][year_start] if year_start < len(gd.get("moy_ann", [])) else None
cells += f'<td colspan="2" style="{_bn_cell_style(v)}{s}">{_bn_fmt(v)}</td>'
cells += f'<td colspan="2" style="{_bn_cell_style(v)};{MOY_BG}{s}">{_bn_fmt(v)}</td>'
return f"<tr>{cells}</tr>"
def _branch_row(branche, sep=False):
s = SEP if sep else ""
cells = f'<td style="{TD}{s}">{branche["nom"]}</td>'
notes = branche.get("notes") or [None] * N
for i in range(N):
v = notes[i] if i < len(notes) else None
cells += f'<td style="{_bn_cell_style(v)}{s}">{_bn_fmt(v)}</td>'
return f"<tr>{cells}</tr>"
def _group_header_row(label, sep=False):
s = SEP if sep else ""
return (
f'<tr><td colspan="{N + 1}" style="{TD};font-weight:bold;'
f'background:#f0f0f0{s}">{label}</td></tr>'
)
body = ""
for grp in groups_order:
gd = d["groupes"].get(grp, {"moy_sem": [None] * N, "moy_ann": [None] * N})
lbl = _GROUP_LABELS.get(grp, grp)
body += _moy_sem_row(lbl, gd, f"{TD};font-weight:bold")
body += _group_header_row(lbl, sep=True)
for br in gd.get("branches", []) or []:
body += _branch_row(br)
body += _moy_sem_row("Moyenne semestrielle du groupe", gd, f"{TD};font-style:italic;color:#555")
body += _moy_ann_row("Moyenne annuelle du groupe", gd, f"{TD};font-style:italic;color:#555")
body += _moy_sem_row("Moyenne semestrielle globale", d["globale"], f"{TD};font-style:italic", sep=True)
@ -410,6 +433,13 @@ class ClasseState(AuthState):
self._reload()
self.is_loading_apprentis = False
def open_apprenti(self, apprenti_id: int):
"""Ouvre la fiche d'un apprenti avec sa sélection pré-remplie."""
return [
FicheState.navigate_to(apprenti_id),
rx.redirect("/fiche"),
]
def set_class_search(self, v: str):
self.class_search = v
@ -532,6 +562,24 @@ class ClasseState(AuthState):
).all()
ne_by_id = {ne.apprenti_id: json.loads(ne.donnees_json) for ne, _ in ne_rows}
# Notices Escada (ApprentiNotice) groupées par apprenti
notices_rows = sess.execute(
select(ApprentiNotice, Apprenti)
.join(Apprenti, Apprenti.id == ApprentiNotice.apprenti_id)
.where(Apprenti.classe == classe)
.order_by(ApprentiNotice.date_event.desc())
).all()
notices_by_id: dict[int, list[dict]] = {}
for n, _ in notices_rows:
notices_by_id.setdefault(n.apprenti_id, []).append({
"date": n.date_event.strftime("%d.%m.%Y") if n.date_event else "",
"type": n.type_notice or "",
"auteur": n.auteur or "",
"titre": n.titre or "",
"remarque": n.remarque or "",
"matiere": n.matiere or "",
})
data = []
for apprenti in apprentis:
abs_data = abs_by_name.get((apprenti.nom, apprenti.prenom))
@ -539,7 +587,8 @@ class ClasseState(AuthState):
excusees = int(abs_data["Excusées"]) if abs_data is not None else 0
non_exc = int(abs_data["NON excusées"]) if abs_data is not None else 0
blocs = nb_blocs_absences(sess, apprenti.id)
quota_atteint = blocs >= QUOTA
# Le quota de 5 absences ne s'applique qu'aux classes EM.
quota_atteint = classe.startswith("EM") and blocs >= QUOTA
# BN HTML
bn = bn_by_id.get(apprenti.id)
@ -570,10 +619,12 @@ class ClasseState(AuthState):
has_notes = False
notes_html = ""
notices = notices_by_id.get(apprenti.id, [])
data.append({
"id": apprenti.id,
"nom": apprenti.nom,
"prenom": apprenti.prenom,
"label": f"{apprenti.prenom} {apprenti.nom}",
"total": total,
"excusees": excusees,
"non_exc": non_exc,
@ -584,6 +635,8 @@ class ClasseState(AuthState):
"bn_caption": bn_caption if has_bn else "",
"has_notes": has_notes,
"notes_html": notes_html,
"has_notices": len(notices) > 0,
"notices": notices,
"has_pdf_bn": bn_pdf_exists,
"has_pdf_notes": notes_pdf_exists,
})
@ -622,7 +675,7 @@ def _classe_searchable_select() -> rx.Component:
padding="0.5rem 0.75rem",
border="1px solid var(--gray-7)",
border_radius="6px",
background_color="white",
background_color="var(--surface)",
cursor="pointer",
width="100%",
custom_attrs={"data-shortcut": "class-search"},
@ -667,9 +720,9 @@ def _classe_searchable_select() -> rx.Component:
def _kpi_mini(label: str, value, color: str = "#37474f") -> rx.Component:
return rx.box(
rx.text(label, size="1", color="#888"),
rx.text(value, size="5", font_weight="700", color=color),
rx.text(value, size="5", font_weight="700", color=color, class_name="tabular"),
padding="0.5rem 0.75rem",
background_color="#f8f9fa",
background_color="var(--surface-muted)",
border_radius="6px",
border="1px solid #e9ecef",
min_width="80px",
@ -677,17 +730,33 @@ def _kpi_mini(label: str, value, color: str = "#37474f") -> rx.Component:
)
def _kpi_card(label: str, value, color: str = "#37474f") -> rx.Component:
"""Carte KPI identique à fiche.py (taille 7, fond surface)."""
return rx.box(
rx.text(label, size="1", color="#666"),
rx.text(value, size="7", font_weight="700", color=color, class_name="tabular"),
padding="1rem",
background_color="var(--surface)",
border_radius="8px",
border="1px solid var(--border)",
flex="1",
min_width="120px",
class_name="hover-lift",
)
def _apprenti_card(item) -> rx.Component:
return rx.box(
# ── En-tête : nom + badge quota ───────────────────────────────────────
rx.hstack(
rx.link(
rx.box(
rx.text(
item["prenom"], " ", item["nom"],
size="4", font_weight="700", color="#1a237e",
),
href="/fiche",
text_decoration="none",
on_click=ClasseState.open_apprenti(item["id"]),
cursor="pointer",
_hover={"text_decoration": "underline"},
),
rx.cond(
item["quota_atteint"],
@ -705,63 +774,111 @@ def _apprenti_card(item) -> rx.Component:
margin_bottom="0.75rem",
),
# ── KPIs absences ─────────────────────────────────────────────────────
# ── KPI cards (identiques à la fiche apprenti) ────────────────────────
rx.flex(
_kpi_mini("Total", item["total"]),
_kpi_mini("Excusees", item["excusees"], "#2e7d32"),
_kpi_mini("Non excusees", item["non_exc"], "#c62828"),
_kpi_card("Périodes d'absence", item["total"]),
_kpi_card("Périodes à excuser", item["non_exc"], "var(--brand-primary-dark)"),
rx.box(
rx.text("Absences", size="1", color="#666"),
rx.text(
item["blocs"],
size="7", font_weight="700",
color=rx.cond(item["quota_atteint"], "#c62828", "#37474f"),
class_name="tabular",
),
rx.cond(
item["quota_atteint"],
_kpi_mini("Absences", item["blocs"], "#c62828"),
_kpi_mini("Absences", item["blocs"]),
rx.text(
"Avis de sanction",
size="1", weight="bold", color="#c62828",
),
gap="0.5rem",
),
padding="1rem",
background_color=rx.cond(item["quota_atteint"], "#fff0f0", "var(--surface)"),
border_radius="8px",
border=rx.cond(
item["quota_atteint"],
"1px solid #ffcdd2",
"1px solid var(--border)",
),
flex="1",
min_width="120px",
),
gap="1rem",
flex_wrap="wrap",
width="100%",
margin_bottom="0.75rem",
),
# ── Boutons téléchargement PDF ────────────────────────────────────────
# ── Actions (PDF exports + créations d'avis) ──────────────────────────
rx.box(
rx.flex(
rx.button(
rx.icon("download", size=13),
"PDF absences",
on_click=ClasseState.download_abs_pdf(item["id"]),
variant="outline",
color_scheme="gray",
size="1",
variant="outline", color_scheme="gray", size="2",
),
rx.cond(
item["has_pdf_bn"],
rx.button(
rx.icon("file-text", size=13),
rx.icon("download", size=13),
"PDF bulletin",
on_click=ClasseState.download_bn_pdf(item["id"]),
variant="outline",
color_scheme="blue",
size="1",
variant="outline", color_scheme="blue", size="2",
),
),
rx.cond(
item["has_pdf_notes"],
rx.button(
rx.icon("file-text", size=13),
rx.icon("download", size=13),
"PDF notes",
on_click=ClasseState.download_notes_pdf(item["id"]),
variant="outline",
color_scheme="violet",
size="1",
variant="outline", color_scheme="violet", size="2",
),
),
flex_wrap="wrap",
# Séparateur visuel
rx.box(
width="1px",
background_color="var(--gray-6)",
margin_x="0.25rem",
align_self="stretch",
),
rx.button(
rx.icon("file-warning", size=14),
"Créer un avis de retenue",
on_click=RetenueState.preload_apprenti(
item["id"], item["label"],
),
color_scheme="orange", variant="soft", size="2",
),
rx.button(
rx.icon("triangle-alert", size=14),
"Créer un avis de sanction",
on_click=SanctionState.preload_apprenti(
item["id"], item["label"],
),
color_scheme="red", variant="soft", size="2",
),
gap="0.5rem",
flex_wrap="wrap",
align="center",
width="100%",
),
padding="0.75rem 1rem",
background_color="var(--surface)",
border_radius="8px",
border="1px solid var(--border)",
width="100%",
margin_bottom="0.75rem",
),
# ── Onglets BN / Notes ────────────────────────────────────────────────
# ── Onglets BN / Notes / Notices ──────────────────────────────────────
rx.tabs.root(
rx.tabs.list(
rx.tabs.trigger("Cours professionnels", value="bn"),
rx.tabs.trigger("Notes d'examen", value="notes"),
rx.tabs.trigger("Notices", value="notices"),
),
rx.tabs.content(
rx.cond(
@ -801,14 +918,45 @@ def _apprenti_card(item) -> rx.Component:
width="100%",
padding_top="0.75rem",
),
rx.tabs.content(
rx.cond(
item["has_notices"],
rx.box(
rx.table.root(
rx.table.header(
rx.table.row(
rx.table.column_header_cell("Date"),
rx.table.column_header_cell("Type"),
rx.table.column_header_cell("Auteur"),
rx.table.column_header_cell("Titre"),
rx.table.column_header_cell("Remarques"),
rx.table.column_header_cell("Matière"),
),
),
rx.table.body(
rx.foreach(item["notices"].to(list[dict]), _notice_row),
),
width="100%", size="1",
),
width="100%", overflow_x="auto",
),
rx.text(
"Aucune notice Escada pour cet(te) apprenti(e).",
size="2", color="#666",
),
),
value="notices",
width="100%",
padding_top="0.75rem",
),
default_value="bn",
width="100%",
),
padding="1.25rem",
background_color="white",
background_color="var(--surface)",
border_radius="8px",
border="1px solid #e0e0e0",
border="1px solid var(--border)",
width="100%",
overflow="hidden",
class_name="hover-lift anim-fade",
@ -818,7 +966,10 @@ def _apprenti_card(item) -> rx.Component:
def classe_page() -> rx.Component:
return layout(
rx.vstack(
rx.heading("Vue classe", size="7"),
# Modals (rendus une fois, contrôlés par leur state respectif)
retenue_modal(),
sanction_modal(),
rx.heading("Classes", size="7"),
rx.cond(
ClasseState.has_classes,

View file

@ -31,6 +31,16 @@ _DAY_LABELS = {
"FRI": "Ven", "SAT": "Sam", "SUN": "Dim",
}
# Libellés des task_kinds. Le choix _quoi traiter_ (Absences, BN+Matu, Notes,
# Fiches, Notices) est porté par des cases à cocher séparées, pas par le
# task_kind lui-même.
_TASK_KINDS = ["push", "sync", "push_then_sync"]
_TASK_LABELS = {
"push": "Push (envoyer vers Escada)",
"sync": "Sync (télécharger depuis Escada)",
"push_then_sync": "Push puis Sync",
}
# ── State ─────────────────────────────────────────────────────────────────────
@ -44,16 +54,17 @@ class CronState(AuthState):
f_name: str = ""
f_enabled: bool = True
f_schedule_kind: str = "daily" # "daily" | "weekly" | "interval"
f_schedule_kind: str = "daily_multi" # "weekly" | "daily_multi"
f_time_hh: str = "03"
f_time_mm: str = "00"
f_interval_min: str = "60"
f_hours: list[str] = [] # ["00:00","06:00",...] pour daily_multi
f_days: list[str] = [] # ["MON","WED",...]
f_task_kind: str = "push_then_sync"
f_sync_abs: bool = True
f_sync_bn: bool = True
f_sync_notes: bool = True
f_sync_fiches: bool = False
f_sync_notices: bool = False
f_force_abs: bool = False
f_classes_all: bool = True
f_classes: list[str] = []
@ -82,8 +93,7 @@ class CronState(AuthState):
"enabled": job.enabled,
"schedule_desc": desc,
"task_kind": job.task_kind,
"task_label": {"push": "Push", "sync": "Sync",
"push_then_sync": "Push + Sync"}.get(job.task_kind, job.task_kind),
"task_label": _TASK_LABELS.get(job.task_kind, job.task_kind),
"last_run_at": job.last_run_at.strftime("%d.%m.%Y %H:%M") if job.last_run_at else "",
"last_status": job.last_status,
"last_message": job.last_message[:120] if job.last_message else "",
@ -94,8 +104,6 @@ class CronState(AuthState):
@staticmethod
def _human_schedule(kind: str, value: str) -> str:
if kind == "daily":
return f"Tous les jours à {value}"
if kind == "weekly":
try:
days_part, time_part = value.split(":", 1)
@ -104,14 +112,13 @@ class CronState(AuthState):
return f"{labels} à {time_part}"
except ValueError:
return value
if kind == "interval":
try:
m = int(value)
if m % 60 == 0:
return f"Toutes les {m // 60} h"
return f"Toutes les {m} min"
except (TypeError, ValueError):
return value
if kind == "daily_multi":
hours = [h.strip() for h in (value or "").split(",") if h.strip()]
if not hours:
return "Aucune heure définie"
if len(hours) <= 6:
return "Tous les jours à " + ", ".join(hours)
return f"Tous les jours — {len(hours)} créneaux ({hours[0]}{hours[-1]})"
return value
@staticmethod
@ -120,27 +127,25 @@ class CronState(AuthState):
if not job.enabled:
return ""
now = datetime.now()
if job.schedule_kind == "interval":
if job.schedule_kind == "daily_multi":
hours = [h.strip() for h in (job.schedule_value or "").split(",") if h.strip()]
best: datetime | None = None
for hhmm in hours:
try:
m = int(job.schedule_value)
except (TypeError, ValueError):
return ""
if job.last_run_at is None:
return "Au prochain tick"
nxt = job.last_run_at + timedelta(minutes=m)
return nxt.strftime("%d.%m %H:%M")
if job.schedule_kind == "daily":
try:
hh, mm = job.schedule_value.split(":")
target = now.replace(hour=int(hh), minute=int(mm), second=0, microsecond=0)
hh, mm = hhmm.split(":")
target = now.replace(hour=int(hh), minute=int(mm),
second=0, microsecond=0)
except (ValueError, AttributeError):
continue
# Si déjà exécuté à ce créneau aujourd'hui, on le pousse au lendemain.
if (job.last_run_at and job.last_run_at.date() == now.date()
and job.last_run_at >= target):
target += timedelta(days=1)
elif target < now:
target += timedelta(days=1)
return target.strftime("%d.%m %H:%M")
except (ValueError, AttributeError):
return ""
if best is None or target < best:
best = target
return best.strftime("%d.%m %H:%M") if best else ""
if job.schedule_kind == "weekly":
return "Selon planning"
return ""
@ -180,16 +185,17 @@ class CronState(AuthState):
self.edit_open = True
self.f_name = ""
self.f_enabled = True
self.f_schedule_kind = "daily"
self.f_schedule_kind = "daily_multi"
self.f_time_hh = "03"
self.f_time_mm = "00"
self.f_interval_min = "60"
self.f_hours = ["03:00"]
self.f_days = ["MON", "TUE", "WED", "THU", "FRI"]
self.f_task_kind = "push_then_sync"
self.f_sync_abs = True
self.f_sync_bn = True
self.f_sync_notes = True
self.f_sync_fiches = False
self.f_sync_notices = False
self.f_force_abs = False
self.f_classes_all = True
self.f_classes = []
@ -210,13 +216,7 @@ class CronState(AuthState):
self.f_name = job.name
self.f_enabled = job.enabled
self.f_schedule_kind = job.schedule_kind
if job.schedule_kind == "daily":
hh, _, mm = (job.schedule_value or "03:00").partition(":")
self.f_time_hh = hh.zfill(2)
self.f_time_mm = mm.zfill(2)
self.f_interval_min = "60"
self.f_days = ["MON", "TUE", "WED", "THU", "FRI"]
elif job.schedule_kind == "weekly":
if job.schedule_kind == "weekly":
try:
days_part, time_part = job.schedule_value.split(":", 1)
hh, _, mm = time_part.partition(":")
@ -227,9 +227,21 @@ class CronState(AuthState):
self.f_time_hh = "03"
self.f_time_mm = "00"
self.f_days = ["MON", "TUE", "WED", "THU", "FRI"]
self.f_interval_min = "60"
else: # interval
self.f_interval_min = job.schedule_value or "60"
self.f_hours = []
else: # daily_multi
hours_norm: list[str] = []
for h in (job.schedule_value or "").split(","):
h = h.strip()
if not h:
continue
parts = h.split(":")
if len(parts) >= 2 and parts[0].isdigit():
# On garde uniquement les heures pleines (00:00, 01:00, ...).
hh_i = int(parts[0])
if 0 <= hh_i < 24:
hours_norm.append(f"{hh_i:02d}:00")
# Dédoublonnage + tri
self.f_hours = sorted(set(hours_norm))
self.f_time_hh = "03"
self.f_time_mm = "00"
self.f_days = ["MON", "TUE", "WED", "THU", "FRI"]
@ -239,6 +251,7 @@ class CronState(AuthState):
self.f_sync_bn = job.sync_bn
self.f_sync_notes = job.sync_notes
self.f_sync_fiches = job.sync_fiches
self.f_sync_notices = bool(getattr(job, "sync_notices", False))
self.f_force_abs = job.force_abs
classes_raw = (job.classes_json or "ALL").strip()
@ -276,9 +289,11 @@ class CronState(AuthState):
def set_f_time_mm(self, v: str):
v = "".join(ch for ch in v if ch.isdigit())[:2]
self.f_time_mm = v
def set_f_interval_min(self, v: str):
v = "".join(ch for ch in v if ch.isdigit())[:5]
self.f_interval_min = v
def toggle_f_hour(self, h: str):
if h in self.f_hours:
self.f_hours = [x for x in self.f_hours if x != h]
else:
self.f_hours = sorted(self.f_hours + [h])
def toggle_f_day(self, day: str):
if day in self.f_days:
self.f_days = [d for d in self.f_days if d != day]
@ -289,6 +304,7 @@ class CronState(AuthState):
def set_f_sync_bn(self, v: bool): self.f_sync_bn = v
def set_f_sync_notes(self, v: bool): self.f_sync_notes = v
def set_f_sync_fiches(self, v: bool): self.f_sync_fiches = v
def set_f_sync_notices(self, v: bool): self.f_sync_notices = v
def set_f_force_abs(self, v: bool): self.f_force_abs = v
def set_f_classes_all(self, v: bool): self.f_classes_all = v
def toggle_f_class(self, c: str):
@ -309,16 +325,7 @@ class CronState(AuthState):
return
# Construire schedule_value selon kind
if self.f_schedule_kind == "daily":
try:
hh = int(self.f_time_hh or "0"); mm = int(self.f_time_mm or "0")
if not (0 <= hh < 24 and 0 <= mm < 60):
raise ValueError
except ValueError:
self.save_error = "Heure invalide."
return
schedule_value = f"{hh:02d}:{mm:02d}"
elif self.f_schedule_kind == "weekly":
if self.f_schedule_kind == "weekly":
if not self.f_days:
self.save_error = "Sélectionne au moins un jour de la semaine."
return
@ -331,15 +338,11 @@ class CronState(AuthState):
return
ordered = [d for d in _DAY_NAMES if d in self.f_days]
schedule_value = f"{','.join(ordered)}:{hh:02d}:{mm:02d}"
else: # interval
try:
m = int(self.f_interval_min or "0")
if m < 1:
raise ValueError
except ValueError:
self.save_error = "Intervalle invalide (minutes > 0)."
else: # daily_multi
if not self.f_hours:
self.save_error = "Sélectionne au moins une heure d'exécution."
return
schedule_value = str(m)
schedule_value = ",".join(sorted(set(self.f_hours)))
if self.f_classes_all:
classes_json = "ALL"
@ -365,6 +368,7 @@ class CronState(AuthState):
sync_bn=self.f_sync_bn,
sync_notes=self.f_sync_notes,
sync_fiches=self.f_sync_fiches,
sync_notices=self.f_sync_notices,
force_abs=self.f_force_abs,
classes_json=classes_json,
notify_on=self.f_notify_on,
@ -388,6 +392,7 @@ class CronState(AuthState):
job.sync_bn = self.f_sync_bn
job.sync_notes = self.f_sync_notes
job.sync_fiches = self.f_sync_fiches
job.sync_notices = self.f_sync_notices
job.force_abs = self.f_force_abs
job.classes_json = classes_json
job.notify_on = self.f_notify_on
@ -516,12 +521,12 @@ def _job_row(job: rx.Var) -> rx.Component:
rx.hstack(
rx.button(
rx.icon(rx.cond(job["enabled"], "pause", "play"), size=14),
on_click=CronState.toggle_enabled(job["id"]),
on_click=CronState.toggle_enabled(job["id"]).stop_propagation,
variant="ghost", size="1", color_scheme="gray",
),
rx.button(
rx.icon("pencil", size=14),
on_click=CronState.open_edit(job["id"]),
on_click=CronState.open_edit(job["id"]).stop_propagation,
variant="ghost", size="1", color_scheme="gray",
),
rx.alert_dialog.root(
@ -559,10 +564,57 @@ def _job_row(job: rx.Var) -> rx.Component:
),
),
padding="0.75rem 1rem",
background_color="white",
background_color="var(--surface)",
border="1px solid var(--gray-5)",
border_radius="6px",
width="100%",
# Click sur la row entière ouvre le panneau d'édition.
on_click=CronState.open_edit(job["id"]),
cursor="pointer",
_hover={"background_color": "var(--surface-hover)"},
)
def _hours_grid() -> rx.Component:
"""Grille 24 cases (00h23h) pour le mode daily_multi."""
cells = []
for h in range(24):
hhmm = f"{h:02d}:00"
cells.append(
rx.box(
rx.text(f"{h:02d}", size="1", weight="bold"),
on_click=CronState.toggle_f_hour(hhmm),
cursor="pointer",
padding="0.4rem 0",
border_radius="6px",
border="2px solid",
text_align="center",
border_color=rx.cond(
CronState.f_hours.contains(hhmm),
"var(--red-9)", "var(--gray-6)",
),
background_color=rx.cond(
CronState.f_hours.contains(hhmm),
"var(--red-9)", "transparent",
),
color=rx.cond(
CronState.f_hours.contains(hhmm),
"white", "var(--gray-12)",
),
)
)
return rx.vstack(
rx.text(
"Heures d'exécution (clic pour activer / désactiver)",
size="1", color="var(--gray-10)",
),
rx.grid(
*cells,
columns="6",
gap="0.3rem",
width="100%",
),
spacing="2", width="100%",
)
@ -570,23 +622,11 @@ def _form_schedule_picker() -> rx.Component:
return rx.vstack(
rx.text("Planification", size="2", font_weight="600"),
rx.radio(
["daily", "weekly", "interval"],
["daily_multi", "weekly"],
value=CronState.f_schedule_kind,
on_change=CronState.set_f_schedule_kind,
direction="row",
),
rx.cond(
CronState.f_schedule_kind == "interval",
rx.hstack(
rx.text("Toutes les", size="2"),
rx.input(
value=CronState.f_interval_min,
on_change=CronState.set_f_interval_min,
width="80px",
),
rx.text("minutes", size="2"),
spacing="2", align="center",
),
rx.cond(
CronState.f_schedule_kind == "weekly",
rx.vstack(
@ -628,17 +668,7 @@ def _form_schedule_picker() -> rx.Component:
),
spacing="2",
),
# daily
rx.hstack(
rx.text("Heure :", size="2"),
rx.input(value=CronState.f_time_hh,
on_change=CronState.set_f_time_hh, width="60px"),
rx.text(":", size="3"),
rx.input(value=CronState.f_time_mm,
on_change=CronState.set_f_time_mm, width="60px"),
spacing="2", align="center",
),
),
_hours_grid(),
),
spacing="2", width="100%",
)
@ -647,16 +677,23 @@ def _form_schedule_picker() -> rx.Component:
def _form_task_picker() -> rx.Component:
return rx.vstack(
rx.text("Tâche", size="2", font_weight="600"),
rx.radio(
["push", "sync", "push_then_sync"],
rx.radio_group.root(
rx.vstack(
*[
rx.flex(
rx.radio_group.item(value=k),
rx.text(_TASK_LABELS[k], size="2"),
gap="0.5rem", align="center",
)
for k in _TASK_KINDS
],
spacing="2",
),
value=CronState.f_task_kind,
on_change=CronState.set_f_task_kind,
direction="column",
),
rx.cond(
CronState.f_task_kind != "push",
rx.vstack(
rx.text("Données à synchroniser", size="2", font_weight="600",
rx.text("Données concernées", size="2", font_weight="600",
margin_top="0.5rem"),
rx.flex(
rx.hstack(
@ -665,6 +702,16 @@ def _form_task_picker() -> rx.Component:
rx.text("Absences", size="2"),
spacing="2", align="center",
),
rx.hstack(
rx.checkbox(checked=CronState.f_sync_notices,
on_change=CronState.set_f_sync_notices, size="2"),
rx.text("Notices", size="2"),
spacing="2", align="center",
),
# BN+Matu / Notes / Fiches : pertinent uniquement pour sync.
rx.cond(
CronState.f_task_kind != "push",
rx.flex(
rx.hstack(
rx.checkbox(checked=CronState.f_sync_bn,
on_change=CronState.set_f_sync_bn, size="2"),
@ -683,17 +730,22 @@ def _form_task_picker() -> rx.Component:
rx.text("Fiches apprentis", size="2"),
spacing="2", align="center",
),
gap="0.5rem 1.25rem", flex_wrap="wrap",
),
),
gap="0.5rem 1.25rem",
flex_wrap="wrap",
),
rx.cond(
CronState.f_task_kind != "push",
rx.hstack(
rx.checkbox(checked=CronState.f_force_abs,
on_change=CronState.set_f_force_abs, size="2"),
rx.text("Forcer le retéléchargement des PDFs absences", size="2"),
spacing="2", align="center",
),
spacing="2",
),
spacing="2",
),
spacing="2", width="100%",
)

View file

@ -76,7 +76,7 @@ def _content() -> rx.Component:
rx.heading(DocState.selected_title, size="6", margin_bottom="1rem"),
rx.html(DocState.selected_html, class_name="doc-content"),
padding="1.5rem 2rem",
background_color="white",
background_color="var(--surface)",
border="1px solid var(--gray-5)",
border_radius="8px",
width="100%",

View file

@ -21,7 +21,7 @@ def _background(fn):
from ..state import AuthState
from ..sidebar import layout
from src.db import get_session, Apprenti, EscadaPending
from src.db import get_session, Apprenti, EscadaPending, Notice
from src.logger import app_log
_RE_SYNC_PROD = re.compile(r"^\[\d{2}:\d{2}:\d{2}\] ([^ ].*)$")
@ -45,6 +45,8 @@ DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data")))
CLASSES_CACHE = DATA_DIR / "esacada_classes.json"
_SYNC_SCRIPT = _ROOT / "scripts" / "sync_esacada.py"
_PUSH_SCRIPT = _ROOT / "scripts" / "push_to_escada.py"
_PUSH_NOTICES_SCRIPT = _ROOT / "scripts" / "push_notices.py"
_PULL_NOTICES_SCRIPT = _ROOT / "scripts" / "pull_notices.py"
_SYNC_RESULT_FILE = DATA_DIR / "sync_last_result.json"
_SYNC_ALL_DONE_FILE = DATA_DIR / "sync_all_done.json"
@ -59,7 +61,9 @@ class EscadaState(AuthState):
sync_bn: bool = True
sync_notes: bool = True
sync_fiches: bool = False
sync_notices: bool = False
force_abs: bool = False
force_notices: bool = False
is_refreshing: bool = False
is_syncing: bool = False
@ -77,11 +81,26 @@ class EscadaState(AuthState):
pending_count: int = 0
pending_data: list[dict] = []
notices_count: int = 0
notices_data: list[dict] = []
push_done: bool = False
push_ok: int = 0
push_errors: list[str] = []
# Push notices
is_pushing_notices: bool = False
notices_push_ok: int = 0
notices_push_done: bool = False
notices_push_errors: list[str] = []
# Pull notices (depuis Escada vers DB)
is_pulling_notices: bool = False
notices_pull_done: bool = False
notices_pull_imported: int = 0
notices_pull_ok: int = 0
notices_pull_errors: list[str] = []
@rx.var
def selected_count(self) -> int:
return sum(1 for v in self.class_checked.values() if v)
@ -118,7 +137,9 @@ class EscadaState(AuthState):
def set_sync_bn(self, v: bool): self.sync_bn = v
def set_sync_notes(self, v: bool): self.sync_notes = v
def set_sync_fiches(self, v: bool): self.sync_fiches = v
def set_sync_notices(self, v: bool): self.sync_notices = v
def set_force_abs(self, v: bool): self.force_abs = v
def set_force_notices(self, v: bool): self.force_notices = v
def _clear_results(self):
self.sync_done = False
@ -221,9 +242,54 @@ class EscadaState(AuthState):
}
for ep in pending
]
self._reload_notices(sess)
finally:
sess.close()
def _reload_notices(self, sess):
notices = sess.execute(
select(Notice)
.options(joinedload(Notice.apprenti))
.join(Apprenti, Notice.apprenti_id == Apprenti.id)
.where(Notice.status == "pending")
.order_by(Apprenti.classe, Notice.date_event, Apprenti.nom)
).scalars().all()
self.notices_count = len(notices)
self.notices_data = [
{
"id": n.id,
"classe": n.apprenti.classe,
"nom": n.apprenti.nom,
"prenom": n.apprenti.prenom,
"date": n.date_event.strftime("%d.%m.%Y"),
"titre": (n.titre or "")[:80] + ("" if len(n.titre or "") > 80 else ""),
"source": n.source,
}
for n in notices
]
def delete_notice(self, notice_id: int):
"""Supprime une notice pending de la file d'attente."""
sess = get_session()
label = ""
try:
n = sess.get(Notice, notice_id)
if n:
ap = n.apprenti
label = (
f"{ap.nom} {ap.prenom}" if ap else f"id={notice_id}"
)
sess.delete(n)
sess.commit()
self._reload_notices(sess)
self.notices_count = len(self.notices_data)
finally:
sess.close()
if label:
app_log(f"[notice] {self.username or '?'} : suppression manuelle pour {label}")
return rx.toast.success(f"Notice supprimée — {label}")
return rx.toast.info("Notice introuvable")
# ── Background: refresh classes ────────────────────────────────────────────
@_background
@ -377,7 +443,9 @@ class EscadaState(AuthState):
sync_bn = self.sync_bn
sync_notes = self.sync_notes
sync_fiches = self.sync_fiches
sync_notices = self.sync_notices
force_abs = self.force_abs
force_notices = self.force_notices
username = self.username or "escada"
if not selected:
return
@ -395,6 +463,7 @@ class EscadaState(AuthState):
if sync_bn: _types.append("BN")
if sync_notes: _types.append("notes")
if sync_fiches: _types.append("fiches")
if sync_notices: _types.append("notices")
_types_label = ", ".join(_types) or ""
app_log(
f"Sync Escada démarrée par {username}"
@ -608,6 +677,9 @@ class EscadaState(AuthState):
# ── État final — async with self #3 ──────────────────────────────────────
app_log(f"Poll terminé — result_ready={_result_ready}")
_uncancel()
# Le sync_done final est posé APRÈS le pull notices (si activé), pour
# que la UI affiche "Pull notices en cours" et pas "terminé" trop tôt.
_will_pull_notices = sync_notices and _result_ready
async with self:
self.import_in_progress = False
if _result_ready:
@ -616,22 +688,155 @@ class EscadaState(AuthState):
self.sync_res_notes = _result_data.get("res_notes", [])
self.sync_res_matu = _result_data.get("res_matu", [])
self.sync_errors = _result_data.get("errors", [])
# Pas encore sync_done=True : on attend le pull notices
if not _will_pull_notices:
self.sync_done = True
app_log("Résultats chargés — sync terminée OK")
else:
self.is_pulling_notices = True
app_log("Résultats chargés — sync principal terminée OK")
_nb_err = len(self.sync_errors)
else:
self.sync_errors = ["Import timeout — vérifiez les logs (> 15min)."]
self.sync_done = True # finalisation (échec)
_nb_err = 1
if _result_ready:
if _result_ready and not _will_pull_notices:
if _nb_err == 0:
yield rx.toast.success("Synchronisation Escada terminée")
else:
yield rx.toast.warning(
f"Synchronisation terminée avec {_nb_err} erreur(s)"
)
else:
elif not _result_ready:
yield rx.toast.error("Import timeout — vérifiez les logs (> 15min)")
# ── Étape supplémentaire : pull des notices ─────────────────────────
if sync_notices and _result_ready:
# Si forcer : supprime les notices pending (push queue) des apprentis
# des classes ciblées AVANT le pull.
if force_notices:
try:
from sqlalchemy import select as _sel, delete as _del
from src.db import get_session as _gs, Apprenti as _Ap, Notice as _Nt
_sess = _gs()
try:
_ap_ids = list(_sess.execute(
_sel(_Ap.id).where(_Ap.classe.in_(selected))
).scalars().all())
if _ap_ids:
_n = _sess.execute(
_del(_Nt).where(_Nt.apprenti_id.in_(_ap_ids))
).rowcount or 0
_sess.commit()
app_log(
f"[pull_notices] force=True → "
f"{_n} notice(s) pending supprimée(s) avant pull"
)
finally:
_sess.close()
except Exception as _e:
app_log(f"[pull_notices] erreur purge force : {_e}")
app_log(f"Pull notices Escada démarré (post-sync) — {len(selected)} classe(s)")
_notices_cmd = [sys.executable, str(_PULL_NOTICES_SCRIPT), *selected]
_notices_lines: list[str] = []
def _run_notices() -> None:
_fd, _tmp = tempfile.mkstemp(suffix="_pull_notices.log")
os.close(_fd)
try:
with open(_tmp, "wb") as _fout:
_proc = subprocess.Popen(
_notices_cmd, stdout=_fout, stderr=subprocess.STDOUT,
env={**os.environ, "PYTHONUNBUFFERED": "1"},
start_new_session=True,
)
_offset = 0
_buf = b""
while True:
_time.sleep(0.5)
try:
with open(_tmp, "rb") as _fin:
_fin.seek(_offset); _chunk = _fin.read(65536)
except Exception:
_chunk = b""
if _chunk:
_buf += _chunk; _offset += len(_chunk)
while b"\n" in _buf:
_raw, _buf = _buf.split(b"\n", 1)
_ln = _raw.decode("utf-8", errors="replace").rstrip()
if _ln:
_notices_lines.append(_ln)
_log_sync_line(_ln, prefix="pull_notices")
if _proc.poll() is not None:
_proc.wait()
break
except Exception as _exc:
app_log(f"Erreur pull notices subprocess : {_exc}")
finally:
try: os.unlink(_tmp)
except Exception: pass
_pool2 = _cf.ThreadPoolExecutor(max_workers=1)
_fut2 = _pool2.submit(_run_notices)
try:
while not _fut2.done():
try:
await asyncio.sleep(1.0)
except asyncio.CancelledError:
_t = asyncio.current_task()
if _t is not None:
for _ in range(_t.cancelling()):
_t.uncancel()
try:
_fut2.result()
except Exception as _te:
app_log(f"[pull_notices] thread exception : {_te}")
finally:
_pool2.shutdown(wait=False)
_nb_imported = 0
_nb_ok = 0
_notices_err: list[str] = []
for _ln in _notices_lines:
if "PULL_NOTICES_DONE " in _ln:
try:
_p = json.loads(_ln[_ln.index("PULL_NOTICES_DONE ") + len("PULL_NOTICES_DONE "):])
_nb_ok = _p.get("ok", 0)
_nb_imported = _p.get("imported", 0)
_notices_err = _p.get("err", [])
except Exception:
pass
try:
_t = asyncio.current_task()
if _t is not None:
for _ in range(_t.cancelling()):
_t.uncancel()
async with self:
self.notices_pull_done = True
self.notices_pull_ok = _nb_ok
self.notices_pull_imported = _nb_imported
self.notices_pull_errors = _notices_err
# Le sync complet est maintenant terminé : on libère l'UI
self.is_pulling_notices = False
self.sync_done = True
except Exception:
pass
app_log(
f"Pull notices terminé — {_nb_ok} apprenti(s), "
f"{_nb_imported} notice(s), {len(_notices_err)} erreur(s)"
)
if _notices_err:
yield rx.toast.warning(
f"Notices : {_nb_imported} importée(s), {len(_notices_err)} erreur(s)"
)
else:
yield rx.toast.success(
f"Synchronisation Escada terminée — {_nb_imported} notice(s) "
f"importée(s) sur {_nb_ok} apprenti(s)"
)
# ── Background: push vers Escada ───────────────────────────────────────────
@_background
@ -767,6 +972,251 @@ class EscadaState(AuthState):
except Exception:
pass
# ── Background: push notices vers Escada ──────────────────────────────────
@_background
async def push_notices(self):
async with self:
user = self.username or "?"
self.is_pushing_notices = True
self.notices_push_done = False
self.notices_push_ok = 0
self.notices_push_errors = []
app_log(f"Push notices Escada démarré par {user}")
cmd = [sys.executable, str(_PUSH_NOTICES_SCRIPT)]
lines: list[str] = []
_rc_holder = [0]
def _run() -> None:
_fd, _tmp = tempfile.mkstemp(suffix="_push_notices.log")
os.close(_fd)
try:
with open(_tmp, "wb") as _fout:
_proc = subprocess.Popen(
cmd, stdout=_fout, stderr=subprocess.STDOUT,
env={**os.environ, "PYTHONUNBUFFERED": "1"},
start_new_session=True,
)
_offset, _buf = 0, b""
while True:
_time.sleep(0.5)
try:
with open(_tmp, "rb") as _fin:
_fin.seek(_offset); _chunk = _fin.read(65536)
except Exception:
_chunk = b""
if _chunk:
_buf += _chunk; _offset += len(_chunk)
while b"\n" in _buf:
_raw, _buf = _buf.split(b"\n", 1)
_ln = _raw.decode("utf-8", errors="replace").rstrip()
if _ln:
lines.append(_ln); _log_sync_line(_ln, prefix="push_notices")
if _proc.poll() is not None:
_rc_holder[0] = _proc.wait() or 0
break
except Exception as _exc:
app_log(f"Erreur push notices subprocess : {_exc}")
finally:
try: os.unlink(_tmp)
except Exception: pass
_pool = _cf.ThreadPoolExecutor(max_workers=1)
_fut = _pool.submit(_run)
try:
while not _fut.done():
try:
await asyncio.sleep(1.0)
except asyncio.CancelledError:
_t = asyncio.current_task()
if _t is not None:
for _ in range(_t.cancelling()):
_t.uncancel()
try:
_fut.result()
except Exception as _te:
app_log(f"[push_notices] thread exception : {_te}")
finally:
_pool.shutdown(wait=False)
_rc = _rc_holder[0]
nb_ok = 0
errors: list[str] = []
done = False
for line in lines:
if "PUSH_NOTICES_DONE " in line:
done = True
try:
p = json.loads(line[line.index("PUSH_NOTICES_DONE ") + len("PUSH_NOTICES_DONE "):])
nb_ok = p.get("ok", 0)
errors = p.get("err", [])
except Exception as _e:
app_log(f" Erreur parse PUSH_NOTICES_DONE : {_e}", debug=True)
if done:
app_log(f"Push notices terminé — ok:{nb_ok} erreurs:{len(errors)}")
else:
app_log(f"Push notices : PUSH_NOTICES_DONE non trouvé (code={_rc})")
try:
_t = asyncio.current_task()
if _t is not None:
for _ in range(_t.cancelling()):
_t.uncancel()
async with self:
self.notices_push_done = done
self.notices_push_ok = nb_ok
self.notices_push_errors = errors
self.is_pushing_notices = False
self._reload_pending()
if done:
if errors:
yield rx.toast.warning(
f"Push notices : {nb_ok} OK, {len(errors)} erreur(s)"
)
else:
yield rx.toast.success(f"Push notices terminé — {nb_ok} envoyée(s)")
else:
yield rx.toast.error("Push notices échoué — vérifiez les logs")
except Exception as _e:
app_log(f"Erreur mise à jour état push notices : {_e}")
try:
async with self:
self.is_pushing_notices = False
except Exception:
pass
# ── Background: pull notices depuis Escada ────────────────────────────────
@_background
async def pull_notices(self):
async with self:
selected = [c for c, v in self.class_checked.items() if v]
user = self.username or "?"
if not selected:
return
self.is_pulling_notices = True
self.notices_pull_done = False
self.notices_pull_imported = 0
self.notices_pull_ok = 0
self.notices_pull_errors = []
app_log(
f"Pull notices Escada démarré par {user}"
f"{len(selected)} classe(s) : {', '.join(selected)}"
)
cmd = [sys.executable, str(_PULL_NOTICES_SCRIPT), *selected]
lines: list[str] = []
_rc_holder = [0]
def _run() -> None:
_fd, _tmp = tempfile.mkstemp(suffix="_pull_notices.log")
os.close(_fd)
try:
with open(_tmp, "wb") as _fout:
_proc = subprocess.Popen(
cmd, stdout=_fout, stderr=subprocess.STDOUT,
env={**os.environ, "PYTHONUNBUFFERED": "1"},
start_new_session=True,
)
_offset, _buf = 0, b""
while True:
_time.sleep(0.5)
try:
with open(_tmp, "rb") as _fin:
_fin.seek(_offset); _chunk = _fin.read(65536)
except Exception:
_chunk = b""
if _chunk:
_buf += _chunk; _offset += len(_chunk)
while b"\n" in _buf:
_raw, _buf = _buf.split(b"\n", 1)
_ln = _raw.decode("utf-8", errors="replace").rstrip()
if _ln:
lines.append(_ln); _log_sync_line(_ln, prefix="pull_notices")
if _proc.poll() is not None:
_rc_holder[0] = _proc.wait() or 0
break
except Exception as _exc:
app_log(f"Erreur pull notices subprocess : {_exc}")
finally:
try: os.unlink(_tmp)
except Exception: pass
_pool = _cf.ThreadPoolExecutor(max_workers=1)
_fut = _pool.submit(_run)
try:
while not _fut.done():
try:
await asyncio.sleep(1.0)
except asyncio.CancelledError:
_t = asyncio.current_task()
if _t is not None:
for _ in range(_t.cancelling()):
_t.uncancel()
try:
_fut.result()
except Exception as _te:
app_log(f"[pull_notices] thread exception : {_te}")
finally:
_pool.shutdown(wait=False)
_rc = _rc_holder[0]
nb_ok = 0
nb_imported = 0
errors: list[str] = []
done = False
for line in lines:
if "PULL_NOTICES_DONE " in line:
done = True
try:
p = json.loads(line[line.index("PULL_NOTICES_DONE ") + len("PULL_NOTICES_DONE "):])
nb_ok = p.get("ok", 0)
nb_imported = p.get("imported", 0)
errors = p.get("err", [])
except Exception as _e:
app_log(f" Erreur parse PULL_NOTICES_DONE : {_e}", debug=True)
if done:
app_log(
f"Pull notices terminé — {nb_ok} apprenti(s), "
f"{nb_imported} notice(s), {len(errors)} erreur(s)"
)
else:
app_log(f"Pull notices : PULL_NOTICES_DONE non trouvé (code={_rc})")
try:
_t = asyncio.current_task()
if _t is not None:
for _ in range(_t.cancelling()):
_t.uncancel()
async with self:
self.notices_pull_done = done
self.notices_pull_ok = nb_ok
self.notices_pull_imported = nb_imported
self.notices_pull_errors = errors
self.is_pulling_notices = False
if done:
if errors:
yield rx.toast.warning(
f"Pull notices : {nb_imported} importée(s), {len(errors)} erreur(s)"
)
else:
yield rx.toast.success(
f"Pull notices terminé — {nb_imported} notice(s) sur {nb_ok} apprenti(s)"
)
else:
yield rx.toast.error("Pull notices échoué — vérifiez les logs")
except Exception as _e:
app_log(f"Erreur mise à jour état pull notices : {_e}")
try:
async with self:
self.is_pulling_notices = False
except Exception:
pass
# ── UI helpers ────────────────────────────────────────────────────────────────
@ -839,7 +1289,7 @@ def _classe_multi_select_escada() -> rx.Component:
padding="0.45rem 0.6rem",
border="2px solid var(--red-7)",
border_radius="6px",
background_color="white",
background_color="var(--surface)",
cursor="pointer",
width="100%",
max_width="640px",
@ -885,16 +1335,16 @@ def _log_box() -> rx.Component:
rx.text(
EscadaState.op_log,
size="1",
color="#37474f",
color="var(--text-strong)",
white_space="pre",
font_family="'Courier New', monospace",
),
max_height="240px",
overflow_y="auto",
overflow_x="auto",
background_color="#f8f9fa",
background_color="var(--surface-muted)",
border_radius="6px",
border="1px solid #dee2e6",
border="1px solid var(--border)",
padding="0.75rem",
width="100%",
margin_top="0.75rem",
@ -906,7 +1356,7 @@ def _result_list(label: str, items, row_fn) -> rx.Component:
return rx.cond(
items.length() > 0,
rx.vstack(
rx.text(label, size="2", font_weight="700", color="#37474f"),
rx.text(label, size="2", font_weight="700", color="var(--text-strong)"),
rx.foreach(items, row_fn),
spacing="1",
),
@ -932,6 +1382,59 @@ def _pending_row(item) -> rx.Component:
)
def _notice_row(item) -> rx.Component:
return rx.table.row(
rx.table.cell(item["classe"]),
rx.table.cell(rx.text(item["nom"], " ", item["prenom"])),
rx.table.cell(item["date"]),
rx.table.cell(rx.text(item["titre"], size="1")),
rx.table.cell(
rx.badge(item["source"], color_scheme="blue", variant="soft", size="1"),
),
rx.table.cell(
rx.alert_dialog.root(
rx.alert_dialog.trigger(
rx.icon_button(
rx.icon("trash-2", size=12),
color_scheme="red",
variant="ghost",
size="1",
),
),
rx.alert_dialog.content(
rx.alert_dialog.title("Supprimer cette notice ?"),
rx.alert_dialog.description(
rx.vstack(
rx.text(
rx.text.strong(item["nom"], " ", item["prenom"]),
"",
item["date"],
size="2",
),
rx.text(item["titre"], size="1", color="var(--gray-11)"),
spacing="1",
),
),
rx.flex(
rx.alert_dialog.cancel(
rx.button("Annuler", variant="soft", color_scheme="gray"),
),
rx.alert_dialog.action(
rx.button(
"Supprimer",
color_scheme="red",
on_click=EscadaState.delete_notice(item["id"]),
),
),
spacing="3", justify="end", margin_top="1rem",
),
max_width="420px",
),
),
),
)
def _sync_progress() -> rx.Component:
"""Indicateurs de progression — remplace l'ancien op_log dans la section sync."""
return rx.vstack(
@ -944,7 +1447,7 @@ def _sync_progress() -> rx.Component:
rx.vstack(
rx.text(
"Synchronisation Escadaweb en cours...",
size="3", font_weight="600", color="#1565c0",
size="3", font_weight="600", color="var(--brand-accent)",
),
rx.text(
"Téléchargement depuis escadaweb.vs.ch (1-3 min)",
@ -1000,6 +1503,34 @@ def _sync_progress() -> rx.Component:
),
),
# Phase 3 : pull notices (uniquement si option Notices cochée)
rx.cond(
EscadaState.is_pulling_notices,
rx.box(
rx.hstack(
rx.spinner(size="3"),
rx.vstack(
rx.text(
"Récupération des notices Escada en cours…",
size="3", font_weight="600", color="#0891b2",
),
rx.text(
"Scrape des notices de chaque apprenti (peut prendre plusieurs minutes)",
size="2", color="#555",
),
spacing="0",
),
align="center",
spacing="3",
),
padding="1rem",
background_color="#ecfeff",
border_radius="8px",
border="1px solid #67e8f9",
width="100%",
),
),
# Résultats
rx.cond(
EscadaState.sync_done,
@ -1069,7 +1600,7 @@ def _sync_progress() -> rx.Component:
rx.cond(
~EscadaState.import_in_progress,
rx.callout.root(
rx.callout.icon(rx.icon("alert-circle", size=16)),
rx.callout.icon(rx.icon("triangle-alert", size=16)),
rx.callout.text(
rx.foreach(
EscadaState.sync_errors,
@ -1106,7 +1637,7 @@ def escada_page() -> rx.Component:
rx.box(
rx.text(
"Synchronisation depuis Escada",
size="3", font_weight="700", color="#37474f",
size="3", font_weight="700", color="var(--text-strong)",
margin_bottom="0.75rem",
),
@ -1154,11 +1685,11 @@ def escada_page() -> rx.Component:
# ── Formulaire sync ────────────────────────────────────────
rx.vstack(
# Sélection des classes — multi-select style Streamlit
rx.text("Classes", size="2", font_weight="700", color="#37474f"),
rx.text("Classes", size="2", font_weight="700", color="var(--text-strong)"),
_classe_multi_select_escada(),
# Options de sync
rx.text("Options", size="2", font_weight="700", color="#37474f"),
rx.text("Options", size="2", font_weight="700", color="var(--text-strong)"),
rx.flex(
rx.flex(
rx.checkbox(checked=EscadaState.sync_abs,
@ -1184,38 +1715,75 @@ def escada_page() -> rx.Component:
rx.text("Données apprentis", size="2"),
gap="0.4rem", align="center",
),
rx.flex(
rx.checkbox(checked=EscadaState.sync_notices,
on_change=EscadaState.set_sync_notices, size="2"),
rx.text("Notices", size="2"),
gap="0.4rem", align="center",
),
gap="1rem",
flex_wrap="wrap",
),
rx.cond(
EscadaState.sync_abs,
# Force re-importation — cases à cocher pour Absences / Notices
rx.box(
rx.flex(
rx.icon(
"triangle-alert",
size=14,
color="#b45309",
rx.icon("triangle-alert", size=14, color="#b45309"),
rx.text(
"Lors de l'import, si des modifications sont en "
"attente (absences, notices) elles ne seront ni "
"écrasées, ni mises à jour. Cocher les cases "
"ci-dessous pour forcer l'import et supprimer "
"les modifications en attente.",
size="2", color="#92400e", font_weight="500",
),
gap="0.5rem", align="start",
margin_bottom="0.5rem",
),
rx.flex(
rx.flex(
rx.checkbox(
checked=EscadaState.force_abs,
on_change=EscadaState.set_force_abs,
size="2",
color_scheme="amber",
disabled=~EscadaState.sync_abs,
),
rx.text(
"Les modifications non uploadées sur Escada lors de l'import sont conservées. Forcer la ré-importation complète des absences pour reprendre l'état complet des absences sur Escada.",
"Absences",
size="2",
color="#92400e",
color=rx.cond(
EscadaState.sync_abs, "#92400e", "#cbd5e1",
),
font_weight="600",
),
gap="0.5rem",
align="center",
padding="0.5rem 0.75rem",
gap="0.4rem", align="center",
),
rx.flex(
rx.checkbox(
checked=EscadaState.force_notices,
on_change=EscadaState.set_force_notices,
size="2",
color_scheme="amber",
disabled=~EscadaState.sync_notices,
),
rx.text(
"Notices",
size="2",
color=rx.cond(
EscadaState.sync_notices, "#92400e", "#cbd5e1",
),
font_weight="600",
),
gap="0.4rem", align="center",
),
gap="1.5rem", flex_wrap="wrap",
),
padding="0.75rem",
background_color="#fef3c7",
border="1px solid #fcd34d",
border_radius="6px",
flex_wrap="wrap",
),
width="100%",
),
# Bouton Synchroniser
@ -1255,17 +1823,17 @@ def escada_page() -> rx.Component:
),
padding="1.25rem",
background_color="white",
background_color="var(--surface)",
border_radius="8px",
border="1px solid #e0e0e0",
border="1px solid var(--border)",
width="100%",
),
# ── Section push vers Escada ───────────────────────────────────────
rx.box(
rx.text(
"Pousser vers Escada",
size="3", font_weight="700", color="#37474f",
"Pousser les absences en attente sur Escada",
size="3", font_weight="700", color="var(--text-strong)",
margin_bottom="0.75rem",
),
@ -1317,7 +1885,7 @@ def escada_page() -> rx.Component:
rx.text("Pousser vers Escada"),
),
on_click=EscadaState.push_escada,
disabled=EscadaState.is_busy,
disabled=EscadaState.is_busy | (EscadaState.pending_count == 0),
color_scheme="red",
size="2",
),
@ -1355,9 +1923,107 @@ def escada_page() -> rx.Component:
),
padding="1.25rem",
background_color="white",
background_color="var(--surface)",
border_radius="8px",
border="1px solid #e0e0e0",
border="1px solid var(--border)",
width="100%",
),
# ── Section notices ───────────────────────────────────────────────
rx.box(
rx.text(
"Pousser les notices en attente sur Escada",
size="3", font_weight="700", color="var(--text-strong)",
margin_bottom="0.75rem",
),
rx.cond(
EscadaState.notices_count == 0,
rx.text("Aucune notice en attente.", size="2", color="#666"),
rx.vstack(
rx.text(
EscadaState.notices_count,
" notice(s) en attente d'envoi vers Escada.",
size="2", color="#e65100", font_weight="600",
),
rx.box(
rx.table.root(
rx.table.header(
rx.table.row(
rx.table.column_header_cell("Classe"),
rx.table.column_header_cell("Apprenti"),
rx.table.column_header_cell("Date"),
rx.table.column_header_cell("Titre"),
rx.table.column_header_cell("Source"),
rx.table.column_header_cell("", width="40px"),
)
),
rx.table.body(
rx.foreach(EscadaState.notices_data, _notice_row),
),
width="100%",
size="1",
),
overflow_x="auto",
width="100%",
),
spacing="2",
width="100%",
margin_bottom="0.75rem",
),
),
rx.flex(
rx.button(
rx.cond(
EscadaState.is_pushing_notices,
rx.spinner(size="2"),
rx.icon("send", size=14),
),
rx.cond(
EscadaState.is_pushing_notices,
rx.text("Envoi en cours..."),
rx.text("Pousser les notices"),
),
on_click=EscadaState.push_notices,
disabled=(
EscadaState.is_pushing_notices
| (EscadaState.notices_count == 0)
),
color_scheme="blue",
size="2",
),
gap="1rem", align="center", flex_wrap="wrap",
margin_top="0.75rem",
),
rx.cond(
EscadaState.notices_push_done,
rx.vstack(
rx.cond(
EscadaState.notices_push_ok > 0,
rx.text(
EscadaState.notices_push_ok,
" notice(s) envoyée(s).",
size="2", color="#2e7d32", font_weight="600",
),
),
rx.cond(
EscadaState.notices_push_errors.length() > 0,
rx.vstack(
rx.foreach(
EscadaState.notices_push_errors,
lambda e: rx.text("", e, size="2", color="#c62828"),
),
spacing="1",
),
),
spacing="2",
margin_top="0.75rem",
width="100%",
),
),
padding="1.25rem",
background_color="var(--surface)",
border_radius="8px",
border="1px solid var(--border)",
width="100%",
),

View file

@ -0,0 +1,915 @@
"""Widget de feedback in-app + page admin /feedback.
Widget : bouton flottant en bas à droite (visible partout). Flow :
1. Bot : « Que voulez-vous faire ? » [Bug] [Idée]
2. User : saisit son message
3. Bot : merci message en DB + email envoyé à l'admin
Page admin : table des messages, modal de réponse SMTP.
"""
from __future__ import annotations
import json
import os
import sys
from datetime import datetime
from pathlib import Path
import reflex as rx
from sqlalchemy import select, func
_ROOT = Path(__file__).resolve().parent.parent.parent
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
from src.db import get_session, FeedbackMessage # noqa: E402
from src.email_sender import send_email # noqa: E402
from src.logger import app_log # noqa: E402
from ..state import AuthState
from ..sidebar import layout
DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data")))
_SETTINGS_FILE = DATA_DIR / "settings.json"
def _load_settings() -> dict:
if _SETTINGS_FILE.exists():
try:
return json.loads(_SETTINGS_FILE.read_text(encoding="utf-8"))
except Exception:
return {}
return {}
# ── State ─────────────────────────────────────────────────────────────────────
class FeedbackChatState(AuthState):
open: bool = False
step: str = "choice" # "choice" | "writing" | "done"
feedback_type: str = "" # "bug" | "feature"
# Historique du chat affiché. Chaque message: {"role": "bot"|"user", "text": str}
messages: list[dict] = []
composing: str = "" # texte en cours dans l'input
submit_error: str = ""
_BOT_GREETING = (
"Bonjour ! Je peux transmettre votre message à l'équipe. "
"Que voulez-vous faire ?"
)
_BOT_BUG = "D'accord. Décrivez le bug : ce qui s'est passé, sur quelle page, ce qui était attendu."
_BOT_FEATURE = "Avec plaisir. Décrivez l'idée : le besoin, le contexte."
_BOT_DONE = "Merci ! Votre message a été transmis. L'équipe vous répondra par email."
def set_open(self, v: bool):
self.open = v
if v and not self.messages:
# Première ouverture : démarrer la conversation
self.messages = [{"role": "bot", "text": self._BOT_GREETING}]
if not v:
# Reset à la fermeture
self.step = "choice"
self.feedback_type = ""
self.messages = []
self.composing = ""
self.submit_error = ""
_SCROLL_JS = (
"setTimeout(() => {"
" var el = document.getElementById('feedback-chat-scroll');"
" if (el) el.scrollTop = el.scrollHeight;"
"}, 50);"
)
def start_bug(self):
self.feedback_type = "bug"
self.messages = self.messages + [
{"role": "user", "text": "Signaler un bug"},
{"role": "bot", "text": self._BOT_BUG},
]
self.step = "writing"
return rx.call_script(self._SCROLL_JS)
def start_feature(self):
self.feedback_type = "feature"
self.messages = self.messages + [
{"role": "user", "text": "Proposer une fonctionnalité"},
{"role": "bot", "text": self._BOT_FEATURE},
]
self.step = "writing"
return rx.call_script(self._SCROLL_JS)
def set_composing(self, v: str):
self.composing = v
self.submit_error = ""
def submit(self):
msg = (self.composing or "").strip()
if not msg:
self.submit_error = "Le message ne peut pas être vide."
return
if not self.feedback_type:
self.submit_error = "Choisissez d'abord Bug ou Idée."
return
# Pousse le message utilisateur dans le chat AVANT la persistance
# (UX : l'utilisateur voit sa bulle immédiatement).
self.messages = self.messages + [{"role": "user", "text": msg}]
self.composing = ""
sess = get_session()
try:
fb = FeedbackMessage(
created_by=self.username or "anonyme",
user_email=self._lookup_user_email(),
type=self.feedback_type,
message=msg,
context_url=self.router.page.path or "",
)
sess.add(fb)
sess.commit()
app_log(
f"[feedback] {self.username or '?'} : nouveau {self.feedback_type}"
)
except Exception as e:
sess.rollback()
self.submit_error = f"Erreur en base : {e}"
return
finally:
sess.close()
# Notification email à l'admin (best-effort).
try:
self._notify_admin_with_msg(msg)
except Exception as _e:
app_log(f"[feedback] échec notif admin : {_e}")
self.messages = self.messages + [{"role": "bot", "text": self._BOT_DONE}]
self.step = "done"
return rx.call_script(self._SCROLL_JS)
def _lookup_user_email(self) -> str | None:
"""Récupère l'email de l'utilisateur depuis auth.yaml."""
try:
import yaml
auth_file = DATA_DIR / "auth.yaml"
if not auth_file.exists():
return None
cfg = yaml.safe_load(auth_file.read_text(encoding="utf-8")) or {}
user = cfg.get("credentials", {}).get("usernames", {}).get(self.username, {})
return user.get("email") or None
except Exception:
return None
def _notify_admin_with_msg(self, msg: str):
s = _load_settings()
admin_email = (s.get("feedback_admin_email") or "").strip()
if not admin_email:
return # pas d'admin configuré → skip
smtp_host = s.get("smtp_host")
smtp_port = int(s.get("smtp_port") or 587)
smtp_login = s.get("smtp_login")
smtp_password = s.get("smtp_password")
smtp_sender = s.get("smtp_sender")
if not (smtp_host and smtp_login and smtp_password and smtp_sender):
return
label = "Bug" if self.feedback_type == "bug" else "Proposition"
subject = f"[EPTM Dashboard] Nouveau {label.lower()}{self.username or '?'}"
body = (
f"Nouveau message de feedback EPTM Dashboard.\n\n"
f"Type : {label}\n"
f"Utilisateur : {self.name or self.username or '?'}\n"
f"Email : {self._lookup_user_email() or '(non renseigné)'}\n"
f"Page d'origine : {self.router.page.path or ''}\n\n"
f"Message :\n{msg}\n\n"
f"Pour répondre : https://dashboard.eptm-automation.ch/feedback\n"
)
send_email(
smtp_host=smtp_host, smtp_port=smtp_port,
smtp_login=smtp_login, smtp_password=smtp_password,
smtp_sender=smtp_sender,
to_email=admin_email, subject=subject, body=body,
)
# ── Widget UI (bouton flottant + chat) ────────────────────────────────────────
def _bubble(msg: rx.Var) -> rx.Component:
"""Une bulle de message. Style Crisp : bot à gauche (gris), user à droite (bleu)."""
is_user = msg["role"] == "user"
return rx.flex(
# Avatar bot à gauche (seulement pour les messages bot)
rx.cond(
is_user,
rx.fragment(),
rx.flex(
rx.icon("bot", color="white"),
background_color="var(--brand-accent)",
border_radius="50%",
width="28px", height="28px",
align="center", justify="center",
flex_shrink="0",
class_name="feedback-bot-bubble",
),
),
rx.box(
rx.text(
msg["text"], size="2",
color=rx.cond(is_user, "white", "var(--text-strong)"),
style={"white_space": "pre-wrap"},
),
padding="0.55rem 0.85rem",
border_radius="14px",
background_color=rx.cond(is_user, "var(--brand-accent)", "var(--gray-3)"),
max_width="80%",
),
# Spacer côté avatar opposé (pour pousser la bulle au bon côté)
rx.cond(
is_user,
rx.fragment(),
rx.fragment(),
),
justify=rx.cond(is_user, "end", "start"),
align="end",
gap="0.5rem",
width="100%",
margin_bottom="0.5rem",
)
def _chat_messages() -> rx.Component:
return rx.box(
rx.foreach(FeedbackChatState.messages, _bubble),
id="feedback-chat-scroll",
height="320px",
overflow_y="auto",
padding="0.75rem",
background_color="var(--surface-soft)",
border_radius="10px",
border="1px solid var(--border-soft)",
width="100%",
class_name="no-scrollbar",
)
def _quick_replies() -> rx.Component:
"""Boutons de choix initiaux (affichés tant que step=choice)."""
return rx.cond(
FeedbackChatState.step == "choice",
rx.flex(
rx.button(
rx.icon("bug", size=13), "Signaler un bug",
on_click=FeedbackChatState.start_bug,
color_scheme="red", variant="soft", size="2",
),
rx.button(
rx.icon("lightbulb", size=13), "Proposer une idée",
on_click=FeedbackChatState.start_feature,
color_scheme="blue", variant="soft", size="2",
),
gap="0.5rem", flex_wrap="wrap", width="100%",
),
)
def _composer() -> rx.Component:
"""Input + bouton send en bas du chat (style Crisp footer)."""
can_send = (
(FeedbackChatState.step == "writing")
& (FeedbackChatState.composing != "")
)
return rx.cond(
FeedbackChatState.step == "done",
rx.flex(
rx.dialog.close(
rx.button("Fermer", variant="soft", color_scheme="gray", size="2"),
),
justify="end", width="100%",
),
rx.flex(
rx.text_area(
value=FeedbackChatState.composing,
on_change=FeedbackChatState.set_composing,
placeholder=rx.cond(
FeedbackChatState.step == "writing",
"Tapez votre message…",
"Choisissez une option ci-dessus…",
),
disabled=FeedbackChatState.step != "writing",
rows="3",
resize="none",
width="100%",
style={"font_size": "0.9rem"},
),
rx.icon_button(
rx.icon("send", size=18),
on_click=FeedbackChatState.submit,
disabled=~can_send,
color_scheme="blue", variant="solid", size="3",
style={"align_self": "flex-end"},
),
gap="0.5rem", width="100%", align="end",
),
)
def feedback_widget() -> rx.Component:
"""Bouton flottant + chat. À placer dans le layout principal."""
return rx.cond(
AuthState.authenticated,
rx.dialog.root(
rx.dialog.trigger(
rx.icon_button(
rx.icon("message-square", size=20),
size="3",
color_scheme="blue",
variant="solid",
style={
"position": "fixed",
"bottom": "1.5rem",
"right": "1.5rem",
"z_index": "1000",
"border_radius": "9999px",
"box_shadow": "0 4px 12px rgba(0,0,0,0.18)",
"cursor": "pointer",
},
title="Signaler un bug ou proposer une idée",
),
),
rx.dialog.content(
# Header style chat (toute largeur, coins arrondis en haut)
rx.flex(
rx.icon("message-square", size=18, color="white"),
rx.text(
"Feedback",
size="3", weight="bold", color="white",
# translate="no" empêche la traduction auto du browser.
class_name="notranslate",
custom_attrs={"translate": "no"},
),
rx.spacer(),
rx.dialog.close(
rx.icon_button(
rx.icon("x", size=14),
variant="ghost", size="1",
style={"color": "white"},
),
),
gap="0.5rem", align="center",
padding="0.75rem 1rem",
background_color="var(--brand-accent)",
),
rx.vstack(
_chat_messages(),
_quick_replies(),
rx.cond(
FeedbackChatState.submit_error != "",
rx.text(
FeedbackChatState.submit_error,
size="1", color="var(--red-10)",
),
),
_composer(),
spacing="2", width="100%",
padding="1rem",
),
max_width="480px",
# Retire le padding par défaut du Radix dialog pour que le
# header s'étende sur toute la largeur, et coins arrondis 4×.
padding="0",
overflow="hidden",
border_radius="12px",
),
open=FeedbackChatState.open,
on_open_change=FeedbackChatState.set_open,
),
)
# ── Page admin /feedback ──────────────────────────────────────────────────────
class FeedbackAdminState(AuthState):
items: list[dict] = []
new_count: int = 0
filter_status: str = "all" # "all" | "new" | "in_progress" | "resolved"
filter_type: str = "all" # "all" | "bug" | "feature"
# Modal détail / réponse
detail_open: bool = False
sel_id: int = 0
sel_created_at: str = ""
sel_created_by: str = ""
sel_user_email: str = ""
sel_type: str = ""
sel_message: str = ""
sel_context_url: str = ""
sel_status: str = "new"
sel_response: str = ""
sel_response_sent_at: str = ""
send_error: str = ""
def set_filter_status(self, v: str):
self.filter_status = v
self._reload()
def set_filter_type(self, v: str):
self.filter_type = v
self._reload()
def load_data(self):
if not self.authenticated:
return rx.redirect("/login")
if self.role != "admin":
return rx.redirect("/accueil")
self._reload()
@staticmethod
def _load_username_to_name() -> dict[str, str]:
"""Mapping username → nom complet depuis auth.yaml (vide si erreur)."""
out: dict[str, str] = {}
try:
import yaml
auth_file = DATA_DIR / "auth.yaml"
if not auth_file.exists():
return out
cfg = yaml.safe_load(auth_file.read_text(encoding="utf-8")) or {}
for uname, data in cfg.get("credentials", {}).get("usernames", {}).items():
out[uname] = (data or {}).get("name") or uname
except Exception:
pass
return out
def _reload(self):
username_to_name = self._load_username_to_name()
sess = get_session()
try:
q = select(FeedbackMessage).order_by(FeedbackMessage.created_at.desc())
if self.filter_status != "all":
q = q.where(FeedbackMessage.status == self.filter_status)
if self.filter_type != "all":
q = q.where(FeedbackMessage.type == self.filter_type)
rows = sess.execute(q).scalars().all()
self.items = [
{
"id": r.id,
"created_at": r.created_at.strftime("%d.%m.%Y %H:%M") if r.created_at else "",
"created_by": r.created_by or "",
"created_by_label": username_to_name.get(r.created_by, r.created_by or ""),
"type": r.type or "",
"type_label": "Bug" if r.type == "bug" else "Idée",
"status": r.status or "new",
"status_label": {"new":"Nouveau","in_progress":"En cours","resolved":"Résolu"}.get(r.status, r.status),
"preview": (r.message or "")[:80] + ("" if r.message and len(r.message) > 80 else ""),
}
for r in rows
]
self.new_count = sess.execute(
select(func.count(FeedbackMessage.id)).where(FeedbackMessage.status == "new")
).scalar() or 0
finally:
sess.close()
def open_detail(self, mid: int):
username_to_name = self._load_username_to_name()
sess = get_session()
try:
fb = sess.get(FeedbackMessage, mid)
if not fb:
return
self.sel_id = fb.id
self.sel_created_at = fb.created_at.strftime("%d.%m.%Y %H:%M") if fb.created_at else ""
self.sel_created_by = username_to_name.get(fb.created_by, fb.created_by or "")
self.sel_user_email = fb.user_email or ""
self.sel_type = fb.type or ""
self.sel_message = fb.message or ""
self.sel_context_url = fb.context_url or ""
self.sel_status = fb.status or "new"
self.sel_response = fb.admin_response or ""
self.sel_response_sent_at = (
fb.response_sent_at.strftime("%d.%m.%Y %H:%M") if fb.response_sent_at else ""
)
self.send_error = ""
self.detail_open = True
finally:
sess.close()
def set_detail_open(self, v: bool):
self.detail_open = v
if not v:
self.send_error = ""
def set_sel_response(self, v: str):
self.sel_response = v
self.send_error = ""
def mark_in_progress(self):
return self._update_status("in_progress")
def mark_resolved(self):
return self._update_status("resolved")
def _update_status(self, status: str):
sess = get_session()
try:
fb = sess.get(FeedbackMessage, self.sel_id)
if fb:
fb.status = status
sess.commit()
self.sel_status = status
finally:
sess.close()
# Notification email à l'utilisateur si email disponible (best-effort)
sent = False
try:
sent = self._send_status_email(status)
except Exception as e:
app_log(f"[feedback] échec notif statut : {e}")
app_log(
f"[feedback] {self.username or '?'} : msg #{self.sel_id}{status}"
)
self._reload()
self._refresh_feedback_count()
if sent:
return rx.toast.success(
f"Statut mis à jour — email envoyé à {self.sel_user_email}"
)
return rx.toast.info("Statut mis à jour")
def _send_status_email(self, status: str) -> bool:
"""Envoie un email à l'auteur du message quand son statut change.
Retourne True si email envoyé, False si skipped (config manquante ou
pas d'email user)."""
if not self.sel_user_email or "@" not in self.sel_user_email:
return False
s = _load_settings()
smtp_host = s.get("smtp_host")
smtp_port = int(s.get("smtp_port") or 587)
smtp_login = s.get("smtp_login")
smtp_password = s.get("smtp_password")
smtp_sender = s.get("smtp_sender")
if not (smtp_host and smtp_login and smtp_password and smtp_sender):
return False
if status == "in_progress":
subject = "[EPTM Dashboard] Votre signalement est en cours de traitement"
status_label = "en cours de traitement"
elif status == "resolved":
subject = "[EPTM Dashboard] Votre signalement a été résolu"
status_label = "résolu"
else:
return False
body = (
f"Bonjour,\n\n"
f"Le statut de votre signalement envoyé le {self.sel_created_at} "
f"est passé à : {status_label}.\n\n"
f"---\nMessage initial :\n{self.sel_message}\n\n"
f"Cordialement,\nEPTM Dashboard\n"
)
send_email(
smtp_host=smtp_host, smtp_port=smtp_port,
smtp_login=smtp_login, smtp_password=smtp_password,
smtp_sender=smtp_sender,
to_email=self.sel_user_email, subject=subject, body=body,
)
return True
def delete_current(self):
"""Supprime le message actuellement ouvert et ferme le modal."""
if not self.sel_id:
return
sess = get_session()
try:
fb = sess.get(FeedbackMessage, self.sel_id)
if fb:
sess.delete(fb)
sess.commit()
app_log(
f"[feedback] {self.username or '?'} : suppression du "
f"message #{self.sel_id}"
)
finally:
sess.close()
self.detail_open = False
self._reload()
self._refresh_feedback_count()
return rx.toast.success("Message supprimé")
def send_response_only(self):
"""Envoie le commentaire par email, sans changer le statut."""
return self._send_response(None)
def send_response_in_progress(self):
"""Envoie le commentaire + marque le message 'en cours'."""
return self._send_response("in_progress")
def send_response_resolved(self):
"""Envoie le commentaire + marque le message 'résolu'."""
return self._send_response("resolved")
def _send_response(self, new_status):
if not self.sel_response.strip():
self.send_error = "La réponse ne peut pas être vide."
return
if not self.sel_user_email or "@" not in self.sel_user_email:
self.send_error = "Pas d'email utilisateur enregistré pour ce message."
return
s = _load_settings()
smtp_host = s.get("smtp_host")
smtp_port = int(s.get("smtp_port") or 587)
smtp_login = s.get("smtp_login")
smtp_password = s.get("smtp_password")
smtp_sender = s.get("smtp_sender")
if not (smtp_host and smtp_login and smtp_password and smtp_sender):
self.send_error = "Configuration SMTP incomplète (Paramètres)."
return
# Sujet adapté au statut résultant
status_suffix = {
"in_progress": " (en cours)",
"resolved": " (résolu)",
}.get(new_status, "")
subject = f"[EPTM Dashboard] Réponse à votre signalement{status_suffix}"
body = (
f"Bonjour,\n\n"
f"Voici la réponse à votre message envoyé le {self.sel_created_at} :\n\n"
f"{self.sel_response.strip()}\n\n"
f"---\nMessage initial :\n{self.sel_message}\n\n"
f"Cordialement,\n{self.name or self.username or 'EPTM Dashboard'}\n"
)
try:
send_email(
smtp_host=smtp_host, smtp_port=smtp_port,
smtp_login=smtp_login, smtp_password=smtp_password,
smtp_sender=smtp_sender,
to_email=self.sel_user_email, subject=subject, body=body,
)
except Exception as e:
self.send_error = f"Échec d'envoi : {e}"
return
# Persister la réponse + (optionnellement) le statut
sess = get_session()
try:
fb = sess.get(FeedbackMessage, self.sel_id)
if fb:
fb.admin_response = self.sel_response.strip()
fb.response_sent_at = datetime.now()
if new_status:
fb.status = new_status
self.sel_status = new_status
sess.commit()
self.sel_response_sent_at = fb.response_sent_at.strftime("%d.%m.%Y %H:%M")
finally:
sess.close()
app_log(
f"[feedback] {self.username or '?'} : réponse envoyée à "
f"{self.sel_user_email} (msg #{self.sel_id}, "
f"statut={new_status or 'inchangé'})"
)
self._reload()
self._refresh_feedback_count()
return rx.toast.success(f"Réponse envoyée à {self.sel_user_email}")
def _row(item: rx.Var) -> rx.Component:
return rx.table.row(
rx.table.cell(item["created_at"], white_space="nowrap", color="var(--text-soft)"),
rx.table.cell(item["created_by_label"], color="var(--text-strong)"),
rx.table.cell(
rx.badge(
item["type_label"],
color_scheme=rx.cond(item["type"] == "bug", "red", "blue"),
variant="soft",
),
),
rx.table.cell(
rx.badge(
item["status_label"],
color_scheme=rx.match(
item["status"],
("new", "amber"),
("in_progress", "blue"),
("resolved", "green"),
"gray",
),
variant="soft",
),
),
rx.table.cell(item["preview"], color="var(--text-soft)"),
# Toute la ligne est cliquable → ouvre le détail
on_click=FeedbackAdminState.open_detail(item["id"]),
cursor="pointer",
_hover={"background_color": "var(--surface-hover)"},
)
def _filters() -> rx.Component:
return rx.flex(
rx.vstack(
rx.text("Statut", size="1", color="var(--text-muted)"),
rx.select.root(
rx.select.trigger(),
rx.select.content(
rx.select.item("Tous", value="all"),
rx.select.item("Nouveau", value="new"),
rx.select.item("En cours", value="in_progress"),
rx.select.item("Résolu", value="resolved"),
),
value=FeedbackAdminState.filter_status,
on_change=FeedbackAdminState.set_filter_status,
),
spacing="1", align="start",
),
rx.vstack(
rx.text("Type", size="1", color="var(--text-muted)"),
rx.select.root(
rx.select.trigger(),
rx.select.content(
rx.select.item("Tous", value="all"),
rx.select.item("Bug", value="bug"),
rx.select.item("Idée", value="feature"),
),
value=FeedbackAdminState.filter_type,
on_change=FeedbackAdminState.set_filter_type,
),
spacing="1", align="start",
),
gap="1rem", flex_wrap="wrap",
)
def _detail_modal() -> rx.Component:
return rx.dialog.root(
rx.dialog.content(
rx.dialog.title("Détail du message"),
rx.vstack(
rx.flex(
rx.badge(
rx.cond(FeedbackAdminState.sel_type == "bug", "Bug", "Idée"),
color_scheme=rx.cond(FeedbackAdminState.sel_type == "bug", "red", "blue"),
variant="soft",
),
rx.text("De : ", FeedbackAdminState.sel_created_by,
size="2", color="var(--text-soft)"),
rx.text("", FeedbackAdminState.sel_created_at,
size="2", color="var(--text-soft)"),
gap="0.5rem", align="center", flex_wrap="wrap",
),
rx.cond(
FeedbackAdminState.sel_user_email != "",
rx.text(
"Email : ", FeedbackAdminState.sel_user_email,
size="2", color="var(--text-soft)",
),
),
rx.cond(
FeedbackAdminState.sel_context_url != "",
rx.text(
"Page d'origine : ", FeedbackAdminState.sel_context_url,
size="1", color="var(--text-muted)",
),
),
rx.box(
rx.text(FeedbackAdminState.sel_message,
size="2", color="var(--text-strong)",
style={"white_space": "pre-wrap"}),
padding="0.75rem 1rem",
background_color="var(--surface-muted)",
border_radius="6px",
border="1px solid var(--border-soft)",
width="100%",
),
rx.divider(),
rx.cond(
FeedbackAdminState.sel_response_sent_at != "",
rx.callout.root(
rx.callout.icon(rx.icon("check", size=14)),
rx.callout.text(
"Réponse envoyée le ", FeedbackAdminState.sel_response_sent_at,
),
color_scheme="green", variant="soft", size="1",
),
),
rx.text("Réponse :", size="2", weight="medium", color="var(--text-strong)"),
rx.text_area(
value=FeedbackAdminState.sel_response,
on_change=FeedbackAdminState.set_sel_response,
placeholder="Tapez votre réponse à l'utilisateur…",
rows="6", width="100%",
),
rx.cond(
FeedbackAdminState.send_error != "",
rx.callout.root(
rx.callout.icon(rx.icon("triangle-alert", size=14)),
rx.callout.text(FeedbackAdminState.send_error),
color_scheme="red", variant="soft", size="1",
),
),
rx.flex(
rx.button(
rx.icon("send", size=14), "Envoyer seulement",
on_click=FeedbackAdminState.send_response_only,
color_scheme="gray", variant="soft", size="2",
),
rx.button(
rx.icon("send", size=14), "Envoyer + en cours",
on_click=FeedbackAdminState.send_response_in_progress,
color_scheme="blue", size="2",
),
rx.button(
rx.icon("send", size=14), "Envoyer + résolu",
on_click=FeedbackAdminState.send_response_resolved,
color_scheme="green", size="2",
),
rx.spacer(),
rx.alert_dialog.root(
rx.alert_dialog.trigger(
rx.button(
rx.icon("trash-2", size=14),
"Supprimer",
variant="outline", color_scheme="red", size="2",
),
),
rx.alert_dialog.content(
rx.alert_dialog.title("Supprimer ce message ?"),
rx.alert_dialog.description(
"Cette action est définitive. Le message et la "
"réponse associée seront perdus.",
size="2",
),
rx.flex(
rx.alert_dialog.cancel(
rx.button("Annuler", variant="soft", color_scheme="gray"),
),
rx.alert_dialog.action(
rx.button(
"Supprimer définitivement",
color_scheme="red",
on_click=FeedbackAdminState.delete_current,
),
),
gap="0.5rem", justify="end", margin_top="1rem",
),
max_width="420px",
),
),
rx.dialog.close(
rx.button("Fermer", variant="soft", color_scheme="gray", size="2"),
),
gap="0.5rem", flex_wrap="wrap", width="100%", align="center",
),
spacing="3", width="100%",
),
max_width="720px",
max_height="90vh",
overflow_y="auto",
),
open=FeedbackAdminState.detail_open,
on_open_change=FeedbackAdminState.set_detail_open,
)
def feedback_page() -> rx.Component:
return layout(
rx.vstack(
rx.flex(
rx.heading("Feedback utilisateurs", size="6"),
rx.spacer(),
rx.badge(
FeedbackAdminState.new_count, " nouveau(x)",
color_scheme="amber", variant="soft", size="2",
),
align="center", width="100%", flex_wrap="wrap",
),
_filters(),
rx.cond(
FeedbackAdminState.items.length() == 0,
rx.callout.root(
rx.callout.icon(rx.icon("inbox", size=16)),
rx.callout.text("Aucun message pour ces filtres."),
color_scheme="gray", variant="soft", size="1",
),
rx.box(
rx.table.root(
rx.table.header(
rx.table.row(
rx.table.column_header_cell("Date"),
rx.table.column_header_cell("Utilisateur"),
rx.table.column_header_cell("Type"),
rx.table.column_header_cell("Statut"),
rx.table.column_header_cell("Message"),
),
),
rx.table.body(
rx.foreach(FeedbackAdminState.items, _row),
),
width="100%", size="2",
),
overflow_x="auto", width="100%",
),
),
_detail_modal(),
spacing="4", width="100%",
)
)

View file

@ -16,6 +16,7 @@ from ..sidebar import layout
from src.db import (
get_session, Apprenti, Absence, ApprentiFiche,
NotesBulletin, NotesMatu, NotesExamen, ImportBN, ImportMatu,
ApprentiNotice,
upsert_escada_pending,
)
from src.stats import nb_blocs_absences
@ -24,6 +25,8 @@ from src.email_sender import build_template_vars, render_template
from src.logger import app_log
from src.user_access import get_allowed_classes, is_class_allowed
from ..components import empty_state
from .retenue import RetenueState, retenue_modal
from .sanction import SanctionState, sanction_modal
MOIS_FR = [
"janvier", "fevrier", "mars", "avril", "mai", "juin",
@ -76,6 +79,9 @@ def _bn_html_table(d: dict, sem_labels: list, groups_order: list) -> str:
TD = "border:1px solid #dee2e6;padding:5px 10px"
TH = "border:1px solid #dee2e6;padding:5px 10px;text-align:center;background:#f8f9fa"
SEP = ";border-top:3px solid #9e9e9e"
# Fond pour les lignes "Moyenne ..." — pas gris (déjà utilisé par les
# en-têtes de groupe), juste un bleu très pâle pour les distinguer.
MOY_BG = "background:#f0f7ff"
header = f'<th style="{TH};text-align:left;min-width:230px"></th>'
for i in range(N):
@ -90,25 +96,48 @@ def _bn_html_table(d: dict, sem_labels: list, groups_order: list) -> str:
def _moy_sem_row(label, gd, label_style, sep=False):
s = SEP if sep else ""
cells = f'<td style="{label_style}{s}">{label}</td>'
cells = f'<td style="{label_style};{MOY_BG}{s}">{label}</td>'
for i in range(N):
v = gd["moy_sem"][i] if i < len(gd.get("moy_sem", [])) else None
cells += f'<td style="{_bn_cell_style(v)}{s}">{_bn_fmt(v)}</td>'
cells += f'<td style="{_bn_cell_style(v)};{MOY_BG}{s}">{_bn_fmt(v)}</td>'
return f"<tr>{cells}</tr>"
def _moy_ann_row(label, gd, label_style, sep=False):
s = SEP if sep else ""
cells = f'<td style="{label_style}{s}">{label}</td>'
cells = f'<td style="{label_style};{MOY_BG}{s}">{label}</td>'
for year_start in range(0, N, 2):
v = gd["moy_ann"][year_start] if year_start < len(gd.get("moy_ann", [])) else None
cells += f'<td colspan="2" style="{_bn_cell_style(v)}{s}">{_bn_fmt(v)}</td>'
cells += f'<td colspan="2" style="{_bn_cell_style(v)};{MOY_BG}{s}">{_bn_fmt(v)}</td>'
return f"<tr>{cells}</tr>"
def _branch_row(branche, sep=False):
s = SEP if sep else ""
cells = f'<td style="{TD}{s}">{branche["nom"]}</td>'
notes = branche.get("notes") or [None] * N
for i in range(N):
v = notes[i] if i < len(notes) else None
cells += f'<td style="{_bn_cell_style(v)}{s}">{_bn_fmt(v)}</td>'
return f"<tr>{cells}</tr>"
def _group_header_row(label, sep=False):
s = SEP if sep else ""
return (
f'<tr><td colspan="{N + 1}" style="{TD};font-weight:bold;'
f'background:#f0f0f0{s}">{label}</td></tr>'
)
body = ""
for grp in groups_order:
gd = d["groupes"].get(grp, {"moy_sem": [None] * N, "moy_ann": [None] * N})
lbl = _GROUP_LABELS.get(grp, grp)
body += _moy_sem_row(lbl, gd, f"{TD};font-weight:bold")
# En-tête du groupe — séparation visuelle au-dessus (y compris du 1er,
# pour le détacher de la ligne d'en-tête des semestres).
body += _group_header_row(lbl, sep=True)
# Branches individuelles du groupe (Anglais, Automatisation, …)
for br in gd.get("branches", []) or []:
body += _branch_row(br)
# Moyennes du groupe
body += _moy_sem_row("Moyenne semestrielle du groupe", gd, f"{TD};font-style:italic;color:#555")
body += _moy_ann_row("Moyenne annuelle du groupe", gd, f"{TD};font-style:italic;color:#555")
body += _moy_sem_row("Moyenne semestrielle globale", d["globale"], f"{TD};font-style:italic", sep=True)
@ -415,12 +444,12 @@ class FicheState(AuthState):
cal_next_name: str = ""
cal_days: list[dict] = []
# ── Pending dates (quick excuse) ─────────────────────────────────────────
pending_dates: list[dict] = []
# ── Calendar day edit ─────────────────────────────────────────────────────
edit_date: str = ""
edit_date_label: str = ""
edit_day_type: str = "" # "theorie" | "pratique" | "matu" | ""
edit_day_type_label: str = "" # "Théorie" | "Pratique" | "Matu" | ""
edit_day_has_schedule: bool = False # True si périodes configurées pour ce jour
edit_p1: str = "present"
edit_p2: str = "present"
edit_p3: str = "present"
@ -432,6 +461,19 @@ class FicheState(AuthState):
edit_p9: str = "present"
edit_p10: str = "present"
# Snapshot des choix au chargement (pour détecter les modifications non
# enregistrées). Mis à jour par _load_day_choices().
initial_p1: str = "present"
initial_p2: str = "present"
initial_p3: str = "present"
initial_p4: str = "present"
initial_p5: str = "present"
initial_p6: str = "present"
initial_p7: str = "present"
initial_p8: str = "present"
initial_p9: str = "present"
initial_p10: str = "present"
# ── Escada fiche ─────────────────────────────────────────────────────────
fiche_available: bool = False
fiche_adresse: str = ""
@ -440,6 +482,18 @@ class FicheState(AuthState):
fiche_email_val: str = ""
fiche_date_naissance: str = ""
fiche_majeur: str = ""
fiche_compensation: str = ""
# Représentant légal (mineurs)
fiche_resp_legal_nom: str = ""
fiche_resp_legal_adresse: str = ""
fiche_resp_legal_cp_localite: str = ""
fiche_resp_legal_tel_p: str = "" # numéro brut
fiche_resp_legal_tel_n: str = "" # numéro brut
# URLs Google Maps construites depuis adresse+CP+localité
fiche_map_url: str = ""
fiche_entreprise_map_url: str = ""
fiche_resp_legal_map_url: str = ""
fiche_entreprise_nom: str = ""
fiche_entreprise_adresse: str = ""
fiche_entreprise_cp_localite: str = ""
@ -459,6 +513,10 @@ class FicheState(AuthState):
has_pdf_bn: bool = False
has_pdf_notes: bool = False
# ── Notices Escada ────────────────────────────────────────────────────────
has_notices: bool = False
notices_data: list[dict] = []
# ── Email ─────────────────────────────────────────────────────────────────
smtp_ok: bool = False
email_dest: str = "apprenti"
@ -524,6 +582,7 @@ class FicheState(AuthState):
self.selected_id = self.apprenti_ids[0]
self.selected_label = self.apprenti_labels[0]
self._reload(reset_email=True)
self._select_today()
def handle_select(self, label: str):
self.selected_label = label
@ -536,6 +595,13 @@ class FicheState(AuthState):
self.apprenti_select_open = False
self.apprenti_search = ""
self._reload(reset_email=True)
self._select_today()
def _select_today(self):
"""Pré-sélectionne la date du jour dans le panneau d'édition."""
today_iso = date.today().isoformat()
self._load_day_choices(today_iso)
self.edit_date = today_iso
def set_apprenti_search(self, v: str):
self.apprenti_search = v
@ -593,12 +659,8 @@ class FicheState(AuthState):
self._rebuild_calendar()
# ── Calendar day edit ─────────────────────────────────────────────────────
def select_day(self, date_str: str):
if not date_str:
return
if self.edit_date == date_str:
self.edit_date = ""
return
def _load_day_choices(self, date_str: str):
"""Met à jour edit_p1..p10 + edit_date_label pour la date donnée."""
sess = get_session()
d = date.fromisoformat(date_str)
absences = sess.execute(
@ -607,6 +669,26 @@ class FicheState(AuthState):
Absence.date == d,
)
).scalars().all()
# Horaire de classe (settings.json) : type + périodes pour ce jour.
ap = sess.get(Apprenti, self.selected_id) if self.selected_id else None
day_names = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"]
day_key = day_names[d.weekday()]
d_type = ""
d_periods: list[int] = []
if ap:
settings = _read_settings()
class_sch = (settings.get("class_schedule") or {}).get(ap.classe) or {}
entry = class_sch.get(day_key)
if isinstance(entry, dict):
d_type = (entry.get("type") or "").strip()
d_periods = list(entry.get("periods") or [])
elif isinstance(entry, list):
d_periods = list(entry)
self.edit_day_type = d_type
self.edit_day_type_label = {
"theorie": "Théorie", "pratique": "Pratique", "matu": "Matu",
}.get(d_type, "")
self.edit_day_has_schedule = bool(d_periods)
pm = {ab.periode: ab.statut for ab in absences}
def _choice(p: int) -> str:
@ -625,9 +707,117 @@ class FicheState(AuthState):
self.edit_p8 = _choice(8)
self.edit_p9 = _choice(9)
self.edit_p10 = _choice(10)
# Snapshot des choix initiaux (pour détecter les modifs)
self.initial_p1 = self.edit_p1
self.initial_p2 = self.edit_p2
self.initial_p3 = self.edit_p3
self.initial_p4 = self.edit_p4
self.initial_p5 = self.edit_p5
self.initial_p6 = self.edit_p6
self.initial_p7 = self.edit_p7
self.initial_p8 = self.edit_p8
self.initial_p9 = self.edit_p9
self.initial_p10 = self.edit_p10
self.edit_date_label = d.strftime("%d.%m.%Y")
def select_day(self, date_str: str):
if not date_str:
return
if self.edit_date == date_str:
self.edit_date = ""
return
self._load_day_choices(date_str)
self.edit_date = date_str
@rx.var
def edit_has_changes(self) -> bool:
"""True si au moins une période diffère de l'état chargé en DB."""
return (
self.edit_p1 != self.initial_p1 or
self.edit_p2 != self.initial_p2 or
self.edit_p3 != self.initial_p3 or
self.edit_p4 != self.initial_p4 or
self.edit_p5 != self.initial_p5 or
self.edit_p6 != self.initial_p6 or
self.edit_p7 != self.initial_p7 or
self.edit_p8 != self.initial_p8 or
self.edit_p9 != self.initial_p9 or
self.edit_p10 != self.initial_p10
)
@rx.var
def edit_has_non_excusee(self) -> bool:
"""True si au moins une période est en N (non excusée)."""
return (
self.edit_p1 == "non_excusee" or
self.edit_p2 == "non_excusee" or
self.edit_p3 == "non_excusee" or
self.edit_p4 == "non_excusee" or
self.edit_p5 == "non_excusee" or
self.edit_p6 == "non_excusee" or
self.edit_p7 == "non_excusee" or
self.edit_p8 == "non_excusee" or
self.edit_p9 == "non_excusee" or
self.edit_p10 == "non_excusee"
)
def mark_school_day_absent(self):
"""Marque toutes les périodes de cours de la journée comme N (non excusées)
dans le panneau. Utilise le mapping classe / jour / périodes configuré
dans /params. Ne touche pas la DB l'enregistrement passe par
« Enregistrer »."""
if not self.edit_date or not self.selected_id:
return
sess = get_session()
try:
ap = sess.get(Apprenti, self.selected_id)
finally:
sess.close()
if not ap:
return rx.toast.error("Apprenti introuvable.")
d = date.fromisoformat(self.edit_date)
day_names = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"]
day_key = day_names[d.weekday()]
settings = _read_settings()
class_sch = (settings.get("class_schedule") or {}).get(ap.classe) or {}
entry = class_sch.get(day_key)
# Nouveau format {type, periods} ; ancien format = list[int] (compat).
if isinstance(entry, dict):
periods = set(entry.get("periods") or [])
elif isinstance(entry, list):
periods = set(entry)
else:
periods = set()
if not periods:
return rx.toast.warning(
f"Aucun horaire configuré pour {ap.classe} le {day_key}. "
f"Configure-le dans Paramètres → Horaires de classe."
)
if 1 in periods: self.edit_p1 = "non_excusee"
if 2 in periods: self.edit_p2 = "non_excusee"
if 3 in periods: self.edit_p3 = "non_excusee"
if 4 in periods: self.edit_p4 = "non_excusee"
if 5 in periods: self.edit_p5 = "non_excusee"
if 6 in periods: self.edit_p6 = "non_excusee"
if 7 in periods: self.edit_p7 = "non_excusee"
if 8 in periods: self.edit_p8 = "non_excusee"
if 9 in periods: self.edit_p9 = "non_excusee"
if 10 in periods: self.edit_p10 = "non_excusee"
def excuse_all_visual(self):
"""Bascule toutes les N → E dans le panneau (sans toucher la DB).
L'enregistrement passe par le bouton « Enregistrer »."""
if self.edit_p1 == "non_excusee": self.edit_p1 = "excusee"
if self.edit_p2 == "non_excusee": self.edit_p2 = "excusee"
if self.edit_p3 == "non_excusee": self.edit_p3 = "excusee"
if self.edit_p4 == "non_excusee": self.edit_p4 = "excusee"
if self.edit_p5 == "non_excusee": self.edit_p5 = "excusee"
if self.edit_p6 == "non_excusee": self.edit_p6 = "excusee"
if self.edit_p7 == "non_excusee": self.edit_p7 = "excusee"
if self.edit_p8 == "non_excusee": self.edit_p8 = "excusee"
if self.edit_p9 == "non_excusee": self.edit_p9 = "excusee"
if self.edit_p10 == "non_excusee": self.edit_p10 = "excusee"
def cancel_edit(self):
self.edit_date = ""
@ -698,6 +888,10 @@ class FicheState(AuthState):
)
sess.commit()
self._reload(reset_email=False)
# Resync du snapshot pour que edit_has_changes reparte à False
# tant qu'aucune nouvelle modif n'est faite.
if self.edit_date:
self._load_day_choices(self.edit_date)
if nb_changes == 0:
return rx.toast.info("Aucune modification")
msg = (
@ -738,9 +932,10 @@ class FicheState(AuthState):
f"{old_type} → E (excuse rapide)"
)
sess.commit()
if self.edit_date == date_str:
self.edit_date = ""
self._reload(reset_email=False)
# Rester sur la date sélectionnée et rafraîchir les choix du panneau.
if self.edit_date == date_str:
self._load_day_choices(date_str)
if nb == 0:
return rx.toast.info("Aucune absence à excuser")
msg = (
@ -881,20 +1076,10 @@ class FicheState(AuthState):
self.kpi_excusees = sum(1 for a in absences if a.statut == "excusee")
self.kpi_non_excusees = sum(1 for a in absences if a.statut == "a_traiter")
self.kpi_blocs = nb_blocs_absences(sess, self.selected_id)
self.quota_atteint = self.kpi_blocs >= QUOTA
# Pending dates
by_date: dict = {}
for ab in absences:
by_date.setdefault(ab.date, []).append(ab)
self.pending_dates = [
{
"date_str": d.isoformat(),
"label": f"{d.strftime('%d.%m')} ({sum(1 for a in al if a.statut == 'a_traiter')})",
}
for d, al in sorted(by_date.items())
if any(a.statut == "a_traiter" for a in al)
]
# Le quota de 5 absences ne s'applique qu'aux classes EM.
apprenti = sess.get(Apprenti, self.selected_id)
_is_em = bool(apprenti and (apprenti.classe or "").startswith("EM"))
self.quota_atteint = _is_em and self.kpi_blocs >= QUOTA
# Fiche
fiche = sess.execute(
@ -913,6 +1098,21 @@ class FicheState(AuthState):
("Majeur : oui" if fiche.majeur else "Majeur : non")
if fiche.majeur is not None else ""
)
self.fiche_compensation = (
("Compensation des désavantages : oui"
if fiche.compensation_desavantages
else "Compensation des désavantages : non")
if fiche.compensation_desavantages is not None else ""
)
self.fiche_resp_legal_nom = fiche.resp_legal_nom or ""
self.fiche_resp_legal_adresse = fiche.resp_legal_adresse or ""
self.fiche_resp_legal_cp_localite = (
f"{fiche.resp_legal_code_postal or ''} "
f"{fiche.resp_legal_localite or ''}".strip()
)
self.fiche_resp_legal_tel_p = fiche.resp_legal_telephone_p or ""
self.fiche_resp_legal_tel_n = fiche.resp_legal_telephone_n or ""
self.fiche_entreprise_nom = fiche.entreprise_nom or ""
self.fiche_entreprise_adresse = fiche.entreprise_adresse or ""
self.fiche_entreprise_cp_localite = (
@ -926,11 +1126,35 @@ class FicheState(AuthState):
self.fiche_updated_at = (
fiche.updated_at.strftime("%d.%m.%Y %H:%M") if fiche.updated_at else ""
)
# URLs Google Maps construites APRÈS l'assignation de tous les
# champs (sinon on utiliserait les valeurs de l'apprenti précédent).
# Pour l'entreprise on inclut le nom → Maps trouve la fiche
# établissement si elle existe.
from urllib.parse import quote_plus as _qp
def _maps(*parts: str) -> str:
q = ", ".join(p.strip() for p in parts if p and p.strip())
return f"https://www.google.com/maps/search/?api=1&query={_qp(q)}" if q else ""
self.fiche_map_url = _maps(self.fiche_adresse, self.fiche_cp_localite)
self.fiche_entreprise_map_url = _maps(
self.fiche_entreprise_nom,
self.fiche_entreprise_adresse,
self.fiche_entreprise_cp_localite,
)
self.fiche_resp_legal_map_url = _maps(
self.fiche_resp_legal_adresse, self.fiche_resp_legal_cp_localite,
)
else:
self.fiche_available = False
for attr in [
"fiche_adresse", "fiche_cp_localite", "fiche_telephone",
"fiche_email_val", "fiche_date_naissance", "fiche_majeur",
"fiche_compensation",
"fiche_resp_legal_nom", "fiche_resp_legal_adresse",
"fiche_resp_legal_cp_localite",
"fiche_resp_legal_tel_p", "fiche_resp_legal_tel_n",
"fiche_map_url", "fiche_entreprise_map_url",
"fiche_resp_legal_map_url",
"fiche_entreprise_nom", "fiche_entreprise_adresse",
"fiche_entreprise_cp_localite", "fiche_entreprise_telephone",
"fiche_entreprise_email", "fiche_formateur_nom",
@ -958,10 +1182,12 @@ class FicheState(AuthState):
apprenti = sess.get(Apprenti, self.selected_id)
if apprenti:
tvars = build_template_vars(apprenti, list(absences))
_def_subj = "Relevé d'absences — {nom_complet} ({classe})"
# Mêmes valeurs par défaut que la page Paramètres
# (DEFAULT_TEMPLATE_SUBJ / DEFAULT_TEMPLATE_BODY).
_def_subj = "Document EPTM — {nom_complet} ({classe})"
_def_body = (
"Bonjour {prenom},\n\n"
"Veuillez trouver ci-joint votre document.\n\n"
"Veuillez trouver ci-joint votre document pour la classe {classe}.\n\n"
"Cordialement,\nL'équipe EPTM"
)
self.email_subject = render_template(
@ -1028,6 +1254,25 @@ class FicheState(AuthState):
self.has_notes = False
self.notes_html = ""
# ── Notices Escada ──────────────────────────────────────────────────
notices_list = sess.execute(
select(ApprentiNotice)
.where(ApprentiNotice.apprenti_id == self.selected_id)
.order_by(ApprentiNotice.date_event.desc())
).scalars().all()
self.has_notices = len(notices_list) > 0
self.notices_data = [
{
"date": n.date_event.strftime("%d.%m.%Y") if n.date_event else "",
"type": n.type_notice or "",
"auteur": n.auteur or "",
"titre": n.titre or "",
"remarque": n.remarque or "",
"matiere": n.matiere or "",
}
for n in notices_list
]
pdf_dir = DATA_DIR / "pdfs"
self.has_pdf_bn = bool(self.bn_pdf_fichier) and (pdf_dir / self.bn_pdf_fichier).exists()
apprenti = sess.get(Apprenti, self.selected_id)
@ -1133,7 +1378,7 @@ def _apprenti_searchable_select() -> rx.Component:
padding="0.5rem 0.75rem",
border="1px solid var(--gray-7)",
border_radius="6px",
background_color="white",
background_color="var(--surface)",
cursor="pointer",
width="100%",
custom_attrs={"data-shortcut": "apprenti-search"},
@ -1178,11 +1423,11 @@ def _apprenti_searchable_select() -> rx.Component:
def _kpi_card(label: str, value, color: str = "#37474f") -> rx.Component:
return rx.box(
rx.text(label, size="1", color="#666"),
rx.text(value, size="7", font_weight="700", color=color),
rx.text(value, size="7", font_weight="700", color=color, class_name="tabular"),
padding="1rem",
background_color="white",
background_color="var(--surface)",
border_radius="8px",
border="1px solid #e0e0e0",
border="1px solid var(--border)",
flex="1",
min_width="120px",
class_name="hover-lift",
@ -1201,6 +1446,62 @@ def _info_line(icon: str, value) -> rx.Component:
)
def _info_line_email(icon: str, value) -> rx.Component:
"""Ligne info avec lien mailto: cliquable."""
return rx.cond(
value != "",
rx.hstack(
rx.icon(icon, size=14, color="#9e9e9e"),
rx.link(
value, href="mailto:" + value,
size="2", color="var(--brand-accent)",
text_decoration="none",
_hover={"text_decoration": "underline"},
),
spacing="2", align="center",
),
)
def _info_line_tel(icon: str, value, label_prefix: str = "") -> rx.Component:
"""Ligne info avec lien tel: cliquable (cliquable depuis un smartphone)."""
return rx.cond(
value != "",
rx.hstack(
rx.icon(icon, size=14, color="#9e9e9e"),
rx.link(
label_prefix + value,
href="tel:" + value.replace(" ", ""),
size="2", color="var(--brand-accent)",
text_decoration="none",
_hover={"text_decoration": "underline"},
),
spacing="2", align="center",
),
)
def _info_line_map(line1, line2, map_url) -> rx.Component:
"""Bloc adresse : une seule icône cliquable + 2 lignes de texte (rue puis CP localité)."""
return rx.cond(
(line1 != "") | (line2 != ""),
rx.hstack(
rx.link(
rx.icon("map-pin", size=14, color="var(--brand-accent)"),
href=map_url, is_external=True,
_hover={"opacity": "0.7"},
title="Voir sur Google Maps",
),
rx.vstack(
rx.cond(line1 != "", rx.text(line1, size="2", color="#555")),
rx.cond(line2 != "", rx.text(line2, size="2", color="#555")),
spacing="0", align="start",
),
spacing="2", align="start",
),
)
def _cal_day_cell(d) -> rx.Component:
is_selected = d["date_str"] == FicheState.edit_date
return rx.cond(
@ -1212,7 +1513,7 @@ def _cal_day_cell(d) -> rx.Component:
size="1",
font_weight=rx.cond(d["is_today"], "700", "400"),
color=rx.cond(
is_selected, "#1565c0",
is_selected, "var(--brand-accent)",
rx.cond(
d["has_non_exc"], "#c62828",
rx.cond(d["has_abs"], "#2e7d32", "#333"),
@ -1233,7 +1534,7 @@ def _cal_day_cell(d) -> rx.Component:
),
),
border=rx.cond(
is_selected, "2px solid #1565c0",
is_selected, "2px solid var(--brand-accent)",
rx.cond(d["is_today"], "2px solid #90caf9", "1px solid #eee"),
),
display="flex",
@ -1279,10 +1580,24 @@ def _edit_panel() -> rx.Component:
return rx.box(
rx.vstack(
rx.hstack(
rx.icon("pencil", size=15, color="#1565c0"),
rx.icon("pencil", size=15, color="var(--brand-accent)"),
rx.text(
"Édition du ", FicheState.edit_date_label,
size="3", weight="bold", color="#37474f",
size="3", weight="bold", color="var(--text-strong)",
),
rx.cond(
FicheState.edit_day_type_label != "",
rx.badge(
FicheState.edit_day_type_label,
color_scheme=rx.match(
FicheState.edit_day_type,
("theorie", "blue"),
("pratique", "orange"),
("matu", "violet"),
"gray",
),
variant="soft", size="1",
),
),
rx.spacer(),
rx.button(
@ -1314,38 +1629,131 @@ def _edit_panel() -> rx.Component:
flex_wrap="wrap",
width="100%",
),
rx.hstack(
# Actions rapides : marquer toute la journée N (selon horaire classe)
# ou excuser toutes les N → E. Aucune touche la DB — l'enregistrement
# passe par « Enregistrer ».
rx.flex(
rx.button(
rx.icon("calendar-x", size=14),
rx.cond(
FicheState.edit_day_has_schedule,
rx.text("Absent toute la journée"),
rx.text("Absent toute la journée (Données chronoplan manquantes)"),
),
on_click=FicheState.mark_school_day_absent,
disabled=~FicheState.edit_day_has_schedule,
variant="soft", color_scheme="red", size="2",
),
rx.button(
rx.icon("check-check", size=14),
"Excuser toutes les périodes",
on_click=FicheState.excuse_all_visual,
disabled=~FicheState.edit_has_non_excusee,
variant="soft", color_scheme="green", size="2",
),
gap="0.5rem", flex_wrap="wrap", width="100%",
),
rx.divider(),
rx.flex(
rx.button(
rx.icon("save", size=14), "Enregistrer",
on_click=FicheState.save_day_edit,
disabled=~FicheState.edit_has_changes,
color_scheme="blue", size="2",
),
rx.button(
"Annuler",
on_click=FicheState.cancel_edit,
disabled=~FicheState.edit_has_changes,
variant="outline", color_scheme="gray", size="2",
),
spacing="3",
gap="0.75rem", flex_wrap="wrap",
),
spacing="3", width="100%",
),
padding="1rem",
background_color="#f0f7ff",
background_color="var(--brand-accent-soft)",
border_radius="8px",
border="1px solid #bfdbfe",
border="1px solid var(--border)",
width="100%",
class_name="anim-slide-down",
)
def _pending_btn(item: dict) -> rx.Component:
return rx.button(
rx.icon("check", size=13),
item["label"],
on_click=FicheState.excuse_day(item["date_str"]),
color_scheme="green",
variant="soft",
size="1",
def _actions_row() -> rx.Component:
"""Bandeau d'actions sous les KPIs : exports PDF + création d'avis."""
return rx.box(
rx.flex(
# Exports PDF (avec icône download partout)
rx.button(
rx.icon("download", size=13),
"PDF absences",
on_click=FicheState.download_abs_pdf,
variant="outline", color_scheme="gray", size="2",
),
rx.cond(
FicheState.has_pdf_bn,
rx.button(
rx.icon("download", size=13),
"PDF bulletin",
on_click=FicheState.download_bn_pdf,
variant="outline", color_scheme="blue", size="2",
),
),
rx.cond(
FicheState.has_pdf_notes,
rx.button(
rx.icon("download", size=13),
"PDF notes",
on_click=FicheState.download_notes_pdf,
variant="outline", color_scheme="violet", size="2",
),
),
# Séparateur visuel
rx.box(
width="1px",
background_color="var(--gray-6)",
margin_x="0.25rem",
align_self="stretch",
),
# Création d'avis
rx.button(
rx.icon("file-warning", size=14),
"Créer un avis de retenue",
on_click=RetenueState.preload_apprenti(
FicheState.selected_id, FicheState.selected_label,
),
color_scheme="orange", variant="soft", size="2",
),
rx.button(
rx.icon("triangle-alert", size=14),
"Créer un avis de sanction",
on_click=SanctionState.preload_apprenti(
FicheState.selected_id, FicheState.selected_label,
),
color_scheme="red", variant="soft", size="2",
),
gap="0.5rem",
flex_wrap="wrap",
align="center",
width="100%",
),
padding="0.75rem 1rem",
background_color="var(--surface)",
border_radius="8px",
border="1px solid var(--border)",
width="100%",
)
def _notice_row(item) -> rx.Component:
return rx.table.row(
rx.table.cell(item["date"], white_space="nowrap"),
rx.table.cell(rx.text(item["type"], size="1")),
rx.table.cell(rx.text(item["auteur"], size="1", color="#666")),
rx.table.cell(rx.text(item["titre"], size="1", weight="medium")),
rx.table.cell(rx.text(item["remarque"], size="1", color="#444")),
rx.table.cell(rx.text(item["matiere"], size="1", color="#666")),
)
@ -1353,8 +1761,8 @@ def _email_section() -> rx.Component:
return rx.box(
rx.vstack(
rx.hstack(
rx.icon("mail", size=16, color="#37474f"),
rx.text("Envoyer par email", size="3", weight="bold", color="#37474f"),
rx.icon("mail", size=16, color="var(--text-strong)"),
rx.text("Envoyer par email", size="3", weight="bold", color="var(--text-strong)"),
spacing="2", align="center",
),
rx.divider(),
@ -1499,9 +1907,9 @@ def _email_section() -> rx.Component:
spacing="3", width="100%",
),
padding="1rem",
background_color="white",
background_color="var(--surface)",
border_radius="8px",
border="1px solid #e0e0e0",
border="1px solid var(--border)",
width="100%",
)
@ -1512,7 +1920,10 @@ _DOW = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"]
def fiche_page() -> rx.Component:
return layout(
rx.vstack(
rx.heading("Fiche apprenti", size="7"),
# Modals (rendus une fois, contrôlés par leur state respectif)
retenue_modal(),
sanction_modal(),
rx.heading("Apprentis", size="7"),
rx.cond(
FicheState.has_apprentis,
@ -1524,7 +1935,7 @@ def fiche_page() -> rx.Component:
# ── KPI cards ─────────────────────────────────────────────
rx.flex(
_kpi_card("Périodes d'absence", FicheState.kpi_total),
_kpi_card("Périodes à excuser", FicheState.kpi_non_excusees, "#c62828"),
_kpi_card("Périodes à excuser", FicheState.kpi_non_excusees, "var(--brand-primary-dark)"),
rx.box(
rx.text("Absences", size="1", color="#666"),
rx.text(
@ -1553,6 +1964,9 @@ def fiche_page() -> rx.Component:
gap="1rem", flex_wrap="wrap", width="100%",
),
# ── Actions (PDF exports + créations d'avis) ───────────────
_actions_row(),
# ── Fiche détaillée Escada ────────────────────────────────
rx.box(
rx.cond(
@ -1560,30 +1974,41 @@ def fiche_page() -> rx.Component:
rx.vstack(
rx.flex(
rx.vstack(
rx.text("Élève", size="2", font_weight="700", color="#37474f"),
_info_line("map-pin", FicheState.fiche_adresse),
_info_line("map-pin", FicheState.fiche_cp_localite),
_info_line("phone", FicheState.fiche_telephone),
_info_line("mail", FicheState.fiche_email_val),
rx.text("Élève", size="2", font_weight="700", color="var(--text-strong)"),
_info_line_map(FicheState.fiche_adresse, FicheState.fiche_cp_localite, FicheState.fiche_map_url),
_info_line_tel("phone", FicheState.fiche_telephone),
_info_line_email("mail", FicheState.fiche_email_val),
_info_line("cake", FicheState.fiche_date_naissance),
_info_line("user-check", FicheState.fiche_majeur),
_info_line("scale", FicheState.fiche_compensation),
spacing="1", align="start", flex="1", min_width="200px",
),
rx.vstack(
rx.text("Entreprise", size="2", font_weight="700", color="#37474f"),
rx.text("Entreprise", size="2", font_weight="700", color="var(--text-strong)"),
_info_line("building-2", FicheState.fiche_entreprise_nom),
_info_line("map-pin", FicheState.fiche_entreprise_adresse),
_info_line("map-pin", FicheState.fiche_entreprise_cp_localite),
_info_line("phone", FicheState.fiche_entreprise_telephone),
_info_line("mail", FicheState.fiche_entreprise_email),
_info_line_map(FicheState.fiche_entreprise_adresse, FicheState.fiche_entreprise_cp_localite, FicheState.fiche_entreprise_map_url),
_info_line_tel("phone", FicheState.fiche_entreprise_telephone),
_info_line_email("mail", FicheState.fiche_entreprise_email),
spacing="1", align="start", flex="1", min_width="200px",
),
rx.vstack(
rx.text("Formateur", size="2", font_weight="700", color="#37474f"),
rx.text("Formateur", size="2", font_weight="700", color="var(--text-strong)"),
_info_line("user", FicheState.fiche_formateur_nom),
_info_line("mail", FicheState.fiche_formateur_email),
_info_line_email("mail", FicheState.fiche_formateur_email),
spacing="1", align="start", flex="1", min_width="200px",
),
# Représentant légal (mineurs uniquement)
rx.cond(
FicheState.fiche_resp_legal_nom != "",
rx.vstack(
rx.text("Représentant légal", size="2", font_weight="700", color="var(--text-strong)"),
_info_line("user", FicheState.fiche_resp_legal_nom),
_info_line_map(FicheState.fiche_resp_legal_adresse, FicheState.fiche_resp_legal_cp_localite, FicheState.fiche_resp_legal_map_url),
_info_line_tel("phone", FicheState.fiche_resp_legal_tel_p, label_prefix="Fixe : "),
_info_line_tel("phone", FicheState.fiche_resp_legal_tel_n, label_prefix="Mobile : "),
spacing="1", align="start", flex="1", min_width="200px",
),
),
gap="1.5rem", flex_wrap="wrap", width="100%",
),
rx.text(
@ -1598,9 +2023,9 @@ def fiche_page() -> rx.Component:
),
),
padding="1rem",
background_color="white",
background_color="var(--surface)",
border_radius="8px",
border="1px solid #e0e0e0",
border="1px solid var(--border)",
width="100%",
),
@ -1610,6 +2035,7 @@ def fiche_page() -> rx.Component:
rx.tabs.list(
rx.tabs.trigger("Cours professionnels", value="bn"),
rx.tabs.trigger("Notes d'examen", value="notes"),
rx.tabs.trigger("Notices", value="notices"),
),
rx.tabs.content(
rx.cond(
@ -1642,41 +2068,44 @@ def fiche_page() -> rx.Component:
),
value="notes", width="100%", padding_top="1rem",
),
rx.tabs.content(
rx.cond(
FicheState.has_notices,
rx.box(
rx.table.root(
rx.table.header(
rx.table.row(
rx.table.column_header_cell("Date"),
rx.table.column_header_cell("Type"),
rx.table.column_header_cell("Auteur"),
rx.table.column_header_cell("Titre"),
rx.table.column_header_cell("Remarques"),
rx.table.column_header_cell("Matière"),
),
),
rx.table.body(
rx.foreach(FicheState.notices_data, _notice_row),
),
size="1", width="100%",
),
width="100%", overflow_x="auto",
),
rx.text(
"Aucune notice. Récupère-les depuis Escada via la page Escada (bouton « Récupérer les notices »).",
size="2", color="#666",
),
),
value="notices", width="100%", padding_top="1rem",
),
default_value="bn", width="100%",
),
padding="1rem",
background_color="white",
background_color="var(--surface)",
border_radius="8px",
border="1px solid #e0e0e0",
border="1px solid var(--border)",
width="100%",
),
# ── Export PDF ────────────────────────────────────────────
rx.flex(
rx.button(
rx.icon("download", size=13), "PDF absences",
on_click=FicheState.download_abs_pdf,
variant="outline", color_scheme="gray", size="1",
),
rx.cond(
FicheState.has_pdf_bn,
rx.button(
rx.icon("file-text", size=13), "PDF bulletin",
on_click=FicheState.download_bn_pdf,
variant="outline", color_scheme="blue", size="1",
),
),
rx.cond(
FicheState.has_pdf_notes,
rx.button(
rx.icon("file-text", size=13), "PDF notes",
on_click=FicheState.download_notes_pdf,
variant="outline", color_scheme="violet", size="1",
),
),
flex_wrap="wrap", gap="0.5rem",
),
# ── Calendrier mensuel ────────────────────────────────────
rx.cond(
FicheState.kpi_total > 0,
@ -1689,7 +2118,7 @@ def fiche_page() -> rx.Component:
),
rx.text(
FicheState.cal_month_name,
size="4", font_weight="700", color="#37474f",
size="4", font_weight="700", color="var(--text-strong)",
flex="1", text_align="center",
),
rx.button(
@ -1719,7 +2148,7 @@ def fiche_page() -> rx.Component:
border_radius="2px", border="1px solid #eee"),
rx.text("Excusée", size="1", color="#666"),
rx.box(width="12px", height="12px", background_color="#dbeafe",
border_radius="2px", border="2px solid #1565c0"),
border_radius="2px", border="2px solid var(--brand-accent)"),
rx.text("Sélectionné", size="1", color="#666"),
spacing="2", align="center", margin_top="0.5rem",
),
@ -1728,41 +2157,14 @@ def fiche_page() -> rx.Component:
size="1", color="#9e9e9e", margin_top="0.25rem",
),
padding="1rem",
background_color="white",
background_color="var(--surface)",
border_radius="8px",
border="1px solid #e0e0e0",
border="1px solid var(--border)",
width="100%",
),
rx.text("Aucune absence enregistrée.", size="2", color="#666"),
),
# ── Actions rapides ───────────────────────────────────────
rx.cond(
FicheState.pending_dates.length() > 0,
rx.box(
rx.vstack(
rx.hstack(
rx.icon("clock", size=15, color="#b45309"),
rx.text(
"Valider toutes les absences d'une journée",
size="2", weight="bold", color="#92400e",
),
spacing="2", align="center",
),
rx.flex(
rx.foreach(FicheState.pending_dates, _pending_btn),
flex_wrap="wrap", gap="0.5rem",
),
spacing="2", width="100%",
),
padding="0.75rem 1rem",
background_color="#fffbeb",
border_radius="8px",
border="1px solid #fcd34d",
width="100%",
),
),
# ── Panneau d'édition ─────────────────────────────────────
rx.cond(
FicheState.edit_date != "",
@ -1783,9 +2185,9 @@ def fiche_page() -> rx.Component:
spacing="2", align="center",
),
padding="0.75rem 1rem",
background_color="#f9fafb",
background_color="var(--surface-muted)",
border_radius="8px",
border="1px solid #e5e7eb",
border="1px solid var(--border-soft)",
width="100%",
),
),

View file

@ -1,9 +1,18 @@
import json
import os
import sys
from pathlib import Path
import reflex as rx
_ROOT = Path(__file__).resolve().parent.parent.parent
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
from src.profession import load_mapping, save_mapping, find_unmapped_classes, refresh_all_professions # noqa: E402
from src.db import get_session, Apprenti # noqa: E402
from sqlalchemy import select # noqa: E402
from ..sidebar import layout
from ..state import AuthState
@ -14,6 +23,16 @@ _SETTINGS_FILE = DATA_DIR / "settings.json"
_DEFAULT_SANCTION = (
"Selon le règlement de l'EM, l'apprenti a dépassé le nombre d'absences limite."
)
# Horaire de classe : 5 jours ouvrés × 10 périodes max. Stocké dans settings.json
# sous la clé "class_schedule" → dict[classe, dict[jour, {type, periods}]].
# Compatible aussi avec l'ancien format list[int] (auto-migré au load).
_SCH_DAYS = ["MON", "TUE", "WED", "THU", "FRI"]
_SCH_DAY_LABELS = {"MON": "Lun", "TUE": "Mar", "WED": "Mer", "THU": "Jeu", "FRI": "Ven"}
_SCH_PERIODS = list(range(1, 11))
# Types de jour : Théorie / Pratique / Matu. "" = non défini (fallback neutre).
_SCH_TYPES = ["theorie", "pratique", "matu"]
_SCH_TYPE_LABELS = {"theorie": "Théorie", "pratique": "Pratique", "matu": "Matu", "": ""}
_DEFAULT_TEMPLATE_SUBJ = "Document EPTM — {nom_complet} ({classe})"
_DEFAULT_TEMPLATE_BODY = (
"Bonjour {prenom},\n\n"
@ -50,6 +69,7 @@ class ParamsState(AuthState):
smtp_login: str = ""
smtp_password: str = ""
smtp_sender: str = ""
feedback_admin_email: str = "" # destinataire notifs feedback in-app
save_ok_smtp: bool = False
# ── Escada ────────────────────────────────────────────────────────────────
@ -67,6 +87,22 @@ class ParamsState(AuthState):
app_base_url: str = ""
save_ok_app: bool = False
# ── Profession mapping ────────────────────────────────────────────────────
prof_mapping: list[dict] = []
prof_unmapped: list[str] = []
new_prefix: str = ""
new_profession: str = ""
save_ok_prof: bool = False
refresh_msg: str = ""
# ── Horaires de classe (mapping classe / jour / périodes + type) ──────────
sch_classes_avail: list[str] = []
sch_class_selected: str = ""
# État courant pour la classe sélectionnée. Chargé / sauvegardé en bloc.
sch_periods: dict[str, list[int]] = {}
sch_types: dict[str, str] = {} # day → "theorie"|"pratique"|"matu"|""
save_ok_schedule: bool = False
# ── Setters ───────────────────────────────────────────────────────────────
def set_texte_sanction(self, v: str): self.texte_sanction = v
def set_chef_section(self, v: str): self.chef_section = v
@ -75,12 +111,15 @@ class ParamsState(AuthState):
def set_smtp_login(self, v: str): self.smtp_login = v
def set_smtp_password(self, v: str): self.smtp_password = v
def set_smtp_sender(self, v: str): self.smtp_sender = v
def set_feedback_admin_email(self, v: str): self.feedback_admin_email = v
def set_escada_username(self, v: str): self.escada_username = v
def set_escada_password(self, v: str): self.escada_password = v
def set_totp_secret(self, v: str): self.totp_secret = v
def set_email_subject(self, v: str): self.email_subject = v
def set_email_body(self, v: str): self.email_body = v
def set_app_base_url(self, v: str): self.app_base_url = v
def set_new_prefix(self, v: str): self.new_prefix = v
def set_new_profession(self, v: str): self.new_profession = v
def load_data(self):
if not self.authenticated:
@ -93,6 +132,7 @@ class ParamsState(AuthState):
self.smtp_login = s.get("smtp_login", s.get("smtp_email", ""))
self.smtp_password = s.get("smtp_password", "")
self.smtp_sender = s.get("smtp_sender", "EPTM Automation <noreply@eptm-automation.ch>")
self.feedback_admin_email = s.get("feedback_admin_email", "")
self.escada_username = s.get("escada_username", "")
self.escada_password = s.get("escada_password", "")
self.totp_secret = s.get("totp_secret", "")
@ -104,6 +144,18 @@ class ParamsState(AuthState):
self.save_ok_escada = False
self.save_ok_template = False
self.save_ok_app = False
self._reload_prof_mapping()
self._reload_schedule_list()
def _reload_prof_mapping(self):
self.prof_mapping = load_mapping()
sess = get_session()
try:
self.prof_unmapped = find_unmapped_classes(sess)
finally:
sess.close()
self.save_ok_prof = False
self.refresh_msg = ""
def save_sanctions(self):
s = _read_settings()
@ -125,6 +177,7 @@ class ParamsState(AuthState):
s["smtp_login"] = self.smtp_login.strip()
s["smtp_password"] = self.smtp_password.strip()
s["smtp_sender"] = self.smtp_sender.strip()
s["feedback_admin_email"] = self.feedback_admin_email.strip()
s.pop("smtp_email", None)
_write_settings(s)
self.save_ok_smtp = True
@ -164,6 +217,143 @@ class ParamsState(AuthState):
self.save_ok_escada = False
self.save_ok_template = False
# ── Profession mapping ───────────────────────────────────────────────────
def add_mapping(self):
prefix = self.new_prefix.strip()
prof = self.new_profession.strip()
if not prefix or not prof:
return
cur = list(self.prof_mapping)
# Si le préfixe existe déjà, on met juste à jour la profession
for m in cur:
if m.get("prefix") == prefix:
m["profession"] = prof
break
else:
cur.append({"prefix": prefix, "profession": prof})
save_mapping(cur)
self.new_prefix = ""
self.new_profession = ""
self._reload_prof_mapping()
self.save_ok_prof = True
def remove_mapping(self, prefix: str):
cur = [m for m in self.prof_mapping if m.get("prefix") != prefix]
save_mapping(cur)
self._reload_prof_mapping()
self.save_ok_prof = True
def quick_add_prefix(self, prefix: str):
"""Pré-remplit le formulaire avec une classe orpheline."""
self.new_prefix = prefix
self.new_profession = ""
def apply_mapping_to_db(self):
"""Recalcule la profession pour tous les apprentis avec le mapping actuel."""
sess = get_session()
try:
n = refresh_all_professions(sess)
finally:
sess.close()
self.refresh_msg = f"{n} fiche(s) mise(s) à jour."
# ── Horaires de classe ───────────────────────────────────────────────────
def _reload_schedule_list(self):
"""Charge la liste des classes connues + sélectionne la 1re par défaut."""
sess = get_session()
try:
rows = sess.execute(
select(Apprenti.classe).distinct().order_by(Apprenti.classe)
).scalars().all()
finally:
sess.close()
# Filtre MP/MI/Formation (cohérent avec le reste de l'app).
self.sch_classes_avail = [
c for c in rows
if c and not (c.startswith("MP") or c.startswith("MI")
or c.lower().startswith("formation"))
]
if not self.sch_class_selected and self.sch_classes_avail:
self.sch_class_selected = self.sch_classes_avail[0]
if self.sch_class_selected:
self._load_schedule_for(self.sch_class_selected)
else:
self.sch_periods = {d: [] for d in _SCH_DAYS}
self.sch_types = {d: "" for d in _SCH_DAYS}
def _load_schedule_for(self, classe: str):
s = _read_settings()
all_sch = s.get("class_schedule") or {}
class_sch = all_sch.get(classe) or {}
periods: dict[str, list[int]] = {}
types: dict[str, str] = {}
for d in _SCH_DAYS:
raw = class_sch.get(d)
# Compat ascendante : ancien format = list[int], nouveau = {type, periods}
if isinstance(raw, list):
p_list = raw
d_type = ""
elif isinstance(raw, dict):
p_list = raw.get("periods") or []
d_type = raw.get("type") or ""
else:
p_list = []
d_type = ""
periods[d] = sorted({int(p) for p in p_list
if isinstance(p, int) or str(p).isdigit()})
types[d] = d_type if d_type in _SCH_TYPES else ""
self.sch_periods = periods
self.sch_types = types
self.save_ok_schedule = False
def set_sch_class_selected(self, classe: str):
self.sch_class_selected = classe
self._load_schedule_for(classe)
def toggle_sch_cell(self, day: str, period: int):
cur = dict(self.sch_periods)
lst = list(cur.get(day, []))
if period in lst:
lst = [p for p in lst if p != period]
else:
lst = sorted(lst + [period])
cur[day] = lst
self.sch_periods = cur
self.save_ok_schedule = False
def set_sch_type(self, day: str, day_type: str):
# Sentinelle "none" (Radix Select) → vide.
if day_type == "none":
day_type = ""
if day_type not in _SCH_TYPES and day_type != "":
return
cur = dict(self.sch_types)
cur[day] = day_type
self.sch_types = cur
self.save_ok_schedule = False
def save_schedule(self):
if not self.sch_class_selected:
return
s = _read_settings()
all_sch = dict(s.get("class_schedule") or {})
# Vide si aucune période ET aucun type configuré → on retire l'entrée.
has_any = any(self.sch_periods.get(d) for d in _SCH_DAYS) \
or any(self.sch_types.get(d) for d in _SCH_DAYS)
if has_any:
all_sch[self.sch_class_selected] = {
d: {
"type": self.sch_types.get(d) or "",
"periods": list(self.sch_periods.get(d) or []),
}
for d in _SCH_DAYS
}
else:
all_sch.pop(self.sch_class_selected, None)
s["class_schedule"] = all_sch
_write_settings(s)
self.save_ok_schedule = True
# ── UI helpers ────────────────────────────────────────────────────────────────
@ -181,9 +371,9 @@ def _section(title: str, *children) -> rx.Component:
width="100%",
),
padding="1.25rem",
background_color="white",
background_color="var(--surface)",
border_radius="8px",
border="1px solid #e0e0e0",
border="1px solid var(--border)",
width="100%",
)
@ -311,6 +501,16 @@ def _section_smtp() -> rx.Component:
width="100%",
),
),
_field(
"Email admin (feedback in-app)",
rx.input(
value=ParamsState.feedback_admin_email,
on_change=ParamsState.set_feedback_admin_email,
placeholder="admin@eptm-automation.ch",
type="email",
width="100%",
),
),
rx.hstack(
rx.button(
rx.icon("save", size=16),
@ -445,6 +645,219 @@ def _section_template() -> rx.Component:
)
def _mapping_row(m: rx.Var) -> rx.Component:
return rx.flex(
rx.box(
rx.text("Préfixe", size="1", color="var(--gray-10)"),
rx.text(m["prefix"], size="2", weight="medium"),
flex="1", min_width="120px",
),
rx.box(
rx.text("Profession", size="1", color="var(--gray-10)"),
rx.text(m["profession"], size="2"),
flex="2", min_width="200px",
),
rx.button(
rx.icon("trash-2", size=14),
on_click=ParamsState.remove_mapping(m["prefix"]),
color_scheme="red", variant="ghost", size="1",
),
gap="0.75rem", align="center", flex_wrap="wrap",
padding="0.4rem 0.6rem",
border="1px solid var(--gray-5)",
border_radius="6px",
background_color="var(--surface)",
width="100%",
)
def _unmapped_chip(classe: rx.Var) -> rx.Component:
return rx.button(
rx.icon("plus", size=12),
classe,
on_click=ParamsState.quick_add_prefix(classe),
color_scheme="amber", variant="soft", size="1",
)
def _section_profession() -> rx.Component:
return _section(
"Correspondances classe → profession",
rx.text(
"Lors de l'import des données apprentis, la profession est dérivée "
"du préfixe de la classe (ex. classe « AUTOMAT 1 » → profession "
"« Automaticien CFC »). Utilisée notamment dans les avis de retenue.",
size="1", color="var(--gray-11)",
),
# Tableau des correspondances
rx.cond(
ParamsState.prof_mapping.length() > 0,
rx.vstack(
rx.foreach(ParamsState.prof_mapping, _mapping_row),
spacing="2", width="100%",
),
rx.text("Aucune correspondance configurée.", size="2", color="var(--gray-10)"),
),
# Classes orphelines
rx.cond(
ParamsState.prof_unmapped.length() > 0,
rx.box(
rx.text(
"Classes sans correspondance (clique pour ajouter) :",
size="2", weight="medium", color="#92400e", margin_bottom="0.4rem",
),
rx.flex(
rx.foreach(ParamsState.prof_unmapped, _unmapped_chip),
gap="0.35rem", flex_wrap="wrap",
),
padding="0.75rem",
background_color="#fef3c7",
border="1px solid #fcd34d",
border_radius="6px",
width="100%",
),
rx.fragment(),
),
# Ajout d'une nouvelle correspondance
rx.divider(),
rx.text("Ajouter / modifier une correspondance", size="2", weight="medium"),
rx.flex(
_field(
"Préfixe de classe",
rx.input(
value=ParamsState.new_prefix,
on_change=ParamsState.set_new_prefix,
placeholder="ex. AUTOMAT",
width="100%",
),
),
_field(
"Profession",
rx.input(
value=ParamsState.new_profession,
on_change=ParamsState.set_new_profession,
placeholder="ex. Automaticien CFC",
width="100%",
),
),
gap="0.75rem", flex_wrap="wrap", width="100%",
),
rx.flex(
rx.button(
rx.icon("plus", size=16),
"Ajouter / mettre à jour",
on_click=ParamsState.add_mapping,
color_scheme="blue", size="2",
),
rx.button(
rx.icon("refresh-cw", size=14),
"Appliquer aux fiches existantes",
on_click=ParamsState.apply_mapping_to_db,
color_scheme="gray", variant="soft", size="2",
),
_save_ok_callout(ParamsState.save_ok_prof),
rx.cond(
ParamsState.refresh_msg != "",
rx.text(ParamsState.refresh_msg, size="1", color="#15803d"),
rx.fragment(),
),
gap="0.5rem", align="center", flex_wrap="wrap",
),
)
def _sch_cell(day: str, period: int) -> rx.Component:
"""Une case de la grille horaire (jour × période). Cliquable."""
is_on = ParamsState.sch_periods[day].contains(period)
return rx.box(
rx.text(period, size="1", weight="bold"),
on_click=ParamsState.toggle_sch_cell(day, period),
cursor="pointer",
padding="0.35rem 0",
border_radius="6px",
border="2px solid",
text_align="center",
min_width="36px",
border_color=rx.cond(is_on, "var(--red-9)", "var(--gray-6)"),
background_color=rx.cond(is_on, "var(--red-9)", "transparent"),
color=rx.cond(is_on, "white", "var(--gray-12)"),
)
def _sch_type_select(day: str) -> rx.Component:
"""Petit dropdown pour choisir le type de jour. — = pas de type."""
return rx.select.root(
rx.select.trigger(placeholder="", width="100%"),
rx.select.content(
rx.select.item("", value="none"),
*[rx.select.item(_SCH_TYPE_LABELS[t], value=t) for t in _SCH_TYPES],
),
# Empty string n'est pas une value valide pour Radix Select → "none"
# sert de sentinelle lue/écrite via les handlers.
value=rx.cond(ParamsState.sch_types[day] == "", "none", ParamsState.sch_types[day]),
on_change=lambda v: ParamsState.set_sch_type(day, v),
size="1",
)
def _sch_day_column(day: str) -> rx.Component:
return rx.vstack(
rx.text(_SCH_DAY_LABELS[day], size="2", weight="bold",
color="var(--gray-11)", text_align="center", width="100%"),
_sch_type_select(day),
*[_sch_cell(day, p) for p in _SCH_PERIODS],
spacing="1",
align="center",
flex="1",
min_width="70px",
)
def _section_class_schedule() -> rx.Component:
return _section(
"Horaires de classe (Absent toute la journée)",
rx.text(
"Définit pour chaque classe les périodes de cours par jour de la semaine. "
"Le bouton « Absent toute la journée » de la fiche apprenti marque ces "
"périodes comme non excusées (N) en fonction du jour sélectionné.",
size="2", color="var(--gray-11)",
),
rx.cond(
ParamsState.sch_classes_avail.length() == 0,
rx.text("Aucune classe en base.", size="2", color="var(--gray-10)"),
rx.vstack(
_field(
"Classe",
rx.select(
ParamsState.sch_classes_avail,
value=ParamsState.sch_class_selected,
on_change=ParamsState.set_sch_class_selected,
width="220px",
),
),
rx.flex(
*[_sch_day_column(d) for d in _SCH_DAYS],
gap="0.75rem",
flex_wrap="wrap",
width="100%",
align="start",
),
rx.hstack(
rx.button(
rx.icon("save", size=16),
"Enregistrer l'horaire",
on_click=ParamsState.save_schedule,
color_scheme="blue", variant="solid", size="2",
),
_save_ok_callout(ParamsState.save_ok_schedule),
spacing="3", align="center", flex_wrap="wrap",
),
spacing="3", width="100%",
),
),
)
def _section_app() -> rx.Component:
return _section(
"Application",
@ -482,6 +895,8 @@ def params_page() -> rx.Component:
rx.vstack(
rx.heading("Paramètres", size="7"),
_section_app(),
_section_profession(),
_section_class_schedule(),
_section_sanction(),
_section_smtp(),
_section_escada(),

View file

@ -65,11 +65,29 @@ class ProfileState(AuthState):
# Avatar
upload_ok: bool = False
# Mes classes Escada (enrôlement self-service).
# NB: my_classes / classes_unknown / escada_username / escada_has_password
# sont dans AuthState (rechargés à chaque check_auth depuis auth.yaml pour
# éviter pollution inter-sessions). Ici on garde uniquement les vars qui
# sont propres au formulaire / au cycle de soumission.
classes_ok: bool = False
classes_error: str = ""
classes_loading: bool = False
form_escada_user: str = ""
form_escada_pass: str = ""
form_totp_code: str = ""
def set_edit_name(self, v: str): self.edit_name = v
def set_edit_email(self, v: str): self.edit_email = v
def set_pwd_current(self, v: str): self.pwd_current = v
def set_pwd_new(self, v: str): self.pwd_new = v
def set_pwd_confirm(self, v: str): self.pwd_confirm = v
def set_form_escada_user(self, v: str): self.form_escada_user = v
def set_form_escada_pass(self, v: str): self.form_escada_pass = v
def set_form_totp_code(self, v: str):
# ne garde que les chiffres, max 6
self.form_totp_code = "".join(c for c in v if c.isdigit())[:6]
self.classes_error = ""
def load_data(self):
if not self.authenticated:
@ -82,6 +100,14 @@ class ProfileState(AuthState):
self.profile_role = u.get("role", "user")
self.profile_has_totp = bool(u.get("totp_secret"))
self.profile_avatar = u.get("avatar_url", "")
# Mes classes Escada — les vars de données sont dans AuthState
# (rechargées par check_auth). Ici on initialise seulement le formulaire.
self.classes_ok = False
self.classes_error = ""
self.classes_loading = False
self.form_escada_user = u.get("escada_username") or ""
self.form_escada_pass = ""
self.form_totp_code = ""
self.edit_name = self.profile_name
self.edit_email = self.profile_email
self.info_ok = False
@ -194,6 +220,131 @@ class ProfileState(AuthState):
self.profile_avatar = ""
self.upload_ok = False
# ── Mes classes Escada (enrôlement) ───────────────────────────────────────
@rx.event(background=True)
async def fetch_my_classes(self):
"""Lance le scrape Escada en arrière-plan (async subprocess) pour ne
pas bloquer l'event loop Reflex pendant les ~10-30s du Playwright.
Les autres users continuent d'utiliser l'app pendant ce temps, et les
logs sont accessibles en live sur /logs.
"""
import asyncio as _aio
import json as _json
from pathlib import Path as _P
# Reset état + validation (sous lock state)
async with self:
self.classes_error = ""
self.classes_ok = False
e_user = (self.form_escada_user or "").strip()
e_pass = (self.form_escada_pass or "").strip()
totp = (self.form_totp_code or "").strip()
if not e_user or "@" not in e_user:
self.classes_error = "Email Escada invalide."
return
if not self.escada_has_password and not e_pass:
self.classes_error = "Mot de passe Escada requis pour la première connexion."
return
if len(totp) != 6 or not totp.isdigit():
self.classes_error = "Code 2FA invalide (6 chiffres)."
return
# Persistance des creds (le password n'est ré-écrit que s'il est fourni)
cfg = _load_auth()
users = cfg.get("credentials", {}).get("usernames", {})
u = users.get(self.username)
if not u:
self.classes_error = "Compte introuvable."
return
u["escada_username"] = e_user
if e_pass:
u["escada_password"] = e_pass
_save_auth(cfg)
self.classes_loading = True
current_user = self.username # capture pour la phase async
result_file = _P(DATA_DIR) / f"sync_user_classes_{current_user}.json"
result_file.unlink(missing_ok=True)
cwd = str(_P(__file__).resolve().parent.parent.parent)
# Subprocess async — ne bloque pas l'event loop Reflex
try:
proc = await _aio.create_subprocess_exec(
"python", "scripts/fetch_user_classes.py", current_user,
env={**os.environ, "TOTP_CODE": totp},
cwd=cwd,
stdout=_aio.subprocess.PIPE,
stderr=_aio.subprocess.PIPE,
)
try:
stdout_b, stderr_b = await _aio.wait_for(proc.communicate(), timeout=180)
except _aio.TimeoutError:
proc.kill()
async with self:
self.classes_loading = False
self.classes_error = "Délai dépassé (3 min)."
return
stdout = (stdout_b or b"").decode("utf-8", errors="replace")
stderr = (stderr_b or b"").decode("utf-8", errors="replace")
rc = proc.returncode
except Exception as e:
async with self:
self.classes_loading = False
self.classes_error = f"Erreur subprocess : {e}"
return
# Lecture résultat (hors lock)
app_log(
f"[profile/escada] {current_user} : subprocess rc={rc} "
f"stdout_tail={stdout[-800:]!r} stderr_tail={stderr[-400:]!r}"
)
data = None
if result_file.exists():
try:
data = _json.loads(result_file.read_text(encoding="utf-8"))
except Exception:
pass
# Update state final (sous lock)
async with self:
self.classes_loading = False
if not data:
self.classes_error = "Pas de résultat — voir logs serveur."
return
if not data.get("ok"):
self.classes_error = data.get("error") or "Échec inconnu."
app_log(f"[profile/escada] {current_user} : échec : {self.classes_error}")
return
new_classes = list(data.get("classes") or [])
cache_path = _P(DATA_DIR) / "esacada_classes.json"
try:
known = set(_json.loads(cache_path.read_text(encoding="utf-8")))
except Exception:
known = set()
self.classes_unknown = sorted([c for c in new_classes if c not in known])
cfg = _load_auth()
cfg["credentials"]["usernames"][current_user]["allowed_classes"] = new_classes
_save_auth(cfg)
self.my_classes = new_classes
self.escada_username = e_user
self.escada_has_password = True
self.form_escada_pass = ""
self.form_totp_code = ""
self.classes_ok = True
# Si l'enrôlement vient d'être finalisé, le popup doit se fermer
if new_classes:
self.must_enroll = False
app_log(
f"[profile/escada] {current_user} : {len(new_classes)} classes "
f"accordées ({len(self.classes_unknown)} inconnues du cache)"
)
# ── UI helpers ────────────────────────────────────────────────────────────────
@ -277,9 +428,9 @@ def _avatar_section() -> rx.Component:
spacing="3", width="100%",
),
padding="1.25rem",
background_color="white",
background_color="var(--surface)",
border_radius="8px",
border="1px solid #e0e0e0",
border="1px solid var(--border)",
width="100%",
)
@ -337,9 +488,9 @@ def _info_section() -> rx.Component:
spacing="3", width="100%",
),
padding="1.25rem",
background_color="white",
background_color="var(--surface)",
border_radius="8px",
border="1px solid #e0e0e0",
border="1px solid var(--border)",
width="100%",
)
@ -393,13 +544,227 @@ def _password_section() -> rx.Component:
spacing="3", width="100%",
),
padding="1.25rem",
background_color="white",
background_color="var(--surface)",
border_radius="8px",
border="1px solid #e0e0e0",
border="1px solid var(--border)",
width="100%",
)
_THEMES = [
("eptm", "EPTM (rouge)", "#dc000e"),
("bleu", "Bleu corporate", "#1565c0"),
("indigo", "Indigo nuit", "#3f51b5"),
("vert", "Vert académique","#2e7d32"),
("sombre", "Sombre (dark)", "#1a1a1a"),
]
def _theme_swatch(key: str, label: str, color: str) -> rx.Component:
is_active = ProfileState.theme == key
return rx.box(
rx.vstack(
rx.box(
background_color=color,
width="100%",
height="48px",
border_radius="6px",
border=rx.cond(is_active, "2px solid var(--gray-12)", "1px solid var(--gray-5)"),
),
rx.hstack(
rx.text(label, size="2", weight=rx.cond(is_active, "bold", "regular")),
rx.spacer(),
rx.cond(
is_active,
rx.icon("check", size=15, color="var(--green-10)"),
rx.fragment(),
),
width="100%", align="center",
),
spacing="2", width="100%",
),
on_click=ProfileState.set_theme(key),
padding="0.6rem",
border_radius="8px",
border=rx.cond(is_active, "1.5px solid var(--gray-12)", "1px solid var(--gray-4)"),
background_color=rx.cond(is_active, "var(--gray-2)", "white"),
cursor="pointer",
_hover={"border_color": "var(--gray-8)"},
flex="1", min_width="140px",
)
def _theme_section() -> rx.Component:
return rx.box(
rx.vstack(
rx.text("Thème de couleur", size="3", weight="bold"),
rx.text(
"Personnalise les couleurs de marque (sidebar, KPI, liens, boutons). "
"Les couleurs de notes (rouge < 4, orange < 5, vert ≥ 5) restent inchangées.",
size="1", color="var(--gray-11)",
),
rx.flex(
*[_theme_swatch(k, l, c) for k, l, c in _THEMES],
gap="0.75rem", flex_wrap="wrap", width="100%",
),
spacing="3", width="100%",
),
padding="1.25rem",
background_color="var(--surface)",
border_radius="8px",
border="1px solid var(--border)",
width="100%",
)
def _enroll_form_content() -> rx.Component:
"""Contenu pure du formulaire d'enrôlement Escada (sans wrapper box).
Réutilisé dans la carte /profile et dans le dialog popup global."""
return rx.vstack(
_label("Email Escada"),
rx.input(
value=ProfileState.form_escada_user,
on_change=ProfileState.set_form_escada_user,
placeholder="prenom.nom@vs.ch",
type="email", width="100%",
),
_label(rx.cond(
AuthState.escada_has_password,
"Mot de passe Escada (laisser vide pour réutiliser celui enregistré)",
"Mot de passe Escada",
)),
rx.input(
value=ProfileState.form_escada_pass,
on_change=ProfileState.set_form_escada_pass,
type="password", width="100%",
),
_label("Code 2FA Escada (6 chiffres)"),
rx.input(
value=ProfileState.form_totp_code,
on_change=ProfileState.set_form_totp_code,
placeholder="123456",
max_length=6,
inputmode="numeric",
width="160px",
),
rx.hstack(
rx.button(
rx.cond(
ProfileState.classes_loading,
rx.spinner(size="2"),
rx.icon("refresh-ccw", size=16),
),
rx.cond(
ProfileState.classes_loading,
rx.text("Connexion Escada en cours…"),
rx.cond(
AuthState.escada_has_password,
rx.text("Rafraîchir mes classes"),
rx.text("Récupérer mes classes"),
),
),
on_click=ProfileState.fetch_my_classes,
disabled=ProfileState.classes_loading,
color_scheme="blue", size="2",
),
_ok(ProfileState.classes_ok, "Liste à jour. Voir ci-dessous."),
_err(ProfileState.classes_error),
spacing="3", align="center", flex_wrap="wrap",
),
rx.divider(),
rx.text(
"Classes actuellement accordées :",
size="2", weight="medium", color="var(--gray-11)",
),
rx.cond(
AuthState.my_classes.length() == 0,
rx.text(
"Aucune classe accordée pour l'instant. Lancez une récupération ci-dessus.",
size="2", color="var(--text-soft)",
),
rx.flex(
rx.foreach(
AuthState.my_classes,
lambda c: rx.badge(c, color_scheme="blue", variant="soft", size="2"),
),
gap="0.4rem", flex_wrap="wrap",
),
),
rx.cond(
AuthState.classes_unknown.length() > 0,
rx.callout.root(
rx.callout.icon(rx.icon("triangle-alert", size=16)),
rx.callout.text(
"Les classes suivantes sont accordées mais pas encore "
"synchronisées dans le système. Demandez à un admin de "
"lancer une sync globale Escada : ",
rx.foreach(
AuthState.classes_unknown,
lambda c: rx.badge(c, color_scheme="amber", variant="soft", margin_right="0.25rem"),
),
),
color_scheme="amber", variant="soft", size="1",
),
),
spacing="3", width="100%",
)
def _classes_section() -> rx.Component:
"""Carte enrôlement Escada pour la page /profile (non-admin uniquement)."""
return rx.cond(
ProfileState.profile_role == "admin",
rx.fragment(),
rx.box(
rx.vstack(
rx.text("Mes classes Escada", size="3", weight="bold"),
rx.text(
"Vos accès aux classes sont déterminés par votre compte Escada. "
"Renseignez vos identifiants et un code 2FA pour récupérer votre liste.",
size="1", color="var(--text-soft)",
),
_enroll_form_content(),
spacing="3", width="100%",
),
padding="1.25rem",
background_color="var(--surface)",
border_radius="8px",
border="1px solid var(--border)",
width="100%",
),
)
def enroll_required_dialog() -> rx.Component:
"""Popup forcé pour les users sans aucun accès (must_enroll=True)."""
return rx.dialog.root(
rx.dialog.content(
rx.flex(
rx.heading("Bienvenue ! Configurez votre accès Escada", size="4"),
rx.spacer(),
rx.icon_button(
rx.icon("x", size=14),
on_click=AuthState.dismiss_enroll,
variant="ghost", size="1",
),
align="center", width="100%",
),
rx.text(
"Vous n'avez encore accès à aucune classe. Renseignez vos identifiants "
"Escada et un code 2FA fraîchement généré (validité ~30s) pour récupérer "
"la liste des classes auxquelles votre compte Escada a accès.",
size="2", color="var(--text-soft)",
margin_y="0.75rem",
),
_enroll_form_content(),
max_width="540px",
max_height="90vh",
overflow_y="auto",
),
open=AuthState.must_enroll & ~AuthState.enroll_dismissed,
)
def _totp_section() -> rx.Component:
return rx.box(
rx.vstack(
@ -446,9 +811,9 @@ def _totp_section() -> rx.Component:
spacing="3", width="100%",
),
padding="1.25rem",
background_color="white",
background_color="var(--surface)",
border_radius="8px",
border="1px solid #e0e0e0",
border="1px solid var(--border)",
width="100%",
)
@ -460,6 +825,8 @@ def profile_page() -> rx.Component:
_avatar_section(),
_info_section(),
_password_section(),
_classes_section(),
_theme_section(),
_totp_section(),
spacing="4",
width="100%",

View file

@ -365,7 +365,7 @@ def _classe_selector() -> rx.Component:
padding="0.5rem 0.75rem",
border="1px solid var(--gray-7)",
border_radius="6px",
background_color="white",
background_color="var(--surface)",
cursor="pointer",
width="100%",
custom_attrs={"data-shortcut": "purge-search"},
@ -412,8 +412,8 @@ def _kpi(label: str, value, color: str = "#37474f") -> rx.Component:
rx.text(label, size="1", color="#666"),
rx.text(value, size="5", font_weight="700", color=color),
padding="0.6rem 0.85rem",
background_color="white",
border="1px solid #e0e0e0",
background_color="var(--surface)",
border="1px solid var(--border)",
border_radius="6px",
min_width="110px",
text_align="center",
@ -427,11 +427,11 @@ def _preview_panel() -> rx.Component:
rx.vstack(
rx.text(
"Données qui seront supprimées :",
size="2", weight="bold", color="#37474f",
size="2", weight="bold", color="var(--text-strong)",
),
rx.flex(
_kpi("Apprentis", PurgeState.pv_apprentis, "#c62828"),
_kpi("Absences", PurgeState.pv_absences, "#c62828"),
_kpi("Apprentis", PurgeState.pv_apprentis, "var(--brand-primary-dark)"),
_kpi("Absences", PurgeState.pv_absences, "var(--brand-primary-dark)"),
_kpi("Pendings", PurgeState.pv_pendings, "#b45309"),
_kpi("BN", PurgeState.pv_bn),
_kpi("Matu", PurgeState.pv_matu),
@ -458,7 +458,7 @@ def _preview_panel() -> rx.Component:
lambda f: rx.text("", f, size="1", color="#666"),
),
padding="0.6rem 0.75rem",
background_color="#fafafa",
background_color="var(--surface-soft)",
border_radius="6px",
border="1px solid #eee",
width="100%",

View file

@ -0,0 +1,902 @@
"""Page /retenue — génération et envoi d'avis de retenue."""
from __future__ import annotations
import json
import os
import sys
from datetime import date as _date
from pathlib import Path
from typing import Optional
import reflex as rx
from sqlalchemy import select
_ROOT = Path(__file__).resolve().parent.parent.parent
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
from src.db import get_session, Apprenti, ApprentiFiche, NotesExamen, Notice # noqa: E402
from src.user_access import get_allowed_classes, is_class_allowed # noqa: E402
from src.profession import resolve_profession # noqa: E402
from src.retenue_pdf import generate_retenue_pdf # noqa: E402
from src.email_sender import send_email # noqa: E402
from src.logger import app_log # noqa: E402
from ..state import AuthState
from ..sidebar import layout
from ..components import empty_state
DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data")))
_SETTINGS_FILE = DATA_DIR / "settings.json"
def _load_settings() -> dict:
if _SETTINGS_FILE.exists():
try:
return json.loads(_SETTINGS_FILE.read_text(encoding="utf-8"))
except Exception:
return {}
return {}
# ── State ─────────────────────────────────────────────────────────────────────
class RetenueState(AuthState):
# Modal control (utilisé depuis /fiche)
modal_open: bool = False
# Sélecteur apprenti (présent pour le modal, en read-only)
apprenti_labels: list[str] = []
apprenti_ids: list[int] = []
selected_label: str = ""
selected_id: int = 0
has_apprentis: bool = False
apprenti_search: str = ""
apprenti_select_open: bool = False
# Données de l'apprenti sélectionné
sel_classe: str = ""
sel_profession: str = ""
sel_fiche_email_appr: str = ""
sel_fiche_email_form: str = ""
sel_fiche_email_entr: str = ""
sel_fiche_nom_entr: str = ""
# Cache des branches (récupérées des notes d'examen)
branches_cache: list[str] = []
branche_search: str = ""
branche_open: bool = False
# Formulaire
retenue_date: str = "" # ISO date "YYYY-MM-DD"
probleme_date: str = ""
case: str = "devoir" # "devoir" | "comportement" | "retard"
branche: str = ""
remarque: str = ""
# Email
email_dest: str = "apprenti"
email_custom: str = ""
# Détection notice existante (pending) pour cet apprenti à la date du jour
has_existing_notice: bool = False
existing_notice_label: str = ""
create_anyway: bool = False
# États
form_error: str = ""
@rx.var
def filtered_apprenti_labels(self) -> list[str]:
q = self.apprenti_search.lower().strip()
if not q:
return self.apprenti_labels
return [l for l in self.apprenti_labels if q in l.lower()]
@rx.var
def filtered_branches(self) -> list[str]:
q = self.branche_search.lower().strip()
if not q:
return self.branches_cache
return [b for b in self.branches_cache if q in b.lower()]
# ── Setters ──────────────────────────────────────────────────────────────
def set_apprenti_search(self, v: str): self.apprenti_search = v
def set_apprenti_select_open(self, v: bool):
self.apprenti_select_open = v
if not v:
self.apprenti_search = ""
def set_branche_search(self, v: str): self.branche_search = v
def set_branche_open(self, v: bool):
self.branche_open = v
if not v:
self.branche_search = ""
def set_retenue_date(self, v: str): self.retenue_date = v
def set_probleme_date(self, v: str): self.probleme_date = v
def set_case(self, v: str): self.case = v
def set_branche(self, v: str): self.branche = v
def set_remarque(self, v: str): self.remarque = v
def set_profession(self, v: str): self.sel_profession = v
def set_email_dest(self, v: str): self.email_dest = v
def set_email_custom(self, v: str): self.email_custom = v
def set_create_anyway(self, v: bool): self.create_anyway = v
def set_modal_open(self, v: bool):
self.modal_open = v
if not v:
# Reset partiel à la fermeture
self.form_error = ""
def preload_apprenti(self, apprenti_id: int, label: str):
"""Pré-remplit l'apprenti depuis la fiche et ouvre le modal."""
self.selected_id = apprenti_id
self.selected_label = label
# Reset des autres champs
self.case = "devoir"
self.branche = ""
self.remarque = ""
self.form_error = ""
self.email_dest = "apprenti"
self.email_custom = ""
self.create_anyway = False
# Dates par défaut = aujourd'hui
today = _date.today().isoformat()
self.retenue_date = today
self.probleme_date = today
# Charger les données apprenti (profession, emails) + cache branches
self._load_apprenti()
sess = get_session()
try:
self._load_branches(sess)
self._detect_existing_notice(sess, apprenti_id)
finally:
sess.close()
# Ouvrir le modal
self.modal_open = True
def _detect_existing_notice(self, sess, apprenti_id: int):
"""Détecte si une Notice de retenue pending existe déjà aujourd'hui
pour cet apprenti. Filtre par source pour ne pas confondre avec une
notice de sanction."""
today = _date.today()
existing = sess.execute(
select(Notice)
.where(
Notice.apprenti_id == apprenti_id,
Notice.date_event == today,
Notice.status == "pending",
Notice.source == "retenue",
)
.order_by(Notice.created_at.desc())
).scalars().first()
if existing:
self.has_existing_notice = True
self.existing_notice_label = (
f"{existing.titre or '(sans titre)'}"
f"créée le {existing.created_at.strftime('%d.%m.%Y %H:%M')}"
)
else:
self.has_existing_notice = False
self.existing_notice_label = ""
def close_after_action(self):
"""Appelée après un téléchargement / envoi pour fermer le modal."""
self.modal_open = False
def load_data(self):
if not self.authenticated:
return rx.redirect("/login")
sess = get_session()
try:
allowed = get_allowed_classes(self.username)
q = select(Apprenti).order_by(Apprenti.nom, Apprenti.prenom)
if allowed is not None:
q = q.where(Apprenti.classe.in_(allowed))
apprentis = sess.execute(q).scalars().all()
if not apprentis:
self.has_apprentis = False
self.apprenti_labels = []
self.apprenti_ids = []
return
self.has_apprentis = True
self.apprenti_labels = [
f"{a.nom} {a.prenom} ({a.classe})" for a in apprentis
]
self.apprenti_ids = [a.id for a in apprentis]
# Toujours partir d'une sélection vide à l'arrivée sur la page
self.selected_id = 0
self.selected_label = ""
self.sel_classe = ""
self.sel_profession = ""
self.sel_fiche_email_appr = ""
self.sel_fiche_email_form = ""
self.sel_fiche_email_entr = ""
self.sel_fiche_nom_entr = ""
self._load_branches(sess)
finally:
sess.close()
# Dates par défaut = aujourd'hui
today = _date.today().isoformat()
if not self.retenue_date:
self.retenue_date = today
if not self.probleme_date:
self.probleme_date = today
def _load_apprenti(self):
if not self.selected_id:
return
sess = get_session()
try:
ap = sess.get(Apprenti, self.selected_id)
if not ap:
return
self.sel_classe = ap.classe
fiche = ap.fiche
if fiche:
self.sel_profession = fiche.profession or resolve_profession(ap.classe)
self.sel_fiche_email_appr = fiche.email or ""
self.sel_fiche_email_form = fiche.formateur_email or ""
self.sel_fiche_email_entr = fiche.entreprise_email or ""
self.sel_fiche_nom_entr = fiche.entreprise_nom or ""
else:
self.sel_profession = resolve_profession(ap.classe)
self.sel_fiche_email_appr = ""
self.sel_fiche_email_form = ""
self.sel_fiche_email_entr = ""
self.sel_fiche_nom_entr = ""
finally:
sess.close()
def _load_branches(self, sess):
"""Construit le cache des branches uniques depuis NotesExamen."""
rows = sess.execute(select(NotesExamen.donnees_json)).scalars().all()
seen: set[str] = set()
for raw in rows:
try:
d = json.loads(raw)
except Exception:
continue
if isinstance(d, list):
for br in d:
name = (br.get("branche") or "").strip()
if name:
seen.add(name)
self.branches_cache = sorted(seen)
def handle_select_apprenti(self, label: str):
self.selected_label = label
try:
idx = self.apprenti_labels.index(label)
self.selected_id = self.apprenti_ids[idx]
except ValueError:
pass
self.apprenti_select_open = False
self.apprenti_search = ""
self._load_apprenti()
def apprenti_search_keydown(self, key: str):
if key == "Enter":
results = self.filtered_apprenti_labels
if results:
return RetenueState.handle_select_apprenti(results[0])
elif key == "Escape":
self.apprenti_select_open = False
self.apprenti_search = ""
def select_branche(self, b: str):
self.branche = b
self.branche_open = False
self.branche_search = ""
def branche_keydown(self, key: str):
if key == "Enter":
# Si une seule branche filtrée : la sélectionne. Sinon prend la saisie libre.
results = self.filtered_branches
if len(results) == 1:
return RetenueState.select_branche(results[0])
elif self.branche_search:
self.branche = self.branche_search.strip()
self.branche_open = False
self.branche_search = ""
elif key == "Escape":
self.branche_open = False
self.branche_search = ""
# ── Actions ──────────────────────────────────────────────────────────────
_CASE_LABELS = {
"devoir": "N'a pas remis ses tâches scolaires dans les délais",
"comportement": "A manifesté un comportement répréhensible",
"retard": "Est arrivé en retard aux cours",
}
def _build_notice_titre(self) -> str:
label = self._CASE_LABELS.get(self.case, "")
if self.case == "devoir" and self.branche.strip():
return f"{label} en {self.branche.strip()}"
return label
def _create_notice(self):
"""Crée une Notice en DB (push queue Escada).
Si une notice pending existe déjà pour cet apprenti aujourd'hui et que
l'utilisateur n'a pas coché « Créer quand même », on saute la création.
"""
if not self.selected_id:
return
if self.has_existing_notice and not self.create_anyway:
app_log(
f"[notice] {self.username or '?'} : notice doublon évitée pour "
f"{self.selected_label} (existante : {self.existing_notice_label})"
)
return
remarque = (self.remarque or "").strip()
user = (self.username or "").strip()
if user:
remarque = f"({user}) {remarque}".rstrip()
remarque = remarque or None
sess = get_session()
try:
sess.add(Notice(
apprenti_id=self.selected_id,
date_event=_date.today(),
titre=self._build_notice_titre(),
remarque=remarque,
type_notice=None,
matiere=None,
source="retenue",
status="pending",
created_by=self.username or None,
))
sess.commit()
app_log(
f"[notice] {self.username or '?'} : création (retenue) pour "
f"{self.selected_label} — case={self.case}"
)
except Exception as e:
sess.rollback()
app_log(f"[notice] échec création : {e}")
finally:
sess.close()
def _build_pdf(self) -> Optional[bytes]:
if not self.selected_id:
self.form_error = "Aucun apprenti sélectionné."
return None
if not is_class_allowed(self.username, self.sel_classe):
self.form_error = "Accès refusé pour cette classe."
return None
if self.case == "devoir" and not self.branche.strip():
self.form_error = "Veuillez préciser la branche."
return None
try:
r_date = _date.fromisoformat(self.retenue_date)
p_date = _date.fromisoformat(self.probleme_date)
except Exception:
self.form_error = "Date invalide."
return None
self.form_error = ""
sess = get_session()
try:
return generate_retenue_pdf(
sess, self.selected_id,
profession=self.sel_profession,
retenue_date=r_date,
probleme_date=p_date,
case=self.case,
branche=self.branche.strip(),
remarque=self.remarque,
prof_name=self.name or self.username,
)
finally:
sess.close()
def _filename(self) -> str:
sess = get_session()
try:
ap = sess.get(Apprenti, self.selected_id)
if not ap:
return "Avis_retenue.pdf"
safe_nom = "".join(c if c.isalnum() else "_" for c in ap.nom)
safe_prenom = "".join(c if c.isalnum() else "_" for c in ap.prenom)
return f"Avis_retenue_{safe_nom}_{safe_prenom}.pdf"
finally:
sess.close()
def download_pdf(self):
data = self._build_pdf()
if data is None:
if self.form_error:
return rx.toast.error(self.form_error)
return rx.toast.error("Impossible de générer le PDF.")
app_log(
f"[retenue] {self.username or '?'} : avis téléchargé pour "
f"{self.selected_label} (case={self.case})"
)
self._create_notice()
self.modal_open = False
return [
rx.download(data=data, filename=self._filename()),
rx.toast.success("Avis téléchargé — notice ajoutée à la file Escada"),
]
def send_email_action(self):
data = self._build_pdf()
if data is None:
if self.form_error:
return rx.toast.error(self.form_error)
return rx.toast.error("Impossible de générer le PDF.")
# Destinataire
if self.email_dest == "apprenti":
to = self.sel_fiche_email_appr
elif self.email_dest == "formateur":
to = self.sel_fiche_email_form
else:
to = self.email_custom.strip()
if not to or "@" not in to:
return rx.toast.error("Adresse email invalide ou manquante.")
s = _load_settings()
smtp_host = s.get("smtp_host")
smtp_port = int(s.get("smtp_port") or 587)
smtp_login = s.get("smtp_login")
smtp_password = s.get("smtp_password")
smtp_sender = s.get("smtp_sender")
if not (smtp_host and smtp_login and smtp_password and smtp_sender):
return rx.toast.error("Configuration SMTP incomplète (Paramètres).")
subject = f"Avis de retenue — {self.selected_label}"
body = (
f"Bonjour,\n\nVeuillez trouver en pièce jointe l'avis de retenue concernant "
f"{self.selected_label}.\n\nCordialement,\n{self.name or self.username}\n"
)
try:
send_email(
smtp_host=smtp_host, smtp_port=smtp_port,
smtp_login=smtp_login, smtp_password=smtp_password,
smtp_sender=smtp_sender,
to_email=to, subject=subject, body=body,
attachments=[(data, self._filename())],
)
except Exception as e:
return rx.toast.error(f"Échec d'envoi : {e}")
app_log(
f"[retenue] {self.username or '?'} : avis envoyé à {to} pour "
f"{self.selected_label}"
)
self._create_notice()
self.modal_open = False
return rx.toast.success(
f"Avis envoyé à {to} — notice ajoutée à la file Escada"
)
# ── UI ────────────────────────────────────────────────────────────────────────
def _apprenti_option(label: rx.Var) -> rx.Component:
return rx.box(
rx.text(label, size="2"),
padding="0.45rem 0.75rem",
cursor="pointer",
on_click=RetenueState.handle_select_apprenti(label),
_hover={"background_color": "var(--gray-3)"},
width="100%",
)
def _apprenti_selector() -> rx.Component:
return rx.popover.root(
rx.popover.trigger(
rx.box(
rx.flex(
rx.cond(
RetenueState.selected_label != "",
rx.text(RetenueState.selected_label, size="2"),
rx.text("Sélectionner un apprenti…", size="2", color="var(--gray-9)"),
),
rx.spacer(),
rx.icon("chevron-down", size=18, color="var(--gray-9)"),
align="center",
width="100%",
),
padding="0.5rem 0.75rem",
border="1px solid var(--gray-7)",
border_radius="6px",
background_color="var(--surface)",
cursor="pointer",
width="100%",
custom_attrs={"data-shortcut": "apprenti-search"},
),
),
rx.popover.content(
rx.vstack(
rx.input(
placeholder="Rechercher un apprenti…",
value=RetenueState.apprenti_search,
on_change=RetenueState.set_apprenti_search,
on_key_down=RetenueState.apprenti_search_keydown,
size="2",
width="100%",
auto_focus=True,
),
rx.cond(
RetenueState.filtered_apprenti_labels.length() > 0,
rx.box(
rx.foreach(RetenueState.filtered_apprenti_labels, _apprenti_option),
max_height="280px",
overflow_y="auto",
width="100%",
),
rx.box(
rx.text("Aucun résultat", size="2", color="var(--gray-9)"),
padding="0.5rem 0.75rem",
),
),
spacing="2",
width="100%",
),
min_width="320px",
max_width="500px",
padding="0.5rem",
),
open=RetenueState.apprenti_select_open,
on_open_change=RetenueState.set_apprenti_select_open,
)
def _branche_option(b: rx.Var) -> rx.Component:
return rx.box(
rx.text(b, size="2"),
padding="0.45rem 0.75rem",
cursor="pointer",
on_click=RetenueState.select_branche(b),
_hover={"background_color": "var(--gray-3)"},
width="100%",
)
def _branche_selector() -> rx.Component:
return rx.popover.root(
rx.popover.trigger(
rx.box(
rx.flex(
rx.cond(
RetenueState.branche != "",
rx.text(RetenueState.branche, size="2"),
rx.text("Choisir / taper une branche…", size="2", color="var(--gray-9)"),
),
rx.spacer(),
rx.icon("chevron-down", size=18, color="var(--gray-9)"),
align="center",
width="100%",
),
padding="0.5rem 0.75rem",
border="1px solid var(--gray-7)",
border_radius="6px",
background_color="var(--surface)",
cursor="pointer",
width="100%",
),
),
rx.popover.content(
rx.vstack(
rx.input(
placeholder="Rechercher ou saisir une branche libre…",
value=RetenueState.branche_search,
on_change=RetenueState.set_branche_search,
on_key_down=RetenueState.branche_keydown,
size="2",
width="100%",
auto_focus=True,
),
rx.cond(
RetenueState.filtered_branches.length() > 0,
rx.box(
rx.foreach(RetenueState.filtered_branches, _branche_option),
max_height="280px",
overflow_y="auto",
width="100%",
),
rx.text(
"Appuyez sur Entrée pour valider votre saisie libre.",
size="1", color="var(--gray-9)",
padding="0.5rem 0.75rem",
),
),
spacing="2",
width="100%",
),
min_width="320px",
max_width="500px",
padding="0.5rem",
),
open=RetenueState.branche_open,
on_open_change=RetenueState.set_branche_open,
)
def _profession_warning() -> rx.Component:
# Affiché uniquement si un apprenti est sélectionné ET que sa profession est vide
return rx.cond(
(RetenueState.selected_id != 0) & (RetenueState.sel_profession == ""),
rx.callout.root(
rx.callout.icon(rx.icon("triangle-alert", size=16)),
rx.callout.text(
"Profession non définie pour ",
RetenueState.sel_classe,
". Renseigne-la ci-dessous, ou ajoute la correspondance dans ",
rx.link("Paramètres", href="/params", color="var(--brand-accent)"),
" pour qu'elle soit pré-remplie automatiquement.",
),
color_scheme="amber", variant="soft", size="1",
),
rx.fragment(),
)
def _form() -> rx.Component:
return rx.vstack(
# Bannière apprenti (read-only, pré-rempli depuis la fiche)
rx.box(
rx.flex(
rx.icon("user", size=16, color="var(--brand-accent)"),
rx.text(RetenueState.selected_label, size="2", weight="medium", color="var(--text-strong)"),
gap="0.5rem", align="center",
),
padding="0.5rem 0.75rem",
background_color="#e3f2fd",
border_radius="6px",
border="1px solid #90caf9",
),
_profession_warning(),
# Profession (éditable)
rx.vstack(
rx.text("Profession", size="2", weight="medium", color="var(--gray-11)"),
rx.input(
value=RetenueState.sel_profession,
on_change=RetenueState.set_profession,
placeholder="ex. Automaticien CFC",
width="100%",
),
spacing="1", width="100%",
),
# Dates
rx.flex(
rx.vstack(
rx.text("Date de retenue", size="2", weight="medium", color="var(--gray-11)"),
rx.input(
type="date",
value=RetenueState.retenue_date,
on_change=RetenueState.set_retenue_date,
width="100%",
),
spacing="1", flex="1", min_width="200px",
),
rx.vstack(
rx.text("Date du problème", size="2", weight="medium", color="var(--gray-11)"),
rx.input(
type="date",
value=RetenueState.probleme_date,
on_change=RetenueState.set_probleme_date,
width="100%",
),
spacing="1", flex="1", min_width="200px",
),
gap="0.75rem", flex_wrap="wrap", width="100%",
),
# Motif (radio)
rx.vstack(
rx.text("Motif de la retenue", size="2", weight="medium", color="var(--gray-11)"),
rx.radio_group.root(
rx.vstack(
rx.radio_group.item(
rx.text("N'a pas remis ses tâches scolaires dans les délais", size="2"),
value="devoir",
),
rx.radio_group.item(
rx.text("A manifesté un comportement répréhensible", size="2"),
value="comportement",
),
rx.radio_group.item(
rx.text("Est arrivé en retard aux cours", size="2"),
value="retard",
),
spacing="2",
),
value=RetenueState.case,
on_change=RetenueState.set_case,
),
spacing="2", width="100%",
),
# Branche (visible seulement si case devoir)
rx.cond(
RetenueState.case == "devoir",
rx.vstack(
rx.text("Branche", size="2", weight="medium", color="var(--gray-11)"),
_branche_selector(),
spacing="1", width="100%",
),
rx.fragment(),
),
# Remarque
rx.vstack(
rx.text("Remarque éventuelle de l'école", size="2", weight="medium", color="var(--gray-11)"),
rx.text_area(
value=RetenueState.remarque,
on_change=RetenueState.set_remarque,
rows="4",
width="100%",
resize="vertical",
),
spacing="1", width="100%",
),
# Erreur
rx.cond(
RetenueState.form_error != "",
rx.callout.root(
rx.callout.icon(rx.icon("triangle-alert", size=16)),
rx.callout.text(RetenueState.form_error),
color_scheme="red", variant="soft", size="1",
),
rx.fragment(),
),
# Bandeau d'info notice Escada (jaune si doublon détecté, bleu sinon)
rx.cond(
RetenueState.has_existing_notice,
rx.box(
rx.flex(
rx.icon("triangle-alert", size=14, color="#b45309"),
rx.text(
"Une notice est déjà en attente pour cet apprenti aujourd'hui : ",
rx.text.strong(RetenueState.existing_notice_label),
". Par défaut, aucune nouvelle notice ne sera créée.",
size="1", color="#78350f",
),
gap="0.4rem", align="start",
),
rx.flex(
rx.checkbox(
checked=RetenueState.create_anyway,
on_change=RetenueState.set_create_anyway,
size="2",
color_scheme="amber",
),
rx.text(
"Créer quand même une nouvelle notice",
size="2", color="#78350f", weight="medium",
),
gap="0.5rem", align="center", margin_top="0.4rem",
),
padding="0.6rem 0.75rem",
background_color="#fef3c7",
border="1px solid #fcd34d",
border_radius="6px",
),
rx.flex(
rx.icon("info", size=14, color="var(--brand-accent)"),
rx.text(
"Une notice sera ajoutée à la file d'attente Escada lors du téléchargement "
"ou de l'envoi par email. Choisis une seule de ces deux actions.",
size="1", color="var(--brand-accent)",
),
gap="0.4rem", align="start",
padding="0.5rem 0.65rem",
background_color="#e3f2fd",
border="1px solid #90caf9",
border_radius="6px",
),
),
# Bouton Télécharger
rx.button(
rx.icon("file-down", size=16),
"Télécharger l'avis",
on_click=RetenueState.download_pdf,
color_scheme="red", size="2",
disabled=RetenueState.selected_id == 0,
width="100%",
),
spacing="4",
width="100%",
)
def _email_section() -> rx.Component:
return rx.box(
rx.vstack(
rx.flex(
rx.icon("mail", size=16, color="var(--text-strong)"),
rx.text("Envoyer par email", size="3", weight="bold", color="var(--text-strong)"),
gap="0.5rem", align="center",
),
rx.divider(),
rx.text("Destinataire", size="2", weight="medium", color="var(--gray-11)"),
rx.radio_group.root(
rx.vstack(
rx.radio_group.item(
rx.cond(
RetenueState.sel_fiche_email_appr != "",
rx.text("Apprenti — ", RetenueState.sel_fiche_email_appr, size="2"),
rx.text("Apprenti (email inconnu)", size="2", color="var(--gray-9)"),
),
value="apprenti",
disabled=RetenueState.sel_fiche_email_appr == "",
),
rx.radio_group.item(
rx.cond(
RetenueState.sel_fiche_email_form != "",
rx.text("Formateur — ", RetenueState.sel_fiche_email_form, size="2"),
rx.text("Formateur (email inconnu)", size="2", color="var(--gray-9)"),
),
value="formateur",
disabled=RetenueState.sel_fiche_email_form == "",
),
rx.radio_group.item(
rx.text("Autre adresse", size="2"),
value="autre",
),
spacing="2",
),
value=RetenueState.email_dest,
on_change=RetenueState.set_email_dest,
),
rx.cond(
RetenueState.email_dest == "autre",
rx.input(
placeholder="email@domaine.ch",
value=RetenueState.email_custom,
on_change=RetenueState.set_email_custom,
type="email",
width="100%",
),
rx.fragment(),
),
rx.button(
rx.icon("send", size=16),
"Envoyer l'avis par email",
on_click=RetenueState.send_email_action,
color_scheme="blue", size="2",
disabled=RetenueState.selected_id == 0,
),
spacing="3", width="100%",
),
padding="1.25rem",
background_color="var(--surface)",
border_radius="8px",
border="1px solid var(--border)",
width="100%",
)
def retenue_modal() -> rx.Component:
"""Modal réutilisable pour créer un avis de retenue.
L'apprenti doit être pré-rempli via `RetenueState.preload_apprenti(id, label)`
avant l'ouverture. L'état `modal_open` contrôle l'affichage.
"""
return rx.dialog.root(
rx.dialog.content(
rx.dialog.title("Créer un avis de retenue"),
rx.dialog.description(
"Renseigne les informations et télécharge ou envoie l'avis par email.",
size="2", color="var(--gray-11)",
),
rx.vstack(
_form(),
_email_section(),
spacing="4", width="100%",
),
rx.flex(
rx.dialog.close(
rx.button("Fermer", variant="soft", color_scheme="gray"),
),
gap="0.5rem", justify="end", margin_top="1rem",
),
max_width="720px",
max_height="90vh",
overflow_y="auto",
),
open=RetenueState.modal_open,
on_open_change=RetenueState.set_modal_open,
)

View file

@ -0,0 +1,512 @@
"""Modal et state pour la création d'un avis de sanction depuis la fiche apprenti.
Le PDF est généré automatiquement depuis le template AcroForm
(`data/templates/GF_FO_Avis_de_sanction.pdf`) et les valeurs par défaut
configurées dans Paramètres (texte_sanction, chef_section).
"""
from __future__ import annotations
import json
import os
import sys
from datetime import date as _date
from pathlib import Path
import reflex as rx
from sqlalchemy import select
_ROOT = Path(__file__).resolve().parent.parent.parent
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
from src.db import get_session, Apprenti, ApprentiFiche, Notice # noqa: E402
from src.sanction_pdf import ( # noqa: E402
generate_avis_pdf, _DEFAULT_TEXTE_SANCTION, _DEFAULT_CHEF_SECTION,
)
from src.email_sender import send_email # noqa: E402
from src.user_access import is_class_allowed # noqa: E402
from src.logger import app_log # noqa: E402
from ..state import AuthState
DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data")))
_SETTINGS_FILE = DATA_DIR / "settings.json"
def _load_settings() -> dict:
if _SETTINGS_FILE.exists():
try:
return json.loads(_SETTINGS_FILE.read_text(encoding="utf-8"))
except Exception:
return {}
return {}
# ── State ─────────────────────────────────────────────────────────────────────
class SanctionState(AuthState):
modal_open: bool = False
# Apprenti pré-rempli depuis la fiche
selected_id: int = 0
selected_label: str = ""
sel_classe: str = ""
sel_fiche_email_appr: str = ""
sel_fiche_email_form: str = ""
# Texte de description et chef de section, pré-remplis depuis Paramètres
# (ou valeurs par défaut) à chaque preload — modifiables librement.
texte_description: str = ""
chef_section: str = ""
# Email
email_dest: str = "apprenti"
email_custom: str = ""
# Détection notice existante (pending) pour cet apprenti à la date du jour
has_existing_notice: bool = False
existing_notice_label: str = ""
create_anyway: bool = False
form_error: str = ""
def set_modal_open(self, v: bool):
self.modal_open = v
if not v:
self.form_error = ""
def set_email_dest(self, v: str): self.email_dest = v
def set_email_custom(self, v: str): self.email_custom = v
def set_texte_description(self, v: str): self.texte_description = v
def set_chef_section(self, v: str): self.chef_section = v
def set_create_anyway(self, v: bool): self.create_anyway = v
def preload_apprenti(self, apprenti_id: int, label: str):
self.selected_id = apprenti_id
self.selected_label = label
self.form_error = ""
self.email_dest = "apprenti"
self.email_custom = ""
# Pré-remplit texte + chef de section avec Paramètres ou valeurs par défaut.
settings = _load_settings()
self.texte_description = (
(settings.get("texte_sanction") or "").strip() or _DEFAULT_TEXTE_SANCTION
)
self.chef_section = (
(settings.get("chef_section") or "").strip() or _DEFAULT_CHEF_SECTION
)
self.create_anyway = False
sess = get_session()
try:
ap = sess.get(Apprenti, apprenti_id)
if ap:
self.sel_classe = ap.classe
fiche = ap.fiche
if fiche:
self.sel_fiche_email_appr = fiche.email or ""
self.sel_fiche_email_form = fiche.formateur_email or ""
else:
self.sel_fiche_email_appr = ""
self.sel_fiche_email_form = ""
self._detect_existing_notice(sess, apprenti_id)
finally:
sess.close()
self.modal_open = True
def _detect_existing_notice(self, sess, apprenti_id: int):
"""Détecte si une Notice de sanction pending existe déjà aujourd'hui
pour cet apprenti. Filtre par source pour ne pas confondre avec une
notice de retenue."""
today = _date.today()
existing = sess.execute(
select(Notice)
.where(
Notice.apprenti_id == apprenti_id,
Notice.date_event == today,
Notice.status == "pending",
Notice.source == "sanction",
)
.order_by(Notice.created_at.desc())
).scalars().first()
if existing:
self.has_existing_notice = True
self.existing_notice_label = (
f"{existing.titre or '(sans titre)'}"
f"créée le {existing.created_at.strftime('%d.%m.%Y %H:%M')}"
)
else:
self.has_existing_notice = False
self.existing_notice_label = ""
def _create_notice(self):
"""Crée une Notice en DB (push queue Escada).
Si une notice pending existe déjà pour cet apprenti aujourd'hui et que
l'utilisateur n'a pas coché « Créer quand même », on saute la création.
"""
if not self.selected_id:
return
if self.has_existing_notice and not self.create_anyway:
app_log(
f"[notice] {self.username or '?'} : notice doublon évitée pour "
f"{self.selected_label} (existante : {self.existing_notice_label})"
)
return
remarque = (self.texte_description or "").strip()
user = (self.username or "").strip()
if user:
remarque = f"({user}) {remarque}".rstrip()
remarque = remarque or None
sess = get_session()
try:
sess.add(Notice(
apprenti_id=self.selected_id,
date_event=_date.today(),
titre="Avis de sanction",
remarque=remarque,
type_notice=None,
matiere=None,
source="sanction",
status="pending",
created_by=self.username or None,
))
sess.commit()
app_log(
f"[notice] {self.username or '?'} : création (sanction) pour "
f"{self.selected_label}"
)
except Exception as e:
sess.rollback()
app_log(f"[notice] échec création : {e}")
finally:
sess.close()
def _build_pdf(self) -> bytes | None:
if not self.selected_id:
self.form_error = "Aucun apprenti sélectionné."
return None
if not is_class_allowed(self.username, self.sel_classe):
self.form_error = "Accès refusé pour cette classe."
return None
txt = (self.texte_description or "").strip()
if not txt:
self.form_error = "Le texte de description ne peut pas être vide."
return None
self.form_error = ""
sess = get_session()
try:
return generate_avis_pdf(
sess, self.selected_id,
prof_name=self.name or self.username,
texte_override=txt,
chef_override=(self.chef_section or "").strip() or None,
)
finally:
sess.close()
def _filename(self) -> str:
sess = get_session()
try:
ap = sess.get(Apprenti, self.selected_id)
if not ap:
return "Avis_sanction.pdf"
safe_nom = "".join(c if c.isalnum() else "_" for c in ap.nom)
safe_prenom = "".join(c if c.isalnum() else "_" for c in ap.prenom)
return f"Avis_sanction_{safe_nom}_{safe_prenom}.pdf"
finally:
sess.close()
def download_pdf(self):
data = self._build_pdf()
if data is None:
return rx.toast.error(self.form_error or "Impossible de générer le PDF.")
app_log(
f"[sanction] {self.username or '?'} : avis téléchargé pour "
f"{self.selected_label}"
)
self._create_notice()
self.modal_open = False
return [
rx.download(data=data, filename=self._filename()),
rx.toast.success("Avis de sanction téléchargé — notice ajoutée à la file Escada"),
]
def send_email_action(self):
data = self._build_pdf()
if data is None:
return rx.toast.error(self.form_error or "Impossible de générer le PDF.")
if self.email_dest == "apprenti":
to = self.sel_fiche_email_appr
elif self.email_dest == "formateur":
to = self.sel_fiche_email_form
else:
to = self.email_custom.strip()
if not to or "@" not in to:
return rx.toast.error("Adresse email invalide ou manquante.")
s = _load_settings()
smtp_host = s.get("smtp_host")
smtp_port = int(s.get("smtp_port") or 587)
smtp_login = s.get("smtp_login")
smtp_password = s.get("smtp_password")
smtp_sender = s.get("smtp_sender")
if not (smtp_host and smtp_login and smtp_password and smtp_sender):
return rx.toast.error("Configuration SMTP incomplète (Paramètres).")
subject = f"Avis de sanction — {self.selected_label}"
body = (
f"Bonjour,\n\nVeuillez trouver en pièce jointe l'avis de sanction "
f"concernant {self.selected_label}.\n\nCordialement,\n"
f"{self.name or self.username}\n"
)
try:
send_email(
smtp_host=smtp_host, smtp_port=smtp_port,
smtp_login=smtp_login, smtp_password=smtp_password,
smtp_sender=smtp_sender,
to_email=to, subject=subject, body=body,
attachments=[(data, self._filename())],
)
except Exception as e:
return rx.toast.error(f"Échec d'envoi : {e}")
app_log(
f"[sanction] {self.username or '?'} : avis envoyé à {to} pour "
f"{self.selected_label}"
)
self._create_notice()
self.modal_open = False
return rx.toast.success(
f"Avis de sanction envoyé à {to} — notice ajoutée à la file Escada"
)
# ── UI ────────────────────────────────────────────────────────────────────────
def _texte_section() -> rx.Component:
"""Texte de description + chef de section, pré-remplis depuis Paramètres
(ou fallback). L'utilisateur les modifie librement avant génération."""
return rx.box(
rx.vstack(
rx.flex(
rx.icon("file-text", size=16, color="var(--text-strong)"),
rx.text("Contenu de l'avis", size="3", weight="bold", color="var(--text-strong)"),
gap="0.5rem", align="center",
),
rx.divider(),
rx.text(
"Pré-remplis depuis les Paramètres. Modifiables avant génération.",
size="1", color="var(--gray-11)",
),
rx.vstack(
rx.text("Texte de description", size="2", weight="medium", color="var(--gray-11)"),
rx.text_area(
value=SanctionState.texte_description,
on_change=SanctionState.set_texte_description,
rows="8",
width="100%",
),
spacing="1", width="100%",
),
rx.vstack(
rx.text("Chef de section", size="2", weight="medium", color="var(--gray-11)"),
rx.input(
value=SanctionState.chef_section,
on_change=SanctionState.set_chef_section,
width="100%",
),
spacing="1", width="100%",
),
spacing="3", width="100%",
),
padding="1.25rem",
background_color="var(--surface)",
border_radius="8px",
border="1px solid var(--border)",
width="100%",
)
def _duplicate_notice_banner() -> rx.Component:
"""Bannière jaune si une notice pending existe déjà aujourd'hui (avec checkbox override)."""
return rx.cond(
SanctionState.has_existing_notice,
rx.box(
rx.vstack(
rx.flex(
rx.icon("triangle-alert", size=16, color="#92400e"),
rx.text(
"Une notice est déjà en attente aujourd'hui pour cet apprenti :",
size="2", weight="medium", color="#92400e",
),
gap="0.5rem", align="center",
),
rx.text(
SanctionState.existing_notice_label,
size="1", color="#78350f",
),
rx.flex(
rx.checkbox(
checked=SanctionState.create_anyway,
on_change=SanctionState.set_create_anyway,
size="2",
),
rx.text(
"Créer quand même une nouvelle notice",
size="2", color="#78350f",
),
gap="0.5rem", align="center",
),
spacing="2", width="100%",
),
padding="0.75rem 1rem",
background_color="#fef3c7",
border="1px solid #fcd34d",
border_radius="6px",
width="100%",
),
rx.fragment(),
)
def _email_section() -> rx.Component:
return rx.box(
rx.vstack(
rx.flex(
rx.icon("mail", size=16, color="var(--text-strong)"),
rx.text("Envoyer par email", size="3", weight="bold", color="var(--text-strong)"),
gap="0.5rem", align="center",
),
rx.divider(),
rx.text("Destinataire", size="2", weight="medium", color="var(--gray-11)"),
rx.radio_group.root(
rx.vstack(
rx.radio_group.item(
rx.cond(
SanctionState.sel_fiche_email_appr != "",
rx.text("Apprenti — ", SanctionState.sel_fiche_email_appr, size="2"),
rx.text("Apprenti (email inconnu)", size="2", color="var(--gray-9)"),
),
value="apprenti",
disabled=SanctionState.sel_fiche_email_appr == "",
),
rx.radio_group.item(
rx.cond(
SanctionState.sel_fiche_email_form != "",
rx.text("Formateur — ", SanctionState.sel_fiche_email_form, size="2"),
rx.text("Formateur (email inconnu)", size="2", color="var(--gray-9)"),
),
value="formateur",
disabled=SanctionState.sel_fiche_email_form == "",
),
rx.radio_group.item(
rx.text("Autre adresse", size="2"),
value="autre",
),
spacing="2",
),
value=SanctionState.email_dest,
on_change=SanctionState.set_email_dest,
),
rx.cond(
SanctionState.email_dest == "autre",
rx.input(
placeholder="email@domaine.ch",
value=SanctionState.email_custom,
on_change=SanctionState.set_email_custom,
type="email",
width="100%",
),
rx.fragment(),
),
rx.button(
rx.icon("send", size=16),
"Envoyer par email",
on_click=SanctionState.send_email_action,
color_scheme="blue", size="2",
disabled=SanctionState.selected_id == 0,
),
spacing="3", width="100%",
),
padding="1.25rem",
background_color="var(--surface)",
border_radius="8px",
border="1px solid var(--border)",
width="100%",
)
def sanction_modal() -> rx.Component:
"""Modal pour créer un avis de sanction.
L'avis de sanction n'a pas de champ à remplir côté UI : tout est pré-rempli
automatiquement (texte de description et chef de section depuis Paramètres,
adresse/entreprise depuis la fiche apprenti). L'utilisateur télécharge ou
envoie l'avis par email.
"""
return rx.dialog.root(
rx.dialog.content(
rx.dialog.title("Créer un avis de sanction"),
rx.dialog.description(
"Génère l'avis de sanction officiel à partir du template EPTM.",
size="2", color="var(--gray-11)",
),
rx.vstack(
# Bannière apprenti
rx.box(
rx.flex(
rx.icon("user", size=16, color="#c62828"),
rx.text(SanctionState.selected_label, size="2", weight="medium", color="var(--text-strong)"),
gap="0.5rem", align="center",
),
padding="0.5rem 0.75rem",
background_color="#fff5f5",
border_radius="6px",
border="1px solid #ffcdd2",
),
rx.callout.root(
rx.callout.icon(rx.icon("info", size=16)),
rx.callout.text(
"L'adresse et le nom de l'entreprise proviennent de la fiche "
"apprenti Escada. Les valeurs par défaut sont configurables dans ",
rx.link("Paramètres", href="/params", color="var(--brand-accent)"),
".",
),
color_scheme="blue", variant="soft", size="1",
),
_texte_section(),
_duplicate_notice_banner(),
rx.cond(
SanctionState.form_error != "",
rx.callout.root(
rx.callout.icon(rx.icon("triangle-alert", size=16)),
rx.callout.text(SanctionState.form_error),
color_scheme="red", variant="soft", size="1",
),
rx.fragment(),
),
rx.button(
rx.icon("file-down", size=16),
"Télécharger l'avis de sanction",
on_click=SanctionState.download_pdf,
color_scheme="red", size="2",
disabled=SanctionState.selected_id == 0,
),
_email_section(),
spacing="3", width="100%",
),
rx.flex(
rx.dialog.close(
rx.button("Fermer", variant="soft", color_scheme="gray"),
),
gap="0.5rem", justify="end", margin_top="1rem",
),
max_width="640px",
max_height="90vh",
overflow_y="auto",
),
open=SanctionState.modal_open,
on_open_change=SanctionState.set_modal_open,
)

View file

@ -245,6 +245,30 @@ class UsersState(AuthState):
self.totp_ok = True
app_log(f"[users] {self.username} : 2FA réinitialisé pour {self.edit_target}")
def reset_access(self):
"""Efface tous les droits du user : allowed_classes=[] + creds Escada."""
cfg = _load_auth()
users = cfg.get("credentials", {}).get("usernames", {})
uname = self.edit_target
if uname not in users:
self.access_error = "Utilisateur introuvable."
return
users[uname]["allowed_classes"] = []
users[uname].pop("escada_username", None)
users[uname].pop("escada_password", None)
_save_auth(cfg)
# Sync l'état local de l'édition
self.edit_restrict = True
self.edit_classes = []
self.access_ok = True
self.access_error = ""
self._refresh_list()
app_log(
f"[users] {self.username} : RÉINITIALISATION accès pour {uname} "
f"(allowed_classes vidée + creds Escada effacés)"
)
return rx.toast.success(f"Droits réinitialisés pour {uname}")
async def handle_avatar_upload(self, files: list[rx.UploadFile]):
if not files:
return
@ -570,7 +594,7 @@ def _user_row(user: dict) -> rx.Component:
rx.button(
rx.cond(is_selected, rx.icon("chevron-up", size=14), rx.icon("pencil", size=14)),
rx.cond(is_selected, "Fermer", "Éditer"),
on_click=UsersState.select_user(user["username"]),
on_click=UsersState.select_user(user["username"]).stop_propagation,
variant=rx.cond(is_selected, "solid", "outline"),
color_scheme="blue",
size="1",
@ -591,6 +615,10 @@ def _user_row(user: dict) -> rx.Component:
border_radius="6px",
border=rx.cond(is_selected, "1px solid var(--blue-6)", "1px solid #e0e0e0"),
width="100%",
# Click sur la row entière ouvre / ferme le panneau d'édition.
on_click=UsersState.select_user(user["username"]),
cursor="pointer",
_hover={"background_color": rx.cond(is_selected, "var(--blue-2)", "var(--surface-hover)")},
)
@ -770,7 +798,7 @@ def _classes_multi_select() -> rx.Component:
padding="0.45rem 0.6rem",
border="1px solid var(--gray-7)",
border_radius="6px",
background_color="white",
background_color="var(--surface)",
cursor="pointer",
width="100%",
),
@ -882,6 +910,40 @@ def _edit_panel_access() -> rx.Component:
on_click=UsersState.save_access,
color_scheme="blue", size="2",
),
rx.alert_dialog.root(
rx.alert_dialog.trigger(
rx.button(
rx.icon("trash-2", size=14),
"Réinitialiser les droits",
variant="outline", color_scheme="red", size="2",
),
),
rx.alert_dialog.content(
rx.alert_dialog.title(
"Réinitialiser les droits de ", UsersState.edit_target, " ?",
),
rx.alert_dialog.description(
"Cette action efface allowed_classes (= aucun accès) "
"ET les identifiants Escada stockés. L'utilisateur devra "
"refaire un enrôlement complet depuis sa page profil.",
size="2",
),
rx.flex(
rx.alert_dialog.cancel(
rx.button("Annuler", variant="soft", color_scheme="gray"),
),
rx.alert_dialog.action(
rx.button(
"Réinitialiser",
color_scheme="red",
on_click=UsersState.reset_access,
),
),
gap="0.5rem", justify="end", margin_top="1rem",
),
max_width="480px",
),
),
_ok_callout(UsersState.access_ok, "Accès mis à jour."),
_err_callout(UsersState.access_error),
spacing="3", align="center", flex_wrap="wrap",
@ -1064,9 +1126,9 @@ def _add_user_section() -> rx.Component:
spacing="3", width="100%",
),
padding="1.25rem",
background_color="white",
background_color="var(--surface)",
border_radius="8px",
border="1px solid #e0e0e0",
border="1px solid var(--border)",
width="100%",
)
@ -1085,9 +1147,9 @@ def users_page() -> rx.Component:
spacing="2", width="100%",
),
padding="1.25rem",
background_color="white",
background_color="var(--surface)",
border_radius="8px",
border="1px solid #e0e0e0",
border="1px solid var(--border)",
width="100%",
),
_edit_panel(),

View file

@ -1,3 +1,6 @@
import subprocess
from pathlib import Path
import reflex as rx
from .state import AuthState
from .components import scan_docs
@ -6,24 +9,63 @@ from .components import scan_docs
# détecter de nouveaux fichiers).
_DOC_SECTIONS = scan_docs()
def _resolve_version() -> str:
"""Renvoie le dernier tag git, ou le contenu de data/VERSION en fallback.
Lu une fois au démarrage du module un restart suffit pour refléter un
nouveau tag (utile en prod le .git du container peut être figé)."""
root = Path(__file__).resolve().parent.parent
try:
r = subprocess.run(
["git", "describe", "--tags", "--abbrev=0"],
cwd=root, capture_output=True, text=True, timeout=3,
)
if r.returncode == 0 and r.stdout.strip():
return r.stdout.strip()
except Exception:
pass
for candidate in (root / "data" / "VERSION", root / "VERSION"):
try:
return candidate.read_text(encoding="utf-8").strip()
except Exception:
continue
return ""
_VERSION = _resolve_version()
def _version_badge() -> rx.Component:
"""Petit libellé centré affichant la version sous forme 'v<tag>'.
Renvoie un fragment vide si aucune version n'est disponible."""
if not _VERSION:
return rx.fragment()
return rx.box(
rx.text(
"v" + _VERSION, size="1", color=_TEXT_MUTED,
text_align="center", width="100%",
),
padding_y="0.25rem", width="100%",
)
FULL_W = "240px"
RAIL_W = "68px"
TOPBAR_H = "56px"
# EPTM brand palette (logo: noir #000 + rouge #e00010)
_BG = "#f8f9fa" # sidebar background (light)
_BORDER = "#e5e7eb" # subtle separator
_TEXT = "#4b5563" # inactive text
_TEXT_MUTED = "#9ca3af" # muted labels
_ACTIVE_BG = "rgba(220, 0, 14, 0.18)" # EPTM red tint
_ACTIVE_CLR = "#ff4a54" # bright red on dark bg
_HOVER_BG = "#f3f4f6"
_USER_BG = "#f3f4f6" # slightly darker user section
# Sidebar palette — utilise les tokens de marque (cf. responsive.css).
_BG = "var(--surface-muted)" # sidebar background
_BORDER = "var(--border-soft)" # subtle separator
_TEXT = "var(--text-soft)" # inactive text
_TEXT_MUTED = "var(--text-muted)" # muted labels
_HOVER_BG = "var(--surface-hover)"
_USER_BG = "var(--surface-hover)" # slightly darker user section
_ACTIVE_BG = "var(--brand-primary-tint)"
_ACTIVE_CLR = "var(--brand-primary-light)"
_PAGES = [
("Tableau de bord", "/accueil", "layout-dashboard"),
("Apprentis", "/fiche", "user"),
("Classes", "/classe", "users"),
("Apprentis", "/fiche", "user"),
]
_ADMIN_PAGES = [
@ -32,13 +74,34 @@ _ADMIN_PAGES = [
("Logs", "/logs", "file-text"),
("Utilisateurs", "/users", "user-cog"),
("Paramètres", "/params", "settings"),
("Feedback", "/feedback", "message-square"),
("Purger classe","/purge", "trash-2"),
]
def _href_badge_count(href: str):
"""Retourne le compteur de badge pour un href donné (None si pas de badge).
Sert à colorer l'icône de nav en rouge quand il y a des messages non lus."""
if href == "/feedback":
return AuthState.feedback_new_count
return None
def _nav_full(label: str, href: str, icon_name: str, close_menu: bool = False) -> rx.Component:
is_active = AuthState.router.page.path == href
click_handler = AuthState.close_mobile_menu if close_menu else None
badge_cnt = _href_badge_count(href)
# Si on a un badge actif, l'icône passe en rouge + pulse au lieu de la
# couleur normale (inactive ou active).
if badge_cnt is not None:
icon_color = rx.cond(
badge_cnt > 0, "#dc2626",
rx.cond(is_active, _ACTIVE_CLR, _TEXT),
)
icon_class = rx.cond(badge_cnt > 0, "pulse-badge", "")
else:
icon_color = rx.cond(is_active, _ACTIVE_CLR, _TEXT)
icon_class = ""
return rx.link(
rx.hstack(
rx.box(
@ -53,7 +116,8 @@ def _nav_full(label: str, href: str, icon_name: str, close_menu: bool = False) -
),
rx.icon(
icon_name, size=17,
color=rx.cond(is_active, _ACTIVE_CLR, _TEXT),
color=icon_color,
class_name=icon_class,
flex_shrink="0",
),
rx.text(
@ -83,11 +147,20 @@ def _nav_full(label: str, href: str, icon_name: str, close_menu: bool = False) -
def _nav_rail(label: str, href: str, icon_name: str) -> rx.Component:
is_active = AuthState.router.page.path == href
badge_cnt = _href_badge_count(href)
if badge_cnt is not None:
icon_color = rx.cond(
badge_cnt > 0, "#dc2626",
rx.cond(is_active, _ACTIVE_CLR, _TEXT),
)
icon_class = rx.cond(badge_cnt > 0, "pulse-badge", "")
else:
icon_color = rx.cond(is_active, _ACTIVE_CLR, _TEXT)
icon_class = ""
return rx.tooltip(
rx.link(
rx.box(
rx.icon(icon_name, size=20,
color=rx.cond(is_active, _ACTIVE_CLR, _TEXT)),
rx.icon(icon_name, size=20, color=icon_color, class_name=icon_class),
width="100%",
display="flex",
align_items="center",
@ -402,6 +475,9 @@ def sidebar() -> rx.Component:
_doc_section(),
rx.spacer(),
# Version (dernier tag git) — au-dessus du profil
_version_badge(),
# User
rx.box(height="1px", width="100%", background_color=_BORDER),
rx.box(
@ -525,6 +601,9 @@ _KEYBOARD_SHORTCUTS_JS = """
def layout(content: rx.Component) -> rx.Component:
# Imports locaux pour éviter les cycles sidebar ↔ pages.*
from .pages.feedback import feedback_widget
from .pages.profile import enroll_required_dialog
return rx.box(
sidebar(),
_mobile_topbar(),
@ -536,11 +615,15 @@ def layout(content: rx.Component) -> rx.Component:
"content-area",
),
padding=rx.cond(AuthState.sidebar_collapsed, "1rem", "1.5rem"),
background_color="var(--gray-2)",
# Fond de page : doit être plus sombre que les cartes en mode dark
# (--surface = gray-2) pour assurer la séparation visuelle.
background_color="var(--gray-1)",
overflow_x="hidden",
transition="margin-left 0.22s ease, width 0.22s ease",
box_sizing="border-box",
),
feedback_widget(),
enroll_required_dialog(),
rx.script(_KEYBOARD_SHORTCUTS_JS),
width="100%",
height="100vh",

View file

@ -43,6 +43,9 @@ class AuthState(rx.State):
name: str = rx.LocalStorage("", sync=True)
role: str = rx.LocalStorage("user", sync=True)
photo_url: str = rx.LocalStorage("", sync=True)
# Thème de couleur de l'interface : "eptm" (défaut), "bleu", "indigo", "vert".
# Appliqué via data-theme sur <html> côté client.
theme: str = rx.LocalStorage("eptm", sync=True)
# In-memory only (login form, transient UI state)
login_user: str = ""
@ -62,6 +65,20 @@ class AuthState(rx.State):
mobile_menu_open: bool = False
admin_expanded: bool = True
doc_expanded: bool = False
# Compteur de messages feedback "new" (admin uniquement)
feedback_new_count: int = 0
# Flag : True si le user (non-admin) n'a aucune classe accordée et doit
# passer par l'enrôlement Escada. Auto-set par check_auth.
must_enroll: bool = False
# L'user a fermé le popup manuellement (pour cette session) — on ne le
# réaffiche pas automatiquement même si must_enroll reste True.
enroll_dismissed: bool = False
# Données de l'utilisateur connecté pour la section enrôlement Escada
# (rechargées à chaque check_auth pour éviter la pollution entre sessions).
my_classes: list[str] = []
classes_unknown: list[str] = []
escada_username: str = ""
escada_has_password: bool = False
@rx.var
def authenticated(self) -> bool:
@ -119,6 +136,80 @@ class AuthState(rx.State):
self._clear_session()
return rx.redirect("/login")
self.photo_url = users[self.username].get("avatar_url", "")
# Re-synchronise le thème depuis auth.yaml (au cas où changé sur un autre device).
stored_theme = users[self.username].get("theme") or "eptm"
if stored_theme != self.theme:
self.theme = stored_theme
# Compteur feedback (admin uniquement) — pour le badge sidebar
self._refresh_feedback_count()
# Recharge les données d'enrôlement depuis auth.yaml à chaque page —
# évite la pollution si un autre user était dans la session avant.
u = users.get(self.username) or {}
self.my_classes = list(u.get("allowed_classes") or [])
self.escada_username = u.get("escada_username") or ""
self.escada_has_password = bool(u.get("escada_password"))
self.classes_unknown = [] # reset l'avertissement à chaque page
# Détecte si l'user doit s'enrôler (non-admin sans classes accordées)
if u.get("role") == "admin":
self.must_enroll = False
else:
self.must_enroll = not self.my_classes
return self._apply_theme_script(self.theme)
def dismiss_enroll(self):
"""Ferme le popup d'enrôlement pour la session courante."""
self.enroll_dismissed = True
def _refresh_feedback_count(self):
if self.role != "admin":
self.feedback_new_count = 0
return
try:
from src.db import get_session, FeedbackMessage
from sqlalchemy import select, func
sess = get_session()
try:
self.feedback_new_count = sess.execute(
select(func.count(FeedbackMessage.id))
.where(FeedbackMessage.status == "new")
).scalar() or 0
finally:
sess.close()
except Exception:
self.feedback_new_count = 0
@staticmethod
def _apply_theme_script(theme: str):
"""Script JS qui set data-theme + color-scheme sur <html> immédiatement
(sans attendre re-render). color-scheme empêche le browser de bascule
dark sur OS dark mode."""
safe = "".join(c for c in (theme or "eptm") if c.isalnum() or c in "-_")
scheme = "dark" if safe == "sombre" else "light"
if not safe or safe == "eptm":
return rx.call_script(
"document.documentElement.removeAttribute('data-theme');"
"document.body && document.body.removeAttribute('data-theme');"
f"document.documentElement.style.colorScheme = '{scheme}';"
)
return rx.call_script(
f"document.documentElement.setAttribute('data-theme', '{safe}');"
f"document.body && document.body.setAttribute('data-theme', '{safe}');"
f"document.documentElement.style.colorScheme = '{scheme}';"
)
def set_theme(self, value: str):
"""Change le thème de couleur (persiste en LocalStorage et auth.yaml)."""
if value not in ("eptm", "bleu", "indigo", "vert", "sombre"):
value = "eptm"
self.theme = value
# Persister dans auth.yaml pour synchronisation multi-device.
if self.username:
cfg = _load_auth_full()
users = cfg.get("credentials", {}).get("usernames", {})
if self.username in users:
users[self.username]["theme"] = value
_save_auth_full(cfg)
return self._apply_theme_script(value)
def handle_login(self, form_data: dict | None = None):
self.login_error = ""
@ -208,8 +299,12 @@ class AuthState(rx.State):
self.name = user.get("name", self.totp_pending_user)
self.role = user.get("role", "user")
self.photo_url = user.get("avatar_url", "")
self.theme = user.get("theme") or "eptm"
# Reset le flag de dismiss du popup d'enrôlement à chaque login —
# si l'user n'a toujours pas de classes, le popup doit ré-apparaître.
self.enroll_dismissed = False
self._reset_totp_flow()
return rx.redirect("/accueil")
return [self._apply_theme_script(self.theme), rx.redirect("/accueil")]
def cancel_totp(self):
"""Annule le flow 2FA et revient à l'étape password."""
@ -232,6 +327,13 @@ class AuthState(rx.State):
self.name = ""
self.role = "user"
self.photo_url = ""
self.theme = "eptm"
self.must_enroll = False
self.enroll_dismissed = False
self.my_classes = []
self.classes_unknown = []
self.escada_username = ""
self.escada_has_password = False
self.login_user = ""
self.login_pass = ""
self.login_error = ""

View file

@ -1,3 +1,4 @@
reflex==0.9.2
markdown==3.10.2
pikepdf==10.5.1

View file

@ -11,7 +11,9 @@ config = rx.Config(
plugins=[
rx.plugins.RadixThemesPlugin(
theme=rx.theme(
appearance="inherit",
# Force le mode clair (ignore dark mode OS). Les thèmes de
# couleur user sont gérés via tokens CSS dans responsive.css.
appearance="light",
accent_color="red",
radius="medium",
scaling="95%",

View file

@ -52,6 +52,8 @@ except Exception:
SCRIPT_SYNC = _ROOT / "scripts" / "sync_esacada.py"
SCRIPT_PUSH = _ROOT / "scripts" / "push_to_escada.py"
SCRIPT_PUSH_NOTICES = _ROOT / "scripts" / "push_notices.py"
SCRIPT_PULL_NOTICES = _ROOT / "scripts" / "pull_notices.py"
DATA_DIR = _ROOT / "data"
# Marqueur écrit par run_imports.py à la fin des imports en DB
@ -76,21 +78,15 @@ def _is_due(job: CronJob, now: datetime) -> bool:
last = job.last_run_at
if job.schedule_kind == "interval":
# schedule_value = nb minutes
try:
minutes = int(job.schedule_value)
except (TypeError, ValueError):
return False
if minutes < 1:
return False
if last is None:
if job.schedule_kind == "daily_multi":
# schedule_value = "HH:MM,HH:MM,HH:MM,..." (plusieurs heures par jour)
for hhmm in (job.schedule_value or "").split(","):
hhmm = hhmm.strip()
if not hhmm:
continue
if _due_time_of_day(hhmm, last, now):
return True
return (now - last).total_seconds() >= minutes * 60
if job.schedule_kind == "daily":
# schedule_value = "HH:MM"
return _due_time_of_day(job.schedule_value, last, now)
return False
if job.schedule_kind == "weekly":
# schedule_value = "MON,WED,FRI:HH:MM"
@ -178,6 +174,30 @@ def _build_push_cmd(job: CronJob) -> list[str]:
return [sys.executable, str(SCRIPT_PUSH)]
def _job_classes(job: CronJob) -> list[str]:
"""Résout la liste de classes du job (ALL → toutes les classes en DB)."""
if (job.classes_json or "").strip().upper() == "ALL":
from sqlalchemy import text as _text
sess = get_session()
try:
rows = sess.execute(_text(
"SELECT DISTINCT classe FROM apprentis WHERE classe IS NOT NULL "
"AND classe <> '' ORDER BY classe"
)).all()
return [r[0] for r in rows]
finally:
sess.close()
try:
data = json.loads(job.classes_json or "[]")
return [c for c in data if isinstance(c, str) and c.strip()]
except Exception:
return []
def _build_pull_notices_cmd(job: CronJob) -> list[str]:
return [sys.executable, str(SCRIPT_PULL_NOTICES), *_job_classes(job)]
def _wait_for_run_imports(log_fp, mtime_before: float) -> tuple[bool, str, dict]:
"""Après que sync_esacada.py a fini, run_imports.py tourne en sous-process
détaché. Attend que sync_last_result.json soit mis à jour, puis log les
@ -317,28 +337,47 @@ def run_job(job: CronJob, sess) -> None:
fp.write(f"\n=== Job #{job.id} '{job.name}' — démarré {started.isoformat(timespec='seconds')} ===\n")
fp.write(f"task_kind={job.task_kind} classes={job.classes_json}\n")
# task_kind ∈ {push, sync, push_then_sync}.
# Les flags sync_abs / sync_bn / sync_notes / sync_fiches / sync_notices
# déterminent quels scripts sont exécutés à chaque étape.
sync_any_abs_bn = (
job.sync_abs or job.sync_bn or job.sync_notes or job.sync_fiches
)
push_step: list[tuple[str, list[str]]] = []
if job.sync_abs:
push_step.append(("Push absences", _build_push_cmd(job)))
if job.sync_notices:
push_step.append(("Push notices", [sys.executable, str(SCRIPT_PUSH_NOTICES)]))
sync_step: list[tuple[str, list[str]]] = []
if sync_any_abs_bn:
sync_step.append(("Sync absences", _build_sync_cmd(job)))
if job.sync_notices:
sync_step.append(("Sync notices", _build_pull_notices_cmd(job)))
steps: list[tuple[str, list[str]]] = []
if job.task_kind == "push":
steps = [("Push Escada", _build_push_cmd(job))]
steps = push_step
elif job.task_kind == "sync":
steps = [("Sync Escada", _build_sync_cmd(job))]
steps = sync_step
elif job.task_kind == "push_then_sync":
steps = [
("Push Escada", _build_push_cmd(job)),
("Sync Escada", _build_sync_cmd(job)),
]
steps = push_step + sync_step
else:
fp.write(f"[error] task_kind inconnu : {job.task_kind}\n")
overall_rc = 99
final_msg = f"task_kind invalide : {job.task_kind}"
if not steps and overall_rc == 0:
fp.write("[warn] aucune donnée sélectionnée — rien à faire\n")
final_msg = "Aucune donnée sélectionnée (Absences/Notices/etc.)"
for title, cmd in steps:
# Capturer mtime du marqueur run_imports AVANT le sync
# (utilisé après pour détecter la fin de run_imports.py)
is_sync = title.startswith("Sync")
# Capturer mtime du marqueur run_imports AVANT le sync absences
# (run_imports.py est uniquement déclenché par sync_esacada.py,
# pas par pull_notices.py).
is_sync_abs = title == "Sync absences"
mtime_before = (
RUN_IMPORTS_RESULT.stat().st_mtime
if is_sync and RUN_IMPORTS_RESULT.exists() else 0.0
if is_sync_abs and RUN_IMPORTS_RESULT.exists() else 0.0
)
rc, pid = _run_step(cmd, fp, title)
@ -348,8 +387,8 @@ def run_job(job: CronJob, sess) -> None:
final_msg = f"{title} a échoué (code {rc})"
break
# Si c'était une étape sync, attendre que run_imports termine
if is_sync:
# Si c'était l'étape sync absences, attendre que run_imports termine
if is_sync_abs:
imports_ok, imports_msg, imports_result = _wait_for_run_imports(fp, mtime_before)
if not imports_ok:
overall_rc = 2

View file

@ -0,0 +1,278 @@
#!/usr/bin/env python3
"""Scrape la liste des classes accessibles à un utilisateur dans Escadaweb.
Usage :
python scripts/fetch_user_classes.py <username>
Lit `escada_username` et `escada_password` depuis `auth.yaml` pour le user.
Le code TOTP (6 chiffres) est lu depuis la variable d'environnement TOTP_CODE.
Écrit le résultat dans data/sync_user_classes_<username>.json sous la forme :
{"ok": true, "classes": [...], "duration_s": 12.3}
ou en cas d'échec :
{"ok": false, "error": "...", "classes": []}
Le browser tourne en mode headless. Profil Chromium éphémère (pas de
persistance entre sessions chaque user a sa propre session indépendante
de celle de l'admin).
"""
from __future__ import annotations
import json
import os
import sys
import time
import tempfile
from pathlib import Path
_ROOT = Path(__file__).resolve().parent.parent
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
import yaml
from playwright.sync_api import sync_playwright, TimeoutError as PWTimeout, Error as PWError
from scripts.sync_esacada import (
BASE_URL, LEHRPERSONEN_URL, CLASSES_URL,
_ensure_french_language, _scrape_classes,
)
from src.logger import app_log
DATA_DIR = _ROOT / "data"
AUTH_FILE = DATA_DIR / "auth.yaml"
_USERNAME = "" # set par main() pour préfixer les logs
def _log(msg: str) -> None:
from datetime import datetime
line = f"[{datetime.now().strftime('%H:%M:%S')}] {msg}"
print(line, flush=True)
# Log aussi dans operations.log (visible en live depuis /logs)
try:
app_log(f"[fetch_classes:{_USERNAME or '?'}] {msg}")
except Exception:
pass
def _load_user_creds(username: str) -> tuple[str, str]:
"""Lit (escada_username, escada_password) depuis auth.yaml."""
if not AUTH_FILE.exists():
raise RuntimeError("auth.yaml introuvable")
cfg = yaml.safe_load(AUTH_FILE.read_text(encoding="utf-8")) or {}
user = cfg.get("credentials", {}).get("usernames", {}).get(username)
if not user:
raise RuntimeError(f"Utilisateur {username!r} introuvable dans auth.yaml")
e_user = (user.get("escada_username") or "").strip()
e_pass = (user.get("escada_password") or "").strip()
if not e_user or not e_pass:
raise RuntimeError(
f"Identifiants Escada manquants pour {username!r} "
"(escada_username / escada_password)"
)
return e_user, e_pass
def _fill_login(page, escada_user: str, escada_pass: str) -> bool:
"""Remplit le formulaire Keycloak avec les creds passés."""
try:
page.wait_for_selector("input#username", state="visible", timeout=5_000)
page.wait_for_selector("input#password", state="visible", timeout=2_000)
_log(" [LOGIN] Formulaire Keycloak détecté")
page.locator("input#username").fill(escada_user)
page.locator("input#password").fill(escada_pass)
try:
page.locator("input#kc-login").click(timeout=2_000)
except Exception:
page.locator("input#password").press("Enter")
return True
except Exception as e:
_log(f" [LOGIN] ERR : {e}")
return False
def _fill_totp(page, code: str) -> bool:
"""Saisie du code TOTP via JS (le champ est caché par CSS)."""
_log(f" [2FA] Saisie du code")
try:
result = page.evaluate("""(code) => {
const inp = document.querySelector('#otp')
|| document.querySelector('[name="otp"]')
|| document.querySelector('[autocomplete="one-time-code"]')
|| document.querySelector('input[type="text"]:not([type="hidden"])');
if (!inp) return 'not_found';
inp.value = code;
inp.dispatchEvent(new Event('input', {bubbles: true}));
inp.dispatchEvent(new Event('change', {bubbles: true}));
return 'filled';
}""", code)
if result != "filled":
_log(f" [2FA] champ introuvable ({result})")
return False
submitted = page.evaluate("""() => {
const btn = document.querySelector('input[type="submit"]')
|| document.querySelector('button[type="submit"]');
if (btn) { btn.click(); return 'clicked'; }
const form = document.querySelector('form');
if (form) { form.submit(); return 'submitted'; }
return 'no_submit';
}""")
return submitted in ("clicked", "submitted")
except Exception as e:
_log(f" [2FA] err : {e}")
return False
def fetch_classes(username: str, totp_code: str) -> dict:
"""Fait login + scrape ViewKlassen et retourne le résultat."""
e_user, e_pass = _load_user_creds(username)
t_start = time.time()
profile_dir = tempfile.mkdtemp(prefix=f"escada_{username}_")
pw = sync_playwright().start()
try:
ctx = pw.chromium.launch_persistent_context(
profile_dir,
headless=True,
args=["--disable-popup-blocking"],
)
page = ctx.pages[0] if ctx.pages else ctx.new_page()
try:
_log(f"GOTO {CLASSES_URL}")
page.goto(CLASSES_URL)
# Boucle login + 2FA (timeout 90s)
deadline = time.time() + 90
login_done = False
totp_done = False
last_url = ""
stuck_counter = 0
while time.time() < deadline:
cur = page.url.lower()
if page.url != last_url:
_log(f" url: {page.url[:120]}")
last_url = page.url
stuck_counter = 0
if "viewklassen" in cur:
_log("LOGIN_OK")
break
# Si on est sur une page hors flux (Timeout.aspx, root EPTM,
# erreur DevExpress), forcer un goto vers Lehrpersonen pour
# déclencher le redirect Keycloak.
if not any(k in cur for k in (
"edusso", "login", "authenticate", "logon", "otp",
"lehrpersonen/viewklassen",
)):
_log(f" hors flux ({cur[:80]}…) → goto Lehrpersonen")
try:
page.goto(LEHRPERSONEN_URL, timeout=15_000)
except (PWTimeout, PWError) as _e:
_log(f" goto err : {_e}")
page.wait_for_timeout(1_000)
continue
if not login_done:
if _fill_login(page, e_user, e_pass):
login_done = True
_log(" login submitted, wait for redirect…")
try:
page.wait_for_load_state("networkidle", timeout=8_000)
except (PWTimeout, PWError):
pass
if not totp_done and (
"authenticate" in cur
or "otp" in cur
or page.locator("input#otp").count() > 0
):
if _fill_totp(page, totp_code):
totp_done = True
_log(" otp submitted, wait for redirect to ViewKlassen…")
try:
page.wait_for_url("**ViewKlassen**", timeout=15_000)
except (PWTimeout, PWError):
_log(f" wait_for_url failed, url={page.url[:120]}")
page.wait_for_timeout(800)
stuck_counter += 1
# Sortie anticipée si totp validé mais redirect ne vient pas
# (probablement code OTP invalide ou expiré)
if totp_done and stuck_counter > 15 and "viewklassen" not in cur:
_log(f" TOTP submitted mais pas de redirect → code peut-être invalide")
break
else:
# Diagnostic supplémentaire
_log(f"TIMEOUT url={page.url[:120]} login_done={login_done} totp_done={totp_done}")
try:
# Pages d'erreur Keycloak fréquentes
body_txt = page.evaluate("() => (document.body && document.body.innerText || '').slice(0, 500)")
_log(f" body_preview: {body_txt!r}")
except Exception:
pass
raise RuntimeError(
f"Timeout login (90s) — login_done={login_done} totp_done={totp_done} url={page.url[:80]}"
)
# Force le français + scrape
_ensure_french_language(page)
page.goto(CLASSES_URL, wait_until="domcontentloaded", timeout=15_000)
try:
page.wait_for_load_state("networkidle", timeout=10_000)
except (PWTimeout, PWError):
pass
classes = _scrape_classes(page)
# Filtre : exclure les classes MP* (Matu Pro), MI* (Maîtrise),
# "Formation*" (modules de formation continue, hors flux régulier).
filtered = [
c for c in classes
if not (
c.startswith("MP")
or c.startswith("MI")
or c.lower().startswith("formation")
)
]
removed = sorted(set(classes) - set(filtered))
_log(f"DONE {len(filtered)} classes (filtré {len(removed)} : {removed})")
return {
"ok": True,
"classes": filtered,
"duration_s": round(time.time() - t_start, 1),
}
finally:
try: ctx.close()
except Exception: pass
finally:
try: pw.stop()
except Exception: pass
# cleanup du profile temporaire
import shutil
shutil.rmtree(profile_dir, ignore_errors=True)
def main():
global _USERNAME
if len(sys.argv) < 2:
print("Usage: fetch_user_classes.py <username>", file=sys.stderr)
sys.exit(2)
username = sys.argv[1].strip()
_USERNAME = username
totp_code = (os.getenv("TOTP_CODE") or "").strip()
if not totp_code or not totp_code.isdigit() or len(totp_code) != 6:
result = {"ok": False, "error": "TOTP_CODE manquant ou invalide (6 chiffres requis)", "classes": []}
else:
try:
result = fetch_classes(username, totp_code)
except Exception as e:
result = {"ok": False, "error": str(e), "classes": []}
out_file = DATA_DIR / f"sync_user_classes_{username}.json"
out_file.parent.mkdir(parents=True, exist_ok=True)
out_file.write_text(json.dumps(result, ensure_ascii=False), encoding="utf-8")
print(json.dumps(result, ensure_ascii=False))
sys.exit(0 if result.get("ok") else 1)
if __name__ == "__main__":
main()

426
scripts/pull_notices.py Executable file
View file

@ -0,0 +1,426 @@
#!/usr/bin/env python3
"""Pull des notices depuis Escadaweb pour les apprentis des classes données.
Usage : python pull_notices.py CLASSE1 CLASSE2 ...
Pour chaque classe :
1. Navigue vers la liste Élèves (ViewLernende)
2. Pour chaque apprenti de la classe :
- Clic "Notices" dans sa ligne
- Scrape la grille (pagination gérée)
- Wipe + insert les notices dans ApprentiNotice
- Retour à la liste Élèves
3. Passe à la classe suivante
Sortie standard (parsable) :
PULL_NOTICES_DONE {"ok": N_apprentis_ok, "imported": N_notices, "err": [...]}
"""
from __future__ import annotations
import json
import re
import sys
import traceback
from datetime import date, datetime
from pathlib import Path
_ROOT = Path(__file__).resolve().parent.parent
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
from sqlalchemy import select, delete # noqa: E402
from playwright.sync_api import Page, TimeoutError as PWTimeout # noqa: E402
from src.db import get_session, Apprenti, ApprentiNotice # noqa: E402
from src.logger import app_log # noqa: E402
from scripts.sync_esacada import ( # noqa: E402
_launch_context, _ensure_logged_in, _go_to_students_page, _log,
CLASSES_URL,
)
_DATE_RE = re.compile(r"(\d{2})\.(\d{2})\.(\d{4})")
def _parse_date(s: str) -> date | None:
if not s:
return None
m = _DATE_RE.search(s)
if not m:
return None
try:
return date(int(m.group(3)), int(m.group(2)), int(m.group(1)))
except Exception:
return None
def _scrape_notices_grid(page: Page) -> list[dict]:
"""Scrape toutes les pages de la grille des notices.
Ordre des colonnes attendu (basé sur la structure DevExpress observée) :
0 Editer (icône) | 1 Date | 2 Type | 3 Auteur | 4 Titre | 5 Remarques
6 Visible classe (checkbox) | 7 Matière | 8-11 visibilités (ignorées)
"""
notices: list[dict] = []
seen_rows: set[str] = set() # éviter de re-scraper après navigation pagination
current_pg = 1
while True:
try:
page.wait_for_selector(
"table[id$='gridNotizen_DXMainTable']",
state="attached", timeout=10_000,
)
except PWTimeout:
_log(f" [notices p={current_pg}] grille non chargée")
break
# Récupérer toutes les lignes de données via JS pour fiabilité
rows_data = page.evaluate("""() => {
const out = [];
const rows = document.querySelectorAll("tr[id*='gridNotizen_DXDataRow']");
for (const tr of rows) {
const cells = tr.querySelectorAll(":scope > td");
const texts = Array.from(cells).map(td => (td.innerText || td.textContent || '').trim());
// Détection checkbox "Visible classe" : présence d'une image cochée
const cb = cells[6] ? cells[6].querySelector("img") : null;
const vis = cb ? !(cb.src || '').toLowerCase().includes('unchecked') : null;
out.push({texts, visible: vis, id: tr.id});
}
return out;
}""")
added = 0
for row in rows_data:
if row["id"] in seen_rows:
continue
seen_rows.add(row["id"])
t = row["texts"]
# Index défensif (cas où le DOM diffère légèrement)
def col(i: int) -> str:
return t[i] if i < len(t) else ""
notices.append({
"date": _parse_date(col(1)),
"type": col(2) or None,
"auteur": col(3) or None,
"titre": col(4) or None,
"remarque": col(5) or None,
"matiere": col(7) or None,
"visible_classe": row.get("visible"),
})
added += 1
_log(f" [notices p={current_pg}] +{added} ligne(s)")
# Pagination : aller à la page suivante si dispo
try:
next_link = page.locator(
f"a.dxp-num:has-text('{current_pg + 1}')"
).first
if next_link.count() == 0:
break
next_link.click()
page.wait_for_load_state("networkidle", timeout=10_000)
page.wait_for_timeout(400)
current_pg += 1
except Exception:
break
return notices
def _student_rows(page: Page) -> list[dict]:
"""Liste des lignes Élèves avec nom, prénom, et drapeau "a des notices".
Structure de la grille Lernende (cellules) :
[0] Detail expand
[1] Notes link icon
[2] Edit button
[3] **Nom**
[4] **Prénom**
[5] Entreprise
[6] MP / [7] Disp. CG / [8] Abs. excu / [9] Abs. non excu / [10] Remarque
[11] Compensation / [12] Documents
[13] **Notices link** (icône : note_pinned = vide, note_text = avec)
[14] History / [15] Tasks
Format : [{row_id, nom, prenom, has_notices, notices_href}].
Gère la pagination.
"""
out: list[dict] = []
seen: set[str] = set()
current_pg = 1
while True:
rows = page.evaluate("""() => {
const out = [];
const trs = document.querySelectorAll("tr[id*='GridLernende_DXDataRow']");
for (const tr of trs) {
const cells = tr.querySelectorAll(":scope > td");
const txt = (i) => cells[i] ? (cells[i].innerText || cells[i].textContent || '').trim() : '';
const nom = txt(3);
const prenom = txt(4);
// Lien Notices = cellule 13 (peut varier si Escada change l'ordre)
let hasNotices = false;
let noticesHref = null;
// Cherche dans toute la ligne le lien Notices via son title
const noticeLink = tr.querySelector("a[title='Notices']");
if (noticeLink) {
noticesHref = noticeLink.getAttribute('href');
const img = noticeLink.querySelector('img');
if (img) {
const src = (img.getAttribute('src') || '').toLowerCase();
hasNotices = src.includes('note_text');
}
}
out.push({
id: tr.id,
nom: nom,
prenom: prenom,
has_notices: hasNotices,
notices_href: noticesHref,
});
}
return out;
}""")
added = 0
for r in rows:
if r["id"] in seen:
continue
seen.add(r["id"])
out.append({
"row_id": r["id"],
"nom": r["nom"],
"prenom": r["prenom"],
"has_notices": r["has_notices"],
"notices_href": r["notices_href"],
})
added += 1
_log(f" [élèves p={current_pg}] +{added}")
# Page suivante ?
try:
next_link = page.locator(
f"a.dxp-num:has-text('{current_pg + 1}')"
).first
if next_link.count() == 0:
break
next_link.click()
page.wait_for_load_state("networkidle", timeout=10_000)
page.wait_for_timeout(400)
current_pg += 1
except Exception:
break
return out
def _pull_one_row(
page: Page, sess, row: dict, classe: str, students_url: str,
db_apprentis: list,
) -> tuple[int, str | None, Apprenti | None]:
"""Pour une ligne Élève avec notices, scrape la grille et insert en DB.
`row` est le dict produit par `_student_rows` : {row_id, nom, prenom, has_notices, notices_href}
Retourne (nb_importées, err, apprenti_match).
"""
nom = (row.get("nom") or "").strip()
prenom = (row.get("prenom") or "").strip()
# 1. Recherche match dans la liste DB de la classe (avant navigation).
# Plusieurs stratégies en cascade pour tolérer les différences de
# découpage nom/prénom (ex: "Loureiro" + "de Menezes Tiago" en DB vs
# "Loureiro de Menezes" + "Tiago" sur Escada).
import unicodedata
def _norm(s: str) -> str:
nfkd = unicodedata.normalize("NFKD", s or "")
return " ".join(
nfkd.encode("ascii", "ignore").decode("ascii").lower().split()
)
full_escada = _norm(f"{nom} {prenom}")
match: Apprenti | None = None
# Stratégie A : match nom strict + premier mot du prénom
for a in db_apprentis:
db_nom = (a.nom or "").strip()
db_pre1 = (a.prenom or "").strip().split(maxsplit=1)[0] if a.prenom else ""
if db_nom == nom and prenom and (
prenom.startswith(db_pre1) or db_pre1.startswith(prenom.split(maxsplit=1)[0])
):
match = a
break
# Stratégie B : match nom strict seul
if not match:
for a in db_apprentis:
if (a.nom or "").strip() == nom:
match = a
break
# Stratégie C : match par nom complet normalisé (sans accents, casse insensible)
if not match and full_escada:
for a in db_apprentis:
full_db = _norm(f"{a.nom} {a.prenom}")
if full_db == full_escada:
match = a
break
if not match:
return 0, f"apprenti '{nom} {prenom}' non trouvé en DB pour {classe}", None
# 2. Navigation vers la page Notices : on utilise href si dispo (plus rapide),
# sinon clic sur le lien Notices de la ligne.
href = row.get("notices_href")
try:
if href:
# href peut être relatif (ex: "ViewNotizen.aspx?id=...") — on résout via JS
target_url = page.evaluate(
"(h) => new URL(h, document.baseURI).href", href
)
page.goto(target_url)
else:
page.locator(f"#{row['row_id']}").get_by_role("link", name="Notices").first.click()
page.wait_for_load_state("networkidle", timeout=15_000)
except Exception as e:
return 0, f"navigation Notices : {e}", match
# 3. Scrape grille
try:
notices = _scrape_notices_grid(page)
except Exception as e:
try:
page.goto(students_url)
page.wait_for_load_state("networkidle", timeout=10_000)
except Exception:
pass
return 0, f"scrape grille : {e}", match
# 4. Insert (le wipe global a déjà été fait au début de la classe)
try:
for n in notices:
if not n["date"]:
continue
sess.add(ApprentiNotice(
apprenti_id = match.id,
date_event = n["date"],
type_notice = n.get("type"),
auteur = n.get("auteur"),
titre = n.get("titre"),
remarque = n.get("remarque"),
matiere = n.get("matiere"),
visible_classe = n.get("visible_classe"),
imported_at = datetime.now(),
))
sess.commit()
except Exception as e:
sess.rollback()
return 0, f"DB insert : {e}", match
# 5. Retour à la liste élèves
try:
page.goto(students_url)
page.wait_for_load_state("networkidle", timeout=15_000)
except Exception:
pass
return len(notices), None, match
def main():
if len(sys.argv) < 2:
print("Usage : pull_notices.py CLASSE1 [CLASSE2 ...]", file=sys.stderr)
sys.exit(2)
target_classes = [c for c in sys.argv[1:] if c.strip()]
sess = get_session()
ok_count = 0
total_imported = 0
errors: list[str] = []
try:
app_log(f"[pull_notices] démarrage — {len(target_classes)} classe(s)")
pw, ctx, page = _launch_context()
try:
page.goto(CLASSES_URL)
_ensure_logged_in(page)
for classe in target_classes:
_log(f"[pull_notices] classe={classe}")
# 1. Wipe global des notices existantes pour les apprentis de cette classe
db_apprentis = sess.execute(
select(Apprenti).where(Apprenti.classe == classe)
).scalars().all()
if db_apprentis:
appr_ids = [a.id for a in db_apprentis]
sess.execute(
delete(ApprentiNotice).where(ApprentiNotice.apprenti_id.in_(appr_ids))
)
sess.commit()
_log(f" [{classe}] wipe ApprentiNotice : {len(appr_ids)} apprenti(s)")
# 2. Navigue vers la liste Élèves
try:
students_page = _go_to_students_page(page, classe)
except Exception as e:
students_page = None
_log(f" ERR navigation : {e}")
if not students_page:
errors.append(f"classe '{classe}' : page Élèves introuvable")
continue
students_url = students_page.url
# 3. Liste des lignes (avec drapeau has_notices)
try:
rows = _student_rows(students_page)
except Exception as e:
errors.append(f"classe '{classe}' : scrape liste élèves : {e}")
continue
nb_with = sum(1 for r in rows if r["has_notices"])
_log(f" [{classe}] {len(rows)} élève(s), {nb_with} avec notice(s)")
# 4. Pour chaque ligne ayant des notices : pull
for r in rows:
label = f"{r.get('nom','?')} {r.get('prenom','?')}"
if not r["has_notices"]:
continue
try:
n, err, match = _pull_one_row(
students_page, sess, r, classe, students_url, db_apprentis,
)
if err:
errors.append(f"{label} ({classe}) : {err}")
try:
students_page.goto(students_url)
students_page.wait_for_load_state("networkidle", timeout=10_000)
except Exception:
break
else:
ok_count += 1
total_imported += n
_log(f" OK {label} : {n} notice(s)")
except Exception as e:
errors.append(f"{label} ({classe}) : {e}")
_log(f" EX {label} : {e}\n{traceback.format_exc()}")
finally:
try: ctx.close()
except Exception: pass
try: pw.stop()
except Exception: pass
finally:
sess.close()
print(
'PULL_NOTICES_DONE '
+ json.dumps({
"ok": ok_count,
"imported": total_imported,
"err": errors,
}, ensure_ascii=False),
flush=True,
)
app_log(
f"[pull_notices] terminé — {ok_count} apprenti(s) OK, "
f"{total_imported} notice(s) importée(s), {len(errors)} erreur(s)"
)
if __name__ == "__main__":
main()

251
scripts/push_notices.py Executable file
View file

@ -0,0 +1,251 @@
#!/usr/bin/env python3
"""Push des notices en attente vers Escadaweb.
Workflow par notice :
Classes Élèves (de la classe) Notices (de l'apprenti) → Ajouter
Date / Titre / Remarques Mettre à jour retour Élèves
Réutilise les helpers de `sync_esacada.py` :
- `_launch_context()` : navigateur headless avec profil persistant
- `_ensure_logged_in(page)` : login SSO + 2FA + langue FR
- `_go_to_students_page(page, class_name)` : ouvre ViewLernende d'une classe
Sortie standard (parsée par `cron_tick.py` et la page /escada) :
PUSH_NOTICES_DONE {"ok": N, "err": [...], "remaining": N}
Behaviour DB :
- status='pending' tentative
- succès suppression de la Notice de la DB
- échec status='failed' + error_msg
"""
from __future__ import annotations
import json
import sys
import traceback
from pathlib import Path
_ROOT = Path(__file__).resolve().parent.parent
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
from sqlalchemy import select # noqa: E402
from playwright.sync_api import Page # noqa: E402
from src.db import get_session, Notice # noqa: E402
from src.logger import app_log # noqa: E402
from scripts.sync_esacada import ( # noqa: E402
_launch_context, _ensure_logged_in, _go_to_students_page, _log,
CLASSES_URL,
)
def _fill_date(page: Page, date_str: str) -> None:
"""Remplit le champ Date du formulaire notice (DevExpress).
On vise l'input texte directement (`id$="_DXEditor1_I"`), plus stable que
le calendrier popup.
"""
date_input = page.locator("input[id$='_DXEditor1_I']").first
date_input.wait_for(state="visible", timeout=10_000)
date_input.click()
# Sélectionne tout l'ancien contenu (date pré-remplie d'aujourd'hui) puis tape
date_input.press("Control+A")
date_input.type(date_str)
date_input.press("Tab") # commit la valeur
def _push_one_notice(page: Page, notice: Notice, students_url: str) -> tuple[bool, str]:
"""Pousse une notice. Renvoie (ok, error_message).
Pré : `page` est sur la liste Élèves de la classe de l'apprenti.
Post (succès ou échec) : `page` est de retour sur la liste Élèves.
"""
ap = notice.apprenti
nom = ap.nom
prenom = ap.prenom
# 1. Trouver la ligne de l'apprenti et cliquer "Notices"
try:
# On filtre par nom ET prénom pour éviter les homonymes
student_row = page.locator("tr").filter(has_text=nom).filter(has_text=prenom).first
if not student_row.count():
return False, f"Apprenti '{nom} {prenom}' introuvable dans la grille"
student_row.get_by_role("link", name="Notices").first.click()
page.wait_for_load_state("networkidle", timeout=15_000)
except Exception as e:
return False, f"Navigation Notices : {e}"
# 2. Cliquer "Ajouter"
try:
page.locator("a").filter(has_text="Ajouter").first.click()
page.wait_for_timeout(800)
except Exception as e:
return False, f"Bouton Ajouter introuvable : {e}"
# 3. Remplir Date / Titre / Remarques
try:
_fill_date(page, notice.date_event.strftime("%d.%m.%Y"))
except Exception as e:
return False, f"Remplissage date : {e}"
try:
page.get_by_role("textbox", name="Titre:").fill(notice.titre)
except Exception as e:
return False, f"Remplissage titre : {e}"
if notice.remarque:
try:
page.get_by_role("textbox", name="Remarques:").fill(notice.remarque)
except Exception:
pass # Non bloquant
# 4. Sauver
try:
page.get_by_role("link", name="Mettre à jour").click()
page.wait_for_load_state("networkidle", timeout=15_000)
page.wait_for_timeout(500) # laisse le temps à la grille de se rafraîchir
except Exception as e:
return False, f"Échec Mettre à jour : {e}"
# 5. Vérifier que la notice est bien dans la grille de l'apprenti
try:
# On cherche le titre dans la grille des notices (max 30 chars pour éviter
# les soucis de longueur / wrapping).
needle = (notice.titre or "").strip()[:30]
if needle:
cell = page.locator("td").filter(has_text=needle).first
cell.wait_for(state="visible", timeout=8_000)
except Exception:
# Vérification échouée — on retourne quand même à la liste Élèves
# avant de signaler l'échec.
try:
page.goto(students_url)
page.wait_for_load_state("networkidle", timeout=15_000)
except Exception:
pass
return False, "Notice non retrouvée dans la grille après save (échec probable)"
# 6. Retour à la liste Élèves de la même classe (option a : navigation directe)
try:
page.goto(students_url)
page.wait_for_load_state("networkidle", timeout=15_000)
except Exception as e:
return False, f"Retour grille élèves : {e}"
return True, ""
def main():
sess = get_session()
ok_count = 0
errors: list[str] = []
try:
notices = sess.execute(
select(Notice).where(Notice.status == "pending")
).scalars().all()
app_log(f"[push_notices] {len(notices)} notice(s) en attente")
if not notices:
print(
'PUSH_NOTICES_DONE '
+ json.dumps({"ok": 0, "err": [], "remaining": 0}),
flush=True,
)
return
# Groupe par classe pour minimiser les navigations
by_class: dict[str, list[Notice]] = {}
for n in notices:
by_class.setdefault(n.apprenti.classe, []).append(n)
pw, ctx, page = _launch_context()
try:
# Navigation initiale vers ViewKlassen : redirige vers le login
# si la session est expirée, et permet à _ensure_logged_in
# de détecter le succès (ViewKlassen dans l'URL).
page.goto(CLASSES_URL)
_ensure_logged_in(page)
for classe, class_notices in by_class.items():
_log(f"[push_notices] classe={classe} ({len(class_notices)} notices)")
try:
students_page = _go_to_students_page(page, classe)
except Exception as e:
students_page = None
_log(f"[push_notices] erreur navigation {classe}: {e}")
if not students_page:
msg = f"classe '{classe}' introuvable sur Escada"
for n in class_notices:
n.status = "failed"
n.error_msg = msg
errors.append(
f"id={n.id} ({n.apprenti.nom} {n.apprenti.prenom}): {msg}"
)
sess.commit()
continue
students_url = students_page.url
for notice in class_notices:
label = f"{notice.apprenti.nom} {notice.apprenti.prenom}"
try:
ok, err = _push_one_notice(students_page, notice, students_url)
if ok:
sess.delete(notice)
sess.commit()
ok_count += 1
_log(f"[push_notices] OK id={notice.id} ({label})")
else:
notice.status = "failed"
notice.error_msg = err[:500]
sess.commit()
errors.append(f"id={notice.id} ({label}): {err}")
_log(f"[push_notices] FAIL id={notice.id}: {err}")
# Si on est paumé, tenter un retour propre
try:
students_page.goto(students_url)
students_page.wait_for_load_state(
"networkidle", timeout=10_000
)
except Exception:
break # impossible de recover, on passe à la classe suivante
except Exception as e:
notice.status = "failed"
notice.error_msg = str(e)[:500]
sess.commit()
errors.append(f"id={notice.id} ({label}): {e}")
_log(f"[push_notices] EX id={notice.id}: {e}\n{traceback.format_exc()}")
finally:
try: ctx.close()
except Exception: pass
try: pw.stop()
except Exception: pass
finally:
# Compte les notices encore pending (n'incluant pas les "failed")
try:
remaining = sess.execute(
select(Notice).where(Notice.status == "pending")
).all()
remaining_count = len(remaining)
except Exception:
remaining_count = 0
sess.close()
print(
'PUSH_NOTICES_DONE '
+ json.dumps({
"ok": ok_count,
"err": errors,
"remaining": remaining_count,
}, ensure_ascii=False),
flush=True,
)
if __name__ == "__main__":
main()

View file

@ -4,12 +4,10 @@ Usage :
python scripts/push_to_escada.py # tous les changements en attente
python scripts/push_to_escada.py --test # test limité à Poidevin Alexandre / EM-AU 1
python scripts/push_to_escada.py --count # affiche le nombre de changements en attente
python scripts/push_to_escada.py --no-pull # ne pas récupérer le serveur avant push
"""
from __future__ import annotations
import json
import subprocess
import sys
from datetime import date
from pathlib import Path
@ -25,7 +23,7 @@ if hasattr(sys.stderr, "reconfigure"):
from playwright.sync_api import sync_playwright, TimeoutError as PWTimeout
from src.db import Absence, Apprenti, EscadaPending, get_engine, init_db, upsert_escada_pending
from src.db import Absence, Apprenti, EscadaPending, get_engine, init_db
from sqlalchemy.orm import sessionmaker, Session
from sqlalchemy import select
@ -36,10 +34,6 @@ from scripts.sync_esacada import (
_go_to_absence_page, _cache_load,
)
# ── Coordonnées du serveur ────────────────────────────────────────────────────
_SSH_HOST = "julbal@20.199.136.37"
_SSH_REMOTE = "/opt/absences"
# ── Interaction avec la page d'absences ───────────────────────────────────────
@ -227,90 +221,6 @@ def _save(page) -> bool:
return False
# ── Synchronisation avec le serveur ──────────────────────────────────────────
def _pull_from_server(session: Session) -> dict[tuple, int]:
"""SSH → serveur, exporte EscadaPending en JSON, upsert en local.
Retourne un mapping (nom, prenom, classe, date_iso, periode) server_id
pour permettre le nettoyage côté serveur après push réussi.
"""
_log("PULL Récupération des modifications en attente depuis le serveur…")
cmd = (
f'ssh {_SSH_HOST} '
f'"cd {_SSH_REMOTE} && .venv/bin/python scripts/export_pending.py"'
)
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=30, shell=True
)
if result.returncode != 0:
_log(f" WARN SSH export_pending échoué : {result.stderr.strip()}")
return {}
raw = result.stdout.strip()
if not raw:
_log(" INFO Aucune modification en attente sur le serveur.")
return {}
entries = json.loads(raw)
except Exception as e:
_log(f" WARN Impossible de récupérer depuis le serveur : {e}")
return {}
if not entries:
_log(" INFO Aucune modification en attente sur le serveur.")
return {}
_log(f" {len(entries)} entrée(s) récupérée(s) du serveur")
server_id_map: dict[tuple, int] = {}
for entry in entries:
ap = session.execute(
select(Apprenti).where(
Apprenti.nom == entry["nom"],
Apprenti.prenom == entry["prenom"],
Apprenti.classe == entry["classe"],
)
).scalar_one_or_none()
if ap is None:
_log(
f" WARN apprenti introuvable localement : "
f"{entry['nom']} {entry['prenom']} / {entry['classe']}"
)
continue
d = date.fromisoformat(entry["date"])
upsert_escada_pending(session, ap.id, d, entry["periode"], entry["action"])
key = (entry["nom"], entry["prenom"], entry["classe"],
entry["date"], entry["periode"])
server_id_map[key] = entry["id"]
session.commit()
_log(f" {len(server_id_map)} entrée(s) fusionnée(s) dans la DB locale")
return server_id_map
def _clear_server_pending(server_ids: list[int]) -> None:
"""SSH → serveur pour supprimer les EscadaPending par IDs."""
if not server_ids:
return
ids_str = " ".join(str(i) for i in server_ids)
cmd = (
f'ssh {_SSH_HOST} '
f'"cd {_SSH_REMOTE} && .venv/bin/python scripts/clear_pending.py {ids_str}"'
)
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=30, shell=True
)
if result.returncode != 0:
_log(f" WARN SSH clear_pending échoué : {result.stderr.strip()}")
else:
_log(f" OK serveur nettoyé ({result.stdout.strip()})")
except Exception as e:
_log(f" WARN Impossible de nettoyer le serveur : {e}")
# ── Commande principale ───────────────────────────────────────────────────────
def cmd_count(session: Session) -> None:
@ -322,22 +232,13 @@ def cmd_count(session: Session) -> None:
_log(f" {ap.classe} | {ap.nom} {ap.prenom} | {ep.date} P{ep.periode}{ep.action}")
def cmd_push(session: Session, test_mode: bool = False, no_pull: bool = False, debug: bool = False) -> None:
def cmd_push(session: Session, test_mode: bool = False, debug: bool = False) -> None:
"""Pousse tous les changements en attente vers Escada.
1. Pull depuis le serveur (sauf --no-pull).
2. Lecture des EscadaPending locaux.
3. Navigation Playwright + mise à jour des dropdowns.
4. Nettoyage côté serveur pour les entrées syncées avec succès.
1. Lecture des EscadaPending locaux.
2. Navigation Playwright + mise à jour des dropdowns.
"""
# ── 1. Pull depuis le serveur ─────────────────────────────────────────────
server_id_map: dict[tuple, int] = {}
if not no_pull:
server_id_map = _pull_from_server(session)
else:
_log("INFO --no-pull : synchronisation serveur ignorée")
# ── 2. Lecture des EscadaPending locaux ───────────────────────────────────
# ── 1. Lecture des EscadaPending locaux ───────────────────────────────────
q = select(EscadaPending).join(Apprenti, EscadaPending.apprenti_id == Apprenti.id)
if test_mode:
_log("INFO Mode test : Poidevin Alexandre / EM-AU 1 uniquement")
@ -364,8 +265,6 @@ def cmd_push(session: Session, test_mode: bool = False, no_pull: bool = False, d
_ensure_logged_in(page)
results = {"ok": [], "err": []}
# EscadaPending IDs locaux syncés avec succès → pour retrouver les server_ids
synced_eps: list[EscadaPending] = []
for i, ((classe, target_date), entries) in enumerate(sorted(groups.items()), 1):
_log(f"PROGRESS {i}/{len(groups)} {classe} {target_date}")
@ -418,25 +317,12 @@ def cmd_push(session: Session, test_mode: bool = False, no_pull: bool = False, d
session.commit()
_log(f"OK {classe} {target_date} : {len(synced_ids)} changement(s) sauvegardé(s)")
results["ok"].extend(synced_ids)
synced_eps.extend(synced_ep_objs)
else:
_log(f"ERR {classe} {target_date} : sauvegarde échouée")
results["err"].append(f"{classe} {target_date} : enregistrement échoué")
_log(f"PUSH_DONE {json.dumps({'ok': len(results['ok']), 'err': results['err']}, ensure_ascii=False)}")
# ── 4. Nettoyage côté serveur ─────────────────────────────────────────
if server_id_map and synced_eps:
server_ids_to_clear: list[int] = []
for ep in synced_eps:
ap = ep.apprenti
key = (ap.nom, ap.prenom, ap.classe, ep.date.isoformat(), ep.periode)
srv_id = server_id_map.get(key)
if srv_id is not None:
server_ids_to_clear.append(srv_id)
if server_ids_to_clear:
_clear_server_pending(server_ids_to_clear)
finally:
ctx.close()
pw.stop()
@ -451,8 +337,6 @@ if __name__ == "__main__":
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--test", action="store_true", help="Limite au test Poidevin Alexandre")
ap.add_argument("--count", action="store_true", help="Affiche les changements en attente")
ap.add_argument("--no-pull", action="store_true", help="Ne pas récupérer les données du serveur avant push")
ap.add_argument("--pull-only", action="store_true", help="Récupère depuis le serveur sans pousser vers Escada")
ap.add_argument("--debug", action="store_true", help="Pause interactive après ouverture de la page absences")
args = ap.parse_args()
@ -461,7 +345,5 @@ if __name__ == "__main__":
with Session_() as sess:
if args.count:
cmd_count(sess)
elif args.pull_only:
_pull_from_server(sess)
else:
cmd_push(sess, test_mode=args.test, no_pull=args.no_pull, debug=args.debug)
cmd_push(sess, test_mode=args.test, debug=args.debug)

View file

@ -766,6 +766,20 @@ def _scrape_student_details(page: Page, class_name: str) -> list[dict]:
# Déplier + lire une ligne à la fois (Escada ne gère pas les AJAX simultanés)
fiches: list[dict] = []
for i in range(n):
# Lire l'indicateur "Compensation des désavantages" sur la ligne
# principale AVANT l'expand. L'icône est pawn_glass_blue.png (a le droit)
# ou pawn_glass_white.png (pas le droit).
compensation = page.evaluate("""([gid, i]) => {
const row = document.getElementById(`${gid}_DXDataRow${i}`);
if (!row) return null;
const img = row.querySelector('img[src*="pawn_glass"]');
if (!img) return null;
const src = img.getAttribute('src') || '';
if (src.includes('blue')) return true;
if (src.includes('white')) return false;
return null;
}""", [gid, i])
# Clic sur le bouton expand de la ligne i
clicked = page.evaluate("""([gid, i]) => {
const row = document.getElementById(`${gid}_DXDataRow${i}`);
@ -816,18 +830,139 @@ def _scrape_student_details(page: Page, class_name: str) -> list[dict]:
if raw:
fiche = _parse_fiche_text(raw)
fiche["compensation_desavantages"] = compensation
if fiche.get('nom_eleve') or fiche.get('entreprise_nom'):
fiches.append(fiche)
_log(f" [fiches] {i}: {fiche.get('nom_eleve', '?')}")
_comp_lbl = (
"compensation=oui" if compensation
else "compensation=non" if compensation is False
else "compensation=?"
)
_log(f" [fiches] {i}: {fiche.get('nom_eleve', '?')} ({_comp_lbl})")
else:
_log(f" [fiches] {i}: WARNING données vides — raw[:80]={raw[:80]!r}")
else:
_log(f" [fiches] {i}: WARNING cellule vide")
# Récupération du PDF "Liste des classes" + injection des représentants
# légaux dans les fiches (uniquement pour les mineurs).
try:
lc_pdf = _download_liste_classe_pdf(page, class_name)
if lc_pdf:
from src.parser_liste_classe import parse_liste_classe_pdf
lc_data = parse_liste_classe_pdf(lc_pdf)
_merge_resp_legaux(fiches, lc_data.get("apprentis", []))
except Exception as _e:
_log(f" [resp.lég.] WARN: {_e}")
_log(f" [fiches] {len(fiches)} fiche(s) extraite(s)")
return fiches
_LISTES_CLASSES_DIR = _root / "data" / "pdfs" / "listes_classes"
def _download_liste_classe_pdf(page: Page, class_name: str) -> Path | None:
"""Télécharge le PDF "Liste de la classe" (Rapport DevExpress) sur la page
ViewLernende. Le lien a un href direct vers Reports/RptEscada.aspx?id=&key=
on récupère le href et on télécharge via context.request avec les
cookies de session."""
_LISTES_CLASSES_DIR.mkdir(parents=True, exist_ok=True)
dest = _LISTES_CLASSES_DIR / f"liste_{class_name.replace(' ', '_')}.pdf"
try:
page.wait_for_selector("a.dxr-item.dxr-buttonItem", timeout=10_000)
except Exception:
pass
href = page.evaluate("""() => {
const links = document.querySelectorAll('a.dxr-item.dxr-buttonItem');
for (const a of links) {
const txt = (a.innerText || '').trim();
if (txt === 'Liste des classes' || txt === 'Klassenliste') {
return a.getAttribute('href');
}
}
return null;
}""")
if not href:
_log(f" [liste] {class_name}: bouton 'Liste des classes' introuvable")
return None
full_url = f"{BASE_URL}{href}" if href.startswith("/") else href
try:
resp = page.context.request.get(full_url, timeout=30_000)
if resp.status != 200:
_log(f" [liste] {class_name}: HTTP {resp.status}")
return None
body = resp.body()
if not body.startswith(b"%PDF"):
_log(f" [liste] {class_name}: réponse n'est pas un PDF")
return None
dest.write_bytes(body)
_log(f" [liste] {class_name}: {dest.name} ({len(body)} bytes)")
return dest
except Exception as e:
_log(f" [liste] {class_name}: {e}")
return None
def _merge_resp_legaux(fiches: list[dict], lc_apprentis: list[dict]) -> None:
"""Match par nom_eleve (normalisé) et injecte :
- resp_legal_* si présent dans le PDF ;
- entreprise_nom en fallback (PDF a 'CFCNomEntreprise' collé en col F)
quand le scraping ViewLernende a manqué le nom ou stocké une adresse.
"""
import re as _re
def _norm(s: str) -> str:
import unicodedata
nfkd = unicodedata.normalize("NFKD", s or "")
return " ".join(
nfkd.encode("ascii", "ignore").decode("ascii").lower().split()
)
_addr_prefix = _re.compile(
r"^(Chemin|Rue|Route|Avenue|Impasse|Ruelle|Allée|Place|Boulevard|Bd|Av\.|Ch\.|Rte)\s",
_re.I,
)
by_name: dict[str, dict] = {}
for ap in lc_apprentis:
n = _norm(ap.get("nom_eleve") or "")
if n:
by_name[n] = ap
matched_rl = 0
matched_ent = 0
for fiche in fiches:
n = _norm(fiche.get("nom_eleve") or "")
ap = by_name.get(n)
if not ap:
continue
# Resp. légal
rl_keys = (
"resp_legal_nom", "resp_legal_adresse", "resp_legal_code_postal",
"resp_legal_localite", "resp_legal_telephone_p", "resp_legal_telephone_n",
)
if any(ap.get(k) for k in rl_keys):
for k in rl_keys:
if ap.get(k):
fiche[k] = ap[k]
matched_rl += 1
# Fallback entreprise_nom (cas où ViewLernende a raté)
ent_pdf = ap.get("entreprise_nom_pdf")
ent_cur = (fiche.get("entreprise_nom") or "").strip()
if ent_pdf and (not ent_cur or _addr_prefix.match(ent_cur)):
# Pousser la valeur courante (qui est en fait l'adresse) en
# entreprise_adresse si celle-ci est vide.
if ent_cur and not (fiche.get("entreprise_adresse") or "").strip():
fiche["entreprise_adresse"] = ent_cur
fiche["entreprise_nom"] = ent_pdf
matched_ent += 1
_log(f" [resp.lég.] {matched_rl} apprenti(s) avec représentant légal")
if matched_ent:
_log(f" [entreprise] {matched_ent} apprenti(s) avec entreprise_nom corrigé depuis le PDF")
def _download_pdf(page: Page, class_name: str) -> Path | None:
"""Clique sur 'Contrôle des absences (apprenants)' et récupère le PDF.

188
src/db.py
View file

@ -176,6 +176,9 @@ class ApprentiFiche(Base):
email: Mapped[Optional[str]] = mapped_column(String, nullable=True)
date_naissance: Mapped[Optional[str]] = mapped_column(String, nullable=True)
majeur: Mapped[Optional[bool]] = mapped_column(nullable=True)
# Compensation des désavantages (Nachteilsausgleich) — True si accordée,
# False sinon, None si la donnée n'a pas été scrapée
compensation_desavantages: Mapped[Optional[bool]] = mapped_column(nullable=True)
# Entreprise
entreprise_nom: Mapped[Optional[str]] = mapped_column(String, nullable=True)
@ -189,6 +192,18 @@ class ApprentiFiche(Base):
formateur_nom: Mapped[Optional[str]] = mapped_column(String, nullable=True)
formateur_email: Mapped[Optional[str]] = mapped_column(String, nullable=True)
# Représentant légal (uniquement pour les mineurs ; scrapé depuis le PDF
# "Liste des classes" sur Escada).
resp_legal_nom: Mapped[Optional[str]] = mapped_column(String, nullable=True)
resp_legal_adresse: Mapped[Optional[str]] = mapped_column(String, nullable=True)
resp_legal_code_postal: Mapped[Optional[str]] = mapped_column(String, nullable=True)
resp_legal_localite: Mapped[Optional[str]] = mapped_column(String, nullable=True)
resp_legal_telephone_p: Mapped[Optional[str]] = mapped_column(String, nullable=True) # fixe
resp_legal_telephone_n: Mapped[Optional[str]] = mapped_column(String, nullable=True) # mobile
# Profession dérivée du préfixe de classe (mapping dans data/settings.json)
profession: Mapped[Optional[str]] = mapped_column(String, nullable=True)
updated_at: Mapped[datetime] = mapped_column(default=datetime.now, onupdate=datetime.now)
apprenti: Mapped["Apprenti"] = relationship(back_populates="fiche")
@ -206,6 +221,52 @@ class NotesExamen(Base):
apprenti: Mapped["Apprenti"] = relationship(back_populates="notes_examen")
class Notice(Base):
"""Note à pousser sur Escada (liée à un apprenti).
Créée notamment lors de la génération d'un avis de retenue (si la case
correspondante est cochée). Supprimée après push réussi.
"""
__tablename__ = "notices"
id: Mapped[int] = mapped_column(primary_key=True)
apprenti_id: Mapped[int] = mapped_column(ForeignKey("apprentis.id"))
date_event: Mapped[date]
titre: Mapped[str] = mapped_column(Text)
remarque: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
type_notice: Mapped[Optional[str]] = mapped_column(String, nullable=True)
matiere: Mapped[Optional[str]] = mapped_column(String, nullable=True)
source: Mapped[str] = mapped_column(default="manual") # "retenue" pour le moment
status: Mapped[str] = mapped_column(default="pending") # "pending" | "failed"
created_at: Mapped[datetime] = mapped_column(default=datetime.now)
created_by: Mapped[Optional[str]] = mapped_column(String, nullable=True)
error_msg: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
apprenti: Mapped["Apprenti"] = relationship()
class ApprentiNotice(Base):
"""Notices scrapées depuis Escada (read-only côté app, pas re-poussées).
Stratégie : à chaque pull, on supprime toutes les ApprentiNotice de
l'apprenti puis on ré-insère depuis Escada (full replace).
"""
__tablename__ = "apprenti_notices"
id: Mapped[int] = mapped_column(primary_key=True)
apprenti_id: Mapped[int] = mapped_column(ForeignKey("apprentis.id"))
date_event: Mapped[date]
type_notice: Mapped[Optional[str]] = mapped_column(String, nullable=True)
auteur: Mapped[Optional[str]] = mapped_column(String, nullable=True)
titre: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
remarque: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
matiere: Mapped[Optional[str]] = mapped_column(String, nullable=True)
visible_classe: Mapped[Optional[bool]] = mapped_column(nullable=True)
imported_at: Mapped[datetime] = mapped_column(default=datetime.now)
apprenti: Mapped["Apprenti"] = relationship()
class SanctionExport(Base):
__tablename__ = "sanctions_export"
@ -226,21 +287,25 @@ class CronJob(Base):
name: Mapped[str]
enabled: Mapped[bool] = mapped_column(default=True)
# schedule_kind ∈ {"daily", "weekly", "interval"}
# daily : schedule_value="HH:MM"
# schedule_kind ∈ {"daily_multi", "weekly"}
# daily_multi : schedule_value="HH:MM,HH:MM,..." (1..N heures par jour)
# weekly : schedule_value="MON,TUE,WED,THU,FRI:HH:MM"
# interval: schedule_value="60" (minutes)
schedule_kind: Mapped[str] = mapped_column(default="daily")
schedule_kind: Mapped[str] = mapped_column(default="daily_multi")
schedule_value: Mapped[str] = mapped_column(default="03:00")
# task_kind ∈ {"push", "sync", "push_then_sync"}
# Les sous-options sync_* déterminent _sur quoi_ le push/sync agit :
# push : push_to_escada.py si sync_abs, et/ou push_notices.py si sync_notices
# sync : sync_esacada.py si une de {sync_abs, sync_bn, sync_notes, sync_fiches},
# et/ou pull_notices.py si sync_notices
task_kind: Mapped[str] = mapped_column(default="push_then_sync")
# Sous-options pour task sync
# Sous-options : quelles données traiter
sync_abs: Mapped[bool] = mapped_column(default=True)
sync_bn: Mapped[bool] = mapped_column(default=True)
sync_notes: Mapped[bool] = mapped_column(default=True)
sync_fiches: Mapped[bool] = mapped_column(default=False)
sync_notices: Mapped[bool] = mapped_column(default=False)
force_abs: Mapped[bool] = mapped_column(default=False)
# Liste de classes en JSON, ou "ALL" pour toutes
@ -264,6 +329,23 @@ class CronJob(Base):
updated_at: Mapped[datetime] = mapped_column(default=datetime.now)
class FeedbackMessage(Base):
"""Message de feedback utilisateur (bug / proposition) collecté via le
widget chat in-app. Géré depuis la page admin /feedback."""
__tablename__ = "feedback_messages"
id: Mapped[int] = mapped_column(primary_key=True)
created_at: Mapped[datetime] = mapped_column(default=datetime.now)
created_by: Mapped[str] # username
user_email: Mapped[Optional[str]] = mapped_column(String, nullable=True)
type: Mapped[str] # "bug" | "feature"
message: Mapped[str] = mapped_column(Text)
context_url: Mapped[Optional[str]] = mapped_column(String, nullable=True) # page d'origine
status: Mapped[str] = mapped_column(default="new") # "new" | "in_progress" | "resolved"
admin_response: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
response_sent_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
def get_engine(db_url: str | None = None):
url = db_url or f"sqlite:///{DB_PATH}"
from sqlalchemy import event as _sa_event
@ -286,6 +368,55 @@ def init_db(engine=None):
for stmt in (
"ALTER TABLE sanctions_export ADD COLUMN nb_absences INTEGER",
"ALTER TABLE cron_jobs ADD COLUMN notify_level TEXT DEFAULT 'normal'",
"ALTER TABLE apprenti_fiches ADD COLUMN profession TEXT",
"ALTER TABLE apprenti_fiches ADD COLUMN compensation_desavantages BOOLEAN",
"ALTER TABLE apprenti_fiches ADD COLUMN resp_legal_nom TEXT",
"ALTER TABLE apprenti_fiches ADD COLUMN resp_legal_adresse TEXT",
"ALTER TABLE apprenti_fiches ADD COLUMN resp_legal_code_postal TEXT",
"ALTER TABLE apprenti_fiches ADD COLUMN resp_legal_localite TEXT",
"ALTER TABLE apprenti_fiches ADD COLUMN resp_legal_telephone_p TEXT",
"ALTER TABLE apprenti_fiches ADD COLUMN resp_legal_telephone_n TEXT",
"ALTER TABLE cron_jobs ADD COLUMN sync_notices BOOLEAN DEFAULT 0",
# Migration cron task_kind — schéma 3 valeurs + checkbox sync_notices.
# Étape A : pour les rows qui ciblaient les notices, on flag sync_notices=1
# et on désactive les autres data flags (avant de perdre l'info en B).
"""UPDATE cron_jobs SET
sync_notices = 1,
sync_abs = 0,
sync_bn = 0,
sync_notes = 0,
sync_fiches = 0
WHERE task_kind IN ('notices_push','notices_sync','notices_push_then_sync','push_notices')""",
# Étape B : on normalise task_kind sur les 3 valeurs canoniques.
"UPDATE cron_jobs SET task_kind='push' WHERE task_kind IN ('absences_push','notices_push')",
"UPDATE cron_jobs SET task_kind='sync' WHERE task_kind IN ('absences_sync','notices_sync')",
"UPDATE cron_jobs SET task_kind='push_then_sync' WHERE task_kind IN ('absences_push_then_sync','notices_push_then_sync','push_notices')",
"""CREATE TABLE IF NOT EXISTS apprenti_notices (
id INTEGER PRIMARY KEY,
apprenti_id INTEGER NOT NULL REFERENCES apprentis(id),
date_event DATE NOT NULL,
type_notice TEXT,
auteur TEXT,
titre TEXT,
remarque TEXT,
matiere TEXT,
visible_classe BOOLEAN,
imported_at DATETIME NOT NULL
)""",
"""CREATE TABLE IF NOT EXISTS notices (
id INTEGER PRIMARY KEY,
apprenti_id INTEGER NOT NULL REFERENCES apprentis(id),
date_event DATE NOT NULL,
titre TEXT NOT NULL,
remarque TEXT,
type_notice TEXT,
matiere TEXT,
source TEXT NOT NULL DEFAULT 'manual',
status TEXT NOT NULL DEFAULT 'pending',
created_at DATETIME NOT NULL,
created_by TEXT,
error_msg TEXT
)""",
"""CREATE TABLE IF NOT EXISTS escada_pending (
id INTEGER PRIMARY KEY,
apprenti_id INTEGER NOT NULL REFERENCES apprentis(id),
@ -301,6 +432,48 @@ def init_db(engine=None):
_conn.commit()
except Exception:
pass
# Migration cron schedule_kind : 'interval' (minutes) → 'daily_multi' (HH:MM,…)
# On déroule l'intervalle sur 24 h à partir de 00:00 et on enregistre la liste.
try:
with engine.connect() as _conn:
rows = _conn.execute(text(
"SELECT id, schedule_value FROM cron_jobs WHERE schedule_kind='interval'"
)).all()
for jid, val in rows:
try:
interval = int(val)
except (TypeError, ValueError):
interval = 0
if interval <= 0 or interval >= 1440:
# valeur invalide → on bascule sur une exécution quotidienne à minuit
new_value = "00:00"
else:
hours: list[str] = []
m = 0
while m < 1440:
hours.append(f"{m // 60:02d}:{m % 60:02d}")
m += interval
new_value = ",".join(hours)
_conn.execute(text(
"UPDATE cron_jobs SET schedule_kind='daily_multi', schedule_value=:v "
"WHERE id=:i"
), {"v": new_value, "i": jid})
_conn.commit()
except Exception:
pass
# Migration 'daily' (HH:MM) → 'daily_multi' (HH:MM unique). 'daily' devient
# un cas particulier de daily_multi avec une seule heure.
try:
with engine.connect() as _conn:
_conn.execute(text(
"UPDATE cron_jobs SET schedule_kind='daily_multi' WHERE schedule_kind='daily'"
))
_conn.commit()
except Exception:
pass
return engine
@ -311,10 +484,13 @@ def upsert_apprenti_fiche(session: Session, apprenti_id: int, data: dict) -> Non
).scalar_one_or_none()
fields = [
"adresse", "code_postal", "localite", "telephone", "email",
"date_naissance", "majeur",
"date_naissance", "majeur", "compensation_desavantages",
"entreprise_nom", "entreprise_adresse", "entreprise_code_postal",
"entreprise_localite", "entreprise_telephone", "entreprise_email",
"formateur_nom", "formateur_email",
"profession",
"resp_legal_nom", "resp_legal_adresse", "resp_legal_code_postal",
"resp_legal_localite", "resp_legal_telephone_p", "resp_legal_telephone_n",
]
if existing:
for f in fields:

View file

@ -7,6 +7,7 @@ Two PDF variants:
groups: Branches professionnelles (BP) + Travaux pratiques (TP)
Extracted rows (per group):
- Branches individuelles branches: [{"nom": str, "notes": [...]}]
- Moyenne semestrielle du groupe moy_sem[0..7]
- Moyenne annuelle du groupe moy_ann[0..7] (non-null at Sem.2,4,6,8 positions)
@ -70,7 +71,14 @@ def _extract_name(page) -> tuple[str, str]:
)
skip_kw = {"EPTM", "Professionnelle", "Technique", "Département",
"Service", "Ecole", "École", "formation", "Canton",
"Kanton", "page", "Sion", "Saint", "BULLETIN", "NOTES"}
"Kanton", "page", "Sion", "Saint", "BULLETIN", "NOTES",
# En-têtes de colonnes du PDF qui peuvent être pris pour un
# nom d'élève si le bloc adresse n'est pas trouvé.
"Profession", "Automaticien", "Monteur", "Electronicien",
"Polymécanicien", "CFC", "AFP", "Classe", "Titulaire",
# Libellés du tableau de notes (cas où le tableau commence
# haut sur la page et le bloc adresse est absent).
"Moyenne", "Branches", "Travaux", "Culture", "globale", "groupe"}
for line_words in lines:
text = " ".join(w["text"] for w in line_words).strip()
@ -89,8 +97,53 @@ def _extract_name(page) -> tuple[str, str]:
return "", ""
def _find_bn_table_obj(page):
"""Retourne l'objet Table (avec bbox) correspondant à la table des notes,
et le contenu extrait sous forme list[list[str]]. Garder l'objet permet
d'utiliser les bbox de chaque cellule pour aligner les sous-lignes."""
for tbl in page.find_tables():
ext = tbl.extract()
if not ext or len(ext) < 4:
continue
header = ext[0]
if len(header) >= 7 and any(h and "Sem." in str(h) for h in header):
return tbl, ext
return None, None
def _cell_lines(page, bbox):
"""Retourne la liste des lignes visuelles dans une cellule, avec leur
position verticale (top) sert à aligner branches notes."""
if bbox is None:
return []
try:
words = page.crop(bbox).extract_words()
except Exception:
return []
if not words:
return []
words.sort(key=lambda w: (w["top"], w["x0"]))
lines: list[list[dict]] = [[words[0]]]
for w in words[1:]:
if abs(w["top"] - lines[-1][-1]["top"]) < 4:
lines[-1].append(w)
else:
lines.append([w])
out = []
for ln in lines:
ln.sort(key=lambda w: w["x0"])
out.append({
"top": sum(w["top"] for w in ln) / len(ln),
"text": " ".join(w["text"] for w in ln).strip(),
})
return out
def _find_bn_table(tables: list) -> list | None:
"""Return the first table that looks like the BN grades table (≥7 cols, Sem. header)."""
"""Return the first table that looks like the BN grades table (≥7 cols, Sem. header).
Kept for backward compatibility preferred path is _find_bn_table_obj.
"""
for tbl in tables:
if not tbl or len(tbl) < 4:
continue
@ -129,8 +182,7 @@ def parse_bn_page(page) -> dict | None:
nom, prenom = _extract_name(page)
tables = page.extract_tables()
bn_table = _find_bn_table(tables)
table_obj, bn_table = _find_bn_table_obj(page)
if not bn_table:
return None
@ -143,14 +195,61 @@ def parse_bn_page(page) -> dict | None:
while len(sem_labels) < 8:
sem_labels.append(None)
table_rows = table_obj.rows # bbox-aware rows, indexed comme bn_table
# Parse data rows
current_group: str | None = None
groups: dict[str, dict] = {}
globale: dict[str, list] = {"moy_sem": [None] * 8, "moy_ann": [None] * 8}
for row in bn_table[1:]:
def _empty_group() -> dict:
return {
"moy_sem": [None] * 8,
"moy_ann": [None] * 8,
"branches": [],
}
def _branches_from_bbox(table_row) -> list[dict]:
"""Démultiplexe une ligne du tableau en plusieurs branches en utilisant
la position verticale des mots dans chaque cellule. Indispensable car
pdfplumber.extract_tables() ne préserve PAS les sous-lignes vides
(ex: 25 branches dans le label, 7 valeurs visibles dans la colonne
Sem.1 l'approche par split('\\n') décale tout)."""
if table_row is None:
return []
cells = table_row.cells
if not cells or len(cells) < 2 or cells[0] is None:
return []
label_lines = _cell_lines(page, cells[0])
if not label_lines:
return []
col_lines: list[list[dict]] = []
for i in range(8):
bbox = cells[i + 1] if (i + 1) < len(cells) else None
col_lines.append(_cell_lines(page, bbox))
branches = []
for lab in label_lines:
notes = []
for col in col_lines:
match = None
for nl in col:
if abs(nl["top"] - lab["top"]) < 4:
match = _to_float(nl["text"])
break
notes.append(match)
branches.append({"nom": lab["text"], "notes": notes})
return branches
stop = False # bascule à True après "moyenne annuelle globale" → ignore
# les lignes "Absences", "Observations", etc.
for idx in range(1, len(bn_table)):
if stop:
continue
row = bn_table[idx]
if not row or not row[0]:
continue
table_row = table_rows[idx] if idx < len(table_rows) else None
label = str(row[0]).strip()
vals = [
_to_float(row[i + 1]) if (i + 1) < len(row) else None
@ -159,15 +258,20 @@ def parse_bn_page(page) -> dict | None:
low = label.lower()
if "branches de culture" in low or "culture g" in low:
# Headers de groupe = label avec coefficient "(Nx)" (ex: "Travaux
# pratiques (1x)"). Indispensable pour distinguer du label de
# branche homonyme "Travaux pratiques" qui apparaît parfois.
is_group_header = bool(re.search(r"\(\d+x\)", low))
if is_group_header and ("branches de culture" in low or "culture g" in low):
current_group = "CG"
groups.setdefault("CG", {"moy_sem": [None] * 8, "moy_ann": [None] * 8})
elif "branches professionnelles" in low:
groups.setdefault("CG", _empty_group())
elif is_group_header and "branches professionnelles" in low:
current_group = "BP"
groups.setdefault("BP", {"moy_sem": [None] * 8, "moy_ann": [None] * 8})
elif "travaux pratiques" in low:
groups.setdefault("BP", _empty_group())
elif is_group_header and "travaux pratiques" in low:
current_group = "TP"
groups.setdefault("TP", {"moy_sem": [None] * 8, "moy_ann": [None] * 8})
groups.setdefault("TP", _empty_group())
elif "moyenne semestrielle du groupe" in low and current_group:
groups[current_group]["moy_sem"] = vals
elif "moyenne annuelle du groupe" in low and current_group:
@ -176,6 +280,14 @@ def parse_bn_page(page) -> dict | None:
globale["moy_sem"] = vals
elif "moyenne annuelle globale" in low:
globale["moy_ann"] = vals
stop = True # tout ce qui suit (Absences, Observations) est ignoré
elif current_group is not None:
# Toute autre ligne dans un groupe = branches individuelles.
# On utilise la position verticale (bbox) pour aligner branches
# ↔ notes — voir docstring de _branches_from_bbox.
groups[current_group]["branches"].extend(
_branches_from_bbox(table_row)
)
if not groups:
return None

235
src/parser_liste_classe.py Normal file
View file

@ -0,0 +1,235 @@
"""PDF parser for the EPTM "Liste de la classe" report.
Source : bouton "Liste des classes" sur ViewLernende d'une classe Escada
(`Reports/RptEscada.aspx?...`).
Layout par apprenti (4 colonnes alignées verticalement) :
Col 1 (Apprenti) : Nom Prénom / Adresse / CP Localité / Tél / Email
Col 2 (Formation) : Métier / Date naissance / Origine
Col 3 (Entreprise) : Nom / Adresse / CP Loc / Tél / (Formateur en dessous)
Col 4 (Resp. lég.) : Civilité Nom / Adresse / CP Localité / P:tél / N:tél
Le resp. légal n'est présent que pour les apprentis MINEURS — les majeurs
ont une 4e colonne vide.
Sortie de parse_liste_classe_pdf() :
{
"classe": "AUTOMAT 1",
"apprentis": [
{
"nom_eleve": "Clivaz Eloan",
"resp_legal_nom": "Madame Diana Linda Clivaz",
"resp_legal_adresse": "Route du Fougir 6",
"resp_legal_code_postal": "3971",
"resp_legal_localite": "Chermignon",
"resp_legal_telephone_p": "+41 27 483 36 27",
"resp_legal_telephone_n": "+41 79 103 14 79",
},
...
]
}
"""
from __future__ import annotations
import re
from pathlib import Path
import pdfplumber
_RE_CLASSE = re.compile(r"Liste de la classe\s+([^\n]+?)\s*$", re.I | re.M)
_RE_CP_LOC = re.compile(r"^(\d{4})\s+(.+)$")
_RE_DATE = re.compile(r"\b\d{2}\.\d{2}\.\d{4}\b")
_RE_CIVILITE = re.compile(r"^(Monsieur|Madame)\s+(.+)$")
_RE_TEL_P = re.compile(r"^P:\s*(.+)$")
_RE_TEL_N = re.compile(r"^N:\s*(.+)$")
# Colonnes du rapport (en points PDF, page A4 portrait = 612pt large).
# Calibrées sur le template Escada (en-têtes "Apprenti / Formation /
# Entreprise / Resp. légal" aux x0 ≈ 56 / 184 / 302 / 433).
_COL_APPRENTI = ( 50, 184)
_COL_FORMATION = (184, 302)
_COL_ENTREPRISE = (302, 425)
_COL_RESP_LEGAL = (425, 612)
def _group_words_by_line(words: list[dict], y_tol: float = 3.0) -> list[list[dict]]:
"""Cluster words par ligne visuelle (top similaire)."""
if not words:
return []
ws = sorted(words, key=lambda w: (w["top"], w["x0"]))
lines: list[list[dict]] = [[ws[0]]]
for w in ws[1:]:
if abs(w["top"] - lines[-1][-1]["top"]) < y_tol:
lines[-1].append(w)
else:
lines.append([w])
for line in lines:
line.sort(key=lambda w: w["x0"])
return lines
def _words_in_col(line: list[dict], x_min: float, x_max: float) -> str:
"""Joint les mots d'une ligne dont x0 est dans la plage [x_min, x_max]."""
cells = [w["text"] for w in line if x_min <= w["x0"] < x_max]
return " ".join(cells).strip()
def _is_header_line(line_text: str) -> bool:
"""True si la ligne est un en-tête de page (à ignorer)."""
low = line_text.lower()
return any(kw in low for kw in (
"département de l'économie", "ecole professionnelle",
"liste de la classe", "titulaire", "apprenti portable",
"formation date de", "origine", "resp. légal",
"chemin st-hubert", "entreprise formateur",
)) or line_text.startswith("Total:")
def _parse_apprenti_block(block_lines: list[tuple[str, str, str, str]]) -> dict:
"""Parse les 4 colonnes d'un bloc d'apprenti.
block_lines : list de tuples (col_apprenti, col_formation, col_entreprise, col_resp_legal)
"""
# Col Apprenti
col_ap = [c[0] for c in block_lines if c[0]]
nom_eleve = col_ap[0] if col_ap else ""
# Nom d'entreprise depuis col Formation + col Entreprise de la PREMIÈRE
# ligne du bloc. Le PDF Escada concatène CFC/AFP avec le début du nom
# ("CFCTelsa") et peut couper le reste en col E ("SA"). On joint les deux
# pour reconstruire (ex: "Monteur automaticien CFCTelsa SA" → "Telsa SA",
# ou "Automaticien CFC Constellium Valais SA" → "Constellium Valais SA").
# Utilisé en fallback quand la cellule ViewLernende ne renvoie pas le nom.
entreprise_nom_pdf = None
first_row = next((r for r in block_lines if r[1] or r[2]), None)
if first_row:
line_text = f"{first_row[1]} {first_row[2]}".strip()
m_ent = re.search(r"\b(?:CFC|AFP)\s*([A-ZÀ-Ÿ].*)", line_text)
if m_ent:
entreprise_nom_pdf = m_ent.group(1).strip()
# Col Resp. Légal — extraction
col_rl = [c[3] for c in block_lines if c[3]]
if not col_rl:
return {
"nom_eleve": nom_eleve,
"entreprise_nom_pdf": entreprise_nom_pdf,
} # apprenti majeur, pas de resp.
rl_nom = ""
rl_adresse = ""
rl_cp = ""
rl_loc = ""
rl_tp = ""
rl_tn = ""
for line in col_rl:
m = _RE_CIVILITE.match(line)
if m and not rl_nom:
rl_nom = f"{m.group(1)} {m.group(2)}".strip()
continue
m = _RE_CP_LOC.match(line)
if m and not rl_cp:
rl_cp = m.group(1)
rl_loc = m.group(2).strip()
continue
m = _RE_TEL_P.match(line)
if m:
rl_tp = m.group(1).strip()
continue
m = _RE_TEL_N.match(line)
if m:
rl_tn = m.group(1).strip()
continue
# Ligne non matchée. Si elle ne contient pas de chiffre, c'est la
# suite du nom (ex: "Madame Séverine Massy" / "Luisier" sur 2 lignes).
# Sinon c'est l'adresse (rue avec numéro).
if rl_nom and not rl_adresse:
if any(c.isdigit() for c in line):
rl_adresse = line.strip()
else:
rl_nom = f"{rl_nom} {line.strip()}".strip()
if not rl_nom:
return {
"nom_eleve": nom_eleve,
"entreprise_nom_pdf": entreprise_nom_pdf,
}
return {
"nom_eleve": nom_eleve,
"entreprise_nom_pdf": entreprise_nom_pdf,
"resp_legal_nom": rl_nom,
"resp_legal_adresse": rl_adresse or None,
"resp_legal_code_postal": rl_cp or None,
"resp_legal_localite": rl_loc or None,
"resp_legal_telephone_p": rl_tp or None,
"resp_legal_telephone_n": rl_tn or None,
}
def parse_liste_classe_pdf(pdf_path: Path) -> dict:
"""Parse le PDF "Liste de la classe" et retourne classe + liste d'apprentis
avec leurs représentants légaux (si mineur)."""
pdf_path = Path(pdf_path)
classe = ""
apprentis: list[dict] = []
# Pour identifier la fin d'un bloc apprenti : nouvelle ligne avec un nom
# en col 1 dont la première position y est > précédente + un seuil. Plus
# simple : on regroupe par bloc selon la présence d'une ligne "Formation"
# (col 2) qui contient un métier (ex. "Automaticien CFC"). Chaque
# apparition d'une telle ligne démarre un nouveau bloc.
with pdfplumber.open(str(pdf_path)) as pdf:
for page in pdf.pages:
text = page.extract_text() or ""
if not classe:
m = _RE_CLASSE.search(text)
if m:
classe = m.group(1).strip()
words = page.extract_words()
lines = _group_words_by_line(words)
# Convertir chaque ligne en (col1, col2, col3, col4) selon x0
structured = []
for line in lines:
row = (
_words_in_col(line, *_COL_APPRENTI),
_words_in_col(line, *_COL_FORMATION),
_words_in_col(line, *_COL_ENTREPRISE),
_words_in_col(line, *_COL_RESP_LEGAL),
)
joined = " ".join(c for c in row if c).strip()
if _is_header_line(joined):
continue
if not any(row):
continue
structured.append(row)
# Découpe en blocs : un nouveau bloc commence quand col2 contient
# un métier ("CFC" ou "AFP" en col Formation). Le PDF Escada
# concatène parfois CFC + nom d'entreprise sans espace
# ("CFCBOBST", "CFCBühler") → on accepte "CFC"/"AFP" en début de
# mot, sans exiger une frontière à droite.
blocks: list[list[tuple]] = []
current: list[tuple] | None = None
for row in structured:
col2 = row[1]
is_new = bool(re.search(r"(\s|^)(CFC|AFP)", col2))
if is_new:
if current:
blocks.append(current)
current = []
if current is not None:
current.append(row)
if current:
blocks.append(current)
for blk in blocks:
fiche = _parse_apprenti_block(blk)
if fiche.get("nom_eleve"):
apprentis.append(fiche)
return {"classe": classe, "apprentis": apprentis}

113
src/profession.py Normal file
View file

@ -0,0 +1,113 @@
"""Helper pour la résolution `classe → profession` via mapping configurable.
Mapping stocké dans `data/settings.json` sous la clé `class_profession_mapping`,
forme : `[{"prefix": "AUTOMAT", "profession": "Automaticien CFC"}, ...]`.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Optional
from sqlalchemy import select
from sqlalchemy.orm import Session
from src.db import Apprenti, ApprentiFiche, upsert_apprenti_fiche
_ROOT = Path(__file__).resolve().parent.parent
_DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data")))
_SETTINGS_PATH = _DATA_DIR / "settings.json"
_DEFAULT_MAPPING = [
{"prefix": "AUTOMAT", "profession": "Automaticien CFC"},
{"prefix": "MONTAUT", "profession": "Monteur Automaticien CFC"},
{"prefix": "EM-AU", "profession": "Automaticien CFC"},
]
def _load_settings() -> dict:
if _SETTINGS_PATH.exists():
try:
return json.loads(_SETTINGS_PATH.read_text(encoding="utf-8"))
except Exception:
return {}
return {}
def _save_settings(s: dict) -> None:
_SETTINGS_PATH.write_text(json.dumps(s, ensure_ascii=False, indent=2), encoding="utf-8")
def load_mapping() -> list[dict]:
"""Renvoie la liste des correspondances [{prefix, profession}, ...]."""
s = _load_settings()
return list(s.get("class_profession_mapping", _DEFAULT_MAPPING))
def save_mapping(mapping: list[dict]) -> None:
"""Sauve le mapping (filtre les entrées vides)."""
cleaned = [
{"prefix": (m.get("prefix") or "").strip(), "profession": (m.get("profession") or "").strip()}
for m in mapping
]
cleaned = [m for m in cleaned if m["prefix"] and m["profession"]]
s = _load_settings()
s["class_profession_mapping"] = cleaned
_save_settings(s)
def resolve_profession(classe: str, mapping: Optional[list[dict]] = None) -> str:
"""Renvoie la profession matchant le préfixe de la classe, ou '' si aucun."""
if not classe:
return ""
if mapping is None:
mapping = load_mapping()
# On préfère le préfixe le plus long en cas de chevauchement
for entry in sorted(mapping, key=lambda m: -len(m.get("prefix", ""))):
prefix = entry.get("prefix", "")
if prefix and classe.startswith(prefix):
return entry.get("profession", "")
return ""
def find_unmapped_classes(session: Session) -> list[str]:
"""Liste les classes en DB sans correspondance dans le mapping.
Exclut MP/MI (déjà filtrées partout dans l'app).
"""
mapping = load_mapping()
classes = session.execute(
select(Apprenti.classe).distinct().order_by(Apprenti.classe)
).scalars().all()
out = []
for c in classes:
if not c or c.startswith(("MP", "MI")):
continue
if not resolve_profession(c, mapping):
out.append(c)
return out
def refresh_all_professions(session: Session) -> int:
"""Recalcule `profession` pour tous les apprentis en base.
Renvoie le nombre de fiches mises à jour. Utile :
- une fois à l'init après ajout du champ
- après modification du mapping dans Paramètres
- après une sync Escada
"""
mapping = load_mapping()
apprentis = session.execute(select(Apprenti)).scalars().all()
n = 0
for ap in apprentis:
prof = resolve_profession(ap.classe, mapping)
if not prof:
# Pas de mapping → on laisse la valeur existante si présente
continue
# upsert : crée la fiche si elle n'existe pas, sinon met à jour profession
upsert_apprenti_fiche(session, ap.id, {"profession": prof})
n += 1
session.commit()
return n

257
src/retenue_pdf.py Normal file
View file

@ -0,0 +1,257 @@
"""Génération d'avis de retenue à partir du template AcroForm.
Template : `data/templates/GF_FO_Avis_de_retenue.pdf`. Le champ `Date` du
template a 3 widgets-enfants partagés (un par ligne du formulaire). On les
sépare en 3 champs distincts (`Date_devoir`, `Date_comportement`, `Date_retard`)
puis on remplit uniquement celui correspondant à la case cochée.
Le PDF généré reste éditable (formulaire préservé).
"""
from __future__ import annotations
import io
import os
from datetime import date as _date
from pathlib import Path
from typing import Optional
import pypdf
from sqlalchemy.orm import Session
from src.db import Apprenti, ApprentiFiche
_ROOT = Path(__file__).resolve().parent.parent
_DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data")))
_TEMPLATE_PATH = _DATA_DIR / "templates" / "GF_FO_Avis_de_retenue.pdf"
_MOIS_FR = [
"janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
]
# Mapping case → suffixe + index (ordre des widgets enfants triés par Y desc)
_CASE_TO_SUFFIX = {
"devoir": ("Date_devoir", 0),
"comportement": ("Date_comportement", 1),
"retard": ("Date_retard", 2),
}
def format_date_long(d: _date) -> str:
"""Formate une date en 'jour mois année' (ex: '12 mars 2026')."""
return f"{d.day} {_MOIS_FR[d.month - 1]} {d.year}"
def _destinataire(
apprenti: "Apprenti", fiche: Optional["ApprentiFiche"]
) -> tuple[str, str, str]:
"""Renvoie (nom, adresse, "NPA Localité") du destinataire de l'avis.
Apprenti mineur représentant légal. Sinon apprenti lui-même.
L'adresse de l'entreprise n'est jamais utilisée.
"""
if not fiche:
return f"{apprenti.prenom} {apprenti.nom}".strip(), "", ""
if fiche.majeur is False:
cp = (fiche.resp_legal_code_postal or "").strip()
loc = (fiche.resp_legal_localite or "").strip()
return (
(fiche.resp_legal_nom or "").strip(),
(fiche.resp_legal_adresse or "").strip(),
f"{cp} {loc}".strip(),
)
cp = (fiche.code_postal or "").strip()
loc = (fiche.localite or "").strip()
return (
f"{apprenti.prenom} {apprenti.nom}".strip(),
(fiche.adresse or "").strip(),
f"{cp} {loc}".strip(),
)
def generate_retenue_pdf(
sess: Session,
apprenti_id: int,
*,
profession: str,
retenue_date: _date,
probleme_date: _date,
case: str, # "devoir" | "comportement" | "retard"
branche: str = "",
remarque: str = "",
prof_name: str = "",
) -> Optional[bytes]:
"""Pré-remplit le template puis aplatit le PDF. Renvoie les bytes du PDF aplati."""
if not _TEMPLATE_PATH.exists():
return None
apprenti = sess.get(Apprenti, apprenti_id)
if apprenti is None:
return None
fiche: Optional[ApprentiFiche] = apprenti.fiche
classe_full = (
f"{profession.strip()} {apprenti.classe}".strip()
if profession else apprenti.classe
)
# Destinataire : représentant légal si mineur, sinon l'apprenti lui-même.
# L'adresse de l'entreprise n'est plus utilisée.
dest_nom, dest_adresse, dest_npa_ville = _destinataire(apprenti, fiche)
# 1. Lecture template + clone
reader = pypdf.PdfReader(str(_TEMPLATE_PATH))
writer = pypdf.PdfWriter(clone_from=reader)
# 2. Séparer les 3 widgets du champ Date en 3 champs distincts.
# Après cette opération, on peut remplir chaque Date_xxx individuellement.
_split_date_field(writer)
# 3. Remplit les champs texte (Date_xxx inclus pour la case sélectionnée)
target_date_field = _CASE_TO_SUFFIX.get(case, (None, None))[0]
field_values: dict[str, str] = {
"NomApprenti": f"{apprenti.prenom} {apprenti.nom}".strip(),
"Classe": classe_full,
"NomEntreprise": dest_nom,
"Adresse": dest_adresse,
"NPA-Ville": dest_npa_ville,
"RetenueDateHeure": retenue_date.strftime("%d.%m.%Y"),
"Branche": branche if case == "devoir" else "",
"Remarque": remarque,
"DateAvis": format_date_long(_date.today()),
"Profs": prof_name or "",
}
if target_date_field:
field_values[target_date_field] = probleme_date.strftime("%d.%m.%Y")
for page in writer.pages:
try:
writer.update_page_form_field_values(
page, field_values, auto_regenerate=False,
)
except Exception:
pass
# 4. Checkboxes
case_to_field = {
"devoir": "CaseDevoir",
"comportement": "CaseComportement",
"retard": "CaseRetard",
}
target_check = case_to_field.get(case)
for fname in case_to_field.values():
try:
_set_checkbox(writer, fname, fname == target_check)
except Exception:
pass
# 5. Force NeedAppearances pour que les viewers redessinent les valeurs
try:
root = writer._root_object
if "/AcroForm" in root:
root["/AcroForm"].update({
pypdf.generic.NameObject("/NeedAppearances"):
pypdf.generic.BooleanObject(True)
})
except Exception:
pass
# 6. Écriture (formulaire préservé éditable)
buf = io.BytesIO()
writer.write(buf)
return buf.getvalue()
def _split_date_field(writer: pypdf.PdfWriter) -> None:
"""Sépare le champ `Date` (avec 3 widgets enfants) en 3 champs indépendants.
Renomme les widgets selon leur position Y (ordre du haut vers le bas) :
kid #0 (haut) → Date_devoir
kid #1 (milieu) → Date_comportement
kid #2 (bas) → Date_retard
"""
NameObject = pypdf.generic.NameObject
acroform_ref = writer._root_object.get("/AcroForm")
if not acroform_ref:
return
acroform = acroform_ref.get_object() if hasattr(acroform_ref, "get_object") else acroform_ref
fields = acroform.get("/Fields") or []
date_field = None
date_ref = None
for f in fields:
if f.get_object().get("/T") == "Date":
date_field = f.get_object()
date_ref = f
break
if date_field is None:
return
kids = date_field.get("/Kids") or []
if not kids:
return
# Trier les enfants par Y descendant
indexed = []
for kid in kids:
ko = kid.get_object()
rect = ko.get("/Rect")
y = float(rect[1]) if rect else 0.0
indexed.append((y, kid, ko))
indexed.sort(key=lambda t: -t[0])
# Promouvoir chaque enfant en champ indépendant
new_fields = []
suffixes_by_order = ["Date_devoir", "Date_comportement", "Date_retard"]
for i, (_y, kid_ref, kid_obj) in enumerate(indexed):
# Renomme : donne un /T propre à l'ancien widget enfant
kid_obj[NameObject("/T")] = pypdf.generic.create_string_object(
suffixes_by_order[i]
)
# Hériter du /FT, /DA, /Q du parent si manquant sur l'enfant
for prop in ("/FT", "/DA", "/Q"):
if prop not in kid_obj and prop in date_field:
kid_obj[NameObject(prop)] = date_field[prop]
# Détacher du parent
if "/Parent" in kid_obj:
del kid_obj[NameObject("/Parent")]
new_fields.append(kid_ref)
# Retirer l'ancien champ Date de /Fields, ajouter les 3 nouveaux
new_field_list = [f for f in fields if f is not date_ref] + new_fields
acroform[NameObject("/Fields")] = pypdf.generic.ArrayObject(new_field_list)
def _find_field(writer: pypdf.PdfWriter, name: str):
acroform = writer._root_object.get("/AcroForm")
if not acroform:
return None
for f in acroform.get("/Fields") or []:
obj = f.get_object()
if obj.get("/T") == name:
return obj
return None
def _set_checkbox(writer: pypdf.PdfWriter, field_name: str, checked: bool) -> None:
"""Coche/décoche une checkbox AcroForm, gère les widgets enfants sans /T."""
NameObject = pypdf.generic.NameObject
field = _find_field(writer, field_name)
if field is None:
return
kids = field.get("/Kids")
widgets = [k.get_object() for k in kids] if kids else [field]
on_value = "/Yes"
for widget in widgets:
ap = widget.get("/AP") or field.get("/AP")
if ap is not None:
n_ap = ap.get("/N") if hasattr(ap, "get") else None
if n_ap is not None:
for k in n_ap.keys():
ks = str(k)
if ks not in ("/Off", "Off"):
on_value = ks if ks.startswith("/") else f"/{ks}"
break
new_val = NameObject(on_value if checked else "/Off")
widget[NameObject("/AS")] = new_val
field[NameObject("/V")] = NameObject(on_value if checked else "/Off")

View file

@ -40,10 +40,41 @@ def _load_settings() -> dict:
return {}
def _destinataire(
apprenti: "Apprenti", fiche: Optional["ApprentiFiche"]
) -> tuple[str, str, str]:
"""Renvoie (nom, adresse, "NPA Localité") du destinataire de l'avis.
Apprenti mineur représentant légal (resp_legal_*).
Apprenti majeur (ou statut inconnu) adresse personnelle de l'apprenti.
L'adresse de l'entreprise n'est jamais utilisée.
"""
if not fiche:
nom = f"{apprenti.prenom} {apprenti.nom}".strip()
return nom, "", ""
if fiche.majeur is False:
cp = (fiche.resp_legal_code_postal or "").strip()
loc = (fiche.resp_legal_localite or "").strip()
return (
(fiche.resp_legal_nom or "").strip(),
(fiche.resp_legal_adresse or "").strip(),
f"{cp} {loc}".strip(),
)
cp = (fiche.code_postal or "").strip()
loc = (fiche.localite or "").strip()
return (
f"{apprenti.prenom} {apprenti.nom}".strip(),
(fiche.adresse or "").strip(),
f"{cp} {loc}".strip(),
)
def generate_avis_pdf(
sess: Session,
apprenti_id: int,
prof_name: str = "",
texte_override: Optional[str] = None,
chef_override: Optional[str] = None,
) -> Optional[bytes]:
"""Renvoie les bytes d'un PDF d'avis de sanction pré-rempli pour l'apprenti.
@ -51,6 +82,9 @@ def generate_avis_pdf(
NomParents = nom entreprise) puisque les parents ne sont pas stockés.
Texte de description et chef de section depuis data/settings.json.
Si `texte_override` ou `chef_override` est fourni (non vide), il remplace
la valeur issue des paramètres.
Renvoie None si le template est introuvable ou l'apprenti n'existe pas.
"""
if not _TEMPLATE_PATH.exists():
@ -63,23 +97,28 @@ def generate_avis_pdf(
fiche: Optional[ApprentiFiche] = apprenti.fiche
settings = _load_settings()
# Construction des valeurs
npa_ville = ""
if fiche:
cp = (fiche.entreprise_code_postal or "").strip()
loc = (fiche.entreprise_localite or "").strip()
npa_ville = f"{cp} {loc}".strip()
# Destinataire : représentant légal si mineur, sinon l'apprenti lui-même.
# L'adresse de l'entreprise n'est plus utilisée.
dest_nom, dest_adresse, dest_npa_ville = _destinataire(apprenti, fiche)
field_values: dict[str, str] = {
"NomApprenti": f"{apprenti.prenom} {apprenti.nom}".strip(),
"Classe": apprenti.classe or "",
"NomParents": (fiche.entreprise_nom if fiche else "") or "",
"Adresse": (fiche.entreprise_adresse if fiche else "") or "",
"NPA-Ville": npa_ville,
"NomParents": dest_nom,
"Adresse": dest_adresse,
"NPA-Ville": dest_npa_ville,
"Date": date.today().strftime("%d.%m.%Y"),
"TexteDescription": settings.get("texte_sanction") or _DEFAULT_TEXTE_SANCTION,
"TexteDescription": (
(texte_override or "").strip()
or settings.get("texte_sanction")
or _DEFAULT_TEXTE_SANCTION
),
"Prof": prof_name or "",
"CS": settings.get("chef_section") or _DEFAULT_CHEF_SECTION,
"CS": (
(chef_override or "").strip()
or settings.get("chef_section")
or _DEFAULT_CHEF_SECTION
),
}
# Lecture du template + clone vers writer (préserve la structure AcroForm)

View file

@ -1,6 +1,7 @@
"""Fonctions de calcul pour les dashboards (sans dépendance Streamlit)."""
import io
import json
from datetime import date, timedelta
from itertools import groupby
@ -8,7 +9,10 @@ import pandas as pd
from sqlalchemy import case, func, or_, select
from sqlalchemy.orm import Session
from src.db import Absence, Apprenti, Import
from src.db import (
Absence, Apprenti, Import,
NotesBulletin, NotesMatu, ImportBN, ImportMatu,
)
# ── Helpers semestre ──────────────────────────────────────────────────────────
@ -243,3 +247,118 @@ def export_excel_global(session: Session, semestre: str | None = None) -> bytes:
df_syn.to_excel(writer, sheet_name=sheet, index=False)
return buf.getvalue()
# ── Alertes notes insuffisantes (BN / Matu < 4.0) ─────────────────────────────
def _last_filled(arr):
"""Dernière valeur non-null dune liste, ou None."""
if not arr:
return None
for v in reversed(arr):
if v is None:
continue
try:
return float(v)
except (TypeError, ValueError):
continue
return None
def alertes_notes_insuffisantes(
session: Session, allowed_classes: list[str] | None = None,
) -> list[dict]:
"""Liste les apprentis avec une moyenne insuffisante (< 4.0) :
- sur le BN : dernier moy_sem global non-null OU dernier moy_ann global non-null
- sur la Matu : champ moy < 4.0
Retourne une liste de dicts triés par classe puis nom :
{id, nom, prenom, classe, worst (float), types (list[str]),
bn_sem, bn_ann, matu (None si non concerné)}.
"""
q = select(Apprenti).order_by(Apprenti.classe, Apprenti.nom, Apprenti.prenom)
if allowed_classes is not None:
q = q.where(Apprenti.classe.in_(allowed_classes))
apprentis = session.execute(q).scalars().all()
if not apprentis:
return []
ids = [a.id for a in apprentis]
# Latest BN par apprenti (1 query)
bn_rows = session.execute(
select(NotesBulletin, ImportBN.date_import)
.join(ImportBN, ImportBN.id == NotesBulletin.import_id)
.where(NotesBulletin.apprenti_id.in_(ids))
.order_by(ImportBN.date_import.desc())
).all()
bn_by_id = {}
for bn, _dt in bn_rows:
bn_by_id.setdefault(bn.apprenti_id, bn) # premier (= plus récent)
# Latest Matu par apprenti (1 query)
nm_rows = session.execute(
select(NotesMatu, ImportMatu.date_import)
.join(ImportMatu, ImportMatu.id == NotesMatu.import_id)
.where(NotesMatu.apprenti_id.in_(ids))
.order_by(ImportMatu.date_import.desc())
).all()
nm_by_id = {}
for nm, _dt in nm_rows:
nm_by_id.setdefault(nm.apprenti_id, nm)
alerts = []
for ap in apprentis:
bn = bn_by_id.get(ap.id)
nm = nm_by_id.get(ap.id)
# Valeurs "brutes" : dernière non-null peu importe le seuil (utile
# pour afficher la moyenne annuelle en contexte quand la sem est insuf).
bn_sem_val = bn_ann_val = matu_val = None
if bn:
try:
d = json.loads(bn.donnees_json or "{}")
except (ValueError, TypeError):
d = {}
g = d.get("globale", {}) or {}
bn_sem_val = _last_filled(g.get("moy_sem"))
bn_ann_val = _last_filled(g.get("moy_ann"))
if nm and nm.moy is not None:
try:
matu_val = float(nm.moy)
except (TypeError, ValueError):
matu_val = None
# Flags d'insuffisance
bn_sem_insuf = bn_sem_val is not None and bn_sem_val < 4.0
bn_ann_insuf = bn_ann_val is not None and bn_ann_val < 4.0
matu_insuf = matu_val is not None and matu_val < 4.0
if not (bn_sem_insuf or bn_ann_insuf or matu_insuf):
continue
types = []
if bn_sem_insuf: types.append("BN sem.")
if bn_ann_insuf: types.append("BN ann.")
if matu_insuf: types.append("Matu")
worst = min(v for v in (
bn_sem_val if bn_sem_insuf else None,
bn_ann_val if bn_ann_insuf else None,
matu_val if matu_insuf else None,
) if v is not None)
alerts.append({
"id": ap.id,
"nom": ap.nom,
"prenom": ap.prenom,
"classe": ap.classe,
"worst": round(worst, 1),
"types": types,
# Valeurs brutes (toujours, si dispo)
"bn_sem": bn_sem_val,
"bn_ann": bn_ann_val,
"matu": matu_val,
# Flags : laquelle est < 4
"bn_sem_insuf": bn_sem_insuf,
"bn_ann_insuf": bn_ann_insuf,
"matu_insuf": matu_insuf,
})
return alerts

View file

@ -28,9 +28,13 @@ def _load_user(username: str) -> Optional[dict]:
def get_allowed_classes(username: str) -> Optional[list[str]]:
"""Retourne la liste des classes autorisées pour l'utilisateur.
- None : aucune restriction (admin, ou champ vide / absent)
- [] : restriction explicite à zéro classe (= ne voit rien)
- None : aucune restriction (admin uniquement)
- [] : restriction à zéro classe (= ne voit rien) défaut pour user
- [...] : restreint à ces classes
Sémantique 2026-05-11 : un user (rôle != admin) sans `allowed_classes`
configuré n'a accès à AUCUNE classe. Il doit s'enrôler via /profile
ou recevoir un accès manuel via /users.
"""
user = _load_user(username)
if not user:
@ -39,8 +43,7 @@ def get_allowed_classes(username: str) -> Optional[list[str]]:
return None
allowed = user.get("allowed_classes")
if allowed is None:
return None
# `allowed_classes: []` (présent mais vide) signifie « aucun accès »
return [] # ← user sans config = aucun accès
return list(allowed)