diff --git a/CLAUDE.md b/CLAUDE.md index 837a1af..1ea80e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,6 +36,68 @@ Goal: make blocked modules invisible and unreachable for every user, including A - Slug/doctype/workspace sets are cached on `frappe.local` per-request. - When adding a new block vector: extend `extend_bootinfo` rather than relying only on `has_permission`, because the client has many independent boot-derived UIs. +### Python: desk layout engine (`jey_theme/desk_layout.py` + `desk_overrides.py`) + +Configurable, **non-destructive** (variant A) control of desk workspaces/cards/links, +driven by a JSON config. Lets a System Manager, inline on `/desk`, **hide**, **block**, +**add** and **reorder** links. The companion editor is the separate `jey_layout` app +(install on the design machine only); the engine here is enforced on every site. +The Workspace DB records are never modified — fully reversible, survives ERPNext +migrations (everything is applied at request time, server-side → no client flash). + +**Config** (`jey_theme/config/desk_layout.json` = shipped snapshot; per machine the +editor writes a working copy in `jey_layout/config/desk_layout.json`): +```jsonc +{ "workspaces": { "": { + "state": "visible|hidden|blocked", + "cards": { "": {"state": "visible|hidden"} }, + "links": { "::": {"state": "visible|hidden|blocked"} }, // hide/block of any link + "added": { "::": {"link_type","link_to","label","card"} },// injected links + "order": { "": ["", ...] } // per-card link order +} } } +``` +Key for every link = `link_type::link_to` (Type ∈ DocType/Report/Page). + +- **hide** = the item is filtered OUT of the data the server sends (sidebar / + `get_desktop_page` cards+links) → absent in the DOM. URL still works. +- **block** = ALSO added to `blocked_doctypes/reports/pages` → denied at + `before_request` (HTTP/URL/API, catches Administrator) + `has_permission` (per-doc). + Truly unreachable for everyone. +- **add / reorder** = structural overlay: `get_desktop_page` injects added links into + their card and reorders per `order`. Applied ALWAYS (even in edit mode); only + hide/block is gated by enforcement. + +**Files** +- `desk_layout.py` — loads config; `_cache()`/`_build()` (enforcing-gated, fail-open) + produce hide/block sets cached on `frappe.local`; `_raw_workspaces()` + + `added_links()`/`order_map()`/`apply_order()` are the structural (always-on) overlay. + `is_enforcing()`: production → always; design machine → `not is_editing()` + (Redis key `jey_layout_editing`). Tolerates missing refs. +- `desk_overrides.py` — wraps `get_workspace_sidebar_items`/`get_desktop_page` via + `override_whitelisted_methods`. `get_desktop_page` does inject → reorder (always) + → hide/block filter (only when enforcing). `_build_link_item` shapes an injected + link like frappe's so the client renders it natively. Card labels in output are + translated, so matching forward-translates config labels (`_(label)`). +- `access_control.py` — `before_request` + `has_permission` deny `blocked` + doctypes/reports/pages (alongside static `BLOCKED_MODULES`); `extend_bootinfo` + strips hidden/blocked workspaces from boot and sets `bootinfo.jey_design_mode`. + All three are wrapped fail-open (never break the desk; errors → Error Log). + +**Edit vs applied model** (no "preview"): on the design machine, the normal desk is +the *applied* result; "✎ Edit layout" flips `jey_layout_editing` and reloads to show +all items + controls; "✓ Done editing" reloads back to applied. Export copies the +working file into this snapshot for shipping. + +> ⚠️ **Edge gotcha — DO NOT load the editor via `app_include`.** `app_include_js/css` +> assets are added to the HTTP `Link: rel=preload` response header. On this +> deployment the desk's total response headers are ~4 KB and the edge proxy's +> `proxy_buffer_size` is 4 KB; two extra app_include entries pushed it over → +> `502 "upstream sent too big header"` (local nginx has a bigger buffer, so it only +> failed through the edge). Instead `jey_theme.js` loads `jey_layout`'s JS/CSS +> **dynamically** (design mode only, via `bootinfo.jey_design_mode`), adding nothing +> to the header. Proper infra fix (not required by the app): raise the edge's +> `proxy_buffer_size`/`large_client_header_buffers`. + ### Python: Taxes Az workspace (`jey_theme/setup/taxes_workspace.py`) Idempotent setup run via `after_migrate`. diff --git a/jey_theme/access_control.py b/jey_theme/access_control.py index ff563c4..e1be828 100644 --- a/jey_theme/access_control.py +++ b/jey_theme/access_control.py @@ -3,6 +3,8 @@ from urllib.parse import unquote import frappe from frappe import _ +from jey_theme import desk_layout + BLOCKED_MODULES: frozenset[str] = frozenset({ "Quality Management", "Subcontracting", @@ -25,6 +27,7 @@ def get_blocked_doctypes() -> frozenset[str]: if cached is not None: return cached doctypes = set(BLOCKED_DOCTYPES_EXTRA) + doctypes.update(desk_layout.blocked_doctypes()) if BLOCKED_MODULES: try: rows = frappe.get_all( @@ -40,11 +43,23 @@ def get_blocked_doctypes() -> frozenset[str]: return result +def get_blocked_reports() -> frozenset[str]: + return desk_layout.blocked_reports() + + +def get_blocked_pages() -> frozenset[str]: + return desk_layout.blocked_pages() + + +def is_blocked_report(report: str) -> bool: + return report in get_blocked_reports() + + def get_blocked_workspace_names() -> frozenset[str]: cached = getattr(frappe.local, "_jey_blocked_workspaces", None) if cached is not None: return cached - names: set[str] = set() + names: set[str] = set(desk_layout.blocked_workspaces()) try: rows = frappe.get_all( "Workspace", @@ -66,6 +81,8 @@ def get_blocked_slugs() -> frozenset[str]: slugs = {_slugify(dt) for dt in get_blocked_doctypes()} for w in get_blocked_workspace_names(): slugs.add(_slugify(w)) + for p in get_blocked_pages(): + slugs.add(_slugify(p)) result = frozenset(slugs) frappe.local._jey_blocked_slugs = result return result @@ -79,7 +96,24 @@ def _deny(): frappe.throw(_("Not Permitted"), frappe.PermissionError) +def _safe_log(message: str): + try: + frappe.log_error(message, "Jey Access Control") + except Exception: + pass + + def before_request(): + """Hot-path guard — never raise except the intended PermissionError block.""" + try: + _before_request_impl() + except frappe.PermissionError: + raise + except Exception: + _safe_log("before_request failed") + + +def _before_request_impl(): """HTTP-level block. Works even for Administrator (who bypasses has_permission). Covers /api/resource/, /app/, and any whitelisted method that passes `doctype` in form_dict.""" @@ -96,6 +130,12 @@ def before_request(): if is_blocked_doctype(doctype): _deny() + if path.startswith("/app/query-report/"): + remainder = path[len("/app/query-report/"):] + report = unquote(remainder.split("/", 1)[0].split("?", 1)[0]) + if report and is_blocked_report(report): + _deny() + if path.startswith("/app/"): remainder = path[len("/app/"):] first_segment = remainder.split("/", 1)[0].split("?", 1)[0].lower() @@ -108,9 +148,22 @@ def before_request(): value = form_dict.get(key) if isinstance(value, str) and is_blocked_doctype(value): _deny() + # Report data endpoints (frappe.desk.query_report.run / export_query) + report_name = form_dict.get("report_name") + if isinstance(report_name, str) and is_blocked_report(report_name): + _deny() def has_permission(doc=None, ptype=None, user=None, debug=False): + """Hot-path hook — never raise; on unexpected error fail open (allow).""" + try: + return _has_permission_impl(doc, ptype, user, debug) + except Exception: + _safe_log("has_permission failed") + return True + + +def _has_permission_impl(doc=None, ptype=None, user=None, debug=False): """Per-doc hook. Applies to non-Administrator users that might otherwise have role access. IMPORTANT: Frappe treats a falsy return (None/False/0) as a denial — see @@ -125,10 +178,22 @@ def has_permission(doc=None, ptype=None, user=None, debug=False): return True if is_blocked_doctype(doctype): return False + + # Name of the doc being checked (for Report/Page/Workspace identity blocks). + docname = None + if doc is not None and not isinstance(doc, str): + docname = getattr(doc, "name", None) + + if doctype == "Report" and docname and is_blocked_report(docname): + return False + if doctype == "Page" and docname and docname in get_blocked_pages(): + return False if doctype == "Workspace": module = getattr(doc, "module", None) if module and module in BLOCKED_MODULES: return False + if docname and docname in get_blocked_workspace_names(): + return False return True @@ -153,8 +218,19 @@ def _module_of(item): def extend_bootinfo(bootinfo): + """Hot-path hook — never raise; on unexpected error skip filtering.""" + try: + _extend_bootinfo_impl(bootinfo) + except Exception: + _safe_log("extend_bootinfo failed") + + +def _extend_bootinfo_impl(bootinfo): """Strip blocked modules/doctypes from bootinfo so client router never creates routes for them and the sidebar/switcher hide them.""" + # Flag for jey_theme.js to decide whether to dynamically load the jey_layout + # editor (kept out of app_include to avoid bloating the preload Link header). + bootinfo.jey_design_mode = desk_layout.is_design_mode() blocked_doctypes = get_blocked_doctypes() blocked_modules = BLOCKED_MODULES @@ -182,13 +258,24 @@ def extend_bootinfo(bootinfo): _filter_list(user, key, not_blocked) _filter_list(user, "allow_modules", lambda m: m not in blocked_modules) + # Config-driven hidden/blocked workspaces (by name), in addition to module-based. + hidden_ws = desk_layout.hidden_or_blocked_workspaces() + + def _ws_name(p): + if isinstance(p, dict): + return p.get("name") or p.get("title") + return getattr(p, "name", None) or getattr(p, "title", None) + + def _ws_kept(p): + return _module_of(p) not in blocked_modules and _ws_name(p) not in hidden_ws + # bootinfo.workspaces = {"pages": [...], "blocks": [...], ...} workspaces = getattr(bootinfo, "workspaces", None) if isinstance(workspaces, dict): for key in ("pages", "private_pages"): pages = workspaces.get(key) if isinstance(pages, list): - workspaces[key] = [p for p in pages if _module_of(p) not in blocked_modules] + workspaces[key] = [p for p in pages if _ws_kept(p)] # Sidebar items keyed by section title sidebar_items = getattr(bootinfo, "workspace_sidebar_item", None) @@ -215,9 +302,14 @@ def extend_bootinfo(bootinfo): continue ws = app.get("workspaces") if isinstance(ws, list): - # Remove workspace names that belong to blocked modules. - # We compare by name since app_data workspaces are just names. - app["workspaces"] = [w for w in ws if _slugify(w) not in get_blocked_slugs()] + # Remove workspace names that belong to blocked modules, are + # config-blocked, or are config-hidden. app_data workspaces are + # just names, so compare by name and slug. + app["workspaces"] = [ + w + for w in ws + if _slugify(w) not in get_blocked_slugs() and w not in hidden_ws + ] mods = app.get("modules") if isinstance(mods, list): app["modules"] = [m for m in mods if m not in blocked_modules] diff --git a/jey_theme/config/desk_layout.json b/jey_theme/config/desk_layout.json new file mode 100644 index 0000000..a00c3a6 --- /dev/null +++ b/jey_theme/config/desk_layout.json @@ -0,0 +1,4 @@ +{ + "version": 1, + "workspaces": {} +} diff --git a/jey_theme/desk_layout.py b/jey_theme/desk_layout.py new file mode 100644 index 0000000..cced9d1 --- /dev/null +++ b/jey_theme/desk_layout.py @@ -0,0 +1,306 @@ +"""Desk layout engine (variant A — non-destructive, server-side filtering). + +Reads a JSON snapshot describing which workspaces / cards / links are visible, +visually hidden, or fully blocked, and exposes derived sets that the rest of +jey_theme (access_control.py, desk_overrides.py, extend_bootinfo) uses to filter +desk data *before* it reaches the browser. The Workspace records in the DB are +never modified, so everything is fully reversible and survives ERPNext migrations. + +Config file format (config/desk_layout.json): + + { + "version": 1, + "workspaces": { + "Selling": { + "state": "visible", # visible | hidden | blocked + "cards": { + "Sales Order": {"state": "hidden"} # visible | hidden + }, + "links": { + "DocType::Quotation": {"state": "hidden"}, # visible | hidden | blocked + "Report::Sales Analytics": {"state": "blocked"} + } + } + } + } + +Sources & modes +--------------- +- Production (default): read the snapshot shipped inside jey_theme. Always enforced. +- Design mode (`jey_design_mode: 1` in site_config): read the *working* file written + by the jey_layout editor app (if present), and only enforce when the editor's + Preview switch is on — so the operator can arrange the desk against the raw, + unfiltered layout and toggle Preview to verify the result. +""" + +import json +import os + +import frappe + +VISIBLE = "visible" +HIDDEN = "hidden" +BLOCKED = "blocked" + +CONFIG_FILENAME = "desk_layout.json" +EDITING_CACHE_KEY = "jey_layout_editing" + +# Per-request cache slot on frappe.local +_CACHE_ATTR = "_jey_layout" + + +def link_key(link_type: str, link_to: str) -> str: + """Stable identity for a workspace link across migrations.""" + return f"{link_type}::{link_to}" + + +# --------------------------------------------------------------------------- +# Mode helpers +# --------------------------------------------------------------------------- +def is_design_mode() -> bool: + return bool(frappe.conf.get("jey_design_mode")) + + +def is_editing() -> bool: + """Whether the operator is in an active edit session (design machine only).""" + try: + return bool(frappe.cache().get_value(EDITING_CACHE_KEY)) + except Exception: + return False + + +def set_editing(on: bool) -> None: + frappe.cache().set_value(EDITING_CACHE_KEY, 1 if on else 0) + + +def is_enforcing() -> bool: + """Whether filtering should be applied for the current request. + + - Production (no design mode): always enforce — the snapshot is the default. + - Design machine: enforce by DEFAULT too, so leaving the editor shows the real + applied layout. Enforcement is suspended only while actively editing, so the + operator can see every item (including hidden ones) and toggle it back.""" + if is_design_mode(): + return not is_editing() + return True + + +# --------------------------------------------------------------------------- +# File locations +# --------------------------------------------------------------------------- +def snapshot_path() -> str: + """Canonical, shipped snapshot inside jey_theme (the production source).""" + return os.path.join(frappe.get_app_path("jey_theme"), "config", CONFIG_FILENAME) + + +def working_path() -> str | None: + """Editable working file written by the jey_layout editor app, if installed.""" + try: + base = frappe.get_app_path("jey_layout") + except Exception: + return None + return os.path.join(base, "config", CONFIG_FILENAME) + + +def _read_file(path: str | None) -> dict: + if not path or not os.path.exists(path): + return {} + try: + with open(path, encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except Exception: + frappe.log_error("jey_theme: failed to read desk layout config", "Desk Layout") + return {} + + +def read_raw_layout() -> dict: + """Raw config dict from the active source, ignoring enforce/preview state. + + In design mode prefers the working file (falls back to the snapshot).""" + if is_design_mode(): + working = _read_file(working_path()) + if working: + return working + return _read_file(snapshot_path()) + + +# --------------------------------------------------------------------------- +# Per-request cache of the parsed, *effective* layout + derived sets +# --------------------------------------------------------------------------- +_EMPTY = { + "workspaces": {}, + "hidden_ws": frozenset(), + "blocked_ws": frozenset(), + "blocked_doctypes": frozenset(), + "blocked_reports": frozenset(), + "blocked_pages": frozenset(), +} + + +def _build() -> dict: + try: + return _build_inner() + except Exception: + # Never let the layout engine break the desk: fail open (no filtering). + try: + frappe.log_error("jey_theme: desk layout build failed", "Desk Layout") + except Exception: + pass + return _EMPTY + + +def _build_inner() -> dict: + if not is_enforcing(): + layout = {} + else: + layout = read_raw_layout() + + workspaces = layout.get("workspaces") or {} + + hidden_ws: set[str] = set() + blocked_ws: set[str] = set() + blocked_doctypes: set[str] = set() + blocked_reports: set[str] = set() + blocked_pages: set[str] = set() + + for ws_name, ws in workspaces.items(): + if not isinstance(ws, dict): + continue + state = ws.get("state", VISIBLE) + if state == HIDDEN: + hidden_ws.add(ws_name) + elif state == BLOCKED: + blocked_ws.add(ws_name) + + for key, link in (ws.get("links") or {}).items(): + if not isinstance(link, dict) or link.get("state") != BLOCKED: + continue + ltype, _, lto = key.partition("::") + if not lto: + continue + if ltype == "DocType": + blocked_doctypes.add(lto) + elif ltype == "Report": + blocked_reports.add(lto) + elif ltype == "Page": + blocked_pages.add(lto) + + return { + "workspaces": workspaces, + "hidden_ws": frozenset(hidden_ws), + "blocked_ws": frozenset(blocked_ws), + "blocked_doctypes": frozenset(blocked_doctypes), + "blocked_reports": frozenset(blocked_reports), + "blocked_pages": frozenset(blocked_pages), + } + + +def _cache() -> dict: + cached = getattr(frappe.local, _CACHE_ATTR, None) + if cached is None: + cached = _build() + setattr(frappe.local, _CACHE_ATTR, cached) + return cached + + +# --------------------------------------------------------------------------- +# Public derived accessors (config-driven) +# --------------------------------------------------------------------------- +def hidden_or_blocked_workspaces() -> frozenset[str]: + """Workspaces to remove from the sidebar (hidden + blocked).""" + c = _cache() + return c["hidden_ws"] | c["blocked_ws"] + + +def blocked_workspaces() -> frozenset[str]: + return _cache()["blocked_ws"] + + +def blocked_doctypes() -> frozenset[str]: + return _cache()["blocked_doctypes"] + + +def blocked_reports() -> frozenset[str]: + return _cache()["blocked_reports"] + + +def blocked_pages() -> frozenset[str]: + return _cache()["blocked_pages"] + + +def _ws_entry(ws_name: str) -> dict: + return _cache()["workspaces"].get(ws_name) or {} + + +def is_workspace_visible(ws_name: str) -> bool: + return _ws_entry(ws_name).get("state", VISIBLE) == VISIBLE + + +def should_hide_card(ws_name: str, card_label: str) -> bool: + cards = _ws_entry(ws_name).get("cards") or {} + entry = cards.get(card_label) or {} + return entry.get("state", VISIBLE) != VISIBLE + + +def hidden_card_labels(ws_name: str) -> frozenset[str]: + """Untranslated Card Break labels marked hidden for this workspace. + + Output card labels are translated (`_(label)`), so callers should forward- + translate these for matching rather than comparing raw.""" + cards = _ws_entry(ws_name).get("cards") or {} + return frozenset(label for label, entry in cards.items() if (entry or {}).get("state", VISIBLE) != VISIBLE) + + +def should_hide_link(ws_name: str, link_type: str, link_to: str) -> bool: + """True for both 'hidden' and 'blocked' links (both removed from display).""" + links = _ws_entry(ws_name).get("links") or {} + entry = links.get(link_key(link_type, link_to)) or {} + return entry.get("state", VISIBLE) != VISIBLE + + +# --------------------------------------------------------------------------- +# Structural overlay: added links + per-card ordering. +# These are NOT gated by is_enforcing() — adding a link and reordering are +# structural and must apply in edit mode too (so the operator can see/manage +# them). Hide/block (above) stays gated, so edit mode shows everything. +# --------------------------------------------------------------------------- +_RAW_ATTR = "_jey_layout_raw" + + +def _raw_workspaces() -> dict: + cached = getattr(frappe.local, _RAW_ATTR, None) + if cached is None: + try: + cached = read_raw_layout().get("workspaces") or {} + except Exception: + cached = {} + setattr(frappe.local, _RAW_ATTR, cached) + return cached + + +def added_links(ws_name: str) -> list[dict]: + """List of injected links for a workspace: {link_type, link_to, label, card}.""" + entry = _raw_workspaces().get(ws_name) or {} + out = [] + for a in (entry.get("added") or {}).values(): + if isinstance(a, dict) and a.get("link_to") and a.get("link_type") and a.get("card"): + out.append(a) + return out + + +def order_map(ws_name: str) -> dict: + """{: [, ...]} desired link order per card.""" + entry = _raw_workspaces().get(ws_name) or {} + order = entry.get("order") + return order if isinstance(order, dict) else {} + + +def apply_order(items: list, keys: list, key_of) -> list: + """Stable reorder: items whose key is listed in `keys` come first in that + order; everything else keeps its original relative order, appended after.""" + if not keys: + return items + rank = {k: i for i, k in enumerate(keys)} + big = len(keys) + return sorted(items, key=lambda it: rank.get(key_of(it), big)) diff --git a/jey_theme/desk_overrides.py b/jey_theme/desk_overrides.py new file mode 100644 index 0000000..2ab40d2 --- /dev/null +++ b/jey_theme/desk_overrides.py @@ -0,0 +1,173 @@ +"""Whitelisted-method overrides that filter desk data server-side. + +Wired in hooks.py via override_whitelisted_methods. Each wrapper calls the +original Frappe implementation, then strips workspaces / cards / links that the +desk layout config marks hidden or blocked — before the payload reaches the +browser, so there is no flash of soon-to-be-hidden items. + +Importing the originals by module path bypasses the override map and always +yields Frappe's real implementation. +""" + +import json + +import frappe +from frappe import _ + +from frappe.desk.desktop import ( + get_desktop_page as _orig_get_desktop_page, + get_workspace_sidebar_items as _orig_get_workspace_sidebar_items, +) + +from jey_theme import desk_layout + + +def _log(message): + try: + frappe.log_error(message, "Jey Desk Overrides") + except Exception: + pass + + +@frappe.whitelist() +def get_workspace_sidebar_items(): + result = _orig_get_workspace_sidebar_items() + # Filtering must never break the desk: on any error, return the original. + try: + if not desk_layout.is_enforcing(): + return result + hidden = desk_layout.hidden_or_blocked_workspaces() + if not hidden: + return result + pages = result.get("pages") + if isinstance(pages, list): + result["pages"] = [p for p in pages if p.get("name") not in hidden] + except Exception: + _log("get_workspace_sidebar_items filter failed") + return result + + +def _workspace_name_from_page(page) -> str | None: + if isinstance(page, str): + try: + page = json.loads(page) + except Exception: + return None + if isinstance(page, dict): + return page.get("name") or page.get("title") + return None + + +def _link_key(link) -> str: + return f'{link.get("link_type")}::{link.get("link_to")}' + + +def _cards_by_translated_label(items) -> dict: + m = {} + for c in items: + m.setdefault(c.get("label"), c) + return m + + +def _added_allowed(link_type, link_to) -> bool: + """Don't surface an injected link to a user who can't access it.""" + try: + if link_type == "DocType": + return frappe.has_permission(link_to, "read") + if link_type == "Report": + return frappe.has_permission("Report", doc=link_to) + return True # Page (own role checks) and anything else + except Exception: + return True + + +def _build_link_item(link_type, link_to, label) -> dict: + """Construct a link item shaped like frappe's get_links output so the + client LinksWidget renders it natively (route is computed client-side).""" + item = { + "type": "Link", + "link_type": link_type, + "link_to": link_to, + "label": label or link_to, + "dependencies": "", + "onboard": 0, + "is_query_report": 0, + } + if link_type == "Report": + try: + vals = frappe.db.get_value("Report", link_to, ["report_type", "ref_doctype"]) + if vals: + report_type, ref_doctype = vals + if report_type in ("Query Report", "Script Report", "Custom Report"): + item["is_query_report"] = 1 + else: + # Report-builder report: client uses `dependencies` as the doctype. + item["dependencies"] = ref_doctype or "" + except Exception: + pass + return item + + +def _inject_added(ws_name, items): + by_label = _cards_by_translated_label(items) + for a in desk_layout.added_links(ws_name): + card = by_label.get(_(a["card"])) + if not card: + continue # target card not present (empty/custom) — v1 limitation + links = card.get("links") + if not isinstance(links, list): + links = card["links"] = [] + key = f'{a["link_type"]}::{a["link_to"]}' + if any(_link_key(l) == key for l in links): + continue + if not _added_allowed(a["link_type"], a["link_to"]): + continue + item = _build_link_item(a["link_type"], a["link_to"], a.get("label")) + item["label"] = _(item["label"]) # output labels are translated + links.append(item) + + +def _reorder(ws_name, items): + by_label = _cards_by_translated_label(items) + for label, keys in desk_layout.order_map(ws_name).items(): + card = by_label.get(_(label)) + if card and isinstance(card.get("links"), list): + card["links"] = desk_layout.apply_order(card["links"], keys, _link_key) + + +def _filter_hide_block(ws_name, items) -> list: + # Output card labels are translated; forward-translate config labels to match. + hidden_card_set = {_(label) for label in desk_layout.hidden_card_labels(ws_name)} + new_items = [] + for card in items: + if card.get("label") in hidden_card_set: + continue + links = card.get("links") + if isinstance(links, list): + card["links"] = [ + link + for link in links + if not desk_layout.should_hide_link(ws_name, link.get("link_type"), link.get("link_to")) + ] + if not card["links"]: + continue # drop a card emptied purely by hiding + new_items.append(card) + return new_items + + +@frappe.whitelist() +def get_desktop_page(page): + result = _orig_get_desktop_page(page) + # Transforms must never break the desk: on any error, return the original. + try: + ws_name = _workspace_name_from_page(page) + cards = result.get("cards") if isinstance(result, dict) else None + if ws_name and isinstance(cards, dict) and isinstance(cards.get("items"), list): + items = cards["items"] + _inject_added(ws_name, items) # always (structural) + _reorder(ws_name, items) # always (structural) + if desk_layout.is_enforcing(): + cards["items"] = _filter_hide_block(ws_name, items) + except Exception: + _log("get_desktop_page transform failed") + return result diff --git a/jey_theme/hooks.py b/jey_theme/hooks.py index bea3f9d..12573ca 100644 --- a/jey_theme/hooks.py +++ b/jey_theme/hooks.py @@ -28,6 +28,13 @@ has_permission = { "*": "jey_theme.access_control.has_permission", } +# Desk layout engine (variant A): filter workspace sidebar & page content +# server-side, before they reach the browser. See jey_theme/desk_layout.py. +override_whitelisted_methods = { + "frappe.desk.desktop.get_workspace_sidebar_items": "jey_theme.desk_overrides.get_workspace_sidebar_items", + "frappe.desk.desktop.get_desktop_page": "jey_theme.desk_overrides.get_desktop_page", +} + # Idempotent setup of the custom Taxes workspace (see setup/taxes_workspace.py) # and the Employees /desk icon under Frappe HR (see setup/employees_icon.py). after_migrate = [ diff --git a/jey_theme/public/js/jey_theme.js b/jey_theme/public/js/jey_theme.js index bb3e0b5..43371c5 100644 --- a/jey_theme/public/js/jey_theme.js +++ b/jey_theme/public/js/jey_theme.js @@ -2575,3 +2575,31 @@ }, 50); } })(); + +// --------------------------------------------------------------------------- +// jey_layout inline editor loader. +// Loaded dynamically (NOT via app_include) so the editor's JS/CSS are not added +// to the desk's `Link: rel=preload` response header. On this deployment that +// header is ~4 KB and the edge proxy's proxy_buffer_size is 4 KB, so any extra +// app_include asset tips it over -> 502 "upstream sent too big header". Dynamic +//