"""Modèle Frappe/ERPNext v15 : mapping `portail` RBAC → `Role Profile`. Pourquoi ce module en plus de `../fixtures_gen` et `../userperm_gen` ------------------------------------------------------------------- `fixtures_gen` répond à « QUELS verbes sur QUEL DocType » (Role + Custom DocPerm). `userperm_gen` répond à « SUR QUELLES LIGNES » (scope_donnees → User Permission). Il manque le **bundle assignable** : comment donner à un utilisateur, en UN seul geste, l'ensemble des rôles de SON portail ? ERPNext v15 le fait nativement avec le DocType `Role Profile` (contrainte #1 « ERPNext natif = priorité absolue » : on n'invente aucun mécanisme de regroupement externe). Référence Frappe (v15, `frappe/core/doctype/role_profile`) : - DocType `Role Profile` : champ `role_profile` (Data, nom du profil, unique) + table `roles` (child `Has Role`). Assigné à un utilisateur via le champ `User.role_profile_name` (ou la table `role_profiles`) → applique d'un coup tous les rôles du profil (comportement natif v15). - Child `Has Role` : un champ `role` (Link → `Role`) par ligne. Frappe auto-nomme la ligne et pose `parent`/`idx` à l'import → on ne les invente pas. Anti-invention (#6) : les rôles proviennent tous du contrat `rbac_50_roles.json` (champ `erpnext_role_name`). Le NOM du profil est une convention de nommage déterministe dérivée de la clé `portail` (aucun chiffre ni libellé métier fabriqué), documentée ci-dessous. Ce module ne touche JAMAIS le VPS : il produit des dicts sérialisables. """ from __future__ import annotations from typing import Any # --------------------------------------------------------------------------- # # Convention de nommage du profil (non un chiffre métier — une étiquette stable # dérivée de la clé `portail`). Préfixe « OTO Portail » cohérent avec le préfixe # « OTO » déjà porté par les rôles du contrat (anti-collision desk natif). # --------------------------------------------------------------------------- # ROLE_PROFILE_PREFIX = "OTO Portail" # DocTypes ERPNext v15 natifs mobilisés (aucun DocType custom → rien à confirmer # côté VPS pour ce livrable, contrairement aux `Custom DocPerm`). ROLE_PROFILE_DOCTYPE = "Role Profile" HAS_ROLE_DOCTYPE = "Has Role" def role_profile_name(portail: str) -> str: """Nom Frappe déterministe d'un `Role Profile` pour une clé de portail. « ventes » → « OTO Portail Ventes ». La casse de titre est purement cosmétique et reproductible ; la clé brute reste tracée dans le manifeste. """ if not portail: raise ValueError("portail vide : impossible de nommer le Role Profile.") return f"{ROLE_PROFILE_PREFIX} {portail.capitalize()}" def has_role_row(erpnext_role_name: str) -> dict[str, Any]: """Construit une ligne enfant `Has Role` (référence un `Role` existant). `parent`/`idx`/`name` sont volontairement omis → Frappe les pose à l'import (on n'invente aucun identifiant de ligne). """ return { "doctype": HAS_ROLE_DOCTYPE, "parentfield": "roles", "parenttype": ROLE_PROFILE_DOCTYPE, "role": erpnext_role_name, } def role_profile_fixture( portail: str, role_names: list[str] ) -> dict[str, Any]: """Construit un enregistrement de fixture `Role Profile` avec ses `Has Role`. `role_names` est supposé déjà trié (déterminisme géré par le builder). Le champ `name` = `role_profile` (Frappe nomme un Role Profile par son libellé). """ if not role_names: raise ValueError(f"portail {portail!r} sans rôle : Role Profile vide refusé.") name = role_profile_name(portail) return { "doctype": ROLE_PROFILE_DOCTYPE, "name": name, "role_profile": name, "roles": [has_role_row(r) for r in role_names], }