feat(gui): onglet Profils éditable (création/modification/persistance)
Retour Dom : remplacer la page vitrine par un vrai éditeur de profils. - gui_v6/profile_editor.py : couche logique (build_profile_spec, profile_is_editable runtime vs defaut, list_profile_choices, slug_for_copy, save/set_default/delete) au-dessus de profile_defaults — persistance dans config/profiles.yml. - gui_v6/editable_list.py : EditableTermList (tableau scrollable de termes, ajout/suppression, pas de pastilles) — reste lisible à 50+ termes. - tab_config : sous-onglet « 👤 Profils » réintroduit comme éditeur — menu déroulant « Profil à modifier », boutons Nouveau / Dupliquer / Enregistrer / Annuler / Définir par défaut, sections Identité, Masquage (require_manual_mask, template), Moteurs (force_disable_vlm), Mots (à masquer/conserver/ignorer éditables), Règles « à venir ». Profils défaut = lecture seule (dupliquer pour modifier). Confirmation non bloquante (pas de modale). - Réglages : bouton « ✏️ Modifier le profil… » → ouvre Profils sur le profil actif. Pas de pastilles inline. Persiste : label, description, require_manual_mask, force_disable_vlm, preferred_manual_mask_template, param_lists (3 listes). 260 tests unit OK (0 régression), self-test OK, nav 5 sous-onglets + thème OK. Préserve 1bbe70a/d30f7b7. Aucun build/push sans GO Dom. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@ from manual_masking import ensure_mask_templates_dir, list_mask_templates, mask_
|
||||
|
||||
_SUBTABS = [
|
||||
("reg", "⚙️ Réglages"),
|
||||
("pro", "👤 Profils"),
|
||||
("msk", "🎭 Masquage"),
|
||||
("shr", "🔄 Partage"),
|
||||
("rul", "🛡️ Règles"),
|
||||
@@ -184,6 +185,12 @@ class ConfigTab(ctk.CTkFrame):
|
||||
# ouverte pour éviter d'en empiler plusieurs.
|
||||
self._mask_editor_window = None
|
||||
|
||||
# Éditeur de profils : chemin overlay runtime (None = config/profiles.yml standard,
|
||||
# surchargeable en test), clé en cours d'édition + widgets.
|
||||
self._profiles_path = None
|
||||
self._pro_edit_key: str | None = None
|
||||
self._pro_term_lists: dict = {}
|
||||
|
||||
self._build()
|
||||
|
||||
@property
|
||||
@@ -221,6 +228,7 @@ class ConfigTab(ctk.CTkFrame):
|
||||
|
||||
builders = {
|
||||
"reg": self._build_reglages,
|
||||
"pro": self._build_profils,
|
||||
"msk": self._build_masquage,
|
||||
"shr": self._build_partage,
|
||||
"rul": self._build_regles,
|
||||
@@ -288,6 +296,9 @@ class ConfigTab(ctk.CTkFrame):
|
||||
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, "✏️ Modifier le profil…", command=self._open_profile_editor).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)
|
||||
@@ -426,6 +437,250 @@ class ConfigTab(ctk.CTkFrame):
|
||||
rows = profile_term_rows(self._active_profile_dict())
|
||||
TermsTableWindow(self.winfo_toplevel(), self._p, rows, profile_label=summary.label)
|
||||
|
||||
# -- Profils (éditeur) ------------------------------------------------
|
||||
|
||||
def _build_profils(self, parent) -> None:
|
||||
p = self._p
|
||||
from gui_v6.editable_list import EditableTermList
|
||||
|
||||
self._section_intro(
|
||||
parent,
|
||||
"Un profil regroupe les moteurs, les masques, les règles et les mots à conserver ou masquer.",
|
||||
_HELP_PROFIL,
|
||||
"Profils d'anonymisation",
|
||||
)
|
||||
|
||||
bar = ui_kit.Card(parent, p, title="👤 Profil à modifier")
|
||||
bar.pack(fill="x", pady=(0, 8))
|
||||
top = ctk.CTkFrame(bar, fg_color="transparent")
|
||||
top.pack(fill="x", padx=12, pady=(0, 4))
|
||||
self._pro_menu_var = ctk.StringVar(value="")
|
||||
self._pro_menu = ctk.CTkOptionMenu(
|
||||
top, values=["—"], variable=self._pro_menu_var, command=self._pro_on_select,
|
||||
fg_color=p["btn_sec_bg"], button_color=p["primary"], button_hover_color=p["primary_dim"],
|
||||
text_color=p["text"], width=260, height=30,
|
||||
)
|
||||
self._pro_menu.pack(side="left")
|
||||
self._pro_status = ctk.CTkLabel(top, text="", text_color=p["text_muted"], font=ui_kit.font(11), anchor="w")
|
||||
self._pro_status.pack(side="left", padx=(10, 0))
|
||||
|
||||
actions = ctk.CTkFrame(bar, fg_color="transparent")
|
||||
actions.pack(fill="x", padx=12, pady=(0, 12))
|
||||
ui_kit.secondary_button(actions, p, "+ Nouveau", command=self._pro_new).pack(side="left", padx=(0, 6))
|
||||
ui_kit.secondary_button(actions, p, "⧉ Dupliquer", command=self._pro_duplicate).pack(side="left", padx=(0, 6))
|
||||
self._pro_save_btn = ui_kit.primary_button(actions, p, "💾 Enregistrer", command=self._pro_save)
|
||||
self._pro_save_btn.pack(side="left", padx=(6, 6))
|
||||
ui_kit.secondary_button(actions, p, "↩ Annuler", command=self._pro_cancel).pack(side="left")
|
||||
self._pro_default_btn = ui_kit.secondary_button(actions, p, "⭐ Définir par défaut", command=self._pro_set_default)
|
||||
self._pro_default_btn.pack(side="right")
|
||||
|
||||
cols = self._columns(parent, 2, gap=8)
|
||||
left, right = cols[0], cols[1]
|
||||
|
||||
ident = ui_kit.Card(left, p, title="🏷️ Identité")
|
||||
ident.pack(fill="x", pady=(0, 8))
|
||||
self._pro_label_var = ctk.StringVar()
|
||||
self._pro_label_entry = ctk.CTkEntry(ident, textvariable=self._pro_label_var, fg_color=p["btn_sec_bg"], border_color=p["btn_sec_border"], text_color=p["text"], height=30)
|
||||
ctk.CTkLabel(ident, text="Nom du profil", text_color=p["text_muted"], font=ui_kit.font(11), anchor="w").pack(fill="x", padx=12, pady=(0, 2))
|
||||
self._pro_label_entry.pack(fill="x", padx=12, pady=(0, 6))
|
||||
self._pro_desc_var = ctk.StringVar()
|
||||
self._pro_desc_entry = ctk.CTkEntry(ident, textvariable=self._pro_desc_var, fg_color=p["btn_sec_bg"], border_color=p["btn_sec_border"], text_color=p["text"], height=30)
|
||||
ctk.CTkLabel(ident, text="Description", text_color=p["text_muted"], font=ui_kit.font(11), anchor="w").pack(fill="x", padx=12, pady=(0, 2))
|
||||
self._pro_desc_entry.pack(fill="x", padx=12, pady=(0, 12))
|
||||
|
||||
mask = ui_kit.Card(left, p, title="⬛ Masquage")
|
||||
mask.pack(fill="x", pady=(0, 8))
|
||||
self._pro_require_mask_var = ctk.BooleanVar(value=False)
|
||||
self._pro_require_switch = ctk.CTkSwitch(mask, text="Masque manuel obligatoire", variable=self._pro_require_mask_var, progress_color=p["primary"], text_color=p["text"], font=ui_kit.font(12))
|
||||
self._pro_require_switch.pack(anchor="w", padx=12, pady=(0, 6))
|
||||
ctk.CTkLabel(mask, text="Template de masque préféré", text_color=p["text_muted"], font=ui_kit.font(11), anchor="w").pack(fill="x", padx=12, pady=(0, 2))
|
||||
self._pro_template_var = ctk.StringVar()
|
||||
self._pro_template_entry = ctk.CTkEntry(mask, textvariable=self._pro_template_var, fg_color=p["btn_sec_bg"], border_color=p["btn_sec_border"], text_color=p["text"], height=30)
|
||||
self._pro_template_entry.pack(fill="x", padx=12, pady=(0, 12))
|
||||
|
||||
eng = ui_kit.Card(left, p, title="🧠 Moteurs")
|
||||
eng.pack(fill="x")
|
||||
self._pro_disable_vlm_var = ctk.BooleanVar(value=False)
|
||||
self._pro_vlm_switch = ctk.CTkSwitch(eng, text="Désactiver le moteur VLM (images)", variable=self._pro_disable_vlm_var, progress_color=p["primary"], text_color=p["text"], font=ui_kit.font(12))
|
||||
self._pro_vlm_switch.pack(anchor="w", padx=12, pady=(0, 6))
|
||||
ctk.CTkLabel(eng, text="CamemBERT-bio (standard) toujours actif ; EDS-Pseudo / GLiNER optionnels.", text_color=p["text_muted"], font=ui_kit.font(11), anchor="w", wraplength=300, justify="left").pack(fill="x", padx=12, pady=(0, 12))
|
||||
|
||||
words = ui_kit.Card(right, p, title="📝 Mots du profil")
|
||||
words.pack(fill="both", expand=True)
|
||||
self._pro_term_lists = {
|
||||
"blacklist": EditableTermList(words, p, title="À masquer", height=104),
|
||||
"whitelist": EditableTermList(words, p, title="À conserver", height=104),
|
||||
"stopwords": EditableTermList(words, p, title="À ignorer", height=88),
|
||||
}
|
||||
for tl in self._pro_term_lists.values():
|
||||
tl.pack(fill="x", padx=12, pady=(0, 8))
|
||||
|
||||
rules = ui_kit.Card(parent, p, title="🛡️ Règles du profil")
|
||||
rules.pack(fill="x", pady=(8, 0))
|
||||
self._note(rules, "Les règles embarquées par profil seront éditables prochainement.")
|
||||
self._mockup_button(rules, "+ Ajouter une règle").pack(anchor="w", padx=12, pady=(0, 12))
|
||||
|
||||
self._pro_refresh_and_load()
|
||||
|
||||
# -- Profils : logique -----------------------------------------------
|
||||
|
||||
def _pro_choices(self) -> list:
|
||||
from gui_v6.profile_editor import list_profile_choices
|
||||
|
||||
return list_profile_choices(self._profiles_path)
|
||||
|
||||
@staticmethod
|
||||
def _pro_label_for(choice: dict) -> str:
|
||||
if choice["is_default"]:
|
||||
return f"{choice['label']} (défaut)"
|
||||
if not choice["editable"]:
|
||||
return f"{choice['label']} (lecture seule)"
|
||||
return choice["label"]
|
||||
|
||||
def _pro_refresh_and_load(self, select_key: str | None = None) -> None:
|
||||
choices = self._pro_choices()
|
||||
self._pro_choice_by_label = {self._pro_label_for(c): c for c in choices}
|
||||
labels = list(self._pro_choice_by_label) or ["—"]
|
||||
self._pro_menu.configure(values=labels)
|
||||
target = None
|
||||
if select_key is not None:
|
||||
target = next((lbl for lbl, c in self._pro_choice_by_label.items() if c["key"] == select_key), None)
|
||||
if target is None:
|
||||
target = self._pro_menu_var.get() if self._pro_menu_var.get() in self._pro_choice_by_label else labels[0]
|
||||
self._pro_menu_var.set(target)
|
||||
self._pro_on_select(target)
|
||||
|
||||
def _pro_on_select(self, label: str) -> None:
|
||||
choice = getattr(self, "_pro_choice_by_label", {}).get(label)
|
||||
if choice is None:
|
||||
return
|
||||
self._pro_load(choice["key"])
|
||||
|
||||
def _profile_dict_for(self, key: str) -> dict:
|
||||
from profile_defaults import list_effective_profiles
|
||||
|
||||
try:
|
||||
return list_effective_profiles(self._profiles_path).get(key, {}) or {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _pro_load(self, key: str) -> None:
|
||||
from gui_v6.profile_editor import profile_is_editable
|
||||
|
||||
profile = self._profile_dict_for(key)
|
||||
self._pro_edit_key = key
|
||||
self._pro_label_var.set(str(profile.get("label") or key))
|
||||
self._pro_desc_var.set(str(profile.get("description") or ""))
|
||||
self._pro_require_mask_var.set(bool(profile.get("require_manual_mask")))
|
||||
self._pro_disable_vlm_var.set(bool(profile.get("force_disable_vlm")))
|
||||
self._pro_template_var.set(str(profile.get("preferred_manual_mask_template") or ""))
|
||||
param_lists = profile.get("param_lists") or {}
|
||||
self._pro_term_lists["blacklist"].set_terms(param_lists.get("blacklist_force_mask_terms") or [])
|
||||
self._pro_term_lists["whitelist"].set_terms(param_lists.get("whitelist_phrases") or [])
|
||||
self._pro_term_lists["stopwords"].set_terms(param_lists.get("additional_stopwords") or [])
|
||||
editable = profile_is_editable(key, self._profiles_path)
|
||||
self._pro_set_editable(editable)
|
||||
self._pro_status.configure(
|
||||
text="Éditable" if editable else "Profil par défaut — lecture seule (dupliquez pour modifier)"
|
||||
)
|
||||
|
||||
def _pro_set_editable(self, editable: bool) -> None:
|
||||
state = "normal" if editable else "disabled"
|
||||
for widget in (self._pro_label_entry, self._pro_desc_entry, self._pro_template_entry,
|
||||
self._pro_require_switch, self._pro_vlm_switch, self._pro_save_btn):
|
||||
widget.configure(state=state)
|
||||
for term_list in self._pro_term_lists.values():
|
||||
term_list.set_editable(editable)
|
||||
|
||||
def _pro_collect_spec(self) -> dict:
|
||||
from gui_v6.profile_editor import build_profile_spec
|
||||
|
||||
return build_profile_spec(
|
||||
label=self._pro_label_var.get(),
|
||||
description=self._pro_desc_var.get(),
|
||||
require_manual_mask=bool(self._pro_require_mask_var.get()),
|
||||
force_disable_vlm=bool(self._pro_disable_vlm_var.get()),
|
||||
preferred_manual_mask_template=self._pro_template_var.get(),
|
||||
whitelist=self._pro_term_lists["whitelist"].terms(),
|
||||
blacklist=self._pro_term_lists["blacklist"].terms(),
|
||||
stopwords=self._pro_term_lists["stopwords"].terms(),
|
||||
)
|
||||
|
||||
def _pro_unique_key(self, base: str) -> str:
|
||||
from gui_v6.profile_editor import slug_for_copy
|
||||
|
||||
existing = {c["key"] for c in self._pro_choices()}
|
||||
if base not in existing:
|
||||
return base
|
||||
return slug_for_copy(base, existing)
|
||||
|
||||
def _pro_new(self) -> None:
|
||||
from gui_v6.profile_editor import build_profile_spec, save_profile
|
||||
|
||||
key = self._pro_unique_key("nouveau_profil")
|
||||
spec = build_profile_spec(label="Nouveau profil")
|
||||
try:
|
||||
save_profile(key, spec, path=self._profiles_path)
|
||||
except Exception as exc: # pragma: no cover
|
||||
messagebox.showerror("Profils", f"Impossible de créer le profil : {exc}")
|
||||
return
|
||||
self._pro_refresh_and_load(select_key=key)
|
||||
|
||||
def _pro_duplicate(self) -> None:
|
||||
from gui_v6.profile_editor import save_profile, slug_for_copy
|
||||
|
||||
if not self._pro_edit_key:
|
||||
return
|
||||
existing = {c["key"] for c in self._pro_choices()}
|
||||
new_key = slug_for_copy(self._pro_edit_key, existing)
|
||||
spec = self._pro_collect_spec()
|
||||
spec["label"] = f"{spec['label']} (copie)"
|
||||
try:
|
||||
save_profile(new_key, spec, path=self._profiles_path)
|
||||
except Exception as exc: # pragma: no cover
|
||||
messagebox.showerror("Profils", f"Impossible de dupliquer : {exc}")
|
||||
return
|
||||
self._pro_refresh_and_load(select_key=new_key)
|
||||
|
||||
def _pro_save(self) -> None:
|
||||
from gui_v6.profile_editor import save_profile
|
||||
|
||||
key = self._pro_edit_key
|
||||
if not key:
|
||||
return
|
||||
spec = self._pro_collect_spec()
|
||||
try:
|
||||
save_profile(key, spec, path=self._profiles_path)
|
||||
except Exception as exc: # pragma: no cover
|
||||
messagebox.showerror("Profils", f"Impossible d'enregistrer le profil : {exc}")
|
||||
return
|
||||
self._pro_refresh_and_load(select_key=key)
|
||||
# Confirmation non bloquante (pas de modale qui fige l'app).
|
||||
self._pro_status.configure(text=f"✓ Profil « {spec['label']} » enregistré.")
|
||||
|
||||
def _pro_cancel(self) -> None:
|
||||
if self._pro_edit_key:
|
||||
self._pro_load(self._pro_edit_key)
|
||||
|
||||
def _pro_set_default(self) -> None:
|
||||
from gui_v6.profile_editor import set_default_profile
|
||||
|
||||
if not self._pro_edit_key:
|
||||
return
|
||||
try:
|
||||
set_default_profile(self._pro_edit_key, path=self._profiles_path)
|
||||
except Exception as exc: # pragma: no cover
|
||||
messagebox.showerror("Profils", f"Impossible de définir par défaut : {exc}")
|
||||
return
|
||||
self._pro_refresh_and_load(select_key=self._pro_edit_key)
|
||||
|
||||
def _open_profile_editor(self) -> None:
|
||||
"""Ouvre le sous-onglet Profils sur le profil actif (depuis Réglages)."""
|
||||
self._show_sub("pro")
|
||||
active = self._state.profile
|
||||
if active:
|
||||
self._pro_refresh_and_load(select_key=active)
|
||||
|
||||
# -- Masquage ---------------------------------------------------------
|
||||
|
||||
def _build_masquage(self, parent) -> None:
|
||||
|
||||
Reference in New Issue
Block a user