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, usage_reporter=self._report_usage,
) )
if key == "cfg": 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( return AboutTab(
self._content, self._content,
palette=p, 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." "Pour de longues listes, utilisez le tableau des termes afin de rechercher et vérifier plus facilement."
) )
_HELP_EXPORT_CONFIG = ( _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." "Les documents patients, résultats d'anonymisation et audits ne sont pas exportés."
) )
_HELP_IMPORT_CONFIG = ( _HELP_IMPORT_CONFIG = (
"Importe des réglages reçus d'un administrateur ou d'un autre poste.\n\n" "Importe les deux listes locales (termes à conserver, termes à masquer) reçues d'un "
"L'import ne lit pas de documents patients. Vérifiez toujours le profil actif après import." "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 = { CONFIG_MOCKUP_SECTIONS = {
@@ -223,10 +226,11 @@ def _app_base_dir() -> Path:
class ConfigTab(ctk.CTkFrame): 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) self._p = palette or theme_mod.get_palette(theme_mod.DEFAULT_THEME)
super().__init__(master, fg_color=self._p["bg"], **kwargs) super().__init__(master, fg_color=self._p["bg"], **kwargs)
self._state = state if state is not None else ConfigState() self._state = state if state is not None else ConfigState()
self._config_path = config_path
self._sub = "reg" self._sub = "reg"
self._sub_buttons: dict[str, ctk.CTkButton] = {} self._sub_buttons: dict[str, ctk.CTkButton] = {}
self._panels: dict[str, ctk.CTkFrame] = {} self._panels: dict[str, ctk.CTkFrame] = {}
@@ -868,16 +872,20 @@ class ConfigTab(ctk.CTkFrame):
help_text=_HELP_EXPORT_CONFIG, help_title="Exporter la configuration", help_text=_HELP_EXPORT_CONFIG, help_title="Exporter la configuration",
) )
export.pack(fill="both", expand=True) export.pack(fill="both", expand=True)
self._note(export, "Listes locales, règles admin, style de masquage et template actif.") self._note(export, "Vos listes locales : termes à toujours conserver et termes à toujours masquer.")
self._mockup_button(export, "⬇ Exporter (.json)").pack(anchor="w", padx=12, pady=(0, 12)) 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( import_card = ui_kit.Card(
cols[1], p, title="📥 Importer une configuration", cols[1], p, title="📥 Importer une configuration",
help_text=_HELP_IMPORT_CONFIG, help_title="Importer une configuration", help_text=_HELP_IMPORT_CONFIG, help_title="Importer une configuration",
) )
import_card.pack(fill="both", expand=True) import_card.pack(fill="both", expand=True)
self._note(import_card, "Fusionne la configuration reçue avec vos réglages locaux.") self._note(import_card, "Fusionne les listes reçues (à conserver / à masquer) avec vos listes locales.")
self._mockup_button(import_card, "⬆ Importer (.json)").pack(anchor="w", padx=12, pady=(0, 12)) 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 ----------------------------------------- # -- helpers aide / maquette -----------------------------------------
@@ -942,6 +950,47 @@ class ConfigTab(ctk.CTkFrame):
def _on_manual_mask_template(self, label: str) -> None: def _on_manual_mask_template(self, label: str) -> None:
self._state.manual_mask_template = self._manual_mask_templates.get(label) 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: def _pick_output(self) -> None:
path = filedialog.askdirectory(title="Dossier de sortie") path = filedialog.askdirectory(title="Dossier de sortie")
if path: 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"]