[DTP-Worker] Sprint 4 · Générateur Workspaces ERPNext 5 portails rôle (Frontend Console · roadmap L49)
Livre le dernier volet ouvert de Sprint 4 : Frontend Console « 5 portails (Ventes/Construction/Achat/Compta/Direction) ». Contrainte #1 (ERPNext natif) : le portail de landing par rôle EST le DocType Workspace v15 → 5 Workspaces natifs. Anti-invention (#6) : rôles et DocTypes dérivés du contrat rbac_50_roles.json ; chaque lien/raccourci vise un DocType présent dans les permissions_cibles du portail (droit prouvé) ; couverture exhaustive ; flag custom issu du contrat ; tokens de marque repris verbatim de CLAUDE.md #4. CLI + 12 invariants + 19 tests (4 négatifs). Régression 260 tests verts. Hand-off VPS #8 documenté. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
# Caches Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
# Le dossier out/ EST commite (hand-off ERPNext direct) — voir README.
|
||||
@@ -0,0 +1,100 @@
|
||||
# Générateur de Workspaces ERPNext · 5 portails rôle
|
||||
|
||||
**Sprint 4 · agent Frontend Console.** Livre le volet _« Frontend Console : 5
|
||||
portails (Ventes/Construction/Achat/Compta/Direction) »_ de la roadmap
|
||||
([`../../../04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md`](../../../04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md)
|
||||
§Sprint 4 l.49).
|
||||
|
||||
Transforme le contrat RBAC [`../../rbac/rbac_50_roles.json`](../../rbac/rbac_50_roles.json)
|
||||
+ la mise en page [`portails_spec.json`](portails_spec.json) en **fixtures Frappe
|
||||
`Workspace`** prêtes à importer : un portail rôle par entrée.
|
||||
|
||||
> 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 `Workspace` (et pas une page HTML externe)
|
||||
|
||||
Contrainte #1 « **ERPNext natif = priorité absolue avant tout outil externe** ».
|
||||
Dans ERPNext v15, le **portail de landing par rôle EST le DocType `Workspace`** :
|
||||
le desk affiche à chaque utilisateur les Workspaces dont il détient au moins un
|
||||
rôle autorisé (table enfant `roles`). On ne fabrique donc aucun framework de
|
||||
dashboard externe — on livre 5 Workspaces natifs, un par portail métier. Le thème
|
||||
**luxury dark+doré** (#4) s'applique par-dessus via la couche thème desk (voir
|
||||
_Hand-off_).
|
||||
|
||||
## Les 5 portails (métier)
|
||||
|
||||
| Portail | Workspace | Cartes | Liens | Rôles restreints |
|
||||
|---|---|---|---|---|
|
||||
| ventes | `OTO Ventes` | 4 | 11 | 12 |
|
||||
| construction | `OTO Construction` | 4 | 9 | 10 |
|
||||
| achat | `OTO Achat` | 3 | 8 | 5 |
|
||||
| compta | `OTO Compta` | 4 | 11 | 8 |
|
||||
| direction | `OTO Direction` | 4 | 14 | 9 |
|
||||
|
||||
> La console technique `plateforme` (6ᵉ portail RBAC) est **exclue** : la roadmap
|
||||
> demande 5 portails métier. Un invariant vérifie l'égalité stricte avec
|
||||
> `portails_business` du contrat.
|
||||
|
||||
## Anti-invention (#6) — comment c'est garanti
|
||||
|
||||
La **source de vérité** est le contrat RBAC, jamais la spec :
|
||||
|
||||
- **Aucun lien / raccourci inventé** : tout DocType visé par une carte ou un
|
||||
raccourci doit figurer dans les `permissions_cibles` du portail (le portail a
|
||||
donc **prouvablement** le droit dessus). Invariant #2/#3.
|
||||
- **Couverture exhaustive** : chaque DocType autorisé apparaît dans **exactement
|
||||
une** carte — aucun oubli silencieux, aucun doublon. Invariant #3.
|
||||
- **Rôles** dérivés du contrat (mêmes rôles que le [`Role Profile`](../../rbac/roleprofile_gen/README.md)
|
||||
du portail), triés. Invariant #4.
|
||||
- **Flag `custom`** (DocType OTO à créer vs natif v15) lu dans le **contrat**, pas
|
||||
dans la spec. Invariant #9.
|
||||
- **Aucun chiffre stocké** : les raccourcis type DocType affichent le **compteur
|
||||
live** calculé par le desk — rien n'est figé.
|
||||
- **Tokens de marque** (`#0a0a12`, `#f0b429`, Fraunces, Cormorant Garamond) repris
|
||||
**verbatim** de CLAUDE.md #4, chacun avec sa `source`. Invariant #11.
|
||||
|
||||
## Ce qui est généré (`out/`, commité comme hand-off)
|
||||
|
||||
| Fichier | Rôle |
|
||||
|---|---|
|
||||
| `workspace.json` | **1 fixture `Workspace` par portail** (tables `shortcuts` / `links` / `roles`). |
|
||||
| `MANIFEST.json` | Traçabilité : comptes, rôles couverts, DocTypes custom à créer, tokens de marque, note `module`. |
|
||||
|
||||
## Utilisation
|
||||
|
||||
```bash
|
||||
python3 workspaces_gen.py build # écrit out/workspace.json + out/MANIFEST.json
|
||||
python3 workspaces_gen.py validate # schéma + 12 invariants, sans écrire
|
||||
python3 -m unittest discover -s tests # 19 tests (stdlib pur, zéro pip)
|
||||
```
|
||||
|
||||
La génération est **refusée** si un invariant casse (anti-régression). Sortie
|
||||
**déterministe** (tri stable, aucun horodatage) → diffable, re-générable en CI.
|
||||
|
||||
## Hand-off → agent ERPNext Backend (VPS · #8)
|
||||
|
||||
1. **`Workspace.module`** est laissé à `null` (`a_confirmer`) : le fixer au module
|
||||
de l'app OTO au moment de l'import (le worker ne fabrique pas de nom d'app).
|
||||
2. **DocTypes custom à créer** avant import (sinon les liens pointent dans le
|
||||
vide) : `CONFOTUR Application`, `Faisabilité`, `Publiciste Log` (issus des
|
||||
modules OTOV7 du contrat). Les autres DocTypes sont **natifs v15**.
|
||||
3. Déposer `workspace.json` dans les `fixtures/` de l'app OTO puis `bench migrate`.
|
||||
4. **Thème luxury** : appliquer les tokens du `MANIFEST.brand` via la couche thème
|
||||
desk (Website Theme / CSS custom) — l'`indicator_color` / la couleur des
|
||||
raccourcis utilisent la palette native Frappe (mapping cosmétique documenté).
|
||||
|
||||
## Réutilisation (zéro duplication · #5)
|
||||
|
||||
- Validateur JSON-Schema **maison Publiciste** (`../../publiciste/lib/validator.py`)
|
||||
— pas de dépendance pip sur le runner Gitea.
|
||||
- Rôles et DocTypes **dérivés du contrat RBAC** — même source que les générateurs
|
||||
RBAC (`fixtures_gen`, `userperm_gen`, `roleprofile_gen`).
|
||||
|
||||
## Auto-score 4Big
|
||||
|
||||
**96/100.** ERPNext natif (#1), anti-invention traçable au contrat (#6), 5
|
||||
portails métier exacts, 19 tests (dont 4 négatifs prouvant que les invariants
|
||||
mordent), sortie déterministe, hand-off VPS explicite. Points non tenus par ce
|
||||
worker (hors périmètre #8) : création des DocTypes custom + application du thème
|
||||
desk + fixation du `module` à l'import.
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"generated_from": "rbac_50_roles.json (permissions_cibles + portail par rôle)",
|
||||
"spec_version": "1.0.0",
|
||||
"source_version": "1.0.0",
|
||||
"roadmap_ref": "04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md · Sprint 4 l.49 « Frontend Console : 5 portails (Ventes/Construction/Achat/Compta/Direction) »",
|
||||
"counts": {
|
||||
"workspaces": 5,
|
||||
"roles_couverts": 44,
|
||||
"liens_total": 53,
|
||||
"raccourcis_total": 20
|
||||
},
|
||||
"brand": {
|
||||
"note": "Tokens de marque luxury dark+doré (CLAUDE.md #4). Appliqués par la couche thème desk (Website Theme / CSS custom) déposée par l'agent Frontend sur le VPS — HORS de ce worker (#8). Repris ici pour le hand-off, jamais fabriqués.",
|
||||
"fond": {
|
||||
"valeur": "#0a0a12",
|
||||
"source": "CLAUDE.md #4"
|
||||
},
|
||||
"accent": {
|
||||
"valeur": "#f0b429",
|
||||
"source": "CLAUDE.md #4"
|
||||
},
|
||||
"polices": [
|
||||
{
|
||||
"valeur": "Fraunces",
|
||||
"source": "CLAUDE.md #4"
|
||||
},
|
||||
{
|
||||
"valeur": "Cormorant Garamond",
|
||||
"source": "CLAUDE.md #4"
|
||||
}
|
||||
]
|
||||
},
|
||||
"module_note": "Workspace.module laisse a None (a_confirmer) : l'agent ERPNext Backend le fixe au module de l'app OTO a l'import (#8). Le worker ne fabrique pas de nom d'app.",
|
||||
"doctypes_custom_a_creer": [
|
||||
"CONFOTUR Application",
|
||||
"Faisabilité",
|
||||
"Publiciste Log"
|
||||
],
|
||||
"workspaces": [
|
||||
{
|
||||
"portail": "ventes",
|
||||
"workspace": "OTO Ventes",
|
||||
"nb_cartes": 4,
|
||||
"nb_raccourcis": 4,
|
||||
"nb_liens": 11,
|
||||
"nb_roles": 12,
|
||||
"doctypes_custom": [
|
||||
"CONFOTUR Application",
|
||||
"Publiciste Log"
|
||||
]
|
||||
},
|
||||
{
|
||||
"portail": "construction",
|
||||
"workspace": "OTO Construction",
|
||||
"nb_cartes": 4,
|
||||
"nb_raccourcis": 4,
|
||||
"nb_liens": 9,
|
||||
"nb_roles": 10,
|
||||
"doctypes_custom": [
|
||||
"Faisabilité"
|
||||
]
|
||||
},
|
||||
{
|
||||
"portail": "achat",
|
||||
"workspace": "OTO Achat",
|
||||
"nb_cartes": 3,
|
||||
"nb_raccourcis": 4,
|
||||
"nb_liens": 8,
|
||||
"nb_roles": 5,
|
||||
"doctypes_custom": []
|
||||
},
|
||||
{
|
||||
"portail": "compta",
|
||||
"workspace": "OTO Compta",
|
||||
"nb_cartes": 4,
|
||||
"nb_raccourcis": 4,
|
||||
"nb_liens": 11,
|
||||
"nb_roles": 8,
|
||||
"doctypes_custom": []
|
||||
},
|
||||
{
|
||||
"portail": "direction",
|
||||
"workspace": "OTO Direction",
|
||||
"nb_cartes": 4,
|
||||
"nb_raccourcis": 4,
|
||||
"nb_liens": 14,
|
||||
"nb_roles": 9,
|
||||
"doctypes_custom": [
|
||||
"CONFOTUR Application",
|
||||
"Faisabilité"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"roadmap_ref": "04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md · Sprint 4 l.49 « Frontend Console : 5 portails (Ventes/Construction/Achat/Compta/Direction) »",
|
||||
"description": "Spec machine-lisible des 5 portails rôle rendus en Workspaces ERPNext v15 natifs. La source de vérité des DocTypes et des rôles reste le contrat RBAC (rbac_50_roles.json) : ce fichier ne fait QUE la mise en page (cartes, raccourcis, thème). Un invariant refuse tout DocType absent des permissions_cibles du portail (anti-invention #6) et impose la couverture exhaustive.",
|
||||
"generated_from": "rbac_50_roles.json (permissions_cibles + portail par rôle)",
|
||||
"brand": {
|
||||
"note": "Tokens de marque luxury dark+doré (CLAUDE.md #4). Appliqués par la couche thème desk (Website Theme / CSS custom) déposée par l'agent Frontend sur le VPS — HORS de ce worker (#8). Repris ici pour le hand-off, jamais fabriqués.",
|
||||
"fond": {"valeur": "#0a0a12", "source": "CLAUDE.md #4"},
|
||||
"accent": {"valeur": "#f0b429", "source": "CLAUDE.md #4"},
|
||||
"polices": [
|
||||
{"valeur": "Fraunces", "source": "CLAUDE.md #4"},
|
||||
{"valeur": "Cormorant Garamond", "source": "CLAUDE.md #4"}
|
||||
]
|
||||
},
|
||||
"palette_note": "Chaque portail porte un `accent_desk` = nom de couleur de la palette native Frappe (Workspace Shortcut.color / Workspace.indicator_color). C'est un libellé COSMÉTIQUE reproductible (le desk n'accepte pas un hex arbitraire par raccourci) ; le doré réel de la marque vit dans `brand.accent` et s'applique par le thème. Idem pour `icon` (jeu d'icônes desk Frappe v15).",
|
||||
"portails": [
|
||||
{
|
||||
"key": "ventes",
|
||||
"label": "OTO Ventes",
|
||||
"icon": "sell",
|
||||
"accent_desk": "Yellow",
|
||||
"sequence_id": 1,
|
||||
"raccourcis": ["Lead", "Opportunity", "Quotation", "Sales Order"],
|
||||
"cartes": [
|
||||
{"titre": "Pipeline commercial", "doctypes": ["Lead", "Opportunity", "Quotation", "Sales Order"]},
|
||||
{"titre": "Clients & encaissements", "doctypes": ["Customer", "Sales Invoice", "Payment Entry"]},
|
||||
{"titre": "Apres-vente & conformite", "doctypes": ["Issue", "CONFOTUR Application"]},
|
||||
{"titre": "Marketing & web", "doctypes": ["Publiciste Log", "Web Page"]}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "construction",
|
||||
"label": "OTO Construction",
|
||||
"icon": "project",
|
||||
"accent_desk": "Orange",
|
||||
"sequence_id": 2,
|
||||
"raccourcis": ["Project", "Task", "Faisabilité", "Quality Inspection"],
|
||||
"cartes": [
|
||||
{"titre": "Chantier & projet", "doctypes": ["Project", "Task", "Timesheet"]},
|
||||
{"titre": "Faisabilite & plans", "doctypes": ["Faisabilité", "File"]},
|
||||
{"titre": "Approvisionnement chantier", "doctypes": ["Material Request", "Purchase Order", "Item"]},
|
||||
{"titre": "Qualite", "doctypes": ["Quality Inspection"]}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "achat",
|
||||
"label": "OTO Achat",
|
||||
"icon": "buying",
|
||||
"accent_desk": "Cyan",
|
||||
"sequence_id": 3,
|
||||
"raccourcis": ["Material Request", "Request for Quotation", "Purchase Order", "Supplier"],
|
||||
"cartes": [
|
||||
{"titre": "Cycle achat", "doctypes": ["Material Request", "Request for Quotation", "Purchase Order", "Purchase Receipt"]},
|
||||
{"titre": "Fournisseurs", "doctypes": ["Supplier", "Contact"]},
|
||||
{"titre": "Stock & articles", "doctypes": ["Item", "Stock Entry"]}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "compta",
|
||||
"label": "OTO Compta",
|
||||
"icon": "accounting",
|
||||
"accent_desk": "Green",
|
||||
"sequence_id": 4,
|
||||
"raccourcis": ["Journal Entry", "Payment Entry", "Sales Invoice", "Purchase Invoice"],
|
||||
"cartes": [
|
||||
{"titre": "Grand livre", "doctypes": ["Account", "GL Entry", "Journal Entry", "Cost Center"]},
|
||||
{"titre": "Clients & fournisseurs", "doctypes": ["Sales Invoice", "Purchase Invoice", "Payment Entry"]},
|
||||
{"titre": "Tresorerie & budget", "doctypes": ["Bank Transaction", "Budget"]},
|
||||
{"titre": "Paie", "doctypes": ["Employee", "Salary Slip"]}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "direction",
|
||||
"label": "OTO Direction",
|
||||
"icon": "organization",
|
||||
"accent_desk": "Purple",
|
||||
"sequence_id": 5,
|
||||
"raccourcis": ["Faisabilité", "Sales Order", "Journal Entry", "Project"],
|
||||
"cartes": [
|
||||
{"titre": "Commercial (vue direction)", "doctypes": ["Lead", "Opportunity", "Quotation", "Sales Order", "Sales Invoice"]},
|
||||
{"titre": "Operations & projets", "doctypes": ["Project", "Task", "Faisabilité"]},
|
||||
{"titre": "Finance (vue direction)", "doctypes": ["Journal Entry", "Payment Entry", "Purchase Invoice", "Purchase Order"]},
|
||||
{"titre": "Legal & conformite", "doctypes": ["Contract", "CONFOTUR Application"]}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Tests du generateur de Workspaces (stdlib `unittest`, zero pip).
|
||||
|
||||
Reutilise le validateur maison Publiciste (workflow #5 · pas de duplication) et,
|
||||
s'il est installe, l'oracle `jsonschema` en plus. Invariants couverts :
|
||||
1. Le bundle valide contre workspace.schema.json (maison + oracle).
|
||||
2. Exactement les 5 portails METIER du contrat (plateforme exclue), ordre spec.
|
||||
3. ANTI-INVENTION (#6) : tout lien/raccourci vise un DocType autorise (permissions_cibles).
|
||||
4. Couverture EXHAUSTIVE + sans doublon des DocTypes autorises par les cartes.
|
||||
5. Restriction de visibilite = tous les roles du portail (contrat), tries.
|
||||
6. Table Links coherente (Card Break -> link_count == liens reels).
|
||||
7. Raccourcis : couleur = accent, ordre = spec ; compteurs desk (aucun chiffre stocke).
|
||||
8. Workspace.module reste None (a_confirmer, hand-off VPS #8).
|
||||
9. `content` (JSON de blocs) coherent avec raccourcis + cartes.
|
||||
10. Flag `custom` issu du CONTRAT (source unique).
|
||||
11. Tokens de marque = CLAUDE.md #4 (jamais fabriques).
|
||||
12. Determinisme : deux builds bit-a-bit identiques.
|
||||
13. NEGATIFS : les invariants mordent (invention, oubli de couverture, mauvais portail).
|
||||
"""
|
||||
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_GEN = os.path.normpath(os.path.join(_HERE, "..")) # portails/
|
||||
_FRONTEND = os.path.normpath(os.path.join(_GEN, "..")) # frontend/
|
||||
_DELIVERABLES = os.path.normpath(os.path.join(_FRONTEND, "..")) # 05_deliverables_mvp/
|
||||
_RBAC = os.path.join(_DELIVERABLES, "rbac")
|
||||
|
||||
sys.path.insert(0, _GEN)
|
||||
sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste"))
|
||||
|
||||
from wslib import builder, frappe # noqa: E402
|
||||
from lib import validator as maison # noqa: E402
|
||||
|
||||
_CONTRACT = os.path.join(_RBAC, "rbac_50_roles.json")
|
||||
_SPEC = os.path.join(_GEN, "portails_spec.json")
|
||||
_SCHEMA = os.path.join(_GEN, "workspace.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(
|
||||
"workspaces_gen", os.path.join(_GEN, "workspaces_gen.py")
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
class WorkspacesGenTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.contract = _load(_CONTRACT)
|
||||
cls.spec = _load(_SPEC)
|
||||
cls.schema = _load(_SCHEMA)
|
||||
cls.bundle = builder.build_bundle(cls.contract, cls.spec)
|
||||
cls.workspaces = cls.bundle["workspace"]
|
||||
cls.dt_map = builder.doctypes_by_portail(cls.contract)
|
||||
cls.roles_map = builder.roles_by_portail(cls.contract)
|
||||
cls.flags = builder.custom_flags(cls.contract)
|
||||
cls.cli = _load_cli()
|
||||
|
||||
# 1 --------------------------------------------------------------------
|
||||
def test_valide_schema_maison(self):
|
||||
self.assertEqual(maison.validate(self.bundle, self.schema), [])
|
||||
|
||||
def test_valide_schema_oracle(self):
|
||||
try:
|
||||
import jsonschema
|
||||
except ImportError:
|
||||
self.skipTest("jsonschema absent (oracle facultatif)")
|
||||
jsonschema.validate(self.bundle, self.schema)
|
||||
|
||||
def test_validate_bundle_reel_ok(self):
|
||||
# schema + 12 invariants du CLI, sur le bundle reel (disque).
|
||||
self.assertEqual(self.cli._validate_bundle(self.bundle), [])
|
||||
|
||||
# 2 --------------------------------------------------------------------
|
||||
def test_cinq_portails_metier_ordonnes(self):
|
||||
keys = [w["label"] for w in self.workspaces]
|
||||
self.assertEqual(len(keys), 5)
|
||||
spec_keys = [p["key"] for p in self.spec["portails"]]
|
||||
self.assertEqual(set(spec_keys), set(self.contract["portails_business"]))
|
||||
self.assertNotIn("plateforme", spec_keys) # console technique exclue
|
||||
|
||||
# 3 --------------------------------------------------------------------
|
||||
def test_anti_invention_liens_autorises(self):
|
||||
for p, w in zip(self.spec["portails"], self.workspaces):
|
||||
authorized = self.dt_map[p["key"]]
|
||||
for row in w["links"]:
|
||||
if row["type"] == "Link":
|
||||
self.assertIn(row["link_to"], authorized)
|
||||
for s in w["shortcuts"]:
|
||||
self.assertIn(s["link_to"], authorized)
|
||||
|
||||
# 4 --------------------------------------------------------------------
|
||||
def test_couverture_exhaustive(self):
|
||||
for p in self.spec["portails"]:
|
||||
dts = [dt for c in p["cartes"] for dt in c["doctypes"]]
|
||||
self.assertEqual(len(dts), len(set(dts)), f"{p['key']}: doublon")
|
||||
self.assertEqual(set(dts), self.dt_map[p["key"]], f"{p['key']}: couverture")
|
||||
|
||||
# 5 --------------------------------------------------------------------
|
||||
def test_roles_restriction(self):
|
||||
total = 0
|
||||
for p, w in zip(self.spec["portails"], self.workspaces):
|
||||
roles = [r["role"] for r in w["roles"]]
|
||||
self.assertEqual(roles, sorted(roles))
|
||||
self.assertEqual(set(roles), set(self.roles_map[p["key"]]))
|
||||
self.assertTrue(all(r["parenttype"] == "Workspace" for r in w["roles"]))
|
||||
total += len(roles)
|
||||
self.assertEqual(total, self.bundle["manifest"]["counts"]["roles_couverts"])
|
||||
|
||||
# 6 --------------------------------------------------------------------
|
||||
def test_links_card_break_coherent(self):
|
||||
for w in self.workspaces:
|
||||
rows = w["links"]
|
||||
self.assertEqual(rows[0]["type"], "Card Break")
|
||||
i = 0
|
||||
while i < len(rows):
|
||||
self.assertEqual(rows[i]["type"], "Card Break")
|
||||
declared = rows[i]["link_count"]
|
||||
j = i + 1
|
||||
seen = 0
|
||||
while j < len(rows) and rows[j]["type"] == "Link":
|
||||
seen += 1
|
||||
j += 1
|
||||
self.assertEqual(seen, declared)
|
||||
i = j
|
||||
|
||||
# 7 --------------------------------------------------------------------
|
||||
def test_shortcuts_accent_et_ordre(self):
|
||||
for p, w in zip(self.spec["portails"], self.workspaces):
|
||||
self.assertEqual([s["link_to"] for s in w["shortcuts"]], p["raccourcis"])
|
||||
for s in w["shortcuts"]:
|
||||
self.assertEqual(s["color"], p["accent_desk"])
|
||||
self.assertEqual(s["type"], "DocType")
|
||||
self.assertEqual(s["doc_view"], "List")
|
||||
# Aucun chiffre stocke (compteur calcule LIVE par le desk).
|
||||
self.assertNotIn("stats_filter", s)
|
||||
|
||||
# 8 --------------------------------------------------------------------
|
||||
def test_module_none(self):
|
||||
for w in self.workspaces:
|
||||
self.assertIsNone(w["module"])
|
||||
self.assertEqual(w["public"], 1)
|
||||
|
||||
# 9 --------------------------------------------------------------------
|
||||
def test_content_coherent(self):
|
||||
for p, w in zip(self.spec["portails"], self.workspaces):
|
||||
blocks = json.loads(w["content"])
|
||||
headers = [b for b in blocks if b["type"] == "header"]
|
||||
self.assertEqual(len(headers), 1)
|
||||
self.assertEqual(headers[0]["data"]["text"], p["label"])
|
||||
self.assertEqual(
|
||||
[b["data"]["shortcut_name"] for b in blocks if b["type"] == "shortcut"],
|
||||
p["raccourcis"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[b["data"]["card_name"] for b in blocks if b["type"] == "card"],
|
||||
[c["titre"] for c in p["cartes"]],
|
||||
)
|
||||
|
||||
# 10 -------------------------------------------------------------------
|
||||
def test_flag_custom_du_contrat(self):
|
||||
for m in self.bundle["manifest"]["workspaces"]:
|
||||
p = next(x for x in self.spec["portails"] if x["key"] == m["portail"])
|
||||
expected = sorted({
|
||||
dt for c in p["cartes"] for dt in c["doctypes"] if self.flags.get(dt)
|
||||
})
|
||||
self.assertEqual(m["doctypes_custom"], expected)
|
||||
self.assertEqual(
|
||||
self.bundle["manifest"]["doctypes_custom_a_creer"],
|
||||
sorted({"CONFOTUR Application", "Faisabilité", "Publiciste Log"}),
|
||||
)
|
||||
|
||||
# 11 -------------------------------------------------------------------
|
||||
def test_marque_claude_md(self):
|
||||
brand = self.bundle["manifest"]["brand"]
|
||||
self.assertEqual(brand["accent"]["valeur"], "#f0b429")
|
||||
self.assertEqual(brand["fond"]["valeur"], "#0a0a12")
|
||||
self.assertTrue(brand["accent"]["source"])
|
||||
|
||||
# 12 -------------------------------------------------------------------
|
||||
def test_determinisme(self):
|
||||
b1 = builder.build_bundle(_load(_CONTRACT), _load(_SPEC))
|
||||
b2 = builder.build_bundle(_load(_CONTRACT), _load(_SPEC))
|
||||
self.assertEqual(json.dumps(b1, sort_keys=True), json.dumps(b2, sort_keys=True))
|
||||
|
||||
# 13 · NEGATIFS ---------------------------------------------------------
|
||||
def _validate_with_spec(self, mutated_spec):
|
||||
"""Ecrit une spec mutee dans un tmp, y pointe le CLI, revalide."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
path = os.path.join(td, "spec.json")
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(mutated_spec, fh, ensure_ascii=False)
|
||||
orig = self.cli._SPEC_PATH
|
||||
self.cli._SPEC_PATH = path
|
||||
try:
|
||||
bundle = self.cli._build_bundle()
|
||||
return self.cli._validate_bundle(bundle)
|
||||
finally:
|
||||
self.cli._SPEC_PATH = orig
|
||||
|
||||
def test_negatif_doctype_invente(self):
|
||||
bad = copy.deepcopy(self.spec)
|
||||
bad["portails"][0]["cartes"][0]["doctypes"].append("DocType Bidon Invente")
|
||||
errs = self._validate_with_spec(bad)
|
||||
self.assertTrue(any("non autorise" in e for e in errs), errs)
|
||||
|
||||
def test_negatif_couverture_incomplete(self):
|
||||
bad = copy.deepcopy(self.spec)
|
||||
# retire un DocType d'une carte -> couverture non exhaustive.
|
||||
bad["portails"][0]["cartes"][0]["doctypes"].pop()
|
||||
errs = self._validate_with_spec(bad)
|
||||
self.assertTrue(any("couverture" in e for e in errs), errs)
|
||||
|
||||
def test_negatif_mauvais_ensemble_portails(self):
|
||||
bad = copy.deepcopy(self.spec)
|
||||
bad["portails"] = bad["portails"][:4] # 4 portails != 5 metier
|
||||
errs = self._validate_with_spec(bad)
|
||||
self.assertTrue(any("portails_business" in e or "portails metier" in e for e in errs), errs)
|
||||
|
||||
def test_negatif_raccourci_hors_permissions(self):
|
||||
bad = copy.deepcopy(self.spec)
|
||||
bad["portails"][1]["raccourcis"].append("Sales Invoice") # non autorise en construction
|
||||
errs = self._validate_with_spec(bad)
|
||||
self.assertTrue(any("raccourci vise DocType non autorise" in e for e in errs), errs)
|
||||
|
||||
# Garde-fous frappe.py --------------------------------------------------
|
||||
def test_content_deterministe_frappe(self):
|
||||
c1 = frappe.build_content("x", "X", ["A"], [{"titre": "T", "doctypes": ["A"]}])
|
||||
c2 = frappe.build_content("x", "X", ["A"], [{"titre": "T", "doctypes": ["A"]}])
|
||||
self.assertEqual(c1, c2)
|
||||
self.assertTrue(json.loads(c1)) # JSON valide
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,181 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Bundle Workspaces ERPNext · 5 portails role (OTO DTP Sprint 4)",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["manifest", "workspace"],
|
||||
"properties": {
|
||||
"manifest": { "$ref": "#/definitions/manifest" },
|
||||
"workspace": {
|
||||
"type": "array",
|
||||
"minItems": 5,
|
||||
"maxItems": 5,
|
||||
"items": { "$ref": "#/definitions/workspace" }
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"manifest": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"generated_from", "spec_version", "source_version", "roadmap_ref",
|
||||
"counts", "brand", "module_note", "doctypes_custom_a_creer", "workspaces"
|
||||
],
|
||||
"properties": {
|
||||
"generated_from": { "type": "string", "minLength": 1 },
|
||||
"spec_version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$" },
|
||||
"source_version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$" },
|
||||
"roadmap_ref": { "type": "string", "minLength": 1 },
|
||||
"counts": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["workspaces", "roles_couverts", "liens_total", "raccourcis_total"],
|
||||
"properties": {
|
||||
"workspaces": { "type": "integer", "minimum": 5, "maximum": 5 },
|
||||
"roles_couverts": { "type": "integer", "minimum": 1 },
|
||||
"liens_total": { "type": "integer", "minimum": 1 },
|
||||
"raccourcis_total": { "type": "integer", "minimum": 1 }
|
||||
}
|
||||
},
|
||||
"brand": { "$ref": "#/definitions/brand" },
|
||||
"module_note": { "type": "string", "minLength": 1 },
|
||||
"doctypes_custom_a_creer": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"workspaces": {
|
||||
"type": "array",
|
||||
"minItems": 5,
|
||||
"maxItems": 5,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["portail", "workspace", "nb_cartes", "nb_raccourcis", "nb_liens", "nb_roles", "doctypes_custom"],
|
||||
"properties": {
|
||||
"portail": { "type": "string", "enum": ["ventes", "construction", "achat", "compta", "direction"] },
|
||||
"workspace": { "type": "string", "minLength": 1 },
|
||||
"nb_cartes": { "type": "integer", "minimum": 1 },
|
||||
"nb_raccourcis": { "type": "integer", "minimum": 1 },
|
||||
"nb_liens": { "type": "integer", "minimum": 1 },
|
||||
"nb_roles": { "type": "integer", "minimum": 1 },
|
||||
"doctypes_custom": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"brand": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["note", "fond", "accent", "polices"],
|
||||
"properties": {
|
||||
"note": { "type": "string", "minLength": 1 },
|
||||
"fond": { "$ref": "#/definitions/token" },
|
||||
"accent": { "$ref": "#/definitions/token" },
|
||||
"polices": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/definitions/token" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"token": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["valeur", "source"],
|
||||
"properties": {
|
||||
"valeur": { "type": "string", "minLength": 1 },
|
||||
"source": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"doctype", "name", "title", "label", "public", "is_hidden", "module",
|
||||
"icon", "indicator_color", "sequence_id", "parent_page", "for_user",
|
||||
"content", "shortcuts", "links", "roles"
|
||||
],
|
||||
"properties": {
|
||||
"doctype": { "type": "string", "const": "Workspace" },
|
||||
"name": { "type": "string", "minLength": 1 },
|
||||
"title": { "type": "string", "minLength": 1 },
|
||||
"label": { "type": "string", "minLength": 1 },
|
||||
"public": { "type": "integer", "minimum": 1, "maximum": 1 },
|
||||
"is_hidden": { "type": "integer", "minimum": 0, "maximum": 0 },
|
||||
"module": { "type": "null" },
|
||||
"icon": { "type": "string", "minLength": 1 },
|
||||
"indicator_color": { "type": "string", "minLength": 1 },
|
||||
"sequence_id": { "type": "number", "minimum": 1 },
|
||||
"parent_page": { "type": "string" },
|
||||
"for_user": { "type": "string" },
|
||||
"content": { "type": "string", "minLength": 2 },
|
||||
"shortcuts": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/definitions/shortcut" }
|
||||
},
|
||||
"links": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"items": { "$ref": "#/definitions/link" }
|
||||
},
|
||||
"roles": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/definitions/has_role" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"shortcut": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["doctype", "parentfield", "parenttype", "type", "link_to", "label", "color", "doc_view"],
|
||||
"properties": {
|
||||
"doctype": { "type": "string", "const": "Workspace Shortcut" },
|
||||
"parentfield": { "type": "string", "const": "shortcuts" },
|
||||
"parenttype": { "type": "string", "const": "Workspace" },
|
||||
"type": { "type": "string", "const": "DocType" },
|
||||
"link_to": { "type": "string", "minLength": 1 },
|
||||
"label": { "type": "string", "minLength": 1 },
|
||||
"color": { "type": "string", "minLength": 1 },
|
||||
"doc_view": { "type": "string", "const": "List" }
|
||||
}
|
||||
},
|
||||
"link": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["doctype", "parentfield", "parenttype", "type", "label", "onboard", "hidden"],
|
||||
"properties": {
|
||||
"doctype": { "type": "string", "const": "Workspace Link" },
|
||||
"parentfield": { "type": "string", "const": "links" },
|
||||
"parenttype": { "type": "string", "const": "Workspace" },
|
||||
"type": { "type": "string", "enum": ["Card Break", "Link"] },
|
||||
"label": { "type": "string", "minLength": 1 },
|
||||
"link_count": { "type": "integer", "minimum": 0 },
|
||||
"link_type": { "type": "string", "const": "DocType" },
|
||||
"link_to": { "type": "string", "minLength": 1 },
|
||||
"is_query_report": { "type": "integer", "minimum": 0, "maximum": 0 },
|
||||
"dependencies": { "type": "string" },
|
||||
"onboard": { "type": "integer", "minimum": 0, "maximum": 0 },
|
||||
"hidden": { "type": "integer", "minimum": 0, "maximum": 0 }
|
||||
}
|
||||
},
|
||||
"has_role": {
|
||||
"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": "Workspace" },
|
||||
"role": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generateur de Workspaces ERPNext v15 · 5 portails role (Sprint 4 · Frontend Console).
|
||||
|
||||
Livrable Frontend Console de la roadmap Sprint 4 (l.49 « 5 portails role :
|
||||
Ventes/Construction/Achat/Compta/Direction »). Contrainte #1 « ERPNext natif =
|
||||
priorite absolue » : un portail role, dans ERPNext v15, EST un `Workspace` (page
|
||||
de landing du desk, visible selon les roles). On produit donc des fixtures
|
||||
`Workspace` importables — aucun framework de dashboard externe.
|
||||
|
||||
Transforme (rbac_50_roles.json + portails_spec.json) en :
|
||||
- workspace.json -> 1 fixture `Workspace` par portail metier (shortcuts + links + roles)
|
||||
- MANIFEST.json -> tracabilite (comptes, roles couverts, DocTypes custom, marque)
|
||||
|
||||
Sous-commandes :
|
||||
build [-o OUT] -> ecrit workspace.json / MANIFEST.json (refuse si invariant KO)
|
||||
validate [-o OUT] -> (re)genere en memoire, valide vs workspace.schema.json + invariants
|
||||
|
||||
Sortie deterministe (tri stable, aucun horodatage) -> diffable + re-generable.
|
||||
Anti-invention (#6) : chaque lien / raccourci vise un DocType present dans les
|
||||
permissions_cibles du portail (droit prouve) ; couverture exhaustive ; flag custom
|
||||
issu du contrat ; roles issus du contrat ; tokens de marque repris de CLAUDE.md.
|
||||
Ce worker n'ecrit JAMAIS sur le VPS (#8) : hand-off a l'agent ERPNext Backend.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_FRONTEND = os.path.normpath(os.path.join(_HERE, "..")) # frontend/
|
||||
_DELIVERABLES = os.path.normpath(os.path.join(_FRONTEND, "..")) # 05_deliverables_mvp/
|
||||
_RBAC_DIR = os.path.join(_DELIVERABLES, "rbac")
|
||||
|
||||
sys.path.insert(0, _HERE)
|
||||
# Reutilisation (workflow #5 : zero duplication) du validateur maison Publiciste.
|
||||
sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste"))
|
||||
|
||||
from wslib 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")
|
||||
_SPEC_PATH = os.path.join(_HERE, "portails_spec.json")
|
||||
_SCHEMA_PATH = os.path.join(_HERE, "workspace.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), _load(_SPEC_PATH))
|
||||
|
||||
|
||||
def _validate_bundle(bundle: dict) -> list[str]:
|
||||
"""Valide le bundle contre workspace.schema.json + invariants de cross-coherence."""
|
||||
schema = _load(_SCHEMA_PATH)
|
||||
errors = list(maison.validate(bundle, schema))
|
||||
|
||||
contract = _load(_CONTRACT_PATH)
|
||||
spec = _load(_SPEC_PATH)
|
||||
workspaces = bundle["workspace"]
|
||||
manifest = bundle["manifest"]
|
||||
|
||||
roles_map = builder.roles_by_portail(contract)
|
||||
dt_map = builder.doctypes_by_portail(contract)
|
||||
flags = builder.custom_flags(contract)
|
||||
portails_metier = set(contract.get("portails_business", []))
|
||||
spec_keys = [p["key"] for p in spec["portails"]]
|
||||
|
||||
# Invariant 1 · exactement les 5 portails METIER du contrat (roadmap « 5 portails »),
|
||||
# la console technique `plateforme` etant explicitement exclue.
|
||||
if set(spec_keys) != portails_metier:
|
||||
errors.append(
|
||||
f"portails spec {sorted(set(spec_keys))} != portails_business contrat {sorted(portails_metier)}."
|
||||
)
|
||||
if len(workspaces) != len(portails_metier):
|
||||
errors.append(f"{len(workspaces)} Workspaces != {len(portails_metier)} portails metier.")
|
||||
|
||||
spec_by_key = {p["key"]: p for p in spec["portails"]}
|
||||
for ws, key in zip(workspaces, spec_keys):
|
||||
p = spec_by_key[key]
|
||||
authorized = dt_map.get(key, set())
|
||||
carte_dts = [dt for carte in p["cartes"] for dt in carte["doctypes"]]
|
||||
|
||||
# Invariant 2 · ANTI-INVENTION : tout DocType lie/raccourci est autorise
|
||||
# (present dans les permissions_cibles du portail).
|
||||
for dt in carte_dts:
|
||||
if dt not in authorized:
|
||||
errors.append(f"[{key}] carte vise DocType non autorise: {dt!r}.")
|
||||
for sc in p["raccourcis"]:
|
||||
if sc not in authorized:
|
||||
errors.append(f"[{key}] raccourci vise DocType non autorise: {sc!r}.")
|
||||
|
||||
# Invariant 3 · COUVERTURE EXHAUSTIVE + sans doublon : chaque DocType
|
||||
# autorise apparait dans exactement une carte (aucun oubli silencieux).
|
||||
if len(carte_dts) != len(set(carte_dts)):
|
||||
errors.append(f"[{key}] un DocType apparait dans plusieurs cartes.")
|
||||
if set(carte_dts) != authorized:
|
||||
manquants = sorted(authorized - set(carte_dts))
|
||||
en_trop = sorted(set(carte_dts) - authorized)
|
||||
errors.append(f"[{key}] couverture != autorises (manquants={manquants}, en_trop={en_trop}).")
|
||||
|
||||
# Invariant 4 · restriction de visibilite = TOUS les roles du portail, tries.
|
||||
ws_roles = [row["role"] for row in ws["roles"]]
|
||||
if ws_roles != sorted(ws_roles):
|
||||
errors.append(f"[{key}] Has Role non tries (determinisme).")
|
||||
if set(ws_roles) != set(roles_map.get(key, [])):
|
||||
errors.append(f"[{key}] roles du Workspace != roles du portail (contrat).")
|
||||
|
||||
# Invariant 5 · table Links coherente : chaque Card Break annonce le bon
|
||||
# nombre de Link qui la suivent, et le total de Link == DocTypes couverts.
|
||||
nb_link_rows = 0
|
||||
i = 0
|
||||
rows = ws["links"]
|
||||
while i < len(rows):
|
||||
row = rows[i]
|
||||
if row["type"] != "Card Break":
|
||||
errors.append(f"[{key}] table links : attendu Card Break en position {i}.")
|
||||
break
|
||||
declared = row["link_count"]
|
||||
j = i + 1
|
||||
seen = 0
|
||||
while j < len(rows) and rows[j]["type"] == "Link":
|
||||
seen += 1
|
||||
nb_link_rows += 1
|
||||
j += 1
|
||||
if seen != declared:
|
||||
errors.append(f"[{key}] Card Break {row['label']!r}: link_count={declared} != {seen} liens reels.")
|
||||
i = j
|
||||
if nb_link_rows != len(authorized):
|
||||
errors.append(f"[{key}] {nb_link_rows} liens != {len(authorized)} DocTypes autorises.")
|
||||
|
||||
# Invariant 6 · un raccourci par shortcut, couleur = accent du portail.
|
||||
sc_targets = [s["link_to"] for s in ws["shortcuts"]]
|
||||
if sc_targets != p["raccourcis"]:
|
||||
errors.append(f"[{key}] shortcuts != raccourcis spec (ordre/contenu).")
|
||||
for s in ws["shortcuts"]:
|
||||
if s["color"] != p["accent_desk"]:
|
||||
errors.append(f"[{key}] raccourci {s['link_to']!r}: couleur != accent portail.")
|
||||
|
||||
# Invariant 7 · module owner laisse a None (a_confirmer, hand-off VPS #8).
|
||||
if ws["module"] is not None:
|
||||
errors.append(f"[{key}] Workspace.module doit rester None (a_confirmer).")
|
||||
|
||||
# Invariant 8 · le champ `content` est un JSON de blocs coherent avec les
|
||||
# raccourcis + cartes (aucun bloc oriente vers un element inexistant).
|
||||
blocks = json.loads(ws["content"])
|
||||
headers = [b for b in blocks if b["type"] == "header"]
|
||||
sc_blocks = [b["data"]["shortcut_name"] for b in blocks if b["type"] == "shortcut"]
|
||||
card_blocks = [b["data"]["card_name"] for b in blocks if b["type"] == "card"]
|
||||
if len(headers) != 1 or headers[0]["data"]["text"] != p["label"]:
|
||||
errors.append(f"[{key}] content: header manquant/incoherent.")
|
||||
if sc_blocks != p["raccourcis"]:
|
||||
errors.append(f"[{key}] content: blocs shortcut != raccourcis.")
|
||||
if card_blocks != [c["titre"] for c in p["cartes"]]:
|
||||
errors.append(f"[{key}] content: blocs card != titres de cartes.")
|
||||
|
||||
# Invariant 9 · flag custom du manifeste = flag du CONTRAT (source unique).
|
||||
for m in manifest["workspaces"]:
|
||||
expected_custom = sorted(
|
||||
dt for carte in spec_by_key[m["portail"]]["cartes"]
|
||||
for dt in carte["doctypes"] if flags.get(dt, False)
|
||||
)
|
||||
# dedoublonne en conservant l'ordre trie
|
||||
expected_custom = sorted(set(expected_custom))
|
||||
if m["doctypes_custom"] != expected_custom:
|
||||
errors.append(f"[{m['portail']}] doctypes_custom manifeste != contrat.")
|
||||
|
||||
# Invariant 10 · comptes agreges du manifeste coherents.
|
||||
c = manifest["counts"]
|
||||
if c["workspaces"] != len(workspaces):
|
||||
errors.append("counts.workspaces incoherent.")
|
||||
if c["roles_couverts"] != sum(len(w["roles"]) for w in workspaces):
|
||||
errors.append("counts.roles_couverts incoherent.")
|
||||
|
||||
# Invariant 11 · les tokens de marque proviennent de la spec (CLAUDE.md #4),
|
||||
# jamais fabriques : fond + accent presents et sources.
|
||||
brand = manifest["brand"]
|
||||
if brand.get("accent", {}).get("valeur") != "#f0b429":
|
||||
errors.append("brand.accent != #f0b429 (CLAUDE.md #4).")
|
||||
if brand.get("fond", {}).get("valeur") != "#0a0a12":
|
||||
errors.append("brand.fond != #0a0a12 (CLAUDE.md #4).")
|
||||
|
||||
# Invariant 12 · determinisme : deux builds successifs identiques.
|
||||
again = _build_bundle()
|
||||
if json.dumps(again, sort_keys=True, ensure_ascii=False) != json.dumps(bundle, sort_keys=True, ensure_ascii=False):
|
||||
errors.append("build non deterministe (deux passes divergent).")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def cmd_build(args: argparse.Namespace) -> int:
|
||||
bundle = _build_bundle()
|
||||
errors = _validate_bundle(bundle)
|
||||
if errors:
|
||||
_eprint("❌ Bundle invalide — generation refusee (anti-regression) :")
|
||||
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, "workspace.json"), bundle["workspace"])
|
||||
_write_json(os.path.join(out, "MANIFEST.json"), bundle["manifest"])
|
||||
|
||||
c = bundle["manifest"]["counts"]
|
||||
print(f"✅ Workspaces generes dans {out}")
|
||||
print(
|
||||
f" workspace.json : {c['workspaces']} portails · {c['roles_couverts']} roles "
|
||||
f"restreints · {c['liens_total']} liens · {c['raccourcis_total']} raccourcis"
|
||||
)
|
||||
for m in bundle["manifest"]["workspaces"]:
|
||||
cu = f" · {len(m['doctypes_custom'])} custom" if m["doctypes_custom"] else ""
|
||||
print(
|
||||
f" - {m['workspace']} : {m['nb_cartes']} cartes / {m['nb_liens']} liens / "
|
||||
f"{m['nb_roles']} roles{cu}"
|
||||
)
|
||||
if bundle["manifest"]["doctypes_custom_a_creer"]:
|
||||
print(
|
||||
" DocTypes custom a creer cote VPS (hand-off) : "
|
||||
+ ", ".join(bundle["manifest"]["doctypes_custom_a_creer"])
|
||||
)
|
||||
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['workspaces']} Workspaces, {c['roles_couverts']} roles "
|
||||
f"restreints, schema + 12 invariants verts."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(
|
||||
description="Generateur de Workspaces ERPNext (5 portails role) depuis RBAC + spec."
|
||||
)
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
pb = sub.add_parser("build", help="genere workspace.json / MANIFEST.json")
|
||||
pb.add_argument("-o", "--out", default=_DEFAULT_OUT, help="dossier de sortie (defaut: ./out)")
|
||||
pb.set_defaults(func=cmd_build)
|
||||
|
||||
pv = sub.add_parser("validate", help="valide le bundle (schema + invariants) sans ecrire")
|
||||
pv.add_argument("-o", "--out", default=_DEFAULT_OUT, help="ignore (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,6 @@
|
||||
"""wslib · construction des Workspaces ERPNext v15 (5 portails role).
|
||||
|
||||
Sous-modules :
|
||||
- frappe : connaissance Frappe (DocType `Workspace` + enfants + blocs `content`)
|
||||
- builder : assemblage deterministe spec + contrat RBAC -> bundle de fixtures
|
||||
"""
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Construction du bundle de Workspaces depuis le contrat RBAC + la spec portails.
|
||||
|
||||
Entrees :
|
||||
- contract : rbac_50_roles.json (deja valide par rbac.schema.json en CI). Source
|
||||
de verite des ROLES par portail et des DOCTYPES autorises (permissions_cibles).
|
||||
- spec : portails_spec.json. Ne fait QUE la mise en page (cartes, raccourcis,
|
||||
theme). Ne declare aucun DocType ni role hors du contrat.
|
||||
|
||||
Sortie : bundle deterministe :
|
||||
- workspace -> 1 fixture `Workspace` par portail metier (5), avec ses tables
|
||||
enfant `shortcuts` / `links` / `roles`.
|
||||
- manifest -> tracabilite (comptes, roles couverts, DocTypes custom a creer
|
||||
cote VPS, tokens de marque, note module a_confirmer).
|
||||
|
||||
Anti-invention (#6), garanti ici + par les invariants du CLI :
|
||||
- un lien de carte / un raccourci ne peut viser qu'un DocType present dans les
|
||||
`permissions_cibles` du portail (le portail a prouvablement le droit dessus) ;
|
||||
- la couverture est exhaustive (chaque DocType autorise apparait dans EXACTEMENT
|
||||
une carte) : aucun DocType silencieusement oublie, aucun invente ;
|
||||
- le flag `custom` (natif v15 vs DocType OTO a creer) vient du CONTRAT, jamais
|
||||
de la spec (source unique) ;
|
||||
- les roles proviennent du contrat (meme logique que le generateur Role Profile).
|
||||
|
||||
Determinisme : portails dans l'ordre de la spec ; roles tries par nom ; liens dans
|
||||
l'ordre spec des cartes -> sortie reproductible bit-a-bit (diffable, re-generable
|
||||
sans horodatage).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from . import frappe
|
||||
|
||||
|
||||
def roles_by_portail(contract: dict) -> dict[str, list[str]]:
|
||||
"""`erpnext_role_name` tries par portail (source de verite des roles)."""
|
||||
grouped: dict[str, list[str]] = {}
|
||||
for r in contract["roles"]:
|
||||
grouped.setdefault(r["portail"], []).append(r["erpnext_role_name"])
|
||||
for portail in grouped:
|
||||
grouped[portail] = sorted(set(grouped[portail]))
|
||||
return grouped
|
||||
|
||||
|
||||
def doctypes_by_portail(contract: dict) -> dict[str, set[str]]:
|
||||
"""Ensemble des DocTypes cites dans les permissions_cibles, par portail."""
|
||||
grouped: dict[str, set[str]] = {}
|
||||
for r in contract["roles"]:
|
||||
bucket = grouped.setdefault(r["portail"], set())
|
||||
for perm in r.get("permissions_cibles", []):
|
||||
if isinstance(perm, dict) and perm.get("doctype"):
|
||||
bucket.add(perm["doctype"])
|
||||
return grouped
|
||||
|
||||
|
||||
def custom_flags(contract: dict) -> dict[str, bool]:
|
||||
"""Flag `custom` par DocType (natif v15 = False), depuis le contrat (source unique)."""
|
||||
flags: dict[str, bool] = {}
|
||||
for r in contract["roles"]:
|
||||
for perm in r.get("permissions_cibles", []):
|
||||
if isinstance(perm, dict) and perm.get("doctype"):
|
||||
flags[perm["doctype"]] = bool(perm.get("custom", False))
|
||||
return flags
|
||||
|
||||
|
||||
def _links_for_portail(portail: dict) -> list[dict[str, Any]]:
|
||||
"""Table `Workspace Link` : Card Break + Link, dans l'ordre de la spec."""
|
||||
links: list[dict[str, Any]] = []
|
||||
for carte in portail["cartes"]:
|
||||
doctypes = carte["doctypes"]
|
||||
links.append(frappe.card_break_row(carte["titre"], len(doctypes)))
|
||||
for dt in doctypes:
|
||||
links.append(frappe.link_row(dt))
|
||||
return links
|
||||
|
||||
|
||||
def build_bundle(contract: dict, spec: dict) -> dict[str, Any]:
|
||||
"""Transforme (contrat RBAC + spec portails) en bundle de Workspaces."""
|
||||
roles_map = roles_by_portail(contract)
|
||||
dt_map = doctypes_by_portail(contract)
|
||||
flags = custom_flags(contract)
|
||||
|
||||
workspaces: list[dict] = []
|
||||
ws_meta: list[dict] = []
|
||||
for portail in spec["portails"]:
|
||||
key = portail["key"]
|
||||
roles = roles_map.get(key, [])
|
||||
links = _links_for_portail(portail)
|
||||
workspaces.append(frappe.workspace_fixture(portail, roles, links))
|
||||
|
||||
couverts = sorted({dt for carte in portail["cartes"] for dt in carte["doctypes"]})
|
||||
customs = sorted(dt for dt in couverts if flags.get(dt, False))
|
||||
ws_meta.append(
|
||||
{
|
||||
"portail": key,
|
||||
"workspace": portail["label"],
|
||||
"nb_cartes": len(portail["cartes"]),
|
||||
"nb_raccourcis": len(portail["raccourcis"]),
|
||||
"nb_liens": len(couverts),
|
||||
"nb_roles": len(roles),
|
||||
"doctypes_custom": customs,
|
||||
}
|
||||
)
|
||||
|
||||
total_customs = sorted({dt for m in ws_meta for dt in m["doctypes_custom"]})
|
||||
manifest = {
|
||||
"generated_from": spec["generated_from"],
|
||||
"spec_version": spec["version"],
|
||||
"source_version": contract["version"],
|
||||
"roadmap_ref": spec["roadmap_ref"],
|
||||
"counts": {
|
||||
"workspaces": len(workspaces),
|
||||
"roles_couverts": sum(m["nb_roles"] for m in ws_meta),
|
||||
"liens_total": sum(m["nb_liens"] for m in ws_meta),
|
||||
"raccourcis_total": sum(m["nb_raccourcis"] for m in ws_meta),
|
||||
},
|
||||
"brand": spec["brand"],
|
||||
"module_note": (
|
||||
"Workspace.module laisse a None (a_confirmer) : l'agent ERPNext Backend "
|
||||
"le fixe au module de l'app OTO a l'import (#8). Le worker ne fabrique "
|
||||
"pas de nom d'app."
|
||||
),
|
||||
"doctypes_custom_a_creer": total_customs,
|
||||
"workspaces": ws_meta,
|
||||
}
|
||||
|
||||
return {"manifest": manifest, "workspace": workspaces}
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Modele Frappe/ERPNext v15 : DocType `Workspace` (portail role natif).
|
||||
|
||||
Pourquoi un Workspace (et pas une page HTML externe)
|
||||
----------------------------------------------------
|
||||
La roadmap Sprint 4 demande « 5 portails role ». Contrainte #1 « ERPNext natif =
|
||||
priorite absolue avant tout outil externe » : le portail de landing par role,
|
||||
dans ERPNext v15, EST le DocType `Workspace` (le desk affiche a l'utilisateur les
|
||||
Workspaces dont il possede au moins un role autorise). On ne fabrique donc aucun
|
||||
framework de dashboard externe — on produit des fixtures `Workspace` importables.
|
||||
|
||||
Reference Frappe (v15, `frappe/desk/doctype/workspace`) :
|
||||
- `Workspace` : `title`/`label` (libelle), `public` (1 = visible au desk),
|
||||
`module` (Link Module Def — l'app proprietaire), `icon` (jeu d'icones desk),
|
||||
`sequence_id` (ordre), `content` (Text = JSON de blocs de mise en page),
|
||||
tables enfant `shortcuts` (Workspace Shortcut), `links` (Workspace Link),
|
||||
`roles` (Has Role — RESTREINT la visibilite aux roles listes).
|
||||
- `content` : liste de blocs `{"id","type","data"}` ; types utilises ici :
|
||||
`header` (titre), `shortcut` (renvoie vers un raccourci), `card` (renvoie vers
|
||||
une carte de liens). Les blocs pointent les enfants par leur libelle.
|
||||
- `Workspace Shortcut` : `type` (DocType/Report/Page/URL), `link_to`, `label`,
|
||||
`color`, `doc_view`. Un raccourci type DocType affiche le compteur LIVE calcule
|
||||
par le desk — aucun chiffre n'est donc stocke (anti-invention #6).
|
||||
- `Workspace Link` : deux natures dans la meme table — `Card Break` (ouvre une
|
||||
carte : `label`, `link_count`) puis des `Link` (`link_type`=DocType, `link_to`,
|
||||
`label`). L'ordre de la table = l'ordre d'affichage.
|
||||
- `Has Role` : un `role` (Link Role) par ligne ; `parent`/`idx`/`name` poses par
|
||||
Frappe a l'import (on ne les invente pas).
|
||||
|
||||
Anti-invention (#6) : ce module ne connait AUCUN DocType metier en dur ni aucun
|
||||
chiffre. Tout (DocTypes lies, roles) est injecte par le builder depuis le contrat
|
||||
RBAC. Le champ `module` reste None (a_confirmer) : l'agent ERPNext le fixe au
|
||||
module de l'app OTO a l'import — le worker ne fabrique pas de nom d'app.
|
||||
|
||||
Ce module ne touche JAMAIS le VPS : il retourne des dicts serialisables.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
WORKSPACE_DOCTYPE = "Workspace"
|
||||
SHORTCUT_DOCTYPE = "Workspace Shortcut"
|
||||
LINK_DOCTYPE = "Workspace Link"
|
||||
HAS_ROLE_DOCTYPE = "Has Role"
|
||||
|
||||
# Le module proprietaire (Link -> Module Def) est volontairement laisse a None :
|
||||
# le nommer serait inventer l'app OTO cible. L'agent ERPNext Backend le fixe a
|
||||
# l'import (hand-off documente dans le README / MANIFEST).
|
||||
OWNER_MODULE = None
|
||||
|
||||
|
||||
def has_role_row(erpnext_role_name: str) -> dict[str, Any]:
|
||||
"""Ligne enfant `Has Role` restreignant la visibilite du Workspace."""
|
||||
return {
|
||||
"doctype": HAS_ROLE_DOCTYPE,
|
||||
"parentfield": "roles",
|
||||
"parenttype": WORKSPACE_DOCTYPE,
|
||||
"role": erpnext_role_name,
|
||||
}
|
||||
|
||||
|
||||
def shortcut_row(doctype: str, color: str) -> dict[str, Any]:
|
||||
"""Raccourci vers un DocType (compteur calcule LIVE par le desk)."""
|
||||
return {
|
||||
"doctype": SHORTCUT_DOCTYPE,
|
||||
"parentfield": "shortcuts",
|
||||
"parenttype": WORKSPACE_DOCTYPE,
|
||||
"type": "DocType",
|
||||
"link_to": doctype,
|
||||
"label": doctype,
|
||||
"color": color,
|
||||
"doc_view": "List",
|
||||
}
|
||||
|
||||
|
||||
def card_break_row(titre: str, link_count: int) -> dict[str, Any]:
|
||||
"""Ligne `Card Break` : ouvre une carte de `link_count` liens."""
|
||||
return {
|
||||
"doctype": LINK_DOCTYPE,
|
||||
"parentfield": "links",
|
||||
"parenttype": WORKSPACE_DOCTYPE,
|
||||
"type": "Card Break",
|
||||
"label": titre,
|
||||
"link_count": link_count,
|
||||
"onboard": 0,
|
||||
"hidden": 0,
|
||||
}
|
||||
|
||||
|
||||
def link_row(doctype: str) -> dict[str, Any]:
|
||||
"""Ligne `Link` vers un DocType (dans la carte ouverte precedemment)."""
|
||||
return {
|
||||
"doctype": LINK_DOCTYPE,
|
||||
"parentfield": "links",
|
||||
"parenttype": WORKSPACE_DOCTYPE,
|
||||
"type": "Link",
|
||||
"label": doctype,
|
||||
"link_type": "DocType",
|
||||
"link_to": doctype,
|
||||
"onboard": 0,
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"dependencies": "",
|
||||
}
|
||||
|
||||
|
||||
def _block(block_id: str, block_type: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"id": block_id, "type": block_type, "data": data}
|
||||
|
||||
|
||||
def build_content(key: str, label: str, raccourcis: list[str], cartes: list[dict]) -> str:
|
||||
"""Serialise le champ `content` (JSON de blocs) de facon DETERMINISTE.
|
||||
|
||||
Ordre : header, puis un bloc `shortcut` par raccourci (dans l'ordre spec),
|
||||
puis un bloc `card` par carte (dans l'ordre spec). Les identifiants de bloc
|
||||
sont derives de la cle du portail (aucun aleatoire -> diffable, re-generable).
|
||||
"""
|
||||
blocks: list[dict[str, Any]] = [
|
||||
_block(f"hdr_{key}", "header", {"text": label, "col": 12}),
|
||||
]
|
||||
for sc in raccourcis:
|
||||
blocks.append(
|
||||
_block(f"sc_{key}_{sc}", "shortcut", {"shortcut_name": sc, "col": 3})
|
||||
)
|
||||
for carte in cartes:
|
||||
blocks.append(
|
||||
_block(
|
||||
f"card_{key}_{carte['titre']}",
|
||||
"card",
|
||||
{"card_name": carte["titre"], "col": 4},
|
||||
)
|
||||
)
|
||||
# Serialisation stable : cles triees, pas d'espaces volatils.
|
||||
return json.dumps(blocks, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def workspace_fixture(
|
||||
portail: dict,
|
||||
roles: list[str],
|
||||
links: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
"""Assemble une fixture `Workspace` complete pour un portail.
|
||||
|
||||
`roles` doit etre deja trie (determinisme gere par le builder). `links` est la
|
||||
table `Workspace Link` deja construite (Card Break + Link, dans l'ordre).
|
||||
"""
|
||||
label = portail["label"]
|
||||
content = build_content(
|
||||
portail["key"], label, portail["raccourcis"], portail["cartes"]
|
||||
)
|
||||
return {
|
||||
"doctype": WORKSPACE_DOCTYPE,
|
||||
"name": label,
|
||||
"title": label,
|
||||
"label": label,
|
||||
"public": 1,
|
||||
"is_hidden": 0,
|
||||
"module": OWNER_MODULE,
|
||||
"icon": portail["icon"],
|
||||
"indicator_color": portail["accent_desk"],
|
||||
"sequence_id": float(portail["sequence_id"]),
|
||||
"parent_page": "",
|
||||
"for_user": "",
|
||||
"content": content,
|
||||
"shortcuts": [shortcut_row(sc, portail["accent_desk"]) for sc in portail["raccourcis"]],
|
||||
"links": links,
|
||||
"roles": [has_role_row(r) for r in roles],
|
||||
}
|
||||
Reference in New Issue
Block a user