Files
anonymisation/gui_v6/tabs/tab_config.py
Domi31tls a9e8b2c2e6 feat(gui): addenda Dom GUI V6 — sous-onglet Profils, libellés, aide, bêta
Suite des retours Dom sur la GUI V6 (par-dessus 6a0a581).

Addendum Profils / Réglages :
- Nouveau sous-onglet Administration « 👤 Profils » : le profil actif devient
  un objet lisible (nom, description, masque requis, template, listes locales
  avec compteurs) — données réelles lues depuis profile_defaults.
- Fenêtre « Tableau des termes » (terms_table_window.py) : table scrollable
  avec recherche/filtre, colonnes Type/Terme/Source ; reste lisible à 50+
  termes. Ajouter/éditer/supprimer désactivés « (à venir) » (écriture par
  profil non câblée).
- Réglages : « Profil métier » → « Profil d'anonymisation », « Sortie… » →
  « Dossier de sortie… » (+ infobulle), hints moteurs (standard/optionnel/
  plus lent), bouton « Voir le profil », « Ouvrir le tableau des termes ».
- Aide « ? » + infobulles (ui_kit.attach_tooltip) près des éléments ambigus.
- profile_view.py : logique pure (résumé profil + lignes du tableau),
  testable sans display.

Addendum bêta : en-tête « aivanonym » + badge « bêta », titre fenêtre
« … — bêta ». Détail version conservé dans À propos.

tests/unit/test_gui_v6_profiles.py + ajouts shell. 237 tests unit OK
(228 → 237, 0 régression), self-test GUI V6 OK, navigation des 5 sous-onglets
+ thème OK. V5/moteur/app_aivanov/profile_defaults non touchés, 0 dépendance.
Aucun build/push sans GO Dom — validation visuelle Dom attendue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 17:02:54 +02:00

941 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Onglet Configuration de la GUI V6.
L'onglet est construit une seule fois : les sous-sections sont des panneaux
préchargés puis simplement relevés avec ``tkraise()``. Cela évite le rendu
progressif et les micro-latences visibles lors du passage Réglages/Masquage/
Partage/Règles.
"""
from __future__ import annotations
import sys
import webbrowser
from pathlib import Path
from tkinter import filedialog, messagebox
import customtkinter as ctk
from gui_v6 import theme as theme_mod
from gui_v6 import ui_kit
from gui_v6.config_state import ConfigState, default_profile_key, list_profile_keys
from manual_masking import ensure_mask_templates_dir, list_mask_templates, mask_template_label
_SUBTABS = [
("reg", "⚙️ Réglages"),
("pro", "👤 Profils"),
("msk", "🎭 Masquage"),
("shr", "🔄 Partage"),
("rul", "🛡️ Règles"),
]
_DETECTION_OPTIONS = [
("Noms et prénoms", "Gazetteers + IA"),
("Dates de naissance", "Contexte naissance"),
("Établissements", "FINESS + contexte"),
("Adresses / CP", "Voie, ville, code"),
("N° sécurité sociale", "NIR"),
("Téléphones / e-mails", "Contact"),
("N° adhérent mutuelle", "Identifiant local"),
]
_REPLACEMENT_CODES = [
("Nom/Prénom", "[NOM]"),
("Date naissance", "[DATE_NAISSANCE]"),
("Établissement", "[ETABLISSEMENT]"),
("Adresse", "[ADRESSE]"),
("Téléphone", "[TEL]"),
("N° sécu", "[NIR]"),
("IPP", "[IPP]"),
("Email", "[EMAIL]"),
]
_MASK_COLORS = [
("Noir", "#000000"),
("Bleu nuit", "#1a1a2e"),
("Gris", "#374151"),
("Marron", "#92400e"),
("Bleu marine", "#1e3a5f"),
]
_PRESERVE_TERMS = ["FUROSEMIDE", "rééducation fonctionnelle", "classification internationale"]
_MASK_TERMS = ["CHUXX"]
_STOPWORDS = ["hospitalisation", "contrôle", "prescription"]
MANUAL_MASK_NONE_LABEL = "Aucun masque manuel"
# Textes d'aide « ? » (français simple, pour utilisateurs non informaticiens).
_HELP_REGLAGES = (
"Réglages de l'anonymisation.\n\n"
"• Profil d'anonymisation : choisit un jeu de réglages adapté à votre usage.\n"
"• Moteurs NER : les modèles qui détectent les noms et données personnelles.\n"
"• Données à détecter : ce qui sera masqué (noms, dates de naissance, etc.).\n"
"• Listes locales : vos termes à toujours masquer ou toujours conserver.\n\n"
"Tout fonctionne 100 % en local sur ce poste. Aucun document patient n'est envoyé sur Internet."
)
_HELP_MASQUAGE = (
"Masquage des documents.\n\n"
"• Couleur, style et marges du masque appliqué sur les PDF.\n"
"• Codes de remplacement affichés à la place des données ([NOM], [DATE_NAISSANCE]…).\n"
"• Masques de zones fixes : ouvrez l'éditeur pour dessiner les zones à masquer "
"(en-têtes, blocs identité) directement sur un PDF modèle, puis enregistrez un modèle réutilisable.\n\n"
"Le traitement reste local ; rien n'est envoyé sur Internet."
)
_HELP_PARTAGE = (
"À quoi sert le Partage ?\n\n"
"Il permet d'échanger les RÉGLAGES de l'application (listes de termes, règles, "
"style de masquage, modèle de masque) entre plusieurs postes, ou avec votre administrateur.\n\n"
"• Exporter : enregistre vos réglages dans un fichier .json à transmettre.\n"
"• Importer : fusionne des réglages reçus avec les vôtres.\n\n"
"IMPORTANT : seuls les réglages sont partagés. Vos documents patients ne sont JAMAIS "
"partagés ni envoyés sur Internet."
)
_HELP_REGLES = (
"Les Règles adaptent le moteur à votre établissement (ex. : toujours masquer un sigle, "
"toujours conserver un terme métier).\n\n"
"Chaque règle est validée avant d'être activée.\n\n"
"Cette section est en cours de finalisation : les actions marquées « à venir » "
"ne sont pas encore disponibles."
)
_HELP_PROFIL = (
"Un profil d'anonymisation regroupe tous les réglages adaptés à un usage "
"(ex. : interne standard, diffusion prudente, recherche…).\n\n"
"Il définit les moteurs utilisés, les données détectées, les termes à conserver "
"ou à masquer, et si un masque manuel est requis.\n\n"
"Choisissez un profil ici, et consultez son détail dans l'onglet « Profils »."
)
_HELP_MOTEURS = (
"Les moteurs détectent les données personnelles.\n\n"
"• CamemBERT-bio : moteur standard, rapide et fiable — activé par défaut.\n"
"• EDS-Pseudo et GLiNER : optionnels. Ils renforcent la détection mais sont "
"plus lents et ne sont pas toujours installés sur le poste.\n\n"
"Si tout n'est pas coché, c'est que les moteurs optionnels ne sont pas requis "
"par le profil ou pas disponibles."
)
_HELP_LISTES = (
"Les listes locales personnalisent la détection pour votre établissement :\n\n"
"• À conserver : termes à ne jamais masquer (vocabulaire métier).\n"
"• À masquer : termes à toujours masquer (sigles, en-têtes…).\n"
"• À ignorer : mots à ne pas considérer.\n\n"
"Pour une liste longue, ouvrez le tableau des termes (onglet « Profils ») : "
"il reste lisible et permet la recherche."
)
CONFIG_MOCKUP_SECTIONS = {
"reglages": [
"Profil d'anonymisation",
"Moteurs NER",
"Données à détecter",
"Termes à toujours conserver",
"Termes à toujours masquer",
"Masque manuel obligatoire",
"Template de masque manuel",
],
"masquage": [
"Couleur de masquage (PDF)",
"Style des marqueurs (texte)",
"Épaisseur du masque",
"Codes de remplacement",
"Masques de zones fixes",
"Éditeur interactif de masques",
],
"partage": ["Exporter la configuration", "Importer une configuration"],
"regles": ["Règles actives", "Testeur de règle"],
}
CONFIG_INTERACTION_CONTRACT = {
"subtabs": "prebuilt_panels",
"reglages_columns": 3,
"mask_editor": [
"open_pdf",
"draw_rectangle",
"delete_rectangle_on_click",
"zoom",
"save_template_json",
"load_template_json_or_yaml",
"clear_page",
"apply_template_selection",
],
}
def _app_base_dir() -> Path:
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path.cwd()
class ConfigTab(ctk.CTkFrame):
def __init__(self, master, state: ConfigState | None = None, palette: dict | None = None, **kwargs):
self._p = palette or theme_mod.get_palette(theme_mod.DEFAULT_THEME)
super().__init__(master, fg_color=self._p["bg"], **kwargs)
self._state = state if state is not None else ConfigState()
self._sub = "reg"
self._sub_buttons: dict[str, ctk.CTkButton] = {}
self._panels: dict[str, ctk.CTkFrame] = {}
self._manual_mask_templates: dict[str, Path | None] = {MANUAL_MASK_NONE_LABEL: None}
self._manual_mask_var = ctk.StringVar(value=MANUAL_MASK_NONE_LABEL)
self._manual_mask_required_var = ctk.BooleanVar(value=self._state.manual_mask_required)
self._mask_style_var = ctk.StringVar(value=self._state.mask_marker_style)
self._mask_color = self._state.mask_color
self._mask_margin_x_var = ctk.IntVar(value=self._state.mask_margin_x)
self._mask_margin_y_var = ctk.IntVar(value=self._state.mask_margin_y)
self._mask_rounded_var = ctk.BooleanVar(value=self._state.mask_rounded_corners)
self._mask_status_text = ctk.StringVar(value="Éditeur de masques en fenêtre dédiée.")
# L'édition interactive des masques se fait dans une fenêtre séparée
# (gui_v6.mask_editor_window) ; on garde juste une référence à l'instance
# ouverte pour éviter d'en empiler plusieurs.
self._mask_editor_window = None
self._build()
@property
def state(self) -> ConfigState:
return self._state
# -- construction -----------------------------------------------------
def _build(self) -> None:
p = self._p
bar = ctk.CTkFrame(self, fg_color="transparent")
bar.pack(fill="x", padx=14, pady=(10, 2))
for key, label in _SUBTABS:
btn = ctk.CTkButton(
bar,
text=label,
command=lambda k=key: self._show_sub(k),
fg_color=p["btn_sec_bg"] if key == self._sub else "transparent",
hover_color=p["card_border"],
text_color=p["primary"] if key == self._sub else p["text_dim"],
border_color=p["card_border"],
border_width=1 if key == self._sub else 0,
font=ui_kit.font(13, "bold" if key == self._sub else "normal"),
corner_radius=6,
width=10,
height=30,
)
btn.pack(side="left", padx=(0, 6))
self._sub_buttons[key] = btn
self._body = ctk.CTkFrame(self, fg_color="transparent")
self._body.pack(fill="both", expand=True, padx=14, pady=(4, 0))
self._body.grid_columnconfigure(0, weight=1)
self._body.grid_rowconfigure(0, weight=1)
builders = {
"reg": self._build_reglages,
"pro": self._build_profils,
"msk": self._build_masquage,
"shr": self._build_partage,
"rul": self._build_regles,
}
for key, builder in builders.items():
panel = ctk.CTkFrame(self._body, fg_color="transparent")
panel.grid(row=0, column=0, sticky="nsew")
self._panels[key] = panel
builder(panel)
self._refresh_manual_mask_templates()
self._show_sub("reg")
def _show_sub(self, key: str) -> None:
self._sub = key
p = self._p
for k, btn in self._sub_buttons.items():
active = k == key
btn.configure(
fg_color=p["btn_sec_bg"] if active else "transparent",
border_width=1 if active else 0,
text_color=p["primary"] if active else p["text_dim"],
font=ui_kit.font(13, "bold" if active else "normal"),
)
self._panels[key].tkraise()
# -- Réglages ---------------------------------------------------------
def _build_reglages(self, parent) -> None:
p = self._p
self._section_intro(
parent,
"Choisissez ce que l'application doit détecter et masquer. Tout reste local.",
_HELP_REGLAGES,
"Les Réglages",
)
top = ctk.CTkFrame(
parent,
fg_color=p["card"],
border_color=p["card_border"],
border_width=1,
corner_radius=8,
)
top.pack(fill="x", pady=(0, 8))
profiles = list_profile_keys()
current = self._state.profile or default_profile_key() or (profiles[0] if profiles else "")
self._state.profile = current or None
ctk.CTkLabel(top, text="Profil d'anonymisation", text_color=p["text_dim"], font=ui_kit.font(11, "bold")).pack(
side="left", padx=(12, 8), pady=10
)
self._profile_menu = ctk.CTkOptionMenu(
top,
values=profiles or ["(aucun profil)"],
command=self._on_profile,
fg_color=p["btn_sec_bg"],
button_color=p["primary"],
button_hover_color=p["primary_dim"],
text_color=p["text"],
width=180,
height=30,
)
if current:
self._profile_menu.set(current)
self._profile_menu.pack(side="left", pady=10)
ui_kit.help_button(top, p, _HELP_PROFIL, title="Profil d'anonymisation").pack(side="left", padx=(6, 0), pady=10)
ui_kit.secondary_button(top, p, "👤 Voir le profil", command=lambda: self._show_sub("pro")).pack(
side="left", padx=(10, 4), pady=10
)
sortie = ui_kit.secondary_button(top, p, "📁 Dossier de sortie…", command=self._pick_output)
sortie.pack(side="left", padx=(6, 6), pady=10)
ui_kit.attach_tooltip(
sortie, "Dossier où seront écrits les documents anonymisés.\nRéglage local de traitement (pas une règle du profil)."
)
self._out_label = ctk.CTkLabel(
top,
text=str(self._state.output_dir or "anonymise/"),
text_color=p["text_muted"],
font=ui_kit.font(12),
)
self._out_label.pack(side="left", fill="x", expand=True, padx=(0, 12), pady=10)
cols = self._columns(parent, 3, gap=8, height=455)
det = ui_kit.Card(cols[0], p, title="🔍 Données à détecter")
det.pack(fill="both", expand=True)
for label, hint in _DETECTION_OPTIONS:
self._mini_toggle(det, label, hint, value=True).pack(fill="x", padx=12, pady=1)
ner = ui_kit.Card(cols[1], p, title="🧠 Moteurs et masques")
ner.pack(fill="both", expand=True)
hint_row = ctk.CTkFrame(ner, fg_color="transparent")
hint_row.pack(fill="x", padx=12, pady=(0, 2))
ctk.CTkLabel(
hint_row, text="Pourquoi pas tout coché ?", text_color=p["text_muted"], font=ui_kit.font(11), anchor="w"
).pack(side="left")
ui_kit.help_button(hint_row, p, _HELP_MOTEURS, title="Moteurs de détection").pack(side="right")
self._tog_ner = self._mini_toggle(
ner, "CamemBERT-bio", "standard · rapide · F1 0.963", value=self._state.use_local_ner, command=self._on_ner
)
self._tog_ner.pack(fill="x", padx=12, pady=1)
self._tog_eds = self._mini_toggle(
ner, "EDS-Pseudo", "optionnel · médical français · plus lent", value=self._state.enable_eds, command=self._on_eds
)
self._tog_eds.pack(fill="x", padx=12, pady=1)
self._tog_gli = self._mini_toggle(
ner, "GLiNER", "optionnel · vote croisé · plus lent", value=self._state.enable_gliner, command=self._on_gliner
)
self._tog_gli.pack(fill="x", padx=12, pady=1)
self._mini_toggle(
ner,
"Masque manuel obligatoire",
"bloque le traitement si absent",
value=self._state.manual_mask_required,
variable=self._manual_mask_required_var,
command=self._on_manual_mask_required,
).pack(fill="x", padx=12, pady=(8, 1))
ctk.CTkLabel(
ner,
text="Template de masque manuel",
text_color=p["text_muted"],
font=ui_kit.font(11, "bold"),
anchor="w",
).pack(fill="x", padx=12, pady=(10, 2))
self._manual_mask_menu = ctk.CTkOptionMenu(
ner,
values=[MANUAL_MASK_NONE_LABEL],
variable=self._manual_mask_var,
command=self._on_manual_mask_template,
fg_color=p["btn_sec_bg"],
button_color=p["primary"],
button_hover_color=p["primary_dim"],
text_color=p["text"],
height=30,
)
self._manual_mask_menu.pack(fill="x", padx=12, pady=(0, 6))
mask_actions = ctk.CTkFrame(ner, fg_color="transparent")
mask_actions.pack(fill="x", padx=12, pady=(0, 12))
ui_kit.secondary_button(mask_actions, p, "🔄 Actualiser", command=self._refresh_manual_mask_templates).pack(
side="left", fill="x", expand=True, padx=(0, 4)
)
ui_kit.secondary_button(mask_actions, p, "📁 Dossier", command=self._open_templates_dir).pack(
side="left", fill="x", expand=True, padx=(4, 0)
)
terms = ui_kit.Card(cols[2], p, title="✅ Listes locales")
terms.pack(fill="both", expand=True)
terms_help = ctk.CTkFrame(terms, fg_color="transparent")
terms_help.pack(fill="x", padx=12, pady=(0, 2))
ctk.CTkLabel(
terms_help, text="Termes propres à votre établissement", text_color=p["text_muted"], font=ui_kit.font(11), anchor="w"
).pack(side="left")
ui_kit.help_button(terms_help, p, _HELP_LISTES, title="Listes locales").pack(side="right")
self._compact_tag_editor(terms, "Termes à conserver", "Ex : FUROSEMIDE", _PRESERVE_TERMS, "keep")
self._compact_tag_editor(terms, "Termes à masquer", "Ex : CHUXX", _MASK_TERMS, "mask")
self._compact_tag_editor(terms, "Mots à ignorer", "Ex : prescription", _STOPWORDS, "stop")
ctk.CTkButton(
terms,
text="📋 Ouvrir le tableau des termes",
command=lambda: self._show_sub("pro"),
fg_color=p["btn_sec_bg"],
hover_color=p["card_border"],
text_color=p["text"],
border_color=p["btn_sec_border"],
border_width=1,
corner_radius=8,
height=30,
font=ui_kit.font(12),
).pack(fill="x", padx=12, pady=(6, 12))
# -- Profils ----------------------------------------------------------
def _active_profile_dict(self) -> dict:
try:
from profile_defaults import list_effective_profiles
key = self._state.profile or default_profile_key()
if not key:
return {}
return list_effective_profiles().get(key, {}) or {}
except Exception:
return {}
def _active_profile_summary(self):
from gui_v6.profile_view import summarize_profile
key = self._state.profile or default_profile_key() or ""
return summarize_profile(key, self._active_profile_dict())
def _open_terms_table(self) -> None:
from gui_v6.profile_view import profile_term_rows
from gui_v6.terms_table_window import TermsTableWindow
summary = self._active_profile_summary()
rows = profile_term_rows(self._active_profile_dict())
TermsTableWindow(self.winfo_toplevel(), self._p, rows, profile_label=summary.label)
def _rebuild_profils(self) -> None:
panel = self._panels.get("pro")
if panel is None:
return
for child in panel.winfo_children():
child.destroy()
self._build_profils(panel)
def _build_profils(self, parent) -> None:
p = self._p
self._section_intro(
parent,
"Un profil regroupe tous les réglages d'anonymisation. Voici le profil actif.",
_HELP_PROFIL,
"Création / modification d'un profil d'anonymisation",
)
summary = self._active_profile_summary()
card = ui_kit.Card(parent, p, title=f"👤 {summary.label}")
card.pack(fill="x", pady=(0, 8))
if summary.description:
self._note(card, summary.description)
grid = ctk.CTkFrame(card, fg_color="transparent")
grid.pack(fill="x", padx=12, pady=(0, 10))
infos = [
("Masque manuel requis", "Oui" if summary.require_manual_mask else "Non"),
("Template de masque", summary.mask_template or ""),
("Moteur VLM (images)", "désactivé" if summary.disable_vlm else "selon réglages"),
]
for idx, (key, val) in enumerate(infos):
ctk.CTkLabel(grid, text=key, text_color=p["text_muted"], font=ui_kit.font(11), anchor="w").grid(
row=idx, column=0, sticky="w", pady=1
)
ctk.CTkLabel(grid, text=val, text_color=p["text"], font=ui_kit.font(11, "bold"), anchor="w").grid(
row=idx, column=1, sticky="w", padx=(12, 0), pady=1
)
grid.grid_columnconfigure(1, weight=1)
lists_card = ui_kit.Card(parent, p, title="✅ Listes locales du profil")
lists_card.pack(fill="x", pady=(0, 8))
chips = ctk.CTkFrame(lists_card, fg_color="transparent")
chips.pack(fill="x", padx=12, pady=(0, 8))
for label, count in summary.list_counts.items():
ctk.CTkLabel(
chips,
text=f"{label} : {count}",
text_color=p["text"],
fg_color=p["divider"],
corner_radius=8,
font=ui_kit.font(11, "bold"),
).pack(side="left", padx=(0, 8), ipadx=8, ipady=3)
ui_kit.primary_button(lists_card, p, "📋 Ouvrir le tableau des termes", command=self._open_terms_table).pack(
anchor="w", padx=12, pady=(0, 12)
)
create = ui_kit.Card(parent, p, title="🧩 Créer / modifier un profil")
create.pack(fill="x")
self._note(create, "La création et la modification de profils seront disponibles prochainement.")
self._mockup_button(create, "+ Nouveau profil").pack(anchor="w", padx=12, pady=(0, 12))
# -- Masquage ---------------------------------------------------------
def _build_masquage(self, parent) -> None:
p = self._p
self._section_intro(
parent,
"Apparence des masques et éditeur de zones fixes à masquer sur vos PDF.",
_HELP_MASQUAGE,
"Le Masquage",
)
top_cols = self._columns(parent, 3, gap=8, height=300)
pdf_opts = ui_kit.Card(top_cols[0], p, title="⬛ PDF")
pdf_opts.pack(fill="both", expand=True)
ctk.CTkLabel(
pdf_opts, text="Couleur de masquage", text_color=p["text"], font=ui_kit.font(12, "bold")
).pack(anchor="w", padx=12, pady=(0, 4))
swatches = ctk.CTkFrame(pdf_opts, fg_color="transparent")
swatches.pack(fill="x", padx=12, pady=(0, 8))
self._swatch_buttons: dict[str, ctk.CTkButton] = {}
for label, color_value in _MASK_COLORS:
btn = ctk.CTkButton(
swatches,
text="",
width=30,
height=26,
fg_color=color_value,
hover_color=color_value,
border_color=p["primary"] if color_value == self._mask_color else p["card_border"],
border_width=3 if color_value == self._mask_color else 1,
corner_radius=6,
command=lambda c=color_value: self._set_mask_color(c),
)
btn.pack(side="left", padx=(0, 6))
self._swatch_buttons[color_value] = btn
self._slider_row(pdf_opts, "Marge H", self._mask_margin_x_var, self._on_mask_margin_x)
self._slider_row(pdf_opts, "Marge V", self._mask_margin_y_var, self._on_mask_margin_y)
self._mini_toggle(
pdf_opts,
"Coins arrondis",
"",
value=self._state.mask_rounded_corners,
variable=self._mask_rounded_var,
command=self._on_rounded_corners,
).pack(fill="x", padx=12, pady=(4, 10))
text_opts = ui_kit.Card(top_cols[1], p, title="🏷️ Texte")
text_opts.pack(fill="both", expand=True)
for label, value, preview in [
("Crochets", "brackets", "[NOM]"),
("Étoiles", "stars", "***"),
("Noirci", "blackout", "████"),
]:
ctk.CTkRadioButton(
text_opts,
text=f"{label} {preview}",
variable=self._mask_style_var,
value=value,
command=self._update_mask_preview,
text_color=p["text"],
fg_color=p["primary"],
hover_color=p["primary_dim"],
font=ui_kit.font(12),
).pack(anchor="w", padx=12, pady=2)
self._mask_preview = ctk.CTkLabel(
text_opts,
text="Patient [NOM], né le [DATE_NAISSANCE]",
text_color=p["text_dim"],
fg_color=p["divider"],
corner_radius=6,
font=ui_kit.font(12),
anchor="w",
)
self._mask_preview.pack(fill="x", padx=12, pady=(8, 10), ipady=7)
codes = ui_kit.Card(top_cols[2], p, title="🔒 Codes")
codes.pack(fill="both", expand=True)
grid = ctk.CTkFrame(codes, fg_color="transparent")
grid.pack(fill="both", expand=True, padx=12, pady=(0, 10))
for idx, (label, code) in enumerate(_REPLACEMENT_CODES):
ctk.CTkLabel(grid, text=label, text_color=p["text_muted"], font=ui_kit.font(11), anchor="w").grid(
row=idx, column=0, sticky="w", pady=1
)
ctk.CTkLabel(grid, text=code, text_color=p["primary"], font=ui_kit.font(11, "bold"), anchor="w").grid(
row=idx, column=1, sticky="w", padx=(8, 0), pady=1
)
grid.grid_columnconfigure(0, weight=1)
editor = ui_kit.Card(parent, p, title="🏠 Masques de zones fixes")
editor.pack(fill="x", pady=(8, 0))
ctk.CTkLabel(
editor,
text=(
"Définissez les zones à masquer (en-têtes, blocs identité…) directement sur "
"votre PDF, dans une fenêtre dédiée où le document est affiché en grand et "
"défilable (scroll, zoom, ajuster largeur/page). Les templates enregistrés "
"apparaissent ensuite dans « Template de masque manuel » (onglet Réglages)."
),
text_color=p["text_muted"],
font=ui_kit.font(12),
justify="left",
wraplength=760,
anchor="w",
).pack(fill="x", padx=14, pady=(0, 10))
actions = ctk.CTkFrame(editor, fg_color="transparent")
actions.pack(fill="x", padx=14, pady=(0, 6))
ui_kit.primary_button(
actions, p, "🖊 Ouvrir l'éditeur de masques", command=self._open_full_mask_editor
).pack(side="left")
ui_kit.secondary_button(
actions, p, "📁 Dossier des templates", command=self._open_templates_dir
).pack(side="left", padx=(8, 0))
ctk.CTkLabel(
editor,
textvariable=self._mask_status_text,
text_color=p["text_muted"],
font=ui_kit.font(11),
anchor="w",
).pack(fill="x", padx=14, pady=(2, 12))
# -- Partage / Règles -------------------------------------------------
def _build_partage(self, parent) -> None:
p = self._p
self._section_intro(
parent,
"Partagez vos réglages (jamais vos documents) entre postes ou avec l'administrateur.",
_HELP_PARTAGE,
"À quoi sert le Partage ?",
)
cols = self._columns(parent, 2, gap=8, height=180)
export = ui_kit.Card(cols[0], p, title="📤 Exporter la configuration")
export.pack(fill="both", expand=True)
self._note(export, "Listes locales, règles admin, style de masquage et template actif.")
self._mockup_button(export, "⬇ Exporter (.json)").pack(anchor="w", padx=12, pady=(0, 12))
import_card = ui_kit.Card(cols[1], p, title="📥 Importer une configuration")
import_card.pack(fill="both", expand=True)
self._note(import_card, "Fusionne la configuration reçue avec vos réglages locaux.")
self._mockup_button(import_card, "⬆ Importer (.json)").pack(anchor="w", padx=12, pady=(0, 12))
def _build_regles(self, parent) -> None:
p = self._p
self._section_intro(
parent,
"Règles d'adaptation du moteur à votre établissement.",
_HELP_REGLES,
"Les Règles",
)
card = ui_kit.Card(parent, p, title="🛡️ Règles actives")
card.pack(fill="x", pady=(0, 8))
self._note(card, "Ces règles adaptent le moteur à votre établissement. Chaque règle est validée avant activation.")
headers = ctk.CTkFrame(card, fg_color="transparent")
headers.pack(fill="x", padx=12, pady=(0, 4))
for text, width in [("Label", 190), ("Type", 80), ("Cible → Résultat", 210), ("Statut", 70), ("", 70)]:
ctk.CTkLabel(headers, text=text.upper(), width=width, anchor="w", text_color=p["text_muted"], font=ui_kit.font(10, "bold")).pack(side="left")
for row in [
("Masquer le sigle CHUXX", "exact", "CHUXX → [MASK]", "Actif"),
("Préserver “classification internationale”", "preserve", "conservé tel quel", "Actif"),
("Identifier N° 1234567", "norm-id", "N° 1234567 → [NDA]", "Candidat"),
]:
self._rule_row(card, row)
actions = ctk.CTkFrame(card, fg_color="transparent")
actions.pack(fill="x", padx=12, pady=(8, 12))
self._mockup_button(actions, "+ Nouvelle règle", primary=True).pack(side="left", padx=(0, 8))
self._mockup_button(actions, "🔄 Recharger").pack(side="left")
sim = ui_kit.Card(parent, p, title="🧪 Testeur de règle")
sim.pack(fill="x")
ctk.CTkLabel(sim, text="Texte de test", text_color=p["text_muted"], font=ui_kit.font(12)).pack(anchor="w", padx=12)
txt = ctk.CTkTextbox(sim, height=74, fg_color=p["divider"], text_color=p["text"], border_color=p["card_border"], border_width=1)
txt.pack(fill="x", padx=12, pady=(5, 8))
txt.insert("1.0", "Compte rendu CHUXX, patient N° 1234567.")
btns = ctk.CTkFrame(sim, fg_color="transparent")
btns.pack(fill="x", padx=12, pady=(0, 12))
self._mockup_button(btns, "▶ Tester", primary=True).pack(side="left", padx=(0, 8))
self._mockup_button(btns, "✖ Fermer").pack(side="left")
# -- helpers aide / maquette -----------------------------------------
def _section_intro(self, parent, sentence: str, help_text: str, help_title: str) -> None:
"""Ligne d'introduction d'une sous-section : phrase courte + bouton d'aide « ? »."""
p = self._p
intro = ctk.CTkFrame(parent, fg_color="transparent")
intro.pack(fill="x", pady=(0, 6))
ctk.CTkLabel(
intro, text=sentence, text_color=p["text_dim"], font=ui_kit.font(12), anchor="w", justify="left"
).pack(side="left", padx=(2, 6))
ui_kit.help_button(intro, p, help_text, title=help_title).pack(side="right", padx=2)
def _mockup_button(self, parent, text: str, primary: bool = False):
"""Bouton de maquette non câblé : désactivé + suffixe « (à venir) » pour
ne pas laisser croire qu'il fonctionne."""
p = self._p
factory = ui_kit.primary_button if primary else ui_kit.secondary_button
btn = factory(parent, p, f"{text} (à venir)", command=lambda: None)
btn.configure(state="disabled")
return btn
# -- callbacks réglages ----------------------------------------------
def _on_profile(self, value: str) -> None:
self._state.profile = value
self._rebuild_profils()
def _on_ner(self) -> None:
self._state.use_local_ner = self._tog_ner.get()
def _on_eds(self) -> None:
self._state.enable_eds = self._tog_eds.get()
def _on_gliner(self) -> None:
self._state.enable_gliner = self._tog_gli.get()
def _on_manual_mask_required(self) -> None:
self._state.manual_mask_required = bool(self._manual_mask_required_var.get())
def _on_manual_mask_template(self, label: str) -> None:
self._state.manual_mask_template = self._manual_mask_templates.get(label)
def _pick_output(self) -> None:
path = filedialog.askdirectory(title="Dossier de sortie")
if path:
self._state.output_dir = Path(path)
self._out_label.configure(text=str(self._state.output_dir))
def _refresh_manual_mask_templates(self) -> None:
selected = self._state.manual_mask_template
options: dict[str, Path | None] = {MANUAL_MASK_NONE_LABEL: None}
try:
for path in list_mask_templates(_app_base_dir()):
options[mask_template_label(path, _app_base_dir())] = path
except Exception:
pass
self._manual_mask_templates = options
labels = list(options)
if hasattr(self, "_manual_mask_menu"):
self._manual_mask_menu.configure(values=labels)
selected_label = MANUAL_MASK_NONE_LABEL
if selected is not None:
for label, path in options.items():
if path == selected:
selected_label = label
break
self._manual_mask_var.set(selected_label)
self._state.manual_mask_template = options.get(selected_label)
def _open_templates_dir(self) -> None:
path = ensure_mask_templates_dir(_app_base_dir())
try:
webbrowser.open(path.as_uri())
except Exception:
messagebox.showinfo("Dossier modèles", str(path))
# -- callbacks masquage ----------------------------------------------
def _set_mask_color(self, color: str) -> None:
self._mask_color = color
self._state.mask_color = color
p = self._p
for value, btn in self._swatch_buttons.items():
btn.configure(
border_color=p["primary"] if value == color else p["card_border"],
border_width=3 if value == color else 1,
)
def _on_mask_margin_x(self, value: float) -> None:
self._state.mask_margin_x = int(round(value))
self._mask_margin_x_var.set(self._state.mask_margin_x)
def _on_mask_margin_y(self, value: float) -> None:
self._state.mask_margin_y = int(round(value))
self._mask_margin_y_var.set(self._state.mask_margin_y)
def _on_rounded_corners(self) -> None:
self._state.mask_rounded_corners = bool(self._mask_rounded_var.get())
def _update_mask_preview(self) -> None:
value = self._mask_style_var.get()
self._state.mask_marker_style = value
if value == "stars":
text = "Patient ***, né le ***"
elif value == "blackout":
text = "Patient ████, né le ████"
else:
text = "Patient [NOM], né le [DATE_NAISSANCE]"
self._mask_preview.configure(text=text)
# -- éditeur masques --------------------------------------------------
def _open_full_mask_editor(self) -> None:
existing = self._mask_editor_window
if existing is not None:
try:
if existing.winfo_exists():
existing.lift()
existing.focus_force()
return
except Exception:
pass
try:
from gui_v6.mask_editor_window import MaskEditorWindow
active = self._state.manual_mask_template
initial_template = active if (active and Path(active).exists()) else None
win = MaskEditorWindow(
self.winfo_toplevel(),
templates_dir=ensure_mask_templates_dir(_app_base_dir()),
initial_template=initial_template,
on_template_saved=self._on_mask_template_saved,
)
self._mask_editor_window = win
self._mask_status_text.set("Éditeur de masques ouvert.")
except Exception as exc:
messagebox.showerror("Masques PDF", f"Impossible d'ouvrir l'éditeur : {exc}")
def _on_mask_template_saved(self, path: Path) -> None:
"""Callback déclenché par la fenêtre dédiée après sauvegarde d'un template."""
self._refresh_manual_mask_templates()
try:
self._manual_mask_var.set(mask_template_label(path, _app_base_dir()))
self._state.manual_mask_template = path
except Exception:
pass
self._mask_status_text.set(f"Template enregistré : {path.name}")
# -- helpers UI -------------------------------------------------------
def _columns(self, parent, count: int, gap: int = 8, height: int | None = None) -> list[ctk.CTkFrame]:
row = ctk.CTkFrame(parent, fg_color="transparent")
row.pack(fill="x")
if height is not None:
row.configure(height=height)
row.pack_propagate(False)
row.grid_rowconfigure(0, weight=1)
frames: list[ctk.CTkFrame] = []
for idx in range(count):
row.grid_columnconfigure(idx, weight=1, uniform="config-cols")
frame = ctk.CTkFrame(row, fg_color="transparent")
frame.grid(row=0, column=idx, sticky="nsew", padx=(0 if idx == 0 else gap // 2, 0 if idx == count - 1 else gap // 2))
frames.append(frame)
return frames
def _note(self, parent, text: str) -> None:
p = self._p
ctk.CTkLabel(
parent,
text=text,
text_color=p["text_muted"],
fg_color=p["divider"],
corner_radius=4,
font=ui_kit.font(11),
anchor="w",
justify="left",
wraplength=330,
).pack(fill="x", padx=12, pady=(0, 10), ipady=5)
def _mini_toggle(self, parent, label: str, hint: str, value: bool = True, variable=None, command=None):
p = self._p
row = ctk.CTkFrame(parent, fg_color="transparent", height=34)
row.pack_propagate(False)
left = ctk.CTkFrame(row, fg_color="transparent")
left.pack(side="left", fill="x", expand=True)
ctk.CTkLabel(left, text=label, text_color=p["text"], font=ui_kit.font(12), anchor="w").pack(anchor="w")
if hint:
ctk.CTkLabel(left, text=hint, text_color=p["text_muted"], font=ui_kit.font(10), anchor="w").pack(anchor="w")
var = variable if variable is not None else ctk.BooleanVar(value=value)
switch = ctk.CTkSwitch(row, text="", variable=var, command=command, progress_color=p["primary"], width=38)
switch.pack(side="right", padx=(6, 0))
row.var = var # type: ignore[attr-defined]
row.get = lambda: bool(var.get()) # type: ignore[attr-defined]
return row
def _compact_tag_editor(self, parent, title: str, placeholder: str, terms: list[str], kind: str) -> None:
p = self._p
color = {"keep": p["success"], "mask": p["primary"], "stop": p["warning"]}.get(kind, p["primary"])
ctk.CTkLabel(parent, text=title, text_color=p["text"], font=ui_kit.font(12, "bold"), anchor="w").pack(
fill="x", padx=12, pady=(0, 2)
)
row = ctk.CTkFrame(parent, fg_color="transparent")
row.pack(fill="x", padx=12, pady=(0, 5))
ctk.CTkEntry(
row,
placeholder_text=placeholder,
fg_color=p["btn_sec_bg"],
border_color=p["btn_sec_border"],
text_color=p["text"],
height=28,
).pack(side="left", fill="x", expand=True, padx=(0, 6))
ui_kit.secondary_button(row, p, "+").pack(side="right")
cloud = ctk.CTkFrame(parent, fg_color="transparent")
cloud.pack(fill="x", padx=12, pady=(0, 8))
for term in terms[:2]:
display = f"{term[:18]}{'' if len(term) > 18 else ''} ×"
ctk.CTkLabel(
cloud,
text=display,
width=150,
anchor="w",
text_color=color,
fg_color=p["btn_sec_bg"],
corner_radius=99,
font=ui_kit.font(10),
).pack(anchor="w", fill="x", pady=2, ipadx=5, ipady=2)
def _slider_row(self, parent, label: str, variable: ctk.IntVar, command) -> None:
p = self._p
row = ctk.CTkFrame(parent, fg_color="transparent")
row.pack(fill="x", padx=12, pady=2)
ctk.CTkLabel(row, text=label, text_color=p["text"], font=ui_kit.font(12)).pack(side="left")
ctk.CTkLabel(row, textvariable=variable, text_color=p["primary"], font=ui_kit.font(11, "bold"), width=20).pack(
side="right"
)
slider = ctk.CTkSlider(row, from_=0, to=6, number_of_steps=6, progress_color=p["primary"], width=96, command=command)
slider.set(variable.get())
slider.pack(side="right", padx=(8, 4))
def _rule_row(self, parent, values: tuple[str, str, str, str]) -> None:
p = self._p
label, rule_type, target, status = values
row = ctk.CTkFrame(parent, fg_color="transparent")
row.pack(fill="x", padx=12, pady=1)
ctk.CTkLabel(row, text=label, width=190, anchor="w", text_color=p["text"], font=ui_kit.font(12)).pack(side="left")
ctk.CTkLabel(row, text=rule_type, width=80, anchor="w", text_color=p["text_dim"], font=ui_kit.font(11)).pack(side="left")
ctk.CTkLabel(row, text=target, width=210, anchor="w", text_color=p["primary"], font=ui_kit.font(12, "bold")).pack(side="left")
color = p["success"] if status == "Actif" else p["warning"]
ctk.CTkLabel(row, text=status, width=70, anchor="w", text_color=color, font=ui_kit.font(11, "bold")).pack(side="left")
self._mockup_button(row, "▶ Tester").pack(side="left")