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