From 41c050d2d40d86275d2ec19019055183d34daa4a Mon Sep 17 00:00:00 2001 From: Julien Balet Date: Sun, 10 May 2026 19:11:25 +0200 Subject: [PATCH] tuiles sanction et webmanifest --- assets/apple-touch-icon.png | Bin 0 -> 10931 bytes assets/icon-192.png | Bin 0 -> 11720 bytes assets/icon-512.png | Bin 0 -> 18531 bytes assets/manifest.webmanifest | 25 ++ assets/responsive.css | 57 +++ data/auth.yaml | 2 +- data/class_href_cache.json | 56 ++- eptm_dashboard/eptm_dashboard.py | 11 + eptm_dashboard/pages/accueil.py | 239 ++++++++---- eptm_dashboard/pages/logs.py | 246 ++++++++++-- eptm_dashboard/pages/purge.py | 616 +++++++++++++++++++++++++++++++ eptm_dashboard/sidebar.py | 27 +- src/db.py | 20 + src/importer.py | 22 +- src/importer_bn.py | 12 + src/importer_notes.py | 4 + src/sanction_pdf.py | 113 ++++++ 17 files changed, 1318 insertions(+), 132 deletions(-) create mode 100644 assets/apple-touch-icon.png create mode 100644 assets/icon-192.png create mode 100644 assets/icon-512.png create mode 100644 assets/manifest.webmanifest create mode 100644 eptm_dashboard/pages/purge.py create mode 100644 src/sanction_pdf.py diff --git a/assets/apple-touch-icon.png b/assets/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..48795a52e1c5e10e0b01caf40eadea0f4af49a3a GIT binary patch literal 10931 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 z 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 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()