jey_layout/jey_layout/api.py

325 lines
9.0 KiB
Python

"""Server API for the Jey Layout editor desk page.
Reads the live desk structure (workspaces -> cards -> links), merges the saved
states from the working config, and persists the operator's choices back to the
working file. Export copies the working file into jey_theme's snapshot, which is
what every site enforces in production.
All endpoints are restricted to System Manager.
"""
import json
import os
import frappe
from jey_theme import desk_layout
VISIBLE = desk_layout.VISIBLE
HIDDEN = desk_layout.HIDDEN
BLOCKED = desk_layout.BLOCKED
DEFAULT_CARD_LABEL = "Link" # mirrors frappe's default card when links precede any Card Break
def _guard():
frappe.only_for("System Manager")
def _read_working() -> dict:
return desk_layout._read_file(desk_layout.working_path())
def _config_workspaces(cfg: dict) -> dict:
return (cfg or {}).get("workspaces") or {}
# ---------------------------------------------------------------------------
# Read
# ---------------------------------------------------------------------------
@frappe.whitelist()
def get_status():
_guard()
return {
"design_mode": desk_layout.is_design_mode(),
"editing": desk_layout.is_editing(),
"working_path": desk_layout.working_path(),
"snapshot_path": desk_layout.snapshot_path(),
"jey_theme_installed": "jey_theme" in frappe.get_installed_apps(),
}
def _build_node(ws_row, cfg_ws):
"""Build one workspace node (cards -> links) merged with saved states."""
ws_cfg = cfg_ws.get(ws_row.name) or {}
cards_cfg = ws_cfg.get("cards") or {}
links_cfg = ws_cfg.get("links") or {}
rows = frappe.get_all(
"Workspace Link",
filters={"parent": ws_row.name},
fields=["type", "label", "link_type", "link_to"],
order_by="idx asc",
)
cards = []
def _flush(card):
if card and card["links"]:
cards.append(card)
# Implicit default card for links that appear before the first Card Break.
current = {"label": DEFAULT_CARD_LABEL, "state": VISIBLE, "links": []}
for row in rows:
if row.type == "Card Break":
_flush(current)
label = row.label or DEFAULT_CARD_LABEL
current = {
"label": label,
"state": (cards_cfg.get(label) or {}).get("state", VISIBLE),
"links": [],
}
continue
if not row.link_to:
continue
key = desk_layout.link_key(row.link_type, row.link_to)
current["links"].append({
"link_type": row.link_type,
"link_to": row.link_to,
"label": row.label or row.link_to,
"state": (links_cfg.get(key) or {}).get("state", VISIBLE),
"added": False,
})
_flush(current)
if not cards:
return None
# Inject added links into their target cards (labels here are untranslated,
# matching the config), then apply per-card ordering — the SAME logic that
# get_desktop_page applies, so DOM and node stay index-aligned for the editor.
_key_of = lambda l: desk_layout.link_key(l["link_type"], l["link_to"])
cards_by_label = {}
for c in cards:
cards_by_label.setdefault(c["label"], c)
for key, a in (ws_cfg.get("added") or {}).items():
if not (isinstance(a, dict) and a.get("link_to") and a.get("link_type") and a.get("card")):
continue
card = cards_by_label.get(a["card"])
if not card or any(_key_of(l) == key for l in card["links"]):
continue
card["links"].append({
"link_type": a["link_type"],
"link_to": a["link_to"],
"label": a.get("label") or a["link_to"],
"state": (links_cfg.get(key) or {}).get("state", VISIBLE),
"added": True,
})
for label, keys in (ws_cfg.get("order") or {}).items():
card = cards_by_label.get(label)
if card and isinstance(keys, list):
card["links"] = desk_layout.apply_order(card["links"], keys, _key_of)
return {
"name": ws_row.name,
"title": ws_row.title or ws_row.name,
"module": ws_row.module,
"state": ws_cfg.get("state", VISIBLE),
"cards": cards,
}
@frappe.whitelist()
def get_workspace_node(ws_name):
"""Authoritative structure for ONE workspace (untranslated card labels,
link identity, saved states). Used by the inline desk editor."""
_guard()
ws_row = frappe.db.get_value(
"Workspace", ws_name, ["name", "title", "module"], as_dict=True
)
if not ws_row:
return None
return _build_node(ws_row, _config_workspaces(_read_working()))
@frappe.whitelist()
def get_config():
_guard()
cfg = _read_working()
return cfg or {"version": 1, "workspaces": {}}
@frappe.whitelist()
def get_desk_tree():
"""Live workspaces -> cards -> links, merged with saved states."""
_guard()
cfg_ws = _config_workspaces(_read_working())
workspaces = frappe.get_all(
"Workspace",
filters={"public": 1},
fields=["name", "title", "module"],
order_by="sequence_id asc, name asc",
)
tree = []
for ws in workspaces:
node = _build_node(ws, cfg_ws)
if node:
tree.append(node)
return tree
# ---------------------------------------------------------------------------
# Write
# ---------------------------------------------------------------------------
def _validate_config(cfg: dict) -> dict:
if not isinstance(cfg, dict):
frappe.throw("Invalid config")
out_ws = {}
for ws_name, ws in (cfg.get("workspaces") or {}).items():
if not isinstance(ws, dict):
continue
entry = {"state": ws.get("state", VISIBLE) if ws.get("state") in (VISIBLE, HIDDEN, BLOCKED) else VISIBLE}
cards = {}
for label, c in (ws.get("cards") or {}).items():
state = (c or {}).get("state", VISIBLE)
if state in (VISIBLE, HIDDEN):
cards[label] = {"state": state}
links = {}
for key, link in (ws.get("links") or {}).items():
state = (link or {}).get("state", VISIBLE)
if state in (VISIBLE, HIDDEN, BLOCKED):
links[key] = {"state": state}
added = {}
for key, a in (ws.get("added") or {}).items():
if (
isinstance(a, dict)
and a.get("link_type") in ("DocType", "Report", "Page")
and a.get("link_to")
and a.get("card")
):
added[key] = {
"link_type": a["link_type"],
"link_to": a["link_to"],
"label": a.get("label") or a["link_to"],
"card": a["card"],
}
order = {}
for label, keys in (ws.get("order") or {}).items():
if isinstance(keys, list):
order[label] = [k for k in keys if isinstance(k, str)]
if cards:
entry["cards"] = cards
if links:
entry["links"] = links
if added:
entry["added"] = added
if order:
entry["order"] = order
out_ws[ws_name] = entry
out = {"version": 1, "workspaces": out_ws}
# Global block list: doctypes blocked anywhere (incl. ones not on /desk).
blocked_doctypes = []
for dt in cfg.get("blocked_doctypes") or []:
if isinstance(dt, str) and dt and dt not in blocked_doctypes:
blocked_doctypes.append(dt)
if blocked_doctypes:
out["blocked_doctypes"] = blocked_doctypes
return out
def _write(path: str, cfg: dict):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
@frappe.whitelist()
def save_config(config):
_guard()
if isinstance(config, str):
config = json.loads(config)
cfg = _validate_config(config)
_write(desk_layout.working_path(), cfg)
_bust_caches() # so a follow-up request reflects the save (hide/block + structural)
return {"ok": True}
@frappe.whitelist()
def set_editing(on):
_guard()
on = frappe.parse_json(on) if isinstance(on, str) else on
desk_layout.set_editing(bool(on))
return {"editing": desk_layout.is_editing()}
@frappe.whitelist()
def export_snapshot():
"""Copy the working config into jey_theme's shipped snapshot."""
_guard()
cfg = _read_working()
if not cfg:
frappe.throw("Nothing to export — save a layout first.")
target = desk_layout.snapshot_path()
_write(target, _validate_config(cfg))
return {"ok": True, "path": target}
@frappe.whitelist()
def reset_to_default():
"""Reload the working config from the shipped snapshot."""
_guard()
cfg = desk_layout._read_file(desk_layout.snapshot_path())
_write(desk_layout.working_path(), _validate_config(cfg or {"workspaces": {}}))
_bust_caches()
return {"ok": True}
def _bust_caches():
for attr in (desk_layout._CACHE_ATTR, "_jey_layout_raw"):
if hasattr(frappe.local, attr):
delattr(frappe.local, attr)
@frappe.whitelist()
def blocked_doctypes_list():
"""Current global block list — used by the DocType list-view extension."""
_guard()
cfg = _read_working() or {}
return [d for d in (cfg.get("blocked_doctypes") or []) if isinstance(d, str)]
@frappe.whitelist()
def block_doctypes(names):
return _toggle_blocked(names, True)
@frappe.whitelist()
def unblock_doctypes(names):
return _toggle_blocked(names, False)
def _toggle_blocked(names, block: bool):
"""Add/remove doctypes from the global block list (read-modify-write)."""
_guard()
if isinstance(names, str):
names = frappe.parse_json(names)
names = [n for n in (names or []) if isinstance(n, str) and n]
cfg = _read_working() or {"version": 1, "workspaces": {}}
cur = [n for n in (cfg.get("blocked_doctypes") or []) if isinstance(n, str)]
if block:
for n in names:
if n not in cur:
cur.append(n)
else:
drop = set(names)
cur = [n for n in cur if n not in drop]
cfg["blocked_doctypes"] = cur
_write(desk_layout.working_path(), _validate_config(cfg))
_bust_caches()
return {"blocked_doctypes": cur}