Merge remote-tracking branch 'upstream' into desktop-route-addition
This commit is contained in:
commit
60a8498d54
210 changed files with 61358 additions and 21793 deletions
5
.github/helper/roulette.py
vendored
5
.github/helper/roulette.py
vendored
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -549,6 +549,11 @@ def get_sidebar_items(allowed_workspaces):
|
|||
else:
|
||||
sidebar_title = s.title
|
||||
w = s
|
||||
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": [],
|
||||
|
|
|
|||
103
frappe/commands/execute.py
Normal file
103
frappe/commands/execute.py
Normal 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
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright (c) {year}, {app_publisher} and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.views.calendar["{doctype}"] = {{
|
||||
}};
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,6 +610,9 @@ class File(Document):
|
|||
encodings = FILE_ENCODING_OPTIONS
|
||||
with open(file_path, mode="rb") as f:
|
||||
self._content = f.read()
|
||||
# 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:
|
||||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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={})
|
||||
|
|
|
|||
|
|
@ -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,19 +432,27 @@ 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) {
|
||||
if (frm.roles_editor) {
|
||||
frm.roles_editor.disable = 1;
|
||||
}
|
||||
frm.call("populate_role_profile_roles").then(() => {
|
||||
if (frm.roles_editor) {
|
||||
frm.roles_editor.show();
|
||||
}
|
||||
});
|
||||
if (frm.roles_editor) {
|
||||
$(".deselect-all, .select-all").prop("disabled", true);
|
||||
}
|
||||
}
|
||||
},
|
||||
role_profiles_remove: function (frm) {
|
||||
if (frm.doc.role_profiles.length == 0) {
|
||||
if (frm.roles_editor) {
|
||||
frm.roles_editor.disable = 0;
|
||||
frm.roles_editor.show();
|
||||
$(".deselect-all, .select-all").prop("disabled", false);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,6 +440,9 @@ 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:
|
||||
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
|
||||
|
|
@ -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,9 +759,8 @@ 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
|
||||
# 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:
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]:
|
||||
|
|
|
|||
|
|
@ -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,7 +207,7 @@ 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -130,8 +130,15 @@ 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}")
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -51,10 +51,13 @@ class WorkspaceSidebar(Document):
|
|||
|
||||
def before_save(self):
|
||||
self.export_sidebar()
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -8,21 +8,21 @@
|
|||
alt="{{ _("App Logo") |e }}"
|
||||
>
|
||||
</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"
|
||||
>
|
||||
<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>
|
||||
<svg class="icon icon-sm">
|
||||
<use href="#icon-search"></use>
|
||||
</svg>
|
||||
{{ _("Search") }}
|
||||
</span>
|
||||
<span>
|
||||
{{ "⌘K" if is_mac else "Ctrl+K" }}
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -129,8 +129,11 @@ function save_desktop(icons) {
|
|||
}
|
||||
|
||||
function reset_to_default() {
|
||||
frappe.db.delete_doc("Desktop Layout", frappe.session.user).then(() => {
|
||||
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]);
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
if self.email_account.add_x_original_from:
|
||||
self.set_header("X-Original-From", self.sender)
|
||||
|
||||
self.replace_sender()
|
||||
self.replace_sender_name()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1425
frappe/locale/ar.po
1425
frappe/locale/ar.po
File diff suppressed because it is too large
Load diff
1425
frappe/locale/bs.po
1425
frappe/locale/bs.po
File diff suppressed because it is too large
Load diff
1423
frappe/locale/cs.po
1423
frappe/locale/cs.po
File diff suppressed because it is too large
Load diff
1423
frappe/locale/da.po
1423
frappe/locale/da.po
File diff suppressed because it is too large
Load diff
1425
frappe/locale/de.po
1425
frappe/locale/de.po
File diff suppressed because it is too large
Load diff
1425
frappe/locale/eo.po
1425
frappe/locale/eo.po
File diff suppressed because it is too large
Load diff
1891
frappe/locale/es.po
1891
frappe/locale/es.po
File diff suppressed because it is too large
Load diff
1609
frappe/locale/fa.po
1609
frappe/locale/fa.po
File diff suppressed because it is too large
Load diff
1425
frappe/locale/fr.po
1425
frappe/locale/fr.po
File diff suppressed because it is too large
Load diff
1425
frappe/locale/hr.po
1425
frappe/locale/hr.po
File diff suppressed because it is too large
Load diff
1641
frappe/locale/hu.po
1641
frappe/locale/hu.po
File diff suppressed because it is too large
Load diff
1423
frappe/locale/id.po
1423
frappe/locale/id.po
File diff suppressed because it is too large
Load diff
1957
frappe/locale/it.po
1957
frappe/locale/it.po
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
36677
frappe/locale/mn.po
Normal file
File diff suppressed because it is too large
Load diff
1423
frappe/locale/my.po
1423
frappe/locale/my.po
File diff suppressed because it is too large
Load diff
1425
frappe/locale/nb.po
1425
frappe/locale/nb.po
File diff suppressed because it is too large
Load diff
1423
frappe/locale/nl.po
1423
frappe/locale/nl.po
File diff suppressed because it is too large
Load diff
1423
frappe/locale/pl.po
1423
frappe/locale/pl.po
File diff suppressed because it is too large
Load diff
1423
frappe/locale/pt.po
1423
frappe/locale/pt.po
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
1423
frappe/locale/ru.po
1423
frappe/locale/ru.po
File diff suppressed because it is too large
Load diff
1423
frappe/locale/sl.po
1423
frappe/locale/sl.po
File diff suppressed because it is too large
Load diff
1425
frappe/locale/sr.po
1425
frappe/locale/sr.po
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
1441
frappe/locale/sv.po
1441
frappe/locale/sv.po
File diff suppressed because it is too large
Load diff
1423
frappe/locale/th.po
1423
frappe/locale/th.po
File diff suppressed because it is too large
Load diff
1425
frappe/locale/tr.po
1425
frappe/locale/tr.po
File diff suppressed because it is too large
Load diff
1423
frappe/locale/vi.po
1423
frappe/locale/vi.po
File diff suppressed because it is too large
Load diff
1425
frappe/locale/zh.po
1425
frappe/locale/zh.po
File diff suppressed because it is too large
Load diff
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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__,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
9
frappe/patches/v16_0/fix_myanmar_language_name.py
Normal file
9
frappe/patches/v16_0/fix_myanmar_language_name.py
Normal 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()
|
||||
|
|
@ -5,13 +5,13 @@ 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)
|
||||
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,
|
||||
):
|
||||
try:
|
||||
icon_doc.standard = 0
|
||||
icon_doc.save()
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
// 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(() => {
|
||||
|
|
|
|||
|
|
@ -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)") : ""}`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue