Merge pull request #34995 from sagarvora/fix-search-link
This commit is contained in:
commit
de37b8f397
9 changed files with 302 additions and 19 deletions
|
|
@ -1108,12 +1108,12 @@ class TestGunicornWorker(IntegrationTestCase):
|
|||
return sum(c.cpu_percent(1.0) for c in process.children(True)) + process.cpu_percent(1.0)
|
||||
|
||||
self.spawn_gunicorn(["--threads=2"])
|
||||
self.assertLessEqual(get_total_usage(), 2)
|
||||
self.assertLessEqual(get_total_usage(), 3)
|
||||
|
||||
# Wake up at least one thread, go idle and check again
|
||||
path = f"http://{self.TEST_SITE}:{self.port}/api/method/ping"
|
||||
self.assertEqual(requests.get(path).status_code, 200)
|
||||
self.assertLessEqual(get_total_usage(), 2)
|
||||
self.assertLessEqual(get_total_usage(), 3)
|
||||
|
||||
|
||||
class TestRQWorker(IntegrationTestCase):
|
||||
|
|
|
|||
|
|
@ -329,6 +329,7 @@
|
|||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval:[\"Link\", \"Dynamic Link\", \"Table MultiSelect\"].includes(doc.fieldtype)",
|
||||
"fieldname": "ignore_user_permissions",
|
||||
"fieldtype": "Check",
|
||||
"label": "Ignore User Permissions"
|
||||
|
|
@ -623,7 +624,7 @@
|
|||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-10-14 14:22:21.517289",
|
||||
"modified": "2025-12-01 06:56:29.335491",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "DocField",
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@
|
|||
{
|
||||
"fieldname": "for_value",
|
||||
"fieldtype": "Dynamic Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "For Value",
|
||||
|
|
@ -89,8 +88,9 @@
|
|||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"links": [],
|
||||
"modified": "2024-03-23 16:04:00.823875",
|
||||
"modified": "2025-12-01 06:55:21.174845",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "User Permission",
|
||||
|
|
@ -109,9 +109,10 @@
|
|||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "user",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@ class Engine:
|
|||
skip_locked: bool = False,
|
||||
wait: bool = True,
|
||||
ignore_permissions: bool = True,
|
||||
ignore_user_permissions: bool = False,
|
||||
user: str | None = None,
|
||||
parent_doctype: str | None = None,
|
||||
reference_doctype: str | None = None,
|
||||
|
|
@ -184,6 +185,8 @@ class Engine:
|
|||
Args:
|
||||
db_query_compat: When True, uses legacy db_query behavior for sorting and filtering.
|
||||
This is kept optional to not break existing code that relies on the original query builder behaviour.
|
||||
ignore_user_permissions: Ignore user permissions for the query.
|
||||
Useful for link search queries when the link field has `ignore_user_permissions` set.
|
||||
"""
|
||||
|
||||
qb = frappe.local.qb
|
||||
|
|
@ -197,6 +200,7 @@ class Engine:
|
|||
self.parent_doctype = parent_doctype
|
||||
self.reference_doctype = reference_doctype
|
||||
self.apply_permissions = not ignore_permissions
|
||||
self.ignore_user_permissions = ignore_user_permissions
|
||||
self.function_aliases = set()
|
||||
self.field_aliases = set()
|
||||
self.db_query_compat = db_query_compat
|
||||
|
|
@ -1296,8 +1300,7 @@ class Engine:
|
|||
conditions = []
|
||||
fetch_shared_docs = False
|
||||
|
||||
# add user permission only if role has read perm
|
||||
if not (role_permissions.get("read") or role_permissions.get("select")):
|
||||
if self.ignore_user_permissions:
|
||||
return conditions, fetch_shared_docs
|
||||
|
||||
user_permissions = frappe.permissions.get_user_permissions(self.user)
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@ from typing_extensions import NotRequired # not required in 3.11+
|
|||
import frappe
|
||||
|
||||
# Backward compatbility
|
||||
from frappe import _, is_whitelisted, validate_and_sanitize_search_inputs
|
||||
from frappe import _, bold, is_whitelisted, validate_and_sanitize_search_inputs
|
||||
from frappe.database.schema import SPECIAL_CHAR_PATTERN
|
||||
from frappe.model.db_query import get_order_by
|
||||
from frappe.permissions import has_permission
|
||||
from frappe.utils import cint, cstr, unique
|
||||
from frappe.utils import cint, cstr, escape_html, unique
|
||||
from frappe.utils.data import make_filter_tuple
|
||||
|
||||
|
||||
|
|
@ -43,6 +43,9 @@ def search_link(
|
|||
searchfield: str | None = None,
|
||||
reference_doctype: str | None = None,
|
||||
ignore_user_permissions: bool = False,
|
||||
*,
|
||||
form_doctype: str | None = None,
|
||||
link_fieldname: str | None = None,
|
||||
) -> list[LinkSearchResults]:
|
||||
results = search_widget(
|
||||
doctype,
|
||||
|
|
@ -53,6 +56,8 @@ def search_link(
|
|||
filters=filters,
|
||||
reference_doctype=reference_doctype,
|
||||
ignore_user_permissions=ignore_user_permissions,
|
||||
form_doctype=form_doctype,
|
||||
link_fieldname=link_fieldname,
|
||||
)
|
||||
return build_for_autosuggest(results, doctype=doctype)
|
||||
|
||||
|
|
@ -71,6 +76,9 @@ def search_widget(
|
|||
as_dict: bool = False,
|
||||
reference_doctype: str | None = None,
|
||||
ignore_user_permissions: bool = False,
|
||||
*,
|
||||
form_doctype: str | None = None,
|
||||
link_fieldname: str | None = None,
|
||||
):
|
||||
start = cint(start)
|
||||
|
||||
|
|
@ -102,6 +110,8 @@ def search_widget(
|
|||
as_dict=as_dict,
|
||||
reference_doctype=reference_doctype,
|
||||
ignore_user_permissions=ignore_user_permissions,
|
||||
form_doctype=form_doctype,
|
||||
link_fieldname=link_fieldname,
|
||||
)
|
||||
except (frappe.PermissionError, frappe.AppNotInstalledError, ImportError):
|
||||
if frappe.local.conf.developer_mode:
|
||||
|
|
@ -189,14 +199,17 @@ def search_widget(
|
|||
# Since we are sorting by alias postgres needs to know number of column we are sorting
|
||||
order_by = f"{len(formatted_fields)} desc nulls last, {order_by}"
|
||||
|
||||
ignore_permissions = doctype == "DocType" or (
|
||||
cint(ignore_user_permissions)
|
||||
and has_permission(
|
||||
doctype,
|
||||
ptype="select" if frappe.only_has_select_perm(doctype) else "read",
|
||||
parent_doctype=reference_doctype,
|
||||
)
|
||||
)
|
||||
if ignore_user_permissions:
|
||||
if form_doctype and link_fieldname:
|
||||
validate_ignore_user_permissions(form_doctype, link_fieldname, doctype)
|
||||
else:
|
||||
frappe.logger().error(
|
||||
"setting ignore_user_permissions=True in search_link requires "
|
||||
"form_doctype and link_fieldname to be set. "
|
||||
f"Got form_doctype={form_doctype}, link_fieldname={link_fieldname}. "
|
||||
"Ignoring flag."
|
||||
)
|
||||
ignore_user_permissions = False
|
||||
|
||||
values = frappe.get_list(
|
||||
doctype,
|
||||
|
|
@ -206,7 +219,8 @@ def search_widget(
|
|||
limit_start=start,
|
||||
limit_page_length=None if meta.translated_doctype else page_length,
|
||||
order_by=order_by,
|
||||
ignore_permissions=ignore_permissions,
|
||||
ignore_permissions=doctype == "DocType",
|
||||
ignore_user_permissions=ignore_user_permissions,
|
||||
reference_doctype=reference_doctype,
|
||||
as_list=not as_dict,
|
||||
strict=False,
|
||||
|
|
@ -239,6 +253,63 @@ def search_widget(
|
|||
return values
|
||||
|
||||
|
||||
def validate_ignore_user_permissions(form_doctype, link_fieldname, link_doctype):
|
||||
def _throw(message):
|
||||
frappe.throw(message, title=_('Error validating "Ignore User Permissions"'))
|
||||
|
||||
meta = frappe.get_meta(form_doctype)
|
||||
link_field = meta.get_field(link_fieldname)
|
||||
|
||||
if not link_field:
|
||||
_throw(
|
||||
_("Field <code>{0}</code> not found in {1}").format(
|
||||
escape_html(link_fieldname), bold(_(form_doctype))
|
||||
)
|
||||
)
|
||||
|
||||
ignore_user_permissions = link_field.ignore_user_permissions
|
||||
found_doctype = None
|
||||
|
||||
if link_field.fieldtype == "Link":
|
||||
found_doctype = link_field.options
|
||||
|
||||
if link_field.fieldtype == "Table MultiSelect":
|
||||
child_meta = frappe.get_meta(link_field.options)
|
||||
child_link_field = next((field for field in child_meta.fields if field.fieldtype == "Link"), None)
|
||||
if not child_link_field:
|
||||
_throw(
|
||||
_(
|
||||
"Table MultiSelect requires a table with at least one Link field, but none was found in {0}"
|
||||
).format(bold(_(link_field.options)))
|
||||
)
|
||||
|
||||
found_doctype = child_link_field.options
|
||||
if not ignore_user_permissions:
|
||||
# ignore user permissions should be set in parent table field
|
||||
# or in child table link field
|
||||
ignore_user_permissions = child_link_field.ignore_user_permissions
|
||||
|
||||
if not ignore_user_permissions:
|
||||
_throw(
|
||||
_("The field {0} in {1} does not allow ignoring user permissions").format(
|
||||
bold(meta.get_label(link_fieldname)), bold(_(form_doctype))
|
||||
)
|
||||
)
|
||||
|
||||
if link_field.fieldtype == "Dynamic Link":
|
||||
return # skip doctype check for Dynamic Link fields
|
||||
|
||||
if found_doctype != link_doctype:
|
||||
_throw(
|
||||
_("The field {0} in {1} links to {2} and not {3}").format(
|
||||
bold(meta.get_label(link_fieldname)),
|
||||
bold(_(form_doctype)),
|
||||
bold(_(found_doctype)),
|
||||
bold(escape_html(link_doctype)),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_std_fields_list(meta, key):
|
||||
# get additional search fields
|
||||
sflist = ["name"]
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ class DatabaseQuery:
|
|||
ignore_ddl: bool = False,
|
||||
*,
|
||||
parent_doctype: str | None = None,
|
||||
ignore_user_permissions: bool = False,
|
||||
) -> list:
|
||||
"""Execute a database query using the Query Builder engine.
|
||||
|
||||
|
|
@ -89,6 +90,8 @@ class DatabaseQuery:
|
|||
pluck: Extract single field values as a simple list.
|
||||
ignore_ddl: Ignore DDL operations during query execution (legacy compatibility).
|
||||
parent_doctype: Parent doctype for child table queries.
|
||||
ignore_user_permissions: Ignore user permissions for the query.
|
||||
Useful for link search queries when the link field has `ignore_user_permissions` set.
|
||||
|
||||
Returns:
|
||||
Query results as list of dicts (default) or list of lists (as_list=True).
|
||||
|
|
@ -186,6 +189,7 @@ class DatabaseQuery:
|
|||
"offset": frappe.cint(offset),
|
||||
"distinct": distinct,
|
||||
"ignore_permissions": ignore_permissions,
|
||||
"ignore_user_permissions": ignore_user_permissions,
|
||||
"user": user,
|
||||
"parent_doctype": parent_doctype,
|
||||
"reference_doctype": reference_doctype,
|
||||
|
|
|
|||
|
|
@ -280,6 +280,8 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
|
|||
ignore_user_permissions: me.df.ignore_user_permissions,
|
||||
reference_doctype: me.get_reference_doctype() || "",
|
||||
page_length: cint(frappe.boot.sysdefaults?.link_field_results_limit) || 10,
|
||||
form_doctype: me.doctype,
|
||||
link_fieldname: me.df.fieldname,
|
||||
};
|
||||
|
||||
me.set_custom_query(args);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,14 @@ frappe.ui.form.ControlTableMultiSelect = class ControlTableMultiSelect extends (
|
|||
frappe.ui.form.ControlLink
|
||||
) {
|
||||
static horizontal = false;
|
||||
make() {
|
||||
// parent element
|
||||
super.make();
|
||||
const link_field = this.get_link_field();
|
||||
if (link_field?.ignore_user_permissions) {
|
||||
this.df.ignore_user_permissions = true;
|
||||
}
|
||||
}
|
||||
make_input() {
|
||||
super.make_input();
|
||||
this.$input_area.addClass("form-control table-multiselect");
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ from contextlib import contextmanager
|
|||
from functools import partial
|
||||
|
||||
import frappe
|
||||
from frappe.core.doctype.doctype.test_doctype import new_doctype
|
||||
from frappe.desk.search import get_names_for_mentions, search_link, search_widget
|
||||
from frappe.permissions import add_user_permission
|
||||
from frappe.tests import IntegrationTestCase
|
||||
from frappe.tests.utils import whitelist_for_tests
|
||||
|
||||
|
|
@ -181,6 +183,197 @@ class TestSearch(IntegrationTestCase):
|
|||
result = search(txt="(txt)")
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_search_link_with_ignore_user_permissions(self):
|
||||
"""Test that ignore_user_permissions works correctly in search_link
|
||||
when the link field has ignore_user_permissions enabled"""
|
||||
|
||||
# Clean up any leftover doctypes from previous test runs
|
||||
for dt in ("Test Search Form", "Test Search Linked"):
|
||||
if frappe.db.exists("DocType", dt):
|
||||
frappe.delete_doc("DocType", dt, force=True)
|
||||
|
||||
# Create a test doctype to link to
|
||||
new_doctype(
|
||||
name="Test Search Linked",
|
||||
fields=[{"label": "Title", "fieldname": "title", "fieldtype": "Data"}],
|
||||
permissions=[{"role": "System Manager", "read": 1, "write": 1}],
|
||||
search_fields="title",
|
||||
).insert()
|
||||
|
||||
# Create a form doctype with a link field that has ignore_user_permissions
|
||||
new_doctype(
|
||||
name="Test Search Form",
|
||||
fields=[
|
||||
{
|
||||
"label": "Linked Doc",
|
||||
"fieldname": "linked_doc",
|
||||
"fieldtype": "Link",
|
||||
"options": "Test Search Linked",
|
||||
"ignore_user_permissions": 1,
|
||||
}
|
||||
],
|
||||
permissions=[{"role": "System Manager", "read": 1, "write": 1}],
|
||||
).insert()
|
||||
|
||||
self.addCleanup(
|
||||
lambda: frappe.delete_doc("DocType", "Test Search Form", force=True, ignore_missing=True)
|
||||
)
|
||||
self.addCleanup(lambda: frappe.delete_doc("DocType", "Test Search Linked", force=True))
|
||||
|
||||
# Create some test documents
|
||||
allowed_doc = frappe.get_doc({"doctype": "Test Search Linked", "title": "Allowed Document"}).insert()
|
||||
restricted_doc = frappe.get_doc(
|
||||
{"doctype": "Test Search Linked", "title": "Restricted Document"}
|
||||
).insert()
|
||||
self.addCleanup(lambda: frappe.delete_doc("Test Search Linked", allowed_doc.name, force=True))
|
||||
self.addCleanup(lambda: frappe.delete_doc("Test Search Linked", restricted_doc.name, force=True))
|
||||
|
||||
# Create a test user with restricted permissions
|
||||
test_user = "test_search_user@example.com"
|
||||
if not frappe.db.exists("User", test_user):
|
||||
user = frappe.get_doc(
|
||||
{
|
||||
"doctype": "User",
|
||||
"email": test_user,
|
||||
"first_name": "Test Search User",
|
||||
"user_type": "System User",
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
user.add_roles("System Manager")
|
||||
self.addCleanup(lambda: frappe.delete_doc("User", test_user, force=True))
|
||||
|
||||
# Add user permission to restrict the user to only allowed_doc
|
||||
add_user_permission("Test Search Linked", allowed_doc.name, test_user)
|
||||
self.addCleanup(
|
||||
lambda: frappe.db.delete("User Permission", {"user": test_user, "allow": "Test Search Linked"})
|
||||
)
|
||||
|
||||
frappe.set_user(test_user)
|
||||
self.addCleanup(lambda: frappe.set_user("Administrator"))
|
||||
|
||||
# Without ignore_user_permissions, only allowed_doc should be returned
|
||||
results_without_ignore = search_link(
|
||||
doctype="Test Search Linked",
|
||||
txt="Document",
|
||||
ignore_user_permissions=False,
|
||||
)
|
||||
result_values = [r["value"] for r in results_without_ignore]
|
||||
self.assertIn(allowed_doc.name, result_values)
|
||||
self.assertNotIn(restricted_doc.name, result_values)
|
||||
|
||||
# With ignore_user_permissions + form_doctype + link_fieldname, both should be returned
|
||||
results_with_ignore = search_link(
|
||||
doctype="Test Search Linked",
|
||||
txt="Document",
|
||||
ignore_user_permissions=True,
|
||||
form_doctype="Test Search Form",
|
||||
link_fieldname="linked_doc",
|
||||
)
|
||||
result_values = [r["value"] for r in results_with_ignore]
|
||||
self.assertIn(allowed_doc.name, result_values)
|
||||
self.assertIn(restricted_doc.name, result_values)
|
||||
|
||||
# With ignore_user_permissions=True but WITHOUT form_doctype/link_fieldname,
|
||||
# the flag should be silently ignored and user permissions should apply
|
||||
results_without_context = search_link(
|
||||
doctype="Test Search Linked",
|
||||
txt="Document",
|
||||
ignore_user_permissions=True,
|
||||
# form_doctype and link_fieldname not provided
|
||||
)
|
||||
result_values = [r["value"] for r in results_without_context]
|
||||
self.assertIn(allowed_doc.name, result_values)
|
||||
self.assertNotIn(restricted_doc.name, result_values)
|
||||
|
||||
def test_search_link_ignore_user_permissions_validation(self):
|
||||
"""Test that ignore_user_permissions is validated correctly"""
|
||||
|
||||
# Clean up any leftover doctypes from previous test runs
|
||||
for dt in ("Test Search Form No Ignore", "Test Search Form Wrong Link", "Test Search Linked2"):
|
||||
if frappe.db.exists("DocType", dt):
|
||||
frappe.delete_doc("DocType", dt, force=True)
|
||||
|
||||
# Create doctypes for testing
|
||||
new_doctype(
|
||||
name="Test Search Linked2",
|
||||
fields=[{"label": "Title", "fieldname": "title", "fieldtype": "Data"}],
|
||||
).insert()
|
||||
|
||||
# Form with link field WITHOUT ignore_user_permissions
|
||||
new_doctype(
|
||||
name="Test Search Form No Ignore",
|
||||
fields=[
|
||||
{
|
||||
"label": "Linked Doc",
|
||||
"fieldname": "linked_doc",
|
||||
"fieldtype": "Link",
|
||||
"options": "Test Search Linked2",
|
||||
"ignore_user_permissions": 0,
|
||||
}
|
||||
],
|
||||
).insert()
|
||||
|
||||
self.addCleanup(
|
||||
lambda: frappe.delete_doc(
|
||||
"DocType", "Test Search Form No Ignore", force=True, ignore_missing=True
|
||||
)
|
||||
)
|
||||
self.addCleanup(
|
||||
lambda: frappe.delete_doc(
|
||||
"DocType", "Test Search Form Wrong Link", force=True, ignore_missing=True
|
||||
)
|
||||
)
|
||||
self.addCleanup(
|
||||
lambda: frappe.delete_doc("DocType", "Test Search Linked2", force=True, ignore_missing=True)
|
||||
)
|
||||
|
||||
# Should throw when field does not have ignore_user_permissions
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
search_link,
|
||||
doctype="Test Search Linked2",
|
||||
txt="test",
|
||||
ignore_user_permissions=True,
|
||||
form_doctype="Test Search Form No Ignore",
|
||||
link_fieldname="linked_doc",
|
||||
)
|
||||
|
||||
# Should throw when field doesn't exist
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
search_link,
|
||||
doctype="Test Search Linked2",
|
||||
txt="test",
|
||||
ignore_user_permissions=True,
|
||||
form_doctype="Test Search Form No Ignore",
|
||||
link_fieldname="nonexistent_field",
|
||||
)
|
||||
|
||||
# Should throw when doctype doesn't match
|
||||
new_doctype(
|
||||
name="Test Search Form Wrong Link",
|
||||
fields=[
|
||||
{
|
||||
"label": "Wrong Link",
|
||||
"fieldname": "wrong_link",
|
||||
"fieldtype": "Link",
|
||||
"options": "User", # Different doctype
|
||||
"ignore_user_permissions": 1,
|
||||
}
|
||||
],
|
||||
).insert()
|
||||
self.addCleanup(lambda: frappe.delete_doc("DocType", "Test Search Form Wrong Link", force=True))
|
||||
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
search_link,
|
||||
doctype="Test Search Linked2",
|
||||
txt="test",
|
||||
ignore_user_permissions=True,
|
||||
form_doctype="Test Search Form Wrong Link",
|
||||
link_fieldname="wrong_link",
|
||||
)
|
||||
|
||||
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_data(doctype, txt, searchfield, start, page_len, filters):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue