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:
|
||||
|
||||
Reference in New Issue
Block a user