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:
parent
8f95aecee5
commit
3decdc6ae8
62
CLAUDE.md
62
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.
|
- 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.
|
- 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`)
|
### Python: Taxes Az workspace (`jey_theme/setup/taxes_workspace.py`)
|
||||||
|
|
||||||
Idempotent setup run via `after_migrate`.
|
Idempotent setup run via `after_migrate`.
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ from urllib.parse import unquote
|
||||||
import frappe
|
import frappe
|
||||||
from frappe import _
|
from frappe import _
|
||||||
|
|
||||||
|
from jey_theme import desk_layout
|
||||||
|
|
||||||
BLOCKED_MODULES: frozenset[str] = frozenset({
|
BLOCKED_MODULES: frozenset[str] = frozenset({
|
||||||
"Quality Management",
|
"Quality Management",
|
||||||
"Subcontracting",
|
"Subcontracting",
|
||||||
|
|
@ -25,6 +27,7 @@ def get_blocked_doctypes() -> frozenset[str]:
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
return cached
|
||||||
doctypes = set(BLOCKED_DOCTYPES_EXTRA)
|
doctypes = set(BLOCKED_DOCTYPES_EXTRA)
|
||||||
|
doctypes.update(desk_layout.blocked_doctypes())
|
||||||
if BLOCKED_MODULES:
|
if BLOCKED_MODULES:
|
||||||
try:
|
try:
|
||||||
rows = frappe.get_all(
|
rows = frappe.get_all(
|
||||||
|
|
@ -40,11 +43,23 @@ def get_blocked_doctypes() -> frozenset[str]:
|
||||||
return result
|
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]:
|
def get_blocked_workspace_names() -> frozenset[str]:
|
||||||
cached = getattr(frappe.local, "_jey_blocked_workspaces", None)
|
cached = getattr(frappe.local, "_jey_blocked_workspaces", None)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
return cached
|
||||||
names: set[str] = set()
|
names: set[str] = set(desk_layout.blocked_workspaces())
|
||||||
try:
|
try:
|
||||||
rows = frappe.get_all(
|
rows = frappe.get_all(
|
||||||
"Workspace",
|
"Workspace",
|
||||||
|
|
@ -66,6 +81,8 @@ def get_blocked_slugs() -> frozenset[str]:
|
||||||
slugs = {_slugify(dt) for dt in get_blocked_doctypes()}
|
slugs = {_slugify(dt) for dt in get_blocked_doctypes()}
|
||||||
for w in get_blocked_workspace_names():
|
for w in get_blocked_workspace_names():
|
||||||
slugs.add(_slugify(w))
|
slugs.add(_slugify(w))
|
||||||
|
for p in get_blocked_pages():
|
||||||
|
slugs.add(_slugify(p))
|
||||||
result = frozenset(slugs)
|
result = frozenset(slugs)
|
||||||
frappe.local._jey_blocked_slugs = result
|
frappe.local._jey_blocked_slugs = result
|
||||||
return result
|
return result
|
||||||
|
|
@ -79,7 +96,24 @@ def _deny():
|
||||||
frappe.throw(_("Not Permitted"), frappe.PermissionError)
|
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():
|
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).
|
"""HTTP-level block. Works even for Administrator (who bypasses has_permission).
|
||||||
Covers /api/resource/<Doctype>, /app/<slug>, and any whitelisted method that
|
Covers /api/resource/<Doctype>, /app/<slug>, and any whitelisted method that
|
||||||
passes `doctype` in form_dict."""
|
passes `doctype` in form_dict."""
|
||||||
|
|
@ -96,6 +130,12 @@ def before_request():
|
||||||
if is_blocked_doctype(doctype):
|
if is_blocked_doctype(doctype):
|
||||||
_deny()
|
_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/"):
|
if path.startswith("/app/"):
|
||||||
remainder = path[len("/app/"):]
|
remainder = path[len("/app/"):]
|
||||||
first_segment = remainder.split("/", 1)[0].split("?", 1)[0].lower()
|
first_segment = remainder.split("/", 1)[0].split("?", 1)[0].lower()
|
||||||
|
|
@ -108,9 +148,22 @@ def before_request():
|
||||||
value = form_dict.get(key)
|
value = form_dict.get(key)
|
||||||
if isinstance(value, str) and is_blocked_doctype(value):
|
if isinstance(value, str) and is_blocked_doctype(value):
|
||||||
_deny()
|
_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):
|
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.
|
"""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
|
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
|
return True
|
||||||
if is_blocked_doctype(doctype):
|
if is_blocked_doctype(doctype):
|
||||||
return False
|
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":
|
if doctype == "Workspace":
|
||||||
module = getattr(doc, "module", None)
|
module = getattr(doc, "module", None)
|
||||||
if module and module in BLOCKED_MODULES:
|
if module and module in BLOCKED_MODULES:
|
||||||
return False
|
return False
|
||||||
|
if docname and docname in get_blocked_workspace_names():
|
||||||
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -153,8 +218,19 @@ def _module_of(item):
|
||||||
|
|
||||||
|
|
||||||
def extend_bootinfo(bootinfo):
|
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
|
"""Strip blocked modules/doctypes from bootinfo so client router
|
||||||
never creates routes for them and the sidebar/switcher hide them."""
|
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_doctypes = get_blocked_doctypes()
|
||||||
blocked_modules = BLOCKED_MODULES
|
blocked_modules = BLOCKED_MODULES
|
||||||
|
|
||||||
|
|
@ -182,13 +258,24 @@ def extend_bootinfo(bootinfo):
|
||||||
_filter_list(user, key, not_blocked)
|
_filter_list(user, key, not_blocked)
|
||||||
_filter_list(user, "allow_modules", lambda m: m not in blocked_modules)
|
_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": [...], ...}
|
# bootinfo.workspaces = {"pages": [...], "blocks": [...], ...}
|
||||||
workspaces = getattr(bootinfo, "workspaces", None)
|
workspaces = getattr(bootinfo, "workspaces", None)
|
||||||
if isinstance(workspaces, dict):
|
if isinstance(workspaces, dict):
|
||||||
for key in ("pages", "private_pages"):
|
for key in ("pages", "private_pages"):
|
||||||
pages = workspaces.get(key)
|
pages = workspaces.get(key)
|
||||||
if isinstance(pages, list):
|
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 keyed by section title
|
||||||
sidebar_items = getattr(bootinfo, "workspace_sidebar_item", None)
|
sidebar_items = getattr(bootinfo, "workspace_sidebar_item", None)
|
||||||
|
|
@ -215,9 +302,14 @@ def extend_bootinfo(bootinfo):
|
||||||
continue
|
continue
|
||||||
ws = app.get("workspaces")
|
ws = app.get("workspaces")
|
||||||
if isinstance(ws, list):
|
if isinstance(ws, list):
|
||||||
# Remove workspace names that belong to blocked modules.
|
# Remove workspace names that belong to blocked modules, are
|
||||||
# We compare by name since app_data workspaces are just names.
|
# config-blocked, or are config-hidden. app_data workspaces are
|
||||||
app["workspaces"] = [w for w in ws if _slugify(w) not in get_blocked_slugs()]
|
# 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")
|
mods = app.get("modules")
|
||||||
if isinstance(mods, list):
|
if isinstance(mods, list):
|
||||||
app["modules"] = [m for m in mods if m not in blocked_modules]
|
app["modules"] = [m for m in mods if m not in blocked_modules]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"workspaces": {}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -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
|
||||||
|
|
@ -28,6 +28,13 @@ has_permission = {
|
||||||
"*": "jey_theme.access_control.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)
|
# 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).
|
# and the Employees /desk icon under Frappe HR (see setup/employees_icon.py).
|
||||||
after_migrate = [
|
after_migrate = [
|
||||||
|
|
|
||||||
|
|
@ -2575,3 +2575,31 @@
|
||||||
}, 50);
|
}, 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();
|
||||||
|
})();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue