[DTP-Worker] Sprint 2 · Générateur Role Profile par portail (RBAC 50 rôles → 6 bundles assignables ERPNext v15)
- roleprofile_gen/ : profilelib (frappe Role Profile + Has Role natif v15, builder déterministe), roleprofile.schema.json, CLI build/validate refusant d'écrire si invariant cassé, 11 tests stdlib, README, .gitignore (out/). - 6 profils couvrant les 50 rôles de façon bijective (5 portails métier + console technique plateforme). Aucun DocType custom (que du natif). - Anti-invention #6 : rôles 100 % issus du contrat, nom de profil = convention déterministe dérivée de la clé portail. - CI : job rbac-roleprofile-tests ajouté au gate (.gitea/workflows/ci.yml). - Doc : SPEC §7 ét.5 + encart livré. Daily report session 8. - Régression : 83 tests verts (72 + 11). Gate CI local vert. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
"""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],
|
||||
}
|
||||
Reference in New Issue
Block a user