"""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}