#!/usr/bin/env python3 """Générateur de fixtures ERPNext · RBAC 50 rôles → Frappe fixtures (Sprint 2). Cible de portage VPS : agent ERPNext Backend (SPEC §7). Ce worker n'écrit JAMAIS sur le VPS — il produit en-repo les fichiers de fixtures que l'agent appliquera côté serveur via `bench --site frontend migrate` / `import-fixtures`. Transforme `rbac_50_roles.json` (contrat validé par `rbac.schema.json`) en : - role.json → fixtures DocType `Role` (50 rôles préfixés « OTO ») - custom_docperm.json → fixtures `Custom DocPerm` (permissions par DocType) - MANIFEST.json → traçabilité + DocTypes DTP `custom` à confirmer VPS Sous-commandes : build [-o OUT] → écrit role.json / custom_docperm.json / MANIFEST.json validate [-o OUT] → (re)génère en mémoire, valide vs fixtures.schema.json, vérifie les invariants (50 rôles, unicité, séparation des pouvoirs) — sort en erreur si un invariant casse. Sortie déterministe (tri stable, aucun horodatage) → diffable + re-générable. Anti-invention (#6) : 100 % des données proviennent du contrat ; les seuls ajouts sont des flags DocPerm natifs à 0 (défaut sûr) et des méta de traçabilité. """ from __future__ import annotations import argparse import json import os import sys _HERE = os.path.dirname(os.path.abspath(__file__)) _RBAC_DIR = os.path.normpath(os.path.join(_HERE, "..")) # rbac/ _DELIVERABLES = os.path.normpath(os.path.join(_RBAC_DIR, "..")) # 05_deliverables_mvp/ sys.path.insert(0, _HERE) # Réutilisation (workflow #5 : zéro duplication) du validateur maison Publiciste. sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste")) from fixturelib import builder # noqa: E402 from lib import validator as maison # type: ignore # noqa: E402 _CONTRACT_PATH = os.path.join(_RBAC_DIR, "rbac_50_roles.json") _SCHEMA_PATH = os.path.join(_HERE, "fixtures.schema.json") _DEFAULT_OUT = os.path.join(_HERE, "out") def _eprint(*args) -> None: print(*args, file=sys.stderr) def _load(path: str) -> dict: with open(path, encoding="utf-8") as fh: return json.load(fh) def _write_json(path: str, data) -> None: with open(path, "w", encoding="utf-8") as fh: json.dump(data, fh, ensure_ascii=False, indent=2) fh.write("\n") def _build_bundle() -> dict: return builder.build_bundle(_load(_CONTRACT_PATH)) def _validate_bundle(bundle: dict) -> list[str]: """Valide le bundle contre fixtures.schema.json + invariants métier RBAC.""" schema = _load(_SCHEMA_PATH) errors = list(maison.validate(bundle, schema)) manifest = bundle["manifest"] roles = bundle["role"] docperms = bundle["custom_docperm"] # Invariant 1 · cible non-négociable « RBAC 50 rôles ». if manifest["cible_rbac_roles"] != len(roles): errors.append( f"cible_rbac_roles={manifest['cible_rbac_roles']} ≠ role fixtures={len(roles)}" ) # Invariant 2 · unicité des noms de rôle Frappe (anti-collision). names = [r["role_name"] for r in roles] if len(names) != len(set(names)): errors.append("Noms de rôle Frappe dupliqués dans les fixtures Role.") # Invariant 3 · unicité (role, DocType, permlevel) côté DocPerm. keys = [(d["role"], d["parent"], d["permlevel"]) for d in docperms] if len(keys) != len(set(keys)): errors.append("DocPerm dupliqué pour un même (role, DocType, permlevel).") # Invariant 4 · séparation des pouvoirs (#6) : `set_user_permissions` # réservé au seul RBAC Admin (aucune élévation de privilège fixée en dur). contract = _load(_CONTRACT_PATH) admin_roles = { r["erpnext_role_name"] for r in contract["roles"] if r.get("famille") == "plateforme" and r.get("scope_donnees") == "groupe" } for d in docperms: if d["set_user_permissions"] == 1 and d["role"] not in admin_roles: errors.append( f"Élévation de privilège : {d['role']} porte set_user_permissions " f"sur {d['parent']} (réservé au RBAC Admin)." ) # Invariant 5 · cohérence de comptage du manifeste. if manifest["counts"]["role"] != len(roles): errors.append("counts.role incohérent avec le nombre de fixtures Role.") if manifest["counts"]["custom_docperm"] != len(docperms): errors.append("counts.custom_docperm incohérent avec les DocPerm.") return errors def cmd_build(args: argparse.Namespace) -> int: bundle = _build_bundle() errors = _validate_bundle(bundle) if errors: _eprint("❌ Bundle invalide — génération refusée (anti-régression) :") for e in errors: _eprint(f" - {e}") return 1 out = os.path.abspath(args.out) os.makedirs(out, exist_ok=True) _write_json(os.path.join(out, "role.json"), bundle["role"]) _write_json(os.path.join(out, "custom_docperm.json"), bundle["custom_docperm"]) _write_json(os.path.join(out, "MANIFEST.json"), bundle["manifest"]) m = bundle["manifest"] print(f"✅ Fixtures générées dans {out}") print(f" role.json : {m['counts']['role']} rôles") print( f" custom_docperm.json : {m['counts']['custom_docperm']} DocPerm " f"sur {m['counts']['doctypes_uniques']} DocTypes" ) if m["custom_doctypes_a_confirmer"]: print( " ⚠ DocTypes DTP `custom` à créer + confirmer VPS avant import : " + ", ".join(m["custom_doctypes_a_confirmer"]) ) return 0 def cmd_validate(args: argparse.Namespace) -> int: bundle = _build_bundle() errors = _validate_bundle(bundle) if errors: _eprint("❌ Validation KO :") for e in errors: _eprint(f" - {e}") return 1 m = bundle["manifest"] print( f"✅ Validation OK — {m['counts']['role']} rôles, " f"{m['counts']['custom_docperm']} DocPerm, schéma + invariants verts." ) return 0 def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser(description="Générateur de fixtures ERPNext depuis le contrat RBAC.") sub = p.add_subparsers(dest="cmd", required=True) pb = sub.add_parser("build", help="génère role.json / custom_docperm.json / MANIFEST.json") pb.add_argument("-o", "--out", default=_DEFAULT_OUT, help="dossier de sortie (défaut: ./out)") pb.set_defaults(func=cmd_build) pv = sub.add_parser("validate", help="valide le bundle (schéma + invariants) sans écrire") pv.add_argument("-o", "--out", default=_DEFAULT_OUT, help="ignoré (compat)") pv.set_defaults(func=cmd_validate) args = p.parse_args(argv) return args.func(args) if __name__ == "__main__": raise SystemExit(main())