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:
@@ -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
72
gui_v6/config_share.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
95
tests/unit/test_gui_v6_config_share.py
Normal file
95
tests/unit/test_gui_v6_config_share.py
Normal 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"]
|
||||
Reference in New Issue
Block a user