616 lines
23 KiB
Python
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",
|
|
)
|
|
)
|