eptm_dashboard/eptm_dashboard/pages/purge.py

616 lines
23 KiB
Python

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