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