"""Modèle Frappe/ERPNext v15 : mapping RBAC → fixtures `Role` + `Custom DocPerm`. Sépare la CONNAISSANCE FRAPPE (noms de champs, ordre des colonnes de permission, valeurs par défaut) de la logique de construction (`builder.py`). Aucun chiffre métier ici — uniquement la structure native ERPNext (contrainte #1 « ERPNext natif = priorité absolue » : on n'invente aucun modèle de permission externe). Références Frappe : - DocType `Role` → un rôle desk (préfixe « OTO » déjà porté par le contrat `rbac_50_roles.json`, anti-collision natif). - DocType `Custom DocPerm`→ personnalise les permissions d'un DocType existant SANS modifier le DocType lui-même (idéal pour appliquer par fixtures + `bench migrate`, réversible, ne casse pas les mises à jour d'app). Ce module ne touche JAMAIS le VPS : il produit des dicts sérialisables. """ from __future__ import annotations from typing import Any # --------------------------------------------------------------------------- # # Colonnes de permission d'un DocPerm Frappe, dans l'ordre natif du DocType. # Toutes défaut 0 ; on ne lève à 1 que les verbes déclarés dans le contrat RBAC. # --------------------------------------------------------------------------- # DOCPERM_FLAGS: tuple[str, ...] = ( "select", "read", "write", "create", "delete", "submit", "cancel", "amend", "report", "export", "import", "print", "email", "share", "set_user_permissions", ) # Verbes que le contrat RBAC (`permissions_cibles[].actions`) peut émettre. # TOUS correspondent 1:1 à un flag DocPerm natif ci-dessus → aucune traduction # arbitraire, aucune invention. Un verbe hors de cet ensemble = erreur de contrat. RBAC_ACTIONS: frozenset[str] = frozenset( { "read", "write", "create", "delete", "submit", "cancel", "report", "export", "print", "email", "share", "set_user_permissions", } ) # Verbes DocPerm jamais pilotés par le contrat RBAC (restent à 0) — documentés # pour prouver qu'aucun flag n'est activé « en douce ». NON_PILOTES: frozenset[str] = frozenset(set(DOCPERM_FLAGS) - RBAC_ACTIONS) # permlevel par défaut (0 = champs de base). Le contrat RBAC ne segmente pas par # niveau de champ → on reste au niveau 0 (pas d'invention de permlevel). DEFAULT_PERMLEVEL = 0 # `scope_donnees == "own"` → restriction Frappe native `if_owner` au niveau # DocPerm. Les autres portées (equipe/entite/groupe) relèvent des `User # Permission` par utilisateur (hors périmètre worker · appliqué VPS §7 du SPEC). SCOPE_IF_OWNER = "own" def role_fixture(erpnext_role_name: str) -> dict[str, Any]: """Construit un enregistrement de fixture `Role` Frappe minimal mais valide. `bench import-fixtures` complète les autres champs par leurs défauts DocType. On force `desk_access=1` (rôles back-office ERPNext) et `disabled=0`. """ return { "doctype": "Role", "name": erpnext_role_name, "role_name": erpnext_role_name, "desk_access": 1, "disabled": 0, "two_factor_auth": 0, "restrict_to_domain": None, } def docperm_fixture( doctype: str, role_name: str, actions: list[str], *, if_owner: bool, permlevel: int = DEFAULT_PERMLEVEL, ) -> dict[str, Any]: """Construit un enregistrement `Custom DocPerm` pour (DocType, Role). Les flags absents de `actions` restent à 0 (défaut sûr). `name` est volontairement omis → Frappe l'auto-nomme à l'import (hash déterministe côté serveur), ce qui évite d'inventer un identifiant ici. """ unknown = set(actions) - RBAC_ACTIONS if unknown: raise ValueError( f"Verbe(s) RBAC inconnu(s) pour {doctype}/{role_name} : {sorted(unknown)}" ) rec: dict[str, Any] = { "doctype": "Custom DocPerm", "parent": doctype, "parenttype": "DocType", "parentfield": "permissions", "role": role_name, "permlevel": permlevel, "if_owner": 1 if if_owner else 0, } wanted = set(actions) for flag in DOCPERM_FLAGS: rec[flag] = 1 if flag in wanted else 0 return rec