#!/usr/bin/env python3 """ Helpers partagés pour les profils métier. """ from __future__ import annotations from copy import deepcopy from pathlib import Path from typing import Any, Dict try: import yaml except Exception: yaml = None from config_defaults import CONFIG_DIR, deep_merge_dict DEFAULT_PROFILES_CONFIG_PATH = CONFIG_DIR / "profiles.default.yml" RUNTIME_PROFILES_CONFIG_PATH = CONFIG_DIR / "profiles.yml" _RUNTIME_PROFILES_OVERLAY_TEXT = """# Surcharge locale des profils métier. # Source de vérité : config/profiles.default.yml # Ne mettez ici que les écarts spécifiques à votre environnement. # # Exemples : # default_profile: chcb_strict # profiles: # mon_profil: # label: Mon profil # description: Surcharge locale # require_manual_mask: true # force_disable_vlm: true # preferred_manual_mask_template: chcb/formulaire.yml # param_lists: # whitelist_phrases: # - Document validé DIM # dictionaries_overlay: # blacklist: # force_mask_terms: # - MON_ETAB {} """ _FALLBACK_DEFAULT_PROFILES_TEXT = """version: 1 default_profile: standard_local profiles: standard_local: label: Standard local description: Profil par défaut pour les traitements internes. require_manual_mask: false force_disable_vlm: false dictionaries_overlay: {} chcb_strict: label: CHCB strict description: Profil conservateur pour le CHCB, orienté diffusion prudente. require_manual_mask: false force_disable_vlm: true dictionaries_overlay: blacklist: force_mask_terms: - CHCB - Centre Hospitalier de la Côte Basque - CENTRE HOSPITALIER DE LA COTE BASQUE partage_recherche: label: Partage recherche description: Profil externe strict. Le masque manuel est recommandé pour les formulaires répétitifs. require_manual_mask: true force_disable_vlm: true dictionaries_overlay: blacklist: force_mask_terms: - CHCB - Centre Hospitalier de la Côte Basque - CENTRE HOSPITALIER DE LA COTE BASQUE dossier_audit: label: Dossier audit description: Profil orienté traçabilité et reproductibilité. require_manual_mask: false force_disable_vlm: true dictionaries_overlay: {} demo: label: Démo description: Profil léger pour démonstration interne sur poste bureautique. require_manual_mask: false force_disable_vlm: true dictionaries_overlay: {} """ _FALLBACK_DEFAULT_PROFILES_DICT: Dict[str, Any] = { "version": 1, "default_profile": "standard_local", "profiles": { "standard_local": { "label": "Standard local", "description": "Profil par défaut pour les traitements internes.", "require_manual_mask": False, "force_disable_vlm": False, "dictionaries_overlay": {}, }, "chcb_strict": { "label": "CHCB strict", "description": "Profil conservateur pour le CHCB, orienté diffusion prudente.", "require_manual_mask": False, "force_disable_vlm": True, "dictionaries_overlay": { "blacklist": { "force_mask_terms": [ "CHCB", "Centre Hospitalier de la Côte Basque", "CENTRE HOSPITALIER DE LA COTE BASQUE", ], }, }, }, "partage_recherche": { "label": "Partage recherche", "description": ( "Profil externe strict. Le masque manuel est recommandé " "pour les formulaires répétitifs." ), "require_manual_mask": True, "force_disable_vlm": True, "dictionaries_overlay": { "blacklist": { "force_mask_terms": [ "CHCB", "Centre Hospitalier de la Côte Basque", "CENTRE HOSPITALIER DE LA COTE BASQUE", ], }, }, }, "dossier_audit": { "label": "Dossier audit", "description": "Profil orienté traçabilité et reproductibilité.", "require_manual_mask": False, "force_disable_vlm": True, "dictionaries_overlay": {}, }, "demo": { "label": "Démo", "description": "Profil léger pour démonstration interne sur poste bureautique.", "require_manual_mask": False, "force_disable_vlm": True, "dictionaries_overlay": {}, }, }, } def read_default_profiles_text() -> str: try: return DEFAULT_PROFILES_CONFIG_PATH.read_text(encoding="utf-8") except Exception: return _FALLBACK_DEFAULT_PROFILES_TEXT def read_runtime_profiles_overlay_text() -> str: return _RUNTIME_PROFILES_OVERLAY_TEXT def load_default_profiles_dict() -> Dict[str, Any]: text = read_default_profiles_text() if yaml is not None: try: loaded = yaml.safe_load(text) or {} if isinstance(loaded, dict): return loaded except Exception: pass return deepcopy(_FALLBACK_DEFAULT_PROFILES_DICT) def list_default_profile_keys() -> set[str]: data = load_default_profiles_dict() profiles = data.get("profiles", {}) or {} if not isinstance(profiles, dict): return set() return {str(key) for key in profiles} def load_runtime_profiles_overlay_dict(path: Path | None = None) -> Dict[str, Any]: target = Path(path) if path is not None else RUNTIME_PROFILES_CONFIG_PATH if not target.exists() or yaml is None: return {} try: loaded = yaml.safe_load(target.read_text(encoding="utf-8")) or {} if isinstance(loaded, dict): return loaded except Exception: pass return {} def load_effective_profiles_dict(path: Path | None = None) -> Dict[str, Any]: return deep_merge_dict( load_default_profiles_dict(), load_runtime_profiles_overlay_dict(path), ) def _normalize_string_list(values: Any) -> list[str]: if not isinstance(values, list): return [] normalized: list[str] = [] for value in values: text = str(value).strip() if text: normalized.append(text) return normalized def _normalize_param_lists(value: Any) -> Dict[str, list[str]]: if not isinstance(value, dict): return {} return { "whitelist_phrases": _normalize_string_list(value.get("whitelist_phrases", [])), "blacklist_force_mask_terms": _normalize_string_list( value.get("blacklist_force_mask_terms", []) ), "additional_stopwords": _normalize_string_list(value.get("additional_stopwords", [])), } def _write_runtime_profiles_overlay_dict(path: Path, data: Dict[str, Any]) -> Path: if yaml is None: raise RuntimeError("PyYAML indisponible") body = yaml.safe_dump( data or {}, allow_unicode=True, default_flow_style=False, sort_keys=False, ) header = ( "# Surcharge locale des profils métier.\n" "# Source de vérité : config/profiles.default.yml\n" "# Les profils créés depuis la GUI sont enregistrés ici.\n" ) path.write_text(header + "\n" + body, encoding="utf-8") return path def ensure_runtime_profiles_config(path: Path | None = None) -> Path: target = Path(path) if path is not None else RUNTIME_PROFILES_CONFIG_PATH if not target.exists(): target.parent.mkdir(parents=True, exist_ok=True) target.write_text(read_runtime_profiles_overlay_text(), encoding="utf-8") return target def list_effective_profiles(path: Path | None = None) -> Dict[str, Dict[str, Any]]: data = load_effective_profiles_dict(path) profiles = data.get("profiles", {}) or {} if not isinstance(profiles, dict): return {} normalized: Dict[str, Dict[str, Any]] = {} for key, value in profiles.items(): if not isinstance(value, dict): continue raw_param_lists = value.get("param_lists") has_param_lists = isinstance(raw_param_lists, dict) preferred_manual_mask_template = str(value.get("preferred_manual_mask_template") or "").strip() normalized[str(key)] = { "label": str(value.get("label") or key), "description": str(value.get("description") or ""), "require_manual_mask": bool(value.get("require_manual_mask", False)), "force_disable_vlm": bool(value.get("force_disable_vlm", False)), "dictionaries_overlay": deepcopy(value.get("dictionaries_overlay") or {}), "param_lists": _normalize_param_lists(raw_param_lists), "has_param_lists": has_param_lists, "preferred_manual_mask_template": preferred_manual_mask_template, "has_preferred_manual_mask_template": "preferred_manual_mask_template" in value, } return normalized def get_default_profile_key(path: Path | None = None) -> str: data = load_effective_profiles_dict(path) key = str(data.get("default_profile") or "").strip() profiles = list_effective_profiles(path) if key and key in profiles: return key if profiles: return next(iter(profiles)) return "standard_local" def save_runtime_profile( profile_key: str, profile_spec: Dict[str, Any], path: Path | None = None, *, set_default: bool = False, ) -> Path: target = ensure_runtime_profiles_config(path) data = load_runtime_profiles_overlay_dict(target) if not isinstance(data, dict): data = {} profiles = data.get("profiles") if not isinstance(profiles, dict): profiles = {} data["profiles"] = profiles normalized_spec: Dict[str, Any] = { "label": str(profile_spec.get("label") or profile_key), "description": str(profile_spec.get("description") or ""), "require_manual_mask": bool(profile_spec.get("require_manual_mask", False)), "force_disable_vlm": bool(profile_spec.get("force_disable_vlm", False)), "dictionaries_overlay": deepcopy(profile_spec.get("dictionaries_overlay") or {}), } if profile_spec.get("has_param_lists") or "param_lists" in profile_spec: normalized_spec["param_lists"] = _normalize_param_lists(profile_spec.get("param_lists")) if ( profile_spec.get("has_preferred_manual_mask_template") or "preferred_manual_mask_template" in profile_spec ): normalized_spec["preferred_manual_mask_template"] = str( profile_spec.get("preferred_manual_mask_template") or "" ).strip() profiles[str(profile_key)] = normalized_spec if set_default: data["default_profile"] = str(profile_key) return _write_runtime_profiles_overlay_dict(target, data) def set_runtime_default_profile(profile_key: str, path: Path | None = None) -> Path: target = ensure_runtime_profiles_config(path) data = load_runtime_profiles_overlay_dict(target) if not isinstance(data, dict): data = {} data["default_profile"] = str(profile_key) return _write_runtime_profiles_overlay_dict(target, data) def delete_runtime_profile(profile_key: str, path: Path | None = None) -> Path: target = ensure_runtime_profiles_config(path) data = load_runtime_profiles_overlay_dict(target) if not isinstance(data, dict): data = {} profiles = data.get("profiles") if isinstance(profiles, dict): profiles.pop(str(profile_key), None) if not profiles: data.pop("profiles", None) if str(data.get("default_profile") or "").strip() == str(profile_key): data["default_profile"] = "standard_local" return _write_runtime_profiles_overlay_dict(target, data)