Merge remote-tracking branch 'upstream' into desktop-route-addition

This commit is contained in:
Ejaaz Khan 2026-02-16 17:01:55 +05:30
commit 60a8498d54
210 changed files with 61358 additions and 21793 deletions

View file

@ -182,10 +182,7 @@ if __name__ == "__main__":
only_frontend_code_changed = len(list(filter(is_frontend_code, files_list))) == len(files_list)
updated_py_file_count = len(list(filter(is_server_side_code, files_list)))
only_py_changed = updated_py_file_count == len(files_list)
run_postgres = (
has_label(pr_number, "postgres", repo) or
matches_postgres_filenames(files_list)
)
run_postgres = has_label(pr_number, "postgres", repo)
# Check for Skip CI label and other conditions
if has_skip_ci_label(pr_number, repo):

View file

@ -88,4 +88,30 @@ context("Awesome Bar", () => {
cy.get(".modal-title").should("contain", "Result");
cy.get(".msgprint").should("contain", "55 + 32 = 87");
});
it.only("support number formats in math expressions", () => {
cy.window()
.its("frappe")
.then((frappe) => {
frappe.boot.sysdefaults.number_format = "#,###.##";
});
cy.get("@awesome_bar").type("1,250.2 + 1,250.2");
cy.wait(150); // Wait a bit before hitting enter
cy.get("@awesome_bar").type("{downarrow}{enter}");
cy.get(".modal-title").should("contain", "Result");
cy.get(".msgprint").should("contain", "1,250.2 + 1,250.2 = 2,500.4");
cy.hide_dialog();
cy.get("@awesome_bar_search").click();
cy.window()
.its("frappe")
.then((frappe) => {
frappe.boot.sysdefaults.number_format = "#.###,##";
});
cy.get("@awesome_bar").type("1.500,2 + 1.500,2");
cy.wait(150); // Wait a bit before hitting enter
cy.get("@awesome_bar").type("{downarrow}{enter}");
cy.get(".modal-title").should("contain", "Result");
cy.get(".msgprint").should("contain", "1.500,2 + 1.500,2 = 3.000,4");
});
});

View file

@ -414,13 +414,15 @@ def _in_request_or_test():
return getattr(local, "request", None) or in_test
def whitelist(allow_guest=False, xss_safe=False, methods=None):
def whitelist(allow_guest=False, xss_safe=False, methods=None, force_types=None):
"""
Decorator for whitelisting a function and making it accessible via HTTP.
Standard request will be `/api/method/[path.to.method]`
:param allow_guest: Allow non logged-in user to access this method.
:param methods: Allowed http method to access the method.
:param force_types: Method should have type annotations. If unset, defaults to hooks
specification.
Use as:
@ -438,7 +440,7 @@ def whitelist(allow_guest=False, xss_safe=False, methods=None):
global whitelisted, guest_methods, xss_safe_methods, allowed_http_methods_for_whitelisted_func
# validate argument types if request is present or in test context
fn = validate_argument_types(fn, apply_condition=_in_request_or_test)
fn = validate_argument_types(fn, apply_condition=_in_request_or_test, force_types=force_types)
whitelisted.add(fn)
allowed_http_methods_for_whitelisted_func[fn] = methods

View file

@ -549,45 +549,50 @@ def get_sidebar_items(allowed_workspaces):
else:
sidebar_title = s.title
w = s
sidebar_items[sidebar_title.lower()] = {
"label": sidebar_title,
"items": [],
"header_icon": s.get("header_icon"),
"module": w.module,
"app": w.app,
}
for si in w.items:
workspace_sidebar = {
"label": _(si.label),
"link_to": si.link_to,
"link_type": si.link_type,
"type": si.type,
"icon": si.icon,
"child": si.child,
"collapsible": si.collapsible,
"indent": si.indent,
"keep_closed": si.keep_closed,
"display_depends_on": si.display_depends_on,
"url": si.url,
"show_arrow": si.show_arrow,
"filters": si.filters,
"route_options": si.route_options,
"tab": si.navigate_to_tab,
if (
frappe.session.user == "Administrator"
or w.module in w.user.allow_modules
or sidebar_title == "My Workspaces"
):
sidebar_items[sidebar_title.lower()] = {
"label": sidebar_title,
"items": [],
"header_icon": s.get("header_icon"),
"module": w.module,
"app": w.app,
}
if si.link_type == "Report" and si.link_to and frappe.db.exists("Report", si.link_to):
report_type, ref_doctype = frappe.db.get_value(
"Report", si.link_to, ["report_type", "ref_doctype"]
)
workspace_sidebar["report"] = {
"report_type": report_type,
"ref_doctype": ref_doctype,
for si in w.items:
workspace_sidebar = {
"label": _(si.label),
"link_to": si.link_to,
"link_type": si.link_type,
"type": si.type,
"icon": si.icon,
"child": si.child,
"collapsible": si.collapsible,
"indent": si.indent,
"keep_closed": si.keep_closed,
"display_depends_on": si.display_depends_on,
"url": si.url,
"show_arrow": si.show_arrow,
"filters": si.filters,
"route_options": si.route_options,
"tab": si.navigate_to_tab,
}
if (
"My Workspaces" in sidebar_title
or si.type == "Section Break"
or w.is_item_allowed(si.link_to, si.link_type, allowed_workspaces)
):
sidebar_items[sidebar_title.lower()]["items"].append(workspace_sidebar)
if si.link_type == "Report" and si.link_to and frappe.db.exists("Report", si.link_to):
report_type, ref_doctype = frappe.db.get_value(
"Report", si.link_to, ["report_type", "ref_doctype"]
)
workspace_sidebar["report"] = {
"report_type": report_type,
"ref_doctype": ref_doctype,
}
if (
"My Workspaces" in sidebar_title
or si.type == "Section Break"
or w.is_item_allowed(si.link_to, si.link_type, allowed_workspaces)
):
sidebar_items[sidebar_title.lower()]["items"].append(workspace_sidebar)
add_user_specific_sidebar(sidebar_items)
return sidebar_items

103
frappe/commands/execute.py Normal file
View file

@ -0,0 +1,103 @@
import json
import frappe
from frappe.exceptions import SiteNotSpecifiedError
from frappe.utils.bench_helper import CliCtxObj
def _execute(context: CliCtxObj, method, args=None, kwargs=None, profile=False, extra_args=None):
for site in context.sites:
ret = ""
try:
frappe.init(site)
frappe.connect()
if args:
try:
fn_args = eval(args)
except NameError:
fn_args = [args]
else:
fn_args = ()
if kwargs:
fn_kwargs = eval(kwargs)
else:
fn_kwargs = {}
if extra_args:
# parse extra_args
# if it starts with --, it is a kwarg
# otherwise it is an arg
# if it is a kwarg, the next argument is the value
# if the next argument starts with --, the value is True
# if there is no next argument, the value is True
# examples:
# bench execute method arg1 arg2 -> args=[arg1, arg2]
# bench execute method --a 1 --b 2 -> kwargs={a: 1, b: 2}
# bench execute method arg1 --a 1 -> args=[arg1], kwargs={a: 1}
# we need to convert values to python objects if possible
def parse_value(value):
try:
return json.loads(value)
except Exception:
return value
extra_args = list(extra_args)
while extra_args:
arg = extra_args.pop(0)
if arg.startswith("--"):
key = arg[2:]
if extra_args and not extra_args[0].startswith("--"):
value = parse_value(extra_args.pop(0))
else:
value = True
fn_kwargs[key] = value
else:
fn_args += (parse_value(arg),)
pr = None
if profile:
import cProfile
pr = cProfile.Profile()
pr.enable()
try:
fn = frappe.get_attr(method)
except Exception:
fn = None
if fn:
ret = fn(*fn_args, **fn_kwargs)
else:
# eval is safe here because input is from console
code = compile(method, "<bench execute>", "eval")
ret = eval(code, globals(), locals()) # nosemgrep
if callable(ret):
suffix = "(*fn_args, **fn_kwargs)"
code = compile(method + suffix, "<bench execute>", "eval")
ret = eval(code, globals(), locals()) # nosemgrep
if profile and pr:
import pstats
from io import StringIO
pr.disable()
s = StringIO()
pstats.Stats(pr, stream=s).sort_stats("cumulative").print_stats(0.5)
print(s.getvalue())
if frappe.db:
frappe.db.commit()
finally:
frappe.destroy()
if ret:
from frappe.utils.response import json_handler
print(json.dumps(ret, default=json_handler).strip('"'))
if not context.sites:
raise SiteNotSpecifiedError

View file

@ -90,6 +90,20 @@ def new_site(
from frappe.installer import _new_site
frappe.init(site, new_site=True)
db_labels = {
"postgres": "PostgreSQL",
"sqlite": "SQLite",
}
if db_type in db_labels:
click.secho(
f"\nNote: {db_labels[db_type]} support is currently in development and considered experimental.",
fg="yellow",
bold=True,
)
click.secho(
"Please report issues with a full traceback here:\nhttps://github.com/frappe/frappe/issues\n",
fg="cyan",
)
if site in frappe.get_all_apps():
click.secho(

View file

@ -244,6 +244,26 @@ class TestCommands(BaseTestCommands):
self.assertEqual(self.returncode, 0)
self.assertEqual(self.stdout, frappe.bold(text="DocType"))
# test 5: execute a command with extra args
self.execute("bench --site {site} execute frappe.bold DocType")
self.assertEqual(self.returncode, 0)
self.assertEqual(self.stdout, frappe.bold(text="DocType"))
# test 6: execute a command with extra kwargs
self.execute("bench --site {site} execute frappe.bold --text DocType")
self.assertEqual(self.returncode, 0)
self.assertEqual(self.stdout, frappe.bold(text="DocType"))
# test 7: execute a command with extra args and kwargs
self.execute("bench --site {site} execute frappe.utils.add_to_date '2024-01-01' --days 1")
self.assertEqual(self.returncode, 0)
self.assertEqual(self.stdout, "2024-01-02")
# test 8: execute a command with extra args and kwargs with types
self.execute("bench --site {site} execute frappe.utils.add_to_date --date '2024-01-01' --days 1")
self.assertEqual(self.returncode, 0)
self.assertEqual(self.stdout, "2024-01-02")
@skipIf(
frappe.conf.db_type == "sqlite",
"Not for SQLite for now",

View file

@ -247,70 +247,18 @@ def reset_perms(context: CliCtxObj):
raise SiteNotSpecifiedError
@click.command("execute")
@click.command("execute", context_settings=EXTRA_ARGS_CTX)
@click.argument("method")
@click.option("--args")
@click.option("--kwargs")
@click.option("--profile", is_flag=True, default=False)
@click.argument("extra_args", nargs=-1)
@pass_context
def execute(context: CliCtxObj, method, args=None, kwargs=None, profile=False):
def execute(context: CliCtxObj, method, args=None, kwargs=None, profile=False, extra_args=None):
"Execute a function"
for site in context.sites:
ret = ""
try:
frappe.init(site)
frappe.connect()
from frappe.commands.execute import _execute
if args:
try:
fn_args = eval(args)
except NameError:
fn_args = [args]
else:
fn_args = ()
if kwargs:
fn_kwargs = eval(kwargs)
else:
fn_kwargs = {}
if profile:
import cProfile
pr = cProfile.Profile()
pr.enable()
try:
ret = frappe.get_attr(method)(*fn_args, **fn_kwargs)
except Exception:
# eval is safe here because input is from console
code = compile(method, "<bench execute>", "eval")
ret = eval(code, globals(), locals()) # nosemgrep
if callable(ret):
suffix = "(*fn_args, **fn_kwargs)"
code = compile(method + suffix, "<bench execute>", "eval")
ret = eval(code, globals(), locals()) # nosemgrep
if profile:
import pstats
from io import StringIO
pr.disable()
s = StringIO()
pstats.Stats(pr, stream=s).sort_stats("cumulative").print_stats(0.5)
print(s.getvalue())
if frappe.db:
frappe.db.commit()
finally:
frappe.destroy()
if ret:
from frappe.utils.response import json_handler
print(json.dumps(ret, default=json_handler).strip('"'))
if not context.sites:
raise SiteNotSpecifiedError
_execute(context, method, args, kwargs, profile, extra_args)
@click.command("add-to-email-queue")

View file

@ -52,6 +52,7 @@ def make(
now=False,
raw_html=False,
add_css=True,
in_reply_to=None,
**kwargs,
) -> dict[str, str]:
"""Make a new communication. Checks for email permissions for specified Document.
@ -73,6 +74,7 @@ def make(
:param send_after: Send after the given datetime.
:param raw_html: Whether to use html version of email template
:param add_css: Add default CSS from hooks/email_css to the email template (default **True**)
:param in_reply_to: Name of the Communication document to which this communication is a reply.
"""
from frappe.utils.commands import warn
@ -127,6 +129,7 @@ def make(
now=now,
raw_html=raw_html,
add_css=add_css,
in_reply_to=in_reply_to,
)
@ -157,6 +160,7 @@ def _make(
now=False,
raw_html=False,
add_css=True,
in_reply_to=None,
) -> dict[str, str]:
"""Internal method to make a new communication that ignores Permission checks."""
@ -185,6 +189,7 @@ def _make(
"has_attachment": 1 if attachments else 0,
"communication_type": communication_type,
"send_after": send_after,
"in_reply_to": in_reply_to,
}
)
comm.flags.skip_add_signature = not add_signature or (

View file

@ -311,6 +311,7 @@ class CommunicationEmailMixin:
"send_after": self.send_after,
"raw_html": raw_html,
"add_css": add_css,
"in_reply_to": self.in_reply_to,
}
def send_email(

View file

@ -82,7 +82,8 @@
"documentation_url",
"placeholder",
"oldfieldname",
"oldfieldtype"
"oldfieldtype",
"show_description_on_click"
],
"fields": [
{
@ -633,6 +634,12 @@
"fieldtype": "Select",
"label": "Button Color",
"options": "\nDefault\nPrimary\nInfo\nSuccess\nWarning\nDanger"
},
{
"default": "0",
"fieldname": "show_description_on_click",
"fieldtype": "Check",
"label": "Show Description on Click"
}
],
"grid_page_length": 50,
@ -640,7 +647,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-01-06 01:37:29.723265",
"modified": "2026-02-06 15:13:03.688027",
"modified_by": "Administrator",
"module": "Core",
"name": "DocField",

View file

@ -14,10 +14,10 @@ class DocField(Document):
if TYPE_CHECKING:
from frappe.types import DF
alignment: DF.Literal["", "Left", "Center", "Right"]
allow_bulk_edit: DF.Check
allow_in_quick_entry: DF.Check
allow_on_submit: DF.Check
alignment: DF.Literal["", "Left", "Center", "Right"]
bold: DF.Check
button_color: DF.Literal["", "Default", "Primary", "Info", "Success", "Warning", "Danger"]
collapsible: DF.Check
@ -117,6 +117,7 @@ class DocField(Document):
search_index: DF.Check
set_only_once: DF.Check
show_dashboard: DF.Check
show_description_on_click: DF.Check
show_on_timeline: DF.Check
sort_options: DF.Check
sticky: DF.Check

View file

@ -0,0 +1,5 @@
// Copyright (c) {year}, {app_publisher} and contributors
// For license information, please see license.txt
frappe.views.calendar["{doctype}"] = {{
}};

View file

@ -680,7 +680,7 @@ class DocType(Document):
where doctype=%s and field='name' and value = %s""",
(new, new, old),
)
else:
elif not self.is_virtual:
frappe.db.rename_table(old, new)
frappe.db.commit()
@ -880,6 +880,9 @@ class DocType(Document):
if self.is_tree:
make_boilerplate("controller_tree.js", self.as_dict())
if self.is_calendar_and_gantt:
make_boilerplate("controller_calendar.js", self.as_dict())
if self.has_web_view:
templates_path = frappe.get_module_path(
frappe.scrub(self.module), "doctype", frappe.scrub(self.name), "templates"

View file

@ -9,6 +9,7 @@ import shutil
import zipfile
from urllib.parse import quote, unquote
import filetype
from PIL import Image, ImageFile, ImageOps
import frappe
@ -609,16 +610,19 @@ class File(Document):
encodings = FILE_ENCODING_OPTIONS
with open(file_path, mode="rb") as f:
self._content = f.read()
# looping will not result in slowdown, as the content is usually utf-8 or utf-8-sig
# encoded so the first iteration will be enough most of the time
for encoding in encodings:
try:
# read file with proper encoding
self._content = self._content.decode(encoding)
break
except UnicodeDecodeError:
# for .png, .jpg, etc
continue
# Only decode if not a binary file
kind = filetype.guess(self._content)
if not kind:
# looping will not result in slowdown, as the content is usually utf-8 or utf-8-sig
# encoded so the first iteration will be enough most of the time
for encoding in encodings:
try:
# read file with proper encoding
self._content = self._content.decode(encoding)
break
except UnicodeDecodeError:
# for .png, .jpg, etc
continue
return self._content
@ -738,6 +742,7 @@ class File(Document):
name=self.file_name,
suffix=self.content_hash[-6:],
is_private=self.is_private,
content_hash=self.content_hash,
)
call_hook_method("before_write_file", file_size=self.file_size)
write_file_method = get_hook_method("write_file")
@ -762,7 +767,7 @@ class File(Document):
max_file_size = get_max_file_size()
file_size = len(self._content or b"")
if file_size > max_file_size:
if not self.flags.skip_file_size_check and file_size > max_file_size:
msg = _("File size exceeded the maximum allowed size of {0} MB").format(max_file_size / 1048576)
if frappe.has_permission("System Settings", "write"):
msg += ".<br>" + _("You can increase the limit from System Settings.")

View file

@ -172,7 +172,12 @@ def delete_file(path: str) -> None:
def remove_file_by_url(file_url: str, doctype: str | None = None, name: str | None = None) -> "Document":
if doctype and name:
fid = frappe.db.get_value(
"File", {"file_url": file_url, "attached_to_doctype": doctype, "attached_to_name": name}
"File",
{
"file_url": file_url,
"attached_to_doctype": doctype,
"attached_to_name": name,
},
)
else:
fid = frappe.db.get_value("File", {"file_url": file_url})
@ -189,20 +194,28 @@ def get_content_hash(content: bytes | str) -> str:
return hashlib.md5(content, usedforsecurity=False).hexdigest() # nosec
def generate_file_name(name: str, suffix: str | None = None, is_private: bool = False) -> str:
def generate_file_name(
name: str, suffix: str | None = None, is_private: bool = False, content_hash=None
) -> str:
"""Generate conflict-free file name. Suffix will be ignored if name available. If the
provided suffix doesn't result in an available path, a random suffix will be picked.
"""
def path_exists(name, is_private):
return os.path.exists(encode(get_files_path(name, is_private=is_private)))
def different_file_exists_at_path(name, is_private):
path = encode(get_files_path(name, is_private=is_private))
if not os.path.exists(path):
return False
if content_hash:
with open(path, "rb") as f:
return get_content_hash(f.read()) != content_hash
return True
if not path_exists(name, is_private):
if not different_file_exists_at_path(name, is_private):
return name
candidate_path = get_file_name(name, suffix)
if path_exists(candidate_path, is_private):
if different_file_exists_at_path(candidate_path, is_private):
return generate_file_name(name, is_private=is_private)
return candidate_path

View file

@ -131,7 +131,7 @@ def has_unseen_error_log():
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_log_doctypes(doctype, txt, searchfield, start, page_len, filters):
filters = filters or {}
filters = filters or []
filters.extend(
[

View file

@ -157,8 +157,10 @@ class Report(Document):
check_safe_sql_query(self.query)
frappe.db.begin(read_only=True)
result = [list(t) for t in frappe.db.sql(self.query, filters)]
columns = self.get_columns() or [cstr(c[0]) for c in frappe.db.get_description()]
frappe.db.rollback()
return [columns, result]

View file

@ -406,33 +406,3 @@ result = [
self.assertEqual(result[-1][0], "Total")
self.assertEqual(result[-1][1], 200)
self.assertEqual(result[-1][2], 150.50)
def test_cte_in_query_report(self):
cte_query = textwrap.dedent(
"""
with enabled_users as (
select name
from `tabUser`
where enabled = 1
)
select * from enabled_users;
"""
)
report = frappe.get_doc(
{
"doctype": "Report",
"ref_doctype": "User",
"report_name": "Enabled Users List",
"report_type": "Query Report",
"is_standard": "No",
"query": cte_query,
}
).insert()
if frappe.db.db_type == "mariadb":
col, rows = report.execute_query_report(filters={})
self.assertEqual(col[0], "name")
self.assertGreaterEqual(len(rows), 1)
elif frappe.db.db_type == "postgres":
self.assertRaises(frappe.PermissionError, report.execute_query_report, filters={})

View file

@ -121,6 +121,9 @@ frappe.ui.form.on("User", {
}
frm.toggle_display(["sb1", "sb3", "modules_access"], false);
if (frm.is_new() && has_access_to_edit_user()) {
frm.toggle_display(["sb1", "sb3", "modules_access"], true);
}
frm.trigger("setup_impersonation");
if (!frm.is_new()) {
@ -429,18 +432,26 @@ 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;
if (frm.roles_editor) {
frm.roles_editor.disable = 1;
}
frm.call("populate_role_profile_roles").then(() => {
frm.roles_editor.show();
if (frm.roles_editor) {
frm.roles_editor.show();
}
});
$(".deselect-all, .select-all").prop("disabled", true);
if (frm.roles_editor) {
$(".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);
if (frm.roles_editor) {
frm.roles_editor.disable = 0;
frm.roles_editor.show();
$(".deselect-all, .select-all").prop("disabled", false);
}
}
},
});

View file

@ -1441,7 +1441,7 @@ def get_enabled_users():
@frappe.whitelist(methods=["POST"])
def impersonate(user: str, reason: str):
frappe.has_permission("User", "impersonate")
frappe.has_permission("User", "impersonate", throw=True)
impersonator = frappe.session.user
frappe.get_doc(
@ -1462,6 +1462,18 @@ def impersonate(user: str, reason: str):
)
notification.set("type", "Alert")
notification.insert(ignore_permissions=True)
# notify user via email too
if not frappe.conf.get("developer_mode"): # bypass for testing locally
user_email = frappe.db.get_value("User", user, "email")
email_message = _(
"User {0} has started an impersonation session as you. <br><br><b>Reason provided:</b> {1}"
).format(escape_html(impersonator), escape_html(reason))
frappe.sendmail(
recipients=[user_email],
subject=_("Security Alert: Your account is being impersonated"),
content=email_message,
)
frappe.local.login_manager.impersonate(user)

View file

@ -9,9 +9,13 @@ frappe.ui.form.on("Client Script", {
},
refresh(frm) {
if (frm.doc.dt && frm.doc.script) {
frm.add_custom_button(__("Go to {0}", [frm.doc.dt]), () =>
frappe.set_route("List", frm.doc.dt, "List")
);
frm.add_custom_button(__("Go to {0}", [frm.doc.dt]), () => {
if (frappe.model.is_single(frm.doc.dt)) {
frappe.set_route("Form", frm.doc.dt);
} else {
frappe.set_route("List", frm.doc.dt);
}
});
}
if (frm.doc.view == "Form") {

View file

@ -275,9 +275,11 @@ class Database:
frappe.log(f"Syntax error in query:\n{query} {values or ''}")
elif self.is_deadlocked(e):
self.db_type == "mariadb" and frappe.log_error("Query deadlocked", defer_insert=True)
raise frappe.QueryDeadlockError(e) from e
elif self.is_timedout(e):
self.db_type == "mariadb" and frappe.log_error("Query timed out", defer_insert=True)
raise frappe.QueryTimeoutError(e) from e
elif self.is_read_only_mode_error(e):
@ -628,6 +630,9 @@ class Database:
# return last login of **User** `test@example.com`
user = frappe.db.get_values("User", "test@example.com", "*")[0]
"""
from frappe.model.utils import is_single_doctype
out = None
if cache and isinstance(filters, str) and fieldname in self.value_cache[doctype][filters]:
return self.value_cache[doctype][filters][fieldname]
@ -677,25 +682,9 @@ class Database:
or str(e).startswith("Invalid DocType")
):
out = None
elif (not ignore) and frappe.db.is_table_missing(e):
# table not found, look in singles
fields = (
[fieldname] if (isinstance(fieldname, str) and fieldname != "*") else fieldname
)
out = self.get_values_from_single(
fields,
filters,
doctype,
as_dict,
debug,
update,
run=run,
distinct=distinct,
)
else:
raise
else:
elif is_single_doctype(doctype):
fields = [fieldname] if (isinstance(fieldname, str) and fieldname != "*") else fieldname
out = self.get_values_from_single(
fields,
@ -708,6 +697,8 @@ class Database:
pluck=pluck,
distinct=distinct,
)
else:
return None
if cache and isinstance(filters, str):
self.value_cache[doctype][filters][fieldname] = out

View file

@ -124,6 +124,7 @@ class MariaDBConnectionUtil:
"charset": "utf8mb4",
"collation": "utf8mb4_unicode_ci",
"use_unicode": True,
"local_infile": False,
}
if self.cur_db_name:
@ -139,9 +140,6 @@ class MariaDBConnectionUtil:
if self.password:
conn_settings["password"] = self.password
if frappe.conf.local_infile:
conn_settings["local_infile"] = frappe.conf.local_infile
# Configure SSL settings
if frappe.conf.db_ssl_ca:
ssl_config = {

View file

@ -125,6 +125,8 @@ class MariaDBConnectionUtil:
"conv": self.CONVERSION_MAP,
"charset": "utf8mb4",
"use_unicode": True,
"local_infile": False,
"multi_statements": False,
}
if self.cur_db_name:
@ -140,9 +142,6 @@ class MariaDBConnectionUtil:
if self.password:
conn_settings["password"] = self.password
if frappe.conf.local_infile:
conn_settings["local_infile"] = frappe.conf.local_infile
# Configure SSL settings
if frappe.conf.db_ssl_ca:
ssl_config = {

View file

@ -134,10 +134,6 @@ TAB_PATTERN = re.compile("^tab")
WORDS_PATTERN = re.compile(r"\w+")
COMMA_PATTERN = re.compile(r",\s*(?![^()]*\))")
# less restrictive version of frappe.core.doctype.doctype.doctype.START_WITH_LETTERS_PATTERN
# to allow table names like __Auth
TABLE_NAME_PATTERN = re.compile(r"^[\w -]*$", flags=re.ASCII)
# Pattern for validating simple field names (alphanumeric + underscore)
SIMPLE_FIELD_PATTERN = re.compile(r"^\w+$", flags=re.ASCII)
@ -272,7 +268,6 @@ class Engine:
self.doctype = get_doctype_name(table.get_sql())
else:
self.doctype = table
self.validate_doctype()
self.table = qb.DocType(table)
if self.apply_permissions:
@ -340,10 +335,6 @@ class Engine:
self.query.immutable = True
return self.query
def validate_doctype(self):
if not TABLE_NAME_PATTERN.match(self.doctype):
frappe.throw(_("Invalid DocType: {0}").format(self.doctype))
def apply_fields(self, fields):
self.fields = self.parse_fields(fields)
@ -390,14 +381,23 @@ class Engine:
if not filters:
return
# 1. Handle special case: list of names -> name IN (...)
# 1. Check for single simple filter [field, op, value] or [doctype, field, op, value]
if len(filters) in (3, 4) and isinstance(filters[1], str):
if (
filters[1].lower() in OPERATOR_MAP
or filters[1].lower() in get_additional_filters_from_hooks()
):
self.apply_list_filters(filters, collect=collect)
return
# 2. Handle special case: list of names -> name IN (...)
if all(isinstance(d, FilterValue) for d in filters):
self.apply_dict_filters(
{"name": ("in", tuple(convert_to_value(f) for f in filters))}, collect=collect
)
return
# 2. Check for nested logic format [cond, op, cond, ...] or [[cond, op, cond]]
# 3. Check for nested logic format [cond, op, cond, ...] or [[cond, op, cond]]
is_nested_structure = False
potential_nested_list = filters
is_single_group = False
@ -406,8 +406,12 @@ class Engine:
if len(filters) == 1 and isinstance(filters[0], list | tuple):
inner_list = filters[0]
# Ensure inner list also looks like a nested structure
# Check if the operator is a string, validation happens inside _parse_nested_filters
if len(inner_list) >= 3 and isinstance(inner_list[1], str):
# Check if the operator is a string, and specifically a logical operator
if (
len(inner_list) >= 3
and isinstance(inner_list[1], str)
and inner_list[1].lower() in ("and", "or")
):
is_nested_structure = True
potential_nested_list = inner_list # Use the inner list for validation and parsing
is_single_group = True # Flag that the original filters was wrapped
@ -416,10 +420,12 @@ class Engine:
# Check if it looks like it *might* be nested (even if malformed).
# This allows lists starting with operators or containing invalid operators
# to be passed to _parse_nested_filters for detailed validation.
# Condition: Contains a string at an odd index OR starts with a string.
elif any(isinstance(item, str) for i, item in enumerate(filters) if i % 2 != 0) or (
len(filters) > 0 and isinstance(filters[0], str)
):
# Condition: Starts with a list/tuple and contains a string at an odd index OR starts with a string.
elif (
len(filters) >= 2
and isinstance(filters[0], list | tuple)
and any(isinstance(item, str) for i, item in enumerate(filters) if i % 2 != 0)
) or (len(filters) > 0 and isinstance(filters[0], str)):
is_nested_structure = True
# potential_nested_list remains filters
@ -434,7 +440,10 @@ class Engine:
# _parse_nested_filters MUST validate the structure, including the first element and operators.
combined_criterion = self._parse_nested_filters(potential_nested_list)
if combined_criterion:
self.query = self.query.where(combined_criterion)
if collect is not None:
collect.append(combined_criterion)
else:
self.query = self.query.where(combined_criterion)
except Exception as e:
# Log the original filters list for better debugging context
frappe.throw(_("Error parsing nested filters: {0}. {1}").format(filters, e), exc=e)
@ -579,6 +588,20 @@ class Engine:
v.strip().strip("'") for v in get_between_date_filter(_value, df).split(" AND ")
)
# Handle empty lists for IN/NOT IN operators before conversion
# IN with empty list should return 0 results (always False)
# NOT IN with empty list should return all results (always True)
if _operator.lower() in ("in", "not in"):
if isinstance(_value, (list, tuple, set)) and len(_value) == 0:
if _operator.lower() == "in":
# Return a criterion that always evaluates to False (1=0)
# This ensures IN with empty list returns 0 results
return RawCriterion("1=0")
else: # not in
# Return a criterion that always evaluates to True (1=1)
# NOT IN with empty set matches all rows since nothing is excluded
return RawCriterion("1=1")
if not _value and isinstance(_value, list | tuple | set):
_value = ("",)
@ -736,10 +759,9 @@ class Engine:
# Check if it's a nested condition list [cond1, op, cond2, ...]
is_nested = False
# Broaden check here as well: length >= 3 and second element is string
if len(condition) >= 3 and isinstance(condition[1], str):
if isinstance(condition[0], list | tuple): # First element must also be a condition
is_nested = True
# Broaden check here as well: length >= 2 and second element is string
if len(condition) >= 2 and isinstance(condition[1], str) and isinstance(condition[0], list | tuple):
is_nested = True
if is_nested:
# It's a nested sub-expression like [["assignee", "=", "A"], "or", ["assignee", "=", "B"]]
@ -844,7 +866,7 @@ class Engine:
parent_doctype_for_perm = self.parent_doctype if doctype else None
# If a specific doctype is provided and it's different from the main query doctype,
# assume it's a child table and add the join using ChildTableField logic.
# if it's a child table, add the join using ChildTableField logic
if doctype and doctype != self.doctype:
# Check if doctype is a valid child table of self.doctype
parent_meta = frappe.get_meta(self.doctype)
@ -855,12 +877,10 @@ class Engine:
parent_fieldname = df.fieldname
break
# If it's not a child table, check permissions
if not parent_fieldname:
frappe.throw(
_("{0} is not a child table of {1}").format(doctype, self.doctype),
frappe.ValidationError,
title=_("Invalid Filter"),
)
self._check_field_permission(target_doctype, target_fieldname, parent_doctype_for_perm)
return frappe.qb.DocType(target_doctype)[target_fieldname]
# Create a ChildTableField instance to handle join and field access
# Pass the identified parent_fieldname
@ -1008,11 +1028,6 @@ class Engine:
field_name = groups[3] # This will be the field name (e.g., 'field')
if table_name:
# Table name specified (e.g., `tabX`.`y` or tabX.y or `tabX Y`.`y`)
# Ensure the extracted table name is valid before creating DocType object
if not TABLE_NAME_PATTERN.match(table_name.lstrip("tab")):
frappe.throw(_("Invalid characters in table name: {0}").format(table_name))
doctype_name = table_name[3:] if table_name.startswith("tab") else table_name
table_obj = frappe.qb.DocType(doctype_name)
pypika_field = table_obj[field_name]
@ -1576,6 +1591,70 @@ class Engine:
# because either of those is required to perform a query
return True
def build_match_conditions(self, as_condition: bool = True) -> str | list:
"""Build permission-based conditions for the doctype."""
if as_condition:
condition = self.get_permission_conditions(self.doctype, self.table)
if condition:
quote_char = "`" if self.is_mariadb else '"'
return condition.get_sql(with_namespace=True, quote_char=quote_char)
return ""
if not self.ignore_user_permissions:
match_filters = []
user_permissions = frappe.permissions.get_user_permissions(self.user)
if not user_permissions:
return match_filters
for df in self.get_doctype_link_fields(self.doctype):
if df.get("ignore_user_permissions"):
continue
options = df.get("options")
if user_permission_values := user_permissions.get(options, {}):
docs = []
for permission in user_permission_values:
applicable_for = permission.get("applicable_for")
doc = permission.get("doc")
if not applicable_for:
docs.append(doc)
elif df.get("fieldname") == "name" and self.reference_doctype:
if applicable_for == self.reference_doctype:
docs.append(doc)
elif applicable_for == self.doctype:
docs.append(doc)
if docs:
match_filters.append({options: docs})
return match_filters
return []
def build_filter_conditions(
self, filters, conditions: list, ignore_permissions: bool | None = None
) -> None:
if not filters:
return
original_apply_permissions = self.apply_permissions
if ignore_permissions is not None:
self.apply_permissions = not ignore_permissions
try:
criteria_list = []
self.apply_filters(filters, collect=criteria_list)
quote_char = "`" if self.is_mariadb else '"'
for c in criteria_list:
conditions.append(c.get_sql(with_namespace=True, quote_char=quote_char))
finally:
self.apply_permissions = original_apply_permissions
def _is_field_nullable(self, doctype: str, fieldname: str) -> bool:
"""Check if a field can contain NULL values."""
# primary key is never nullable, modified is usually indexed by default and always present

View file

@ -467,6 +467,8 @@ def get_workspace_sidebar_items():
pages.append(page)
elif page.for_user == frappe.session.user:
private_pages.append(page)
elif not page.public and not page.for_user:
pages.append(page)
page["label"] = _(page.get("name"))
if not page["app"] and page["module"]:

View file

@ -56,6 +56,11 @@ class DesktopIcon(Document):
def on_update(self):
self.export_desktop_icon()
if self.standard:
frappe.cache.delete_key("desktop_icons")
frappe.cache.delete_key("bootinfo")
else:
clear_desktop_icons_cache(user=self.owner)
def after_rename(self, old, new, merge):
delete_desktop_icon_file(self.app, old)
@ -80,6 +85,18 @@ class DesktopIcon(Document):
os.remove(file_path)
def is_permitted(self, bootinfo):
icon_module = None
if self.icon_type == "Link" and self.link_to:
icon_module = frappe.db.get_value("Workspace", self.link_to, "module")
# module permission check
if icon_module:
blocked_modules = frappe.get_cached_doc("User", frappe.session.user).get_blocked_modules()
if icon_module in blocked_modules:
return False
# perform a permission check based on roles table (desktop icons)
allowed_roles = [d.role for d in self.get("roles") or []]
if allowed_roles and not set(allowed_roles).intersection(frappe.get_roles()):
return False
if self.icon_type == "Folder":
return True
elif self.icon_type == "App":
@ -90,10 +107,11 @@ class DesktopIcon(Document):
if len(items) and all(item["type"] == "Section Break" for item in items):
return False
if len(items) == 0:
return False
return True
except KeyError:
return True
return False
def check_app_permission(self):
for a in frappe.get_installed_apps():
@ -165,27 +183,22 @@ def get_desktop_icons(user=None, bootinfo=None):
"icon_image",
]
standard_icons = frappe.get_all("Desktop Icon", fields=fields, filters={"standard": 1})
from frappe.query_builder import DocType
user_icons = frappe.get_all("Desktop Icon", fields=fields, filters={"standard": 0, "owner": user})
user_icons = user_icons + standard_icons
# for icon in user_icons:
# standard_icon = standard_map.get(icon.module_name, None)
DesktopIcon = DocType("Desktop Icon")
# # override properties from standard icon
# if standard_icon:
# for key in ("route", "label", "color", "icon", "link"):
# if standard_icon.get(key):
# icon[key] = standard_icon.get(key)
# if standard_icon.blocked:
# icon.hidden = 1
# # flag for modules_select dialog
# icon.hidden_in_standard = 1
# elif standard_icon.force_show:
# icon.hidden = 0
user_icons = (
frappe.qb.from_(DesktopIcon)
.select(*fields)
.where(
(DesktopIcon.standard == 1)
| (
(DesktopIcon.standard == 0)
& (DesktopIcon.owner.isin(["Administrator", frappe.session.user]))
)
)
.distinct()
).run(as_dict=True)
# sort by idx
user_icons.sort(key=lambda a: a.idx)
@ -194,12 +207,12 @@ def get_desktop_icons(user=None, bootinfo=None):
permitted_parent_labels = set()
if bootinfo:
for s in user_icons:
icon = frappe.get_doc("Desktop Icon", s)
icon = frappe.get_doc("Desktop Icon", s.name)
if icon.is_permitted(bootinfo):
permitted_icons.append(s)
if not s.parent_icon:
permitted_parent_labels.add(s.label)
if not s.parent_icon:
permitted_parent_labels.add(s.label)
user_icons = [
s for s in permitted_icons if not s.parent_icon or s.parent_icon in permitted_parent_labels

View file

@ -48,3 +48,8 @@ def save_layout(user, layout, new_icons):
desktop_icon.save()
return {"layout": layout}
@frappe.whitelist()
def delete_layout():
return frappe.delete_doc_if_exists("Desktop Layout", frappe.session.user)

View file

@ -34,6 +34,7 @@ class SystemConsole(Document):
)
self.output = "\n".join(frappe.debug_log)
elif self.type == "SQL":
frappe.db.begin(read_only=True)
self.output = frappe.as_json(read_sql(self.console, as_dict=1))
except Exception:
self.commit = False

View file

@ -28,6 +28,26 @@ class TestWorkspace(IntegrationTestCase):
# else:
# self.assertEqual(len(cards), 1)
def test_role_restricted_non_public_workspace_visible_to_permitted_user(self):
"""Non-public workspace with roles should be visible to users with matching role."""
from frappe.desk.desktop import get_workspace_sidebar_items
workspace = frappe.new_doc("Workspace")
workspace.label = "Role Test Workspace"
workspace.title = "Role Test Workspace"
workspace.category = "Modules"
workspace.public = 0
workspace.module = "Desk"
workspace.append("roles", {"role": "System Manager"})
workspace.insert(ignore_if_duplicate=True)
try:
result = get_workspace_sidebar_items()
workspace_titles = [p.title for p in result["pages"]]
self.assertIn("Role Test Workspace", workspace_titles)
finally:
frappe.db.delete("Workspace", {"name": workspace.name})
def create_module(module_name):
module = frappe.get_doc({"doctype": "Module Def", "module_name": module_name, "app_name": "frappe"})

View file

@ -130,11 +130,18 @@ class Workspace(Document):
self.delete_from_my_workspaces()
def delete_from_my_workspaces(self):
if not self.public:
if self.public:
return
try:
my_workspaces = frappe.get_doc("Workspace Sidebar", f"My Workspaces-{frappe.session.user}")
for w in my_workspaces.items:
if self.name == w.link_to:
frappe.delete_doc("Workspace Sidebar Item", w.name)
except frappe.DoesNotExistError:
frappe.clear_messages()
return
for w in my_workspaces.items:
if self.name == w.link_to:
frappe.delete_doc("Workspace Sidebar Item", w.name)
def after_delete(self):
if disable_saving_as_public():

View file

@ -72,7 +72,7 @@
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-01-10 22:12:40.504715",
"modified": "2026-02-02 12:35:38.009501",
"modified_by": "Administrator",
"module": "Desk",
"name": "Workspace Sidebar",
@ -90,6 +90,18 @@
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Desk User",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",

View file

@ -51,10 +51,13 @@ class WorkspaceSidebar(Document):
def before_save(self):
self.export_sidebar()
self.set_module()
if not self.for_user:
self.set_module()
def export_sidebar(self):
allow_export = self.app and not frappe.flags.in_import and frappe.conf.developer_mode
allow_export = (
self.app and self.standard and not frappe.flags.in_import and frappe.conf.developer_mode
)
if allow_export:
folder_path = create_directory_on_app_path("workspace_sidebar", self.app)
file_path = os.path.join(folder_path, f"{frappe.scrub(self.title)}.json")
@ -272,8 +275,8 @@ def auto_generate_sidebar_from_module():
sidebars = []
for module in frappe.get_all("Module Def", pluck="name"):
if not (
frappe.db.exists("Workspace Sidebar", {"module": module})
or frappe.db.exists("Workspace Sidebar", {"name": module})
frappe.db.exists("Workspace Sidebar", {"module": module, "for_user": None})
or frappe.db.exists("Workspace Sidebar", {"name": module, "for_user": None})
):
module_info = get_module_info(module)
sidebar_items = create_sidebar_items(module_info)

View file

@ -7,7 +7,7 @@
--folder-icon-background-color: var(--surface-gray-1);
--desktop-modal-radius: 30px;
--desktop-icon-line-height: 115%;
--navbar-height: 52px;
--desktop-navbar-height: 52px;
}
[data-theme="dark"]{
--folder-icon-background-color: #2b2b2b;
@ -28,7 +28,7 @@
width: 100%;
padding: 10px 20px 10px 20px;
box-sizing: border-box;
height: var(--navbar-height);
height: var(--desktop-navbar-height);
position: sticky;
top: 0px;
z-index: 1030;
@ -141,14 +141,12 @@
text-wrap: nowrap;
display: flex;
justify-content: space-between;
width: 95px;
height: 35px;
flex-direction: column;
}
.icon-title{
font-weight: var(--weight-semibold);
font-size: var(--text-md);
max-width: 95px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@ -183,7 +181,6 @@
& .modal-content {
top: 120px;
border-radius: var(--desktop-modal-radius);
align-items: center;
}
}
}
@ -197,13 +194,10 @@
width: var(--desktop-modal-width);
height: var(--desktop-modal-height);
padding: 24px 23px !important;
width: fit-content;
& .icons{
gap: 0px 0px;
}
& .icons:has(.desktop-edit-mode){
margin-top: 4px;
gap: 6px 6px;
}
.icon-container{
min-height: var(--desktop-icon-dimension);
}
@ -273,7 +267,7 @@
height: var(--folder-thumbnail-icon-height);
width: var(--folder-thumbnail-icon-height);
padding: 0px;
border-radius: 2px;
border-radius: 4px;
& .icon{
width: 5px;
height: 5px;
@ -474,7 +468,7 @@
}
.desktop-pane{
position: absolute;
top: var(--navbar-height);
top: var(--desktop-navbar-height);
right: 0px;
width: 300px;
border-left: 1px solid var(--border-color);
@ -533,4 +527,4 @@
height: 100%;
background: none;
color: var(--neutral-white);
}
}

View file

@ -8,21 +8,21 @@
alt="{{ _("App Logo") |e }}"
>
</div>
<div class="desktop-search-wrapper input-group search-bar">
<button
id="desktop-navbar-modal-search"
class="btn-reset flex justify-between desktop-navbar-modal-search"
title="Search"
>
<span class="desktop-search-icon">
<svg class="icon icon-sm"><use href="#icon-search"></use></svg>
{{ _("Search") }}
</span>
<span>
{{ "⌘K" if is_mac else "Ctrl+K" }}
</span>
</button>
</div>
{% if (show_search_bar) %}
<div class="desktop-search-wrapper input-group search-bar">
<button id="desktop-navbar-modal-search" class="btn-reset flex justify-between desktop-navbar-modal-search"
title="Search">
<span class="desktop-search-icon">
<svg class="icon icon-sm">
<use href="#icon-search"></use>
</svg>
{{ _("Search") }}
</span>
<span class="desktop-keyboard-shortcut">
</span>
</button>
</div>
{% endif %}
<div class="flex" style="gap:16px; align-items: center;">
<div class="desktop-notifications">
<div class="dropdown dropdown-notifications">

View file

@ -129,8 +129,11 @@ function save_desktop(icons) {
}
function reset_to_default() {
frappe.db.delete_doc("Desktop Layout", frappe.session.user).then(() => {
frappe.ui.toolbar.clear_cache();
frappe.call({
method: "frappe.desk.doctype.desktop_layout.desktop_layout.delete_layout",
callback: function (r) {
frappe.ui.toolbar.clear_cache();
},
});
}
@ -429,7 +432,7 @@ class DesktopPage {
},
{
icon: "rotate-ccw",
label: "Reset to Default",
label: "Reset Desktop Layout",
onClick: function () {
reset_to_default();
window.location.reload();
@ -454,6 +457,8 @@ class DesktopPage {
});
}
add_menu_item(item) {
if (this.desktop_menu_items && this.desktop_menu_items.find((i) => i.label === item.label))
return;
this.desktop_menu_items.push(item);
}
setup_navbar() {
@ -461,6 +466,12 @@ class DesktopPage {
}
setup_awesomebar() {
if (!frappe.is_mobile()) {
$(".desktop-keyboard-shortcut").html("Ctrl+K");
if (frappe.utils.is_mac()) {
$(".desktop-keyboard-shortcut").html("⌘K");
}
}
if (this.awesomebar_setup) return;
this.awesomebar_setup = true;
@ -490,9 +501,10 @@ class DesktopPage {
handle_route_change() {
const me = this;
frappe.router.on("change", function () {
if (frappe.get_route()[0] == "desktop" || frappe.get_route()[0] == "")
if (frappe.get_route()[0] == "desktop" || frappe.get_route()[0] == "") {
me.setup_navbar();
else {
me.setup_edit_button();
} else {
$(".navbar").show();
frappe.desktop_utils.close_desktop_modal();
// stop edit mode if route changes and cleanup
@ -950,7 +962,7 @@ class DesktopIcon {
label: "Create Folder",
icon: "folder",
onClick: function () {
let folder = me.grid.add_folder();
let folder = me.icon_grid.add_folder();
add_icons_to_folder(folder.label, [icon_data.label]);
},
},

View file

@ -18,4 +18,6 @@ def get_context(context):
except frappe.DoesNotExistError:
frappe.clear_last_message()
context.desktop_layout = {}
context.show_search_bar = frappe.get_cached_value("User", frappe.session.user, "search_bar")
return context

View file

@ -399,9 +399,6 @@ frappe.setup.slides_settings = [
placeholder: __("Select Country"),
reqd: 1,
},
{
fieldtype: "Section Break",
},
{
fieldname: "timezone",
label: __("Time Zone"),
@ -416,9 +413,6 @@ frappe.setup.slides_settings = [
fieldtype: "Select",
reqd: 1,
},
{
fieldtype: "Section Break",
},
{
fieldname: "enable_telemetry",
label: __("Allow sending usage data for improving applications"),

View file

@ -641,7 +641,9 @@ def delete_bulk(doctype, items):
)
else:
frappe.msgprint(
_("Deleted all documents successfully"), realtime=True, title=_("Bulk Operation Successful")
_(f"Deleted {len(items)} records from {doctype} doctype"),
realtime=True,
title=_("Bulk Operation Successful"),
)
@ -698,14 +700,13 @@ def get_stats(stats, doctype, filters=None):
results[column] = scrub_user_tags(tag_count)
no_tag_count = frappe.get_list(
doctype,
fields=[column, {"COUNT": "1"}],
fields=[column, {"COUNT": "1", "as": "count"}],
filters=[*filters, [column, "in", ("", ",")]],
as_list=True,
group_by=column,
order_by=column,
)
no_tag_count = no_tag_count[0][1] if no_tag_count else 0
no_tag_count = no_tag_count[0].get("count", 0) if no_tag_count else 0
results[column].append([_("No Tags"), no_tag_count])
else:
@ -794,9 +795,11 @@ def scrub_user_tags(tagcount):
# used in building query in queries.py
def get_match_cond(doctype, as_condition=True):
from frappe.model.db_query import DatabaseQuery
from frappe.database.query import Engine
cond = DatabaseQuery(doctype).build_match_conditions(as_condition=as_condition)
engine = Engine()
engine.get_query(doctype, db_query_compat=True)
cond = engine.build_match_conditions(as_condition=as_condition)
if not as_condition:
return cond
@ -804,9 +807,11 @@ def get_match_cond(doctype, as_condition=True):
def build_match_conditions(doctype, user=None, as_condition=True):
from frappe.model.db_query import DatabaseQuery
from frappe.database.query import Engine
match_conditions = DatabaseQuery(doctype, user=user).build_match_conditions(as_condition=as_condition)
engine = Engine()
engine.get_query(doctype, user=user, db_query_compat=True)
match_conditions = engine.build_match_conditions(as_condition=as_condition)
if as_condition:
return match_conditions.replace("%", "%%")
return match_conditions
@ -842,18 +847,18 @@ def get_filters_cond(doctype, filters, conditions, ignore_permissions=None, with
else:
flt.append([doctype, f[0], "=", f[1]])
from frappe.model.db_query import DatabaseQuery
from frappe.database.query import Engine
query = DatabaseQuery(doctype)
query.filters = flt
query.conditions = conditions
engine = Engine()
engine.get_query(doctype, ignore_permissions=ignore_permissions, db_query_compat=True)
if with_match_conditions:
query.build_match_conditions()
if match_cond := engine.build_match_conditions():
conditions.append(match_cond)
query.build_filter_conditions(flt, conditions, ignore_permissions)
engine.build_filter_conditions(flt, conditions)
cond = " and " + " and ".join(query.conditions)
cond = " and " + " and ".join(conditions) if conditions else ""
else:
cond = ""
return cond

View file

@ -35,7 +35,7 @@ class LinkSearchResults(TypedDict):
# this is called by the Link Field
@frappe.whitelist()
@http_cache(max_age=60 * 5, stale_while_revalidate=60 * 5)
@http_cache(max_age=60, stale_while_revalidate=5 * 60)
def search_link(
doctype: str,
txt: str,

View file

@ -65,6 +65,7 @@
"always_use_account_email_id_as_sender",
"always_use_account_name_as_sender_name",
"send_unsubscribe_message",
"add_x_original_from",
"track_email_status",
"outgoing_mail_settings",
"use_tls",
@ -707,13 +708,19 @@
"fieldname": "last_received_at",
"fieldtype": "Datetime",
"label": "Last Received At"
},
{
"default": "1",
"fieldname": "add_x_original_from",
"fieldtype": "Check",
"label": "Add X-Original-From header"
}
],
"icon": "fa fa-inbox",
"index_web_pages_for_search": 1,
"links": [],
"make_attachments_public": 1,
"modified": "2025-08-20 11:35:14.540578",
"modified": "2026-02-04 15:50:27.898578",
"modified_by": "Administrator",
"module": "Email",
"name": "Email Account",

View file

@ -61,6 +61,7 @@ class EmailAccount(Document):
from frappe.types import DF
add_signature: DF.Check
add_x_original_from: DF.Check
always_bcc: DF.Data | None
always_use_account_email_id_as_sender: DF.Check
always_use_account_name_as_sender_name: DF.Check

View file

@ -755,7 +755,8 @@ class QueueBuilder:
mail.msg_root["Disposition-Notification-To"] = self.sender
if self.in_reply_to:
if message_id := frappe.db.get_value("Communication", self.in_reply_to, "message_id"):
mail.set_in_reply_to(get_string_between("<", message_id, ">"))
message_id = message_id.strip("<> \t\n")
mail.set_in_reply_to(f"<{message_id}>")
return mail
def process(self, send_now=False) -> EmailQueue | None:

View file

@ -867,3 +867,75 @@ def _parse_receiver_by_document_field(s):
else:
data_field, child_field = fragments[0], None
return data_field, child_field
def create_notifications(notifications: list[dict], update: bool = False):
"""
Unlike standard notifications, these are NOT marked as is_standard=1,
so they won't be overwritten during migrations. Users can freely customize them.
Args:
notifications: List of notification dicts.
update: If True, update existing notification. If False (default), skip if exists.
"""
for notif_dict in notifications:
name = notif_dict.get("name")
existing = frappe.db.exists("Notification", name)
if existing and not update:
continue
if existing and update:
doc = frappe.get_doc("Notification", name)
doc.update(notif_dict)
doc.flags.ignore_validate = True
doc.save(ignore_permissions=True)
continue
notif_dict["doctype"] = "Notification"
notif_dict["is_standard"] = 0
notif_dict["owner"] = "Administrator"
doc = frappe.get_doc(notif_dict)
doc.flags.ignore_validate = True
doc.insert(ignore_permissions=True)
def get_notification_templates(templates_dir: str) -> list[dict]:
"""
Load notification templates from the templates directory.
Templates are stored in subdirectories:
<templates_dir>/<name>/<name>.json
<templates_dir>/<name>/<name>.html|.md|.txt (optional message content based on message_type)
"""
templates = []
if not os.path.exists(templates_dir):
return templates
for folder_name in os.listdir(templates_dir):
folder_path = os.path.join(templates_dir, folder_name)
if not os.path.isdir(folder_path):
continue
json_file = os.path.join(folder_path, f"{folder_name}.json")
template = frappe.get_file_json(json_file) if os.path.exists(json_file) else None
if not template:
continue
message_type = template.get("message_type", "HTML")
ext = FORMATS.get(message_type, ".html")
message_file = os.path.join(folder_path, f"{folder_name}{ext}")
if message := frappe.read_file(message_file):
template["message"] = message
templates.append(template)
return templates
def install_notification_templates():
templates_dir = frappe.get_module_path("Email", "doctype", "notification", "templates")
templates = get_notification_templates(templates_dir)
create_notifications(templates, update=False)

View file

@ -0,0 +1,65 @@
{% set error_lines = (doc.error or "").split('\n') %}
{% set first_lines = 10 %}
{% set last_lines = 15 %}
{% set max_lines = first_lines + last_lines %}
{% set total_lines = error_lines | length %}
{% set needs_truncation = total_lines > max_lines %}
<table class="email-header" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td>
<h1 class="email-header-title">
Error Log
</h1>
</td>
</tr>
</table>
<table class="table table-bordered" width="100%">
<tr>
<td class="text-bold" style="background: #f8f8f8; width: 120px;">Site</td>
<td><a href="{{ frappe.utils.get_url() }}">{{ frappe.utils.get_url() }}</a></td>
</tr>
<tr>
<td class="text-bold" style="background: #f8f8f8;">Error ID</td>
<td>{{ frappe.utils.get_link_to_form("Error Log", doc.name, doc.name) }}</td>
</tr>
<tr>
<td class="text-bold" style="background: #f8f8f8;">Title</td>
<td>{{ doc.method or "N/A" }}</td>
</tr>
<tr>
<td class="text-bold" style="background: #f8f8f8;">Logged At</td>
<td>{{ frappe.utils.format_datetime(doc.creation) }}</td>
</tr>
{% if doc.reference_doctype and doc.reference_name %}
<tr>
<td class="text-bold" style="background: #f8f8f8;">Reference</td>
<td>{{ frappe.utils.get_link_to_form(doc.reference_doctype, doc.reference_name) }}</td>
</tr>
{% endif %}
</table>
<div style="margin-top: 20px;">
<div class="text-medium text-bold">
Error Details
{% if needs_truncation %}
<span class="text-muted" style="font-weight: normal;"> ({{ max_lines }} of {{ total_lines }} lines)</span>
{% endif %}
</div>
<div class="gray-container" style="margin-top: 8px;">
<pre class="text-small" style="margin: 0; white-space: pre-wrap; word-wrap: break-word;">{% if needs_truncation %}{{ error_lines[:first_lines] | join('\n') }}
<span class="text-muted" style="font-style: italic;">... {{ total_lines - max_lines }} lines omitted ...</span>
{{ error_lines[-last_lines:] | join('\n') }}{% else %}{{ error_lines | join('\n') }}{% endif %}</pre>
</div>
</div>
<div class="more-info">
<a href="{{ frappe.utils.get_url_to_form('Error Log', doc.name) }}" class="btn btn-primary">View Error Log</a>
</div>
<p class="text-muted text-small" style="margin-top: 20px;">
This is an automated notification from {{ frappe.utils.get_host_name() }}.
</p>

View file

@ -0,0 +1,16 @@
{
"name": "Error Log",
"document_type": "Error Log",
"event": "New",
"channel": "Email",
"enabled": 0,
"subject": "[Error] {{ doc.method }}",
"message_type": "HTML",
"recipients": [
{
"receiver_by_role": "System Manager"
}
],
"send_system_notification": 0,
"send_to_all_assignees": 0
}

View file

@ -0,0 +1,117 @@
{% set error_lines = (doc.error or "").split('\n') %}
{% set output_lines = (doc.output or "").split('\n') %}
{% set first_lines = 10 %}
{% set last_lines = 15 %}
{% set max_lines = first_lines + last_lines %}
{% set total_error_lines = error_lines | length %}
{% set error_needs_truncation = total_error_lines > max_lines %}
{% set total_output_lines = output_lines | length %}
{% set output_needs_truncation = total_output_lines > max_lines %}
<table class="email-header" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td>
<h1 class="email-header-title">
Integration Request
</h1>
</td>
</tr>
</table>
<table class="table table-bordered" style="width: 100%;">
<tr>
<td class="text-bold" style="background: #f8f8f8; width: 120px">Site</td>
<td><a href="{{ frappe.utils.get_url() }}">{{ frappe.utils.get_url() }}</a></td>
</tr>
<tr>
<td class="text-bold" style="background: #f8f8f8;">Request ID</td>
<td>{{ frappe.utils.get_link_to_form("Integration Request", doc.name, doc.request_id or
doc.name) }}</td>
</tr>
<tr>
<td class="text-bold" style="background: #f8f8f8;">Service</td>
<td>{{ doc.integration_request_service or "N/A" }}</td>
</tr>
<tr>
<td class="text-bold" style="background: #f8f8f8;">Status</td>
<td>{{ doc.status }}</td>
</tr>
{% if doc.request_description %}
<tr>
<td class="text-bold" style="background: #f8f8f8;">Description</td>
<td>{{ doc.request_description }}</td>
</tr>
{% endif %}
<tr>
<td class="text-bold" style="background: #f8f8f8;">Logged At</td>
<td>{{ frappe.utils.format_datetime(doc.creation) }}</td>
</tr>
{% if doc.url %}
<tr>
<td class="text-bold" style="background: #f8f8f8;">Endpoint URL</td>
<td class="text-small" style="word-break: break-all;"><a href="{{ doc.url }}">{{ doc.url}}</a></td>
</tr>
{% endif %}
{% if doc.reference_doctype and doc.reference_docname %}
<tr>
<td class="text-bold" style="background: #f8f8f8;">Reference</td>
<td>
<a
href="{{ frappe.utils.get_url_to_form(doc.reference_doctype, doc.reference_docname) }}">
{{ doc.reference_doctype }}: {{ doc.reference_docname }}
</a>
</td>
</tr>
{% endif %}
</table>
{% if doc.error %}
<div style="margin-top: 20px;">
<div class="text-medium text-bold">
Error Details
{% if error_needs_truncation %}
<span class="text-muted" style="font-weight: normal;"> ({{ max_lines }} of {{
total_error_lines }} lines)</span>
{% endif %}
</div>
<div class="gray-container" style="margin-top: 8px;">
<pre class="text-small"
style="margin: 0; white-space: pre-wrap; word-wrap: break-word;">{% if error_needs_truncation %}{{ error_lines[:first_lines] | join('\n') }}
<span class="text-muted" style="font-style: italic;">... {{ total_error_lines - max_lines }} lines omitted ...</span>
{{ error_lines[-last_lines:] | join('\n') }}{% else %}{{ error_lines | join('\n') }}{% endif %}</pre>
</div>
</div>
{% endif %}
{% if doc.output %}
<div style="margin-top: 20px;">
<div class="text-medium text-bold">
Response Output
{% if output_needs_truncation %}
<span class="text-muted" style="font-weight: normal;"> ({{ max_lines }} of {{
total_output_lines }} lines)</span>
{% endif %}
</div>
<div class="gray-container" style="margin-top: 8px;">
<pre class="text-small"
style="margin: 0; white-space: pre-wrap; word-wrap: break-word;">{% if output_needs_truncation %}{{ output_lines[:first_lines] | join('\n') }}
<span class="text-muted" style="font-style: italic;">... {{ total_output_lines - max_lines }} lines omitted ...</span>
{{ output_lines[-last_lines:] | join('\n') }}{% else %}{{ output_lines | join('\n') }}{% endif %}</pre>
</div>
</div>
{% endif %}
<div class="more-info">
<a class="btn btn-primary"
href="{{ frappe.utils.get_url_to_form('Integration Request', doc.name) }}">
View Integration Request
</a>
</div>
<p class="text-muted text-small" style="margin-top: 20px;">
This is an automated notification from {{ frappe.utils.get_host_name() }}.
</p>

View file

@ -0,0 +1,17 @@
{
"name": "Integration Request",
"document_type": "Integration Request",
"event": "Save",
"channel": "Email",
"condition": "doc.status==\"Failed\"",
"enabled": 0,
"subject": "[Error] {{ doc.integration_request_service }}",
"message_type": "HTML",
"recipients": [
{
"receiver_by_role": "System Manager"
}
],
"send_system_notification": 0,
"send_to_all_assignees": 0
}

View file

@ -276,7 +276,9 @@ class EMail:
validate_email_address(strip(self.sender), True)
self.reply_to = validate_email_address(strip(self.reply_to) or self.sender, True)
self.set_header("X-Original-From", self.sender)
if self.email_account.add_x_original_from:
self.set_header("X-Original-From", self.sender)
self.replace_sender()
self.replace_sender_name()

View file

@ -65,7 +65,16 @@ class Oauth:
)
if not res.startswith(b"+OK"):
raise
frappe.log_error(
title="POP3 OAuth Authentication Failed",
message=f"Response: {res}",
reference_doctype="Email Account",
reference_name=self.email_account,
)
frappe.throw(
frappe._("POP3 OAuth authentication failed for Email Account {0}").format(self.email_account),
frappe.AuthenticationError,
)
def _connect_imap(self) -> None:
self._conn.authenticate(self._mechanism, lambda x: self._auth_string)

View file

@ -160,8 +160,9 @@ w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
content=email_html,
header=["Email Title", "green"],
).as_string()
# REDESIGN-TODO: Add style for indicators in email
self.assertTrue("""<span class=3D"indicator indicator-green"></span>""" in email_string)
# REDESIGN: Add style for indicators in email
self.assertIn("indicator", email_string)
self.assertIn("indicator-green", email_string)
self.assertTrue("<span>Email Title</span>" in email_string)
self.assertIn(
"Subject: Test Subject, with line break, and Line feed and carriage return.", email_string

View file

@ -27,7 +27,7 @@
"Algeria": {
"code": "dz",
"currency": "DZD",
"currency_fraction": "Santeem",
"currency_fraction": "Centime",
"currency_fraction_units": 100,
"currency_name": "Algerian Dinar",
"currency_symbol": "\u062f.\u062c",

View file

@ -52,7 +52,7 @@ ml,മലയാളം,0
mn,Монгол,0
mr,मराठी,0
ms,Melayu,0
my,မြန်မာ1
my,မြန်မာ,1
nb,Norsk Bokmål,1
nl,Nederlands,0
no,Norsk,0

Can't render this file because it has a wrong number of fields in line 55.

View file

@ -216,7 +216,6 @@ scheduler_events = {
"frappe.deferred_insert.save_to_db",
"frappe.automation.doctype.reminder.reminder.send_reminders",
"frappe.model.utils.link_count.update_link_count",
"frappe.search.sqlite_search.build_index_if_not_exists",
"frappe.utils.telemetry.pulse.client.send_queued_events",
],
# 10 minutes
@ -227,6 +226,9 @@ scheduler_events = {
"30 * * * *": [],
# Daily but offset by 45 minutes
"45 0 * * *": [],
"0 */3 * * *": [
"frappe.search.sqlite_search.build_index_if_not_exists",
],
},
"all": [
"frappe.email.queue.flush",

View file

@ -260,6 +260,8 @@ def parse_app_name(name: str) -> str:
else:
_repo = name.rsplit("/", 2)[2]
repo = _repo.split(".", 1)[0]
elif name in frappe.get_all_apps():
return name
else:
_, repo, _ = fetch_details_from_tag(name)
return repo

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

36677
frappe/locale/mn.po Normal file

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

@ -242,7 +242,7 @@ def get_permitted_fields(
)
if permission_type == "select":
return permitted_fields
return [*meta.default_fields, *permitted_fields]
valid_columns = set(valid_columns)
result = [

View file

@ -1,8 +1,8 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import datetime
import functools
import json
import keyword
import re
import weakref
from types import MappingProxyType
@ -67,18 +67,15 @@ DOCTYPES_FOR_DOCTYPE = {"DocType", *TABLE_DOCTYPES_FOR_DOCTYPE.values()}
def _reduce_extended_instance(doc):
"""Make extended class instances pickle-able.
When unpickling, this will use get_controller() to recreate the extended class.
Stores __bases__ for reconstructing the extended class during unpickling.
Respects the __getstate__ method for proper state handling.
"""
return (_reconstruct_extended_instance, (doc.doctype,), doc.__getstate__())
return (_reconstruct_extended_instance, (type(doc).__bases__,), doc.__getstate__())
def _reconstruct_extended_instance(doctype):
"""
Helper function to reconstruct an extended class instance during unpickling.
"""
# Get the current extended class (uses caching from get_controller)
extended_class = get_controller(doctype)
def _reconstruct_extended_instance(bases):
"""Reconstruct an extended class instance during unpickling."""
extended_class = _create_extended_class(bases)
return extended_class.__new__(extended_class)
@ -208,9 +205,25 @@ def _get_extended_class(base_class, doctype):
# Create the extended class by combining extension classes with base class
# Extension classes come first in MRO, then base class
return _create_extended_class((*extension_classes, base_class))
# cached to avoid recreating the same class multiple times during unpickling
# safe to cache, classes on file don't change at runtime
@functools.cache
def _create_extended_class(bases):
"""Create an extended class from base classes.
Args:
bases: Tuple of base classes (extension classes first, then the controller class)
Returns:
Extended class combining all bases with pickle support
"""
base_class = bases[-1]
return type(
f"Extended{base_class.__name__}",
(*extension_classes, base_class),
bases,
{
"__reduce__": _reduce_extended_instance,
"__module__": base_class.__module__,

View file

@ -499,9 +499,11 @@ from {tables}
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:
if token.ttype in (tokens.Keyword, tokens.Name):
if any(re.search(rf"\b{kw}\b", token.value.lower()) for kw in blacklisted_keywords):
_raise_exception()
if token.is_group:
_check_sql_token(token)
@ -868,6 +870,15 @@ from {tables}
if f.operator.lower() == "in":
can_be_null &= not f.value or any(v is None or v == "" for v in f.value)
# Handle empty lists for IN/NOT IN operators before processing
# IN with empty list should return 0 results (always False: 1=0)
# NOT IN with empty list should return all results (always True: 1=1)
if isinstance(f.value, (list, tuple)) and len(f.value) == 0:
if f.operator.lower() == "in":
return "1=0"
else: # not in
return "1=1"
if value is None:
values = f.value or ""
if isinstance(values, str):

View file

@ -912,11 +912,10 @@ def get_field_precision(df, doc=None, currency=None):
def get_precision_from_currency_format(currency: str) -> int:
"""Get precision from currency format string if applicable."""
from frappe.locale import get_number_format
from frappe.utils.number_format import NumberFormat
use_format_from_currency = frappe.get_system_settings("use_number_format_from_currency")
number_format = get_number_format()
number_format = NumberFormat.from_string(frappe.db.get_default("number_format"))
if use_format_from_currency:
currency_format = frappe.db.get_value("Currency", currency, "number_format", cache=True)
number_format = NumberFormat.from_string(currency_format) if currency_format else number_format

View file

@ -230,7 +230,22 @@ def set_name_from_naming_options(autoname, doc):
elif _autoname.startswith("format:"):
doc.name = _format_autoname(autoname, doc)
elif "#" in autoname:
doc.name = make_autoname(autoname, doc=doc)
# For Expression naming rule, first replace braced params, then normalize, then process series
# This handles patterns like {full_name}-{description}-.#####
def get_param_value_for_match(match):
param = match.group()
return parse_naming_series([param[1:-1]], doc=doc)
# Replace braced params first
name_with_params = BRACED_PARAMS_PATTERN.sub(get_param_value_for_match, autoname)
# Normalize pattern: convert '-.#####' to '.-.#####' to support both formats
# This handles cases like {fieldname}-.##### (without dot before dash)
# Pattern matches: dash followed by dot followed by one or more hashes, but only if not preceded by a dot
normalized_autoname = re.sub(r"(?<!\.)(-\.#+)", r".\1", name_with_params)
# Process the series
doc.name = make_autoname(normalized_autoname, doc=doc)
def set_naming_from_document_naming_rule(doc):
@ -584,11 +599,19 @@ def _format_autoname(autoname: str, doc):
Independent of remaining string or separators.
Example pattern: 'format:LOG-{MM}-{fieldname1}-{fieldname2}-{#####}'
Supports both patterns:
- {fieldname}.-.##### (with dot before dash)
- {fieldname}-.##### (without dot before dash)
"""
first_colon_index = autoname.find(":")
autoname_value = autoname[first_colon_index + 1 :]
# Normalize pattern: convert '-.#####' to '.-.#####' to support both formats
# This handles cases like {fieldname}-.##### (without dot before dash)
# Pattern matches: dash followed by dot followed by one or more hashes, but only if not preceded by a dot
autoname_value = re.sub(r"(?<!\.)(-\.#+)", r".\1", autoname_value)
def get_param_value_for_match(match):
param = match.group()
return parse_naming_series([param[1:-1]], doc=doc)
@ -596,4 +619,8 @@ def _format_autoname(autoname: str, doc):
# Replace braced params with their parsed value
name = BRACED_PARAMS_PATTERN.sub(get_param_value_for_match, autoname_value)
# If the result still contains unbraced hash patterns (like .#####), process them as naming series
if "#" in name and "{" not in name:
name = make_autoname(name, doc=doc)
return name

View file

@ -649,6 +649,9 @@ def update_parenttype_values(old: str, new: str):
child_doctypes = set(list(d["options"] for d in child_doctypes) + property_setter_child_doctypes)
for doctype in child_doctypes:
if frappe.get_meta(doctype).is_virtual:
continue
table = frappe.qb.DocType(doctype)
frappe.qb.update(table).set(table.parenttype, new).where(table.parenttype == old).run()

View file

@ -256,3 +256,6 @@ frappe.patches.v16_0.add_standard_field_in_workspace_sidebar
execute:frappe.db.set_single_value("Desktop Settings", "icon_style", "Solid")
execute:frappe.delete_doc_if_exists("Workspace Sidebar", "Productivity")
frappe.patches.v16_0.unset_standard_field_for_auto_generated_icons
execute:from frappe.email.doctype.notification.notification import install_notification_templates; install_notification_templates()
execute:frappe.db.set_value("Email Account", {}, "add_x_original_from", 1)
frappe.patches.v16_0.fix_myanmar_language_name

View file

@ -0,0 +1,9 @@
import frappe
def execute():
Language = frappe.qb.DocType("Language")
frappe.qb.update(Language).set(Language.language_name, "မြန်မာ").where(
Language.language_code == "my"
).run()

View file

@ -5,14 +5,14 @@ from frappe.model.sync import check_if_record_exists
def execute():
for icon in frappe.get_all("Desktop Icon"):
icon_doc = frappe.get_doc("Desktop Icon", icon.name)
if (icon_doc.standard and icon_doc.app) and not check_if_record_exists(
"app",
frappe.get_app_path(icon_doc.app),
"Desktop Icon",
icon_doc.name,
):
try:
try:
if (icon_doc.standard and icon_doc.app) and not check_if_record_exists(
"app",
frappe.get_app_path(icon_doc.app),
"Desktop Icon",
icon_doc.name,
):
icon_doc.standard = 0
icon_doc.save()
except Exception as e:
print("Error in unsetting standard field", e)
except Exception as e:
print("Error in unsetting standard field", e)

View file

@ -14,6 +14,8 @@ import "./frappe/ui/sidebar/sidebar_header.html";
import "./frappe/ui/sidebar/sidebar.html";
import "./frappe/ui/sidebar/sidebar_item.html";
import "./frappe/ui/sidebar/sidebar.js";
import "./frappe/ui/sidebar/sidebar_card.html";
import "./frappe/ui/sidebar/sidebar_card.js";
import "./frappe/ui/link_preview.js";
import "./frappe/request.js";

View file

@ -107,8 +107,31 @@ export const useStore = defineStore("form-builder-store", () => {
}
}
// Preserve the currently active tab index before regenerating layout
// This is more reliable than tracking by name since tab names can change after save
let previous_active_tab_index = null;
if (form.value.layout?.tabs && form.value.active_tab) {
previous_active_tab_index = form.value.layout.tabs.findIndex(
(tab) => tab.df.name === form.value.active_tab
);
}
form.value.layout = get_layout();
form.value.active_tab = form.value.layout.tabs[0].df.name;
// Try to restore the previously active tab by index if it still exists
if (
previous_active_tab_index !== null &&
previous_active_tab_index >= 0 &&
previous_active_tab_index < form.value.layout.tabs.length
) {
form.value.active_tab = form.value.layout.tabs[previous_active_tab_index].df.name;
} else if (form.value.layout.tabs.length > 0) {
// If previous tab doesn't exist or no previous tab, default to first tab
form.value.active_tab = form.value.layout.tabs[0].df.name;
} else {
form.value.active_tab = null;
}
form.value.selected_field = null;
nextTick(() => {

View file

@ -287,6 +287,19 @@ frappe.data_import.DataExporter = class DataExporter {
return false;
};
let is_field_depends_on = (df) => {
if (df.depends_on && this.exporting_for == "Insert New Records") {
return true;
}
if (autoname_field && df.fieldname == autoname_field.fieldname) {
return true;
}
if (df.fieldname === "name") {
return true;
}
return false;
};
return fields
.filter((df) => {
if (autoname_field && df.fieldname === "name") {
@ -299,6 +312,7 @@ frappe.data_import.DataExporter = class DataExporter {
label: __(df.label, null, df.parent),
value: df.fieldname,
danger: is_field_mandatory(df),
warning: is_field_depends_on(df),
checked: false,
description: `${df.fieldname} ${df.reqd ? __("(Mandatory)") : ""}`,
};

View file

@ -347,12 +347,12 @@ frappe.get_modal = function (title, content) {
<span class="indicator hidden"></span>
<h4 class="modal-title">${title}</h4>
</div>
<div class="modal-actions">
<button class="btn btn-modal-minimize btn-link hide">
<div class="modal-actions d-flex">
<button class="btn btn-ghost btn-modal-minimize icon-btn hide">
${frappe.utils.icon("collapse")}
</button>
<button class="btn btn-modal-close btn-link" data-dismiss="modal">
${frappe.utils.icon("close", "sm")}
<button class="btn btn-ghost btn-modal-close icon-btn" data-dismiss="modal">
${frappe.utils.icon("x", "sm")}
</button>
</div>
</div>

View file

@ -64,7 +64,16 @@ frappe.ui.form.ControlAttach = class ControlAttach extends frappe.ui.form.Contro
}
on_attach_doc_image() {
this.set_upload_options();
this.upload_options.restrictions.allowed_file_types = ["image/*"];
this.upload_options.restrictions.allowed_file_types = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/svg+xml",
"image/avif",
"image/bmp",
"image/x-icon",
];
this.file_uploader = new frappe.ui.FileUploader(this.upload_options);
}
set_upload_options() {

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