[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:
Claude Code DTP Worker
2026-07-30 03:03:12 +00:00
parent 911f70b9ab
commit d815c7ab63
11 changed files with 869 additions and 7 deletions
@@ -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