325 lines
9.0 KiB
Python
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}
|