[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:
Claude Code DTP Worker
2026-07-30 04:04:04 +00:00
parent b1b011ab81
commit 70e022ccd6
11 changed files with 879 additions and 2 deletions
@@ -0,0 +1,5 @@
"""Bibliothèque du générateur de `Role Profile` (bundles de rôles par portail).
`frappe.py` → connaissance ERPNext v15 native (DocTypes `Role Profile` / `Has Role`).
`builder.py` → construction déterministe du bundle depuis `rbac_50_roles.json`.
"""
@@ -0,0 +1,80 @@
"""Construction des `Role Profile` depuis `rbac_50_roles.json`.
Entrée : le contrat RBAC 50 rôles (déjà validé par `rbac.schema.json` dans le
job CI `rbac-tests`). Sortie : un « bundle » déterministe :
- role_profile → 1 fixture `Role Profile` par portail distinct du contrat,
chacun bundlant les `erpnext_role_name` de ses rôles.
- manifest → traçabilité (comptes, portails métier vs technique, mapping
portail → profil), 100 % dérivé du contrat (contrainte #6).
Le contrat porte 6 portails distincts (5 métier listés dans `portails_business`
+ la console technique `plateforme`). Chaque rôle a EXACTEMENT un `portail` →
la couverture des 50 rôles par les profils est bijective (invariant vérifié par
le CLI). La source de vérité des comptes est le JSON, pas la doc (SPEC §3).
Déterminisme : profils triés par clé de portail, rôles triés par nom Frappe →
sortie reproductible bit-à-bit à contrat constant (diffable, re-générable en CI
sans horodatage volatil).
"""
from __future__ import annotations
from typing import Any
from . import frappe
def _roles_by_portail(contract: dict) -> dict[str, list[str]]:
"""Regroupe les `erpnext_role_name` par `portail` (ordre trié, stable)."""
grouped: dict[str, list[str]] = {}
for r in contract["roles"]:
portail = r["portail"]
name = r["erpnext_role_name"]
grouped.setdefault(portail, []).append(name)
for portail in grouped:
names = grouped[portail]
if len(names) != len(set(names)):
raise ValueError(f"Rôle dupliqué dans le portail {portail!r}.")
names.sort()
return grouped
def build_bundle(contract: dict) -> dict[str, Any]:
"""Transforme le contrat RBAC en bundle de `Role Profile` déterministe."""
grouped = _roles_by_portail(contract)
portails_metier = set(contract.get("portails_business", []))
role_profiles: list[dict] = []
profiles_meta: list[dict] = []
for portail in sorted(grouped):
role_names = grouped[portail]
role_profiles.append(frappe.role_profile_fixture(portail, role_names))
profiles_meta.append(
{
"portail": portail,
"role_profile": frappe.role_profile_name(portail),
"metier": portail in portails_metier,
"nb_roles": len(role_names),
}
)
nb_metier = sum(1 for p in profiles_meta if p["metier"])
manifest = {
"generated_from": "rbac_50_roles.json",
"source_version": contract["version"],
"cible_rbac_roles": contract["cible_rbac_roles"],
"counts": {
"role_profiles": len(role_profiles),
"portails_metier": nb_metier,
"portails_techniques": len(role_profiles) - nb_metier,
"roles_couverts": sum(p["nb_roles"] for p in profiles_meta),
},
# Mapping portail → profil (métier vs console technique `plateforme`),
# pour que l'agent ERPNext assigne le bon profil à chaque utilisateur.
"profiles": profiles_meta,
}
return {
"manifest": manifest,
"role_profile": role_profiles,
}
@@ -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],
}