Skip to content
Merged
Show file tree
Hide file tree
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
28 changes: 28 additions & 0 deletions draw/api/permission.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Permission helpers for Draw Diagram sharing (Writer-style view/edit/comment).
# Diagrams are owner-scoped by default (the Draw User role's if_owner perms); this
# widens list visibility to also include diagrams shared with the user (Frappe
# core DocShare) and public ones. Document-level read/write come from DocShare
# automatically; the custom "comment" permission type is checked via
# frappe.has_permission("Draw Diagram", "comment", doc=name).

import frappe


def query_conditions(user: str | None = None) -> str:
"""SQL clause limiting Draw Diagram list queries to what `user` may see:
their own diagrams, ones shared with them, and public ones. System Managers
(full access) get no restriction."""
user = user or frappe.session.user
if "System Manager" in frappe.get_roles(user):
return ""

table = "`tabDraw Diagram`"
conditions = [
f"{table}.owner = {frappe.db.escape(user)}",
f"{table}.is_public = 1",
]
shared = frappe.share.get_shared("Draw Diagram", user)
if shared:
names = ", ".join(frappe.db.escape(name) for name in shared)
conditions.append(f"{table}.name in ({names})")
return "(" + " or ".join(conditions) + ")"
60 changes: 60 additions & 0 deletions draw/api/share.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Writer-style sharing for Draw Diagram — view / comment / edit access levels,
# built on Frappe core DocShare (frappe.share) plus a custom "comment" permission
# type (registered by draw.patches.register_comment_permission_type). No Frappe
# Drive dependency. Public access reuses the diagram's own `is_public` flag, which
# draw.api.permission.query_conditions already honours.

import frappe
from frappe import _
from frappe.utils import cint

# Access level -> DocShare flags. "edit" also grants share so collaborators can
# re-share, matching the Drive/Writer "editor" tier.
LEVEL_FLAGS = {
"view": {"read": 1, "comment": 0, "write": 0, "share": 0},
"comment": {"read": 1, "comment": 1, "write": 0, "share": 0},
"edit": {"read": 1, "comment": 1, "write": 1, "share": 1},
}


def _check_can_share(name: str) -> None:
if not frappe.has_permission("Draw Diagram", "share", doc=name):
frappe.throw(_("You are not permitted to share this diagram."), frappe.PermissionError)


@frappe.whitelist()
def share_diagram(name: str, user: str, level: str = "view") -> list:
"""Share a diagram with a user at view / comment / edit level (idempotent —
re-sharing updates the level). Returns the current share list."""
_check_can_share(name)
flags = LEVEL_FLAGS.get(level)
if not flags:
frappe.throw(_("Unknown access level: {0}").format(level))
# Clear any prior grant first so lowering a level actually removes flags.
frappe.share.remove("Draw Diagram", name, user)
frappe.share.add("Draw Diagram", name, user=user, notify=0, **flags)
return get_diagram_shares(name)


@frappe.whitelist()
def unshare_diagram(name: str, user: str) -> list:
"""Revoke a user's access. Returns the current share list."""
_check_can_share(name)
frappe.share.remove("Draw Diagram", name, user)
return get_diagram_shares(name)


@frappe.whitelist()
def get_diagram_shares(name: str) -> list:
"""The users this diagram is shared with, each with their access flags."""
if not frappe.has_permission("Draw Diagram", "read", doc=name):
frappe.throw(_("Not permitted."), frappe.PermissionError)
Comment on lines +47 to +51

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 get_diagram_shares only requires read permission, so any "view"-only user can call it and get the full share list (every collaborator's email + their access level). The PR description says all endpoints are "gated on the caller holding share" — this one is not. Change the gate to share.

Suggested change
@frappe.whitelist()
def get_diagram_shares(name: str) -> list:
"""The users this diagram is shared with, each with their access flags."""
if not frappe.has_permission("Draw Diagram", "read", doc=name):
frappe.throw(_("Not permitted."), frappe.PermissionError)
@frappe.whitelist()
def get_diagram_shares(name: str) -> list:
"""The users this diagram is shared with, each with their access flags."""
if not frappe.has_permission("Draw Diagram", "share", doc=name):
frappe.throw(_("Not permitted."), frappe.PermissionError)

return frappe.share.get_users("Draw Diagram", name)


@frappe.whitelist()
def set_public(name: str, enabled) -> None:
"""Toggle "anyone in this site can view" via the diagram's is_public flag
(honoured by draw.api.permission.query_conditions)."""
_check_can_share(name)
frappe.db.set_value("Draw Diagram", name, "is_public", 1 if cint(enabled) else 0)
68 changes: 68 additions & 0 deletions draw/draw/doctype/draw_diagram/test_draw_diagram.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,71 @@ def test_legacy_single_type_still_valid(self):
for t in ("block", "mindmap", "flowchart", "whiteboard"):
doc = self._make(t, {"schemaVersion": 1, "diagramType": t})
self.assertEqual(doc.diagram_type, t)

# ----- Writer-style sharing (view / comment / edit) -----

def _user(self, email):
# Deliberately NO Draw-specific role — this proves DocShare alone grants
# access to a shared diagram, independent of any role permission.
if not frappe.db.exists("User", email):
frappe.get_doc(
{
"doctype": "User",
"email": email,
"first_name": email.split("@")[0],
"send_welcome_email": 0,
}
).insert(ignore_permissions=True)
self.addCleanup(lambda: frappe.delete_doc("User", email, force=True, ignore_permissions=True))
return email

def test_share_edit_grants_read_write_comment(self):
from draw.api.share import get_diagram_shares, share_diagram

user = self._user("draw-editor@example.com")
doc = self._make("unified", {"schemaVersion": 1, "diagramType": "unified"})
share_diagram(doc.name, user, "edit")

# Core flags on the share row are reliable everywhere.
shares = {s["user"]: s for s in get_diagram_shares(doc.name)}
self.assertIn(user, shares)
self.assertTrue(shares[user]["read"] and shares[user]["write"])

# The contract that matters is enforcement — check it functionally, incl.
# the custom "comment" permission type.
frappe.set_user(user)
try:
self.assertTrue(frappe.has_permission("Draw Diagram", "read", doc=doc.name))
self.assertTrue(frappe.has_permission("Draw Diagram", "write", doc=doc.name))
self.assertTrue(frappe.has_permission("Draw Diagram", "comment", doc=doc.name))
finally:
frappe.set_user("Administrator")

def test_share_view_is_read_only(self):
from draw.api.share import share_diagram

user = self._user("draw-viewer@example.com")
doc = self._make("block", {"schemaVersion": 1, "diagramType": "block"})
share_diagram(doc.name, user, "view")

frappe.set_user(user)
try:
self.assertTrue(frappe.has_permission("Draw Diagram", "read", doc=doc.name))
self.assertFalse(frappe.has_permission("Draw Diagram", "write", doc=doc.name))
finally:
frappe.set_user("Administrator")

def test_unshare_revokes_access(self):
from draw.api.share import get_diagram_shares, share_diagram, unshare_diagram

user = self._user("draw-revoke@example.com")
doc = self._make("block", {"schemaVersion": 1, "diagramType": "block"})
share_diagram(doc.name, user, "edit")
unshare_diagram(doc.name, user)

self.assertEqual(get_diagram_shares(doc.name), [])
frappe.set_user(user)
try:
self.assertFalse(frappe.has_permission("Draw Diagram", "read", doc=doc.name))
finally:
frappe.set_user("Administrator")
21 changes: 11 additions & 10 deletions draw/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,11 @@
# ------------

# before_install = "draw.install.before_install"
# after_install = "draw.install.after_install"
# Idempotent setup (Draw User role + owner perms + diagram "comment" permission
# type). Run on fresh install AND on every migrate, because patches.txt patches do
# NOT execute on a fresh install.
after_install = "draw.setup.ensure_setup"
after_migrate = "draw.setup.ensure_setup"

# Uninstallation
# ------------
Expand Down Expand Up @@ -129,15 +133,12 @@

# Permissions
# -----------
# Permissions evaluated in scripted ways

# permission_query_conditions = {
# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions",
# }
#
# has_permission = {
# "Event": "frappe.desk.doctype.event.event.has_permission",
# }
# Diagrams are owner-scoped by default; widen list visibility to also include
# diagrams shared with the user (DocShare) and public ones. Document-level
# read/write/comment come from DocShare + the custom "comment" permission type.
permission_query_conditions = {
"Draw Diagram": "draw.api.permission.query_conditions",
}

# Document Events
# ---------------
Expand Down
4 changes: 3 additions & 1 deletion draw/patches.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@

[post_model_sync]
# Patches added in this section will be executed after doctypes are migrated
draw.patches.add_draw_user_role_and_permissions
# (App setup — Draw User role, permissions, "comment" permission type — now lives
# in draw.setup.ensure_setup, run from after_install + after_migrate so it also
# applies on fresh installs, which skip patches.)
55 changes: 0 additions & 55 deletions draw/patches/add_draw_user_role_and_permissions.py

This file was deleted.

65 changes: 65 additions & 0 deletions draw/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Idempotent app setup, run from BOTH the after_install and after_migrate hooks.
#
# Why hooks and not patches: patches in patches.txt are marked complete WITHOUT
# executing on a fresh install (they exist to migrate existing data), so a fresh
# Frappe Cloud install would otherwise never get the Draw User role or the custom
# permission type. Running an idempotent setup from after_install (fresh) +
# after_migrate (upgrades) covers both.

import frappe

ROLE = "Draw User"
OWNED_DOCTYPES = ("Draw Diagram", "Draw Folder")


def ensure_setup(*args, **kwargs) -> None:
"""Create the Draw User role + owner-scoped perms and register the diagram
"comment" permission type. Safe to run repeatedly."""
_ensure_role()
for doctype in OWNED_DOCTYPES:
_ensure_owner_permission(doctype)
_ensure_comment_permission_type()
frappe.clear_cache()


def _ensure_role() -> None:
"""Create the Draw User role idempotently (desk-enabled, normal users get it)."""
if frappe.db.exists("Role", ROLE):
return
frappe.get_doc({"doctype": "Role", "role_name": ROLE, "desk_access": 1}).insert(
ignore_permissions=True
)


def _ensure_owner_permission(doctype: str) -> None:
"""Add an if_owner perm row for Draw User on the given doctype if missing."""
if frappe.db.exists("Custom DocPerm", {"parent": doctype, "role": ROLE, "if_owner": 1}):
return
frappe.get_doc(
{
"doctype": "Custom DocPerm",
"parent": doctype,
"parenttype": "DocType",
"parentfield": "permissions",
"role": ROLE,
"if_owner": 1,
"read": 1,
"write": 1,
"create": 1,
"delete": 1,
"share": 1,
}
).insert(ignore_permissions=True)


def _ensure_comment_permission_type() -> None:
"""Register a "comment" permission type for Draw Diagram so diagrams can be
shared at a view / comment / edit level. Adds a `comment` Check to DocShare +
the doctype's perm rules. No-ops on Frappe versions without Permission Type."""
if not frappe.db.exists("DocType", "Permission Type"):
return
if frappe.db.exists("Permission Type", {"doc_type": "Draw Diagram", "perm_type": "comment"}):
return
frappe.get_doc(
{"doctype": "Permission Type", "doc_type": "Draw Diagram", "perm_type": "comment"}
).insert(ignore_permissions=True)
Loading