refactor(gui): intégrer le Masquage dans Administration > Profils

Retour Dom : le sous-onglet Masquage séparé créait de la confusion. Le
masquage fait partie de la manière d'anonymiser associée au profil.

- Retrait du sous-onglet « Administration > Masquage » (_SUBTABS, builder,
  méthode _build_masquage).
- Section « Profils > Masquage » enrichie : masque manuel requis, template
  de masque (lié au profil édité), bouton « Ouvrir l'éditeur de masque »
  (fenêtre dédiée) + dossier des templates, et apparence du masque
  (couleur, style des marqueurs + aperçu, marges H/V, coins arrondis).
- Le template enregistré depuis l'éditeur remplit désormais le champ
  Template du profil (preferred_manual_mask_template via _pro_template_var).
- Profils devient le centre des réglages métier (général/masquage/mots/
  moteurs/règles). Réglages inchangé (pas de pastilles, pas de grosse
  refonte). Nettoyage du code mort (_REPLACEMENT_CODES, _HELP_MASQUAGE).

261 tests unit OK (0 régression), self-test OK, nav 4 sous-onglets + éditeur
de masque depuis Profils + thème OK. Préserve 72841ed/GO Qwen. Aucun build/
push sans GO Dom.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 10:24:49 +02:00
parent 72841ed7b3
commit d8bc0cd8c8
2 changed files with 65 additions and 141 deletions

View File

@@ -23,7 +23,6 @@ from manual_masking import ensure_mask_templates_dir, list_mask_templates, mask_
_SUBTABS = [ _SUBTABS = [
("reg", "⚙️ Réglages"), ("reg", "⚙️ Réglages"),
("pro", "👤 Profils"), ("pro", "👤 Profils"),
("msk", "🎭 Masquage"),
("shr", "🔄 Partage"), ("shr", "🔄 Partage"),
("rul", "🛡️ Règles"), ("rul", "🛡️ Règles"),
] ]
@@ -38,17 +37,6 @@ _DETECTION_OPTIONS = [
("N° adhérent mutuelle", "Identifiant local"), ("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 = [ _MASK_COLORS = [
("Noir", "#000000"), ("Noir", "#000000"),
("Bleu nuit", "#1a1a2e"), ("Bleu nuit", "#1a1a2e"),
@@ -68,14 +56,6 @@ _HELP_REGLAGES = (
"• Listes locales : vos termes à toujours masquer ou toujours conserver.\n\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." "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 = ( _HELP_PARTAGE = (
"À quoi sert le Partage ?\n\n" "À quoi sert le Partage ?\n\n"
"Il permet d'échanger les RÉGLAGES de l'application (listes de termes, règles, " "Il permet d'échanger les RÉGLAGES de l'application (listes de termes, règles, "
@@ -229,7 +209,6 @@ class ConfigTab(ctk.CTkFrame):
builders = { builders = {
"reg": self._build_reglages, "reg": self._build_reglages,
"pro": self._build_profils, "pro": self._build_profils,
"msk": self._build_masquage,
"shr": self._build_partage, "shr": self._build_partage,
"rul": self._build_regles, "rul": self._build_regles,
} }
@@ -496,7 +475,39 @@ class ConfigTab(ctk.CTkFrame):
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)) 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_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 = 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)) self._pro_template_entry.pack(fill="x", padx=12, pady=(0, 6))
mask_actions = ctk.CTkFrame(mask, fg_color="transparent")
mask_actions.pack(fill="x", padx=12, pady=(0, 6))
ui_kit.secondary_button(mask_actions, p, "🖊 Ouvrir l'éditeur de masque", command=self._open_full_mask_editor).pack(side="left")
ui_kit.secondary_button(mask_actions, p, "📁 Dossier", command=self._open_templates_dir).pack(side="left", padx=(6, 0))
# Apparence du masque (couleur / style / marges) — réglage global appliqué aux PDF.
ctk.CTkLabel(mask, text="Apparence du masque", text_color=p["text_muted"], font=ui_kit.font(11, "bold"), anchor="w").pack(fill="x", padx=12, pady=(6, 2))
swatches = ctk.CTkFrame(mask, fg_color="transparent")
swatches.pack(fill="x", padx=12, pady=(0, 6))
self._swatch_buttons: dict[str, ctk.CTkButton] = {}
for _label, color_value in _MASK_COLORS:
btn = ctk.CTkButton(
swatches, text="", width=28, height=24, 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
style_row = ctk.CTkFrame(mask, fg_color="transparent")
style_row.pack(fill="x", padx=12, pady=(0, 6))
for _label, value, preview in [("Crochets", "brackets", "[NOM]"), ("Étoiles", "stars", "***"), ("Noirci", "blackout", "████")]:
ctk.CTkRadioButton(
style_row, text=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(11),
).pack(side="left", padx=(0, 10))
self._slider_row(mask, "Marge H", self._mask_margin_x_var, self._on_mask_margin_x)
self._slider_row(mask, "Marge V", self._mask_margin_y_var, self._on_mask_margin_y)
self._mini_toggle(
mask, "Coins arrondis", "", value=self._state.mask_rounded_corners,
variable=self._mask_rounded_var, command=self._on_rounded_corners,
).pack(fill="x", padx=12, pady=(2, 12))
eng = ui_kit.Card(left, p, title="🧠 Moteurs") eng = ui_kit.Card(left, p, title="🧠 Moteurs")
eng.pack(fill="x") eng.pack(fill="x")
@@ -683,124 +694,6 @@ class ConfigTab(ctk.CTkFrame):
# -- Masquage --------------------------------------------------------- # -- 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 ------------------------------------------------- # -- Partage / Règles -------------------------------------------------
@@ -999,13 +892,22 @@ class ConfigTab(ctk.CTkFrame):
messagebox.showerror("Masques PDF", f"Impossible d'ouvrir l'éditeur : {exc}") messagebox.showerror("Masques PDF", f"Impossible d'ouvrir l'éditeur : {exc}")
def _on_mask_template_saved(self, path: Path) -> None: 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.""" """Callback déclenché par la fenêtre dédiée après sauvegarde d'un template.
Lie le template au profil en cours d'édition (`preferred_manual_mask_template`).
"""
self._refresh_manual_mask_templates() self._refresh_manual_mask_templates()
try: try:
self._manual_mask_var.set(mask_template_label(path, _app_base_dir())) self._manual_mask_var.set(mask_template_label(path, _app_base_dir()))
self._state.manual_mask_template = path self._state.manual_mask_template = path
except Exception: except Exception:
pass pass
# Renseigne le champ Template du profil édité (section Profils > Masquage).
if hasattr(self, "_pro_template_var"):
try:
self._pro_template_var.set(str(path))
except Exception:
pass
self._mask_status_text.set(f"Template enregistré : {path.name}") self._mask_status_text.set(f"Template enregistré : {path.name}")

View File

@@ -251,3 +251,25 @@ def test_usage_tab_finish_calls_reporter(ctk_root):
assert called.wait(timeout=3.0) # reporter appelé en thread daemon assert called.wait(timeout=3.0) # reporter appelé en thread daemon
assert captured["summary"] is summary assert captured["summary"] is summary
tab.destroy() tab.destroy()
def test_masquage_moved_into_profils(ctk_root, tmp_path, monkeypatch):
"""Le sous-onglet Masquage est retiré ; son contenu utile est dans Profils."""
from gui_v6.tabs import tab_config
keys = [k for k, _ in tab_config._SUBTABS]
assert "msk" not in keys # plus de sous-onglet Masquage séparé
monkeypatch.setattr(tab_config, "_app_base_dir", lambda: tmp_path)
tab = tab_config.ConfigTab(ctk_root)
tab._show_sub("pro")
tab.update_idletasks()
# apparence du masque relocalisée dans la section Profils > Masquage
assert getattr(tab, "_swatch_buttons", None)
# un template enregistré depuis l'éditeur remplit le champ Template du profil
saved = tmp_path / "config" / "mask_templates" / "depuis_editeur.json"
saved.parent.mkdir(parents=True, exist_ok=True)
tab._on_mask_template_saved(saved)
assert tab._pro_template_var.get().endswith("depuis_editeur.json")
tab.destroy()