Merge branch 'develop' into 32489-role-perm-based-masking

This commit is contained in:
mergify[bot] 2025-09-30 09:00:48 +00:00 committed by GitHub
commit 16058b92af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
105 changed files with 14525 additions and 12551 deletions

View file

@ -61,3 +61,6 @@ e9bbe03354079cfcef65a77b0c33f57b047a7c93
# replace `frappe.flags.in_test` with `frappe.in_test`
653c80b8483cc41aef25cd7d66b9b6bb188bf5f8
# another ruff update
6ca4d4d167a1a009d99062747711de7a994aa633

View file

@ -108,7 +108,7 @@ jobs:
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
id: yarn-cache

View file

@ -21,7 +21,7 @@ repos:
exclude: ^frappe/tests/classes/context_managers\.py$
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.1
rev: v0.13.2
hooks:
- id: ruff
name: "Run ruff import sorter"

View file

@ -321,7 +321,10 @@ def set_authenticate_headers(response: Response):
def make_form_dict(request: Request):
request_data = request.get_data(as_text=True)
if request_data and request.is_json:
args = orjson.loads(request_data)
try:
args = orjson.loads(request_data)
except orjson.JSONDecodeError:
frappe.throw(_("Invalid request body"), frappe.DataError)
else:
args = {}
args.update(request.args or {})

View file

@ -66,6 +66,9 @@ class HTTPRequest:
elif frappe.get_request_header("REMOTE_ADDR"):
frappe.local.request_ip = frappe.get_request_header("REMOTE_ADDR")
elif frappe.request and getattr(frappe.request, "remote_addr", None):
frappe.local.request_ip = frappe.request.remote_addr
else:
frappe.local.request_ip = "127.0.0.1"
@ -666,7 +669,7 @@ def validate_oauth(authorization_header):
required_scopes = frappe.db.get_value("OAuth Bearer Token", token, "scopes").split(
get_url_delimiter()
)
valid, oauthlib_request = get_oauth_server().verify_request(
valid, _oauthlib_request = get_oauth_server().verify_request(
uri, http_method, body, headers, required_scopes
)
if valid:

View file

@ -67,7 +67,7 @@
"label": "Assignment Rules"
},
{
"description": "Simple Python Expression, Example: status == 'Open' and type == 'Bug'",
"description": "Simple Python Expression, Example: <code class=\"language-python\">status == 'Open' and issue_type == 'Bug'</code>",
"fieldname": "assign_condition",
"fieldtype": "Code",
"in_list_view": 1,
@ -80,7 +80,7 @@
"fieldtype": "Column Break"
},
{
"description": "Simple Python Expression, Example: Status in (\"Closed\", \"Cancelled\")",
"description": "Simple Python Expression, Example: <code class=\"language-python\">status in (\"Closed\", \"Cancelled\")</code>",
"fieldname": "unassign_condition",
"fieldtype": "Code",
"label": "Unassign Condition",
@ -119,7 +119,7 @@
"fieldtype": "Section Break"
},
{
"description": "Simple Python Expression, Example: Status in (\"Invalid\")",
"description": "Simple Python Expression, Example: <code class=\"language-python\">status == \"Invalid\"</code>",
"fieldname": "close_condition",
"fieldtype": "Code",
"label": "Close Condition",
@ -152,9 +152,10 @@
"mandatory_depends_on": "eval: doc.rule == 'Based on Field'"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-03-23 16:01:27.590910",
"modified": "2025-08-25 17:09:11.644603",
"modified_by": "Administrator",
"module": "Automation",
"name": "Assignment Rule",
@ -174,8 +175,9 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View file

@ -61,7 +61,7 @@ def create_rq_users(set_admin_password=False, use_rq_auth=False):
)
click.secho(f"`export {env_key}={user_credentials['default'][1]}`")
click.secho(
"NOTE: Please save the admin password as you " "can not access redis server without the password",
"NOTE: Please save the admin password as you can not access redis server without the password",
fg="yellow",
)

View file

@ -336,7 +336,7 @@ def restore_backup(
# Check if the backup is of an older version of frappe and the user hasn't specified force
if is_downgrade(sql_file_path, verbose=True) and not force:
warn_message = (
"This is not recommended and may lead to unexpected behaviour. " "Do you want to continue anyway?"
"This is not recommended and may lead to unexpected behaviour. Do you want to continue anyway?"
)
click.confirm(warn_message, abort=True)

View file

@ -297,7 +297,7 @@ class TestCommands(BaseTestCommands):
self.execute("bench --site {test_site} backup --exclude 'ToDo'", site_data)
site_data.update({"kw": "\"{'partial':True}\""})
self.execute(
"bench --site {test_site} execute" " frappe.utils.backups.fetch_latest_backups --kwargs {kw}",
"bench --site {test_site} execute frappe.utils.backups.fetch_latest_backups --kwargs {kw}",
site_data,
)
site_data.update({"database": json.loads(self.stdout)["database"]})

View file

@ -435,8 +435,7 @@ def import_doc(context: CliCtxObj, path, force=False):
type=click.Path(exists=True, dir_okay=False, resolve_path=True),
required=True,
help=(
"Path to import file (.csv, .xlsx)."
"Consider that relative paths will resolve from 'sites' directory"
"Path to import file (.csv, .xlsx). Consider that relative paths will resolve from 'sites' directory"
),
)
@click.option("--doctype", type=str, required=True)

View file

@ -25,7 +25,7 @@ class TestAuditTrail(IntegrationTestCase):
re_amended_doc = amend_document(amended_doc, changed_fields, {}, 1)
comparator = create_comparator_doc("Test Custom Doctype for Doc Comparator", re_amended_doc.name)
documents, results = comparator.compare_document()
_documents, results = comparator.compare_document()
test_field_values = results["changed"]["Field"]
self.check_expected_values(test_field_values, ["first value", "second value", "third value"])
@ -41,7 +41,7 @@ class TestAuditTrail(IntegrationTestCase):
amended_doc = amend_document(doc, {}, rows_updated, 1)
comparator = create_comparator_doc("Test Custom Doctype for Doc Comparator", amended_doc.name)
documents, results = comparator.compare_document()
_documents, results = comparator.compare_document()
results = frappe._dict(results)
self.check_rows_updated(results.row_changed)

View file

@ -565,11 +565,11 @@ def parse_email(email_strings):
for email in email_string.split(","):
local_part = email.split("@", 1)[0].strip('"')
user, detail = None, None
_user, detail = None, None
if "+" in local_part:
user, detail = local_part.split("+", 1)
_user, detail = local_part.split("+", 1)
elif "--" in local_part:
detail, user = local_part.rsplit("--", 1)
detail, _user = local_part.rsplit("--", 1)
if not detail:
continue

View file

@ -11,8 +11,8 @@
"label",
"fieldtype",
"fieldname",
"precision",
"length",
"precision",
"non_negative",
"hide_days",
"hide_seconds",
@ -136,7 +136,7 @@
},
{
"depends_on": "eval:in_list([\"Float\", \"Currency\", \"Percent\"], doc.fieldtype)",
"description": "Set non-standard precision for a Float or Currency field",
"description": "Set non-standard precision for a Float, Currency or Percent field",
"fieldname": "precision",
"fieldtype": "Select",
"label": "Precision",
@ -144,7 +144,7 @@
"print_hide": 1
},
{
"depends_on": "eval:in_list(['Data', 'Link', 'Dynamic Link', 'Password', 'Select', 'Read Only', 'Attach', 'Attach Image', 'Int'], doc.fieldtype)",
"depends_on": "eval:in_list(['Data', 'Link', 'Dynamic Link', 'Password', 'Select', 'Read Only', 'Attach', 'Attach Image', 'Int', 'Float', 'Currency', 'Percent'], doc.fieldtype)",
"fieldname": "length",
"fieldtype": "Int",
"label": "Length"
@ -622,7 +622,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-08-26 22:08:20.940308",
"modified": "2025-09-17 13:20:57.852396",
"modified_by": "Administrator",
"module": "Core",
"name": "DocField",

View file

@ -702,7 +702,7 @@
"label": "Protect Attached Files"
},
{
"default": "0",
"default": "20",
"depends_on": "istable",
"fieldname": "rows_threshold_for_grid_search",
"fieldtype": "Int",
@ -792,7 +792,7 @@
"link_fieldname": "document_type"
}
],
"modified": "2025-07-19 12:23:16.296416",
"modified": "2025-09-23 06:48:13.555017",
"modified_by": "Administrator",
"module": "Core",
"name": "DocType",

View file

@ -1660,6 +1660,18 @@ def validate_fields(meta: Meta):
if docfield.options and (int(docfield.options) > 10 or int(docfield.options) < 3):
frappe.throw(_("Options for Rating field can range from 3 to 10"))
def check_decimal_config(docfield):
if docfield.fieldtype not in ("Currency", "Float", "Percent"):
return
if docfield.length and docfield.precision:
if cint(docfield.precision) > cint(docfield.length):
frappe.throw(
_("Precision ({0}) for {1} cannot be greater than its length ({2}).").format(
docfield.precision, frappe.bold(docfield.label), docfield.length
)
)
fields = meta.get("fields")
fieldname_list = [d.fieldname for d in fields]
@ -1682,6 +1694,7 @@ def validate_fields(meta: Meta):
scrub_options_in_select(d)
validate_fetch_from(d)
validate_data_field_type(d)
check_decimal_config(d)
if not frappe.flags.in_migrate or in_ci:
check_unique_fieldname(meta.get("name"), d.fieldname)

View file

@ -827,6 +827,35 @@ class TestDocType(IntegrationTestCase):
self.assertEqual(get_format(compressed_dt), "COMPRESSED")
self.assertEqual(get_format(dynamic_dt), "DYNAMIC")
def test_decimal_field_configuration(self):
doctype = new_doctype(
"Test Decimal Config",
fields=[
{
"fieldname": "decimal_field",
"fieldtype": "Currency",
"length": 30,
"precision": 3,
}
],
).insert(ignore_if_duplicate=True)
decimal_field_type = frappe.db.get_column_type(doctype.name, "decimal_field")
self.assertIn("(30,3)", decimal_field_type.lower())
def test_decimal_field_precision_exceeds_length(self):
doctype = new_doctype(
"Test Decimal Config 2",
fields=[
{
"fieldname": "decimal_field",
"fieldtype": "Currency",
"length": 10,
"precision": 11,
}
],
)
self.assertRaises(frappe.ValidationError, doctype.insert)
def new_doctype(
name: str | None = None,

View file

@ -25,6 +25,7 @@ from frappe.utils import (
get_url,
)
from frappe.utils.file_manager import is_safe_path
from frappe.utils.html_utils import escape_html
from frappe.utils.image import optimize_image, strip_exif_data
from frappe.utils.pdf import pdf_contains_js
@ -37,7 +38,9 @@ from .exceptions import (
from .utils import *
exclude_from_linked_with = True
ImageFile.LOAD_TRUNCATED_IMAGES = True
ImageFile.LOAD_TRUNCATED_IMAGES = True # nosemgrep
URL_PREFIXES = ("http://", "https://", "/api/method/")
FILE_ENCODING_OPTIONS = ("utf-8-sig", "utf-8", "windows-1250", "windows-1252")
@ -139,7 +142,6 @@ class File(Document):
self.validate_file_url()
self.validate_file_on_disk()
self.file_size = frappe.form_dict.file_size or self.file_size
self.check_content()
def validate_attachment_references(self):
if not self.attached_to_doctype:
@ -434,6 +436,9 @@ class File(Document):
else:
self.file_name = re.sub(r"/", "", self.file_name)
# Escape HTML characters in file name
self.file_name = escape_html(self.file_name)
def generate_content_hash(self):
if self.content_hash or not self.file_url or self.is_remote_file:
return
@ -783,7 +788,7 @@ class File(Document):
def create_attachment_record(self):
icon = ' <i class="fa fa-lock text-warning"></i>' if self.is_private else ""
file_url = quote(frappe.safe_encode(self.file_url), safe="/:") if self.file_url else self.file_name
file_name = self.file_name or self.file_url
file_name = escape_html(self.file_name or self.file_url)
self.add_comment_in_reference_doc(
"Attachment",

View file

@ -30,6 +30,7 @@
"apply_strict_user_permissions",
"column_break_21",
"allow_older_web_view_links",
"show_external_link_warning",
"security_tab",
"security",
"session_expiry",
@ -744,12 +745,19 @@
"fieldtype": "Int",
"label": "Max signups allowed per hour",
"non_negative": 1
},
{
"default": "Never",
"fieldname": "show_external_link_warning",
"fieldtype": "Select",
"label": "Show External Link Warning",
"options": "Never\nAsk\nAlways"
}
],
"icon": "fa fa-cog",
"issingle": 1,
"links": [],
"modified": "2025-09-03 10:52:38.096662",
"modified": "2025-09-24 16:04:02.016562",
"modified_by": "Administrator",
"module": "Core",
"name": "System Settings",

View file

@ -97,6 +97,7 @@ class SystemSettings(Document):
session_expiry: DF.Data | None
setup_complete: DF.Check
show_absolute_datetime_in_timeline: DF.Check
show_external_link_warning: DF.Literal["Never", "Ask", "Always"]
store_attached_pdf_document: DF.Check
strip_exif_metadata_from_uploaded_images: DF.Check
time_format: DF.Literal["HH:mm:ss", "HH:mm"]

View file

@ -37,18 +37,6 @@ frappe.ui.form.on("User", {
}
},
role_profiles: function (frm) {
if (frm.doc.role_profiles && frm.doc.role_profiles.length) {
frm.roles_editor.disable = 1;
frm.call("populate_role_profile_roles").then(() => {
frm.roles_editor.show();
});
} else {
frm.roles_editor.disable = 0;
frm.roles_editor.show();
}
},
module_profile: function (frm) {
if (frm.doc.module_profile) {
frappe.call({
@ -431,6 +419,25 @@ frappe.ui.form.on("User Email", {
},
});
frappe.ui.form.on("User Role Profile", {
role_profiles_add: function (frm) {
if (frm.doc.role_profiles.length > 0) {
frm.roles_editor.disable = 1;
frm.call("populate_role_profile_roles").then(() => {
frm.roles_editor.show();
});
$(".deselect-all, .select-all").prop("disabled", true);
}
},
role_profiles_remove: function (frm) {
if (frm.doc.role_profiles.length == 0) {
frm.roles_editor.disable = 0;
frm.roles_editor.show();
$(".deselect-all, .select-all").prop("disabled", false);
}
},
});
function has_access_to_edit_user() {
return has_common(frappe.user_roles, get_roles_for_editing_user());
}

View file

@ -13,6 +13,7 @@
"label",
"search_fields",
"grid_page_length",
"rows_threshold_for_grid_search",
"link_filters",
"column_break_5",
"istable",
@ -43,6 +44,7 @@
"force_re_route_to_default_view",
"column_break_29",
"show_preview_popup",
"show_name_in_global_search",
"email_settings_section",
"default_email_template",
"column_break_26",
@ -422,6 +424,19 @@
"fieldname": "recipient_account_field",
"fieldtype": "Data",
"label": "Recipient Account Field"
},
{
"depends_on": "istable",
"fieldname": "rows_threshold_for_grid_search",
"fieldtype": "Int",
"label": "Rows Threshold for Grid Search",
"non_negative": 1
},
{
"default": "0",
"fieldname": "show_name_in_global_search",
"fieldtype": "Check",
"label": "Make \"name\" searchable in Global Search"
}
],
"hide_toolbar": 1,
@ -430,7 +445,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-07-19 12:23:41.564203",
"modified": "2025-09-23 07:13:52.631903",
"modified_by": "Administrator",
"module": "Custom",
"name": "Customize Form",

View file

@ -75,9 +75,11 @@ class CustomizeForm(Document):
queue_in_background: DF.Check
quick_entry: DF.Check
recipient_account_field: DF.Data | None
rows_threshold_for_grid_search: DF.Int
search_fields: DF.Data | None
sender_field: DF.Data | None
sender_name_field: DF.Data | None
show_name_in_global_search: DF.Check
show_preview_popup: DF.Check
show_title_field_in_link: DF.Check
sort_field: DF.Literal[None]
@ -306,6 +308,8 @@ class CustomizeForm(Document):
)
def set_property_setters_for_doctype(self, meta):
if self.get("show_name_in_global_search") != meta.get("show_name_in_global_search"):
self.flags.rebuild_doctype_for_global_search = True
for prop, prop_type in doctype_properties.items():
if self.get(prop) != meta.get(prop):
self.make_property_setter(prop, self.get(prop), prop_type)
@ -735,6 +739,7 @@ doctype_properties = {
"track_views": "Check",
"allow_auto_repeat": "Check",
"allow_import": "Check",
"show_name_in_global_search": "Check",
"show_preview_popup": "Check",
"default_email_template": "Data",
"email_append_to": "Check",
@ -748,6 +753,7 @@ doctype_properties = {
"force_re_route_to_default_view": "Check",
"translated_doctype": "Check",
"grid_page_length": "Int",
"rows_threshold_for_grid_search": "Int",
}
docfield_properties = {

View file

@ -147,7 +147,7 @@ class PostgresTable(DBTable):
if isinstance(default, str):
default = frappe.db.escape(default)
change_nullability.append(
f"ALTER COLUMN \"{col.fieldname}\" {'SET' if col.not_nullable else 'DROP'} NOT NULL"
f'ALTER COLUMN "{col.fieldname}" {"SET" if col.not_nullable else "DROP"} NOT NULL'
)
change_nullability.append(f'ALTER COLUMN "{col.fieldname}" SET DEFAULT {default}')

View file

@ -10,6 +10,10 @@ SPECIAL_CHAR_PATTERN = re.compile(r"[\W]", flags=re.UNICODE)
VARCHAR_CAST_PATTERN = re.compile(r"varchar\(([\d]+)\)")
CONFIGURABLE_DECIMAL_TYPES = ("Currency", "Float", "Percent")
DEFAULT_DECIMAL_LENGTH = 21
DEFAULT_DECIMAL_PRECISION = 9
class InvalidColumnName(frappe.ValidationError):
pass
@ -429,10 +433,13 @@ def get_definition(fieldtype, precision=None, length=None, *, options=None):
size = d[1] if d[1] else None
if size:
# This check needs to exist for backward compatibility.
# Till V13, default size used for float, currency and percent are (18, 6).
if fieldtype in ["Float", "Currency", "Percent"] and cint(precision) > 6:
size = "21,9"
if fieldtype in CONFIGURABLE_DECIMAL_TYPES:
width = length if length else DEFAULT_DECIMAL_LENGTH
precision_is_set = precision not in (None, "")
precision = precision if precision_is_set else DEFAULT_DECIMAL_PRECISION
if cint(precision) > cint(width):
precision = width
size = f"{cint(width)},{cint(precision)}"
if length:
if coltype == "varchar":

View file

@ -899,7 +899,7 @@ def tests_utils_get_dependencies(doctype):
import frappe
from frappe.tests.utils.generators import get_modules
module, test_module = get_modules(doctype)
_module, test_module = get_modules(doctype)
meta = frappe.get_meta(doctype)
link_fields = meta.get_link_fields()

View file

@ -448,7 +448,11 @@ frappe.ui.form.on("Number Card", {
let document_type = frm.doc.document_type;
let doc_is_table =
document_type &&
(await frappe.db.get_value("DocType", document_type, "istable")).message.istable;
(await new Promise((resolve) => {
frappe.model.with_doctype(document_type, () => {
resolve(frappe.get_meta(document_type).istable);
});
}));
frm.set_df_property("parent_document_type", "hidden", !doc_is_table);

View file

@ -622,8 +622,7 @@ def get_linked_fields(doctype, without_ignore_user_permissions_enabled=False):
"DocField", fields=["parent", "options"], filters=child_filters, as_list=1
):
ret[parent] = {"child_doctype": options, "fieldname": links_dict[options]}
if options in ret:
del ret[options]
ret.pop(options, None)
virtual_doctypes = frappe.get_all("DocType", {"is_virtual": 1}, pluck="name")
for dt in virtual_doctypes:

View file

@ -83,7 +83,7 @@ def get_group_by_count(doctype: str, current_filters: str, field: str) -> list[d
break
if owner_idx:
data = [data.pop(owner_idx)] + data[0:49]
data = [data.pop(owner_idx), *data[0:49]]
else:
data = data[0:50]
else:

View file

@ -548,7 +548,7 @@ def get_field_info(fields, doctype):
if parenttype != doctype:
# If the column is from a child table, append the child doctype.
# For example, "Item Code (Sales Invoice Item)".
label += f" ({ _(parenttype) })"
label += f" ({_(parenttype)})"
field_info.append(
{"name": name, "label": label, "fieldtype": fieldtype, "translatable": translatable}

View file

@ -117,6 +117,12 @@ def search_widget(
meta = frappe.get_meta(doctype)
include_disabled = False
if filters and "include_disabled" in filters:
if filters["include_disabled"] == 1:
include_disabled = True
filters.pop("include_disabled")
if isinstance(filters, dict):
filters = [make_filter_tuple(doctype, key, value) for key, value in filters.items()]
elif filters is None:
@ -147,10 +153,11 @@ def search_widget(
if not meta.translated_doctype and (f == "name" or (fmeta and fmeta.fieldtype in field_types)):
or_filters.append([doctype, f.strip(), "like", f"%{txt}%"])
if meta.get("fields", {"fieldname": "enabled", "fieldtype": "Check"}):
filters.append([doctype, "enabled", "=", 1])
if meta.get("fields", {"fieldname": "disabled", "fieldtype": "Check"}):
filters.append([doctype, "disabled", "!=", 1])
if not include_disabled:
if meta.get("fields", {"fieldname": "enabled", "fieldtype": "Check"}):
filters.append([doctype, "enabled", "=", 1])
if meta.get("fields", {"fieldname": "disabled", "fieldtype": "Check"}):
filters.append([doctype, "disabled", "!=", 1])
# format a list of fields combining search fields and filter fields
fields = get_std_fields_list(meta, searchfield or "name")

View file

@ -9,8 +9,7 @@ from frappe import _
def get_all_nodes(doctype, label, parent, tree_method, **filters):
"""Recursively gets all data from tree nodes"""
if "cmd" in filters:
del filters["cmd"]
filters.pop("cmd", None)
filters.pop("data", None)
tree_method = frappe.get_attr(tree_method)
@ -20,8 +19,7 @@ def get_all_nodes(doctype, label, parent, tree_method, **filters):
data = tree_method(doctype, parent, **filters)
out = [dict(parent=label, data=data)]
if "is_root" in filters:
del filters["is_root"]
filters.pop("is_root", None)
to_check = [d.get("value") for d in data if d.get("expandable")]
while to_check:

View file

@ -54,7 +54,7 @@ class TestEmailQueue(IntegrationTestCase):
Subject: {subject}
From: Test <test@example.com>
To: <!--recipient-->
Date: {frappe.utils.now_datetime().strftime('%a, %d %b %Y %H:%M:%S %z')}
Date: {frappe.utils.now_datetime().strftime("%a, %d %b %Y %H:%M:%S %z")}
Reply-To: test@example.com
X-Frappe-Site: {frappe.local.site}
"""

View file

@ -352,7 +352,9 @@ def get_context(context):
To queue a notification from a server script:
```python
notification = frappe.get_doc("Notification", "My Notification", ignore_permissions=True)
notification = frappe.get_doc(
"Notification", "My Notification", ignore_permissions=True
)
notification.queue_send(customer)
```

View file

@ -237,7 +237,7 @@ class EMail:
"""Append the message with MIME content to the root node (as attachment)"""
from email.mime.text import MIMEText
maintype, subtype = mime_type.split("/")
_maintype, subtype = mime_type.split("/")
part = MIMEText(message, _subtype=subtype, policy=policy.SMTP)
if as_attachment:
@ -445,7 +445,7 @@ def add_attachment(fname, fcontent, content_type=None, parent=None, content_id=N
from email.mime.text import MIMEText
if not content_type:
content_type, encoding = mimetypes.guess_type(fname)
content_type, _encoding = mimetypes.guess_type(fname)
if not parent:
return
@ -597,7 +597,7 @@ def get_header(header=None):
if not title:
title = frappe.get_hooks("app_title")[-1]
email_header, text = get_email_from_template(
email_header, _text = get_email_from_template(
"email_header", {"header_title": title, "indicator": indicator}
)

View file

@ -205,7 +205,7 @@ class EmailServer:
readonly = self.settings.email_sync_rule != "UNSEEN"
self.imap.select(folder, readonly=readonly)
response, message = self.imap.uid("search", None, self.settings.email_sync_rule)
_response, message = self.imap.uid("search", None, self.settings.email_sync_rule)
if message[0]:
email_list = message[0].split()
else:
@ -217,7 +217,7 @@ class EmailServer:
# compare the UIDVALIDITY of email account and imap server
uid_validity = self.settings.uid_validity
response, message = self.imap.status(folder, "(UIDVALIDITY UIDNEXT)")
_response, message = self.imap.status(folder, "(UIDVALIDITY UIDNEXT)")
current_uid_validity = self.parse_imap_response("UIDVALIDITY", message[0]) or 0
uidnext = int(self.parse_imap_response("UIDNEXT", message[0]) or "1")
@ -270,7 +270,7 @@ class EmailServer:
def retrieve_message(self, uid, msg_num, folder):
try:
if cint(self.settings.use_imap):
status, message = self.imap.uid("fetch", uid, "(BODY.PEEK[] BODY.PEEK[HEADER] FLAGS)")
_status, message = self.imap.uid("fetch", uid, "(BODY.PEEK[] BODY.PEEK[HEADER] FLAGS)")
raw = message[0]
self.get_email_seen_status(uid, raw[0])

View file

@ -34,7 +34,7 @@ class Here:
"label": address["label"],
"value": json.dumps(
{
"address_line1": f'{address.get("street", "")} {address.get("houseNumber", "")}'.strip(),
"address_line1": f"{address.get('street', '')} {address.get('houseNumber', '')}".strip(),
"city": address.get("city", ""),
"state": address.get("state", ""),
"pincode": address.get("postalCode", ""),

View file

@ -37,7 +37,7 @@ class Nomatim:
"label": result["display_name"],
"value": json.dumps(
{
"address_line1": f'{address.get("road")} {address.get("house_number", "")}'.strip(),
"address_line1": f"{address.get('road')} {address.get('house_number', '')}".strip(),
"city": address.get("city") or address.get("town") or address.get("village"),
"state": address.get("state"),
"pincode": address.get("postcode"),

View file

@ -240,7 +240,7 @@ class LDAP_TestCase:
function_return = self.test_class.connect_to_ldap(
base_dn=self.base_dn, password=self.base_password
)
args, kwargs = ldap3_connection_method.call_args
_args, kwargs = ldap3_connection_method.call_args
for connection_arg in kwargs:
if (
@ -305,7 +305,7 @@ class LDAP_TestCase:
base_dn=self.base_dn, password=self.base_password, read_only=False
)
args, kwargs = ldap3_connection_method.call_args
_args, kwargs = ldap3_connection_method.call_args
self.assertFalse(
kwargs["read_only"],

View file

@ -72,7 +72,7 @@ def approve(*args, **kwargs):
frappe.flags.oauth_credentials,
) = get_oauth_server().validate_authorization_request(r.url, r.method, r.get_data(), r.headers)
headers, body, status = get_oauth_server().create_authorization_response(
headers, _body, _status = get_oauth_server().create_authorization_response(
uri=frappe.flags.oauth_credentials["redirect_uri"],
body=r.get_data(),
headers=r.headers,
@ -144,7 +144,7 @@ def authorize(**kwargs):
def get_token(*args, **kwargs):
try:
r = frappe.request
headers, body, status = get_oauth_server().create_token_response(
_headers, body, _status = get_oauth_server().create_token_response(
r.url, r.method, r.form, r.headers, frappe.flags.oauth_credentials
)
body = frappe._dict(json.loads(body))
@ -165,7 +165,7 @@ def get_token(*args, **kwargs):
def revoke_token(*args, **kwargs):
try:
r = frappe.request
headers, body, status = get_oauth_server().create_revocation_response(
_headers, _body, status = get_oauth_server().create_revocation_response(
r.url,
headers=r.headers,
body=r.form,
@ -184,7 +184,7 @@ def revoke_token(*args, **kwargs):
def openid_profile(*args, **kwargs):
try:
r = frappe.request
headers, body, status = get_oauth_server().create_userinfo_response(
_headers, body, _status = get_oauth_server().create_userinfo_response(
r.url,
headers=r.headers,
body=r.form,

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

@ -443,8 +443,6 @@ from {tables}
"concat",
"concat_ws",
"if",
"ifnull",
"nullif",
"coalesce",
"connection_id",
"current_user",
@ -474,16 +472,19 @@ from {tables}
if SUB_QUERY_PATTERN.match(field):
# Check for subquery anywhere in the field, not just at the beginning
if "(" in lower_field:
location = lower_field.index("(")
subquery_token = lower_field[location + 1 :].lstrip().split(" ", 1)[0]
if any(keyword in subquery_token for keyword in blacklisted_keywords):
_raise_exception()
function = lower_field.split("(", 1)[0].rstrip()
if function in blacklisted_functions:
frappe.throw(
_("Use of function {0} in field is restricted").format(function), exc=frappe.DataError
)
# 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
if "@" in lower_field:
# prevent access to global variables

View file

@ -3,6 +3,7 @@
import os
import shutil
from typing import Any
import frappe
import frappe.defaults
@ -20,19 +21,58 @@ from frappe.utils.password import delete_all_passwords_for
def delete_doc(
doctype=None,
name=None,
force=0,
ignore_doctypes=None,
for_reload=False,
ignore_permissions=False,
flags=None,
ignore_on_trash=False,
ignore_missing=True,
delete_permanently=False,
):
doctype: str | None = None,
name: str | int | list[str | int] | None = None,
force: int | bool = 0,
ignore_doctypes: list[str] | None = None,
for_reload: bool = False,
ignore_permissions: bool = False,
flags: dict[str, Any] | None = None,
ignore_on_trash: bool = False,
ignore_missing: bool = True,
delete_permanently: bool = False,
) -> bool | None:
"""
Deletes a doc(dt, dn) and validates if it is not submitted and not linked in a live record
Deletes a document and validates if it is not submitted and not linked in a live record.
Args:
doctype (str, optional): The document type to delete. If not provided,
retrieved from frappe.form_dict.get("dt"). Defaults to None.
name (str | int | list, optional): The name/ID of the document(s) to delete.
Can be a single name or a list of names. If not provided,
retrieved from frappe.form_dict.get("dn"). Defaults to None.
force (bool, optional): When True, bypasses link existence checks and allows
deletion of documents that are linked to other records. Also allows
deletion of standard DocTypes. Defaults to 0 (False).
ignore_doctypes (list, optional): A list of child doctypes to ignore when
deleting child table records associated with the document. Defaults to None.
for_reload (bool, optional): When True, indicates the deletion is for reloading
purposes (like during doctype updates). Skips certain validations like
permissions and on_trash methods, and automatically sets delete_permanently=True.
Defaults to False.
ignore_permissions (bool, optional): When True, bypasses permission checks
during deletion. Useful for system operations. Defaults to False.
flags (dict, optional): Additional flags to set on the document during the
deletion process. These flags affect document behavior during deletion.
Defaults to None.
ignore_on_trash (bool, optional): When True, skips calling the document's
on_trash method, which typically contains cleanup logic. Defaults to False.
ignore_missing (bool, optional): When True, doesn't raise an error if the
document doesn't exist and returns False. When False, raises
frappe.DoesNotExistError if document is missing. Defaults to True.
delete_permanently (bool, optional): When True, permanently deletes the document
without adding it to the "Deleted Document" table for recovery purposes.
When False, the document is soft-deleted and can be recovered. Defaults to False.
Raises:
frappe.DoesNotExistError: When document doesn't exist and ignore_missing=False.
frappe.LinkExistsError: When document is linked to other records and force=False.
frappe.PermissionError: When user doesn't have delete permissions and ignore_permissions=False.
frappe.ValidationError: When trying to delete a submitted document.
frappe.QueryTimeoutError: When document is locked by another user.
Returns:
bool: False if document doesn't exist and ignore_missing=True, otherwise None.
"""
if not ignore_doctypes:
ignore_doctypes = []

View file

@ -13,7 +13,7 @@ dynamic_link_queries = [
`tabDocField`.fieldname, `tabDocField`.options
from `tabDocField`, `tabDocType`
where `tabDocField`.fieldtype='Dynamic Link' and
`tabDocType`.`name`=`tabDocField`.parent and `tabDocType`.is_virtual = 0
`tabDocType`.`name`=`tabDocField`.parent and `tabDocType`.is_virtual = 0 and `tabDocField`.is_virtual = 0
order by `tabDocType`.read_only, `tabDocType`.in_create""",
"""select `tabCustom Field`.dt as parent,
`tabDocType`.read_only, `tabDocType`.in_create,

View file

@ -345,7 +345,7 @@ def _bulk_workflow_action(docnames, doctype, action):
frappe.message_log.pop()
message_dict = {"docname": docname, "message": message.get("message")}
if message.get("raise_exception", False):
if message.get("raise_exception", False) or "Error" in message.get("message", ""):
failed_transactions[docname].append(message_dict)
else:
successful_transactions[docname].append(message_dict)

View file

@ -144,7 +144,7 @@ onMounted(() => store.fetch());
}
.editable {
input,
input:not([type="checkbox"]),
textarea,
select,
.ace_editor,
@ -258,7 +258,7 @@ onMounted(() => store.fetch());
border-color: transparent;
}
input,
input:not([type="checkbox"]),
textarea,
select,
.ace_editor,
@ -269,10 +269,6 @@ onMounted(() => store.fetch());
.ql-editor {
background-color: var(--control-bg) !important;
}
input[type="checkbox"] {
background-color: var(--fg-bg) !important;
}
}
.form-main > :deep(div:first-child:not(.tab-header)) {

View file

@ -1,8 +1,18 @@
<script setup>
import { useSlots } from "vue";
import { useSlots, computed } from "vue";
const props = defineProps(["df", "value", "read_only"]);
let slots = useSlots();
// Get the display value considering both current value and default
let display_checked = computed(() => {
// Use current value if explicitly set, otherwise fall back to default
const value =
props.value !== undefined && props.value !== null ? props.value : props.df.default;
// Frappe checkboxes use "1"/"0" strings or 1/0 numbers
return value === "1" || value === 1;
});
</script>
<template>
@ -10,7 +20,7 @@ let slots = useSlots();
<!-- checkbox -->
<label v-if="slots.label" class="field-controls">
<div class="checkbox">
<input type="checkbox" disabled />
<input type="checkbox" :checked="display_checked" disabled />
<slot name="label" />
</div>
<slot name="actions" />
@ -18,7 +28,7 @@ let slots = useSlots();
<label v-else>
<input
type="checkbox"
:checked="value"
:checked="display_checked"
:disabled="read_only"
@change="(event) => $emit('update:modelValue', event.target.checked)"
/>
@ -42,7 +52,6 @@ label .checkbox {
align-items: center;
input {
background-color: var(--fg-color);
box-shadow: none;
border: 1px solid var(--gray-400);
pointer-events: none;

View file

@ -67,10 +67,20 @@ let select_control = computed(() => {
});
let content = computed({
get: () => props.modelValue,
get: () => props.modelValue ?? props.df.default,
set: (value) => emit("update:modelValue", value),
});
// Get the display label for the current selected value
let display_value = computed(() => {
const current_value = content.value;
if (!current_value) return "";
const options = get_options();
const selected_option = options?.find((opt) => opt.value === current_value);
return selected_option ? selected_option.label : current_value;
});
onMounted(() => {
if (select.value) select_control.value;
});
@ -101,7 +111,7 @@ watch(
<!-- select input -->
<div class="select-input">
<input class="form-control" readonly />
<input class="form-control" readonly :value="display_value" />
<div class="select-icon" v-html="frappe.utils.icon('select', 'sm')"></div>
</div>

View file

@ -416,7 +416,12 @@ function format_content_for_timeline(content) {
function get_user_link(user) {
const user_display_text = frappe.user_info(user).fullname || "";
return frappe.utils.get_form_link("User", user, true, user_display_text);
return frappe.utils.get_form_link(
"User",
user,
true,
frappe.utils.xss_sanitise(user_display_text)
);
}
function get_user_message(user, message_self, message_other) {

View file

@ -8,11 +8,12 @@ export default class GridRow {
this.set_docfields();
this.columns = {};
this.columns_list = [];
this.depandant_fields = {
this.dependent_fields = {
mandatory: [],
read_only: [],
};
this.row_check_html = '<input type="checkbox" class="grid-row-check" tabIndex="-1">';
this.default_rows_threshold_for_grid_search = 20;
this.make();
}
make() {
@ -160,7 +161,7 @@ export default class GridRow {
this.grid.add_new_row(idx, null, show, copy_doc);
}
move() {
// promopt the user where they want to move this row
// prompt the user where they want to move this row
var me = this;
frappe.prompt(
{
@ -802,7 +803,7 @@ export default class GridRow {
this.evaluate_depends_on_value(df.mandatory_depends_on)
) {
df.reqd = 1;
this.depandant_fields["mandatory"].push(df);
this.dependent_fields["mandatory"].push(df);
}
if (
@ -811,16 +812,16 @@ export default class GridRow {
this.evaluate_depends_on_value(df.read_only_depends_on)
) {
df.read_only = 1;
this.depandant_fields["read_only"].push(df);
this.dependent_fields["read_only"].push(df);
}
}
refresh_depedency() {
this.depandant_fields["read_only"].forEach((df) => {
refresh_dependency() {
this.dependent_fields["read_only"].forEach((df) => {
df.read_only = 0;
this.set_dependant_property(df);
});
this.depandant_fields["mandatory"].forEach((df) => {
this.dependent_fields["mandatory"].forEach((df) => {
df.reqd = 0;
this.set_dependant_property(df);
});
@ -871,7 +872,7 @@ export default class GridRow {
let show_length =
this.grid?.meta?.rows_threshold_for_grid_search > 0
? this.grid.meta.rows_threshold_for_grid_search
: 20;
: this.default_rows_threshold_for_grid_search;
this.show_search =
this.show_search &&
(this.grid?.data?.length >= show_length || this.grid.filter_applied);
@ -1016,7 +1017,7 @@ export default class GridRow {
}
}
// Delay date_picker widget to prevent temparary layout shift (UX).
// Delay date_picker widget to prevent temporary layout shift (UX).
function handle_date_picker() {
let date_time_picker = document.querySelectorAll(".datepicker.active")[0];
@ -1184,7 +1185,7 @@ export default class GridRow {
// df.onchange is common for all rows in grid
let field_on_change_function = df.onchange;
field.df.change = (e) => {
this.refresh_depedency();
this.refresh_dependency();
// trigger onchange with current grid row field as "this"
field_on_change_function && field_on_change_function.apply(field, [e]);
me.refresh_field(field.df.fieldname);

View file

@ -126,7 +126,7 @@ frappe.ui.form.Attachments = class Attachments {
<a href="${file_url}" target="_blank" title="${frappe.utils.escape_html(file_name)}"
class="ellipsis attachment-file-label"
>
<span>${file_name}</span>
<span>${frappe.utils.xss_sanitise(file_name)}</span>
</a>`;
let remove_action = null;

View file

@ -336,7 +336,7 @@ frappe.ui.form.Toolbar = class Toolbar {
) {
this.page.add_menu_item(
__("Discard"),
function () {
() => {
this.frm._discard();
},
true

View file

@ -482,7 +482,9 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
get_documentation_link() {
if (this.meta.documentation) {
return `<a href="${this.meta.documentation}" target="blank" class="meta-description small text-muted">Need Help?</a>`;
return `<a href="${
this.meta.documentation
}" target="blank" class="meta-description small text-muted">${__("Need Help?")}</a>`;
}
return "";
}

View file

@ -54,6 +54,8 @@ frappe.RoleEditor = class {
this.make_perm_dialog();
}
$(this.perm_dialog.body).empty();
let is_dark = document.documentElement.getAttribute("data-theme") === "dark";
let header_bg_color = is_dark ? "bg-dark text-white" : "bg-light";
return frappe
.xcall("frappe.core.doctype.user.user.get_perm_info", { role })
.then((permissions) => {
@ -64,17 +66,26 @@ frappe.RoleEditor = class {
</div>`);
} else {
$body.append(`
<table class="user-perm">
<thead>
<tr>
<th> ${__("Document Type")} </th>
<th> ${__("Level")} </th>
<th> ${__("If Owner")} </th>
${frappe.perm.rights.map((p) => `<th> ${__(frappe.unscrub(p))}</th>`).join("")}
</tr>
</thead>
<tbody></tbody>
</table>
<div style="max-height:calc(100vh - 200px); overflow-y:auto;">
<table class="user-perm">
<thead>
<tr>
<th class="sticky-top ${header_bg_color}"> ${__("Document Type")} </th>
<th class="sticky-top ${header_bg_color}"> ${__("Level")} </th>
<th class="sticky-top ${header_bg_color}"> ${__("If Owner")} </th>
${frappe.perm.rights
.map(
(p) =>
`<th class="sticky-top ${header_bg_color}">${__(
frappe.unscrub(p)
)}</th>`
)
.join("")}
</tr>
</thead>
<tbody></tbody>
</table>
</div>
`);
permissions.forEach((perm) => {
$body.find("tbody").append(`

View file

@ -28,6 +28,11 @@ $("body").on("click", "a", function (e) {
const href = target_element.getAttribute("href");
const is_on_same_host = target_element.hostname === window.location.hostname;
if (frappe.router.show_external_link_warning_if_needed(target_element)) {
e.preventDefault();
return; // warning shown
}
if (target_element.getAttribute("target") === "_blank") {
return;
}
@ -570,6 +575,105 @@ frappe.router = {
slug(name) {
return name.toLowerCase().replace(/ /g, "-");
},
show_external_link_warning_if_needed(/** @type {HTMLAnchorElement} */ aElement) {
try {
if (!aElement?.href) {
return false; // not a true link
}
// Get the external link handling type
/** @type {'Always' | 'Ask' | 'Never' | null} */
const showWarningWhen = frappe.boot.show_external_link_warning || "Never";
if (showWarningWhen == "Never") {
return false; // the feature is disabled
}
// Check that the origin is external (does not prevent self-clickjacking on GET endpoints)
const url = new URL(aElement.href);
const hostname = url.hostname;
if (hostname === window.location.hostname) {
return false; // self-linking is allowed
}
// Check if the origin was ignored by the user
const localStorageKey = `skip-external-link-warning:${hostname}`;
if (showWarningWhen == "Ask" && localStorage.getItem(localStorageKey)) {
return false; // user chose to skip warning forever
}
// Check if the link if inside the confirmation popup
const incominSkipToken = aElement.getAttribute("data-skip-link-warning");
if (incominSkipToken && sessionStorage.getItem(incominSkipToken) == "1") {
return false; // anchor is the confirmation itself
}
// Finally, show the warning
const dialog = new frappe.ui.Dialog({
title: __("Warning"),
primary_action: null,
fields: [
{
fieldname: "warning_html",
fieldtype: "HTML",
},
{
fieldname: "confirm_checkbox",
fieldtype: "Check",
label: __("Do not warn me again about {0}", [
frappe.utils.escape_html(hostname).bold(),
]),
default: 0,
hidden: showWarningWhen == "Always",
change() {
if (dialog.get_value("confirm_checkbox")) {
localStorage.setItem(localStorageKey, "1");
} else {
localStorage.removeItem(localStorageKey);
}
},
},
],
});
const warningElement = dialog.fields_dict.warning_html.$wrapper.get(0);
const introElement = document.createElement("p");
introElement.textContent = __(
"You are about to open an external link. To confirm, click the link again."
);
warningElement.appendChild(introElement);
const boxElement = document.createElement("div");
boxElement.classList.add("border", "rounded-lg", "p-3", "mt-6", "mb-6", "text-center");
warningElement.appendChild(boxElement);
const hintElement = document.createElement("p");
hintElement.classList.add("text-sm", "mb-1");
hintElement.textContent = __("You will be redirected to:");
boxElement.appendChild(hintElement);
const confirmElement = document.createElement("a");
confirmElement.classList.add("text-sm", "font-mono");
confirmElement.style.wordBreak = "break-all";
confirmElement.textContent = aElement.href;
confirmElement.href = aElement.href;
confirmElement.target = aElement.target;
confirmElement.addEventListener("click", () => dialog.hide(), { capture: true });
// Add a token to skip the warning when clicking inside the confirmation dialog
const skipToken = frappe.utils.get_random(16);
confirmElement.setAttribute("data-skip-link-warning", skipToken);
sessionStorage.setItem(skipToken, "1");
boxElement.appendChild(confirmElement);
dialog.show();
return true; // prevent default handling
} catch (e) {
console.error(e);
}
return false;
},
};
// global functions for backward compatibility

View file

@ -85,6 +85,9 @@ frappe.ui.Scanner = class Scanner {
on_hide: () => {
this.stop_scan();
},
minimizable: this.options.minimizable,
primary_action_label: this.options.primary_action_label,
primary_action: this.options.primary_action,
});
return dialog;
}

View file

@ -14,7 +14,7 @@ frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup {
this.is_dialog = true;
this.last_focus = null;
$.extend(this, { animate: true, size: null, auto_make: true }, opts);
$.extend(this, { animate: true, size: null, auto_make: true, centered: false }, opts);
if (this.auto_make) {
this.make();
}
@ -34,6 +34,7 @@ frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup {
if (!this.size) this.set_modal_size();
this.wrapper = this.$wrapper.find(".modal-dialog").get(0);
if (this.centered) $(this.wrapper).addClass("modal-dialog-centered");
if (this.size == "small") $(this.wrapper).addClass("modal-sm");
else if (this.size == "large") $(this.wrapper).addClass("modal-lg");
else if (this.size == "extra-large") $(this.wrapper).addClass("modal-xl");

View file

@ -31,7 +31,17 @@ frappe.views.KanbanView = class KanbanView extends frappe.views.ListView {
if (!kanbans.length) {
return frappe.views.KanbanView.show_kanban_dialog(this.doctype, true);
} else if (kanbans.length && frappe.get_route().length !== 4) {
return frappe.views.KanbanView.show_kanban_dialog(this.doctype, true);
// Try to use the last board the user used, else default to the first available board
const last_board = frappe.get_user_settings(this.doctype)["Kanban"]
?.last_kanban_board;
if (last_board && kanbans.includes(last_board)) {
frappe.set_route("List", this.doctype, "Kanban", last_board);
return;
} else {
const first_board = kanbans[0];
frappe.set_route("List", this.doctype, "Kanban", first_board.name);
return;
}
} else {
this.kanbans = kanbans;
@ -120,6 +130,10 @@ frappe.views.KanbanView = class KanbanView extends frappe.views.ListView {
// pass
}
set_result_height() {
// pass
}
toggle_result_area() {
this.$result.toggle(this.data.length > 0);
}

View file

@ -720,6 +720,14 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList {
}
return new Promise((resolve) => {
const js_filters = (frappe.query_reports[this.report_name]?.filters || [])
.filter(
(filter) => filter.fieldtype === "Link" && filters[filter.fieldname] !== ""
)
.map(({ fieldname, fieldtype, options }) => ({ fieldname, fieldtype, options }));
console.log(js_filters, "js_filters");
this.last_ajax = frappe.call({
method: "frappe.desk.query_report.run",
type: "GET",
@ -730,7 +738,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList {
is_tree: this.report_settings.tree,
parent_field: this.report_settings.parent_field,
are_default_filters: are_default_filters,
js_filters: frappe.query_reports[this.report_name]?.filters,
js_filters: js_filters,
},
callback: resolve,
always: () => this.page.btn_secondary.prop("disabled", false),

View file

@ -355,7 +355,7 @@ frappe.views.TreeView = class TreeView {
var node = me.tree.get_selected_node();
if (!(node && node.expandable)) {
frappe.msgprint(__("Select a group node first."));
frappe.msgprint(__("Select a group {0} first.", [__(me.doctype)]));
return;
}
@ -415,8 +415,10 @@ frappe.views.TreeView = class TreeView {
{
fieldtype: "Check",
fieldname: "is_group",
label: __("Group Node"),
description: __("Further nodes can be only created under 'Group' type nodes"),
label: __("Is Group"),
description: __(
"Further sub-groups can only be created under records marked as 'Group'"
),
},
];

View file

@ -345,3 +345,13 @@ body.modal-open[style^="padding-right"] {
}
}
}
.modal-dialog-centered {
display: flex;
align-items: center;
min-height: calc(100% - 1rem);
}
@media (min-width: 576px) {
.modal-dialog-centered {
min-height: calc(100% - 3.5rem);
}
}

View file

@ -49,10 +49,6 @@
align-items: unset;
}
.input-area {
margin-top: 0.2rem;
}
.label-area {
white-space: unset;
}

View file

@ -28,6 +28,7 @@ table.user-perm {
margin-bottom: var(--margin-sm);
label {
position: relative;
align-items: center;
}
input[type="checkbox"] {
margin-left: 0;

View file

@ -181,6 +181,7 @@ def get():
bootinfo["user"]["impersonated_by"] = frappe.session.data.get("impersonated_by")
bootinfo["navbar_settings"] = frappe.client_cache.get_doc("Navbar Settings")
bootinfo.has_app_updates = has_app_update_notifications()
bootinfo.show_external_link_warning = frappe.get_system_settings("show_external_link_warning")
return bootinfo

View file

@ -471,7 +471,7 @@ class TestResponse(FrappeAPITestCase):
}
for redirect, expected_redirect in expected_redirects.items():
response = self.get(f"/login?{urlencode({'redirect-to':redirect})}", {"sid": self.sid})
response = self.get(f"/login?{urlencode({'redirect-to': redirect})}", {"sid": self.sid})
self.assertEqual(response.location, expected_redirect)

View file

@ -205,7 +205,7 @@ def custom_has_permission(doc, ptype, user):
def custom_auth():
auth_type, token = frappe.get_request_header("Authorization", "Bearer ").split(" ")
_auth_type, token = frappe.get_request_header("Authorization", "Bearer ").split(" ")
if token == "set_test_example_user":
frappe.set_user("test@example.com")

View file

@ -121,7 +121,7 @@ class TestPatchReader(IntegrationTestCase):
@patch("builtins.open", new_callable=mock_open, read_data=EDGE_CASES)
def test_new_style_edge_cases(self, _file):
all, pre, post = self.get_patches()
_all, pre, _post = self.get_patches()
self.assertEqual(
pre,
[
@ -134,7 +134,7 @@ class TestPatchReader(IntegrationTestCase):
@patch("builtins.open", new_callable=mock_open, read_data=COMMENTED_OUT)
def test_ignore_comments(self, _file):
all, pre, post = self.get_patches()
_all, pre, _post = self.get_patches()
self.assertEqual(pre, ["app.module.patch1", "app.module.patch3"])
def test_verify_patch_txt(self):

View file

@ -53,7 +53,7 @@ class TestQueryReport(IntegrationTestCase):
visible_idx = [0, 2, 3]
# Build the result
xlsx_data, column_widths = build_xlsx_data(
xlsx_data, _column_widths = build_xlsx_data(
data, visible_idx, include_indentation=False, include_filters=True
)

View file

@ -59,6 +59,7 @@ from frappe.utils.data import (
cint,
comma_and,
comma_or,
compare,
cstr,
duration_to_seconds,
evaluate_filters,
@ -236,6 +237,111 @@ class TestFilters(IntegrationTestCase):
}
self.assertFalse(evaluate_filters(doc, [("last_password_reset_date", "Timespan", "today")]))
def test_is_operator(self):
"""Test 'is' operator for checking if values are set or not set."""
# Test "is set" with different fieldtypes and values
self.assertTrue(compare("1", "is", "set", "Int"))
self.assertTrue(compare(1, "is", "set", "Int"))
self.assertTrue(compare(0, "is", "set", "Int")) # 0 is considered "set"
self.assertTrue(compare("hello", "is", "set", "Data"))
self.assertTrue(compare(0.0, "is", "set", "Float"))
# Test "is set" with unset values - None should always be "not set" regardless of fieldtype
self.assertFalse(compare(None, "is", "set", "Int"))
self.assertFalse(compare(None, "is", "set", "Float"))
self.assertFalse(compare(None, "is", "set", "Check"))
self.assertFalse(compare(None, "is", "set", "Data"))
self.assertFalse(compare("", "is", "set"))
self.assertFalse(compare("", "is", "set", "Data"))
self.assertFalse(compare(None, "is", "set"))
# Test "is not set" with set values
self.assertFalse(compare("1", "is", "not set", "Int"))
self.assertFalse(compare(1, "is", "not set", "Int"))
self.assertFalse(compare(0, "is", "not set", "Int"))
self.assertFalse(compare("hello", "is", "not set", "Data"))
self.assertFalse(compare(0.0, "is", "not set", "Float"))
# Test "is not set" with unset values - None should always be "not set" regardless of fieldtype
self.assertTrue(compare(None, "is", "not set", "Int"))
self.assertTrue(compare(None, "is", "not set", "Float"))
self.assertTrue(compare(None, "is", "not set", "Check"))
self.assertTrue(compare(None, "is", "not set", "Data"))
self.assertTrue(compare("", "is", "not set"))
self.assertTrue(compare("", "is", "not set", "Data"))
self.assertTrue(compare(None, "is", "not set"))
def test_in_operators(self):
"""Test 'in' and 'not in' operators with and without fieldtype casting."""
test_list = ["a", "b", "c"]
# Test "in" operator without fieldtype
self.assertTrue(compare("a", "in", test_list))
self.assertFalse(compare("", "in", test_list))
self.assertFalse(compare("d", "in", test_list))
self.assertFalse(compare(None, "in", test_list))
# Test "not in" operator without fieldtype
self.assertFalse(compare("a", "not in", test_list))
self.assertTrue(compare("", "not in", test_list))
self.assertTrue(compare("d", "not in", test_list))
self.assertTrue(compare(None, "not in", test_list))
# Test "in" operator with fieldtype casting - only first value should be cast
string_list = ["1", "2", "3"]
self.assertTrue(compare(1, "in", string_list, "Data"))
self.assertTrue(compare("2", "in", string_list, "Data"))
self.assertFalse(compare(4, "in", string_list, "Data"))
# Test type mismatch: Int fieldtype with string list (val2 is NOT cast)
mixed_list = ["1", "2", "3"]
self.assertFalse(compare("1", "in", mixed_list, "Int"))
self.assertFalse(compare(1, "in", mixed_list, "Int"))
# Test with matching types: Int fieldtype with int list
int_list = [1, 2, 3]
self.assertTrue(compare("1", "in", int_list, "Int"))
self.assertTrue(compare(2, "in", int_list, "Int"))
self.assertFalse(compare("4", "in", int_list, "Int"))
# Test "not in" operator with fieldtype casting
self.assertFalse(compare(1, "not in", string_list, "Data"))
self.assertFalse(compare("2", "not in", string_list, "Data"))
self.assertTrue(compare(4, "not in", string_list, "Data"))
# Test "not in" with type mismatch
self.assertTrue(compare("1", "not in", mixed_list, "Int"))
self.assertFalse(compare("1", "not in", int_list, "Int"))
# Test with Float fieldtype
float_list = [1.5, 2.5, 3.5]
self.assertTrue(compare("1.5", "in", float_list, "Float"))
self.assertFalse(compare("4.5", "in", float_list, "Float"))
# Test None with "in"/"not in" operators - None should not be cast
self.assertFalse(compare(None, "in", [""], "Data"))
self.assertFalse(compare(None, "in", [0], "Int"))
self.assertFalse(compare(None, "in", [0.0], "Float"))
self.assertFalse(compare(None, "in", ["", "test"], "Data"))
self.assertTrue(compare(None, "in", [None, "test"], "Data"))
# Test "not in" with None
self.assertTrue(compare(None, "not in", [""], "Data"))
self.assertTrue(compare(None, "not in", [0], "Int"))
self.assertTrue(compare(None, "not in", [0.0], "Float"))
self.assertTrue(compare(None, "not in", ["", "test"], "Data"))
self.assertFalse(compare(None, "not in", [None, "test"], "Data"))
def test_is_operator_case_insensitive(self):
"""Test that 'is' operator patterns are case insensitive."""
self.assertTrue(compare("value", "is", "SET"))
self.assertTrue(compare("value", "is", "Set"))
self.assertTrue(compare("value", "is", "set"))
self.assertTrue(compare(None, "is", "NOT SET"))
self.assertTrue(compare(None, "is", "Not Set"))
self.assertTrue(compare(None, "is", "not set"))
class TestMoney(IntegrationTestCase):
def test_money_in_words(self):
@ -362,7 +468,7 @@ class TestMathUtils(IntegrationTestCase):
self.assertEqual(floor(22.7330), 22)
self.assertEqual(floor("24.7"), 24)
self.assertEqual(floor("26.7"), 26)
self.assertEqual(floor(Decimal(29.45)), 29)
self.assertEqual(floor(Decimal("29.45")), 29)
def test_ceil(self):
from decimal import Decimal
@ -372,7 +478,7 @@ class TestMathUtils(IntegrationTestCase):
self.assertEqual(ceil(22.7330), 23)
self.assertEqual(ceil("24.7"), 25)
self.assertEqual(ceil("26.7"), 27)
self.assertEqual(ceil(Decimal(29.45)), 30)
self.assertEqual(ceil(Decimal("29.45")), 30)
class TestHTMLUtils(IntegrationTestCase):
@ -800,7 +906,7 @@ class TestResponse(IntegrationTestCase):
timedelta(days=10, hours=12, minutes=120, seconds=10),
],
"float": [
Decimal(29.21),
Decimal("29.21"),
],
"doc": [
frappe.get_doc("System Settings"),
@ -1071,7 +1177,7 @@ class TestMiscUtils(IntegrationTestCase):
self.assertIsInstance(get_file_timestamp(__file__), str)
def test_execute_in_shell(self):
err, out = execute_in_shell("ls")
_err, out = execute_in_shell("ls")
self.assertIn("apps", cstr(out))
def test_get_all_sites(self):

View file

@ -63,7 +63,7 @@ def get_missing_records_doctypes(doctype, visited=None) -> list[str]:
# Mark as visited
visited.add(doctype)
module, test_module = get_modules(doctype)
_module, test_module = get_modules(doctype)
meta = frappe.get_meta(doctype)
link_fields = meta.get_link_fields()
@ -158,12 +158,11 @@ def _generate_records_for(
index_doctype: str, reset: bool = False, commit: bool = False, initial_doctype: str | None = None
) -> Generator[tuple[str, "Document"], None, None]:
"""Create and yield test records for a specific doctype."""
module: str
test_module: ModuleType
logstr = f" {index_doctype} via {initial_doctype}"
module, test_module = get_modules(index_doctype)
_module, test_module = get_modules(index_doctype)
# First prioriry: module's _make_test_records as an escape hatch
# to completely bypass the standard loading and create test records

View file

@ -695,9 +695,9 @@ def write_csv_file(path, app_messages, lang_dict):
if len(app_message) == 2:
path, message = app_message
elif len(app_message) == 3:
path, message, lineno = app_message
path, message, _lineno = app_message
elif len(app_message) == 4:
path, message, context, lineno = app_message
path, message, context, _lineno = app_message
else:
continue

View file

@ -610,7 +610,7 @@ def get_disk_usage():
files_path = get_files_path()
if not os.path.exists(files_path):
return 0
err, out = execute_in_shell(f"du -hsm {files_path}")
_err, out = execute_in_shell(f"du -hsm {files_path}")
return cint(out.split("\n")[-2].split("\t")[0])

View file

@ -592,7 +592,7 @@ def get_redis_conn(username=None, password=None):
return RedisQueue.get_connection(**cred)
except redis.exceptions.AuthenticationError:
log(
f'Wrong credentials used for {cred.username or "default user"}. '
f"Wrong credentials used for {cred.username or 'default user'}. "
"You can reset credentials using `bench create-rq-users` CLI and restart the server",
colour="red",
)

View file

@ -298,7 +298,7 @@ class BackupGenerator:
def zip_files(self):
# For backwards compatibility - pre v13
click.secho(
"BackupGenerator.zip_files has been deprecated in favour of" " BackupGenerator.backup_files",
"BackupGenerator.zip_files has been deprecated in favour of BackupGenerator.backup_files",
fg="yellow",
)
return self.backup_files()

View file

@ -1633,7 +1633,7 @@ def get_thumbnail_base64_for_image(src: str) -> dict[str, str] | None:
return
try:
image, unused_filename, extn = get_local_image(src)
image, _unused_filename, extn = get_local_image(src)
except OSError:
return
@ -2008,7 +2008,7 @@ def get_url_to_report_with_filters(name, filters, report_type=None, doctype=None
def sql_like(value: str, pattern: str) -> bool:
if not isinstance(pattern, str) and isinstance(value, str):
if not (isinstance(pattern, str) and isinstance(value, str)):
return False
if pattern.startswith("%") and pattern.endswith("%"):
return pattern.strip("%") in value
@ -2021,7 +2021,7 @@ def sql_like(value: str, pattern: str) -> bool:
return pattern in value
def filter_operator_is(value: str, pattern: str) -> bool:
def filter_operator_is(value: str | None, pattern: str) -> bool:
"""Operator `is` can have two values: 'set' or 'not set'."""
pattern = pattern.lower()
@ -2082,11 +2082,37 @@ def evaluate_filters(doc: "Mapping", filters: FilterSignature):
return True
def compare(val1: Any, condition: str, val2: Any, fieldtype: str | None = None):
def compare(val1: Any, condition: str, val2: Any, fieldtype: str | None = None) -> bool:
"""Compare two values using the specified operator with optional fieldtype casting.
Args:
val1: The left operand value to compare
condition: The comparison operator (e.g., "=", ">", "is", "in", "like")
val2: The right operand value to compare against
fieldtype: Optional fieldtype for casting val1 (and val2 for most operators)
Returns:
bool: True if the comparison evaluates to True, False otherwise
Note:
- For "is" operator: No casting is performed to preserve None values
- For "in"/"not in" operators: Only val1 is cast (if not None), val2 remains unchanged
- For "Timespan" operator: No casting is performed
- For other operators: Both val1 and val2 are cast to the specified fieldtype
"""
if fieldtype:
val1 = cast(fieldtype, val1)
if condition != "Timespan":
if condition in {"is", "Timespan"}:
# No casting to preserve original values
pass
elif condition in {"in", "not in"}:
# Cast only val1 (if not None), preserve val2 container
if val1 is not None:
val1 = cast(fieldtype, val1)
else:
# Cast both values for comparison operators (=, !=, >, <, >=, <=, like, etc.)
val1 = cast(fieldtype, val1)
val2 = cast(fieldtype, val2)
if condition in operator_map:
return operator_map[condition](val1, val2)

Some files were not shown because too many files have changed in this diff Show more