diff --git a/assets/apple-touch-icon.png b/assets/apple-touch-icon.png
new file mode 100644
index 0000000..48795a5
Binary files /dev/null and b/assets/apple-touch-icon.png differ
diff --git a/assets/icon-192.png b/assets/icon-192.png
new file mode 100644
index 0000000..34510bb
Binary files /dev/null and b/assets/icon-192.png differ
diff --git a/assets/icon-512.png b/assets/icon-512.png
new file mode 100644
index 0000000..9e054eb
Binary files /dev/null and b/assets/icon-512.png differ
diff --git a/assets/manifest.webmanifest b/assets/manifest.webmanifest
new file mode 100644
index 0000000..809ff53
--- /dev/null
+++ b/assets/manifest.webmanifest
@@ -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"
+ }
+ ]
+}
diff --git a/assets/responsive.css b/assets/responsive.css
index 82da637..9107d20 100644
--- a/assets/responsive.css
+++ b/assets/responsive.css
@@ -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; }
diff --git a/data/auth.yaml b/data/auth.yaml
index 59a2b05..f9cec36 100644
--- a/data/auth.yaml
+++ b/data/auth.yaml
@@ -17,4 +17,4 @@ credentials:
name: test
password: $2b$12$nYZqG/bStQwweDjvR/8RNOqP6AnUDh1Dictx3BCZ2RalIyWDbre42
role: user
- totp_secret: null
+ totp_secret: TCH5IQCRIAVPZEFFUABEVXUCV7TOL5XP
diff --git a/data/class_href_cache.json b/data/class_href_cache.json
index 293a6b3..1125942 100644
--- a/data/class_href_cache.json
+++ b/data/class_href_cache.json
@@ -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"
}
\ No newline at end of file
diff --git a/eptm_dashboard/eptm_dashboard.py b/eptm_dashboard/eptm_dashboard.py
index 970c4a1..aceded6 100644
--- a/eptm_dashboard/eptm_dashboard.py
+++ b/eptm_dashboard/eptm_dashboard.py
@@ -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)
diff --git a/eptm_dashboard/pages/accueil.py b/eptm_dashboard/pages/accueil.py
index ddf2a57..cb3386b 100644
--- a/eptm_dashboard/pages/accueil.py
+++ b/eptm_dashboard/pages/accueil.py
@@ -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",
)
)
diff --git a/eptm_dashboard/pages/logs.py b/eptm_dashboard/pages/logs.py
index 3a52c2e..4a0cc2a 100644
--- a/eptm_dashboard/pages/logs.py
+++ b/eptm_dashboard/pages/logs.py
@@ -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 '
'
+
+ # 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''
+ f'{_html.escape(ts)} '
+ f'{prefix_html}{_html.escape(body)}'
+ f'
'
+ )
+
+ 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''
+ f'{_html.escape(ts)} '
+ f'{prefix_html}{_html.escape(body)}'
+ f'
'
+ )
+
+ # Ligne sans timestamp
+ level = _detect_level(line)
+ return f'{_html.escape(line)}
'
+
+
+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'[{_html.escape(m.group(1))}] '
+ 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:"
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 = 'Chemin invalide.
'
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",
diff --git a/eptm_dashboard/pages/purge.py b/eptm_dashboard/pages/purge.py
new file mode 100644
index 0000000..2c18179
--- /dev/null
+++ b/eptm_dashboard/pages/purge.py
@@ -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",
+ )
+ )
diff --git a/eptm_dashboard/sidebar.py b/eptm_dashboard/sidebar.py
index 84e10d9..ed8ea17 100644
--- a/eptm_dashboard/sidebar.py
+++ b/eptm_dashboard/sidebar.py
@@ -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",
diff --git a/src/db.py b/src/db.py
index ca89f60..6918c5e 100644
--- a/src/db.py
+++ b/src/db.py
@@ -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(
diff --git a/src/importer.py b/src/importer.py
index fa0e38d..62e7fbd 100644
--- a/src/importer.py
+++ b/src/importer.py
@@ -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"])
diff --git a/src/importer_bn.py b/src/importer_bn.py
index 2e64d6b..f6220af 100644
--- a/src/importer_bn.py
+++ b/src/importer_bn.py
@@ -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"])
diff --git a/src/importer_notes.py b/src/importer_notes.py
index b757c9b..ece7737 100644
--- a/src/importer_notes.py
+++ b/src/importer_notes.py
@@ -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()
diff --git a/src/sanction_pdf.py b/src/sanction_pdf.py
new file mode 100644
index 0000000..6fcb78a
--- /dev/null
+++ b/src/sanction_pdf.py
@@ -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()