Merge branch 'develop' into fix-list-view-header

This commit is contained in:
mergify[bot] 2025-10-14 11:41:09 +00:00 committed by GitHub
commit fbdb00a68d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 7931 additions and 7373 deletions

View file

@ -227,17 +227,17 @@ context("Control Link", () => {
field_name: "assigned_by",
property: "default",
property_type: "Text",
value: "Administrator",
value: cy.config("testUser"),
},
true
);
cy.reload();
cy.new_form("ToDo");
cy.fill_field("description", "new", "Text Editor").wait(200);
cy.fill_field("description", "new", "Text Editor").blur().wait(200);
cy.save();
cy.get(".frappe-control[data-fieldname=assigned_by_full_name] .control-value").should(
"contain",
"Administrator"
"Frappe"
);
// if user clears default value explicitly, system should not reset default again
cy.get_field("assigned_by").clear().blur();

View file

@ -924,7 +924,7 @@ def get_installed_apps(*, _ensure_on_bench: bool = False) -> list[str]:
def get_doc_hooks():
"""Return hooked methods for given doc. Expand the dict tuple if required."""
if not hasattr(local, "doc_events_hooks"):
if not getattr(local, "doc_events_hooks", None):
hooks = get_hooks("doc_events", {})
out = {}
for key, value in hooks.items():

View file

@ -437,14 +437,6 @@ def validate_link(doctype: str, docname: str, fields=None):
if not values.name:
return values
if not frappe.has_permission(doctype, "read", doc=values.name):
frappe.throw(
_("You do not have permission to access {0} {1}").format(
frappe.bold(doctype), frappe.bold(docname)
),
frappe.PermissionError,
)
if not fields:
frappe.local.response_headers.set("Cache-Control", "private,max-age=1800,stale-while-revalidate=7200")
return values

View file

@ -57,9 +57,8 @@ class Address(Document):
self.flags.linked = False
def autoname(self):
if not self.address_title:
if self.links:
self.address_title = self.links[0].link_name
if not self.address_title and self.links:
self.address_title = self.links[0].link_title or self.links[0].link_name
if self.address_title:
self.name = cstr(self.address_title).strip() + "-" + cstr(_(self.address_type)).strip()

View file

@ -1199,6 +1199,7 @@ class Database:
"""`ROLLBACK` current transaction. Optionally rollback to a known save_point."""
if save_point:
self.sql(f"rollback to savepoint {save_point}")
self.value_cache.clear()
elif not self._disable_transaction_control:
self.before_commit.reset()
self.after_commit.reset()

View file

@ -359,6 +359,7 @@ def remove_from_installed_apps(app_name):
"DefaultValue", {"defkey": "installed_apps"}, "defvalue", json.dumps(installed_apps)
)
_clear_cache("__global")
frappe.local.doc_events_hooks = None
frappe.get_single("Installed Applications").update_versions()
frappe.db.commit()
if frappe.flags.in_install:

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,11 @@ import json
import re
from collections import Counter
from collections.abc import Mapping, Sequence
from functools import cached_property
from functools import cached_property, lru_cache
import sqlparse
from sqlparse import tokens
from sqlparse.sql import Function, Parenthesis, Statement
import frappe
import frappe.defaults
@ -33,6 +37,22 @@ from frappe.utils import (
)
from frappe.utils.data import DateTimeLikeObject, get_datetime, getdate, sbool
@lru_cache(maxsize=128)
def _parse_sql(field: str) -> Statement | None:
"""
Parse a given SQL statement using `sqlparse`.
Args:
field (str): The SQL statement string to parse.
Returns:
Statement | None: A `sqlparse.sql.Statement` object if parsing succeeds, otherwise `None`.
"""
if parsed := sqlparse.parse(field):
return parsed[0]
LOCATE_PATTERN = re.compile(r"locate\([^,]+,\s*[`\"]?name[`\"]?\s*\)", flags=re.IGNORECASE)
LOCATE_CAST_PATTERN = re.compile(r"locate\(([^,]+),\s*([`\"]?name[`\"]?)\s*\)", flags=re.IGNORECASE)
FUNC_IFNULL_PATTERN = re.compile(r"(strpos|ifnull|coalesce)\(\s*[`\"]?name[`\"]?\s*,", flags=re.IGNORECASE)
@ -456,6 +476,46 @@ from {tables}
"sleep",
]
def _find_subqueries(parsed: Statement) -> list:
"""
Recursively find all subqueries in a parsed SQL statement.
"""
subqueries = []
for token in parsed.tokens:
if isinstance(token, Parenthesis):
# Check for DML token for subquery check
is_subquery = False
for sub_token in token.tokens:
if sub_token.ttype is tokens.DML:
is_subquery = True
break
if is_subquery:
subqueries.append(token)
# Recursively check for nested subqueries
subqueries.extend(_find_subqueries(token))
elif token.is_group:
subqueries.extend(_find_subqueries(token))
return subqueries
def _check_sql_token(statement: Statement) -> None:
"""
Checks the output of `sqlparse.parse()` to detect blocked functions and subqueries.
"""
if _find_subqueries(statement):
_raise_exception()
for token in statement.tokens:
if isinstance(token, Function):
if (name := (token.get_name())) and name.lower() in blacklisted_functions:
_raise_exception()
if token.ttype == tokens.Keyword:
if token.value.lower() in blacklisted_keywords:
_raise_exception()
if token.is_group:
_check_sql_token(token)
def _raise_exception():
frappe.throw(_("Use of sub-query or function is restricted"), frappe.DataError)
@ -470,21 +530,8 @@ from {tables}
lower_field = field.lower().strip()
if SUB_QUERY_PATTERN.match(field):
# Check for subquery anywhere in the field, not just at the beginning
if "(" in lower_field:
# Check all parentheses pairs, not just the first one
paren_start = 0
while True:
location = lower_field.find("(", paren_start)
if location == -1:
break
token = lower_field[location + 1 :].lstrip().split(" ", 1)[0]
if any(
re.search(r"\b" + re.escape(keyword) + r"\b", token)
for keyword in blacklisted_keywords + blacklisted_functions
):
_raise_exception()
paren_start = location + 1
# Check all tokens for subquery detection
_check_sql_token(_parse_sql(field))
if "@" in lower_field:
# prevent access to global variables

View file

@ -1585,7 +1585,10 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView {
fields: this.get_dialog_fields(),
primary_action: (values) => {
// doctype fields
let fields = values[this.doctype].map((f) => [f, this.doctype]);
let fields = (values[this.doctype] || []).map((f) => [
f,
this.doctype,
]);
delete values[this.doctype];
// child table fields
@ -1610,6 +1613,18 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView {
},
});
const $bulk = $(`
<div class="mb-3">
<button class="btn btn-default btn-xs" data-action="select_all">${__("Select All")}</button>
<button class="btn btn-default btn-xs" data-action="unselect_all">${__("Unselect All")}</button>
</div>
`);
const toggleAll = (checked) =>
d.$wrapper.find(":checkbox").prop("checked", checked).trigger("change");
$bulk.on("click", "[data-action=select_all]", () => toggleAll(true));
$bulk.on("click", "[data-action=unselect_all]", () => toggleAll(false));
d.$body.prepend($bulk);
d.$body.prepend(`
<div class="columns-search">
<input type="text" placeholder="${__(

View file

@ -489,6 +489,23 @@ class TestDBQuery(IntegrationTestCase):
)
self.assertTrue("_relevance" in data[0])
# Test that fields with keywords in strings are allowed
data = DatabaseQuery("DocType").execute(
fields=["name", "locate('select', name)"],
limit_start=0,
limit_page_length=1,
)
self.assertTrue(data)
# Test that subqueries with other DML are blocked
self.assertRaises(
frappe.DataError,
DatabaseQuery("DocType").execute,
fields=["name", "issingle", "(insert into tabUser values (1))"],
limit_start=0,
limit_page_length=1,
)
data = DatabaseQuery("DocType").execute(
fields=["name", "issingle", "date(creation) as creation"],
limit_start=0,
@ -554,6 +571,16 @@ class TestDBQuery(IntegrationTestCase):
limit_page_length=1,
)
# Ensure search terms aren't blocked as functions
from frappe.desk.search import search_link
search_terms = ("global", "user")
for term in search_terms:
with self.subTest(term=term):
result = search_link("ToDo", term)
self.assertIsInstance(result, list)
def test_nested_permission(self):
frappe.set_user("Administrator")
create_nested_doctype()