From d3189d5bb7cb8b4f6c414c7edd707b3974e295d9 Mon Sep 17 00:00:00 2001 From: Domi31tls Date: Mon, 29 Jun 2026 19:20:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(gui):=20rec=C3=A2bler=20import/export=20de?= =?UTF-8?q?=20configuration=20par=20email=20(P1-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- gui_v6/app.py | 2 +- gui_v6/config_share.py | 72 +++++++++++++++++++ gui_v6/tabs/tab_config.py | 65 +++++++++++++++--- tests/unit/test_gui_v6_config_share.py | 95 ++++++++++++++++++++++++++ 4 files changed, 225 insertions(+), 9 deletions(-) create mode 100644 gui_v6/config_share.py create mode 100644 tests/unit/test_gui_v6_config_share.py diff --git a/gui_v6/app.py b/gui_v6/app.py index a49e4c8..13b3130 100644 --- a/gui_v6/app.py +++ b/gui_v6/app.py @@ -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, diff --git a/gui_v6/config_share.py b/gui_v6/config_share.py new file mode 100644 index 0000000..c347ded --- /dev/null +++ b/gui_v6/config_share.py @@ -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 diff --git a/gui_v6/tabs/tab_config.py b/gui_v6/tabs/tab_config.py index 7221226..678eb04 100644 --- a/gui_v6/tabs/tab_config.py +++ b/gui_v6/tabs/tab_config.py @@ -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: diff --git a/tests/unit/test_gui_v6_config_share.py b/tests/unit/test_gui_v6_config_share.py new file mode 100644 index 0000000..f1e0989 --- /dev/null +++ b/tests/unit/test_gui_v6_config_share.py @@ -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"]