ajouté import du statut des désavantages, affichage de toutes les notes du BN.

This commit is contained in:
Julien Balet 2026-05-11 19:19:26 +02:00
parent 38189deb0f
commit 7431339ce5
21 changed files with 796 additions and 188 deletions

295
DEPLOY_PROD.md Normal file
View file

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

10
TODO.md
View file

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

View file

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

View file

@ -32,15 +32,15 @@
"MP1-TASV 4D:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=3bc53c77-bda8-43a4-ab47-08c2eb84a917",
"MP1-TASV 4E:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=7673335b-a2aa-42de-9cab-6b95ffc90d7c",
"Z-IT Test 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=f0707cc3-4511-403b-a2a6-d79f2da4fd99",
"AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=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"
}

View file

@ -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,53 +153,44 @@ 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(
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.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="red",
variant="soft",
),
spacing="2",
align="start",
width="100%",
return 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.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-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",
),
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%",

View file

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

View file

@ -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%",

View file

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

View file

@ -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%",
),

View file

@ -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%",
),
),

View file

@ -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%",
)

View file

@ -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,18 +393,19 @@ 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"),
("eptm", "EPTM (rouge)", "#dc000e"),
("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%",
)

View file

@ -365,7 +365,7 @@ def _classe_selector() -> rx.Component:
padding="0.5rem 0.75rem",
border="1px solid var(--gray-7)",
border_radius="6px",
background_color="white",
background_color="var(--surface)",
cursor="pointer",
width="100%",
custom_attrs={"data-shortcut": "purge-search"},
@ -412,8 +412,8 @@ def _kpi(label: str, value, color: str = "#37474f") -> rx.Component:
rx.text(label, size="1", color="#666"),
rx.text(value, size="5", font_weight="700", color=color),
padding="0.6rem 0.85rem",
background_color="white",
border="1px solid #e0e0e0",
background_color="var(--surface)",
border="1px solid var(--border)",
border_radius="6px",
min_width="110px",
text_align="center",
@ -427,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%",

View file

@ -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%",
)

View file

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

View file

@ -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(),

View file

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

View file

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

View file

@ -766,6 +766,20 @@ def _scrape_student_details(page: Page, class_name: str) -> list[dict]:
# Déplier + lire une ligne à la fois (Escada ne gère pas les AJAX simultanés)
fiches: list[dict] = []
for i in range(n):
# Lire l'indicateur "Compensation des désavantages" sur la ligne
# principale AVANT l'expand. L'icône est pawn_glass_blue.png (a le droit)
# ou pawn_glass_white.png (pas le droit).
compensation = page.evaluate("""([gid, i]) => {
const row = document.getElementById(`${gid}_DXDataRow${i}`);
if (!row) return null;
const img = row.querySelector('img[src*="pawn_glass"]');
if (!img) return null;
const src = img.getAttribute('src') || '';
if (src.includes('blue')) return true;
if (src.includes('white')) return false;
return null;
}""", [gid, i])
# Clic sur le bouton expand de la ligne i
clicked = page.evaluate("""([gid, i]) => {
const row = document.getElementById(`${gid}_DXDataRow${i}`);
@ -816,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:

View file

@ -176,6 +176,9 @@ class ApprentiFiche(Base):
email: Mapped[Optional[str]] = mapped_column(String, nullable=True)
date_naissance: Mapped[Optional[str]] = mapped_column(String, nullable=True)
majeur: Mapped[Optional[bool]] = mapped_column(nullable=True)
# Compensation des désavantages (Nachteilsausgleich) — True si accordée,
# False sinon, None si la donnée n'a pas été scrapée
compensation_desavantages: Mapped[Optional[bool]] = mapped_column(nullable=True)
# Entreprise
entreprise_nom: Mapped[Optional[str]] = mapped_column(String, nullable=True)
@ -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",

View file

@ -7,6 +7,7 @@ Two PDF variants:
groups: Branches professionnelles (BP) + Travaux pratiques (TP)
Extracted rows (per group):
- Branches individuelles branches: [{"nom": str, "notes": [...]}]
- Moyenne semestrielle du groupe moy_sem[0..7]
- Moyenne annuelle du groupe moy_ann[0..7] (non-null at Sem.2,4,6,8 positions)
@ -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