Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 11 additions & 178 deletions draw/api/diagram.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
# Copyright (c) 2026, Frappe and contributors
# For license information, please see license.txt

# Whitelisted API for the Draw Diagram editor (SPEC §8 persistence, §2 trash,
# §9 sharing). Owner-or-public reads, revision-checked saves, trash/restore/
# duplicate, and thumbnail upload. Guests may read only public diagrams.
# Whitelisted API for the Draw Diagram editor (SPEC §8 persistence, §2 trash):
# owner-or-public reads, revision-checked saves, thumbnail upload, and the daily
# trash purge. Guests may read only public diagrams.
#
# Deliberately NOT here: listing, trash/restore/delete, duplicate and sharing. The
# frontend does all of that CRUD through frappe-ui's list/document resources (see
# frontend/src/data/diagrams.js and TileGrid's duplicate/trash handlers), and
# sharing lives in draw/api/share.py, which is the only implementation — it carries
# the three-level view/comment/edit model. Parallel endpoints for those used to sit
# in this module, unused, and a caller reaching one would have got weaker access
# rules than the live path applies.

import json

Expand Down Expand Up @@ -74,30 +82,6 @@ def get_public_diagram(name: str) -> dict:
return get_diagram(name)


@frappe.whitelist()
def list_diagrams(include_trashed: bool = False) -> list[dict]:
"""List the current user's diagrams, newest first (trash excluded by default)."""
filters: dict = {"owner": frappe.session.user}
filters["is_trashed"] = 1 if include_trashed else 0
return frappe.get_all(
DOCTYPE,
filters=filters,
fields=[
"name",
"title",
"description",
"folder",
"canvas_size",
"thumbnail",
"is_public",
"sort_order",
"trashed_on",
"modified",
],
order_by="sort_order asc, modified desc",
)


# --- writes ------------------------------------------------------------------


Expand Down Expand Up @@ -149,105 +133,6 @@ def _get_writable_diagram(name: str) -> "frappe.model.document.Document":
return diagram


@frappe.whitelist()
def set_public_access(name: str, is_public: int) -> dict:
"""Toggle a diagram's global (link) access (SPEC §9 sharing)."""
diagram = _get_writable_diagram(name)
diagram.is_public = 1 if int(is_public) else 0
diagram.save()
frappe.db.commit()
return {"name": diagram.name, "is_public": bool(diagram.is_public)}


# --- sharing with people (view / edit roles), SPEC §9 -----------------------
# Uses Frappe's built-in DocShare (frappe.share), so the existing read/write
# gates (has_permission) honour invited users automatically. "Anyone with the
# link" stays the separate is_public flag above.


def _get_shareable_diagram(name: str) -> "frappe.model.document.Document":
"""Load a diagram the caller may share (owner or explicit share permission)."""
if not frappe.db.exists(DOCTYPE, name):
frappe.throw(_("Diagram not found"), frappe.DoesNotExistError)
diagram = frappe.get_doc(DOCTYPE, name)
if diagram.owner != frappe.session.user and not diagram.has_permission("share"):
raise frappe.PermissionError(_("You cannot change sharing for this diagram"))
return diagram


@frappe.whitelist()
def list_shares(name: str) -> list[dict]:
"""People this diagram is shared with (excluding the owner), with their role."""
diagram = _get_shareable_diagram(name)
rows = frappe.get_all(
"DocShare",
filters={"share_doctype": DOCTYPE, "share_name": name, "everyone": 0},
fields=["user", "read", "write"],
)
members = []
for row in rows:
if not row.user or row.user == diagram.owner:
continue
info = frappe.db.get_value("User", row.user, ["full_name", "user_image"], as_dict=True) or {}
members.append(
{
"user": row.user,
"full_name": info.get("full_name") or row.user,
"user_image": info.get("user_image"),
"can_edit": bool(row.write),
}
)
return members


@frappe.whitelist()
def add_share(name: str, user: str, can_edit: int = 0) -> dict:
"""Share a diagram with a specific user as viewer or editor (SPEC §9)."""
diagram = _get_shareable_diagram(name)
if not frappe.db.exists("User", user):
frappe.throw(_("No user found with email {0}").format(user))
if user == diagram.owner:
frappe.throw(_("The owner already has full access"))
frappe.share.add(DOCTYPE, name, user=user, read=1, write=1 if int(can_edit) else 0, notify=0)
frappe.db.commit()
return {"user": user, "can_edit": bool(int(can_edit))}


@frappe.whitelist()
def update_share(name: str, user: str, can_edit: int) -> dict:
"""Change an existing member's role between view and edit."""
_get_shareable_diagram(name)
frappe.share.add(DOCTYPE, name, user=user, read=1, write=1 if int(can_edit) else 0, notify=0)
frappe.db.commit()
return {"user": user, "can_edit": bool(int(can_edit))}


@frappe.whitelist()
def remove_share(name: str, user: str) -> dict:
"""Stop sharing a diagram with a user."""
_get_shareable_diagram(name)
frappe.share.remove(DOCTYPE, name, user)
frappe.db.commit()
return {"user": user, "removed": True}


@frappe.whitelist()
def search_users(txt: str = "") -> list[dict]:
"""Enabled users to invite (matched on email or name); excludes Guest/Admin."""
txt = (txt or "").strip()
or_filters = (
[["User", "name", "like", f"%{txt}%"], ["User", "full_name", "like", f"%{txt}%"]] if txt else []
)
return frappe.get_all(
"User",
filters=[["User", "enabled", "=", 1], ["User", "name", "not in", ["Guest", "Administrator"]]],
or_filters=or_filters,
fields=["name", "full_name", "user_image"],
order_by="full_name asc",
limit_page_length=8,
)


@frappe.whitelist()
def save_thumbnail(name: str, thumbnail: str) -> dict:
"""Attach a freshly rendered thumbnail (a data URL) to the diagram.
Expand Down Expand Up @@ -284,40 +169,6 @@ def _save_thumbnail_file(
).insert(ignore_permissions=True)


# --- trash / restore / duplicate ---------------------------------------------


@frappe.whitelist()
def trash_diagram(name: str) -> dict:
"""Move a diagram to trash (SPEC §2 — no confirmation, trash is the net)."""
diagram = _get_writable_diagram(name)
diagram.is_trashed = 1
diagram.trashed_on = now_datetime()
diagram.save()
frappe.db.commit()
return {"name": diagram.name, "is_trashed": True}


@frappe.whitelist()
def restore_diagram(name: str) -> dict:
"""Restore a trashed diagram back to the home grid."""
diagram = _get_writable_diagram(name)
diagram.is_trashed = 0
diagram.trashed_on = None
diagram.save()
frappe.db.commit()
return {"name": diagram.name, "is_trashed": False}


@frappe.whitelist()
def delete_diagram(name: str) -> dict:
"""Permanently delete a diagram (from the Trash view)."""
diagram = _get_writable_diagram(name)
frappe.delete_doc(DOCTYPE, diagram.name)
frappe.db.commit()
return {"name": name, "deleted": True}


# --- scheduled maintenance ---------------------------------------------------


Expand All @@ -339,21 +190,3 @@ def purge_old_trashed_diagrams() -> None:
frappe.delete_doc(DOCTYPE, name, ignore_permissions=True, force=True)
if stale:
frappe.db.commit()


@frappe.whitelist()
def duplicate_diagram(name: str) -> dict:
"""Copy a diagram (document + metadata) into a fresh draft owned by the caller."""
source = _get_readable_diagram(name)
copy = frappe.get_doc(
{
"doctype": DOCTYPE,
"title": _("{0} (copy)").format(source.title),
"description": source.description,
"folder": source.folder,
"canvas_size": source.canvas_size,
"document": source.document,
}
).insert()
frappe.db.commit()
return {"name": copy.name, "title": copy.title}
Loading