[DTP-Worker] Sprint 2 · Générateur fixtures ERPNext (RBAC 50 rôles → Role + Custom DocPerm)
Transforme rbac_50_roles.json en fixtures Frappe/ERPNext v15 natives, prêtes à appliquer via bench (VPS · agent ERPNext). Réalise le prochain incrément annoncé au §7 de RBAC_50_ROLES_SPEC.md. Zéro pip, zéro VPS, sortie déterministe. - fixturelib/frappe.py : modèle natif (15 flags DocPerm, mapping 1:1 des verbes RBAC, if_owner⇔scope "own"). fixturelib/builder.py : bundle déterministe. - rbac_fixtures_gen.py : CLI build/validate (refuse d'écrire si invariant KO). - fixtures.schema.json : contrat de sortie (validateur maison Publiciste réutilisé). - 11 tests unittest : schéma+oracle, 50 rôles, séparation des pouvoirs, round-trip fidèle au contrat, déterminisme. Job CI rbac-fixtures-tests au gate. - Anti-invention #6 : 100% dérivé du contrat, flags non pilotés à 0, DocTypes custom signalés « à confirmer VPS ». Vérif : 11/11 verts + gate CI local vert (exit 0) + régression 60 tests OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
"""Bibliothèque de génération de fixtures ERPNext depuis le contrat RBAC.
|
||||
|
||||
Modules :
|
||||
- frappe : modèle natif Frappe (Role / Custom DocPerm) + mapping des verbes.
|
||||
- builder : transforme rbac_50_roles.json → bundle de fixtures déterministe.
|
||||
"""
|
||||
|
||||
from . import builder, frappe
|
||||
|
||||
__all__ = ["builder", "frappe"]
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Construction des fixtures ERPNext 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 contenant les deux
|
||||
fichiers de fixtures Frappe (`role`, `custom_docperm`) + un manifeste de
|
||||
traçabilité (contrainte #6 : tout provient du contrat, rien n'est inventé).
|
||||
|
||||
Déterminisme : tri stable (rôles par nom Frappe, DocPerms par (role, doctype,
|
||||
permlevel)) → la sortie est reproductible bit-à-bit à contrat constant, donc
|
||||
diffable et re-générable en CI sans horodatage volatil.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from . import frappe
|
||||
|
||||
|
||||
def _sorted_roles(contract: dict) -> list[dict]:
|
||||
return sorted(contract["roles"], key=lambda r: r["erpnext_role_name"])
|
||||
|
||||
|
||||
def build_bundle(contract: dict) -> dict[str, Any]:
|
||||
"""Transforme le contrat RBAC en bundle de fixtures Frappe déterministe."""
|
||||
roles = _sorted_roles(contract)
|
||||
|
||||
# --- 1. Fixtures Role (unicité garantie par le contrat, re-vérifiée) ------
|
||||
role_fixtures: list[dict] = []
|
||||
seen_roles: set[str] = set()
|
||||
for r in roles:
|
||||
name = r["erpnext_role_name"]
|
||||
if name in seen_roles:
|
||||
raise ValueError(f"Rôle Frappe dupliqué dans le contrat : {name!r}")
|
||||
seen_roles.add(name)
|
||||
role_fixtures.append(frappe.role_fixture(name))
|
||||
|
||||
# --- 2. Fixtures Custom DocPerm ------------------------------------------
|
||||
docperms: list[dict] = []
|
||||
custom_doctypes: set[str] = set()
|
||||
for r in roles:
|
||||
role_name = r["erpnext_role_name"]
|
||||
if_owner = r["scope_donnees"] == frappe.SCOPE_IF_OWNER
|
||||
# Tri des DocTypes cibles pour un ordre stable indépendant du contrat.
|
||||
for perm in sorted(r["permissions_cibles"], key=lambda p: p["doctype"]):
|
||||
doctype = perm["doctype"]
|
||||
if perm.get("custom") is True:
|
||||
custom_doctypes.add(doctype)
|
||||
docperms.append(
|
||||
frappe.docperm_fixture(
|
||||
doctype,
|
||||
role_name,
|
||||
perm["actions"],
|
||||
if_owner=if_owner,
|
||||
)
|
||||
)
|
||||
|
||||
docperms.sort(key=lambda d: (d["role"], d["parent"], d["permlevel"]))
|
||||
|
||||
# --- 3. Manifeste de traçabilité -----------------------------------------
|
||||
manifest = {
|
||||
"generated_from": "rbac_50_roles.json",
|
||||
"source_version": contract["version"],
|
||||
"cible_rbac_roles": contract["cible_rbac_roles"],
|
||||
"counts": {
|
||||
"role": len(role_fixtures),
|
||||
"custom_docperm": len(docperms),
|
||||
"doctypes_uniques": len({d["parent"] for d in docperms}),
|
||||
},
|
||||
# DocTypes DTP non natifs : à CRÉER + confirmer sur le VPS avant import
|
||||
# des DocPerms qui les ciblent (SPEC §6/§7). Jamais supposés existants.
|
||||
"custom_doctypes_a_confirmer": sorted(custom_doctypes),
|
||||
}
|
||||
|
||||
return {
|
||||
"manifest": manifest,
|
||||
"role": role_fixtures,
|
||||
"custom_docperm": docperms,
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user