tuiles sanction et webmanifest

This commit is contained in:
Julien Balet 2026-05-10 19:11:25 +02:00
parent f17041be18
commit 41c050d2d4
17 changed files with 1318 additions and 132 deletions

BIN
assets/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
assets/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
assets/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -0,0 +1,25 @@
{
"name": "EPTM Dashboard",
"short_name": "Dashboard",
"description": "Gestion des absences et notes — EPTM Sion / Monthey",
"start_url": "/accueil",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#f8f9fa",
"theme_color": "#dc000e",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

View file

@ -26,6 +26,20 @@ body, html {
overflow-wrap: break-word;
}
/* Desktop: réserve la largeur de la sidebar (240px ou 68px collapsed) */
@media (min-width: 768px) {
.content-area {
margin-left: 240px;
width: calc(100% - 240px);
max-width: calc(100% - 240px);
}
.content-area.sidebar-collapsed {
margin-left: 68px;
width: calc(100% - 68px);
max-width: calc(100% - 68px);
}
}
/* Allow flex/grid descendants to shrink below their content size,
* preventing horizontal overflow from long text or wide tables.
* Inline `min-width` styles still win (higher specificity). */
@ -44,6 +58,12 @@ body, html {
padding-right: 0.75rem !important;
padding-bottom: 0.75rem !important;
}
/* Tuiles d'avis de sanction : plein écran sur mobile */
.sanction-tile {
max-width: 100% !important;
min-width: 0 !important;
flex: 1 1 100% !important;
}
}
/* Tablet */
@ -144,6 +164,43 @@ img {
transition: all 0.15s ease;
}
/* ── Logs viewer (page /logs) ──────────────────────────────────────────── */
.log-content {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.78rem;
line-height: 1.55;
color: #cbd5e1;
}
.log-line {
white-space: pre-wrap;
word-break: break-word;
padding: 1px 0;
}
.log-line.log-blank { height: 0.4rem; }
.log-ts { color: #64748b; font-weight: 500; margin-right: 0.15rem; }
.log-indent { padding-left: 1rem; opacity: 0.85; }
.log-info { color: #cbd5e1; }
.log-debug { color: #94a3b8; opacity: 0.85; }
.log-success { color: #4ade80; }
.log-warn { color: #fbbf24; }
.log-error { color: #f87171; font-weight: 500; }
.log-prefix {
display: inline-block;
padding: 0 0.25rem;
border-radius: 3px;
font-weight: 600;
font-size: 0.92em;
margin-right: 0.25rem;
}
.log-prefix.prefix-default { background: #334155; color: #cbd5e1; }
.log-prefix.prefix-abs { background: rgba(96, 165, 250, 0.16); color: #93c5fd; }
.log-prefix.prefix-sync { background: rgba(6, 182, 212, 0.16); color: #67e8f9; }
.log-prefix.prefix-push { background: rgba(192, 132, 252, 0.16); color: #d8b4fe; }
.log-prefix.prefix-cron { background: rgba(250, 204, 21, 0.16); color: #fde047; }
/* ── Documentation rendered markdown ───────────────────────────────────── */
.doc-content { line-height: 1.65; color: #1f2937; }

View file

@ -17,4 +17,4 @@ credentials:
name: test
password: $2b$12$nYZqG/bStQwweDjvR/8RNOqP6AnUDh1Dictx3BCZ2RalIyWDbre42
role: user
totp_secret: null
totp_secret: TCH5IQCRIAVPZEFFUABEVXUCV7TOL5XP

View file

@ -1,14 +1,46 @@
{
"EM-AU 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=30066257-3dad-4b00-857b-9ab60a5d8581",
"EM-AU 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=b9363b96-2d6e-4009-a495-f26c036cc088",
"MONTAUT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=b51ec970-5bf4-4982-a05e-80546bb7421f",
"MONTAUT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=aaacc343-c248-4f21-93f6-5d9e3079aa5d",
"MONTAUT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=18c1ddbe-471f-44f6-bde6-8619adc3b767",
"EM-AU 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=a4c4c187-920c-4c91-9620-7f153cf3738a",
"EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=901ba9c8-5bb8-4170-a28e-ad1bdc8dccac",
"AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=553e320e-a5e6-484f-bbf3-989301a15449",
"AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=dd16c8ed-fde7-4aa6-bbce-9cba960b2863",
"AUTOMAT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=dcae994d-7c4e-4843-aa1c-0d44929b277c",
"AUTOMAT 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=77f8b395-5054-4414-97d1-96d9d1cba981",
"CFTI-AU 1A:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=1e858971-1c2f-4d8a-9a41-63c66716ee45"
"CFTI-AU 1A:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=4ec9bbbd-7d12-4073-9fd3-ac275dd0894e",
"CFTI-AU 1B:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=e960b23a-088d-4b57-9a09-3955c899b264",
"CFTI-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=e62f261c-736f-4d71-9392-cd42b36088b2",
"EM-AU 1A:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=545536ad-71b5-45bd-81c9-408b4a75d6aa",
"EM-AU 1B:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=19c5ad0e-db24-437d-8976-b998f13da902",
"EM-AU 2A:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=e8a84837-ea42-4872-bbd1-362d0eb10775",
"EM-AU 2B:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=047a36ce-b8e1-40ae-9ca1-358edfee933c",
"EM-AU 3A:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=eef4f04b-7f26-4a4b-87d5-3129a22b4f15",
"EM-AU 3B:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=4f51056e-ec72-4101-b6de-a2b4246632fb",
"MI-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=fb412b92-9458-4ca9-8c76-718889c0bd23",
"MI-AU CG 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=314dcc1e-f4e3-43da-aa7d-f817c3db80be",
"MI-AU CG 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=435f31f9-4f3a-4755-825a-55df5ff8a571",
"MP1-TASV 1A:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=a15d17d9-2a6d-4872-9b78-8b51bbf7215a",
"MP1-TASV 1B:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=3192aa8f-6137-4187-811a-df247c4f3f14",
"MP1-TASV 1C:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=0242982b-45ae-4059-9cdb-6eda123f60e4",
"MP1-TASV 1D:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=7404bc4d-e559-4961-83f3-28d90dcb2112",
"MP1-TASV 1E:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=94f976f1-1d81-4097-9072-b8601c058cde",
"MP1-TASV 2A:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=df9859d8-5c04-4600-b73f-22b8a8e06992",
"MP1-TASV 2B:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=7f11d6d5-bef6-4d5d-a039-a0bb49b85de4",
"MP1-TASV 2C:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=82525d35-26a9-4320-9551-37b4dd0ed479",
"MP1-TASV 2D:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=21be0157-f248-47af-ab95-027995a0269e",
"MP1-TASV 2E:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=21e967af-520f-46d2-874f-5dbd61ef24b1",
"MP1-TASV 3A:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=80d5e7d6-ff54-4ce8-8b32-677b0fdf9a74",
"MP1-TASV 3B:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=84de9bd4-949c-4472-97e1-0508a3034c6f",
"MP1-TASV 3C:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=f1b20579-73dc-499a-a4cd-5709fcfc56ab",
"MP1-TASV 3D:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=88e89d2b-e754-4501-9ba5-c56e1c14818e",
"MP1-TASV 3E:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=14627c7d-1da2-45ce-b930-1c2de3af25bb",
"MP1-TASV 4A:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=f5fa281e-6a7b-4eed-b433-6e92f1ea61fc",
"MP1-TASV 4B:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=62596263-cbb3-442a-a390-9279cceed4df",
"MP1-TASV 4C:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=1fdeb3aa-4f39-4382-ae98-1784c99a7f51",
"MP1-TASV 4D:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=3bc53c77-bda8-43a4-ab47-08c2eb84a917",
"MP1-TASV 4E:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=7673335b-a2aa-42de-9cab-6b95ffc90d7c",
"Z-IT Test 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=f0707cc3-4511-403b-a2a6-d79f2da4fd99",
"AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=0d1cdaa2-83f0-49b7-90d1-9bb10e4b23b9",
"AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=f6c1e52c-27b8-4e77-a4bf-13ddf31fb139",
"AUTOMAT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=0c361e5b-b97b-4916-9e2b-fbc476a44e9a",
"AUTOMAT 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=c0548ed6-df7c-4455-a6f5-155b86ec3418",
"EM-AU 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=1d860fc3-3128-4328-9670-29589fdc63e8",
"EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=3dae8968-e44f-461b-8fb0-e918895dc4b7",
"EM-AU 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=33b92c8d-1e93-47cf-b954-4596b2b9b59e",
"EM-AU 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=f613a72a-2d22-4093-bb29-f6a1d639466e",
"MONTAUT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=4f15ebd5-6f2f-40e5-9942-24ce8750553e",
"MONTAUT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=4aa19e25-fcf7-4142-8d1d-95ac0d8d1f30",
"MONTAUT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=585c5f01-c8e2-4dd9-91a0-057080a350a9"
}

View file

@ -9,6 +9,7 @@ from .pages.logs import logs_page, LogsState
from .pages.cron import cron_page, CronState
from .pages.users import users_page, UsersState
from .pages.params import params_page, ParamsState
from .pages.purge import purge_page, PurgeState
from .pages.doc import doc_page, DocState
TITLE = "EPTM Dashboard"
@ -17,6 +18,15 @@ app = rx.App(
stylesheets=["/responsive.css"],
head_components=[
rx.el.link(rel="icon", type="image/png", href="/favicon.png"),
# iOS Safari : icône utilisée pour "Sur l'écran d'accueil"
rx.el.link(rel="apple-touch-icon", href="/apple-touch-icon.png"),
# Android Chrome / PWA : manifest avec icônes 192/512
rx.el.link(rel="manifest", href="/manifest.webmanifest"),
# Couleur de la barre d'adresse (Android) + barre de statut (iOS standalone)
rx.el.meta(name="theme-color", content="#dc000e"),
rx.el.meta(name="apple-mobile-web-app-capable", content="yes"),
rx.el.meta(name="apple-mobile-web-app-status-bar-style", content="default"),
rx.el.meta(name="apple-mobile-web-app-title", content="EPTM"),
],
)
@ -35,4 +45,5 @@ app.add_page(logs_page, route="/logs", on_load=[AuthState.check_auth,
app.add_page(cron_page, route="/cron", on_load=[AuthState.check_auth, CronState.load_data], title=TITLE)
app.add_page(users_page, route="/users", on_load=[AuthState.check_auth, UsersState.load_data], title=TITLE)
app.add_page(params_page, route="/params", on_load=[AuthState.check_auth, ParamsState.load_data], title=TITLE)
app.add_page(purge_page, route="/purge", on_load=[AuthState.check_auth, PurgeState.load_data], title=TITLE)
app.add_page(doc_page, route="/doc", on_load=[AuthState.check_auth, DocState.load_data], title=TITLE)

View file

@ -1,19 +1,28 @@
import os
import sys
from collections import defaultdict
sys.path.insert(0, "/opt/eptm-dashboard")
import reflex as rx
from src.db import get_session
from src.stats import kpis, alertes_quota_absences
from src.sanction_pdf import generate_avis_pdf
from src.logger import app_log
from ..state import AuthState
from ..sidebar import layout
from .fiche import FicheState
from .classe import ClasseState
class AccueilState(AuthState):
kpi_mois: int = 0
kpi_total: int = 0
kpi_traiter: int = 0
sanctions: list[dict] = []
# Liste à plat (compteur global)
sanctions_total: int = 0
classes_total: int = 0
# Groupement par classe pour l'affichage en tuiles
sanctions_groups: list[dict] = []
def load_data(self):
if not self.authenticated:
@ -27,7 +36,7 @@ class AccueilState(AuthState):
self.kpi_traiter = k["n_a_traiter"]
df = alertes_quota_absences(sess, seuil=5)
self.sanctions = [
items = [
{
"id": int(row["_id"]),
"nom": str(row["Nom"]),
@ -37,11 +46,65 @@ class AccueilState(AuthState):
}
for _, row in df.iterrows()
]
# Groupement par classe (tri alphabétique des classes,
# puis par nom dans chaque classe).
grouped: dict[str, list[dict]] = defaultdict(list)
for it in items:
grouped[it["classe"]].append(it)
self.sanctions_groups = [
{
"classe": c,
"count": len(grouped[c]),
"items": sorted(grouped[c], key=lambda x: (x["nom"], x["prenom"])),
}
for c in sorted(grouped.keys())
]
self.sanctions_total = len(items)
self.classes_total = len(self.sanctions_groups)
finally:
sess.close()
except Exception as e:
print(f"[accueil] erreur: {e}")
# ── Navigation cross-page (pré-sélection) ────────────────────────────────
def open_fiche(self, apprenti_id: int):
return [
FicheState.navigate_to(apprenti_id),
rx.redirect("/fiche"),
]
def open_classe(self, classe: str):
return [
ClasseState.set_class(classe),
rx.redirect("/classe"),
]
# ── Téléchargement de l'avis de sanction ─────────────────────────────────
def download_avis(self, apprenti_id: int, nom: str, prenom: str, classe: str):
sess = get_session()
try:
data = generate_avis_pdf(
sess, apprenti_id, prof_name=self.name or self.username,
)
finally:
sess.close()
if data is None:
return rx.toast.error(
"Template introuvable. Vérifiez data/templates/GF_FO_Avis_de_sanction.pdf"
)
app_log(
f"[avis] {self.username or '?'} : avis de sanction généré pour "
f"{nom} {prenom} ({classe})"
)
safe_nom = "".join(c if c.isalnum() else "_" for c in nom)
safe_prenom = "".join(c if c.isalnum() else "_" for c in prenom)
filename = f"Avis_sanction_{safe_nom}_{safe_prenom}.pdf"
return rx.download(data=data, filename=filename)
# ── UI ────────────────────────────────────────────────────────────────────────
def _kpi_card(label: str, value: rx.Var) -> rx.Component:
return rx.box(
@ -58,40 +121,111 @@ def _kpi_card(label: str, value: rx.Var) -> rx.Component:
)
def _sanction_card(s: dict) -> rx.Component:
def _sanction_tile(item: rx.Var) -> rx.Component:
return rx.box(
rx.vstack(
rx.hstack(
rx.link(
rx.text(s["nom"], " ", s["prenom"], size="3", font_weight="700"),
href="/fiche",
text_decoration="none",
color="inherit",
rx.flex(
rx.text(
item["nom"], " ", item["prenom"],
size="3", weight="bold", color="#1a237e",
),
rx.spacer(),
rx.box(
rx.text("🔴 ", s["absences"], " abs.",
size="1", color="#B71C1C", font_weight="700"),
background_color="#ffcccc",
padding_x="0.5rem",
padding_y="0.1rem",
border_radius="10px",
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",
),
align="center",
spacing="2",
wrap="wrap",
width="100%", align="center", gap="0.5rem", wrap="wrap",
),
rx.text(s["classe"], size="1", color="#999999"),
spacing="1",
rx.button(
rx.icon("file-down", size=13),
"PDF avis de sanction",
on_click=AccueilState.download_avis(
item["id"], item["nom"], item["prenom"], item["classe"],
).stop_propagation,
size="1",
color_scheme="gray",
variant="soft",
),
spacing="2",
align="start",
width="100%",
),
on_click=AccueilState.open_fiche(item["id"]),
cursor="pointer",
padding="0.85rem 1rem",
background_color="white",
border="1px solid #f5c6cb",
border_left="4px solid #dc3545",
border="1px solid #e0e0e0",
border_radius="8px",
padding="0.625rem 0.875rem",
margin_y="0.15rem",
flex="1 1 240px",
min_width="220px",
max_width="320px",
class_name="hover-lift sanction-tile",
)
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"),
on_click=AccueilState.open_classe(group["classe"]),
cursor="pointer",
padding="0.5rem 0.75rem",
border_radius="6px",
background_color="#f8f9fa",
border="1px solid #e9ecef",
_hover={"background_color": "#eef2f6"},
width="100%",
align="center",
gap="0.5rem",
class_name="smooth-transition",
margin_bottom="0.6rem",
),
# Tuiles apprentis
rx.flex(
rx.foreach(group["items"].to(list[dict]), _sanction_tile),
gap="0.6rem",
flex_wrap="wrap",
width="100%",
),
width="100%",
class_name="hover-lift",
)
def _sanctions_section() -> rx.Component:
return rx.cond(
AccueilState.sanctions_total == 0,
rx.box(
rx.flex(
rx.icon("circle-check-big", size=18, color="#2e7d32"),
rx.text(
"Aucun apprenti n'a atteint le quota de 5 absences.",
size="2", color="#2e7d32",
),
gap="0.5rem", align="center",
),
background_color="#f1f8f1",
border="1px solid #c8e6c9",
border_radius="6px",
padding="0.85rem 1rem",
width="100%",
),
rx.vstack(
rx.foreach(AccueilState.sanctions_groups, _class_group),
spacing="4",
width="100%",
),
)
@ -102,9 +236,9 @@ def accueil_page() -> rx.Component:
# KPIs
rx.hstack(
_kpi_card("Absences ce mois", AccueilState.kpi_mois),
_kpi_card("Total absences", AccueilState.kpi_total),
_kpi_card("À traiter", AccueilState.kpi_traiter),
_kpi_card("Avis de sanction pour absences", AccueilState.sanctions_total),
_kpi_card("Total périodes d'absence", AccueilState.kpi_total),
_kpi_card("Périodes à traiter", AccueilState.kpi_traiter),
spacing="3",
width="100%",
wrap="wrap",
@ -113,44 +247,25 @@ def accueil_page() -> rx.Component:
rx.divider(),
rx.heading("🚨 Avis de sanction — quota atteint", size="5"),
rx.box(
rx.cond(
AccueilState.sanctions.length() == 0,
rx.box(
rx.text("✓ Aucun apprenti n'a atteint le quota de 5 absences.",
color="#2e7d32", size="2"),
background_color="#f1f8f1",
border="1px solid #c8e6c9",
border_radius="6px",
padding="0.75rem 1rem",
width="100%",
),
rx.vstack(
rx.box(
rx.text("", AccueilState.sanctions.length(),
" apprenti(s) en avis de sanction",
color="#B71C1C", size="2", font_weight="600"),
background_color="#fff5f5",
border="1px solid #ffcccc",
border_radius="6px",
padding="0.75rem 1rem",
width="100%",
),
rx.foreach(AccueilState.sanctions, _sanction_card),
width="100%",
spacing="1",
),
),
width="100%",
rx.flex(
rx.icon("triangle-alert", size=20, color="#c62828"),
rx.heading("Avis de sanction (> de 5 absences)", size="5"),
gap="0.5rem", align="center",
),
_sanctions_section(),
rx.divider(),
rx.heading("📉 Notes insuffisantes (BN / Matu < 4.0)", size="5"),
rx.heading("Notes insuffisantes (BN / Matu < 4.0)", size="5"),
rx.box(
rx.text(" Migration en cours — disponible prochainement.",
color="#1565c0", size="2"),
rx.flex(
rx.icon("info", size=16, color="#1565c0"),
rx.text(
"Migration en cours — disponible prochainement.",
color="#1565c0", size="2",
),
gap="0.5rem", align="center",
),
background_color="#e3f2fd",
border="1px solid #90caf9",
border_radius="6px",
@ -161,7 +276,7 @@ def accueil_page() -> rx.Component:
spacing="5",
width="100%",
max_width="100%",
align="start",
align="stretch",
padding_bottom="2rem",
)
)

View file

@ -1,3 +1,5 @@
import asyncio
import html as _html
import re
import os
from pathlib import Path
@ -14,17 +16,103 @@ _LOG_FILE = DATA_DIR / "logs" / "operations.log"
_CRON_DIR = Path(os.getenv("CRON_LOG_DIR", "/logs/cron"))
def _background(fn):
fn._reflex_background_task = True
return fn
# ── Colorisation des logs ──────────────────────────────────────────────────────
_RE_TS_PROD = re.compile(r"^(\[\d{2}:\d{2}:\d{2}\])\s+(?!\s)(.*)$")
_RE_TS_DEBUG = re.compile(r"^(\[\d{2}:\d{2}:\d{2}\])\s{2,}(.*)$")
_RE_PREFIX = re.compile(r"^\[([a-z_-]+)\]\s+(.*)$", re.IGNORECASE)
_LEVEL_PATTERNS = [
("error", re.compile(r"\b(erreur|error|exception|traceback|failed|échou|echou|invalid|timeout)\b", re.IGNORECASE)),
("warn", re.compile(r"\b(warning|warn|attention|skip|ignor)\b", re.IGNORECASE)),
("success", re.compile(r"\b(ok|succès|success|terminé|terminée|all_done|push_done|importé|importée|réussi)\b", re.IGNORECASE)),
]
# Couleurs par préfixe de catégorie
_PREFIX_CLASS = {
"abs": "prefix-abs",
"sync": "prefix-sync",
"push": "prefix-push",
"cron": "prefix-cron",
"refresh": "prefix-sync",
"run_imports": "prefix-sync",
}
def _detect_level(text: str) -> str:
for level, pat in _LEVEL_PATTERNS:
if pat.search(text):
return level
return "info"
def _format_line(line: str) -> str:
"""Convertit une ligne brute en HTML stylé avec span colorés."""
if not line.strip():
return '<div class="log-line log-blank"></div>'
# Ligne debug indentée (PROD: filtrée ; DEBUG: visible)
m_dbg = _RE_TS_DEBUG.match(line)
if m_dbg:
ts, content = m_dbg.group(1), m_dbg.group(2)
body, prefix_html = _extract_prefix(content)
return (
f'<div class="log-line log-debug">'
f'<span class="log-ts">{_html.escape(ts)}</span> '
f'<span class="log-indent">{prefix_html}{_html.escape(body)}</span>'
f'</div>'
)
m_prod = _RE_TS_PROD.match(line)
if m_prod:
ts, content = m_prod.group(1), m_prod.group(2)
body, prefix_html = _extract_prefix(content)
level = _detect_level(content)
return (
f'<div class="log-line log-{level}">'
f'<span class="log-ts">{_html.escape(ts)}</span> '
f'{prefix_html}{_html.escape(body)}'
f'</div>'
)
# Ligne sans timestamp
level = _detect_level(line)
return f'<div class="log-line log-{level}">{_html.escape(line)}</div>'
def _extract_prefix(content: str) -> tuple[str, str]:
"""Retourne (corps_sans_préfixe, html_du_préfixe_stylé). Préfixe = '[xxx] '."""
m = _RE_PREFIX.match(content)
if not m:
return content, ""
name = m.group(1).lower()
body = m.group(2)
cls = _PREFIX_CLASS.get(name, "prefix-default")
label_html = f'<span class="log-prefix {cls}">[{_html.escape(m.group(1))}]</span> '
return body, label_html
def _to_html(lines: list[str]) -> str:
return "\n".join(_format_line(ln) for ln in lines)
# ── State ──────────────────────────────────────────────────────────────────────
class LogsState(AuthState):
# Source: "ops" | "cron:<filename>"
source: str = "ops"
log_level: str = "PROD"
log_content: str = ""
log_html: str = ""
log_total: int = 0
log_shown: int = 0
log_empty: bool = True
confirm_clear: bool = False
live_mode: bool = False
# Liste des logs cron disponibles (filenames seulement)
cron_logs: list[dict] = []
@ -52,7 +140,7 @@ class LogsState(AuthState):
def _read_ops_log(self):
if not _LOG_FILE.exists() or _LOG_FILE.stat().st_size == 0:
self.log_empty = True
self.log_content = ""
self.log_html = ""
self.log_total = 0
self.log_shown = 0
return
@ -65,28 +153,27 @@ class LogsState(AuthState):
ln for ln in lines
if re.match(r"^\[\d{2}:\d{2}:\d{2}\] [^ ]", ln) or not ln.strip()
]
self.log_content = "\n".join(filtered)
self.log_html = _to_html(filtered)
self.log_shown = len(filtered)
else:
self.log_content = raw
self.log_html = _to_html(lines)
self.log_shown = self.log_total
def _read_cron_log(self, filename: str):
# Sanitize : forcer fichier dans _CRON_DIR
target = (_CRON_DIR / filename).resolve()
if not str(target).startswith(str(_CRON_DIR.resolve())):
self.log_empty = True
self.log_content = "Chemin invalide."
self.log_html = '<div class="log-line log-error">Chemin invalide.</div>'
return
if not target.exists():
self.log_empty = True
self.log_content = ""
self.log_html = ""
self.log_total = 0
self.log_shown = 0
return
raw = target.read_text(encoding="utf-8", errors="replace")
lines = raw.splitlines()
self.log_content = raw
self.log_html = _to_html(lines)
self.log_total = len(lines)
self.log_shown = len(lines)
self.log_empty = len(lines) == 0
@ -100,6 +187,8 @@ class LogsState(AuthState):
def load_data(self):
if not self.authenticated:
return rx.redirect("/login")
# Toujours désactiver le live mode quand on (re)charge la page
self.live_mode = False
self._refresh_cron_list()
self._read_log()
@ -139,9 +228,67 @@ class LogsState(AuthState):
if _LOG_FILE.exists():
return rx.download(data=_LOG_FILE.read_bytes(), filename="operations.log")
# ── Live mode ────────────────────────────────────────────────────────────
def toggle_live(self):
if self.live_mode:
self.live_mode = False
return
self.live_mode = True
return LogsState.live_loop
@_background
async def live_loop(self):
"""Polling toutes les 2s tant que live_mode est True."""
try:
while True:
async with self:
if not self.live_mode:
return
self._read_log()
await asyncio.sleep(2)
except asyncio.CancelledError:
pass
# ── UI ─────────────────────────────────────────────────────────────────────────
# Script JS : auto-scroll à chaque mutation du conteneur de logs.
_AUTOSCROLL_JS = """
(() => {
const setup = () => {
const el = document.getElementById('log-viewer');
if (!el) return false;
if (el.__autoscrollSetup) return true;
el.__autoscrollSetup = true;
// Scroll initial
el.scrollTop = el.scrollHeight;
// Auto-scroll : toujours en mode live, sinon si on était proche du bas
const obs = new MutationObserver(() => {
const isLive = el.dataset.live === '1';
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
if (isLive || nearBottom) {
el.scrollTop = el.scrollHeight;
}
});
obs.observe(el, {childList: true, subtree: true, characterData: true});
// Au passage en live mode, scroller immédiatement en bas
new MutationObserver((muts) => {
for (const m of muts) {
if (m.attributeName === 'data-live' && el.dataset.live === '1') {
el.scrollTop = el.scrollHeight;
}
}
}).observe(el, {attributes: true, attributeFilter: ['data-live']});
return true;
};
if (!setup()) {
const retry = setInterval(() => { if (setup()) clearInterval(retry); }, 250);
}
})();
"""
def _clear_zone() -> rx.Component:
return rx.cond(
LogsState.confirm_clear,
@ -176,22 +323,54 @@ def _clear_zone() -> rx.Component:
def _caption() -> rx.Component:
return rx.cond(
LogsState.log_level == "PROD",
rx.text(
LogsState.log_shown,
" ligne(s) affichée(s) / ",
LogsState.log_total,
" total — mode PROD (lignes de synthèse uniquement)",
size="1",
color="gray",
return rx.hstack(
rx.cond(
LogsState.log_level == "PROD",
rx.text(
LogsState.log_shown,
" ligne(s) affichée(s) / ",
LogsState.log_total,
" total — mode PROD (lignes de synthèse)",
size="1",
color="gray",
),
rx.text(
LogsState.log_total,
" ligne(s) — mode DEBUG (toutes lignes)",
size="1",
color="gray",
),
),
rx.text(
LogsState.log_total,
" ligne(s) — mode DEBUG (tous les logs)",
size="1",
color="gray",
rx.cond(
LogsState.live_mode,
rx.flex(
rx.box(
width="6px", height="6px",
border_radius="9999px",
background_color="#22c55e",
class_name="anim-pulse",
),
rx.text("Live", size="1", color="#22c55e", weight="medium"),
gap="0.35rem", align="center",
),
),
gap="0.75rem",
align="center",
)
def _live_button() -> rx.Component:
return rx.button(
rx.cond(
LogsState.live_mode,
rx.icon("pause", size=13),
rx.icon("play", size=13),
),
rx.cond(LogsState.live_mode, "Stop live", "Live"),
on_click=LogsState.toggle_live,
size="1",
color_scheme=rx.cond(LogsState.live_mode, "green", "gray"),
variant=rx.cond(LogsState.live_mode, "solid", "soft"),
)
@ -208,23 +387,16 @@ def _log_display() -> rx.Component:
rx.vstack(
_caption(),
rx.box(
rx.el.pre(
LogsState.log_content,
style={
"fontFamily": "'Courier New', Courier, monospace",
"fontSize": "0.72rem",
"whiteSpace": "pre-wrap",
"wordBreak": "break-all",
"color": "#abb2bf",
"margin": "0",
},
),
background="#1e2228",
padding="1rem",
rx.html(LogsState.log_html, class_name="log-content"),
id="log-viewer",
custom_attrs={"data-live": rx.cond(LogsState.live_mode, "1", "0")},
background="#1a1d23",
padding="0.75rem 1rem",
border_radius="6px",
overflow_y="auto",
max_height="70vh",
width="100%",
border="1px solid #2a2f37",
),
width="100%",
gap="0.375rem",
@ -283,6 +455,7 @@ def logs_page() -> rx.Component:
align="center",
gap="0.375rem",
),
_live_button(),
rx.button(
rx.icon("refresh-cw", size=13),
"Rafraîchir",
@ -290,6 +463,7 @@ def logs_page() -> rx.Component:
size="1",
color_scheme="gray",
variant="soft",
disabled=LogsState.live_mode,
),
rx.button(
rx.icon("download", size=13),
@ -317,8 +491,8 @@ def logs_page() -> rx.Component:
rx.divider(),
# ── Contenu ──────────────────────────────────────────────────────
_log_display(),
rx.script(_AUTOSCROLL_JS),
width="100%",
align="start",

View file

@ -0,0 +1,616 @@
"""Page /purge — suppression complète des données d'une classe (admin)."""
from __future__ import annotations
import os
import sys
from pathlib import Path
import reflex as rx
from sqlalchemy import delete, select
# Path setup pour imports src/
_ROOT = Path(__file__).resolve().parent.parent.parent
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
from src.db import ( # noqa: E402
get_session, Apprenti, Absence, EscadaPending,
Import, ImportBN, NotesBulletin, NotesMatu, NotesExamen,
ApprentiFiche, SanctionExport,
)
from src.logger import app_log # noqa: E402
from ..state import AuthState
from ..sidebar import layout
from ..components import empty_state
DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data")))
PDFS_DIR = DATA_DIR / "pdfs"
# ── State ─────────────────────────────────────────────────────────────────────
class PurgeState(AuthState):
classes: list[str] = []
selected_class: str = ""
class_search: str = ""
class_select_open: bool = False
# Aperçu (avant suppression) — Vars individuelles plutôt qu'un dict
# pour permettre comparaisons numériques côté Reflex.
has_preview: bool = False
pv_apprentis: int = 0
pv_absences: int = 0
pv_pendings: int = 0
pv_bn: int = 0
pv_matu: int = 0
pv_notes_examen: int = 0
pv_fiches: int = 0
pv_sanctions: int = 0
pv_imports: int = 0
pv_imports_bn: int = 0
pv_pdfs: int = 0
pv_pdf_files: list[str] = []
# Confirmation
confirm_text: str = ""
is_purging: bool = False
# Résultat
has_result: bool = False
res_classe: str = ""
res_apprentis: int = 0
res_absences: int = 0
res_pendings: int = 0
res_bn: int = 0
res_matu: int = 0
res_notes_examen: int = 0
res_fiches: int = 0
res_sanctions: int = 0
res_imports: int = 0
res_imports_bn: int = 0
res_pdfs: int = 0
@rx.var
def filtered_classes(self) -> list[str]:
q = self.class_search.lower().strip()
if not q:
return self.classes
return [c for c in self.classes if q in c.lower()]
@rx.var
def confirm_match(self) -> bool:
return (
self.selected_class != ""
and self.confirm_text.strip() == self.selected_class
)
def load_data(self):
if not self.authenticated:
return rx.redirect("/login")
if self.role != "admin":
return rx.redirect("/accueil")
sess = get_session()
classes = sess.execute(
select(Apprenti.classe).distinct().order_by(Apprenti.classe)
).scalars().all()
sess.close()
self.classes = [c for c in classes if c]
self.has_preview = False
self.has_result = False
self.confirm_text = ""
# ── Selector ──────────────────────────────────────────────────────────────
def set_class_search(self, v: str):
self.class_search = v
def set_class_select_open(self, v: bool):
self.class_select_open = v
if not v:
self.class_search = ""
def select_class(self, classe: str):
self.selected_class = classe
self.class_select_open = False
self.class_search = ""
self.confirm_text = ""
self.has_result = False
self._compute_preview()
def class_search_keydown(self, key: str):
if key == "Enter":
results = self.filtered_classes
if results:
return PurgeState.select_class(results[0])
elif key == "Escape":
self.class_select_open = False
self.class_search = ""
def _compute_preview(self):
sess = get_session()
try:
apprenti_ids = list(sess.execute(
select(Apprenti.id).where(Apprenti.classe == self.selected_class)
).scalars().all())
self.pv_apprentis = len(apprenti_ids)
if apprenti_ids:
self.pv_absences = len(sess.execute(
select(Absence.id).where(Absence.apprenti_id.in_(apprenti_ids))
).all())
self.pv_pendings = len(sess.execute(
select(EscadaPending.id).where(EscadaPending.apprenti_id.in_(apprenti_ids))
).all())
self.pv_bn = len(sess.execute(
select(NotesBulletin.id).where(NotesBulletin.apprenti_id.in_(apprenti_ids))
).all())
self.pv_matu = len(sess.execute(
select(NotesMatu.id).where(NotesMatu.apprenti_id.in_(apprenti_ids))
).all())
self.pv_notes_examen = len(sess.execute(
select(NotesExamen.id).where(NotesExamen.apprenti_id.in_(apprenti_ids))
).all())
self.pv_fiches = len(sess.execute(
select(ApprentiFiche.id).where(ApprentiFiche.apprenti_id.in_(apprenti_ids))
).all())
self.pv_sanctions = len(sess.execute(
select(SanctionExport.id).where(SanctionExport.apprenti_id.in_(apprenti_ids))
).all())
else:
self.pv_absences = 0
self.pv_pendings = 0
self.pv_bn = 0
self.pv_matu = 0
self.pv_notes_examen = 0
self.pv_fiches = 0
self.pv_sanctions = 0
self.pv_imports = len(sess.execute(
select(Import.id).where(Import.classe == self.selected_class)
).all())
self.pv_imports_bn = len(sess.execute(
select(ImportBN.id).where(ImportBN.classe == self.selected_class)
).all())
# PDFs : chemins déclarés + canoniques
pdf_set: set[str] = set()
for fichier in sess.execute(
select(Import.fichier).where(Import.classe == self.selected_class)
).scalars().all():
if fichier:
pdf_set.add(fichier)
for fichier in sess.execute(
select(ImportBN.fichier).where(ImportBN.classe == self.selected_class)
).scalars().all():
if fichier:
pdf_set.add(fichier)
classe_normalized = self.selected_class.replace(" ", "_")
for canonical in (
f"esacada_{classe_normalized}.pdf",
f"bn_{classe_normalized}.pdf",
f"notes_{classe_normalized}.pdf",
):
pdf_set.add(canonical)
existing_pdfs = sorted(f for f in pdf_set if (PDFS_DIR / f).exists())
self.pv_pdfs = len(existing_pdfs)
self.pv_pdf_files = existing_pdfs
self.has_preview = True
finally:
sess.close()
# ── Setters ──────────────────────────────────────────────────────────────
def set_confirm_text(self, v: str):
self.confirm_text = v
# ── Suppression ──────────────────────────────────────────────────────────
def purge(self):
if not self.confirm_match:
return rx.toast.error("Confirmation invalide.")
classe = self.selected_class
user = self.username or "?"
self.is_purging = True
sess = get_session()
try:
apprenti_ids = list(sess.execute(
select(Apprenti.id).where(Apprenti.classe == classe)
).scalars().all())
n_pendings = n_abs = n_bn = n_matu = n_notes_ex = 0
n_fiches = n_sanctions = 0
if apprenti_ids:
n_pendings = sess.execute(
delete(EscadaPending).where(EscadaPending.apprenti_id.in_(apprenti_ids))
).rowcount or 0
n_abs = sess.execute(
delete(Absence).where(Absence.apprenti_id.in_(apprenti_ids))
).rowcount or 0
n_bn = sess.execute(
delete(NotesBulletin).where(NotesBulletin.apprenti_id.in_(apprenti_ids))
).rowcount or 0
n_matu = sess.execute(
delete(NotesMatu).where(NotesMatu.apprenti_id.in_(apprenti_ids))
).rowcount or 0
n_notes_ex = sess.execute(
delete(NotesExamen).where(NotesExamen.apprenti_id.in_(apprenti_ids))
).rowcount or 0
n_fiches = sess.execute(
delete(ApprentiFiche).where(ApprentiFiche.apprenti_id.in_(apprenti_ids))
).rowcount or 0
n_sanctions = sess.execute(
delete(SanctionExport).where(SanctionExport.apprenti_id.in_(apprenti_ids))
).rowcount or 0
# Récupération des fichiers PDF avant suppression des imports
pdf_set: set[str] = set()
for fichier in sess.execute(
select(Import.fichier).where(Import.classe == classe)
).scalars().all():
if fichier:
pdf_set.add(fichier)
for fichier in sess.execute(
select(ImportBN.fichier).where(ImportBN.classe == classe)
).scalars().all():
if fichier:
pdf_set.add(fichier)
n_imports = sess.execute(
delete(Import).where(Import.classe == classe)
).rowcount or 0
n_imports_bn = sess.execute(
delete(ImportBN).where(ImportBN.classe == classe)
).rowcount or 0
n_apprentis = sess.execute(
delete(Apprenti).where(Apprenti.classe == classe)
).rowcount or 0
sess.commit()
# Suppression des PDFs (canoniques + référencés dans les imports)
classe_normalized = classe.replace(" ", "_")
for canonical in (
f"esacada_{classe_normalized}.pdf",
f"bn_{classe_normalized}.pdf",
f"notes_{classe_normalized}.pdf",
):
pdf_set.add(canonical)
n_pdfs = 0
for fname in pdf_set:
fpath = PDFS_DIR / fname
if fpath.exists():
try:
fpath.unlink()
n_pdfs += 1
except Exception as e:
app_log(f"[purge] échec suppression PDF {fname} : {e}")
app_log(
f"[purge] {user} : suppression complète classe '{classe}'"
f"{n_apprentis} appr., {n_abs} abs, {n_bn} BN, {n_matu} matu, "
f"{n_notes_ex} notes, {n_fiches} fiches, {n_pendings} pendings, "
f"{n_imports + n_imports_bn} imports, {n_pdfs} PDFs"
)
# Sauver les résultats
self.res_classe = classe
self.res_apprentis = n_apprentis
self.res_absences = n_abs
self.res_pendings = n_pendings
self.res_bn = n_bn
self.res_matu = n_matu
self.res_notes_examen = n_notes_ex
self.res_fiches = n_fiches
self.res_sanctions = n_sanctions
self.res_imports = n_imports
self.res_imports_bn = n_imports_bn
self.res_pdfs = n_pdfs
except Exception as e:
sess.rollback()
self.is_purging = False
app_log(f"[purge] {user} : ERREUR purge classe '{classe}' : {e}")
return rx.toast.error(f"Erreur lors de la suppression : {e}")
finally:
sess.close()
self.is_purging = False
self.has_result = True
self.has_preview = False
self.confirm_text = ""
self.selected_class = ""
# Recharger la liste des classes
sess2 = get_session()
try:
classes = sess2.execute(
select(Apprenti.classe).distinct().order_by(Apprenti.classe)
).scalars().all()
self.classes = [c for c in classes if c]
finally:
sess2.close()
return rx.toast.success(
f"Classe '{classe}' supprimée — {self.res_apprentis} apprentis, "
f"{self.res_absences} absences, {self.res_pdfs} PDFs."
)
# ── UI ────────────────────────────────────────────────────────────────────────
def _classe_option(classe: rx.Var) -> rx.Component:
return rx.box(
rx.text(classe, size="2"),
padding="0.45rem 0.75rem",
cursor="pointer",
on_click=PurgeState.select_class(classe),
_hover={"background_color": "var(--gray-3)"},
width="100%",
)
def _classe_selector() -> rx.Component:
return rx.popover.root(
rx.popover.trigger(
rx.box(
rx.flex(
rx.cond(
PurgeState.selected_class != "",
rx.text(PurgeState.selected_class, size="2"),
rx.text("Sélectionner une classe…", size="2", color="var(--gray-9)"),
),
rx.spacer(),
rx.icon("chevron-down", size=18, color="var(--gray-9)"),
align="center",
width="100%",
),
padding="0.5rem 0.75rem",
border="1px solid var(--gray-7)",
border_radius="6px",
background_color="white",
cursor="pointer",
width="100%",
custom_attrs={"data-shortcut": "purge-search"},
),
),
rx.popover.content(
rx.vstack(
rx.input(
placeholder="Rechercher une classe…",
value=PurgeState.class_search,
on_change=PurgeState.set_class_search,
on_key_down=PurgeState.class_search_keydown,
size="2",
width="100%",
auto_focus=True,
),
rx.cond(
PurgeState.filtered_classes.length() > 0,
rx.box(
rx.foreach(PurgeState.filtered_classes, _classe_option),
max_height="280px",
overflow_y="auto",
width="100%",
),
rx.box(
rx.text("Aucun résultat", size="2", color="var(--gray-9)"),
padding="0.5rem 0.75rem",
),
),
spacing="2",
width="100%",
),
min_width="280px",
max_width="400px",
padding="0.5rem",
),
open=PurgeState.class_select_open,
on_open_change=PurgeState.set_class_select_open,
)
def _kpi(label: str, value, color: str = "#37474f") -> rx.Component:
return rx.box(
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",
border_radius="6px",
min_width="110px",
text_align="center",
flex="1",
)
def _preview_panel() -> rx.Component:
return rx.cond(
PurgeState.has_preview,
rx.vstack(
rx.text(
"Données qui seront supprimées :",
size="2", weight="bold", color="#37474f",
),
rx.flex(
_kpi("Apprentis", PurgeState.pv_apprentis, "#c62828"),
_kpi("Absences", PurgeState.pv_absences, "#c62828"),
_kpi("Pendings", PurgeState.pv_pendings, "#b45309"),
_kpi("BN", PurgeState.pv_bn),
_kpi("Matu", PurgeState.pv_matu),
_kpi("Notes ex.", PurgeState.pv_notes_examen),
_kpi("Fiches", PurgeState.pv_fiches),
_kpi("Sanctions", PurgeState.pv_sanctions),
_kpi("Imports", PurgeState.pv_imports),
_kpi("Imports BN", PurgeState.pv_imports_bn),
_kpi("PDFs", PurgeState.pv_pdfs),
gap="0.5rem",
flex_wrap="wrap",
width="100%",
),
rx.cond(
PurgeState.pv_pdfs > 0,
rx.box(
rx.text(
"Fichiers PDF qui seront effacés :",
size="1", color="#666", weight="medium",
margin_bottom="0.25rem",
),
rx.foreach(
PurgeState.pv_pdf_files,
lambda f: rx.text("", f, size="1", color="#666"),
),
padding="0.6rem 0.75rem",
background_color="#fafafa",
border_radius="6px",
border="1px solid #eee",
width="100%",
),
),
spacing="3",
width="100%",
),
)
def _confirm_panel() -> rx.Component:
return rx.cond(
PurgeState.has_preview,
rx.box(
rx.vstack(
rx.flex(
rx.icon("triangle-alert", size=18, color="#92400e"),
rx.text(
"Confirmation requise",
size="3", weight="bold", color="#92400e",
),
gap="0.5rem", align="center",
),
rx.text(
"Cette action est définitive. Pour confirmer, recopie le nom exact de la classe ci-dessous :",
size="2", color="#78350f",
),
rx.code(PurgeState.selected_class, size="3"),
rx.input(
placeholder="Nom de la classe à recopier…",
value=PurgeState.confirm_text,
on_change=PurgeState.set_confirm_text,
size="2",
width="100%",
),
rx.flex(
rx.button(
rx.icon("trash-2", size=14),
"Supprimer définitivement",
on_click=PurgeState.purge,
color_scheme="red",
size="2",
disabled=~PurgeState.confirm_match | PurgeState.is_purging,
loading=PurgeState.is_purging,
),
gap="0.5rem",
align="center",
),
spacing="3",
align="start",
width="100%",
),
padding="1rem",
background_color="#fef3c7",
border="1px solid #fcd34d",
border_radius="8px",
width="100%",
),
)
def _result_panel() -> rx.Component:
return rx.cond(
PurgeState.has_result,
rx.box(
rx.vstack(
rx.flex(
rx.icon("circle-check-big", size=18, color="#15803d"),
rx.text(
"Suppression terminée — ",
PurgeState.res_classe,
size="3", weight="bold", color="#15803d",
),
gap="0.5rem", align="center",
),
rx.text(
PurgeState.res_apprentis, " apprentis · ",
PurgeState.res_absences, " absences · ",
PurgeState.res_pendings, " pendings · ",
PurgeState.res_bn, " BN · ",
PurgeState.res_matu, " matu · ",
PurgeState.res_notes_examen, " notes · ",
PurgeState.res_fiches, " fiches · ",
PurgeState.res_sanctions, " sanctions · ",
PurgeState.res_imports, " + ",
PurgeState.res_imports_bn, " imports · ",
PurgeState.res_pdfs, " PDFs",
size="2", color="#166534",
),
spacing="2",
width="100%",
),
padding="1rem",
background_color="#dcfce7",
border="1px solid #86efac",
border_radius="8px",
width="100%",
class_name="anim-fade",
),
)
def purge_page() -> rx.Component:
return layout(
rx.vstack(
rx.heading("Supprimer une classe", size="6"),
rx.box(
rx.flex(
rx.icon("triangle-alert", size=18, color="#b91c1c"),
rx.vstack(
rx.text(
"Action destructive",
size="2", weight="bold", color="#7f1d1d",
),
rx.text(
"Supprime définitivement toutes les données liées à une classe : "
"apprentis, absences, pendings, bulletins de notes, notes de matu, "
"notes d'examen, fiches personnelles, sanctions, traces d'imports, "
"et les PDFs sur disque. Cette opération est irréversible.",
size="1", color="#991b1b",
),
spacing="1", align="start",
),
gap="0.65rem", align="start",
),
padding="0.85rem 1rem",
background_color="#fee2e2",
border="1px solid #fca5a5",
border_radius="8px",
width="100%",
),
rx.cond(
PurgeState.classes.length() > 0,
rx.vstack(
_classe_selector(),
_preview_panel(),
_confirm_panel(),
_result_panel(),
spacing="4",
width="100%",
),
empty_state(
icon="database",
title="Aucune classe en base",
description="Il n'y a aucune classe à supprimer.",
),
),
spacing="4",
width="100%",
padding="1rem",
)
)

View file

@ -32,6 +32,7 @@ _ADMIN_PAGES = [
("Logs", "/logs", "file-text"),
("Utilisateurs", "/users", "user-cog"),
("Paramètres", "/params", "settings"),
("Purger classe","/purge", "trash-2"),
]
@ -348,8 +349,8 @@ def sidebar() -> rx.Component:
padding_y="0.5rem",
),
_doc_section(),
_admin_section(),
_doc_section(),
rx.spacer(),
# User
@ -390,10 +391,7 @@ def _mobile_topbar() -> rx.Component:
# Bar row
rx.hstack(
rx.box(
rx.image(src="/logo.png", height="40px", object_fit="contain"),
background_color="white",
border_radius="5px",
padding="4px 8px",
rx.image(src="/logo.png", height="48px", object_fit="contain"),
display="flex",
align_items="center",
justify_content="center",
@ -425,8 +423,8 @@ def _mobile_topbar() -> rx.Component:
spacing="1", width="100%",
padding_x="0", padding_y="0.5rem",
),
_doc_section(mobile=True),
_admin_section(mobile=True),
_doc_section(mobile=True),
rx.box(height="1px", width="100%", background_color=_BORDER),
rx.box(
_user_widget(collapsed=False),
@ -483,20 +481,13 @@ def layout(content: rx.Component) -> rx.Component:
_mobile_topbar(),
rx.box(
content,
class_name="content-area",
class_name=rx.cond(
AuthState.sidebar_collapsed,
"content-area sidebar-collapsed",
"content-area",
),
padding=rx.cond(AuthState.sidebar_collapsed, "1rem", "1.5rem"),
background_color="var(--gray-2)",
margin_left=rx.cond(AuthState.sidebar_collapsed, RAIL_W, FULL_W),
width=rx.cond(
AuthState.sidebar_collapsed,
f"calc(100% - {RAIL_W})",
f"calc(100% - {FULL_W})",
),
max_width=rx.cond(
AuthState.sidebar_collapsed,
f"calc(100% - {RAIL_W})",
f"calc(100% - {FULL_W})",
),
overflow_x="hidden",
transition="margin-left 0.22s ease, width 0.22s ease",
box_sizing="border-box",

View file

@ -377,7 +377,27 @@ def find_or_create_apprenti(
le prénom est compatible (l'un est un préfixe-mot de l'autre).
Ne fusionne que s'il y a exactement un candidat.
3. Sinon : crée un nouvel Apprenti.
Garde-fou : refuse la création pour les classes MP/MI. Les MP servent
uniquement au matching Matu (lookup par nom dans une classe régulière) ;
les MI sont totalement ignorées. Lève ValueError si on tente de créer
une nouvelle entrée dans ces classes.
"""
if not classe or not classe.strip():
# Empêche les orphelins quand le parser PDF n'arrive pas à extraire
# la classe du header "Liste des absences de NOM, classe CODE".
raise ValueError(
f"Création d'apprenti refusée : classe vide pour {nom!r} {prenom!r}. "
f"Vérifier le PDF source (header de page incomplet)."
)
if classe.startswith(("MP", "MI")):
# Pour MP/MI : on retourne None implicitement via une exception. L'appelant
# (importer.py) doit avoir filtré au préalable. Cette garde évite tout
# nouvel import accidentel.
raise ValueError(
f"Création d'apprenti refusée pour la classe '{classe}' "
f"(MP/MI réservées au matching Matu via classes régulières)."
)
# 1. Exact
apprenti = session.execute(
select(Apprenti).where(

View file

@ -50,6 +50,17 @@ def import_pdf(
semestre = data["semestre"]
apprentis_data = data["apprentis"]
# Garde-fou : on n'importe JAMAIS d'absences pour les classes MP/MI.
# Les MP servent uniquement au matching Matu (via NotesMatu, lié à des
# apprentis déjà présents dans une classe régulière). Les MI sont
# totalement ignorées.
if classe.startswith(("MP", "MI")):
return ImportResult(
import_id=0,
classe=classe, semestre=semestre, fichier=pdf_path.name,
nb_apprentis=0, nb_absences_nouvelles=0, nb_absences_doublons=0,
)
nb_nouvelles = 0
nb_doublons = 0
nb_mises_a_jour = 0
@ -75,9 +86,14 @@ def import_pdf(
seen_keys: set[tuple] = set()
for a_data in apprentis_data:
apprenti = find_or_create_apprenti(
session, a_data["nom"], a_data["prenom"], a_data["classe"]
)
try:
apprenti = find_or_create_apprenti(
session, a_data["nom"], a_data["prenom"], a_data["classe"]
)
except ValueError:
# Apprenti rejeté par le garde-fou (classe vide / MP / MI) :
# on saute cette page du PDF sans interrompre tout l'import.
continue
for ab in a_data["absences"]:
key = (apprenti.id, ab["date"], ab["periode"])

View file

@ -22,6 +22,18 @@ def import_bn(pdf_path: Path, session: Session, imported_by: str) -> ImportBN:
"""
data = parse_bn_pdf(pdf_path)
# Garde-fou : pas d'import BN pour les classes MP/MI.
# Les BN sont liés au cursus de la classe régulière.
if data["classe"].startswith(("MP", "MI")):
# Retourne un placeholder non-persisté (id=None)
return ImportBN(
fichier=pdf_path.name,
classe=data["classe"],
type_classe=data.get("type_classe", ""),
nb_apprentis=0,
imported_by=imported_by,
)
# Supprimer les anciens batches pour cette classe
old_imports = session.execute(
select(ImportBN).where(ImportBN.classe == data["classe"])

View file

@ -103,6 +103,10 @@ def import_notes_pdf(pdf_path: Path, sess: Session, classe: str | None = None) -
if classe is None:
classe = p.stem.replace("notes_", "").replace("_", " ")
# Garde-fou : pas de notes d'examen pour les classes MP/MI.
if classe.startswith(("MP", "MI")):
return {"classe": classe, "nb": 0}
apprentis = sess.execute(
select(Apprenti).where(Apprenti.classe == classe)
).scalars().all()

113
src/sanction_pdf.py Normal file
View file

@ -0,0 +1,113 @@
"""Génération d'avis de sanction à partir du template AcroForm officiel.
Le template est `data/templates/GF_FO_Avis_de_sanction.pdf`. Il contient
9 champs de formulaire qu'on remplit programmatiquement avec pypdf, sans
aplatir (les champs restent éditables après téléchargement).
"""
from __future__ import annotations
import io
import json
import os
from datetime import date
from pathlib import Path
from typing import Optional
import pypdf
from sqlalchemy.orm import Session
from src.db import Apprenti, ApprentiFiche
_ROOT = Path(__file__).resolve().parent.parent
_DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data")))
_TEMPLATE_PATH = _DATA_DIR / "templates" / "GF_FO_Avis_de_sanction.pdf"
_SETTINGS_PATH = _DATA_DIR / "settings.json"
# Mêmes valeurs par défaut que la page Paramètres (pages/params.py).
_DEFAULT_TEXTE_SANCTION = (
"Selon le règlement de l'EM, l'apprenti a dépassé le nombre d'absences limite."
)
_DEFAULT_CHEF_SECTION = "Patrick Rausis"
def _load_settings() -> dict:
if _SETTINGS_PATH.exists():
try:
return json.loads(_SETTINGS_PATH.read_text(encoding="utf-8"))
except Exception:
return {}
return {}
def generate_avis_pdf(
sess: Session,
apprenti_id: int,
prof_name: str = "",
) -> Optional[bytes]:
"""Renvoie les bytes d'un PDF d'avis de sanction pré-rempli pour l'apprenti.
Champs remplis depuis ApprentiFiche.entreprise_* (adresse, NPA-Ville et
NomParents = nom entreprise) puisque les parents ne sont pas stockés.
Texte de description et chef de section depuis data/settings.json.
Renvoie None si le template est introuvable ou l'apprenti n'existe pas.
"""
if not _TEMPLATE_PATH.exists():
return None
apprenti = sess.get(Apprenti, apprenti_id)
if apprenti is None:
return None
fiche: Optional[ApprentiFiche] = apprenti.fiche
settings = _load_settings()
# Construction des valeurs
npa_ville = ""
if fiche:
cp = (fiche.entreprise_code_postal or "").strip()
loc = (fiche.entreprise_localite or "").strip()
npa_ville = f"{cp} {loc}".strip()
field_values: dict[str, str] = {
"NomApprenti": f"{apprenti.prenom} {apprenti.nom}".strip(),
"Classe": apprenti.classe or "",
"NomParents": (fiche.entreprise_nom if fiche else "") or "",
"Adresse": (fiche.entreprise_adresse if fiche else "") or "",
"NPA-Ville": npa_ville,
"Date": date.today().strftime("%d.%m.%Y"),
"TexteDescription": settings.get("texte_sanction") or _DEFAULT_TEXTE_SANCTION,
"Prof": prof_name or "",
"CS": settings.get("chef_section") or _DEFAULT_CHEF_SECTION,
}
# Lecture du template + clone vers writer (préserve la structure AcroForm)
reader = pypdf.PdfReader(str(_TEMPLATE_PATH))
writer = pypdf.PdfWriter(clone_from=reader)
# Remplissage des champs sur chaque page (AcroForm peut être réparti).
# auto_regenerate=False : conserve les valeurs même si Reader recalcule
# les apparences (Acrobat les redessine à l'ouverture).
for page in writer.pages:
try:
writer.update_page_form_field_values(
page, field_values, auto_regenerate=False
)
except Exception:
# Champ peut-être absent de cette page : ignore et continue
pass
# Force les champs comme NeedAppearances pour que les viewers redessinent
# correctement les valeurs à l'ouverture.
try:
if "/AcroForm" in writer._root_object:
writer._root_object["/AcroForm"].update(
{pypdf.generic.NameObject("/NeedAppearances"): pypdf.generic.BooleanObject(True)}
)
except Exception:
pass
buf = io.BytesIO()
writer.write(buf)
return buf.getvalue()