[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,93 @@
# Daily Report · 2026-07-30 · Claude Code DTP Worker (session 8)
**Session** : `20260730_035651`
## Tâche exécutée
**Sprint 2 · Livrable ERPNext Backend « Générateur de `Role Profile` par portail
(RBAC 50 rôles → bundles de rôles assignables) »**
(roadmap `04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md` §Sprint 2 « RBAC 50 rôles » ·
`RBAC_50_ROLES_SPEC.md` §7 ét. 5 · **prochaine tâche suggérée** par le daily
report session 7 : « générateur `Role Profile` par portail »).
## Contexte / analyse
- Relu `CLAUDE.md`, `ROADMAP_8_WEEKS_OR_LESS.md`, daily report session 7,
`rbac_50_roles.json` + `rbac.schema.json`, les deux générateurs existants
(`fixtures_gen` Role+DocPerm, `userperm_gen` row-level), le validateur maison
Publiciste, le patron CI.
- État Sprint 2 : contrat RBAC (s4), Publiciste (s3), Faisabilité (s5), fixtures
`Role`+`Custom DocPerm` (s6), plan `User Permission` row-level (s7) livrés.
Frontend/CRM dépendent de layouts LIVE (VPS) → hors périmètre worker.
- **Priorité évidente** (SPEC §7 + report s7) : le **bundle assignable** manquant.
`fixtures_gen` = « quels verbes sur quel DocType », `userperm_gen` = « sur
quelles lignes » ; il manquait « comment donner à un utilisateur tous les rôles
de son portail en un geste ». ERPNext v15 le fait nativement avec le DocType
`Role Profile`. 100 % autorable sans VPS/pip/API, même patron que les deux
générateurs précédents, sur le chemin critique de l'application RBAC (S4).
## Réalisé — module `05_deliverables_mvp/rbac/roleprofile_gen/`
- `profilelib/frappe.py` — modèle **Frappe/ERPNext v15 natif** : DocTypes
`Role Profile` + child `Has Role` (aucun DocType custom). Nom de profil =
**convention de nommage déterministe** `OTO Portail <Portail>` dérivée de la
clé `portail` (aucun libellé métier fabriqué, #6). Garde-fous : portail vide et
profil sans rôle → `ValueError`.
- `profilelib/builder.py``build_bundle()` **déterministe** : regroupe les 50
rôles par `portail` (profils triés par portail, `Has Role` triés par nom de
rôle) → **6 profils** + manifeste (comptes, flag métier vs technique, mapping
`portail → role_profile`).
- `roleprofile_gen.py` — CLI `build` / `validate`. **Refuse d'écrire** si un
invariant casse (schéma, couverture **bijective** des 50 rôles, un profil par
portail distinct, cohérence portail intra-profil, fidélité du manifeste,
cohérence des comptes, tris déterministes).
- `roleprofile.schema.json` — contrat de sortie draft-07 (sous-ensemble supporté
par le **validateur maison Publiciste**, zéro pip).
- `tests/test_roleprofile_gen.py`**11 tests `unittest` (stdlib pur)** : schéma
(maison + oracle `jsonschema` si présent), couverture bijective, un profil par
portail, cohérence portail, manifeste fidèle, anti-invention (que du natif v15),
déterminisme, garde-fous.
- **CI** : job `rbac-roleprofile-tests` ajouté au **gate** de
`.gitea/workflows/ci.yml` (Gitea Actions uniquement · #2).
- `README.md` + `.gitignore` (output `out/` non commité, re-généré à la demande).
- **Docs cousues** : `RBAC_50_ROLES_SPEC.md §7` (nouvelle ét. 5 + encart livré,
renumérotation de la vérification HTTP en ét. 6).
## Anti-invention (#6) appliqué
- 100 % des rôles (et leur portail) proviennent du contrat `rbac_50_roles.json`.
- **Que du natif ERPNext v15** : `Role Profile` + `Has Role` → aucun DocType
custom à confirmer côté VPS (contrainte #1 « ERPNext natif »).
- Le nom du profil est une convention déterministe dérivée de la clé `portail`,
pas un libellé métier inventé ; la clé brute reste tracée dans le manifeste.
- Le flag `metier` reflète strictement l'appartenance à `portails_business` du
contrat (les 6 portails = 5 métier + la console technique `plateforme`).
## Vérifications effectuées (en-repo, sans toucher au VPS)
- **11/11 tests verts** (schéma maison + oracle `jsonschema`). Génération réelle :
**6 profils** (Ventes 12, Construction 10, Direction 9, Compta 8, Achat 5 =
métier ; Plateforme 6 = technique), **50/50 rôles couverts** de façon bijective.
- **Gate CI local vert (exit 0)** : `guard_constraints.sh`, `validate_json.sh`,
`check_docs.sh` (0 lien cassé, mention score présente), YAML `ci.yml` valide.
- **Régression** : 23 Publiciste + 10 RBAC + 11 fixtures + 12 userperm + 16
Faisabilité + 11 roleprofile = **83 tests verts**.
## Note de cohérence documentaire
- Le tableau §3 du SPEC annonçait direction=10 / plateforme=5 ; le JSON réel porte
direction=9 / plateforme=6. Le SPEC note explicitement que **le JSON est la
source de vérité** → le générateur est data-driven (compte réel). Pas de
correction du tableau §3 (marqué « indicatif ») pour ne pas dévier du périmètre.
## Non fait (hors périmètre worker · VPS)
- Import `bench migrate` des fixtures + affectation `User.role_profile_name` par
utilisateur → agent ERPNext Backend (SPEC §7 ét. 3/5).
## Prochaine tâche suggérée
- ERPNext S2 : agrégateur `MANIFEST` unifié Role + DocPerm + User Permission +
Role Profile (vue/ordre d'application VPS unique), pour clore le volet RBAC.
- Ou Faisabilité S3 : `40_llm_outputs/` + rapports bancables FR/EN/ES.
- Frontend/CRM S2 : dépend des layouts LIVE (VPS) → hors périmètre worker.
---
**Auto-score 4Big du livrable Générateur de Role Profile : 96/100.**
Réserve 4 : import `bench` + affectation `role_profile_name` par utilisateur =
côté VPS (agent ERPNext, hors périmètre worker, #8). Validé statiquement en-repo
(11 tests verts + schéma conforme + couverture bijective fidèle au contrat + gate
CI vert · 83 tests de régression au total).
+12 -1
View File
@@ -136,7 +136,12 @@ cible côté serveur (`erpnext-backend-1`) :
par utilisateur assigné (champ `user` = sentinelle) après confirmation des
Companies (`MANIFEST.companies_a_confirmer`) et décision du mécanisme des
rôles `equipe` (`MANIFEST.roles_scope_equipe_a_confirmer`).
5. Vérification HTTP post-déploiement (workflow #3) + audit QA 4Big.
5. Générer les **`Role Profile`** (bundles de rôles par portail) — **livré** :
[`roleprofile_gen/`](roleprofile_gen/README.md) (`python3 roleprofile_gen.py
build`). Importer `role_profile.json` **après** les fixtures `Role` (étape 3),
puis affecter `User.role_profile_name` selon `MANIFEST.profiles` → tous les
rôles d'un portail appliqués en un geste.
6. Vérification HTTP post-déploiement (workflow #3) + audit QA 4Big.
> ✅ Le **générateur de fixtures** `rbac_50_roles.json → fixtures/` est **livré**
> en-repo ([`fixtures_gen/`](fixtures_gen/README.md), même patron que le
@@ -148,6 +153,12 @@ cible côté serveur (`erpnext-backend-1`) :
> templates `Company` (portée `entite`), portées `own`/`groupe` sans restriction
> fabriquée, portée `equipe` signalée (pas de dimension native → aucune
> invention #6). 12 tests + job CI `rbac-userperm-tests`.
>
> ✅ Les **`Role Profile`** (bundles de rôles par portail) sont **livrés** en-repo
> ([`roleprofile_gen/`](roleprofile_gen/README.md)) : **6 profils** (5 portails
> métier + console technique `plateforme`) couvrant les **50 rôles de façon
> bijective**, 100 % DocTypes natifs v15 (`Role Profile` + `Has Role`), aucun
> DocType custom à confirmer. 11 tests + job CI `rbac-roleprofile-tests`.
## 8. Vérification (en-repo, sans toucher au VPS)
@@ -0,0 +1,5 @@
# Artefacts de génération locale (jamais commités — produits à la demande par
# `python3 roleprofile_gen.py build`, re-générables en CI).
__pycache__/
*.pyc
out/
@@ -0,0 +1,103 @@
# Générateur de `Role Profile` · RBAC 50 rôles → bundles par portail
**Sprint 2 · agent ERPNext Backend.** Troisième et dernier maillon RBAC en-repo,
complément des générateurs [`Role` + `Custom DocPerm`](../fixtures_gen/README.md)
(quels verbes sur quel DocType) et [plan `User Permission`](../userperm_gen/README.md)
(sur quelles lignes). Il produit les **bundles assignables** : un `Role Profile`
ERPNext v15 **natif** par portail, qui permet d'attribuer à un utilisateur, en un
seul geste (`User.role_profile_name`), l'ensemble des rôles de SON portail.
Transforme le contrat [`../rbac_50_roles.json`](../rbac_50_roles.json) (validé par
[`../rbac.schema.json`](../rbac.schema.json)) en fixtures Frappe prêtes à importer.
> Ce worker **n'écrit jamais sur le VPS** (contrainte #8). Il produit un bundle de
> fixtures en-repo ; l'application réelle (`bench migrate`) reste côté serveur.
## Pourquoi un `Role Profile` (et pas juste des rôles)
Assigner à la main les 5 à 12 rôles d'un portail à chaque nouvel utilisateur est
fastidieux et source d'erreurs. ERPNext v15 fournit nativement le DocType
`Role Profile` (contrainte #1 « ERPNext natif = priorité absolue ») : un bundle
nommé de rôles (`Has Role`) qu'on affecte en un champ. Les **5 portails métier**
(roadmap Sprint 4 « 5 portails rôle ») + la **console technique `plateforme`**
donnent **6 profils** couvrant les 50 rôles de façon **bijective** (chaque rôle
dans exactement un profil, puisque chaque rôle a exactement un `portail`).
## Ce qui est généré (`out/`, non commité)
| Fichier | Rôle |
|---|---|
| `role_profile.json` | **1 fixture `Role Profile` par portail** ; chacun bundle ses rôles via des lignes enfant `Has Role`. |
| `MANIFEST.json` | Traçabilité : comptes (profils, métier vs technique, rôles couverts) + mapping `portail → role_profile`. |
## Profils générés (contrat courant · source de vérité = le JSON)
| `role_profile` | Portail | Type | Nb rôles |
|---|---|---|---|
| `OTO Portail Ventes` | ventes | métier | 12 |
| `OTO Portail Construction` | construction | métier | 10 |
| `OTO Portail Direction` | direction | métier | 9 |
| `OTO Portail Compta` | compta | métier | 8 |
| `OTO Portail Achat` | achat | métier | 5 |
| `OTO Portail Plateforme` | plateforme | technique | 6 |
| | | **Total** | **50** |
> Le nom du profil est une **convention de nommage déterministe** dérivée de la
> clé `portail` (`OTO Portail <Portail>`) — aucun libellé métier ni chiffre
> fabriqué (anti-invention #6). Les comptes viennent du contrat, pas de cette
> table (cf. SPEC §3 : le JSON reste la source de vérité).
## Utilisation
```bash
# Génère out/role_profile.json + out/MANIFEST.json
python3 roleprofile_gen.py build # [-o DOSSIER]
# Valide le bundle (schéma + invariants) sans rien écrire
python3 roleprofile_gen.py validate
# Tests (stdlib pur, zéro pip)
python3 -m unittest discover -s tests -v
```
## Garde-fous (anti-invention · #6 · déterminisme)
- **Couverture bijective** re-vérifiée : chaque rôle du contrat dans exactement
un profil, aucun rôle inventé, aucun manquant ; le CLI **refuse d'écrire** si un
invariant casse.
- **Que du natif v15** : DocTypes `Role Profile` + `Has Role` uniquement → aucun
DocType custom à confirmer côté VPS (contrairement aux `Custom DocPerm`).
- **Cohérence portail** : un profil ne mélange jamais deux portails ; son nom
correspond à la convention pour ce portail.
- **Fidélité manifeste** : flag `metier` = appartenance à `portails_business` ;
comptes re-vérifiés profil par profil.
- Sortie **déterministe** (profils triés par portail, `Has Role` triés par nom de
rôle, aucun horodatage) → diffable, re-générable bit-à-bit en CI.
## Application sur VPS (agent ERPNext Backend · hors périmètre worker)
1. Importer d'abord les fixtures `Role` ([`fixtures_gen`](../fixtures_gen/README.md))
— les `Has Role` de chaque profil référencent des `Role` qui doivent exister.
2. Déposer `role_profile.json` dans `fixtures/` de l'app OTO (`hooks.py`), puis
`bench --site frontend migrate`.
3. À la création d'un utilisateur, renseigner `role_profile_name` avec le profil
de son portail (mapping dans `MANIFEST.profiles`) → tous les rôles du portail
sont appliqués d'un coup, puis matérialiser les `User Permission` par
utilisateur ([`userperm_gen`](../userperm_gen/README.md)).
4. Vérification HTTP post-déploiement (workflow #3) + audit QA 4Big.
## Vérification en-repo
- `python3 -m unittest discover -s tests -v`**11/11 verts** (schéma maison +
oracle `jsonschema` si présent ; couverture bijective des 50 rôles, un profil
par portail, cohérence portail, manifeste fidèle, anti-invention, déterminisme).
- Génération réelle : **6 profils** (5 métier + 1 technique), **50/50 rôles**
couverts.
- Job CI dédié `rbac-roleprofile-tests` ajouté au **gate** (`.gitea/workflows/ci.yml`,
Gitea Actions uniquement · #2).
## Auto-score 4Big du livrable : **96/100**
_Réserve 4_ : l'import `bench` + l'affectation `role_profile_name` par
utilisateur restent côté VPS (agent ERPNext, hors périmètre worker, contrainte
#8). Validé statiquement en-repo (11 tests verts + schéma conforme + couverture
bijective fidèle au contrat + gate CI).
@@ -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],
}
@@ -0,0 +1,92 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://oto-enterprise-os-dtp/rbac/roleprofile.schema.json",
"title": "Bundle Role Profile généré depuis rbac_50_roles.json",
"description": "Contrat de sortie de roleprofile_gen.py. Validé par le validateur maison Publiciste (zéro pip) dans le job CI rbac-roleprofile-tests. Un Role Profile ERPNext v15 natif par portail distinct du contrat ; chaque profil bundle les rôles (Has Role) de son portail. Couverture bijective des 50 rôles.",
"type": "object",
"additionalProperties": false,
"required": ["manifest", "role_profile"],
"properties": {
"manifest": {
"type": "object",
"additionalProperties": false,
"required": [
"generated_from",
"source_version",
"cible_rbac_roles",
"counts",
"profiles"
],
"properties": {
"generated_from": { "type": "string", "const": "rbac_50_roles.json" },
"source_version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" },
"cible_rbac_roles": { "type": "integer", "const": 50 },
"counts": {
"type": "object",
"additionalProperties": false,
"required": [
"role_profiles",
"portails_metier",
"portails_techniques",
"roles_couverts"
],
"properties": {
"role_profiles": { "type": "integer", "minimum": 1 },
"portails_metier": { "type": "integer", "minimum": 0 },
"portails_techniques": { "type": "integer", "minimum": 0 },
"roles_couverts": { "type": "integer", "minimum": 50, "maximum": 50 }
}
},
"profiles": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/definitions/profile_meta" }
}
}
},
"role_profile": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/definitions/role_profile_fixture" }
}
},
"definitions": {
"profile_meta": {
"type": "object",
"additionalProperties": false,
"required": ["portail", "role_profile", "metier", "nb_roles"],
"properties": {
"portail": { "type": "string", "minLength": 1 },
"role_profile": { "type": "string", "pattern": "^OTO Portail " },
"metier": { "type": "boolean" },
"nb_roles": { "type": "integer", "minimum": 1 }
}
},
"role_profile_fixture": {
"type": "object",
"additionalProperties": false,
"required": ["doctype", "name", "role_profile", "roles"],
"properties": {
"doctype": { "type": "string", "const": "Role Profile" },
"name": { "type": "string", "pattern": "^OTO Portail " },
"role_profile": { "type": "string", "pattern": "^OTO Portail " },
"roles": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/definitions/has_role_row" }
}
}
},
"has_role_row": {
"type": "object",
"additionalProperties": false,
"required": ["doctype", "parentfield", "parenttype", "role"],
"properties": {
"doctype": { "type": "string", "const": "Has Role" },
"parentfield": { "type": "string", "const": "roles" },
"parenttype": { "type": "string", "const": "Role Profile" },
"role": { "type": "string", "pattern": "^OTO " }
}
}
}
}
@@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""Générateur de `Role Profile` par portail · RBAC 50 rôles (Sprint 2).
Troisième et dernier maillon RBAC en-repo, complément de `../fixtures_gen`
(Role + Custom DocPerm) et `../userperm_gen` (row-level) : il produit les
**bundles assignables** — un `Role Profile` ERPNext v15 natif par portail — qui
permettent à l'agent ERPNext d'attribuer à un utilisateur, en un seul geste,
tous les rôles de SON portail (champ `User.role_profile_name`).
Cible de portage VPS : agent ERPNext Backend (SPEC §7). Ce worker n'écrit JAMAIS
sur le VPS — il produit en-repo un bundle de fixtures que l'agent dépose dans
`fixtures/` de l'app OTO puis applique via `bench migrate`.
Transforme `rbac_50_roles.json` (contrat validé par `rbac.schema.json`) en :
- role_profile.json → 1 fixture `Role Profile` par portail (+ ses `Has Role`)
- MANIFEST.json → traçabilité : comptes, portails métier vs technique,
mapping portail → profil
Sous-commandes :
build [-o OUT] → écrit role_profile.json / MANIFEST.json
validate [-o OUT] → (re)génère en mémoire, valide vs roleprofile.schema.json
+ invariants — sort en erreur si un invariant casse.
Sortie déterministe (tri stable, aucun horodatage) → diffable + re-générable.
Anti-invention (#6) : 100 % des rôles proviennent du contrat ; le nom du profil
est une convention déterministe dérivée de la clé `portail` (aucun libellé
métier fabriqué) ; aucun DocType custom (que du natif v15).
"""
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 profilelib import builder, frappe # 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, "roleprofile.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 roleprofile.schema.json + invariants RBAC."""
schema = _load(_SCHEMA_PATH)
errors = list(maison.validate(bundle, schema))
manifest = bundle["manifest"]
profiles = bundle["role_profile"]
contract = _load(_CONTRACT_PATH)
contract_roles = {r["erpnext_role_name"] for r in contract["roles"]}
portail_of_role = {r["erpnext_role_name"]: r["portail"] for r in contract["roles"]}
portails_metier = set(contract.get("portails_business", []))
# Invariant 1 · cible non-négociable « RBAC 50 rôles » : couverture exhaustive.
if manifest["cible_rbac_roles"] != len(contract_roles):
errors.append(
f"cible_rbac_roles={manifest['cible_rbac_roles']} ≠ rôles contrat={len(contract_roles)}"
)
# Invariant 2 · couverture BIJECTIVE : chaque rôle du contrat dans EXACTEMENT
# un profil, aucun rôle inventé, aucun manquant.
assigned: list[str] = []
for prof in profiles:
for row in prof["roles"]:
assigned.append(row["role"])
if len(assigned) != len(set(assigned)):
errors.append("Un rôle apparaît dans plusieurs Role Profile (couverture non bijective).")
if set(assigned) != contract_roles:
missing = sorted(contract_roles - set(assigned))
extra = sorted(set(assigned) - contract_roles)
errors.append(f"Couverture ≠ contrat (manquants={missing}, en trop={extra}).")
# Invariant 3 · un profil par portail distinct du contrat, nom déterministe.
contract_portails = {r["portail"] for r in contract["roles"]}
seen_names: set[str] = set()
for prof in profiles:
name = prof["name"]
if name != prof["role_profile"]:
errors.append(f"{name}: name ≠ role_profile.")
if name in seen_names:
errors.append(f"Role Profile dupliqué : {name}.")
seen_names.add(name)
# Chaque rôle du profil appartient bien au même portail que le profil.
expected = {n: p for n, p in portail_of_role.items()}
portails_in_prof = {expected[row["role"]] for row in prof["roles"]}
if len(portails_in_prof) != 1:
errors.append(f"{name}: mélange de portails {sorted(portails_in_prof)}.")
else:
portail = portails_in_prof.pop()
if name != frappe.role_profile_name(portail):
errors.append(f"{name}: nom ≠ convention pour portail {portail!r}.")
# Invariant 4 · le manifeste liste EXACTEMENT les portails du contrat.
meta_portails = {m["portail"] for m in manifest["profiles"]}
if meta_portails != contract_portails:
errors.append(f"manifest.profiles portails {sorted(meta_portails)} ≠ contrat {sorted(contract_portails)}.")
# Invariant 5 · métadonnées fidèles (flag métier + comptes par profil).
nb_by_portail: dict[str, int] = {}
for prof in profiles:
p = portail_of_role[prof["roles"][0]["role"]]
nb_by_portail[p] = len(prof["roles"])
for m in manifest["profiles"]:
p = m["portail"]
if m["metier"] != (p in portails_metier):
errors.append(f"profil {p!r}: flag metier incohérent avec portails_business.")
if m["role_profile"] != frappe.role_profile_name(p):
errors.append(f"profil {p!r}: role_profile ≠ convention de nommage.")
if m["nb_roles"] != nb_by_portail.get(p):
errors.append(f"profil {p!r}: nb_roles {m['nb_roles']} ≠ réel {nb_by_portail.get(p)}.")
# Invariant 6 · cohérence des comptages agrégés du manifeste.
counts = manifest["counts"]
if counts["role_profiles"] != len(profiles):
errors.append("counts.role_profiles incohérent.")
if counts["roles_couverts"] != len(assigned):
errors.append("counts.roles_couverts incohérent avec les profils.")
nb_metier = sum(1 for m in manifest["profiles"] if m["metier"])
if counts["portails_metier"] != nb_metier:
errors.append("counts.portails_metier incohérent.")
if counts["portails_techniques"] != len(profiles) - nb_metier:
errors.append("counts.portails_techniques incohérent.")
# Invariant 7 · déterminisme : profils triés par portail, rôles triés par nom.
if [m["portail"] for m in manifest["profiles"]] != sorted(meta_portails):
errors.append("manifest.profiles non trié par portail (déterminisme).")
for prof in profiles:
role_seq = [row["role"] for row in prof["roles"]]
if role_seq != sorted(role_seq):
errors.append(f"{prof['name']}: Has Role non triés (déterminisme).")
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_profile.json"), bundle["role_profile"])
_write_json(os.path.join(out, "MANIFEST.json"), bundle["manifest"])
m = bundle["manifest"]
c = m["counts"]
print(f"✅ Role Profile générés dans {out}")
print(
f" role_profile.json : {c['role_profiles']} profils "
f"({c['portails_metier']} métier + {c['portails_techniques']} technique) "
f"· {c['roles_couverts']}/50 rôles couverts"
)
for meta in m["profiles"]:
tag = "métier" if meta["metier"] else "technique"
print(f" - {meta['role_profile']} : {meta['nb_roles']} rôles ({tag})")
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
c = bundle["manifest"]["counts"]
print(
f"✅ Validation OK — {c['role_profiles']} Role Profile, "
f"{c['roles_couverts']}/50 rôles couverts (bijectif), "
f"schéma + invariants verts."
)
return 0
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(
description="Générateur de Role Profile (bundles de rôles) depuis le contrat RBAC."
)
sub = p.add_subparsers(dest="cmd", required=True)
pb = sub.add_parser("build", help="génère role_profile.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())
@@ -0,0 +1,155 @@
"""Tests du générateur de Role Profile (stdlib `unittest`, zéro pip).
Réutilise le validateur maison Publiciste (workflow #5 · pas de duplication) et,
s'il est installé, l'oracle `jsonschema` en plus. Invariants couverts :
1. Le bundle valide contre roleprofile.schema.json (maison + oracle).
2. Couverture BIJECTIVE : chaque rôle du contrat dans exactement un profil.
3. Un profil par portail distinct du contrat ; nom = convention déterministe.
4. Cohérence portail : tous les rôles d'un profil partagent le même portail.
5. Manifeste fidèle : portails, flag métier (vs plateforme), comptes par profil.
6. Anti-invention : aucun rôle hors contrat, aucun DocType custom (que du natif v15).
7. Déterminisme : deux builds successifs bit-à-bit identiques + tris stables.
8. Garde-fous : portail vide et profil vide → ValueError.
9. Le CLI validate fonctionne de bout en bout.
"""
import importlib.util
import json
import os
import sys
import unittest
_HERE = os.path.dirname(os.path.abspath(__file__))
_GEN = os.path.normpath(os.path.join(_HERE, "..")) # roleprofile_gen/
_RBAC = os.path.normpath(os.path.join(_GEN, "..")) # rbac/
_DELIVERABLES = os.path.normpath(os.path.join(_RBAC, "..")) # 05_deliverables_mvp/
sys.path.insert(0, _GEN)
sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste"))
from profilelib import builder, frappe # noqa: E402
from lib import validator as maison # noqa: E402
_CONTRACT = os.path.join(_RBAC, "rbac_50_roles.json")
_SCHEMA = os.path.join(_GEN, "roleprofile.schema.json")
def _load(path):
with open(path, encoding="utf-8") as fh:
return json.load(fh)
def _load_cli():
spec = importlib.util.spec_from_file_location(
"roleprofile_gen", os.path.join(_GEN, "roleprofile_gen.py")
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
class RoleProfileGenTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.contract = _load(_CONTRACT)
cls.schema = _load(_SCHEMA)
cls.bundle = builder.build_bundle(cls.contract)
cls.profiles = cls.bundle["role_profile"]
cls.portail_of_role = {
r["erpnext_role_name"]: r["portail"] for r in cls.contract["roles"]
}
# 1 · conformité au schéma (validateur maison)
def test_bundle_valide_schema_maison(self):
errors = maison.validate(self.bundle, self.schema)
self.assertEqual(errors, [], f"Erreurs de schéma (maison) : {errors}")
# 1bis · oracle jsonschema si disponible
def test_bundle_valide_oracle_jsonschema(self):
try:
import jsonschema # noqa: PLC0415
except ImportError:
self.skipTest("jsonschema non installé — oracle ignoré")
jsonschema.validate(self.bundle, self.schema)
# 2 · couverture bijective des 50 rôles
def test_couverture_bijective_50_roles(self):
assigned = [row["role"] for p in self.profiles for row in p["roles"]]
self.assertEqual(len(assigned), 50)
self.assertEqual(len(assigned), len(set(assigned)), "Rôle dans plusieurs profils")
self.assertEqual(set(assigned), set(self.portail_of_role), "Couverture ≠ contrat")
# 3 · un profil par portail distinct, nom = convention déterministe
def test_un_profil_par_portail(self):
portails = {r["portail"] for r in self.contract["roles"]}
noms = {p["name"] for p in self.profiles}
self.assertEqual(len(self.profiles), len(portails))
self.assertEqual(
noms, {frappe.role_profile_name(p) for p in portails}
)
for p in self.profiles:
self.assertEqual(p["name"], p["role_profile"])
self.assertTrue(p["name"].startswith("OTO Portail "))
# 4 · cohérence portail : un profil ne mélange pas les portails
def test_coherence_portail(self):
for p in self.profiles:
portails = {self.portail_of_role[row["role"]] for row in p["roles"]}
self.assertEqual(len(portails), 1, f"{p['name']} mélange {portails}")
portail = portails.pop()
self.assertEqual(p["name"], frappe.role_profile_name(portail))
# 5 · manifeste fidèle (portails, flag métier, comptes)
def test_manifeste_fidele(self):
m = self.bundle["manifest"]
portails = {r["portail"] for r in self.contract["roles"]}
metier = set(self.contract["portails_business"])
self.assertEqual({x["portail"] for x in m["profiles"]}, portails)
nb_reel = {}
for p in self.profiles:
portail = self.portail_of_role[p["roles"][0]["role"]]
nb_reel[portail] = len(p["roles"])
for x in m["profiles"]:
self.assertEqual(x["metier"], x["portail"] in metier)
self.assertEqual(x["nb_roles"], nb_reel[x["portail"]])
self.assertEqual(x["role_profile"], frappe.role_profile_name(x["portail"]))
self.assertEqual(m["counts"]["roles_couverts"], 50)
self.assertEqual(m["counts"]["role_profiles"], len(self.profiles))
self.assertEqual(m["counts"]["portails_metier"], len(metier & portails))
# 6 · anti-invention : que du natif v15, aucun rôle hors contrat
def test_anti_invention(self):
for p in self.profiles:
self.assertEqual(p["doctype"], "Role Profile")
for row in p["roles"]:
self.assertEqual(row["doctype"], "Has Role")
self.assertIn(row["role"], self.portail_of_role, "Rôle hors contrat")
# 7 · déterminisme + tris stables
def test_deterministe(self):
a = json.dumps(builder.build_bundle(_load(_CONTRACT)))
b = json.dumps(builder.build_bundle(_load(_CONTRACT)))
self.assertEqual(a, b, "Génération non déterministe")
portails_seq = [x["portail"] for x in self.bundle["manifest"]["profiles"]]
self.assertEqual(portails_seq, sorted(portails_seq), "profils non triés")
for p in self.profiles:
roles_seq = [row["role"] for row in p["roles"]]
self.assertEqual(roles_seq, sorted(roles_seq), f"{p['name']}: rôles non triés")
# 8 · garde-fous
def test_portail_vide_rejete(self):
with self.assertRaises(ValueError):
frappe.role_profile_name("")
def test_profil_vide_rejete(self):
with self.assertRaises(ValueError):
frappe.role_profile_fixture("ventes", [])
# 9 · CLI validate de bout en bout
def test_cli_validate_ok(self):
cli = _load_cli()
self.assertEqual(cli.main(["validate"]), 0)
if __name__ == "__main__":
unittest.main(verbosity=2)