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,
|
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
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."
|
"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:
|
||||||
|
|||||||
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