ajouté import du statut des désavantages, affichage de toutes les notes du BN.
This commit is contained in:
parent
38189deb0f
commit
7431339ce5
21 changed files with 796 additions and 188 deletions
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)
|
||||
```
|
||||
8
TODO.md
8
TODO.md
|
|
@ -6,14 +6,20 @@ en haut de la section concernée.
|
|||
## Idées / fonctionnalités
|
||||
|
||||
- [ ] Ajouter sur le dashboard l'affichage des notes insuffisantes
|
||||
- [ ] Afficher toutes les notes du BN
|
||||
- [X] Afficher toutes les notes du BN
|
||||
- [ ] Mettre à jour les MD
|
||||
- [ ] Ajouter l'indication des compensation des désavantages
|
||||
- [X] Ajouter le TAB notices aussi sur la vue classe
|
||||
- [ ] Réussir à récupérer le fichier session Esacada d'un utilisateur pour l'utiliser sur le serveur afin de récupérer la liste des classes dont il a accès et de pouvoir uploader les notices avec son nom propre
|
||||
- [X] Filtrer que les classes EM pour les avis de sanction
|
||||
- [ ] Ajouter sur la page apprenti sa situation au niveau des sanctions (barre de progression en fonction des notices)
|
||||
- [ ] Ajouter dans le texte des notices qui a créé la notice (USER) en attendant d'avoir une identification spécifique escada à chaque utilisateur.
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@
|
|||
|
||||
/* ── 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 <body>.
|
||||
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 {
|
||||
|
|
@ -49,6 +49,17 @@
|
|||
--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 */
|
||||
}
|
||||
|
||||
[data-theme="bleu"] {
|
||||
|
|
@ -78,6 +89,57 @@
|
|||
--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 */
|
||||
[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,
|
||||
|
|
|
|||
|
|
@ -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=47666e48-95f2-4607-b1c6-fa1bd72c79a2",
|
||||
"AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=ffa9ae4e-4531-428b-88ad-7dd3684bdc8f",
|
||||
"AUTOMAT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=a3c8ce5a-9636-44ed-8bff-d52def0a72a1",
|
||||
"AUTOMAT 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=391befbf-cf01-4eed-b18c-23c4a86e8d75",
|
||||
"EM-AU 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=cdbc227f-c8b5-498f-b8c8-9ef8bdc18e91",
|
||||
"EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=876e70ab-fdfa-40ad-bc73-9fa05a08135c",
|
||||
"EM-AU 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=bc53830f-a121-4fc6-a88b-5495f5ba3d28",
|
||||
"EM-AU 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=cbaceaff-133a-4d64-930f-3fb79ecbc795",
|
||||
"MONTAUT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=ad7c4d6b-ddb9-4414-b218-d98249fe559d",
|
||||
"MONTAUT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=c4609f4e-5176-4ad8-b115-2a789f1d82de",
|
||||
"MONTAUT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=b17daa90-89fa-4a10-a5aa-b2433c503aac"
|
||||
"AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=884d4b5a-5c84-4699-b317-a1c20519e8d1",
|
||||
"AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=eff788b8-9b09-4166-baba-91cdb8f4cc8f",
|
||||
"AUTOMAT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=b11daad1-028b-4fb5-bc98-303c5f59c9a8",
|
||||
"AUTOMAT 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=fbd5b085-8016-43bc-88f7-ab9542829a35",
|
||||
"EM-AU 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=194ac578-f4b3-4d17-adf7-3294d8042ce0",
|
||||
"EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=24b579b1-d943-4933-91e7-65bd42a4050a",
|
||||
"EM-AU 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=a5203dae-ffe4-49f0-a0e7-543e457c3494",
|
||||
"EM-AU 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=b1f42f17-426f-44e2-ae21-480486849505",
|
||||
"MONTAUT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=71823b1e-2ec3-4209-aaaa-bff6dcfc16a6",
|
||||
"MONTAUT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=5b147370-9ed0-42ec-ba48-1e10b3d81455",
|
||||
"MONTAUT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=7abe005d-291d-4ed6-91a4-a4b3d0f37c7d"
|
||||
}
|
||||
|
|
@ -55,6 +55,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]
|
||||
|
|
@ -139,8 +141,8 @@ def _kpi_card(label: str, value: rx.Var) -> rx.Component:
|
|||
return rx.box(
|
||||
rx.text(label, size="1", color="#555555"),
|
||||
rx.text(value, size="8", font_weight="700", line_height="1.1", class_name="tabular"),
|
||||
background_color="white",
|
||||
border="1px solid #dee2e6",
|
||||
background_color="var(--surface)",
|
||||
border="1px solid var(--border)",
|
||||
border_radius="8px",
|
||||
padding="0.75rem 1rem",
|
||||
flex="1",
|
||||
|
|
@ -151,30 +153,23 @@ def _kpi_card(label: str, value: rx.Var) -> rx.Component:
|
|||
|
||||
|
||||
def _sanction_tile(item: rx.Var) -> rx.Component:
|
||||
return rx.box(
|
||||
rx.vstack(
|
||||
rx.flex(
|
||||
return rx.flex(
|
||||
rx.text(
|
||||
item["nom"], " ", item["prenom"],
|
||||
size="3", weight="bold", color="#1a237e",
|
||||
size="2", color="#1a237e",
|
||||
white_space="nowrap", overflow="hidden",
|
||||
text_overflow="ellipsis",
|
||||
flex="1", min_width="0",
|
||||
),
|
||||
rx.spacer(),
|
||||
rx.box(
|
||||
rx.flex(
|
||||
rx.icon("triangle-alert", size=12, color="#B71C1C"),
|
||||
rx.text(
|
||||
item["absences"], " abs.",
|
||||
size="1", color="#B71C1C", weight="bold",
|
||||
),
|
||||
gap="0.25rem", align="center",
|
||||
),
|
||||
rx.icon("triangle-alert", size=11, color="#B71C1C"),
|
||||
rx.text(item["absences"], size="1", color="#B71C1C", weight="bold"),
|
||||
gap="0.2rem", align="center",
|
||||
background_color="#ffe5e5",
|
||||
padding="0.15rem 0.5rem",
|
||||
padding="0.1rem 0.4rem",
|
||||
border_radius="9999px",
|
||||
flex_shrink="0",
|
||||
),
|
||||
width="100%", align="center", gap="0.5rem", wrap="wrap",
|
||||
),
|
||||
rx.button(
|
||||
rx.icon("file-plus", size=13),
|
||||
"Créer l'avis de sanction",
|
||||
|
|
@ -182,22 +177,20 @@ def _sanction_tile(item: rx.Var) -> rx.Component:
|
|||
item["id"], item["nom"], item["prenom"], item["classe"],
|
||||
).stop_propagation,
|
||||
size="1",
|
||||
color_scheme="red",
|
||||
color_scheme="gray",
|
||||
variant="soft",
|
||||
),
|
||||
spacing="2",
|
||||
align="start",
|
||||
width="100%",
|
||||
),
|
||||
on_click=AccueilState.open_fiche(item["id"]),
|
||||
cursor="pointer",
|
||||
padding="0.85rem 1rem",
|
||||
background_color="white",
|
||||
border="1px solid #e0e0e0",
|
||||
border_radius="8px",
|
||||
flex="1 1 240px",
|
||||
min_width="220px",
|
||||
max_width="320px",
|
||||
padding="0.4rem 0.6rem",
|
||||
background_color="var(--surface)",
|
||||
border="1px solid var(--border)",
|
||||
border_radius="6px",
|
||||
flex="1 1 280px",
|
||||
min_width="280px",
|
||||
max_width="380px",
|
||||
align="center",
|
||||
gap="0.5rem",
|
||||
class_name="hover-lift sanction-tile",
|
||||
)
|
||||
|
||||
|
|
@ -206,13 +199,13 @@ 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%",
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ 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 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 +58,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 +73,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 +431,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 +560,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 +585,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,6 +617,7 @@ class ClasseState(AuthState):
|
|||
has_notes = False
|
||||
notes_html = ""
|
||||
|
||||
notices = notices_by_id.get(apprenti.id, [])
|
||||
data.append({
|
||||
"id": apprenti.id,
|
||||
"nom": apprenti.nom,
|
||||
|
|
@ -584,6 +632,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 +672,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"},
|
||||
|
|
@ -669,7 +719,7 @@ def _kpi_mini(label: str, value, color: str = "#37474f") -> rx.Component:
|
|||
rx.text(label, size="1", color="#888"),
|
||||
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",
|
||||
|
|
@ -681,13 +731,14 @@ 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"],
|
||||
|
|
@ -757,11 +808,12 @@ def _apprenti_card(item) -> rx.Component:
|
|||
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 +853,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",
|
||||
|
|
|
|||
|
|
@ -574,7 +574,7 @@ 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%",
|
||||
|
|
|
|||
|
|
@ -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%",
|
||||
|
|
|
|||
|
|
@ -1289,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",
|
||||
|
|
@ -1335,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",
|
||||
|
|
@ -1356,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",
|
||||
),
|
||||
|
|
@ -1637,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",
|
||||
),
|
||||
|
||||
|
|
@ -1685,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,
|
||||
|
|
@ -1823,9 +1823,9 @@ 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%",
|
||||
),
|
||||
|
||||
|
|
@ -1833,7 +1833,7 @@ def escada_page() -> rx.Component:
|
|||
rx.box(
|
||||
rx.text(
|
||||
"Pousser les absences en attente sur Escada",
|
||||
size="3", font_weight="700", color="#37474f",
|
||||
size="3", font_weight="700", color="var(--text-strong)",
|
||||
margin_bottom="0.75rem",
|
||||
),
|
||||
|
||||
|
|
@ -1923,9 +1923,9 @@ 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%",
|
||||
),
|
||||
|
||||
|
|
@ -1933,7 +1933,7 @@ def escada_page() -> rx.Component:
|
|||
rx.box(
|
||||
rx.text(
|
||||
"Pousser les notices en attente sur Escada",
|
||||
size="3", font_weight="700", color="#37474f",
|
||||
size="3", font_weight="700", color="var(--text-strong)",
|
||||
margin_bottom="0.75rem",
|
||||
),
|
||||
rx.cond(
|
||||
|
|
@ -2021,9 +2021,9 @@ 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%",
|
||||
),
|
||||
|
||||
|
|
|
|||
|
|
@ -79,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):
|
||||
|
|
@ -93,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)
|
||||
|
|
@ -453,6 +479,7 @@ class FicheState(AuthState):
|
|||
fiche_email_val: str = ""
|
||||
fiche_date_naissance: str = ""
|
||||
fiche_majeur: str = ""
|
||||
fiche_compensation: str = ""
|
||||
fiche_entreprise_nom: str = ""
|
||||
fiche_entreprise_adresse: str = ""
|
||||
fiche_entreprise_cp_localite: str = ""
|
||||
|
|
@ -972,7 +999,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
|
||||
# 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(
|
||||
|
|
@ -991,6 +1021,12 @@ 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_entreprise_nom = fiche.entreprise_nom or ""
|
||||
self.fiche_entreprise_adresse = fiche.entreprise_adresse or ""
|
||||
self.fiche_entreprise_cp_localite = (
|
||||
|
|
@ -1009,6 +1045,7 @@ class FicheState(AuthState):
|
|||
for attr in [
|
||||
"fiche_adresse", "fiche_cp_localite", "fiche_telephone",
|
||||
"fiche_email_val", "fiche_date_naissance", "fiche_majeur",
|
||||
"fiche_compensation",
|
||||
"fiche_entreprise_nom", "fiche_entreprise_adresse",
|
||||
"fiche_entreprise_cp_localite", "fiche_entreprise_telephone",
|
||||
"fiche_entreprise_email", "fiche_formateur_nom",
|
||||
|
|
@ -1230,7 +1267,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"},
|
||||
|
|
@ -1277,9 +1314,9 @@ def _kpi_card(label: str, value, color: str = "#37474f") -> rx.Component:
|
|||
rx.text(label, size="1", color="#666"),
|
||||
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",
|
||||
|
|
@ -1379,7 +1416,7 @@ def _edit_panel() -> rx.Component:
|
|||
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.spacer(),
|
||||
rx.button(
|
||||
|
|
@ -1442,9 +1479,9 @@ def _edit_panel() -> rx.Component:
|
|||
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",
|
||||
)
|
||||
|
|
@ -1509,9 +1546,9 @@ def _actions_row() -> rx.Component:
|
|||
width="100%",
|
||||
),
|
||||
padding="0.75rem 1rem",
|
||||
background_color="white",
|
||||
background_color="var(--surface)",
|
||||
border_radius="8px",
|
||||
border="1px solid #e0e0e0",
|
||||
border="1px solid var(--border)",
|
||||
width="100%",
|
||||
)
|
||||
|
||||
|
|
@ -1531,8 +1568,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(),
|
||||
|
|
@ -1677,9 +1714,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%",
|
||||
)
|
||||
|
||||
|
|
@ -1744,17 +1781,18 @@ def fiche_page() -> rx.Component:
|
|||
rx.vstack(
|
||||
rx.flex(
|
||||
rx.vstack(
|
||||
rx.text("Élève", size="2", font_weight="700", color="#37474f"),
|
||||
rx.text("Élève", size="2", font_weight="700", color="var(--text-strong)"),
|
||||
_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),
|
||||
_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),
|
||||
|
|
@ -1763,7 +1801,7 @@ def fiche_page() -> rx.Component:
|
|||
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),
|
||||
spacing="1", align="start", flex="1", min_width="200px",
|
||||
|
|
@ -1782,9 +1820,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%",
|
||||
),
|
||||
|
||||
|
|
@ -1859,9 +1897,9 @@ def fiche_page() -> rx.Component:
|
|||
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%",
|
||||
),
|
||||
|
||||
|
|
@ -1877,7 +1915,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(
|
||||
|
|
@ -1916,9 +1954,9 @@ 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"),
|
||||
|
|
@ -1944,9 +1982,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%",
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -250,9 +250,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%",
|
||||
)
|
||||
|
||||
|
|
@ -535,7 +535,7 @@ def _mapping_row(m: rx.Var) -> rx.Component:
|
|||
padding="0.4rem 0.6rem",
|
||||
border="1px solid var(--gray-5)",
|
||||
border_radius="6px",
|
||||
background_color="white",
|
||||
background_color="var(--surface)",
|
||||
width="100%",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -277,9 +277,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 +337,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,9 +393,9 @@ 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%",
|
||||
)
|
||||
|
||||
|
|
@ -405,6 +405,7 @@ _THEMES = [
|
|||
("bleu", "Bleu corporate", "#1565c0"),
|
||||
("indigo", "Indigo nuit", "#3f51b5"),
|
||||
("vert", "Vert académique","#2e7d32"),
|
||||
("sombre", "Sombre (dark)", "#1a1a1a"),
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -458,9 +459,9 @@ def _theme_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%",
|
||||
)
|
||||
|
||||
|
|
@ -511,9 +512,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%",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,7 +427,7 @@ 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, "var(--brand-primary-dark)"),
|
||||
|
|
@ -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%",
|
||||
|
|
|
|||
|
|
@ -499,7 +499,7 @@ def _apprenti_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": "apprenti-search"},
|
||||
|
|
@ -570,7 +570,7 @@ def _branche_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%",
|
||||
),
|
||||
|
|
@ -637,7 +637,7 @@ def _form() -> rx.Component:
|
|||
rx.box(
|
||||
rx.flex(
|
||||
rx.icon("user", size=16, color="var(--brand-accent)"),
|
||||
rx.text(RetenueState.selected_label, size="2", weight="medium", color="#37474f"),
|
||||
rx.text(RetenueState.selected_label, size="2", weight="medium", color="var(--text-strong)"),
|
||||
gap="0.5rem", align="center",
|
||||
),
|
||||
padding="0.5rem 0.75rem",
|
||||
|
|
@ -801,8 +801,8 @@ def _email_section() -> rx.Component:
|
|||
return rx.box(
|
||||
rx.vstack(
|
||||
rx.flex(
|
||||
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)"),
|
||||
gap="0.5rem", align="center",
|
||||
),
|
||||
rx.divider(),
|
||||
|
|
@ -857,9 +857,9 @@ def _email_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%",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -287,8 +287,8 @@ def _texte_section() -> rx.Component:
|
|||
return rx.box(
|
||||
rx.vstack(
|
||||
rx.flex(
|
||||
rx.icon("file-text", size=16, color="#37474f"),
|
||||
rx.text("Contenu de l'avis", size="3", weight="bold", color="#37474f"),
|
||||
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(),
|
||||
|
|
@ -318,9 +318,9 @@ def _texte_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%",
|
||||
)
|
||||
|
||||
|
|
@ -371,8 +371,8 @@ def _email_section() -> rx.Component:
|
|||
return rx.box(
|
||||
rx.vstack(
|
||||
rx.flex(
|
||||
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)"),
|
||||
gap="0.5rem", align="center",
|
||||
),
|
||||
rx.divider(),
|
||||
|
|
@ -427,9 +427,9 @@ def _email_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%",
|
||||
)
|
||||
|
||||
|
|
@ -454,7 +454,7 @@ def sanction_modal() -> rx.Component:
|
|||
rx.box(
|
||||
rx.flex(
|
||||
rx.icon("user", size=16, color="#c62828"),
|
||||
rx.text(SanctionState.selected_label, size="2", weight="medium", color="#37474f"),
|
||||
rx.text(SanctionState.selected_label, size="2", weight="medium", color="var(--text-strong)"),
|
||||
gap="0.5rem", align="center",
|
||||
),
|
||||
padding="0.5rem 0.75rem",
|
||||
|
|
|
|||
|
|
@ -770,7 +770,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%",
|
||||
),
|
||||
|
|
@ -1064,9 +1064,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 +1085,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(),
|
||||
|
|
|
|||
|
|
@ -10,14 +10,13 @@ FULL_W = "240px"
|
|||
RAIL_W = "68px"
|
||||
TOPBAR_H = "56px"
|
||||
|
||||
# Sidebar palette : couleurs neutres locales + tokens de marque (cf. responsive.css).
|
||||
_BG = "#f8f9fa" # sidebar background (light)
|
||||
_BORDER = "#e5e7eb" # subtle separator
|
||||
_TEXT = "#4b5563" # inactive text
|
||||
_TEXT_MUTED = "#9ca3af" # muted labels
|
||||
_HOVER_BG = "#f3f4f6"
|
||||
_USER_BG = "#f3f4f6" # slightly darker user section
|
||||
# Tokens dynamiques (changent selon le thème user)
|
||||
# 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)"
|
||||
|
||||
|
|
@ -537,7 +536,9 @@ 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",
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ class AuthState(rx.State):
|
|||
|
||||
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"):
|
||||
if value not in ("eptm", "bleu", "indigo", "vert", "sombre"):
|
||||
value = "eptm"
|
||||
self.theme = value
|
||||
# Persister dans auth.yaml pour synchronisation multi-device.
|
||||
|
|
|
|||
|
|
@ -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,9 +830,15 @@ 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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -341,6 +344,7 @@ def init_db(engine=None):
|
|||
"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 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
|
||||
|
|
@ -407,7 +411,7 @@ 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",
|
||||
|
|
|
|||
125
src/parser_bn.py
125
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)
|
||||
|
||||
|
|
@ -89,8 +90,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 +175,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 +188,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 +251,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 +273,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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue