feat(gui): recâbler import/export de configuration par email (P1-3)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 19:20:42 +02:00
parent 1d65d42430
commit d3189d5bb7
4 changed files with 225 additions and 9 deletions

View File

@@ -195,7 +195,7 @@ class AnonymisationApp(ctk.CTk):
usage_reporter=self._report_usage,
)
if key == "cfg":
return ConfigTab(self._content, palette=p, state=self._config)
return ConfigTab(self._content, palette=p, state=self._config, config_path=self._user_config_path)
return AboutTab(
self._content,
palette=p,

72
gui_v6/config_share.py Normal file
View File

@@ -0,0 +1,72 @@
"""Échange de configuration par fichier JSON (workflow email V5, P1-3).
- ``build_export_payload`` : produit le dict V5 (format consommé par
``scripts/merge_params.merge_params``) à partir des listes du profil courant ;
- ``import_config_file`` : fusionne un JSON reçu dans le ``dictionnaires.yml``
utilisateur, sans écraser l'existant.
Aucune dépendance à un widget : testable en pur Python.
"""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable
EXPORT_VERSION = "1"
def build_export_payload(
whitelist: Iterable[str], blacklist: Iterable[str], version: str = EXPORT_VERSION
) -> dict:
"""Construit la charge utile d'export au format consommé par merge_params."""
return {
"version": version,
"date_export": datetime.now(timezone.utc).isoformat(),
"whitelist_phrases": [str(t) for t in whitelist],
"blacklist_force_mask_terms": [str(t) for t in blacklist],
}
def _yaml_lists(config_path: Path) -> tuple[set, set]:
import yaml
cfg = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
wl = set(cfg.get("whitelist_phrases", []) or [])
bl = set((cfg.get("blacklist", {}) or {}).get("force_mask_terms", []) or [])
return wl, bl
def import_config_file(json_path, config_path) -> bool:
"""Fusionne ``json_path`` dans ``config_path`` (YAML). Retourne True si la
config a changé, False si rien de nouveau.
Fusion autonome (union des listes, jamais d'écrasement) — volontairement
SANS dépendance à ``scripts/merge_params`` (non bundlé en frozen). Même
sémantique : ``whitelist_phrases`` et ``blacklist.force_mask_terms``.
"""
import json
import yaml
json_path = Path(json_path)
config_path = Path(config_path)
before_wl, before_bl = _yaml_lists(config_path)
data = json.loads(json_path.read_text(encoding="utf-8"))
incoming_wl = {str(t).strip() for t in data.get("whitelist_phrases", []) if str(t).strip()}
incoming_bl = {str(t).strip() for t in data.get("blacklist_force_mask_terms", []) if str(t).strip()}
after_wl = before_wl | incoming_wl
after_bl = before_bl | incoming_bl
if after_wl == before_wl and after_bl == before_bl:
return False
cfg = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
cfg["whitelist_phrases"] = sorted(after_wl)
cfg.setdefault("blacklist", {})
cfg["blacklist"]["force_mask_terms"] = sorted(after_bl)
config_path.write_text(
yaml.dump(cfg, allow_unicode=True, default_flow_style=False, sort_keys=False),
encoding="utf-8",
)
return True

View File

@@ -170,12 +170,15 @@ _HELP_PROFIL_MOTS = (
"Pour de longues listes, utilisez le tableau des termes afin de rechercher et vérifier plus facilement."
)
_HELP_EXPORT_CONFIG = (
"Exporte uniquement les réglages de l'application : profils, listes locales, règles et style de masque.\n\n"
"Exporte vos deux listes locales — termes à toujours conserver et termes à toujours "
"masquer — dans un fichier .json à envoyer à un autre poste ou à l'administrateur.\n\n"
"Les documents patients, résultats d'anonymisation et audits ne sont pas exportés."
)
_HELP_IMPORT_CONFIG = (
"Importe des réglages reçus d'un administrateur ou d'un autre poste.\n\n"
"L'import ne lit pas de documents patients. Vérifiez toujours le profil actif après import."
"Importe les deux listes locales (termes à conserver, termes à masquer) reçues d'un "
"autre poste ou de l'administrateur. La fusion ajoute ces termes à votre configuration "
"sans rien écraser.\n\n"
"L'import ne lit pas de documents patients. Redémarrez l'application pour appliquer."
)
CONFIG_MOCKUP_SECTIONS = {
@@ -223,10 +226,11 @@ def _app_base_dir() -> Path:
class ConfigTab(ctk.CTkFrame):
def __init__(self, master, state: ConfigState | None = None, palette: dict | None = None, **kwargs):
def __init__(self, master, state: ConfigState | None = None, palette: dict | None = None, config_path: Path | 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._config_path = config_path
self._sub = "reg"
self._sub_buttons: dict[str, ctk.CTkButton] = {}
self._panels: dict[str, ctk.CTkFrame] = {}
@@ -868,16 +872,20 @@ class ConfigTab(ctk.CTkFrame):
help_text=_HELP_EXPORT_CONFIG, help_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))
self._note(export, "Vos listes locales : termes à toujours conserver et termes à toujours masquer.")
ui_kit.secondary_button(
export, p, "⬇ Exporter (.json)", command=self._on_export_config
).pack(anchor="w", padx=12, pady=(0, 12))
import_card = ui_kit.Card(
cols[1], p, title="📥 Importer une configuration",
help_text=_HELP_IMPORT_CONFIG, help_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))
self._note(import_card, "Fusionne les listes reçues (à conserver / à masquer) avec vos listes locales.")
ui_kit.secondary_button(
import_card, p, "⬆ Importer (.json)", command=self._on_import_config
).pack(anchor="w", padx=12, pady=(0, 12))
# -- helpers aide / maquette -----------------------------------------
@@ -942,6 +950,47 @@ class ConfigTab(ctk.CTkFrame):
def _on_manual_mask_template(self, label: str) -> None:
self._state.manual_mask_template = self._manual_mask_templates.get(label)
def _on_export_config(self) -> None:
from gui_v6.config_share import build_export_payload
import json
lists = getattr(self, "_pro_term_lists", {})
wl = lists["whitelist"].terms() if "whitelist" in lists else []
bl = lists["blacklist"].terms() if "blacklist" in lists else []
path = filedialog.asksaveasfilename(
title="Exporter la configuration", defaultextension=".json",
filetypes=[("Configuration JSON", "*.json")],
)
if not path:
return
try:
payload = build_export_payload(wl, bl)
Path(path).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
messagebox.showinfo("Export", f"Configuration exportée :\n{path}")
except Exception as exc: # pragma: no cover - chemin UI
messagebox.showerror("Export", f"Échec de l'export : {exc}")
def _on_import_config(self) -> None:
from gui_v6.config_share import import_config_file
if self._config_path is None:
messagebox.showerror("Import", "Aucune configuration cible résolue.")
return
path = filedialog.askopenfilename(
title="Importer une configuration",
filetypes=[("Configuration JSON", "*.json")],
)
if not path:
return
try:
changed = import_config_file(path, self._config_path)
if changed:
messagebox.showinfo("Import", "Configuration fusionnée. Redémarrez pour appliquer.")
else:
messagebox.showinfo("Import", "Rien de nouveau à fusionner.")
except Exception as exc: # pragma: no cover - chemin UI
messagebox.showerror("Import", f"Échec de l'import : {exc}")
def _pick_output(self) -> None:
path = filedialog.askdirectory(title="Dossier de sortie")
if path:

View File

@@ -0,0 +1,95 @@
"""Export / import de configuration (P1-3) — format compatible merge_params.
Pur : sérialisation/désérialisation et fusion, sans display ni filedialog.
"""
from __future__ import annotations
import json
from pathlib import Path
from gui_v6.config_share import build_export_payload, import_config_file
def test_export_payload_has_v5_schema():
payload = build_export_payload(
whitelist=["Dr Métier", "Service ORL"],
blacklist=["DUPONT"],
version="2026.06.29",
)
assert payload["version"] == "2026.06.29"
assert "date_export" in payload
assert payload["whitelist_phrases"] == ["Dr Métier", "Service ORL"]
assert payload["blacklist_force_mask_terms"] == ["DUPONT"]
def test_export_payload_is_json_serializable():
payload = build_export_payload(whitelist=["A"], blacklist=["B"], version="1")
json.dumps(payload) # ne doit pas lever
def test_import_merges_into_user_config(tmp_path, monkeypatch):
cfg = tmp_path / "dictionnaires.yml"
cfg.write_text("whitelist_phrases: [Existant]\n", encoding="utf-8")
incoming = tmp_path / "recu.json"
incoming.write_text(
json.dumps({
"version": "1", "date_export": "2026-06-29",
"whitelist_phrases": ["Nouveau"],
"blacklist_force_mask_terms": ["MASQUERMOI"],
}),
encoding="utf-8",
)
added = import_config_file(incoming, cfg)
assert added is True
import yaml
merged = yaml.safe_load(cfg.read_text(encoding="utf-8"))
assert "Existant" in merged["whitelist_phrases"]
assert "Nouveau" in merged["whitelist_phrases"]
assert "MASQUERMOI" in merged["blacklist"]["force_mask_terms"]
def test_import_returns_false_when_nothing_new(tmp_path):
cfg = tmp_path / "dictionnaires.yml"
cfg.write_text("whitelist_phrases: [Deja]\n", encoding="utf-8")
incoming = tmp_path / "recu.json"
incoming.write_text(
json.dumps({"whitelist_phrases": ["Deja"], "blacklist_force_mask_terms": []}),
encoding="utf-8",
)
assert import_config_file(incoming, cfg) is False
def test_import_preserves_unmanaged_yaml_keys(tmp_path):
# Une config riche : l'import ne doit toucher QUE whitelist/blacklist,
# et préserver toutes les autres sections (anti-perte de données).
cfg = tmp_path / "dictionnaires.yml"
cfg.write_text(
"version: 3\n"
"whitelist_phrases: [Existant]\n"
"blacklist:\n"
" force_mask_terms: [DEJA]\n"
" autre_sous_cle: [GARDER]\n"
"regex_overrides:\n"
" - rule_a\n"
"flags:\n"
" strict: true\n",
encoding="utf-8",
)
incoming = tmp_path / "recu.json"
incoming.write_text(
json.dumps({"whitelist_phrases": ["Nouveau"], "blacklist_force_mask_terms": ["MASQUERMOI"]}),
encoding="utf-8",
)
assert import_config_file(incoming, cfg) is True
import yaml
merged = yaml.safe_load(cfg.read_text(encoding="utf-8"))
# Clés non gérées intactes
assert merged["version"] == 3
assert merged["regex_overrides"] == ["rule_a"]
assert merged["flags"] == {"strict": True}
assert merged["blacklist"]["autre_sous_cle"] == ["GARDER"]
# Listes gérées fusionnées
assert "Existant" in merged["whitelist_phrases"]
assert "Nouveau" in merged["whitelist_phrases"]
assert "DEJA" in merged["blacklist"]["force_mask_terms"]
assert "MASQUERMOI" in merged["blacklist"]["force_mask_terms"]