feat: desk layout engine for jey_layout (hide/block/add/reorder)

Non-destructive, server-side filtering/injection of desk workspaces/cards/links
driven by config/desk_layout.json. Adds desk_layout.py + desk_overrides.py,
extends access_control.py (Report/Page blocking, fail-open hooks), dynamically
loads the jey_layout editor in design mode, and documents the edge proxy
proxy_buffer_size gotcha in CLAUDE.md. Test snapshot cleared to empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ali 2026-06-18 12:53:53 +00:00
parent 8f95aecee5
commit 3decdc6ae8
7 changed files with 677 additions and 5 deletions

View File

@ -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": { "<ws>": {
"state": "visible|hidden|blocked",
"cards": { "<untranslated card label>": {"state": "visible|hidden"} },
"links": { "<Type>::<link_to>": {"state": "visible|hidden|blocked"} }, // hide/block of any link
"added": { "<Type>::<link_to>": {"link_type","link_to","label","card"} },// injected links
"order": { "<card label>": ["<key>", ...] } // 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`.

View File

@ -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/<Doctype>, /app/<slug>, 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]

View File

@ -0,0 +1,4 @@
{
"version": 1,
"workspaces": {}
}

306
jey_theme/desk_layout.py Normal file
View File

@ -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:
"""{<untranslated card label>: [<key>, ...]} 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))

173
jey_theme/desk_overrides.py Normal file
View File

@ -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

View File

@ -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 = [

View File

@ -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
// <script>/<link> tags add nothing to that header. Only the design machine
// (jey_design_mode) actually loads the editor.
// ---------------------------------------------------------------------------
(function () {
function loadEditor() {
try {
if (!window.frappe || !frappe.boot || !frappe.boot.jey_design_mode) return;
if (document.getElementById("jey-layout-editor-js")) return;
var css = document.createElement("link");
css.rel = "stylesheet";
css.href = "/assets/jey_layout/css/jey_layout.css";
document.head.appendChild(css);
var js = document.createElement("script");
js.id = "jey-layout-editor-js";
js.src = "/assets/jey_layout/js/jey_layout.js";
document.head.appendChild(js);
} catch (e) {}
}
if (window.$ && window.frappe) $(document).on("app_ready", loadEditor);
if (window.frappe && frappe.boot) loadEditor();
})();