Merge branch 'develop' into get-docs

This commit is contained in:
mergify[bot] 2026-03-19 11:48:31 +00:00 committed by GitHub
commit 1907293ba7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
127 changed files with 25988 additions and 20848 deletions

View file

@ -72,7 +72,7 @@ jobs:
- name: Clone
uses: actions/checkout@v6
- name: Download artifacts
uses: actions/download-artifact@v8.0.0
uses: actions/download-artifact@v8.0.1
- name: Upload coverage data
uses: codecov/codecov-action@v5
with:

View file

@ -57,7 +57,7 @@ jobs:
- name: Clone
uses: actions/checkout@v6
- name: Download artifacts
uses: actions/download-artifact@v8.0.0
uses: actions/download-artifact@v8.0.1
- name: Upload python coverage data
uses: codecov/codecov-action@v5
with:

View file

@ -151,9 +151,10 @@ def get_letter_heads():
def load_conf_settings(bootinfo):
from frappe.core.api.file import get_max_file_size
from frappe.core.api.file import get_file_chunk_size, get_max_file_size
bootinfo.max_file_size = get_max_file_size()
bootinfo.file_chunk_size = get_file_chunk_size()
for key in ("developer_mode", "socketio_port", "file_watcher_port"):
if key in frappe.conf:
bootinfo[key] = frappe.conf.get(key)

View file

@ -465,14 +465,14 @@ def validate_link_and_fetch(
)
if not search_result:
return {} # does not exist or filtered out
return {} # Either the record does not exist or was excluded by link_filters
values = None
is_virtual_dt = bool(meta.get("is_virtual"))
if is_virtual_dt:
try:
doc = frappe.get_doc(doctype, docname)
doc.check_permission("select" if frappe.only_has_select_perm(doctype) else "read")
doc.check_permission("select")
values = {"name": doc.name}
except frappe.DoesNotExistError:

View file

@ -90,6 +90,10 @@ def get_max_file_size() -> int:
)
def get_file_chunk_size() -> int:
return cint(frappe.conf.get("file_chunk_size")) or 25 * 1024 * 1024
@frappe.whitelist()
def create_new_folder(file_name: str, folder: str) -> File:
"""create new folder under current parent folder"""

View file

@ -18,7 +18,10 @@ from frappe.utils import (
get_imaginary_pixel_response,
get_string_between,
list_to_str,
now_datetime,
parse_addr,
split_emails,
time_diff_in_seconds,
validate_email_address,
)
@ -328,3 +331,63 @@ def update_communication_as_read(name):
name,
{"read_by_recipient": 1, "delivery_status": "Read", "read_by_recipient_on": get_datetime()},
)
@frappe.whitelist()
def undo_email_send(communication_name: str):
communication = frappe.get_doc("Communication", communication_name)
if communication.owner != frappe.session.user:
frappe.throw(_("You are not authorized to undo this email"))
if communication.sent_or_received != "Sent" or communication.communication_medium != "Email":
frappe.throw(_("Failed to delete communication"))
time_elapsed_in_seconds = time_diff_in_seconds(now_datetime(), communication.creation)
if time_elapsed_in_seconds > 10:
frappe.msgprint(
_("Email undo window is over. Cannot undo email."), alert=True, indicator="red", raise_exception=1
)
email_queue_records = frappe.get_all(
"Email Queue", filters={"communication": communication_name}, fields=["name", "status"]
)
for queue in email_queue_records:
if queue.status != "Not Sent":
frappe.msgprint(
_("It is too late to undo this email. It is already being sent."),
alert=True,
indicator="red",
raise_exception=1,
)
for queue in email_queue_records:
frappe.delete_doc("Email Queue", queue.name, ignore_permissions=True)
communication_data = {
"subject": communication.subject,
"content": communication.content,
"recipients": communication.recipients,
"cc": communication.cc,
"bcc": communication.bcc,
"doc": {"doctype": communication.reference_doctype, "name": communication.reference_name},
"sender": communication.sender,
"send_read_receipt": communication.read_receipt,
}
linked_files = frappe.get_all(
"File",
filters={"attached_to_doctype": "Communication", "attached_to_name": communication_name},
pluck="name",
)
if linked_files:
for file_name in linked_files:
frappe.db.set_value("File", file_name, {"attached_to_doctype": None, "attached_to_name": None})
communication_data["attachments"] = linked_files
communication.delete(ignore_permissions=True)
return communication_data

View file

@ -1,12 +1,14 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
from datetime import timedelta
from typing import TYPE_CHECKING
import frappe
from frappe.core.doctype.communication.communication import Communication, get_emails, parse_email
from frappe.core.doctype.communication.email import add_attachments, make
from frappe.core.doctype.communication.email import add_attachments, make, undo_email_send
from frappe.email.doctype.email_queue.email_queue import EmailQueue
from frappe.tests import IntegrationTestCase
from frappe.utils import add_to_date, now_datetime
if TYPE_CHECKING:
from frappe.contacts.doctype.contact.contact import Contact
@ -438,6 +440,79 @@ class TestCommunicationEmailMixin(IntegrationTestCase):
self.assertEqual(attached_file.file_name, file_name)
self.assertEqual(attached_file.get_content(), file_content)
def test_undo_email_send(self):
"""Undo should delete Communication and Email Queue, and return original data."""
comm = self.new_communication(recipients=["to@test.com"])
comm.sent_or_received = "Sent"
comm.save(ignore_permissions=True)
eq = frappe.get_doc(
{
"doctype": "Email Queue",
"sender": "Test <test@example.com>",
"message": "Test message",
"status": "Not Sent",
"priority": 1,
"communication": comm.name,
"recipients": [{"recipient": "to@test.com", "status": "Not Sent"}],
}
).insert(ignore_permissions=True)
result = undo_email_send(comm.name)
self.assertFalse(frappe.db.exists("Communication", comm.name))
self.assertFalse(frappe.db.exists("Email Queue", eq.name))
self.assertFalse(frappe.db.exists("Email Queue Recipient", {"parent": eq.name}))
self.assertEqual(result["subject"], comm.subject)
self.assertEqual(result["recipients"], comm.recipients)
def test_undo_email_send_fails_for_different_user(self):
"""Undo should fail if the current user is not the owner."""
comm = self.new_communication(recipients=["to@test.com"])
comm.sent_or_received = "Sent"
comm.save(ignore_permissions=True)
frappe.db.set_value("Communication", comm.name, "owner", "other@test.com")
with self.assertRaises(frappe.exceptions.ValidationError):
undo_email_send(comm.name)
self.assertTrue(frappe.db.exists("Communication", comm.name))
def test_undo_email_send_fails_after_time_window(self):
"""Undo should fail if the 10-second window has passed."""
comm = self.new_communication(recipients=["to@test.com"])
comm.sent_or_received = "Sent"
comm.save(ignore_permissions=True)
with self.freeze_time(add_to_date(now_datetime(), seconds=12)):
with self.assertRaises(frappe.exceptions.ValidationError):
undo_email_send(comm.name)
self.assertTrue(frappe.db.exists("Communication", comm.name))
def test_undo_email_send_fails_if_already_sent(self):
"""Undo should fail if Email Queue status is not 'Not Sent'."""
comm = self.new_communication(recipients=["to@test.com"])
comm.sent_or_received = "Sent"
comm.save(ignore_permissions=True)
frappe.get_doc(
{
"doctype": "Email Queue",
"sender": "Test <test@example.com>",
"message": "Test message",
"status": "Sent",
"priority": 1,
"communication": comm.name,
"recipients": [{"recipient": "to@test.com", "status": "Sent"}],
}
).insert(ignore_permissions=True)
with self.assertRaises(frappe.exceptions.ValidationError):
undo_email_send(comm.name)
self.assertTrue(frappe.db.exists("Communication", comm.name))
def create_email_account() -> "EmailAccount":
frappe.delete_doc_if_exists("Email Account", "_Test Comm Account 1")

View file

@ -187,8 +187,14 @@ def stop_data_import(doc_name: str):
def start_import(data_import):
"""This method runs in background job"""
data_import = frappe.get_doc("Data Import", data_import)
# Apply same delimiter/sniffer settings as preview so CSV is parsed correctly (e.g. EU ";" delimiter)
data_import.set_delimiters_flag()
try:
i = Importer(data_import.reference_doctype, data_import=data_import)
i = Importer(
data_import.reference_doctype,
data_import=data_import,
use_sniffer=data_import.use_csv_sniffer,
)
i.import_data()
except JobTimeoutException:
frappe.db.rollback()

View file

@ -338,7 +338,7 @@
},
{
"default": "0",
"depends_on": "eval: parent.is_submittable",
"depends_on": "eval: parent.is_submittable || parent.istable",
"fieldname": "allow_on_submit",
"fieldtype": "Check",
"label": "Allow on Submit",
@ -647,7 +647,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-02-06 15:13:03.688027",
"modified": "2026-03-10 21:39:58.400441",
"modified_by": "Administrator",
"module": "Core",
"name": "DocField",

View file

@ -751,7 +751,7 @@ class File(Document):
return self.save_file_on_filesystem()
def save_file_on_filesystem(self):
safe_file_name = re.sub(r"[/\\%?#]", "_", self.file_name)
safe_file_name = get_safe_file_name(self.file_name)
if self.is_private:
self.file_url = f"/private/files/{safe_file_name}"
else:

View file

@ -476,3 +476,7 @@ def find_file_by_url(path: str, name: str | None = None) -> "File" | None:
file: File = frappe.get_doc(doctype="File", **file_data)
if file.is_downloadable():
return file
def get_safe_file_name(file_name: str) -> str:
return re.sub(r"[/\\%?#]", "_", file_name)

View file

@ -10,6 +10,7 @@
"action",
"hidden",
"is_standard",
"icon",
"column_break_dtwu",
"condition"
],
@ -72,19 +73,25 @@
{
"fieldname": "column_break_dtwu",
"fieldtype": "Column Break"
},
{
"fieldname": "icon",
"fieldtype": "Icon",
"label": "Icon"
}
],
"istable": 1,
"links": [],
"modified": "2024-11-15 14:12:19.803995",
"modified": "2026-03-11 12:23:02.473404",
"modified_by": "Administrator",
"module": "Core",
"name": "Navbar Item",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View file

@ -125,6 +125,19 @@ def generate_report(prepared_report):
create_json_gz_file(result, instance.doctype, instance.name, instance.report_name)
instance.status = "Completed"
frappe.get_doc(
{
"doctype": "Notification Log",
"subject": f"{instance.report_name} report is ready.",
"for_user": frappe.session.user,
"type": "Alert",
"document_type": "Report",
"document_name": report.name,
"link": f"/desk/query-report/{report.name}?prepared_report_name={instance.name}",
}
).insert(ignore_permissions=True)
except Exception:
# we need to ensure that error gets stored
_save_error(instance, error=frappe.get_traceback(with_context=True))

View file

@ -92,6 +92,13 @@ class TestTranslation(IntegrationTestCase):
self.assertTrue(_(source), target)
def test_html_message_translations(self):
"""Test fallback for messages w/ HTML Tags"""
message = "Hide descendant records of <b>For Value</b>."
translated_message = "隐藏下层节点<b>值</b>"
create_translation("zh", message, translated_message)
self.assertEqual(_(message, lang="zh"), translated_message)
def get_translation_data():
html_source_data = """<font color="#848484" face="arial, tahoma, verdana, sans-serif">

View file

@ -36,7 +36,7 @@ from frappe.utils import (
)
from frappe.utils.data import sha256_hash
from frappe.utils.html_utils import sanitize_html
from frappe.utils.password import check_password, get_password_reset_limit
from frappe.utils.password import check_password, get_password_reset_limit, is_password_reused
from frappe.utils.password import update_password as _update_password
from frappe.utils.user import get_system_managers
from frappe.website.utils import get_home_page, is_signup_disabled
@ -361,7 +361,7 @@ class User(Document):
self.set(field, sanitize_html(field_value, always_sanitize=True))
def set_full_name(self):
self.full_name = " ".join(filter(None, [self.first_name, self.last_name]))
self.full_name = " ".join(p for p in [self.first_name, self.middle_name, self.last_name] if p)
def check_enable_disable(self):
# do not allow disabling administrator/guest
@ -927,6 +927,14 @@ def update_password(
else:
user = res["user"]
if is_password_reused(user, new_password):
frappe.throw(
_(
"New password cannot be the same as your current password. Please choose a different password."
),
title=_("Invalid Password"),
)
logout_all_sessions = cint(logout_all_sessions) or frappe.get_system_settings("logout_on_password_reset")
_update_password(user, new_password, logout_all_sessions=cint(logout_all_sessions))

View file

@ -139,8 +139,8 @@ frappe.PermissionEngine = class PermissionEngine {
data.message.forEach((d) => {
let custom_rights = this.options.doctype_ptype_map[doctype] || [];
d.rights = this.rights
.filter((r) => d[r])
.concat(custom_rights)
.filter((r) => d[r])
.map((r) => {
return __(toTitle(frappe.unscrub(r)));
})

View file

@ -107,13 +107,15 @@ frappe.ui.form.on("Customize Form", {
frm.page.set_title(__("Customize Form - {0}", [__(frm.doc.doc_type)]));
frappe.customize_form.set_primary_action(frm);
frm.add_custom_button(
__("Go to {0} List", [__(frm.doc.doc_type)]),
function () {
frappe.set_route("List", frm.doc.doc_type);
},
__("Actions")
);
if (!frappe.get_meta(frm.doc.doc_type).istable) {
frm.add_custom_button(
__("Go to {0} List", [__(frm.doc.doc_type)]),
() => {
frappe.set_route("List", frm.doc.doc_type);
},
__("Actions")
);
}
frm.add_custom_button(
__("Set Permissions"),

View file

@ -9,6 +9,7 @@ from frappe.database.utils import NestedSetHierarchy
from frappe.model.db_query import get_timespan_date_range
from frappe.query_builder import Field
from frappe.query_builder.functions import Coalesce
from frappe.utils import cstr
def like(key: Field, value: str) -> frappe.qb:
@ -107,7 +108,7 @@ def func_between(key: Field, value: list | tuple) -> frappe.qb:
def func_is(key, value):
"Wrapper for IS"
match value.lower():
match cstr(value).lower():
case "set":
return key != ""
case "not set":

View file

@ -19,6 +19,7 @@ from frappe.database.utils import (
get_doctype_name,
get_doctype_sort_info,
)
from frappe.model import CORE_DOCTYPES as PERMITTED_CORE_DOCTYPES
from frappe.model import OPTIONAL_FIELDS, get_permitted_fields
from frappe.model.base_document import DOCTYPES_FOR_DOCTYPE
from frappe.model.document import Document
@ -83,7 +84,7 @@ def _apply_date_field_filter_conversion(value, operator: str, doctype: str, fiel
elif isinstance(value, datetime.datetime):
return value.date()
except (AttributeError, TypeError, KeyError):
except AttributeError, TypeError, KeyError:
pass
return value
@ -273,7 +274,7 @@ class Engine:
self.table = qb.DocType(table)
if self.apply_permissions:
self.check_read_permission()
self.check_select_permission()
self.permission_doctype = parent_doctype or self.doctype
self.permission_table = (
qb.DocType(self.permission_doctype) if self.permission_doctype != self.doctype else self.table
@ -357,13 +358,19 @@ class Engine:
self.fields = [self.table.name]
self.query._child_queries = []
has_select_field = False
for field in self.fields:
if isinstance(field, DynamicTableField):
self.query = field.apply_select(self.query, engine=self)
has_select_field = True
elif isinstance(field, ChildQuery):
self.query._child_queries.append(field)
else:
self.query = self.query.select(field)
has_select_field = True
if not has_select_field:
self.query = self.query.select(self.table.name)
def apply_filters(
self,
@ -669,7 +676,7 @@ class Engine:
else:
try:
fallback_value = int(fallback_sql)
except (ValueError, TypeError):
except ValueError, TypeError:
fallback_value = fallback_sql
return operator_fn(_field, ValueWrapper(fallback_value))
@ -698,7 +705,7 @@ class Engine:
else:
try:
fallback_value = int(fallback_sql)
except (ValueError, TypeError):
except ValueError, TypeError:
fallback_value = fallback_sql
if fallback_value == _value:
@ -1040,8 +1047,8 @@ class Engine:
# for select permission on parent doctype, allow all permlevel 0 fields in filters
cache_key = (doctype, None, "_filterable_select")
if cache_key not in self.permitted_fields_cache:
if doctype in CORE_DOCTYPES:
# core doctypes have no restrictions - return all valid columns
if doctype in PERMITTED_CORE_DOCTYPES:
# no restrictions - return all valid columns
self.permitted_fields_cache[cache_key] = set(meta.get_valid_columns())
else:
permlevel_0_fields = set(meta.default_fields) | OPTIONAL_FIELDS
@ -1397,18 +1404,11 @@ class Engine:
return parsed_order_fields
def check_read_permission(self):
"""Check if user has read permission on the doctype"""
def has_permission(ptype):
return frappe.has_permission(
self.doctype,
ptype,
user=self.user,
parent_doctype=self.parent_doctype,
)
if not has_permission("select") and not has_permission("read"):
def check_select_permission(self):
"""Check if user has select (or read) permission on the doctype"""
if not frappe.has_permission(
self.doctype, "select", user=self.user, parent_doctype=self.parent_doctype
):
self._raise_permission_error()
def _raise_permission_error(self, doctype=None):
@ -1432,6 +1432,15 @@ class Engine:
# Skip child table fields if parent permission is only 'select'
continue
if field.parent_fieldname:
parent_meta = frappe.get_meta(self.doctype)
if parent_meta.get_field(
field.parent_fieldname
).permlevel not in parent_meta.get_permlevel_access(
parent_permission_type, user=self.user
):
continue
# Cache permitted fields for child doctypes if accessed multiple times
permitted_child_fields_set = self._get_cached_permitted_fields(
field.doctype,
@ -1462,6 +1471,12 @@ class Engine:
# Skip child queries if parent permission is only 'select'
continue
parent_meta = frappe.get_meta(self.doctype)
if parent_meta.get_field(field.fieldname).permlevel not in parent_meta.get_permlevel_access(
parent_permission_type, user=self.user
):
continue
# Cache permitted fields for the child doctype of the query
permitted_child_fields_set = self._get_cached_permitted_fields(
field.doctype,

View file

@ -79,11 +79,11 @@ class V17FrappeDeprecationWarning(PendingFrappeDeprecationWarning):
def __get_deprecation_class(graduation: str | None = None, class_name: str | None = None) -> type:
current_module = sys.modules[__name__]
if graduation:
# Scrub the graduation string to ensure it's a valid class name
cleaned_graduation = re.sub(r"\W|^(?=\d)", "_", graduation.upper())
class_name = f"{cleaned_graduation}FrappeDeprecationWarning"
current_module = sys.modules[__name__]
try:
return getattr(current_module, class_name)
except AttributeError:

View file

@ -13,6 +13,15 @@ class TestWorkspace(IntegrationTestCase):
frappe.db.delete("DocType", {"module": "Test Module"})
frappe.delete_doc("Module Def", "Test Module")
def test_workspace_conflicts_with_existing_doctype(self):
"""Workspace name should not conflict with existing DocType names."""
create_doctype("Test", "Test Module")
workspace = create_workspace(name="Test", label="Test", public=1, title="Test")
with self.assertRaises(frappe.NameError):
workspace.insert()
# TODO: FIX ME - flaky test!!!
# def test_workspace_with_cards_specific_to_a_country(self):
# workspace = create_workspace()
@ -65,6 +74,8 @@ def create_workspace(**args):
workspace.category = args.category or "Modules"
workspace.is_standard = args.is_standard or 1
workspace.module = "Test Module"
workspace.public = args.public or 0
workspace.title = args.title or "Test Workspace"
return workspace

View file

@ -286,7 +286,6 @@ def auto_generate_sidebar_from_module():
sidebar.items = sidebar_items
sidebar.module = module
sidebar.header_icon = "hammer"
sidebar.app = frappe.local.module_app.get(frappe.scrub(module), None)
sidebars.append(sidebar)
return sidebars

View file

@ -1207,7 +1207,7 @@ class IconsPane {
return;
}
this.wrapper.append(
"<span style='margin-top: 10px; margin-bottom: 20px'>Removed Icons</span>"
`<span style='margin-top: 10px; margin-bottom: 20px'>${__("Removed Icons")}</span>`
);
this.grid = new DesktopIconGrid({
name: "hidden-icons-grid",

View file

@ -158,6 +158,13 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides {
this.in_refresh_slides = true;
this.update_values();
const welcome_slide = frappe.setup.slides_settings.find((s) => s.name === "welcome");
if (welcome_slide && this.values.language) {
const lang_field = welcome_slide.fields.find((f) => f.fieldname === "language");
if (lang_field) {
lang_field.default = this.values.language;
}
}
frappe.setup.slides = [];
frappe.setup.run_event("before_load");
@ -444,14 +451,21 @@ frappe.setup.slides_settings = [
} else {
frappe.setup.utils.load_regional_data(slide, setup_fields);
}
let current_selection = frappe.wizard.values.language;
if (!slide.get_value("language")) {
let session_language =
current_selection ||
frappe.setup.utils.get_language_name_from_code(
frappe.boot.lang || navigator.language
) || "English";
) ||
"English";
let language_field = slide.get_field("language");
language_field.df.default = session_language;
language_field.set_input(session_language);
if (language_field.awesomplete) {
language_field.awesomplete.evaluate();
}
if (!frappe.setup._from_load_messages) {
language_field.$input.trigger("change");
}
@ -539,7 +553,8 @@ frappe.setup.utils = {
frappe.wizard.values.currency = r.message.currency;
frappe.wizard.values.country = r.message.country;
frappe.wizard.values.timezone = r.message.time_zone;
frappe.wizard.values.language = r.message.language;
frappe.wizard.values.language =
frappe.wizard.values.language || r.message.language;
frappe.db.get_value(
"User",
@ -582,6 +597,9 @@ frappe.setup.utils = {
setup_language_field: function (slide) {
var language_field = slide.get_field("language");
language_field.df.options = frappe.setup.data.lang.languages;
if (frappe.wizard.values.language) {
language_field.df.default = frappe.wizard.values.language;
}
language_field.set_options();
},
@ -647,6 +665,7 @@ frappe.setup.utils = {
language: lang,
},
callback: function () {
frappe.wizard.values.language = lang;
frappe.setup._from_load_messages = true;
frappe.wizard.refresh_slides();
},

View file

@ -16,7 +16,7 @@ from frappe.model.utils import render_include
from frappe.modules import get_module_path, scrub
from frappe.monitor import add_data_to_monitor
from frappe.permissions import get_role_permissions, get_roles, has_permission
from frappe.utils import cint, cstr, flt, format_duration, get_html_format, sbool
from frappe.utils import cint, cstr, flt, format_datetime, format_duration, formatdate, get_html_format, sbool
from frappe.utils.caching import request_cache
@ -469,13 +469,27 @@ def format_fields(data: frappe._dict) -> None:
if col.get("fieldtype") == "Duration":
for row in data.result:
index = col.get("fieldname") if isinstance(row, dict) else i
if row[index]:
row[index] = format_duration(row[index])
val = row.get(index) if isinstance(row, dict) else row[index]
if val:
row[index] = format_duration(val)
elif col.get("fieldtype") == "Currency" and col.get("precision"):
for row in data.result:
index = col.get("fieldname") if isinstance(row, dict) else i
if row[index]:
row[index] = round(row[index], col.get("precision"))
val = row.get(index) if isinstance(row, dict) else row[index]
if val:
row[index] = round(val, col.get("precision"))
elif col.get("fieldtype") == "Date":
for row in data.result:
index = col.get("fieldname") if isinstance(row, dict) else i
val = row.get(index) if isinstance(row, dict) else row[index]
if val:
row[index] = formatdate(val)
elif col.get("fieldtype") == "Datetime":
for row in data.result:
index = col.get("fieldname") if isinstance(row, dict) else i
val = row.get(index) if isinstance(row, dict) else row[index]
if val:
row[index] = format_datetime(val)
def build_xlsx_data(

View file

@ -809,7 +809,7 @@ def get_match_cond(doctype, as_condition=True):
if not as_condition:
return cond
return ((" and " + cond) if cond else "").replace("%", "%%")
return ((" and (" + cond + ")") if cond else "").replace("%", "%%")
def build_match_conditions(doctype, user=None, as_condition=True):

View file

@ -256,8 +256,16 @@ def validate_ignore_user_permissions(form_doctype, link_fieldname, link_doctype)
frappe.throw(message, title=_('Error validating "Ignore User Permissions"'))
meta = frappe.get_meta(form_doctype)
link_field = meta.get_field(link_fieldname)
# special early exit - link_fieldname is not being considered here
# to avoid cases like bulk edit which have link_fieldname as "value" from failing
if any(
(field.fieldtype == "Link" and field.options == link_doctype and field.ignore_user_permissions)
for field in meta.fields
):
return
link_field = meta.get_field(link_fieldname)
if not link_field:
_throw(
_("Field <code>{0}</code> not found in {1}").format(
@ -268,9 +276,6 @@ def validate_ignore_user_permissions(form_doctype, link_fieldname, link_doctype)
ignore_user_permissions = link_field.ignore_user_permissions
found_doctype = None
if link_field.fieldtype == "Link":
found_doctype = link_field.options
if link_field.fieldtype == "Table MultiSelect":
child_meta = frappe.get_meta(link_field.options)
child_link_field = next((field for field in child_meta.fields if field.fieldtype == "Link"), None)
@ -297,6 +302,11 @@ def validate_ignore_user_permissions(form_doctype, link_fieldname, link_doctype)
if link_field.fieldtype == "Dynamic Link":
return # skip doctype check for Dynamic Link fields
# all cases of valid Link fields are already covered in the early exit above
# the following block only serves to show appropriate error message
if link_field.fieldtype == "Link":
found_doctype = link_field.options
if found_doctype != link_doctype:
_throw(
_("The field {0} in {1} links to {2} and not {3}").format(

View file

@ -13,11 +13,15 @@ def get_all_nodes(doctype: str, label: str, parent: str, tree_method: str | None
filters.pop("cmd", None)
filters.pop("data", None)
tree_method = frappe.get_attr(tree_method)
try:
tree_method = frappe.override_whitelisted_method(tree_method)
callable_tree_method = frappe.get_attr(tree_method)
except Exception as e:
frappe.throw(_("Failed to get method for command {0} with {1}").format(tree_method, str(e)))
frappe.is_whitelisted(tree_method)
frappe.is_whitelisted(callable_tree_method)
data = tree_method(doctype, parent, **filters)
data = callable_tree_method(doctype, parent, **filters)
out = [dict(parent=label, data=data)]
filters.pop("is_root", None)
@ -25,7 +29,7 @@ def get_all_nodes(doctype: str, label: str, parent: str, tree_method: str | None
while to_check:
parent = to_check.pop()
data = tree_method(doctype, parent, is_root=False, **filters)
data = callable_tree_method(doctype, parent, is_root=False, **filters)
out.append(dict(parent=parent, data=data))
for d in data:
if d.get("expandable"):

View file

@ -17,7 +17,11 @@ def validate_route_conflict(doctype, name):
all_names = []
for _doctype in ["Page", "Workspace", "DocType"]:
all_names.extend(
[slug(d) for d in frappe.get_all(_doctype, pluck="name") if (doctype != _doctype and d != name)]
[
slug(d)
for d in frappe.get_all(_doctype, pluck="name")
if not (doctype == _doctype and d == name)
]
)
if slug(name) in all_names:

View file

@ -188,6 +188,15 @@ frappe.ui.form.on("Auto Email Report", {
},
});
dialog.show();
// add filters defined in onload event of report
if (reference_report.onload) {
frappe.query_report = new frappe.views.QueryReport({
filters: dialog.fields_list,
});
reference_report.onload(frappe.query_report);
}
dialog.set_values(filters);
});

View file

@ -5,7 +5,7 @@ from datetime import timedelta
import frappe
from frappe import _, msgprint
from frappe.utils import cint, cstr, get_url, now_datetime
from frappe.utils.data import getdate
from frappe.utils.data import add_to_date, getdate
from frappe.utils.verified_command import get_signed_params, verify_request
# After this percent of failures in every batch, entire batch is aborted.
@ -163,19 +163,21 @@ def flush():
def get_queue():
batch_size = cint(frappe.conf.email_queue_batch_size) or 500
undo_window = add_to_date(now_datetime(), seconds=-10)
return frappe.db.sql(
f"""select
"""select
name, sender
from
`tabEmail Queue`
where
(status='Not Sent' or status='Partially Sent') and
(send_after is null or send_after < %(now)s)
(send_after is null or send_after < %(now)s) and
(creation < %(undo_window)s)
order
by priority desc, retry asc, creation asc
limit {batch_size}""",
{"now": now_datetime()},
limit %(batch_size)s""",
{"now": now_datetime(), "undo_window": undo_window, "batch_size": batch_size},
as_dict=True,
)

View file

@ -59,6 +59,7 @@ no,Norsk,0
pl,Polski,0
ps,پښتو,0
pt,Português,0
pt-BR,Português Brasileiro,0
ro,Română,0
ru,Русский,0
rw,Kinyarwanda,0

1 language_code language_name enabled
59 pl Polski 0
60 ps پښتو 0
61 pt Português 0
62 pt-BR Português Brasileiro 0
63 ro Română 0
64 ru Русский 0
65 rw Kinyarwanda 0

View file

@ -185,27 +185,40 @@ def parse_template_string(
:param options: a dictionary of additional options (optional)
:param lineno: starting line number (optional)
"""
from babel.messages.jslexer import line_re
prev_character = None
current_lineno = lineno
level = 0
inside_str = False
inside_expression_str = False
expression_lineno = lineno
expression_contents = ""
for character in template_string[1:-1]:
if not inside_str and character in ('"', "'", "`"):
inside_str = character
elif inside_str == character and prev_character != r"\\":
inside_str = False
if level:
expression_contents += character
if not inside_str:
if not level:
if character == "{" and prev_character == "$":
expression_lineno = current_lineno
level += 1
elif level and character == "}":
level -= 1
if level == 0 and expression_contents:
expression_contents = expression_contents[:-1]
yield from extract_javascript(expression_contents, keywords, options, lineno)
lineno += len(line_re.findall(expression_contents))
expression_contents = ""
else:
expression_contents += character
if inside_expression_str:
if inside_expression_str == character and prev_character != r"\\":
inside_expression_str = False
else:
if character in ('"', "'", "`"):
inside_expression_str = character
elif character == "{":
level += 1
elif character == "}":
level -= 1
if level == 0 and expression_contents:
expression_contents = expression_contents[:-1]
yield from extract_javascript(
expression_contents,
keywords,
options,
expression_lineno,
)
expression_contents = ""
inside_expression_str = False
if character == "\n":
current_lineno += 1
prev_character = character

View file

@ -15,3 +15,17 @@ class TestJavaScript(IntegrationTestCase):
next(extract_javascript(code)),
(1, "__", ("Test", None, "Context")),
)
def test_extract_javascript_from_template_literal_attribute(self):
code = "let test = `<button title=\"${__('In attribute')}\">${__('In text')}</button>`;"
self.assertEqual(
list(extract_javascript(code)),
[(1, "__", "In attribute"), (1, "__", "In text")],
)
def test_extract_javascript_template_literal_multiline_line_numbers(self):
code = "let test = `\n<button title=\"${__('In attribute')}\">\n ${__('In text')}\n</button>\n`;"
self.assertEqual(
list(extract_javascript(code)),
[(2, "__", "In attribute"), (3, "__", "In text")],
)

View file

@ -3,6 +3,7 @@
import os
from mimetypes import guess_type
from pathlib import Path
from typing import TYPE_CHECKING
from werkzeug.wrappers import Response
@ -11,11 +12,11 @@ import frappe
import frappe.sessions
import frappe.utils
from frappe import _, is_whitelisted, ping
from frappe.core.doctype.file.utils import find_file_by_url
from frappe.core.doctype.file.utils import find_file_by_url, get_safe_file_name
from frappe.core.doctype.server_script.server_script_utils import get_server_script_map
from frappe.monitor import add_data_to_monitor
from frappe.permissions import check_doctype_permission
from frappe.utils import cint
from frappe.utils import cint, get_files_path
from frappe.utils.csvutils import build_csv_response
from frappe.utils.deprecations import deprecated
from frappe.utils.image import optimize_image
@ -162,9 +163,27 @@ def upload_file():
if "file" in files:
file = files["file"]
content = file.stream.read()
filename = file.filename
if frappe.form_dict.chunk_index:
current_chunk = int(frappe.form_dict.chunk_index)
total_chunks = int(frappe.form_dict.total_chunk_count)
offset = int(frappe.form_dict.chunk_byte_offset)
else:
offset = 0
current_chunk = 0
total_chunks = 1
temp_path = Path(get_files_path(".temp-" + get_safe_file_name(filename), is_private=is_private))
with temp_path.open("ab" if current_chunk > 0 else "wb") as f:
total_file_size = frappe.form_dict.total_file_size or 0
f.seek(offset)
f.write(file.stream.read())
if not f.tell() >= int(total_file_size) or current_chunk != total_chunks - 1:
return
content = temp_path.read_bytes()
temp_path.unlink()
content_type = guess_type(filename)[0]
if optimize and content_type and content_type.startswith("image/"):
args = {"content": content, "content_type": content_type}

View file

@ -210,6 +210,7 @@ scheduler_events = {
# 5 minutes
"0/5 * * * *": [
"frappe.email.doctype.notification.notification.trigger_offset_alerts",
"frappe.search.sqlite_search.index_docs_in_queue",
],
# 15 minutes
"0/15 * * * *": [

View file

@ -44,6 +44,11 @@ def get_headers():
def current_site_info():
from frappe.utils import cint
cache_key = f"fc_current_site_info:{frappe.local.site}"
cached_data = frappe.cache().get_value(cache_key)
if cached_data:
return cached_data
res = {}
request = requests.post(f"{get_base_url()}/api/method/press.saas.api.site.info", headers=get_headers())
if request.status_code == 200:
@ -51,13 +56,17 @@ def current_site_info():
if not res or not isinstance(res, dict):
return None
return {
site_info = {
**res,
"site_name": get_site_name(),
"base_url": get_base_url(),
"setup_complete": cint(frappe.get_system_settings("setup_complete")),
}
frappe.cache().set_value(cache_key, site_info, expires_in_sec=600)
return site_info
@frappe.whitelist()
def api(method: str, data: str | dict[str, Any] | None = None):

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -224,6 +224,7 @@ def get_permitted_fields(
meta = frappe.get_meta(doctype)
valid_columns = meta.get_valid_columns()
# note: any change here should also be made in _get_filterable_fields in query.py
if doctype in CORE_DOCTYPES:
return valid_columns

View file

@ -1224,7 +1224,7 @@ class Document(BaseDocument):
if not self.meta.issingle and self._action != "discard":
self.check_docstatus_transition(previous.docstatus)
def check_docstatus_transition(self, to_docstatus):
def check_docstatus_transition(self, from_docstatus):
"""Ensures valid `docstatus` transition.
Valid transitions are (number in brackets is `docstatus`):
@ -1234,34 +1234,43 @@ class Document(BaseDocument):
- Submit (1) > Cancel (2)
"""
if to_docstatus == DocStatus.DRAFT:
if self.docstatus.is_draft():
if self.flags.skip_docstatus_validation:
return
to_docstatus = self.docstatus
if from_docstatus == DocStatus.DRAFT:
if to_docstatus.is_draft():
self._action = "save"
elif self.docstatus.is_submitted():
elif to_docstatus.is_submitted():
if not getattr(self.meta, "is_submittable", False):
frappe.throw(
_("Cannot change docstatus of non submittable doctype {0}").format(self.doctype),
frappe.DocstatusTransitionError,
)
self._action = "submit"
self.check_permission("submit")
elif self.docstatus.is_cancelled():
elif to_docstatus.is_cancelled():
raise frappe.DocstatusTransitionError(
_("Cannot change docstatus from 0 (Draft) to 2 (Cancelled)")
)
else:
raise frappe.ValidationError(_("Invalid docstatus"), self.docstatus)
raise frappe.ValidationError(_("Invalid docstatus"), to_docstatus)
elif to_docstatus == DocStatus.SUBMITTED:
if self.docstatus.is_submitted():
elif from_docstatus == DocStatus.SUBMITTED:
if to_docstatus.is_submitted():
self._action = "update_after_submit"
self.check_permission("submit")
elif self.docstatus.is_cancelled():
elif to_docstatus.is_cancelled():
self._action = "cancel"
self.check_permission("cancel")
elif self.docstatus.is_draft():
elif to_docstatus.is_draft():
raise frappe.DocstatusTransitionError(
_("Cannot change docstatus from 1 (Submitted) to 0 (Draft)")
)
else:
raise frappe.ValidationError(_("Invalid docstatus"), self.docstatus)
raise frappe.ValidationError(_("Invalid docstatus"), to_docstatus)
elif to_docstatus == DocStatus.CANCELLED:
elif from_docstatus == DocStatus.CANCELLED:
raise frappe.ValidationError(_("Cannot edit cancelled document"))
def set_parent_in_children(self):

View file

@ -705,7 +705,8 @@ class Meta(Document):
)
if 0 not in permlevel_access and permission_type in ("read", "select"):
if frappe.share.get_shared(self.name, user, rights=["read"], limit=1):
check_doctype = parenttype if self.istable and parenttype else self.name
if frappe.share.get_shared(check_doctype, user, rights=["read"], limit=1):
permlevel_access.add(0)
permitted_fieldnames.extend(

View file

@ -208,6 +208,19 @@ def has_permission(
debug and _debug_log("Checking if document/doctype is explicitly shared with user")
perm = false_if_not_shared()
# select permission is implied by read permission
if not perm and ptype == "select":
perm = has_permission(
doctype,
ptype="read",
doc=doc,
user=user,
parent_doctype=parent_doctype,
print_logs=print_logs,
debug=debug,
ignore_share_permissions=ignore_share_permissions,
)
return bool(perm)
@ -855,7 +868,11 @@ def has_child_permission(
return False
permlevel = parent_meta.get_field(parentfield).permlevel
accessible_permlevels = parent_meta.get_permlevel_access(ptype, user=user)
# checking for select == checking for "select or read"
# select does not support access of higher permlevel child tables, but read does
accessible_permlevels = parent_meta.get_permlevel_access(
"read" if ptype == "select" else ptype, user=user
)
if permlevel > 0 and permlevel not in accessible_permlevels:
push_perm_check_log(
_("Insufficient Permission Level for {0}").format(frappe.bold(parent_doctype)), debug=debug

View file

@ -206,16 +206,6 @@ frappe.ui.form.PrintView = class {
this.set_breadcrumbs();
this.setup_customize_dialog();
// print designer link
if (!cint(frappe.boot.sysdefaults.disable_product_suggestion)) {
if (!Object.keys(frappe.boot.versions).includes("print_designer")) {
this.page.add_inner_message(`
<a style="line-height: 2.4" href="https://frappecloud.com/marketplace/apps/print_designer?utm_source=framework-desk&utm_medium=print-view&utm_campaign=try-link">
${__("Try the new Print Designer")}
</a>
`);
}
}
let tasks = [
this.set_default_print_format,
this.set_default_print_language,

View file

@ -33,14 +33,10 @@ $(document).ready(function () {
!frappe.is_mobile() &&
frappe.user.has_role("System Manager");
if (visiblity_condition && isFCUser) {
frappe.router.on("change", function () {
if (frappe.get_route()[0] == "") {
addChatBubble();
toggleChatBubble(true);
} else {
toggleChatBubble(false);
}
});
if (site_info.trial_end_date && trial_end_date > new Date()) {
addChatBubble();
toggleChatBubble(true);
}
}
if (isFCUser) {
$.extend(card_args, {
@ -100,30 +96,24 @@ function addChatBubble() {
const desk_apps = ["erpnext", "hrms"];
const apps_allowed = desk_apps.some((app) => all_apps.includes(app));
if (checkBusinessHours() && apps_allowed) {
if (apps_allowed) {
let chat_banner = document.createElement("script");
chat_banner.setAttribute("id", "chat_widget_trigger");
chat_banner.innerHTML =
'(function(d,t){var BASE_URL="https://chat.frappe.cloud";var g=d.createElement(t),s=d.getElementsByTagName(t)[0];g.src=BASE_URL+"/packs/js/sdk.js";g.async=true;s.parentNode.insertBefore(g,s);g.onload=function(){window.chatwootSDK.run({websiteToken:"LdmfJzftdJGEcFjoTqk8CrSq",baseUrl:BASE_URL})}})(document,"script");';
'window.chatwootSettings = {"position":"right","launcherTitle":"Chat with us", darkMode: "auto"}; (function(d,t){var BASE_URL="https://chat.frappe.cloud";var g=d.createElement(t),s=d.getElementsByTagName(t)[0];g.src=BASE_URL+"/packs/js/sdk.js";g.async=true;s.parentNode.insertBefore(g,s);g.onload=function(){window.chatwootSDK.run({websiteToken:"LdmfJzftdJGEcFjoTqk8CrSq",baseUrl:BASE_URL})}})(document,"script");';
document.body.append(chat_banner);
const root = document.documentElement;
root.style.setProperty("--s-700", "var(--gray-500)");
// Add padding to the main section to avoid overlapping with the chat bubble
const main_section = document.getElementsByClassName("main-section");
if (main_section) {
main_section[0].style.paddingBottom = "90px";
}
}
}
function checkBusinessHours() {
let current_time = new Date();
const ist_time = new Date(current_time.toLocaleString("en-US", { timeZone: "Asia/Kolkata" }));
const hours = ist_time.getHours();
const day = ist_time.getDay();
const is_weekend = day === 0 || day === 6;
const is_business_hour = hours >= 11 && hours < 18;
return !is_weekend && is_business_hour;
}
function toggleChatBubble(toggle) {
if (toggle) {
$(".woot-widget-holder").show();

View file

@ -214,9 +214,6 @@ export function evaluate_depends_on_value(expression, doc) {
} else if (expression.substr(0, 5) == "eval:") {
try {
out = frappe.utils.eval(expression.substr(5), { doc, parent });
if (parent && parent.istable && expression.includes("is_submittable")) {
out = true;
}
} catch (e) {
frappe.throw(__('Invalid "depends_on" expression'));
}

View file

@ -377,15 +377,17 @@ frappe.Application = class Application {
logout() {
var me = this;
me.logged_out = true;
return frappe.call({
method: "logout",
callback: function (r) {
if (r.exc) {
return;
}
frappe.confirm(__("Are you sure you want to log out?"), function () {
return frappe.call({
method: "logout",
callback: function (r) {
if (r.exc) {
return;
}
me.redirect_to_login();
},
me.redirect_to_login();
},
});
});
}
handle_session_expired() {

View file

@ -568,57 +568,66 @@ function return_as_dataurl() {
close_dialog.value = true;
return Promise.all(promises);
}
function upload_file(file, i) {
async function upload_file(file, i) {
currently_uploading.value = i;
return new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest();
xhr.upload.addEventListener("loadstart", (e) => {
file.uploading = true;
});
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
file.progress = e.loaded;
file.total = e.total;
}
});
xhr.upload.addEventListener("load", (e) => {
file.uploading = false;
});
xhr.addEventListener("error", (e) => {
file.failed = true;
reject();
});
xhr.onreadystatechange = () => {
if (xhr.readyState == XMLHttpRequest.DONE) {
const CHUNK_SIZE = frappe.boot.file_chunk_size;
const use_chunks = file.file_obj && file.file_obj.size > CHUNK_SIZE;
const total_chunks = use_chunks ? Math.ceil(file.file_obj.size / CHUNK_SIZE) : 1;
const send_chunk = (chunk_blob, chunk_index, chunk_byte_offset) => {
return new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest();
xhr.upload.addEventListener("loadstart", () => {
file.uploading = true;
});
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
file.progress = chunk_byte_offset + e.loaded;
file.total = file.file_obj?.size || e.total;
}
});
xhr.upload.addEventListener("load", () => {
if (chunk_index === total_chunks - 1) {
file.uploading = false;
}
});
xhr.addEventListener("error", () => {
file.failed = true;
reject();
});
xhr.onreadystatechange = () => {
if (xhr.readyState !== XMLHttpRequest.DONE) return;
if (xhr.status === 200) {
resolve();
file.request_succeeded = true;
let r = null;
let file_doc = null;
try {
r = JSON.parse(xhr.responseText);
if (r.message.doctype === "File") {
file_doc = r.message;
// Only the last chunk returns a meaningful response
if (chunk_index === total_chunks - 1) {
file.request_succeeded = true;
let r = null;
let file_doc = null;
try {
r = JSON.parse(xhr.responseText);
if (r.message?.doctype === "File") {
file_doc = r.message;
}
} catch (e) {
r = xhr.responseText;
}
} catch (e) {
r = xhr.responseText;
}
file.doc = file_doc;
if (props.on_success) {
props.on_success(file_doc, r);
}
if (
i == files.value.length - 1 &&
files.value.every((file) => file.request_succeeded)
) {
close_dialog.value = true;
}
if (show_web_link.value && file.file_url) {
close_dialog.value = true;
file.doc = file_doc;
if (props.on_success) {
props.on_success(file_doc, r);
}
if (
(i == files.value.length - 1 &&
files.value.every((f) => f.request_succeeded)) ||
(show_web_link.value && file.file_url)
) {
close_dialog.value = true;
}
}
} else if (xhr.status === 403) {
reject();
@ -669,60 +678,58 @@ function upload_file(file, i) {
}
frappe.request.cleanup({}, error);
}
};
xhr.open("POST", "/api/method/upload_file", true);
xhr.setRequestHeader("Accept", "application/json");
xhr.setRequestHeader("X-Frappe-CSRF-Token", frappe.csrf_token);
let form_data = new FormData();
if (chunk_blob) {
form_data.append("file", chunk_blob, file.name);
}
};
xhr.open("POST", "/api/method/upload_file", true);
xhr.setRequestHeader("Accept", "application/json");
xhr.setRequestHeader("X-Frappe-CSRF-Token", frappe.csrf_token);
let form_data = new FormData();
if (file.file_obj) {
form_data.append("file", file.file_obj, file.name);
}
form_data.append("is_private", +file.private);
form_data.append("folder", props.folder);
form_data.append("is_private", +file.private);
form_data.append("folder", props.folder);
form_data.append("total_file_size", file.file_obj?.size ?? 0);
if (file.file_url) {
form_data.append("file_url", file.file_url);
}
if (file.file_size) {
form_data.append("file_size", file.file_size);
}
if (file.file_name) {
form_data.append("file_name", file.file_name);
}
if (file.library_file_name) {
form_data.append("library_file_name", file.library_file_name);
}
if (use_chunks) {
form_data.append("chunk_index", chunk_index);
form_data.append("total_chunk_count", total_chunks);
form_data.append("chunk_byte_offset", chunk_byte_offset);
}
if (props.doctype) {
form_data.append("doctype", props.doctype);
}
if (file.file_url) form_data.append("file_url", file.file_url);
if (file.file_size) form_data.append("file_size", file.file_size);
if (file.file_name) form_data.append("file_name", file.file_name);
if (file.library_file_name)
form_data.append("library_file_name", file.library_file_name);
if (props.doctype) form_data.append("doctype", props.doctype);
if (props.docname) form_data.append("docname", props.docname);
if (props.fieldname) form_data.append("fieldname", props.fieldname);
if (props.method) form_data.append("method", props.method);
if (file.optimize) form_data.append("optimize", true);
if (props.attach_doc_image) {
form_data.append("max_width", 200);
form_data.append("max_height", 200);
}
if (props.docname) {
form_data.append("docname", props.docname);
}
xhr.send(form_data);
});
};
if (props.fieldname) {
form_data.append("fieldname", props.fieldname);
}
if (props.method) {
form_data.append("method", props.method);
}
if (file.optimize) {
form_data.append("optimize", true);
}
if (props.attach_doc_image) {
form_data.append("max_width", 200);
form_data.append("max_height", 200);
}
xhr.send(form_data);
});
// Slice and send chunks sequentially
let chunk_byte_offset = 0;
for (let chunk_index = 0; chunk_index < total_chunks; chunk_index++) {
const chunk_blob = file.file_obj
? file.file_obj.slice(chunk_byte_offset, chunk_byte_offset + CHUNK_SIZE)
: null;
await send_chunk(chunk_blob, chunk_index, chunk_byte_offset);
chunk_byte_offset += CHUNK_SIZE;
}
}
function parse_error_response(response_text) {
let error_message = "";
let server_messages = [];

View file

@ -37,8 +37,8 @@ export default class Column {
}
resize_all_columns() {
// distribute visible columns equally
let all_columns = this.section.wrapper.find(".form-column");
// distribute visible columns equally, scoped to this section's direct children only
let all_columns = this.section.body.children(".form-column");
let visible_columns = all_columns.filter(":not(.hide-control)");
let columns = visible_columns.length || all_columns.length;
let colspan = cint(12 / columns);

View file

@ -938,6 +938,17 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
.then((response) => {
if (!response) return;
const has_filters = !!(args.filters && Object.keys(args.filters).length);
if (!response.name && has_filters) {
frappe.show_alert({
message: __("{0}: {1} did not match any results.", [
__(this.df.label || this.df.fieldname),
value,
]),
indicator: "red",
});
}
update_dependant_fields(response);
return response.name;
});

View file

@ -19,7 +19,7 @@ class Mention {
return `${item.value}`;
},
mentionDenotationChars: ["@"],
allowedChars: /^[a-zA-Z0-9_]*$/,
allowedChars: /^[\p{L}0-9_]*$/u,
minChars: 0,
maxChars: 31,
offsetTop: 2,

View file

@ -254,7 +254,7 @@ frappe.ui.form.ControlTextEditor = class ControlTextEditor extends frappe.ui.for
let me = this;
return {
allowedChars: /^[A-Za-z0-9_]*$/,
allowedChars: /^[\p{L}0-9_]*$/u,
mentionDenotationChars: ["@"],
isolateCharacter: true,

View file

@ -12,13 +12,14 @@ class FormTimeline extends BaseTimeline {
super.make();
this.setup_timeline_actions();
this.render_timeline_items();
this.setup_activity_toggle();
}
refresh() {
super.refresh();
this.frm.trigger("timeline_refresh");
this.setup_activity_toggle();
this.setup_document_email_link();
this.toggle_show_all_activity();
}
setup_timeline_actions() {
@ -48,11 +49,17 @@ class FormTimeline extends BaseTimeline {
}
}
setup_activity_toggle() {
const doc_info = this.doc_info || this.frm.get_docinfo();
const has_communications = () =>
doc_info.communications?.length || doc_info.comments?.length;
toggle_show_all_activity() {
const btn = this.timeline_wrapper.find(".timeline-item .show-all-activity");
btn.toggle(!!this.has_communications());
}
has_communications() {
const doc_info = this.doc_info || this.frm.get_docinfo();
return doc_info.communications?.length || doc_info.comments?.length;
}
setup_activity_toggle() {
this.timeline_wrapper.remove(this.timeline_actions_wrapper);
this.timeline_wrapper.find(".timeline-item.activity-title").remove();
this.timeline_wrapper.prepend(`
@ -62,28 +69,26 @@ class FormTimeline extends BaseTimeline {
`);
const me = this;
if (has_communications()) {
this.timeline_wrapper
.find(".timeline-item.activity-title")
.append(
`
<div class="d-flex align-items-center show-all-activity">
<span style="color: var(--text-light); margin:0px 6px;">${__("Show all activity")}</span>
<label class="switch">
<input type="checkbox">
<span class="slider round"></span>
</label>
</div>
this.timeline_wrapper
.find(".timeline-item.activity-title")
.append(
`
)
.find("input[type=checkbox]")
.prop("checked", !me.only_communication)
.on("click", function (e) {
me.only_communication = !this.checked;
me.render_timeline_items();
$(this).tab("show");
});
}
<div class="flex align-items-center show-all-activity">
<span style="color: var(--text-light); margin:0px 6px;">${__("Show all activity")}</span>
<label class="switch">
<input type="checkbox">
<span class="slider round"></span>
</label>
</div>
`
)
.find("input[type=checkbox]")
.prop("checked", !me.only_communication)
.on("click", function (e) {
me.only_communication = !this.checked;
me.render_timeline_items();
$(this).tab("show");
});
this.timeline_wrapper
.find(".timeline-item.activity-title")
.append(this.timeline_actions_wrapper);

View file

@ -1355,7 +1355,11 @@ frappe.ui.form.Form = class FrappeForm {
frappe.re_route[frappe.router.get_sub_path()] = `${encodeURIComponent(
frappe.router.slug(this.doctype)
)}/${encodeURIComponent(name)}`;
!frappe._from_link && frappe.set_route("Form", this.doctype, name);
// Skip routing only when the document is created from a Form view's Link field
if (!frappe._from_link?.field_obj?.frm) {
frappe.set_route("Form", this.doctype, name);
}
}
// ACTIONS

View file

@ -331,6 +331,10 @@ export default class GridRow {
// remove row
if (!this.open_form_button) {
this.open_form_button = $('<div class="col"></div>').appendTo(this.row);
this.open_form_button.on("click", function (e) {
me.toggle_view();
return false;
});
if (!this.configure_columns) {
const edit_msg = __("Edit", "", "Edit grid row");
@ -338,12 +342,8 @@ export default class GridRow {
<div class="btn-open-row" data-toggle="tooltip" data-placement="right" title="${edit_msg}">
<a>${frappe.utils.icon("edit", "xs")}</a>
</div>
`)
.appendTo(this.open_form_button)
.on("click", function () {
me.toggle_view();
return false;
});
`).appendTo(this.open_form_button);
$(this.open_form_button)
.parent()
.on("keydown", function (ev) {
@ -836,9 +836,6 @@ export default class GridRow {
} else if (expression.substr(0, 5) == "eval:") {
try {
out = frappe.utils.eval(expression.substr(5), { doc, parent });
if (parent && parent.istable && expression.includes("is_submittable")) {
out = true;
}
} catch (e) {
frappe.throw(__('Invalid "depends_on" expression'));
}

View file

@ -59,9 +59,7 @@ export default class GridRowForm {
${__("Insert Above")}</button>
<button class="btn btn-secondary btn-sm pull-right grid-insert-row-below hidden-xs">
${__("Insert Below")}</button>
<button class="btn btn-danger btn-sm pull-right grid-delete-row">
${frappe.utils.icon("trash-2")} ${__("Delete")}
</button>
<button class="btn btn-danger btn-sm pull-right grid-delete-row">${__("Delete")}</button>
</span>
</div>
</div>

View file

@ -802,9 +802,6 @@ frappe.ui.form.Layout = class Layout {
} else if (expression.substr(0, 5) == "eval:") {
try {
out = frappe.utils.eval(expression.substr(5), { doc, parent });
if (parent && parent.istable && expression.includes("is_submittable")) {
out = true;
}
} catch (e) {
frappe.throw(__('Invalid "depends_on" expression'));
}

View file

@ -554,7 +554,7 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog {
}
} else {
Object.keys(this.setters).forEach(function (setter) {
var value = me.dialog.fields_dict[setter].get_value();
var value = me.dialog.fields_dict[setter].get_value() || me.setters[setter];
if (me.dialog.fields_dict[setter].df.fieldtype == "Data" && value) {
filters[setter] = ["like", "%" + value + "%"];
} else {

View file

@ -198,36 +198,34 @@ frappe.ui.form.check_mandatory = function (frm) {
return !has_errors;
function is_docfield_mandatory(doc, df) {
if (df.reqd) return true;
if (!df.mandatory_depends_on || !doc) return;
if (df.mandatory_depends_on && doc) {
let out = null;
let expression = df.mandatory_depends_on;
let parent = frm.doc;
let out = null;
let expression = df.mandatory_depends_on;
let parent = frappe.get_meta(df.parent);
if (typeof expression === "boolean") {
out = expression;
} else if (typeof expression === "function") {
out = expression(doc);
} else if (expression.substr(0, 5) == "eval:") {
try {
out = frappe.utils.eval(expression.substr(5), { doc, parent });
if (parent && parent.istable && expression.includes("is_submittable")) {
out = true;
if (typeof expression === "boolean") {
out = expression;
} else if (typeof expression === "function") {
out = expression(doc);
} else if (expression.substr(0, 5) == "eval:") {
try {
out = frappe.utils.eval(expression.substr(5), { doc, parent });
} catch (e) {
frappe.throw(__('Invalid "mandatory_depends_on" expression'));
}
} catch (e) {
frappe.throw(__('Invalid "mandatory_depends_on" expression'));
}
} else {
var value = doc[expression];
if ($.isArray(value)) {
out = !!value.length;
} else {
out = !!value;
var value = doc[expression];
if ($.isArray(value)) {
out = !!value.length;
} else {
out = !!value;
}
}
return out;
}
return out;
return !!df.reqd;
}
function scroll_to(fieldname) {

View file

@ -35,7 +35,6 @@ class Picker {
}
}
setup_emojis() {
console.log("Making emojis");
// setup tab
this.setup_tab();
// setup emoji container

View file

@ -1318,7 +1318,10 @@ class FilterArea {
field.set_value(value.replace(/^%+|%+$/g, ""));
}
this.debounced_refresh_list_view();
// Only trigger refresh if field has a value
if (value) {
this.debounced_refresh_list_view();
}
});
}, 100);
}

View file

@ -391,12 +391,14 @@ export default class BulkOperations {
show_help_text();
function set_value_field(dialogObj) {
const new_df = Object.assign({}, field_mappings[dialogObj.get_value("field")]);
const field_value = dialogObj.get_value("field");
if (!field_value || !field_mappings[field_value]) return;
const new_df = Object.assign({}, field_mappings[field_value]);
/* if the field label has status in it and
if it has select fieldtype with no default value then
set a default value from the available option. */
if (
new_df.label.match(status_regex) &&
new_df.label?.match(status_regex) &&
new_df.fieldtype === "Select" &&
!new_df.default
) {

View file

@ -182,18 +182,12 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
const match_rules_list = frappe.perm.get_match_rules(this.doctype);
if (match_rules_list.length) {
this.restricted_list = $(
`<button class="btn btn-xs restricted-button flex align-center ${
frappe.is_mobile() ? "ml-2" : ""
}">
`<button class="btn btn-xs restricted-button flex align-center">
${frappe.utils.icon("restriction", "xs")}
</button>`
)
.click(() => this.show_restrictions(match_rules_list))
.appendTo(
frappe.is_mobile()
? this.page.page_form.find(".filter-section")
: this.page.page_form
);
.appendTo(this.page.page_form.find(".filter-section"));
}
}
@ -1098,7 +1092,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
}
}
get_tags_html(user_tags, limit, colored = false) {
get_tags_html(user_tags, limit = null, colored = false) {
let get_tag_html = (tag) => {
let color = "",
style = "";
@ -1111,11 +1105,12 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
return `<div class="tag-pill ellipsis" title="${tag}" style="${style}">${tag}</div>`;
}
};
return user_tags
.split(",")
.slice(1, limit + 1)
.map(get_tag_html)
.join("");
user_tags = (user_tags || "").split(",");
if (limit !== null) {
// if there is a limit apply it
user_tags = user_tags.slice(0, limit);
}
return user_tags.map(get_tag_html).join("");
}
get_meta_html(doc) {

View file

@ -52,12 +52,11 @@ frappe.ui.menu = class ContextMenu {
function () {
return true;
};
console.log(typeof item.condition);
let render = false;
if (typeof item.condition == "function") {
render = item.condition();
} else {
render = frappe.utils.eval_expression(item.condition);
render = frappe.utils.eval(item.condition);
}
if (render) {
this.add_menu_item(item);

View file

@ -53,8 +53,8 @@
</a>
<div class="nav-item dropdown dropdown-navbar-user dropdown-mobile mt-3">
<a
class="align-center btn-reset flex nav-link"
style="width: 100%; height: 40px;"
class="align-center btn-reset flex nav-link sidebar-user-button"
style="width: 100%; min-height: 40px;"
onclick="return frappe.ui.toolbar.route_to_user()"
aria-label="{{ __("User Menu") }}"
>

View file

@ -65,6 +65,16 @@ frappe.ui.Sidebar = class Sidebar {
frappe.current_app = app;
this.app_logo_url = app.app_logo_url;
return;
} else {
let app_name = frappe.boot.module_app[this.workspace_title];
if (app_name) {
let app_title = frappe.boot.app_data.find((f) => {
return f.app_name == app_name;
}).app_title;
this.header_subtitle = app_title;
} else {
this.header_subtitle = frappe.session.user;
}
}
}

View file

@ -24,6 +24,17 @@ frappe.ui.SidebarHeader = class SidebarHeader {
},
items: this.sibling_workspaces,
},
{
name: "website",
label: __("Website"),
icon: "web",
onClick: function () {
window.open(window.location.origin);
},
},
{
is_divider: true,
},
{
name: "edit-sidebar",
label: __("Edit Sidebar"),
@ -36,11 +47,9 @@ frappe.ui.SidebarHeader = class SidebarHeader {
},
},
{
name: "website",
label: __("Website"),
icon: "web",
onClick: function () {
window.open(window.location.origin);
is_divider: true,
condition: function () {
return frappe.boot.developer_mode;
},
},
];
@ -77,6 +86,9 @@ frappe.ui.SidebarHeader = class SidebarHeader {
icon: "info",
items: this.get_help_siblings(),
},
{
is_divider: true,
},
{
name: "logout",
label: "Logout",

View file

@ -18,6 +18,12 @@ frappe.ui.TagEditor = class TagEditor {
this.initialized = true;
this.refresh(this.user_tags);
}
update_user_tags(tags_string) {
this.user_tags = tags_string;
frappe.model.set_value(this.frm.doctype, this.frm.docname, "_user_tags", this.user_tags);
this.on_change && this.on_change(this.user_tags);
frappe.tags.utils.fetch_tags();
}
setup_tags() {
var me = this;
@ -40,9 +46,7 @@ frappe.ui.TagEditor = class TagEditor {
callback: function (r) {
var user_tags = me.user_tags ? me.user_tags.split(",") : [];
user_tags.push(tag);
me.user_tags = user_tags.join(",");
me.on_change && me.on_change(me.user_tags);
frappe.tags.utils.fetch_tags();
me.update_user_tags(user_tags.join(","));
},
});
}
@ -55,9 +59,7 @@ frappe.ui.TagEditor = class TagEditor {
callback: function (r) {
var user_tags = me.user_tags.split(",");
user_tags.splice(user_tags.indexOf(tag), 1);
me.user_tags = user_tags.join(",");
me.on_change && me.on_change(me.user_tags);
frappe.tags.utils.fetch_tags();
me.update_user_tags(user_tags.join(","));
},
});
}

View file

@ -85,9 +85,6 @@ function resetAll(skips) {
}
function handleAction(step) {
if (step.is_complete) return;
if (step.is_skipped) return;
if (step.route_options && typeof step.route_options === "string") {
frappe.route_options = JSON.parse(step.route_options);
}
@ -284,7 +281,7 @@ function markReset(step) {
style="align-items: center"
:class="
step.is_complete
? 'text-extra-muted onb-cursor-disabled'
? 'text-extra-muted onb-select-cursor'
: 'text-ink-gray-8 onb-select-cursor'
"
>
@ -316,15 +313,14 @@ function markReset(step) {
<div v-if="!step.is_complete">
<div v-if="!step.is_skipped">
<div
class="ml-auto text-base onb-show-on-hover text-sm w-12 text-right text-ink-gray-8"
>
<div class="ml-auto onb-show-on-hover text-sm w-12 text-right">
<span
style="
font-size: 12px;
vertical-align: text-top;
margin-right: 10px;
margin-right: 0px;
"
class="text-ink-gray-7"
@click="markSkip(step)"
>
{{ __("Skip") }}
@ -332,15 +328,14 @@ function markReset(step) {
</div>
</div>
<div v-if="step.is_skipped">
<div
class="ml-auto text-base onb-show-on-hover text-sm w-12 text-right text-ink-gray-8"
>
<div class="ml-auto onb-show-on-hover text-sm w-12 text-right">
<span
style="
font-size: 12px;
vertical-align: text-top;
margin-right: 10px;
margin-right: 0px;
"
class="text-ink-gray-7"
@click="markReset(step)"
>
{{ __("Reset") }}

View file

@ -31,7 +31,7 @@ class UserOnboarding {
title: title,
steps: steps.value,
minimizeIcon: frappe.utils.icon("minimize-2", "sm"),
closeIcon: frappe.utils.icon("close", "sm"),
closeIcon: frappe.utils.icon("x", "sm"),
headerIcon: header_icon,
checklistIcon: frappe.utils.icon("circle-check", "sm"),
completeChecklistIcon: frappe.utils.icon(
@ -59,18 +59,25 @@ class UserOnboarding {
function addStyles() {
if (document.getElementById("user-onboarding-styles")) return;
const main_section = document.getElementsByClassName("main-section");
if (main_section) {
main_section[0].style.paddingBottom = "90px";
}
const style = document.createElement("style");
style.id = "user-onboarding-styles";
style.innerHTML = `
.onb-panel {
position: fixed;
right: 24px;
left: 236px;
bottom: 24px;
width: 310px;
max-height: 80vh;
background: #fff;
border-radius: 16px;
border-radius: 8px;
box-shadow: 0 12px 40px rgba(0,0,0,0.15);
padding: 16px;
z-index: 9999;
@ -82,7 +89,6 @@ function addStyles() {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.onb-header-actions button {
@ -135,21 +141,21 @@ function addStyles() {
.onb-steps {
margin-top: 16px;
padding: 0;
padding: 0px;
list-style: none;
display: flex;
flex-direction: column;
gap: 12px;
gap: 4px;
align-items: flex-start;
}
.onb-group:hover {
color: #111827;
background: #f5f5f5;
.onb-group {
padding: 4px 8px;
border-radius: 8px;
}
.onb-cursor-disabled {
cursor: not-allowed;
.onb-group:hover {
background: #f5f5f5;
}
.onb-select-cursor {
@ -240,7 +246,7 @@ function addStyles() {
}
[data-theme="dark"] .onb-group:hover {
background: #374151;
background: #1C1C1C;
color: #f3f4f6;
}

View file

@ -233,7 +233,7 @@ frappe.breadcrumbs = {
is_new_doc = true;
} else {
let title = frappe.model.get_doc_title(doc);
docname_title = title || doc.name;
docname_title = __(title) || __(doc.name);
if (frappe.utils.is_html(docname_title)) {
docname_title = strip_html(docname_title);
}

View file

@ -896,6 +896,8 @@ frappe.views.CommunicationComposer = class {
if (!r.exc) {
frappe.utils.play_sound("email");
const communication_name = r.message["name"];
if (r.message["emails_not_sent_to"]) {
frappe.msgprint(
__("Email not sent to {0} (unsubscribed / disabled)", [
@ -910,6 +912,54 @@ frappe.views.CommunicationComposer = class {
me.frm.reload_doc();
}
let undo_alert = frappe.show_alert(
{
message: `<span>${__(
"Email Sent"
)}</span><span class="cursor-pointer ml-4" data-action="undo" style="font-weight: 500; text-decoration: underline;">${__(
"Undo"
)}</span>`,
indicator: "green",
},
10,
{
undo: () => {
if (undo_alert) {
undo_alert.find(".close").click();
}
frappe
.xcall(
"frappe.core.doctype.communication.email.undo_email_send",
{ communication_name: communication_name }
)
.then((d) => {
if (me.frm) {
me.frm.reload_doc();
}
// Reopen the composer with the recovered data
new frappe.views.CommunicationComposer({
doc: d.doc,
subject: d.subject,
recipients: d.recipients,
cc: d.cc,
bcc: d.bcc,
message: d.content,
sender: d.sender,
read_receipt: d.send_read_receipt,
attachments: d.attachments,
frm: me.frm,
});
frappe.show_alert({
message: __("Email sending undone"),
indicator: "blue",
});
});
},
}
);
// try the success callback if it exists
if (me.success) {
try {

View file

@ -589,8 +589,6 @@ frappe.provide("frappe.views");
group: "cards",
animation: 150,
dataIdAttr: "data-name",
forceFallback: true,
fallbackTolerance: 20,
onStart: function () {
wrapper.find(".kanban-card.add-card").fadeOut(200, function () {
wrapper.find(".kanban-cards").height("100vh");
@ -599,7 +597,6 @@ frappe.provide("frappe.views");
onEnd: function (e) {
wrapper.find(".kanban-card.add-card").fadeIn(100);
wrapper.find(".kanban-cards").height("auto");
// update order
const args = {
name: decodeURIComponent($(e.item).attr("data-name")),
from_colname: $(e.from)
@ -611,7 +608,6 @@ frappe.provide("frappe.views");
};
store.dispatch("update_order_for_single_card", args);
},
onAdd: function () {},
});
}
@ -756,11 +752,26 @@ frappe.provide("frappe.views");
}
function get_tags_html(card) {
return card.tags
? `<div class="kanban-tags">
${cur_list.get_tags_html(card.tags, 3, true)}
</div>`
: "";
if (!card.tags) return "";
const tags_array = card.tags.split(",");
const limit = 3; // cap. at 3 tags
const visible_tags = tags_array.slice(0, limit).join(",");
const hidden_tags = tags_array.slice(limit).join(",");
const hidden_tags_html = cur_list.get_tags_html(hidden_tags, null, true);
const hidden_count = tags_array.length - limit;
let html = `<div class="kanban-tags">
${cur_list.get_tags_html(visible_tags, null, true)}`;
if (hidden_count > 0) {
html += `
<span class="tag-pill more-tags">
+${hidden_count}
<span class="hidden-tags">${hidden_tags_html}</span>
</span>`;
}
html += `</div>`;
return html;
}
function render_card_meta() {

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