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>
941 lines
40 KiB
Python
941 lines
40 KiB
Python
"""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")
|