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>
This commit is contained in:
@@ -45,7 +45,7 @@ class AnonymisationApp(ctk.CTk):
|
|||||||
self._tab_frames: dict = {}
|
self._tab_frames: dict = {}
|
||||||
self._visible_tab = None
|
self._visible_tab = None
|
||||||
|
|
||||||
self.title("Pseudonymisation de vos documents")
|
self.title("Pseudonymisation de vos documents — bêta")
|
||||||
self.geometry("820x880")
|
self.geometry("820x880")
|
||||||
self.minsize(720, 680)
|
self.minsize(720, 680)
|
||||||
self._render()
|
self._render()
|
||||||
@@ -87,9 +87,19 @@ class AnonymisationApp(ctk.CTk):
|
|||||||
def _build_header(self, p: dict) -> None:
|
def _build_header(self, p: dict) -> None:
|
||||||
header = ctk.CTkFrame(self, fg_color=p["card"], corner_radius=0)
|
header = ctk.CTkFrame(self, fg_color=p["card"], corner_radius=0)
|
||||||
header.pack(fill="x")
|
header.pack(fill="x")
|
||||||
|
identity = ctk.CTkFrame(header, fg_color="transparent")
|
||||||
|
identity.pack(side="left", padx=16, pady=10)
|
||||||
ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
header, text="🛡️ aivanonym", text_color=p["text"], font=ui_kit.font(18, "bold")
|
identity, text="🛡️ aivanonym", text_color=p["text"], font=ui_kit.font(18, "bold")
|
||||||
).pack(side="left", padx=16, pady=10)
|
).pack(side="left")
|
||||||
|
ctk.CTkLabel(
|
||||||
|
identity,
|
||||||
|
text="bêta",
|
||||||
|
text_color="#ffffff",
|
||||||
|
fg_color=p["primary"],
|
||||||
|
corner_radius=8,
|
||||||
|
font=ui_kit.font(10, "bold"),
|
||||||
|
).pack(side="left", padx=(8, 0), ipadx=6, ipady=1)
|
||||||
|
|
||||||
status = self._safe_local_status()
|
status = self._safe_local_status()
|
||||||
ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
|
|||||||
69
gui_v6/profile_view.py
Normal file
69
gui_v6/profile_view.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""Vue lisible d'un profil d'anonymisation (logique pure, testable sans display).
|
||||||
|
|
||||||
|
Un profil de ``profile_defaults`` est un dict riche (label, description,
|
||||||
|
require_manual_mask, force_disable_vlm, preferred_manual_mask_template,
|
||||||
|
param_lists). Ce module en extrait un résumé affichable et les lignes du
|
||||||
|
« tableau des termes » pour les utilisateurs non informaticiens.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
# Ordre + libellés lisibles des listes locales d'un profil.
|
||||||
|
LIST_LABELS = {
|
||||||
|
"whitelist_phrases": "À conserver",
|
||||||
|
"blacklist_force_mask_terms": "À masquer",
|
||||||
|
"additional_stopwords": "À ignorer",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProfileSummary:
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
description: str
|
||||||
|
require_manual_mask: bool
|
||||||
|
mask_template: str # "" si aucun
|
||||||
|
disable_vlm: bool
|
||||||
|
list_counts: dict[str, int]
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_profile(key: str, profile: Optional[dict[str, Any]]) -> ProfileSummary:
|
||||||
|
profile = profile or {}
|
||||||
|
param_lists = profile.get("param_lists") or {}
|
||||||
|
counts = {
|
||||||
|
label: len(param_lists.get(raw) or [])
|
||||||
|
for raw, label in LIST_LABELS.items()
|
||||||
|
}
|
||||||
|
return ProfileSummary(
|
||||||
|
key=key,
|
||||||
|
label=str(profile.get("label") or key or "—"),
|
||||||
|
description=str(profile.get("description") or ""),
|
||||||
|
require_manual_mask=bool(profile.get("require_manual_mask")),
|
||||||
|
mask_template=str(profile.get("preferred_manual_mask_template") or ""),
|
||||||
|
disable_vlm=bool(profile.get("force_disable_vlm")),
|
||||||
|
list_counts=counts,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def profile_term_rows(profile: Optional[dict[str, Any]]) -> list[tuple[str, str, str]]:
|
||||||
|
"""Lignes ``(type, terme, source)`` pour le tableau des termes du profil."""
|
||||||
|
profile = profile or {}
|
||||||
|
source = str(profile.get("label") or "")
|
||||||
|
param_lists = profile.get("param_lists") or {}
|
||||||
|
rows: list[tuple[str, str, str]] = []
|
||||||
|
for raw, type_label in LIST_LABELS.items():
|
||||||
|
for term in (param_lists.get(raw) or []):
|
||||||
|
rows.append((type_label, str(term), source))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def filter_term_rows(
|
||||||
|
rows: list[tuple[str, str, str]], query: str
|
||||||
|
) -> list[tuple[str, str, str]]:
|
||||||
|
q = (query or "").strip().lower()
|
||||||
|
if not q:
|
||||||
|
return list(rows)
|
||||||
|
return [r for r in rows if q in r[1].lower() or q in r[0].lower()]
|
||||||
@@ -22,6 +22,7 @@ from manual_masking import ensure_mask_templates_dir, list_mask_templates, mask_
|
|||||||
|
|
||||||
_SUBTABS = [
|
_SUBTABS = [
|
||||||
("reg", "⚙️ Réglages"),
|
("reg", "⚙️ Réglages"),
|
||||||
|
("pro", "👤 Profils"),
|
||||||
("msk", "🎭 Masquage"),
|
("msk", "🎭 Masquage"),
|
||||||
("shr", "🔄 Partage"),
|
("shr", "🔄 Partage"),
|
||||||
("rul", "🛡️ Règles"),
|
("rul", "🛡️ Règles"),
|
||||||
@@ -65,7 +66,7 @@ MANUAL_MASK_NONE_LABEL = "Aucun masque manuel"
|
|||||||
# Textes d'aide « ? » (français simple, pour utilisateurs non informaticiens).
|
# Textes d'aide « ? » (français simple, pour utilisateurs non informaticiens).
|
||||||
_HELP_REGLAGES = (
|
_HELP_REGLAGES = (
|
||||||
"Réglages de l'anonymisation.\n\n"
|
"Réglages de l'anonymisation.\n\n"
|
||||||
"• Profil métier : choisit un jeu de réglages adapté à votre service.\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"
|
"• 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"
|
"• 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"
|
"• Listes locales : vos termes à toujours masquer ou toujours conserver.\n\n"
|
||||||
@@ -95,10 +96,33 @@ _HELP_REGLES = (
|
|||||||
"Cette section est en cours de finalisation : les actions marquées « à venir » "
|
"Cette section est en cours de finalisation : les actions marquées « à venir » "
|
||||||
"ne sont pas encore disponibles."
|
"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 = {
|
CONFIG_MOCKUP_SECTIONS = {
|
||||||
"reglages": [
|
"reglages": [
|
||||||
"Profil métier",
|
"Profil d'anonymisation",
|
||||||
"Moteurs NER",
|
"Moteurs NER",
|
||||||
"Données à détecter",
|
"Données à détecter",
|
||||||
"Termes à toujours conserver",
|
"Termes à toujours conserver",
|
||||||
@@ -202,6 +226,7 @@ class ConfigTab(ctk.CTkFrame):
|
|||||||
|
|
||||||
builders = {
|
builders = {
|
||||||
"reg": self._build_reglages,
|
"reg": self._build_reglages,
|
||||||
|
"pro": self._build_profils,
|
||||||
"msk": self._build_masquage,
|
"msk": self._build_masquage,
|
||||||
"shr": self._build_partage,
|
"shr": self._build_partage,
|
||||||
"rul": self._build_regles,
|
"rul": self._build_regles,
|
||||||
@@ -251,7 +276,7 @@ class ConfigTab(ctk.CTkFrame):
|
|||||||
current = self._state.profile or default_profile_key() or (profiles[0] if profiles else "")
|
current = self._state.profile or default_profile_key() or (profiles[0] if profiles else "")
|
||||||
self._state.profile = current or None
|
self._state.profile = current or None
|
||||||
|
|
||||||
ctk.CTkLabel(top, text="Profil métier", text_color=p["text_dim"], font=ui_kit.font(11, "bold")).pack(
|
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
|
side="left", padx=(12, 8), pady=10
|
||||||
)
|
)
|
||||||
self._profile_menu = ctk.CTkOptionMenu(
|
self._profile_menu = ctk.CTkOptionMenu(
|
||||||
@@ -268,9 +293,15 @@ class ConfigTab(ctk.CTkFrame):
|
|||||||
if current:
|
if current:
|
||||||
self._profile_menu.set(current)
|
self._profile_menu.set(current)
|
||||||
self._profile_menu.pack(side="left", pady=10)
|
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
|
||||||
|
)
|
||||||
|
|
||||||
ui_kit.secondary_button(top, p, "📁 Sortie…", command=self._pick_output).pack(
|
sortie = ui_kit.secondary_button(top, p, "📁 Dossier de sortie…", command=self._pick_output)
|
||||||
side="left", padx=(12, 6), pady=10
|
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(
|
self._out_label = ctk.CTkLabel(
|
||||||
top,
|
top,
|
||||||
@@ -289,16 +320,22 @@ class ConfigTab(ctk.CTkFrame):
|
|||||||
|
|
||||||
ner = ui_kit.Card(cols[1], p, title="🧠 Moteurs et masques")
|
ner = ui_kit.Card(cols[1], p, title="🧠 Moteurs et masques")
|
||||||
ner.pack(fill="both", expand=True)
|
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(
|
self._tog_ner = self._mini_toggle(
|
||||||
ner, "CamemBERT-bio", "rapide · F1 0.963", value=self._state.use_local_ner, command=self._on_ner
|
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_ner.pack(fill="x", padx=12, pady=1)
|
||||||
self._tog_eds = self._mini_toggle(
|
self._tog_eds = self._mini_toggle(
|
||||||
ner, "EDS-Pseudo", "médical français", value=self._state.enable_eds, command=self._on_eds
|
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_eds.pack(fill="x", padx=12, pady=1)
|
||||||
self._tog_gli = self._mini_toggle(
|
self._tog_gli = self._mini_toggle(
|
||||||
ner, "GLiNER", "vote croisé", value=self._state.enable_gliner, command=self._on_gliner
|
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._tog_gli.pack(fill="x", padx=12, pady=1)
|
||||||
self._mini_toggle(
|
self._mini_toggle(
|
||||||
@@ -340,9 +377,115 @@ class ConfigTab(ctk.CTkFrame):
|
|||||||
|
|
||||||
terms = ui_kit.Card(cols[2], p, title="✅ Listes locales")
|
terms = ui_kit.Card(cols[2], p, title="✅ Listes locales")
|
||||||
terms.pack(fill="both", expand=True)
|
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 à conserver", "Ex : FUROSEMIDE", _PRESERVE_TERMS, "keep")
|
||||||
self._compact_tag_editor(terms, "Termes à masquer", "Ex : CHUXX", _MASK_TERMS, "mask")
|
self._compact_tag_editor(terms, "Termes à masquer", "Ex : CHUXX", _MASK_TERMS, "mask")
|
||||||
self._compact_tag_editor(terms, "Mots à ignorer", "Ex : prescription", _STOPWORDS, "stop")
|
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 ---------------------------------------------------------
|
# -- Masquage ---------------------------------------------------------
|
||||||
|
|
||||||
@@ -548,6 +691,7 @@ class ConfigTab(ctk.CTkFrame):
|
|||||||
|
|
||||||
def _on_profile(self, value: str) -> None:
|
def _on_profile(self, value: str) -> None:
|
||||||
self._state.profile = value
|
self._state.profile = value
|
||||||
|
self._rebuild_profils()
|
||||||
|
|
||||||
def _on_ner(self) -> None:
|
def _on_ner(self) -> None:
|
||||||
self._state.use_local_ner = self._tog_ner.get()
|
self._state.use_local_ner = self._tog_ner.get()
|
||||||
|
|||||||
114
gui_v6/terms_table_window.py
Normal file
114
gui_v6/terms_table_window.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"""Fenêtre « Tableau des termes » d'un profil (lisible même avec 50+ termes).
|
||||||
|
|
||||||
|
Table scrollable avec recherche/filtre — colonnes Type / Terme / Source (profil).
|
||||||
|
Lecture seule pour l'instant : ajouter/supprimer/éditer sont désactivés et
|
||||||
|
marqués « (à venir) » (l'écriture par profil n'est pas encore câblée).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import customtkinter as ctk
|
||||||
|
|
||||||
|
from gui_v6 import ui_kit
|
||||||
|
from gui_v6.profile_view import filter_term_rows
|
||||||
|
|
||||||
|
_TYPE_COLORS = {
|
||||||
|
"À conserver": "success",
|
||||||
|
"À masquer": "primary",
|
||||||
|
"À ignorer": "text_muted",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TermsTableWindow(ctk.CTkToplevel):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
master,
|
||||||
|
palette: dict,
|
||||||
|
rows,
|
||||||
|
*,
|
||||||
|
title: str = "Tableau des termes",
|
||||||
|
profile_label: str = "",
|
||||||
|
) -> None:
|
||||||
|
super().__init__(master)
|
||||||
|
self._palette = palette
|
||||||
|
self._rows = list(rows)
|
||||||
|
self._profile_label = profile_label
|
||||||
|
self._visible = list(self._rows)
|
||||||
|
|
||||||
|
self.title(title)
|
||||||
|
self.geometry("740x560")
|
||||||
|
self.minsize(520, 360)
|
||||||
|
try:
|
||||||
|
self.configure(fg_color=palette["bg"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self._query = tk.StringVar()
|
||||||
|
self._count_text = tk.StringVar(value="")
|
||||||
|
self._build()
|
||||||
|
self._refresh()
|
||||||
|
try:
|
||||||
|
self.transient(master)
|
||||||
|
self.after(120, lambda: (self.lift(), self.focus_force()))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# -- coutures testables --------------------------------------------------
|
||||||
|
def set_query(self, query: str) -> None:
|
||||||
|
self._query.set(query)
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def visible_count(self) -> int:
|
||||||
|
return len(self._visible)
|
||||||
|
|
||||||
|
def add_is_disabled(self) -> bool:
|
||||||
|
return str(self._add_btn.cget("state")) == "disabled"
|
||||||
|
|
||||||
|
# -- UI ------------------------------------------------------------------
|
||||||
|
def _build(self) -> None:
|
||||||
|
p = self._palette
|
||||||
|
head = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
|
head.pack(fill="x", padx=12, pady=(12, 4))
|
||||||
|
title = "Termes du profil"
|
||||||
|
if self._profile_label:
|
||||||
|
title += f" « {self._profile_label} »"
|
||||||
|
ctk.CTkLabel(head, text=title, text_color=p["text"], font=ui_kit.font(15, "bold")).pack(side="left")
|
||||||
|
|
||||||
|
bar = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
|
bar.pack(fill="x", padx=12, pady=(0, 6))
|
||||||
|
ctk.CTkLabel(bar, text="🔎 Rechercher :", text_color=p["text_dim"], font=ui_kit.font(12)).pack(side="left")
|
||||||
|
entry = ctk.CTkEntry(bar, textvariable=self._query, width=240)
|
||||||
|
entry.pack(side="left", padx=6)
|
||||||
|
self._query.trace_add("write", lambda *_: self._refresh())
|
||||||
|
self._add_btn = ui_kit.secondary_button(bar, p, "+ Ajouter (à venir)", command=lambda: None)
|
||||||
|
self._add_btn.configure(state="disabled")
|
||||||
|
self._add_btn.pack(side="right")
|
||||||
|
|
||||||
|
header = ctk.CTkFrame(self, fg_color=p["card"], corner_radius=6)
|
||||||
|
header.pack(fill="x", padx=12)
|
||||||
|
for text, width in [("TYPE", 130), ("TERME", 360), ("SOURCE (PROFIL)", 180)]:
|
||||||
|
ctk.CTkLabel(header, text=text, width=width, anchor="w", text_color=p["text_muted"], font=ui_kit.font(10, "bold")).pack(side="left", padx=8, pady=4)
|
||||||
|
|
||||||
|
self._table = ctk.CTkScrollableFrame(self, fg_color=p["bg"])
|
||||||
|
self._table.pack(fill="both", expand=True, padx=12, pady=(2, 4))
|
||||||
|
|
||||||
|
ctk.CTkLabel(self, textvariable=self._count_text, text_color=p["text_muted"], font=ui_kit.font(11), anchor="w").pack(fill="x", padx=14, pady=(0, 10))
|
||||||
|
|
||||||
|
def _refresh(self) -> None:
|
||||||
|
p = self._palette
|
||||||
|
self._visible = filter_term_rows(self._rows, self._query.get())
|
||||||
|
for child in self._table.winfo_children():
|
||||||
|
child.destroy()
|
||||||
|
if not self._visible:
|
||||||
|
ctk.CTkLabel(self._table, text="Aucun terme.", text_color=p["text_muted"], font=ui_kit.font(12)).pack(anchor="w", padx=8, pady=8)
|
||||||
|
for type_label, term, source in self._visible:
|
||||||
|
row = ctk.CTkFrame(self._table, fg_color="transparent")
|
||||||
|
row.pack(fill="x", pady=1)
|
||||||
|
color = p[_TYPE_COLORS.get(type_label, "text")]
|
||||||
|
ctk.CTkLabel(row, text=type_label, width=130, anchor="w", text_color=color, font=ui_kit.font(11, "bold")).pack(side="left", padx=8)
|
||||||
|
ctk.CTkLabel(row, text=term, width=360, anchor="w", text_color=p["text"], font=ui_kit.font(12)).pack(side="left", padx=8)
|
||||||
|
ctk.CTkLabel(row, text=source, width=180, anchor="w", text_color=p["text_muted"], font=ui_kit.font(11)).pack(side="left", padx=8)
|
||||||
|
self._count_text.set(f"{len(self._visible)} terme(s) affiché(s) sur {len(self._rows)}.")
|
||||||
@@ -7,6 +7,7 @@ Les widgets ne sont créés qu'à l'appel (import sûr pour ``--self-test``).
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import customtkinter as ctk
|
import customtkinter as ctk
|
||||||
@@ -231,3 +232,70 @@ class HelpButton(ctk.CTkButton):
|
|||||||
|
|
||||||
def help_button(master, palette: dict, text: str, title: str = "Aide") -> "HelpButton":
|
def help_button(master, palette: dict, text: str, title: str = "Aide") -> "HelpButton":
|
||||||
return HelpButton(master, palette, text, title=title)
|
return HelpButton(master, palette, text, title=title)
|
||||||
|
|
||||||
|
|
||||||
|
class Tooltip:
|
||||||
|
"""Infobulle au survol (façon V5 ``ToolTip``), pour les éléments ambigus."""
|
||||||
|
|
||||||
|
def __init__(self, widget, text: str, delay: int = 450):
|
||||||
|
self.widget = widget
|
||||||
|
self.text = text
|
||||||
|
self.delay = delay
|
||||||
|
self._tip = None
|
||||||
|
self._after = None
|
||||||
|
widget.bind("<Enter>", self._schedule, add="+")
|
||||||
|
widget.bind("<Leave>", self.hide, add="+")
|
||||||
|
widget.bind("<ButtonPress>", self.hide, add="+")
|
||||||
|
|
||||||
|
def _schedule(self, *_):
|
||||||
|
self._cancel()
|
||||||
|
try:
|
||||||
|
self._after = self.widget.after(self.delay, self.show)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _cancel(self):
|
||||||
|
if self._after is not None:
|
||||||
|
try:
|
||||||
|
self.widget.after_cancel(self._after)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._after = None
|
||||||
|
|
||||||
|
def show(self, *_):
|
||||||
|
if self._tip is not None or not self.text:
|
||||||
|
return self._tip
|
||||||
|
try:
|
||||||
|
x = self.widget.winfo_rootx() + 16
|
||||||
|
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
self._tip = tw = tk.Toplevel(self.widget)
|
||||||
|
tw.wm_overrideredirect(True)
|
||||||
|
tw.wm_geometry(f"+{x}+{y}")
|
||||||
|
tk.Label(
|
||||||
|
tw,
|
||||||
|
text=self.text,
|
||||||
|
justify="left",
|
||||||
|
background="#1f2937",
|
||||||
|
foreground="#f9fafb",
|
||||||
|
relief="solid",
|
||||||
|
borderwidth=1,
|
||||||
|
wraplength=320,
|
||||||
|
padx=8,
|
||||||
|
pady=5,
|
||||||
|
).pack()
|
||||||
|
return tw
|
||||||
|
|
||||||
|
def hide(self, *_):
|
||||||
|
self._cancel()
|
||||||
|
if self._tip is not None:
|
||||||
|
try:
|
||||||
|
self._tip.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._tip = None
|
||||||
|
|
||||||
|
|
||||||
|
def attach_tooltip(widget, text: str, delay: int = 450) -> "Tooltip":
|
||||||
|
return Tooltip(widget, text, delay)
|
||||||
|
|||||||
@@ -77,6 +77,26 @@ def test_help_button_opens_help_window(app):
|
|||||||
win.destroy()
|
win.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
def _all_texts(widget) -> list:
|
||||||
|
out = []
|
||||||
|
try:
|
||||||
|
out.append(str(widget.cget("text")))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
for child in widget.winfo_children():
|
||||||
|
out += _all_texts(child)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def test_beta_label_in_product_identity(app):
|
||||||
|
"""Addendum Dom : indiquer « bêta » à côté du nom produit (en-tête + titre)."""
|
||||||
|
app.update_idletasks()
|
||||||
|
assert "bêta" in app.title().lower() or "beta" in app.title().lower()
|
||||||
|
texts = [t.lower() for t in _all_texts(app)]
|
||||||
|
assert any("aivanonym" in t for t in texts)
|
||||||
|
assert any("bêta" in t or "beta" in t for t in texts)
|
||||||
|
|
||||||
|
|
||||||
def _count_help_buttons(widget) -> int:
|
def _count_help_buttons(widget) -> int:
|
||||||
from gui_v6.ui_kit import HelpButton
|
from gui_v6.ui_kit import HelpButton
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from gui_v6.tabs.tab_config import CONFIG_INTERACTION_CONTRACT, CONFIG_MOCKUP_SE
|
|||||||
def test_config_mockup_sections_cover_admin_surface():
|
def test_config_mockup_sections_cover_admin_surface():
|
||||||
assert CONFIG_MOCKUP_SECTIONS == {
|
assert CONFIG_MOCKUP_SECTIONS == {
|
||||||
"reglages": [
|
"reglages": [
|
||||||
"Profil métier",
|
"Profil d'anonymisation",
|
||||||
"Moteurs NER",
|
"Moteurs NER",
|
||||||
"Données à détecter",
|
"Données à détecter",
|
||||||
"Termes à toujours conserver",
|
"Termes à toujours conserver",
|
||||||
|
|||||||
160
tests/unit/test_gui_v6_profiles.py
Normal file
160
tests/unit/test_gui_v6_profiles.py
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
"""Vue lisible d'un profil d'anonymisation (logique pure, sans display).
|
||||||
|
|
||||||
|
Sous-tend le sous-onglet « Profils » et la fenêtre « Tableau des termes ».
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from gui_v6.profile_view import (
|
||||||
|
filter_term_rows,
|
||||||
|
profile_term_rows,
|
||||||
|
summarize_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
_PROFILE = {
|
||||||
|
"label": "Standard local",
|
||||||
|
"description": "Profil par défaut.",
|
||||||
|
"require_manual_mask": True,
|
||||||
|
"force_disable_vlm": True,
|
||||||
|
"preferred_manual_mask_template": "config/mask_templates/x.json",
|
||||||
|
"param_lists": {
|
||||||
|
"whitelist_phrases": ["classification internationale", "prise en charge"],
|
||||||
|
"blacklist_force_mask_terms": ["CHUXX"],
|
||||||
|
"additional_stopwords": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_profile_reads_real_fields():
|
||||||
|
s = summarize_profile("standard_local", _PROFILE)
|
||||||
|
assert s.key == "standard_local"
|
||||||
|
assert s.label == "Standard local"
|
||||||
|
assert s.description == "Profil par défaut."
|
||||||
|
assert s.require_manual_mask is True
|
||||||
|
assert s.disable_vlm is True
|
||||||
|
assert s.mask_template == "config/mask_templates/x.json"
|
||||||
|
assert s.list_counts == {"À conserver": 2, "À masquer": 1, "À ignorer": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_profile_tolerates_empty():
|
||||||
|
s = summarize_profile("vide", {})
|
||||||
|
assert s.label == "vide"
|
||||||
|
assert s.description == ""
|
||||||
|
assert s.require_manual_mask is False
|
||||||
|
assert s.mask_template == ""
|
||||||
|
assert s.list_counts == {"À conserver": 0, "À masquer": 0, "À ignorer": 0}
|
||||||
|
|
||||||
|
s2 = summarize_profile("none", None)
|
||||||
|
assert s2.list_counts["À masquer"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_term_rows_type_term_source():
|
||||||
|
rows = profile_term_rows(_PROFILE)
|
||||||
|
assert ("À conserver", "classification internationale", "Standard local") in rows
|
||||||
|
assert ("À masquer", "CHUXX", "Standard local") in rows
|
||||||
|
# 2 whitelist + 1 blacklist + 0 stopwords
|
||||||
|
assert len(rows) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_term_rows_by_query():
|
||||||
|
rows = profile_term_rows(_PROFILE)
|
||||||
|
assert len(filter_term_rows(rows, "")) == 3
|
||||||
|
assert filter_term_rows(rows, "chuxx") == [("À masquer", "CHUXX", "Standard local")]
|
||||||
|
assert filter_term_rows(rows, "conserver") == [
|
||||||
|
r for r in rows if r[0] == "À conserver"
|
||||||
|
]
|
||||||
|
assert filter_term_rows(rows, "zzz") == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- Smokes headless (fenêtre tableau + infobulle) --------------------------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ctk_root():
|
||||||
|
ctk = pytest.importorskip("customtkinter")
|
||||||
|
try:
|
||||||
|
root = ctk.CTk()
|
||||||
|
except Exception as exc:
|
||||||
|
pytest.skip(f"display Tk indisponible: {exc}")
|
||||||
|
root.withdraw()
|
||||||
|
try:
|
||||||
|
yield root
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
root.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_terms_table_window_filters_and_disables_add(ctk_root):
|
||||||
|
from gui_v6 import theme as theme_mod
|
||||||
|
from gui_v6.terms_table_window import TermsTableWindow
|
||||||
|
|
||||||
|
p = theme_mod.get_palette(theme_mod.DEFAULT_THEME)
|
||||||
|
win = TermsTableWindow(ctk_root, p, profile_term_rows(_PROFILE), profile_label="Standard local")
|
||||||
|
ctk_root.update_idletasks()
|
||||||
|
assert win.visible_count() == 3
|
||||||
|
assert win.add_is_disabled() # action non câblée → désactivée
|
||||||
|
win.set_query("chuxx")
|
||||||
|
assert win.visible_count() == 1
|
||||||
|
win.set_query("")
|
||||||
|
assert win.visible_count() == 3
|
||||||
|
win.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
def test_attach_tooltip_does_not_break_widget(ctk_root):
|
||||||
|
import customtkinter as ctk
|
||||||
|
|
||||||
|
from gui_v6 import ui_kit
|
||||||
|
|
||||||
|
lbl = ctk.CTkLabel(ctk_root, text="x")
|
||||||
|
lbl.pack()
|
||||||
|
ctk_root.update_idletasks()
|
||||||
|
tip = ui_kit.attach_tooltip(lbl, "aide contextuelle")
|
||||||
|
tip.show()
|
||||||
|
ctk_root.update_idletasks()
|
||||||
|
tip.hide()
|
||||||
|
assert lbl.winfo_exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_subtabs_include_profils():
|
||||||
|
from gui_v6.tabs.tab_config import _SUBTABS
|
||||||
|
|
||||||
|
keys = [k for k, _ in _SUBTABS]
|
||||||
|
labels = [lbl for _, lbl in _SUBTABS]
|
||||||
|
assert "pro" in keys
|
||||||
|
assert any("Profils" in lbl for lbl in labels)
|
||||||
|
|
||||||
|
|
||||||
|
def _all_texts(widget):
|
||||||
|
out = []
|
||||||
|
try:
|
||||||
|
out.append(str(widget.cget("text")))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
for child in widget.winfo_children():
|
||||||
|
out += _all_texts(child)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def test_reglages_labels_renamed_and_profile_readable(ctk_root, tmp_path, monkeypatch):
|
||||||
|
from gui_v6.tabs import tab_config
|
||||||
|
|
||||||
|
monkeypatch.setattr(tab_config, "_app_base_dir", lambda: tmp_path)
|
||||||
|
tab = tab_config.ConfigTab(ctk_root)
|
||||||
|
tab.update_idletasks()
|
||||||
|
|
||||||
|
texts = " | ".join(_all_texts(tab))
|
||||||
|
assert "Profil d'anonymisation" in texts # addendum : renommage
|
||||||
|
assert "Profil métier" not in texts
|
||||||
|
assert "Dossier de sortie" in texts # addendum : « Sortie… » clarifié
|
||||||
|
|
||||||
|
# profil lisible : résumé avec les 3 listes
|
||||||
|
summary = tab._active_profile_summary()
|
||||||
|
assert set(summary.list_counts.keys()) == {"À conserver", "À masquer", "À ignorer"}
|
||||||
|
|
||||||
|
# tableau des termes ouvrable sans erreur
|
||||||
|
tab._open_terms_table()
|
||||||
|
tab.update_idletasks()
|
||||||
|
tab.destroy()
|
||||||
Reference in New Issue
Block a user