jey_theme/jey_theme/desk_layout.py

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