Merge pull request #34995 from sagarvora/fix-search-link

This commit is contained in:
Sagar Vora 2025-12-01 19:32:06 +05:30 committed by GitHub
commit de37b8f397
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 302 additions and 19 deletions

View file

@ -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):

View file

@ -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",

View file

@ -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
}
}

View file

@ -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)

View file

@ -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"]

View file

@ -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,

View file

@ -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);

View file

@ -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");

View file

@ -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):