322 lines
9.9 KiB
Python
322 lines
9.9 KiB
Python
"""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:
|
|
"""Design machines run the editor; production just enforces the snapshot.
|
|
|
|
Default: ON exactly when the `jey_layout` editor app is installed (it's only
|
|
installed on test/design machines). An explicit `jey_design_mode` in
|
|
site_config overrides this either way (set 0 to force off, 1 to force on)."""
|
|
override = frappe.conf.get("jey_design_mode")
|
|
if override is not None:
|
|
return bool(override)
|
|
cached = getattr(frappe.local, "_jey_design_mode", None)
|
|
if cached is None:
|
|
try:
|
|
cached = "jey_layout" in frappe.get_installed_apps()
|
|
except Exception:
|
|
cached = False
|
|
frappe.local._jey_design_mode = cached
|
|
return cached
|
|
|
|
|
|
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))
|