Compare commits
10 commits
54631adff6
...
f1190566a6
| Author | SHA1 | Date | |
|---|---|---|---|
| f1190566a6 | |||
| ea8954bc6f | |||
| eb98ec273c | |||
| 9188e6ba1e | |||
| 7431339ce5 | |||
| 38189deb0f | |||
| 610e37d2a1 | |||
| ef6072112b | |||
| 6d1b7c8044 | |||
| 6a69f36e83 |
55 changed files with 9670 additions and 888 deletions
|
|
@ -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
295
DEPLOY_PROD.md
Normal 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)
|
||||
|
||||
### J−1 (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
331
ESCADAWEB_AUTH.md
Normal 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
47
TODO.md
Normal 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
|
||||
|
||||
-
|
||||
BIN
assets/fonts/InterVariable.woff2
Normal file
BIN
assets/fonts/InterVariable.woff2
Normal file
Binary file not shown.
BIN
assets/fonts/JetBrainsMono-Bold.woff2
Normal file
BIN
assets/fonts/JetBrainsMono-Bold.woff2
Normal file
Binary file not shown.
BIN
assets/fonts/JetBrainsMono-Regular.woff2
Normal file
BIN
assets/fonts/JetBrainsMono-Regular.woff2
Normal file
Binary file not shown.
|
|
@ -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
1
data/VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
1.0.0
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,23 +1,24 @@
|
|||
# Push vers Escada
|
||||
|
||||
Le push envoie les modifications locales (table `EscadaPending`) vers Escadaweb via Selenium.
|
||||
Le push envoie les modifications locales (absences en `EscadaPending` + notices en attente dans `Notice`) vers Escadaweb via Playwright.
|
||||
|
||||
## Page : `/escada` → "Pousser vers Escada"
|
||||
## Page : `/escada` → « Pousser vers Escada »
|
||||
|
||||
### Quand un pending est créé ?
|
||||
### Quand un pending d'absence est créé ?
|
||||
|
||||
Chaque modification d'absence dans l'application crée ou met à jour une entrée dans `EscadaPending` :
|
||||
|
||||
| Action utilisateur | Pending créé |
|
||||
|------------------------------------------|---------------------|
|
||||
| Marquer P3 comme excusée | `action=E` |
|
||||
| Marquer P5 comme non excusée | `action=N` |
|
||||
| Retirer une absence (présent) | `action=clear` |
|
||||
| Excuse rapide d'une journée (page Fiche) | `action=E` × n |
|
||||
| Action utilisateur | Pending créé |
|
||||
|---------------------------------------------------|---------------------|
|
||||
| Marquer P3 comme excusée | `action=E` |
|
||||
| Marquer P5 comme non excusée | `action=N` |
|
||||
| Retirer une absence (présent) | `action=clear` |
|
||||
| Excuse rapide d'une journée (page Apprentis) | `action=E` × n |
|
||||
| « Absent toute la journée » (selon horaire classe)| `action=N` × n (sur enregistrement) |
|
||||
|
||||
La contrainte d'unicité `(apprenti_id, date, periode)` garantit qu'une période a au plus un pending. Si on modifie deux fois la même période, le dernier pending écrase le précédent.
|
||||
|
||||
## Phases du push
|
||||
## Phases du push d'absences
|
||||
|
||||
### Phase 1 : Préparation
|
||||
|
||||
|
|
@ -25,21 +26,21 @@ La contrainte d'unicité `(apprenti_id, date, periode)` garantit qu'une période
|
|||
|
||||
1. Lit toutes les entrées de `EscadaPending`
|
||||
2. Groupe par classe pour minimiser les navigations Escada
|
||||
3. Lance Selenium
|
||||
3. Lance Playwright
|
||||
|
||||
### Phase 2 : Exécution Selenium
|
||||
### Phase 2 : Exécution Playwright
|
||||
|
||||
Pour chaque pending :
|
||||
|
||||
1. Navigue jusqu'à la page d'absences de l'apprenti dans Escadaweb
|
||||
2. Trouve la cellule (date × période)
|
||||
3. Selon l'action :
|
||||
- `E` : sélectionne "Excusée" dans le dropdown
|
||||
- `N` : sélectionne "Non excusée"
|
||||
- `E` : sélectionne « Excusée » dans le dropdown
|
||||
- `N` : sélectionne « Non excusée »
|
||||
- `clear` : remet à blanc (= apprenti présent)
|
||||
4. Clique sur **Speichern** (Enregistrer)
|
||||
5. Si OK → supprime l'entrée du `EscadaPending`
|
||||
6. Si erreur → conserve l'entrée et la liste les erreurs dans `PUSH_DONE`
|
||||
6. Si erreur → conserve l'entrée et la liste dans `PUSH_DONE`
|
||||
|
||||
### Phase 3 : Rapport
|
||||
|
||||
|
|
@ -47,6 +48,27 @@ Le script imprime une ligne `PUSH_DONE {"ok": N, "err": [...]}` à la fin. L'app
|
|||
- Nombre d'envois OK
|
||||
- Liste des erreurs (chaque erreur mentionne l'apprenti, la date et la période)
|
||||
|
||||
## Push de notices
|
||||
|
||||
Les notices créées localement (création d'avis de retenue ou de sanction depuis l'app) sont enregistrées dans la table `Notice` (statut `pending`), puis poussées par `scripts/push_notices.py`.
|
||||
|
||||
### Workflow
|
||||
|
||||
1. L'utilisateur clique sur « Générer l'avis » dans une modale d'avis sanction/retenue. Cela :
|
||||
- Génère le PDF (téléchargement)
|
||||
- Crée une `Notice` avec `source="sanction"` ou `"retenue"`, `status="pending"`, et le préfixe `(<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](#).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Page : `/cron` (admin uniquement)
|
||||
|
||||
Permet de créer des tâches automatiques de synchronisation et/ou de push.
|
||||
Permet de créer des tâches automatiques de synchronisation et/ou de push, avec notifications Telegram.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
|
@ -14,7 +14,7 @@ docker exec eptm-dashboard-app-1 python scripts/cron_tick.py
|
|||
Lit la table CronJob → identifie les tâches à exécuter maintenant
|
||||
↓
|
||||
Pour chaque tâche due :
|
||||
- Lance push_to_escada.py et/ou sync_esacada.py + run_imports.py
|
||||
- Lance push_to_escada.py, push_notices.py, sync_esacada.py + run_imports.py
|
||||
- Met à jour last_run_at, last_status, last_message
|
||||
- Envoie une notification Telegram (selon notify_on)
|
||||
```
|
||||
|
|
@ -25,24 +25,32 @@ Le tick s'exécute toutes les minutes via la crontab du host. Le timezone du con
|
|||
|
||||
| Type | Action |
|
||||
|------------------|----------------------------------------------------------------------|
|
||||
| `push` | Pousse les pendings vers Escada uniquement |
|
||||
| `sync` | Récupère depuis Escada uniquement (selon options abs/BN/notes/fiches)|
|
||||
| `push_then_sync` | Pousse les pendings, puis récupère |
|
||||
| `push` | Pousse les pendings d'absences et/ou notices vers Escada |
|
||||
| `sync` | Récupère depuis Escada (selon options abs/BN/notes/fiches/notices) |
|
||||
| `push_then_sync` | Pousse puis récupère |
|
||||
|
||||
## Schedules
|
||||
## Planifications
|
||||
|
||||
Trois types de planning sont disponibles :
|
||||
Deux types de planning seulement (les anciens `interval` et `daily` ont été remplacés par `daily_multi` qui couvre les deux cas) :
|
||||
|
||||
- **Quotidien (daily)** : à une heure fixe chaque jour. Ex : `03:00`.
|
||||
- **Hebdo (weekly)** : à une heure fixe certains jours. Ex : `MON,WED,FRI:08:30`.
|
||||
- **Intervalle (interval)** : toutes les N minutes. Ex : `30` = toutes les 30 minutes.
|
||||
- **Hebdo (`weekly`)** : à une heure fixe certains jours.
|
||||
- `schedule_value` = `"MON,WED,FRI:08:30"`
|
||||
- Sélection des jours en UI : pastilles rouges Lun..Dim.
|
||||
- **Plusieurs heures par jour (`daily_multi`)** : à un ensemble d'heures pleines, tous les jours.
|
||||
- `schedule_value` = `"00:00,06:00,12:00,18:00"`
|
||||
- Sélection en UI : grille 24 cases (00h–23h) cliquables.
|
||||
- Remplace l'ancien mode `interval` : pour reproduire « toutes les 6 h » on coche 4 cases (00, 06, 12, 18).
|
||||
|
||||
## Options de sync (pour task_kind=sync ou push_then_sync)
|
||||
> Les anciens jobs en format `daily` (`HH:MM`) sont automatiquement convertis en `daily_multi` au boot via la migration de `src/db.py`.
|
||||
> Les anciens jobs en format `interval` (`N minutes`) sont également migrés en déroulant l'intervalle sur 24 h depuis minuit.
|
||||
|
||||
## Options de sync (pour `task_kind=sync` ou `push_then_sync`)
|
||||
|
||||
- `sync_abs` : récupère les absences
|
||||
- `sync_bn` : récupère les BN
|
||||
- `sync_notes` : récupère les notes
|
||||
- `sync_fiches` : récupère les données apprentis
|
||||
- `sync_bn` : récupère les BN + Matu
|
||||
- `sync_notes` : récupère les notes d'examen
|
||||
- `sync_fiches` : récupère les données apprentis (avec représentant légal + compensation)
|
||||
- `sync_notices` : récupère les notices Escada
|
||||
- `force_abs` : forçage (cf. doc Sync Escada)
|
||||
- `classes_json` : `"ALL"` ou liste de classes spécifiques
|
||||
|
||||
|
|
@ -58,7 +66,9 @@ Voir la section [Notifications Telegram](#) pour les détails.
|
|||
|
||||
## Activation / désactivation
|
||||
|
||||
Le toggle dans la liste des tâches active ou désactive sans supprimer. Quand on **réactive** une tâche, son `last_run_at` est remis à `None` pour qu'elle se déclenche au prochain tick (sinon elle attendrait la fin de l'intervalle complet).
|
||||
Le toggle dans la liste des tâches active ou désactive sans supprimer. Quand on **réactive** une tâche, son `last_run_at` est remis à `None` pour qu'elle se déclenche au prochain créneau.
|
||||
|
||||
**Ouverture rapide** : cliquer n'importe où sur la ligne d'une tâche ouvre directement le panneau d'édition.
|
||||
|
||||
## Logs persistants
|
||||
|
||||
|
|
@ -69,10 +79,10 @@ Chaque exécution écrit son log détaillé dans `/logs/cron/cron-{job_id}-{time
|
|||
Toute modification (création/édition/activation/suppression) est tracée :
|
||||
|
||||
```
|
||||
[09:14:22] [cron] prof.demo : création tâche 'Sync nocturne' (id=4) — push_then_sync / 03:00 / activée
|
||||
[09:30:05] [cron] prof.demo : désactivation tâche 'Push 30min' (id=2)
|
||||
[09:14:22] [cron] prof.demo : création tâche 'Sync nocturne' (id=4) — push_then_sync / 00:00,06:00,12:00,18:00 / activée
|
||||
[09:30:05] [cron] prof.demo : désactivation tâche 'Push horaire' (id=2)
|
||||
```
|
||||
|
||||
## Bouton "Tester Telegram"
|
||||
## Bouton « Tester Telegram »
|
||||
|
||||
Bas de page : envoie un message de test au `chat_id` global pour vérifier la config bot.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Authentification & rôles
|
||||
# Authentification, droits & profil
|
||||
|
||||
## Login
|
||||
|
||||
|
|
@ -12,11 +12,15 @@ Format `auth.yaml` :
|
|||
credentials:
|
||||
usernames:
|
||||
prof.demo:
|
||||
password: "$2b$12$..."
|
||||
password: "$2b$12$..." # bcrypt
|
||||
name: "Prof Demo"
|
||||
role: "admin" # ou "user"
|
||||
role: "admin" # ou "user"
|
||||
email: "prof.demo@eptm.ch" # destinataire pour reset mdp / enrôlement
|
||||
avatar_url: "/avatars/prof_demo.png"
|
||||
totp_secret: "ABCD1234..." # rempli automatiquement à la 1ère 2FA
|
||||
totp_secret: "ABCD1234..." # rempli automatiquement à la 1ère 2FA
|
||||
allowed_classes: ["AUTOMAT 1", "EM-AU 2"] # restriction d'accès
|
||||
escada_username: "prenom.nom@eptm.ch" # email Escada (clé login)
|
||||
escada_password: "..." # mot de passe Escada (stocké clair)
|
||||
```
|
||||
|
||||
## 2FA TOTP (obligatoire)
|
||||
|
|
@ -32,39 +36,102 @@ Aux connexions suivantes :
|
|||
1. Login + mot de passe → demande directe du code TOTP
|
||||
2. Code à 6 chiffres → connexion finalisée
|
||||
|
||||
Le code est valide ±30s (paramètre `valid_window=1` de `pyotp`) pour tolérer la dérive d'horloge.
|
||||
Le code est valide ±30 s (paramètre `valid_window=1` de `pyotp`) pour tolérer la dérive d'horloge.
|
||||
|
||||
## Email de bienvenue / réinitialisation de mot de passe
|
||||
|
||||
Sur création d'un user depuis `/users` ou sur reset, un **email** est envoyé contenant un lien `<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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
83
data/docs/11-avis-sanction-retenue.md
Normal file
83
data/docs/11-avis-sanction-retenue.md
Normal 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
80
data/docs/12-feedback.md
Normal 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
|
||||
99
data/docs/13-parametres.md
Normal file
99
data/docs/13-parametres.md
Normal 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.
|
||||
|
|
@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
data/templates/GF_FO_Avis_de_retenue.pdf
Normal file
BIN
data/templates/GF_FO_Avis_de_retenue.pdf
Normal file
Binary file not shown.
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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="2", color="#1a237e",
|
||||
white_space="nowrap", overflow="hidden",
|
||||
text_overflow="ellipsis",
|
||||
flex="1", min_width="0",
|
||||
),
|
||||
rx.flex(
|
||||
rx.text(
|
||||
item["nom"], " ", item["prenom"],
|
||||
size="3", weight="bold", color="#1a237e",
|
||||
),
|
||||
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",
|
||||
),
|
||||
background_color="#ffe5e5",
|
||||
padding="0.15rem 0.5rem",
|
||||
border_radius="9999px",
|
||||
flex_shrink="0",
|
||||
),
|
||||
width="100%", align="center", gap="0.5rem", wrap="wrap",
|
||||
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.1rem 0.4rem",
|
||||
border_radius="9999px",
|
||||
flex_shrink="0",
|
||||
),
|
||||
rx.button(
|
||||
rx.icon("file-down", size=13),
|
||||
"PDF avis de sanction",
|
||||
on_click=AccueilState.download_avis(
|
||||
item["id"], item["nom"], item["prenom"], item["classe"],
|
||||
).stop_propagation,
|
||||
size="1",
|
||||
color_scheme="gray",
|
||||
variant="soft",
|
||||
),
|
||||
spacing="2",
|
||||
align="start",
|
||||
width="100%", align="center", gap="0.5rem",
|
||||
),
|
||||
# Ligne 2 : bouton créer l'avis
|
||||
rx.button(
|
||||
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",
|
||||
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",
|
||||
),
|
||||
gap="0.5rem", align="center",
|
||||
),
|
||||
background_color="#e3f2fd",
|
||||
border="1px solid #90caf9",
|
||||
border_radius="6px",
|
||||
padding="0.75rem 1rem",
|
||||
width="100%",
|
||||
rx.flex(
|
||||
rx.icon("triangle-alert", size=20, color="#c62828"),
|
||||
rx.heading("Notes insuffisantes (BN / Matu < 4.0)", size="5"),
|
||||
gap="0.5rem", align="center",
|
||||
),
|
||||
_notes_insuf_section(),
|
||||
|
||||
spacing="5",
|
||||
width="100%",
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
rx.cond(
|
||||
item["quota_atteint"],
|
||||
_kpi_mini("Absences", item["blocs"], "#c62828"),
|
||||
_kpi_mini("Absences", item["blocs"]),
|
||||
_kpi_card("Périodes d'absence", item["total"]),
|
||||
_kpi_card("Périodes à excuser", item["non_exc"], "var(--brand-primary-dark)"),
|
||||
rx.box(
|
||||
rx.text("Absences", size="1", color="#666"),
|
||||
rx.text(
|
||||
item["blocs"],
|
||||
size="7", font_weight="700",
|
||||
color=rx.cond(item["quota_atteint"], "#c62828", "#37474f"),
|
||||
class_name="tabular",
|
||||
),
|
||||
rx.cond(
|
||||
item["quota_atteint"],
|
||||
rx.text(
|
||||
"Avis de sanction",
|
||||
size="1", weight="bold", color="#c62828",
|
||||
),
|
||||
),
|
||||
padding="1rem",
|
||||
background_color=rx.cond(item["quota_atteint"], "#fff0f0", "var(--surface)"),
|
||||
border_radius="8px",
|
||||
border=rx.cond(
|
||||
item["quota_atteint"],
|
||||
"1px solid #ffcdd2",
|
||||
"1px solid var(--border)",
|
||||
),
|
||||
flex="1",
|
||||
min_width="120px",
|
||||
),
|
||||
gap="0.5rem",
|
||||
gap="1rem",
|
||||
flex_wrap="wrap",
|
||||
width="100%",
|
||||
margin_bottom="0.75rem",
|
||||
),
|
||||
|
||||
# ── Boutons téléchargement PDF ────────────────────────────────────────
|
||||
rx.flex(
|
||||
rx.button(
|
||||
rx.icon("download", size=13),
|
||||
"PDF absences",
|
||||
on_click=ClasseState.download_abs_pdf(item["id"]),
|
||||
variant="outline",
|
||||
color_scheme="gray",
|
||||
size="1",
|
||||
),
|
||||
rx.cond(
|
||||
item["has_pdf_bn"],
|
||||
# ── Actions (PDF exports + créations d'avis) ──────────────────────────
|
||||
rx.box(
|
||||
rx.flex(
|
||||
rx.button(
|
||||
rx.icon("file-text", size=13),
|
||||
"PDF bulletin",
|
||||
on_click=ClasseState.download_bn_pdf(item["id"]),
|
||||
variant="outline",
|
||||
color_scheme="blue",
|
||||
size="1",
|
||||
rx.icon("download", size=13),
|
||||
"PDF absences",
|
||||
on_click=ClasseState.download_abs_pdf(item["id"]),
|
||||
variant="outline", color_scheme="gray", size="2",
|
||||
),
|
||||
rx.cond(
|
||||
item["has_pdf_bn"],
|
||||
rx.button(
|
||||
rx.icon("download", size=13),
|
||||
"PDF bulletin",
|
||||
on_click=ClasseState.download_bn_pdf(item["id"]),
|
||||
variant="outline", color_scheme="blue", size="2",
|
||||
),
|
||||
),
|
||||
rx.cond(
|
||||
item["has_pdf_notes"],
|
||||
rx.button(
|
||||
rx.icon("download", size=13),
|
||||
"PDF notes",
|
||||
on_click=ClasseState.download_notes_pdf(item["id"]),
|
||||
variant="outline", color_scheme="violet", size="2",
|
||||
),
|
||||
),
|
||||
# Séparateur visuel
|
||||
rx.box(
|
||||
width="1px",
|
||||
background_color="var(--gray-6)",
|
||||
margin_x="0.25rem",
|
||||
align_self="stretch",
|
||||
),
|
||||
),
|
||||
rx.cond(
|
||||
item["has_pdf_notes"],
|
||||
rx.button(
|
||||
rx.icon("file-text", size=13),
|
||||
"PDF notes",
|
||||
on_click=ClasseState.download_notes_pdf(item["id"]),
|
||||
variant="outline",
|
||||
color_scheme="violet",
|
||||
size="1",
|
||||
rx.icon("file-warning", size=14),
|
||||
"Créer un avis de retenue",
|
||||
on_click=RetenueState.preload_apprenti(
|
||||
item["id"], item["label"],
|
||||
),
|
||||
color_scheme="orange", variant="soft", size="2",
|
||||
),
|
||||
rx.button(
|
||||
rx.icon("triangle-alert", size=14),
|
||||
"Créer un avis de sanction",
|
||||
on_click=SanctionState.preload_apprenti(
|
||||
item["id"], item["label"],
|
||||
),
|
||||
color_scheme="red", variant="soft", size="2",
|
||||
),
|
||||
gap="0.5rem",
|
||||
flex_wrap="wrap",
|
||||
align="center",
|
||||
width="100%",
|
||||
),
|
||||
flex_wrap="wrap",
|
||||
gap="0.5rem",
|
||||
padding="0.75rem 1rem",
|
||||
background_color="var(--surface)",
|
||||
border_radius="8px",
|
||||
border="1px solid var(--border)",
|
||||
width="100%",
|
||||
margin_bottom="0.75rem",
|
||||
),
|
||||
|
||||
# ── 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,
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
try:
|
||||
m = int(job.schedule_value)
|
||||
except (TypeError, ValueError):
|
||||
return "—"
|
||||
if job.last_run_at is None:
|
||||
return "Au prochain tick"
|
||||
nxt = job.last_run_at + timedelta(minutes=m)
|
||||
return nxt.strftime("%d.%m %H:%M")
|
||||
if job.schedule_kind == "daily":
|
||||
try:
|
||||
hh, mm = job.schedule_value.split(":")
|
||||
target = now.replace(hour=int(hh), minute=int(mm), second=0, microsecond=0)
|
||||
if job.schedule_kind == "daily_multi":
|
||||
hours = [h.strip() for h in (job.schedule_value or "").split(",") if h.strip()]
|
||||
best: datetime | None = None
|
||||
for hhmm in hours:
|
||||
try:
|
||||
hh, mm = hhmm.split(":")
|
||||
target = now.replace(hour=int(hh), minute=int(mm),
|
||||
second=0, microsecond=0)
|
||||
except (ValueError, AttributeError):
|
||||
continue
|
||||
# Si déjà exécuté à ce créneau aujourd'hui, on le pousse au lendemain.
|
||||
if (job.last_run_at and job.last_run_at.date() == now.date()
|
||||
and job.last_run_at >= target):
|
||||
target += timedelta(days=1)
|
||||
elif target < now:
|
||||
target += timedelta(days=1)
|
||||
return target.strftime("%d.%m %H:%M")
|
||||
except (ValueError, AttributeError):
|
||||
return "—"
|
||||
if best is None or target < best:
|
||||
best = target
|
||||
return best.strftime("%d.%m %H:%M") if best else "—"
|
||||
if job.schedule_kind == "weekly":
|
||||
return "Selon planning"
|
||||
return "—"
|
||||
|
|
@ -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,19 +227,32 @@ 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"]
|
||||
|
||||
self.f_task_kind = job.task_kind
|
||||
self.f_sync_abs = job.sync_abs
|
||||
self.f_sync_bn = job.sync_bn
|
||||
self.f_sync_notes = job.sync_notes
|
||||
self.f_sync_fiches = job.sync_fiches
|
||||
self.f_force_abs = job.force_abs
|
||||
self.f_task_kind = job.task_kind
|
||||
self.f_sync_abs = job.sync_abs
|
||||
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()
|
||||
if classes_raw == "ALL":
|
||||
|
|
@ -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 (00h–23h) pour le mode daily_multi."""
|
||||
cells = []
|
||||
for h in range(24):
|
||||
hhmm = f"{h:02d}:00"
|
||||
cells.append(
|
||||
rx.box(
|
||||
rx.text(f"{h:02d}", size="1", weight="bold"),
|
||||
on_click=CronState.toggle_f_hour(hhmm),
|
||||
cursor="pointer",
|
||||
padding="0.4rem 0",
|
||||
border_radius="6px",
|
||||
border="2px solid",
|
||||
text_align="center",
|
||||
border_color=rx.cond(
|
||||
CronState.f_hours.contains(hhmm),
|
||||
"var(--red-9)", "var(--gray-6)",
|
||||
),
|
||||
background_color=rx.cond(
|
||||
CronState.f_hours.contains(hhmm),
|
||||
"var(--red-9)", "transparent",
|
||||
),
|
||||
color=rx.cond(
|
||||
CronState.f_hours.contains(hhmm),
|
||||
"white", "var(--gray-12)",
|
||||
),
|
||||
)
|
||||
)
|
||||
return rx.vstack(
|
||||
rx.text(
|
||||
"Heures d'exécution (clic pour activer / désactiver)",
|
||||
size="1", color="var(--gray-10)",
|
||||
),
|
||||
rx.grid(
|
||||
*cells,
|
||||
columns="6",
|
||||
gap="0.3rem",
|
||||
width="100%",
|
||||
),
|
||||
spacing="2", width="100%",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -570,65 +622,41 @@ def _form_schedule_picker() -> rx.Component:
|
|||
return rx.vstack(
|
||||
rx.text("Planification", size="2", font_weight="600"),
|
||||
rx.radio(
|
||||
["daily", "weekly", "interval"],
|
||||
["daily_multi", "weekly"],
|
||||
value=CronState.f_schedule_kind,
|
||||
on_change=CronState.set_f_schedule_kind,
|
||||
direction="row",
|
||||
),
|
||||
rx.cond(
|
||||
CronState.f_schedule_kind == "interval",
|
||||
rx.hstack(
|
||||
rx.text("Toutes les", size="2"),
|
||||
rx.input(
|
||||
value=CronState.f_interval_min,
|
||||
on_change=CronState.set_f_interval_min,
|
||||
width="80px",
|
||||
CronState.f_schedule_kind == "weekly",
|
||||
rx.vstack(
|
||||
rx.flex(
|
||||
*[
|
||||
rx.box(
|
||||
rx.text(_DAY_LABELS[d], size="1", weight="bold"),
|
||||
on_click=CronState.toggle_f_day(d),
|
||||
cursor="pointer",
|
||||
padding="0.35rem 0.7rem",
|
||||
border_radius="6px",
|
||||
border="2px solid",
|
||||
border_color=rx.cond(
|
||||
CronState.f_days.contains(d),
|
||||
"var(--red-9)", "var(--gray-6)",
|
||||
),
|
||||
background_color=rx.cond(
|
||||
CronState.f_days.contains(d),
|
||||
"var(--red-9)", "transparent",
|
||||
),
|
||||
color=rx.cond(
|
||||
CronState.f_days.contains(d),
|
||||
"white", "var(--gray-12)",
|
||||
),
|
||||
)
|
||||
for d in _DAY_NAMES
|
||||
],
|
||||
gap="0.3rem",
|
||||
wrap="wrap",
|
||||
),
|
||||
rx.text("minutes", size="2"),
|
||||
spacing="2", align="center",
|
||||
),
|
||||
rx.cond(
|
||||
CronState.f_schedule_kind == "weekly",
|
||||
rx.vstack(
|
||||
rx.flex(
|
||||
*[
|
||||
rx.box(
|
||||
rx.text(_DAY_LABELS[d], size="1", weight="bold"),
|
||||
on_click=CronState.toggle_f_day(d),
|
||||
cursor="pointer",
|
||||
padding="0.35rem 0.7rem",
|
||||
border_radius="6px",
|
||||
border="2px solid",
|
||||
border_color=rx.cond(
|
||||
CronState.f_days.contains(d),
|
||||
"var(--red-9)", "var(--gray-6)",
|
||||
),
|
||||
background_color=rx.cond(
|
||||
CronState.f_days.contains(d),
|
||||
"var(--red-9)", "transparent",
|
||||
),
|
||||
color=rx.cond(
|
||||
CronState.f_days.contains(d),
|
||||
"white", "var(--gray-12)",
|
||||
),
|
||||
)
|
||||
for d in _DAY_NAMES
|
||||
],
|
||||
gap="0.3rem",
|
||||
wrap="wrap",
|
||||
),
|
||||
rx.hstack(
|
||||
rx.text("Heure :", size="2"),
|
||||
rx.input(value=CronState.f_time_hh,
|
||||
on_change=CronState.set_f_time_hh, width="60px"),
|
||||
rx.text(":", size="3"),
|
||||
rx.input(value=CronState.f_time_mm,
|
||||
on_change=CronState.set_f_time_mm, width="60px"),
|
||||
spacing="2", align="center",
|
||||
),
|
||||
spacing="2",
|
||||
),
|
||||
# daily
|
||||
rx.hstack(
|
||||
rx.text("Heure :", size="2"),
|
||||
rx.input(value=CronState.f_time_hh,
|
||||
|
|
@ -638,7 +666,9 @@ def _form_schedule_picker() -> rx.Component:
|
|||
on_change=CronState.set_f_time_mm, width="60px"),
|
||||
spacing="2", align="center",
|
||||
),
|
||||
spacing="2",
|
||||
),
|
||||
_hours_grid(),
|
||||
),
|
||||
spacing="2", width="100%",
|
||||
)
|
||||
|
|
@ -647,53 +677,75 @@ 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",
|
||||
margin_top="0.5rem"),
|
||||
rx.flex(
|
||||
rx.hstack(
|
||||
rx.checkbox(checked=CronState.f_sync_abs,
|
||||
on_change=CronState.set_f_sync_abs, size="2"),
|
||||
rx.text("Absences", size="2"),
|
||||
spacing="2", align="center",
|
||||
),
|
||||
rx.hstack(
|
||||
rx.checkbox(checked=CronState.f_sync_bn,
|
||||
on_change=CronState.set_f_sync_bn, size="2"),
|
||||
rx.text("BN + Matu", size="2"),
|
||||
spacing="2", align="center",
|
||||
),
|
||||
rx.hstack(
|
||||
rx.checkbox(checked=CronState.f_sync_notes,
|
||||
on_change=CronState.set_f_sync_notes, size="2"),
|
||||
rx.text("Notes d'examen", size="2"),
|
||||
spacing="2", align="center",
|
||||
),
|
||||
rx.hstack(
|
||||
rx.checkbox(checked=CronState.f_sync_fiches,
|
||||
on_change=CronState.set_f_sync_fiches, size="2"),
|
||||
rx.text("Fiches apprentis", size="2"),
|
||||
spacing="2", align="center",
|
||||
),
|
||||
gap="0.5rem 1.25rem",
|
||||
flex_wrap="wrap",
|
||||
rx.vstack(
|
||||
rx.text("Données concernées", size="2", font_weight="600",
|
||||
margin_top="0.5rem"),
|
||||
rx.flex(
|
||||
rx.hstack(
|
||||
rx.checkbox(checked=CronState.f_sync_abs,
|
||||
on_change=CronState.set_f_sync_abs, size="2"),
|
||||
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"),
|
||||
rx.text("BN + Matu", size="2"),
|
||||
spacing="2", align="center",
|
||||
),
|
||||
rx.hstack(
|
||||
rx.checkbox(checked=CronState.f_sync_notes,
|
||||
on_change=CronState.set_f_sync_notes, size="2"),
|
||||
rx.text("Notes d'examen", size="2"),
|
||||
spacing="2", align="center",
|
||||
),
|
||||
rx.hstack(
|
||||
rx.checkbox(checked=CronState.f_sync_fiches,
|
||||
on_change=CronState.set_f_sync_fiches, size="2"),
|
||||
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%",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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%",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
@ -55,11 +57,13 @@ class EscadaState(AuthState):
|
|||
classes_cache: list[str] = []
|
||||
class_checked: dict[str, bool] = {}
|
||||
|
||||
sync_abs: bool = True
|
||||
sync_bn: bool = True
|
||||
sync_notes: bool = True
|
||||
sync_fiches: bool = False
|
||||
force_abs: bool = False
|
||||
sync_abs: bool = True
|
||||
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)
|
||||
|
|
@ -116,9 +135,11 @@ class EscadaState(AuthState):
|
|||
|
||||
def set_sync_abs(self, v: bool): self.sync_abs = v
|
||||
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_force_abs(self, v: bool): self.force_abs = 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
|
||||
|
|
@ -372,13 +438,15 @@ class EscadaState(AuthState):
|
|||
@_background
|
||||
async def sync_escada(self):
|
||||
async with self: # SEUL async with self: de cette background task
|
||||
selected = [c for c, v in self.class_checked.items() if v]
|
||||
sync_abs = self.sync_abs
|
||||
sync_bn = self.sync_bn
|
||||
sync_notes = self.sync_notes
|
||||
sync_fiches = self.sync_fiches
|
||||
force_abs = self.force_abs
|
||||
username = self.username or "escada"
|
||||
selected = [c for c, v in self.class_checked.items() if v]
|
||||
sync_abs = self.sync_abs
|
||||
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
|
||||
self.is_syncing = True
|
||||
|
|
@ -391,10 +459,11 @@ class EscadaState(AuthState):
|
|||
self.sync_res_matu = []
|
||||
|
||||
_types = []
|
||||
if sync_abs: _types.append("abs" + ("/forcé" if force_abs else ""))
|
||||
if sync_bn: _types.append("BN")
|
||||
if sync_notes: _types.append("notes")
|
||||
if sync_fiches: _types.append("fiches")
|
||||
if sync_abs: _types.append("abs" + ("/forcé" if force_abs else ""))
|
||||
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", [])
|
||||
self.sync_done = True
|
||||
app_log("Résultats chargés — sync terminée OK")
|
||||
# Pas encore sync_done=True : on attend le pull notices
|
||||
if not _will_pull_notices:
|
||||
self.sync_done = True
|
||||
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.checkbox(
|
||||
checked=EscadaState.force_abs,
|
||||
on_change=EscadaState.set_force_abs,
|
||||
size="2",
|
||||
color_scheme="amber",
|
||||
),
|
||||
rx.icon("triangle-alert", size=14, color="#b45309"),
|
||||
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.",
|
||||
size="2",
|
||||
color="#92400e",
|
||||
font_weight="600",
|
||||
"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="center",
|
||||
padding="0.5rem 0.75rem",
|
||||
background_color="#fef3c7",
|
||||
border="1px solid #fcd34d",
|
||||
border_radius="6px",
|
||||
flex_wrap="wrap",
|
||||
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(
|
||||
"Absences",
|
||||
size="2",
|
||||
color=rx.cond(
|
||||
EscadaState.sync_abs, "#92400e", "#cbd5e1",
|
||||
),
|
||||
font_weight="600",
|
||||
),
|
||||
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",
|
||||
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%",
|
||||
),
|
||||
|
||||
|
|
|
|||
915
eptm_dashboard/pages/feedback.py
Normal file
915
eptm_dashboard/pages/feedback.py
Normal 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%",
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -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%",
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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%",
|
||||
|
|
|
|||
|
|
@ -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%",
|
||||
|
|
|
|||
902
eptm_dashboard/pages/retenue.py
Normal file
902
eptm_dashboard/pages/retenue.py
Normal 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,
|
||||
)
|
||||
512
eptm_dashboard/pages/sanction.py
Normal file
512
eptm_dashboard/pages/sanction.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import reflex as rx
|
||||
from .state import AuthState
|
||||
from .components import scan_docs
|
||||
|
|
@ -6,39 +9,99 @@ from .components import scan_docs
|
|||
# détecter de nouveaux fichiers).
|
||||
_DOC_SECTIONS = scan_docs()
|
||||
|
||||
|
||||
def _resolve_version() -> str:
|
||||
"""Renvoie le dernier tag git, ou le contenu de data/VERSION en fallback.
|
||||
Lu une fois au démarrage du module — un restart suffit pour refléter un
|
||||
nouveau tag (utile en prod où le .git du container peut être figé)."""
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["git", "describe", "--tags", "--abbrev=0"],
|
||||
cwd=root, capture_output=True, text=True, timeout=3,
|
||||
)
|
||||
if r.returncode == 0 and r.stdout.strip():
|
||||
return r.stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
for candidate in (root / "data" / "VERSION", root / "VERSION"):
|
||||
try:
|
||||
return candidate.read_text(encoding="utf-8").strip()
|
||||
except Exception:
|
||||
continue
|
||||
return ""
|
||||
|
||||
|
||||
_VERSION = _resolve_version()
|
||||
|
||||
|
||||
def _version_badge() -> rx.Component:
|
||||
"""Petit libellé centré affichant la version sous forme 'v<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 = [
|
||||
("Escada", "/escada", "globe"),
|
||||
("Cron", "/cron", "alarm-clock"),
|
||||
("Logs", "/logs", "file-text"),
|
||||
("Utilisateurs", "/users", "user-cog"),
|
||||
("Paramètres", "/params", "settings"),
|
||||
("Purger classe","/purge", "trash-2"),
|
||||
("Escada", "/escada", "globe"),
|
||||
("Cron", "/cron", "alarm-clock"),
|
||||
("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",
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
|
||||
reflex==0.9.2
|
||||
markdown==3.10.2
|
||||
pikepdf==10.5.1
|
||||
|
|
@ -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%",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
return True
|
||||
return (now - last).total_seconds() >= minutes * 60
|
||||
|
||||
if job.schedule_kind == "daily":
|
||||
# schedule_value = "HH:MM"
|
||||
return _due_time_of_day(job.schedule_value, last, now)
|
||||
if job.schedule_kind == "daily_multi":
|
||||
# schedule_value = "HH:MM,HH:MM,HH:MM,..." (plusieurs heures par jour)
|
||||
for hhmm in (job.schedule_value or "").split(","):
|
||||
hhmm = hhmm.strip()
|
||||
if not hhmm:
|
||||
continue
|
||||
if _due_time_of_day(hhmm, last, now):
|
||||
return True
|
||||
return False
|
||||
|
||||
if job.schedule_kind == "weekly":
|
||||
# schedule_value = "MON,WED,FRI:HH:MM"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
278
scripts/fetch_user_classes.py
Normal file
278
scripts/fetch_user_classes.py
Normal 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
426
scripts/pull_notices.py
Executable 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
251
scripts/push_notices.py
Executable 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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -449,11 +335,9 @@ if __name__ == "__main__":
|
|||
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
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")
|
||||
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("--debug", action="store_true", help="Pause interactive après ouverture de la page absences")
|
||||
args = ap.parse_args()
|
||||
|
||||
engine = init_db()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
190
src/db.py
190
src/db.py
|
|
@ -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"
|
||||
# weekly : schedule_value="MON,TUE,WED,THU,FRI:HH:MM"
|
||||
# interval: schedule_value="60" (minutes)
|
||||
schedule_kind: Mapped[str] = mapped_column(default="daily")
|
||||
# schedule_kind ∈ {"daily_multi", "weekly"}
|
||||
# daily_multi : schedule_value="HH:MM,HH:MM,..." (1..N heures par jour)
|
||||
# weekly : schedule_value="MON,TUE,WED,THU,FRI:HH:MM"
|
||||
schedule_kind: Mapped[str] = mapped_column(default="daily_multi")
|
||||
schedule_value: Mapped[str] = mapped_column(default="03:00")
|
||||
|
||||
# task_kind ∈ {"push", "sync", "push_then_sync"}
|
||||
# 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:
|
||||
|
|
|
|||
134
src/parser_bn.py
134
src/parser_bn.py
|
|
@ -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
235
src/parser_liste_classe.py
Normal 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
113
src/profession.py
Normal 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
257
src/retenue_pdf.py
Normal 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")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
121
src/stats.py
121
src/stats.py
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue