[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:
+19
-1
@@ -125,10 +125,28 @@ jobs:
|
||||
- name: Tests générateur de plan User Permission
|
||||
run: python3 -m unittest discover -s tests -v
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Générateur de Role Profile (Sprint 2 · ERPNext Backend) : le contrat
|
||||
# rbac_50_roles.json → un `Role Profile` ERPNext v15 natif par portail (bundle
|
||||
# de rôles assignable en un geste). Valide le bundle contre
|
||||
# roleprofile.schema.json + invariants (couverture bijective des 50 rôles, un
|
||||
# profil par portail, cohérence portail, anti-invention). stdlib pur.
|
||||
# --------------------------------------------------------------------------
|
||||
rbac-roleprofile-tests:
|
||||
name: RBAC · Role Profile (bundles par portail)
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: 05_deliverables_mvp/rbac/roleprofile_gen
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Tests générateur de Role Profile
|
||||
run: python3 -m unittest discover -s tests -v
|
||||
|
||||
gate:
|
||||
name: Gate qualité (agrégat)
|
||||
runs-on: ubuntu-latest
|
||||
needs: [constraints-guard, validate-json, check-docs, publiciste-tests, rbac-tests, faisabilite-gen-tests, rbac-fixtures-tests, rbac-userperm-tests]
|
||||
needs: [constraints-guard, validate-json, check-docs, publiciste-tests, rbac-tests, faisabilite-gen-tests, rbac-fixtures-tests, rbac-userperm-tests, rbac-roleprofile-tests]
|
||||
steps:
|
||||
- name: Résultat
|
||||
run: echo "✅ Gate qualité 4Big franchi — tous les checks verts."
|
||||
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user