Files
anonymisation/gui_v6/tabs/tab_usage.py
2026-06-29 19:15:28 +02:00

379 lines
16 KiB
Python

"""Onglet « Utilisation » de la GUI V6 (G4 — alignement maquette).
Reprend la structure de ``docs/ui_mockup_v6.html`` : carte Apparence (sélecteur
de thème), carte Documents (dropzone + liste), carte Format, barre d'actions,
carte progression (étapes), carte résultats (cartes statistiques). La logique
(runner moteur câblé, threading, file d'événements, arrêt coopératif, anti
double-lancement) est conservée. Aucune logique de détection ici.
"""
from __future__ import annotations
import queue
import threading
from pathlib import Path
from tkinter import filedialog
import customtkinter as ctk
from gui_v6 import theme as theme_mod
from gui_v6 import ui_kit
from gui_v6.fsutil import open_in_file_manager
from gui_v6.processing_runner import ProcessingRunner, default_output_dir
_STEPS = ["📖 Extraction", "🧠 Détection", "🔒 Masquage", "📄 PDF final"]
_HELP_USAGE = (
"Anonymiser vos documents.\n\n"
"1) Choisissez un fichier ou un dossier de documents.\n"
"2) Vérifiez le format de sortie.\n"
"3) Cliquez sur « Lancer » : l'application détecte et masque les données "
"personnelles, puis écrit les documents anonymisés dans un dossier de sortie.\n\n"
"Tout le traitement se fait 100 % en local sur ce poste. Aucun document "
"n'est envoyé sur Internet."
)
def failure_hint(summary, output_dir) -> str | None:
"""Message localisant les documents livrés, ou None si run nominal.
Honnête : les documents en échec / quarantaine ne sont PAS anonymisés et
ne sont donc pas écrits. Si AUCUN document n'a abouti, on ne prétend pas
qu'un dossier de sortie contient des documents anonymisés.
"""
if summary is None or output_dir is None:
return None
if summary.failed == 0 and not getattr(summary, "stopped", False):
return None
if summary.succeeded == 0:
return (
"Aucun document n'a été anonymisé. Les documents en échec ou en "
"quarantaine ne sont PAS anonymisés et n'ont pas été écrits."
)
return (
f"Documents anonymisés écrits dans : {output_dir}\n"
"Les documents en échec ou en quarantaine ne sont PAS anonymisés et "
"n'ont pas été écrits."
)
class UsageTab(ctk.CTkFrame):
def __init__(
self,
master,
runner: ProcessingRunner | None = None,
config_provider=None,
config_path: Path | None = None,
palette: dict | None = None,
on_theme_change=None,
current_theme: str = theme_mod.DEFAULT_THEME,
usage_reporter=None,
**kwargs,
):
self._p = palette or theme_mod.get_palette(current_theme)
super().__init__(master, fg_color=self._p["bg"], **kwargs)
self._runner = runner or ProcessingRunner()
self._config_provider = config_provider
self._config_path = config_path
self._on_theme_change = on_theme_change
self._current_theme = current_theme
# Callback(summary) appelé en fin de run pour la télémétrie d'usage
# (envoi non bloquant, injecté par l'app avec le contexte licence).
self._usage_reporter = usage_reporter
self._input_path: Path | None = None
self._output_dir: Path | None = None
self._last_output_dir: Path | None = None
self._stop_event: threading.Event | None = None
self._is_running = False
self._events: "queue.Queue[tuple]" = queue.Queue()
self._build()
self.after(120, self._drain_events)
# -- construction UI --------------------------------------------------
def _build(self) -> None:
p = self._p
# Bandeau d'introduction + aide « ? »
intro = ctk.CTkFrame(self, fg_color="transparent")
intro.pack(fill="x", padx=14, pady=(12, 0))
ctk.CTkLabel(
intro,
text="Sélectionnez vos documents puis lancez l'anonymisation (100 % local).",
text_color=p["text_dim"],
font=ui_kit.font(12),
anchor="w",
).pack(side="left", padx=(2, 6))
ui_kit.help_button(intro, p, _HELP_USAGE, title="Comment ça marche ?").pack(side="right", padx=2)
# Carte Apparence (sélecteur de thème)
appearance = ui_kit.Card(self, p, title="🎨 Apparence")
appearance.pack(fill="x", padx=14, pady=(14, 7))
row = ctk.CTkFrame(appearance, fg_color="transparent")
row.pack(fill="x", padx=16, pady=(0, 14))
for name in theme_mod.theme_names():
ui_kit.pill_button(
row,
p,
theme_mod.THEME_LABELS.get(name, name),
command=lambda n=name: self._change_theme(n),
active=(name == self._current_theme),
).pack(side="left", padx=(0, 8))
# Carte Documents + dropzone
docs = ui_kit.Card(self, p, title="📁 Documents à anonymiser")
docs.pack(fill="x", padx=14, pady=7)
dz = ctk.CTkFrame(
docs, fg_color=p["divider"], border_color=p["card_border"], border_width=2, corner_radius=8
)
dz.pack(fill="x", padx=16, pady=(0, 8))
ctk.CTkLabel(dz, text="⬆️", font=ui_kit.font(30)).pack(pady=(20, 4))
ctk.CTkLabel(dz, text="Choisissez vos fichiers", text_color=p["text"], font=ui_kit.font(14)).pack()
ctk.CTkLabel(dz, text="PDF · Word · Images · Texte", text_color=p["text_muted"], font=ui_kit.font(12)).pack(pady=(2, 10))
acts = ctk.CTkFrame(dz, fg_color="transparent")
acts.pack(pady=(0, 20))
ui_kit.secondary_button(acts, p, "📄 Fichier", command=self._pick_file).pack(side="left", padx=4)
ui_kit.secondary_button(acts, p, "📁 Dossier entier", command=self._pick_folder).pack(side="left", padx=4)
self._target_label = ctk.CTkLabel(docs, text="Aucune source sélectionnée", text_color=p["text_muted"], font=ui_kit.font(12), anchor="w")
self._target_label.pack(fill="x", padx=16, pady=(0, 14))
# Carte Format de sortie
fmt = ui_kit.Card(self, p, title="💾 Format de sortie")
fmt.pack(fill="x", padx=14, pady=7)
grid = ctk.CTkFrame(fmt, fg_color="transparent")
grid.pack(fill="x", padx=16, pady=(0, 14))
for icon, name, sub in [("📄", "PDF anonymisé", "Zones noircies"), ("📝", "Texte .txt", "Mots → [NOM]…")]:
fcard = ctk.CTkFrame(grid, fg_color=p["card"], border_color=p["primary"], border_width=2, corner_radius=8)
fcard.pack(side="left", expand=True, fill="x", padx=4)
ctk.CTkLabel(fcard, text=icon, font=ui_kit.font(20)).pack(pady=(10, 0))
ctk.CTkLabel(fcard, text=name, text_color=p["text"], font=ui_kit.font(13, "bold")).pack()
ctk.CTkLabel(fcard, text=sub, text_color=p["text_muted"], font=ui_kit.font(11)).pack(pady=(0, 10))
# Barre d'actions
brow = ctk.CTkFrame(self, fg_color="transparent")
brow.pack(fill="x", padx=14, pady=7)
ui_kit.secondary_button(brow, p, "✖ Effacer", command=self._clear_input).pack(side="right", padx=(8, 0))
self._run_btn = ui_kit.primary_button(brow, p, "▶ Lancer l'anonymisation", command=self._start, large=True)
self._run_btn.configure(state="disabled")
self._run_btn.pack(side="right")
# Carte progression
self._psec = ui_kit.Card(self, p, title="⏳ Traitement en cours")
self._progress = ctk.CTkProgressBar(self._psec, progress_color=p["primary"])
self._progress.set(0.0)
self._progress.pack(fill="x", padx=16, pady=(0, 6))
self._status_label = ctk.CTkLabel(self._psec, text="Prêt.", text_color=p["text_muted"], font=ui_kit.font(12), anchor="w")
self._status_label.pack(fill="x", padx=16)
steps = ctk.CTkFrame(self._psec, fg_color="transparent")
steps.pack(fill="x", padx=16, pady=6)
for s in _STEPS:
ctk.CTkLabel(steps, text=s, fg_color=p["divider"], text_color=p["text_muted"], corner_radius=99, font=ui_kit.font(11)).pack(side="left", padx=3, ipadx=6, ipady=2)
self._log = ctk.CTkTextbox(self._psec, height=110, fg_color=p["divider"], text_color=p["text_dim"], font=ui_kit.font(11), border_color=p["card_border"], border_width=1)
self._log.pack(fill="x", padx=16, pady=8)
self._log.configure(state="disabled")
self._stop_btn = ui_kit.secondary_button(self._psec, p, "⏹ Arrêter", command=self._request_stop)
self._stop_btn.configure(state="disabled")
self._stop_btn.pack(anchor="e", padx=16, pady=(0, 14))
# masquée tant qu'aucun run
# Carte résultats (cartes statistiques)
self._rsec = ui_kit.Card(self, p, title="✅ Résultats")
self._stats_row = ctk.CTkFrame(self._rsec, fg_color="transparent")
self._stats_row.pack(fill="x", padx=16, pady=(0, 14))
self._result_built = False
self._hint_row = None
# -- thème ------------------------------------------------------------
def _change_theme(self, name: str) -> None:
if self._on_theme_change is not None:
self._on_theme_change(name)
# -- runner câblé -----------------------------------------------------
def _build_run_runner(self) -> tuple[ProcessingRunner, Path | None]:
if self._config_provider is None:
return self._runner, self._output_dir
from gui_v6.engine_bridge import make_process_fn
cfg = self._config_provider()
settings = cfg.to_engine_settings(self._config_path)
runner = ProcessingRunner(process_fn=make_process_fn(settings))
output_dir = self._output_dir or getattr(cfg, "output_dir", None)
return runner, output_dir
# -- sélection --------------------------------------------------------
def _pick_file(self) -> None:
path = filedialog.askopenfilename(title="Choisir un document")
if path:
self._set_input(Path(path))
def _pick_folder(self) -> None:
path = filedialog.askdirectory(title="Choisir un dossier")
if path:
self._set_input(Path(path))
def _clear_input(self) -> None:
self._input_path = None
self._target_label.configure(text="Aucune source sélectionnée")
self._run_btn.configure(state="disabled")
def _set_input(self, path: Path) -> None:
self._input_path = path
count = len(self._runner.discover(path))
if path.is_dir():
self._target_label.configure(text=f"📁 {path} · {count} document(s) détecté(s)")
else:
self._target_label.configure(text=f"📄 {path.name} · sortie {default_output_dir(path)}")
self._run_btn.configure(state="normal" if count > 0 else "disabled")
# -- exécution --------------------------------------------------------
def _start(self) -> None:
if self._input_path is None or self._is_running:
return
self._is_running = True
run_runner, run_output_dir = self._build_run_runner()
self._last_output_dir = run_output_dir or default_output_dir(self._input_path)
self._stop_event = threading.Event()
self._run_btn.configure(state="disabled")
self._psec.pack(fill="x", padx=14, pady=7)
self._stop_btn.configure(state="normal")
self._progress.set(0.0)
self._clear_log()
self._set_status("Traitement en cours…")
input_path, output_dir, stop = self._input_path, run_output_dir, self._stop_event
def work() -> None:
try:
summary = run_runner.run(
input_path,
output_dir,
on_progress=lambda done, total, name: self._events.put(("progress", done, total, name)),
on_log=lambda msg: self._events.put(("log", msg)),
stop_event=stop,
)
self._events.put(("done", summary))
except Exception as exc: # garde-fou : ne jamais laisser le thread tuer l'UI
self._events.put(("error", str(exc)))
self._worker = threading.Thread(target=work, daemon=True)
self._worker.start()
def _request_stop(self) -> None:
if self._stop_event is not None:
self._stop_event.set()
self._set_status("Arrêt demandé…")
self._stop_btn.configure(state="disabled")
# -- file d'événements worker → UI ------------------------------------
def _drain_events(self) -> None:
try:
while True:
event = self._events.get_nowait()
self._handle_event(event)
except queue.Empty:
pass
self.after(120, self._drain_events)
def _handle_event(self, event: tuple) -> None:
kind = event[0]
if kind == "progress":
_, done, total, name = event
self._progress.set(done / total if total else 0.0)
self._set_status(f"{done}/{total}{name}")
elif kind == "log":
self._append_log(event[1])
elif kind == "done":
self._finish(event[1])
elif kind == "error":
self._append_log(f"Erreur : {event[1]}")
self._finish(None)
def _finish(self, summary) -> None:
self._is_running = False
self._stop_btn.configure(state="disabled")
self._run_btn.configure(state="normal")
if summary is None:
self._set_status("Terminé avec erreur.")
return
if summary.stopped:
self._set_status(f"Arrêté : {summary.succeeded}/{summary.total} traités.")
else:
self._progress.set(1.0)
self._set_status(f"Terminé : {summary.succeeded} OK, {summary.failed} échec(s) sur {summary.total}.")
self._show_results(summary)
self._show_failure_hint(summary)
self._send_usage_telemetry(summary)
def _send_usage_telemetry(self, summary) -> None:
"""Envoie la télémétrie d'usage en fin de run, sans bloquer l'UI ni le run."""
reporter = self._usage_reporter
if reporter is None:
return
def work():
try:
reporter(summary)
except Exception:
pass # un échec de télémétrie ne doit jamais remonter
threading.Thread(target=work, daemon=True).start()
def _show_results(self, summary) -> None:
p = self._p
for w in self._stats_row.winfo_children():
w.destroy()
cards = [
(str(summary.total), "Documents", p["primary"]),
(str(summary.succeeded), "Réussis", p["success"]),
(str(summary.failed), "Échecs", p["danger"] if summary.failed else p["text_muted"]),
("OK" if summary.ok else "KO", "Statut", p["success"] if summary.ok else p["warning"]),
]
for value, label, color in cards:
ui_kit.StatCard(self._stats_row, p, value, label, value_color=color).pack(side="left", expand=True, fill="x", padx=4)
self._rsec.pack(fill="x", padx=14, pady=(7, 14))
def _show_failure_hint(self, summary) -> None:
# Nettoyage inconditionnel : éviter l'empilement et les hints périmés.
if getattr(self, "_hint_row", None) is not None:
self._hint_row.destroy()
self._hint_row = None
hint = failure_hint(summary, getattr(self, "_last_output_dir", None))
if hint is None:
return
p = self._p
self._hint_row = ctk.CTkFrame(self._rsec, fg_color="transparent")
self._hint_row.pack(fill="x", padx=16, pady=(0, 12))
ctk.CTkLabel(
self._hint_row, text=hint, text_color=p["text_dim"], font=ui_kit.font(11),
anchor="w", justify="left",
).pack(side="left", fill="x", expand=True)
# Bouton « ouvrir » uniquement s'il y a réellement des documents livrés.
if summary is not None and summary.succeeded > 0:
ui_kit.secondary_button(
self._hint_row, p, "📂 Ouvrir le dossier",
command=lambda: open_in_file_manager(self._last_output_dir),
).pack(side="right")
# -- helpers widgets --------------------------------------------------
def _set_status(self, text: str) -> None:
self._status_label.configure(text=text)
def _clear_log(self) -> None:
self._log.configure(state="normal")
self._log.delete("1.0", "end")
self._log.configure(state="disabled")
def _append_log(self, message: str) -> None:
self._log.configure(state="normal")
self._log.insert("end", message + "\n")
self._log.see("end")
self._log.configure(state="disabled")