Merge branch 'develop' into fix-add-doc-button-for-long-doctype-name

This commit is contained in:
mergify[bot] 2026-02-24 11:14:14 +00:00 committed by GitHub
commit 27ab0a1e03
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
76 changed files with 34061 additions and 22818 deletions

View file

@ -72,10 +72,8 @@ context("Web Form", () => {
cy.call("logout");
cy.visit("/note");
cy.get_open_dialog()
.get(".modal-message")
.contains("You are not permitted to access this page without login.");
cy.visit("/note", { failOnStatusCode: false });
cy.contains("You must be logged in to use this form.");
});
it("Show List", () => {

View file

@ -539,7 +539,9 @@ def get_sidebar_items(allowed_workspaces):
from frappe import _
from frappe.desk.doctype.workspace_sidebar.workspace_sidebar import auto_generate_sidebar_from_module
workspace_sidebars = frappe.get_all("Workspace Sidebar", fields=["name", "header_icon"])
workspace_sidebars = frappe.get_all(
"Workspace Sidebar", fields=["name", "header_icon", "module_onboarding"]
)
module_sidebars = auto_generate_sidebar_from_module()
workspace_sidebars.extend(module_sidebars)
sidebar_items = {}
@ -561,6 +563,7 @@ def get_sidebar_items(allowed_workspaces):
"label": sidebar_title,
"items": [],
"header_icon": sidebar.get("header_icon"),
"module_onboarding": sidebar.get("module_onboarding"),
"module": sidebar_doc.module,
"app": sidebar_doc.app,
}

View file

@ -992,7 +992,13 @@ class DocType(Document):
self.append("fields", {"label": "Is Group", "fieldtype": "Check", "fieldname": "is_group"})
self.append(
"fields",
{"label": "Old Parent", "fieldtype": "Link", "options": self.name, "fieldname": "old_parent"},
{
"label": "Old Parent",
"fieldtype": "Link",
"options": self.name,
"fieldname": "old_parent",
"hidden": 1,
},
)
parent_field_label = f"Parent {self.name}"

View file

@ -641,6 +641,77 @@ class TestAttachment(IntegrationTestCase):
self.assertTrue(exists)
class TestCopyAttachmentsFromAmendedFrom(IntegrationTestCase):
"""Test that attached_to_field and folder are copied when amending a document."""
@classmethod
def setUpClass(cls):
super().setUpClass()
from frappe.core.doctype.doctype.test_doctype import new_doctype
cls.test_doctype = "Test Amendable Attachment"
new_doctype(
cls.test_doctype,
is_submittable=1,
fields=[
{"label": "Title", "fieldname": "title", "fieldtype": "Data"},
{"label": "Attachment", "fieldname": "attachment", "fieldtype": "Attach"},
],
).insert(ignore_if_duplicate=True)
@classmethod
def tearDownClass(cls):
frappe.db.rollback()
frappe.delete_doc_if_exists("DocType", cls.test_doctype)
def test_attached_to_field_and_folder_copied_on_amend(self):
# Create custom folder
custom_folder = frappe.get_doc(
{"doctype": "File", "file_name": "Test Amend Folder", "is_folder": 1, "folder": "Home"}
).insert()
# Create original document and attach file with attached_to_field and custom folder
doc = frappe.get_doc(doctype=self.test_doctype, title="Original").insert()
file = frappe.get_doc(
{
"doctype": "File",
"file_name": "amend_test_attach.txt",
"content": "Test Content",
"attached_to_doctype": self.test_doctype,
"attached_to_name": doc.name,
"attached_to_field": "attachment",
"folder": custom_folder.name,
}
).insert()
doc.attachment = file.file_url
doc.save()
# Submit and cancel
doc.submit()
doc.cancel()
# Amend document
amended_doc = frappe.copy_doc(doc)
amended_doc.docstatus = 0
amended_doc.amended_from = doc.name
amended_doc.save()
# Verify copied file has attached_to_field and folder from original
copied_files = frappe.get_all(
"File",
filters={
"attached_to_doctype": self.test_doctype,
"attached_to_name": amended_doc.name,
"file_name": "amend_test_attach.txt",
},
fields=["name", "attached_to_field", "folder"],
)
self.assertEqual(len(copied_files), 1, "Exactly one file should be copied to amended doc")
self.assertEqual(copied_files[0].attached_to_field, "attachment")
self.assertEqual(copied_files[0].folder, custom_folder.name)
class TestAttachmentsAccess(IntegrationTestCase):
def setUp(self) -> None:
frappe.db.delete("File", {"is_folder": 0})

View file

@ -93,15 +93,24 @@ class Report(Document):
doc.prepared_report = 0
def on_trash(self):
if (
self.is_standard == "Yes"
and not cint(getattr(frappe.local.conf, "developer_mode", 0))
and not frappe.flags.in_migrate
and not frappe.flags.in_patch
):
frappe.throw(_("You are not allowed to delete Standard Report"))
if self.is_standard == "Yes":
if (
not cint(getattr(frappe.local.conf, "developer_mode", 0))
and not frappe.flags.in_migrate
and not frappe.flags.in_patch
):
frappe.throw(_("You are not allowed to delete Standard Report"))
if frappe.conf.developer_mode and not frappe.flags.in_test:
frappe.db.after_commit(self.delete_report_folder)
delete_custom_role("report", self.name)
def delete_report_folder(self):
from frappe.modules.export_file import delete_folder
delete_folder(self.module, "Report", self.name)
def get_permission_log_options(self, event=None):
return {"fields": ["roles"]}

View file

@ -2,7 +2,11 @@
// For license information, please see license.txt
frappe.ui.form.on("Translation", {
refresh: function () {
//
refresh: function (frm) {
frm.set_intro(
__(
"Translations can be viewed by guests, avoid storing private details in translations."
)
);
},
});

View file

@ -393,6 +393,9 @@ class User(Document):
"""Set as System User if any of the given roles has desk_access"""
self.user_type = "System User" if self.has_desk_access() else "Website User"
if self.has_value_changed("user_type"):
clear_sessions(user=self.name, force=True)
def set_roles_and_modules_based_on_user_type(self):
user_type_doc = frappe.get_cached_doc("User Type", self.user_type)
if user_type_doc.role:
@ -427,15 +430,6 @@ class User(Document):
self.doctype, self.name, self.name, write=1, share=1, flags={"ignore_share_permission": True}
)
def validate_share(self, docshare):
pass
# if docshare.user == self.name:
# if self.user_type=="System User":
# if docshare.share != 1:
# frappe.throw(_("Sorry! User should have complete access to their own record."))
# else:
# frappe.throw(_("Sorry! Sharing with Website User is prohibited."))
def send_password_notification(self, new_password):
try:
if self.flags.in_insert:

View file

@ -57,7 +57,6 @@ class Workspace:
self.onboarding_list = [
x["data"]["onboarding_name"] for x in loads(self.doc.content) if x["type"] == "onboarding"
]
self.onboardings = []
self.table_counts = get_table_with_counts()
self.restricted_doctypes = (
@ -157,7 +156,7 @@ class Workspace:
self.cards = {"items": self.get_links()}
self.charts = {"items": self.get_charts()}
self.shortcuts = {"items": self.get_shortcuts()}
self.onboardings = {"items": self.get_onboardings()}
self.onboardings = {"items": []}
self.quick_lists = {"items": self.get_quick_lists()}
self.number_cards = {"items": self.get_number_cards()}
self.custom_blocks = {"items": self.get_custom_blocks()}
@ -315,38 +314,6 @@ class Workspace:
return items
@handle_not_exist
def get_onboardings(self):
if self.onboarding_list:
for onboarding in self.onboarding_list:
onboarding_doc = self.get_onboarding_doc(onboarding)
if onboarding_doc:
item = {
"label": _(onboarding),
"title": _(onboarding_doc.title),
"subtitle": _(onboarding_doc.subtitle),
"success": _(onboarding_doc.success_message),
"docs_url": onboarding_doc.documentation_url,
"items": self.get_onboarding_steps(onboarding_doc),
}
self.onboardings.append(item)
return self.onboardings
@handle_not_exist
def get_onboarding_steps(self, onboarding_doc):
steps = []
for doc in onboarding_doc.get_steps():
step = doc.as_dict().copy()
step.label = _(doc.title)
step.description = _(doc.description)
if step.action == "Create Entry":
step.is_submittable = frappe.db.get_value(
"DocType", step.reference_document, "is_submittable", cache=True
)
steps.append(step)
return steps
@handle_not_exist
def get_number_cards(self):
all_number_cards = []
@ -490,7 +457,9 @@ def get_workspace_sidebar_items():
pages.extend(private_pages)
if len(pages) == 0:
pages.append(next((x for x in all_pages if x["title"] == "Welcome Workspace"), None))
welcome_workspace = next((x for x in all_pages if x["title"] == "Welcome Workspace"), None)
if welcome_workspace:
pages.append(welcome_workspace)
return {
"pages": pages,
@ -700,3 +669,50 @@ def update_onboarding_step(name: str | int, field: str, value: int | str):
@frappe.whitelist()
def get_installed_apps():
return frappe.get_installed_apps()
@frappe.whitelist()
@frappe.read_only()
def get_onboarding_data(module: str):
"""Get onboarding data for a page
Args:
page (string): page name
Return:
dict: onboarding data
"""
onboardings = []
onboarding_doc = frappe.get_doc("Module Onboarding", module)
if onboarding_doc.is_complete:
return []
# Check if user is allowed
allowed_roles = set(onboarding_doc.get_allowed_roles())
user_roles = set(frappe.get_roles())
if not allowed_roles & user_roles:
return None
item = {
"label": _(module),
"title": _(onboarding_doc.title),
"items": [],
}
maps = get_onboarding_step_maps(onboarding_doc.name)
for step in maps:
steps = frappe.get_all("Onboarding Step", filters={"name": step}, order_by="idx", fields=["*"])
if steps:
item["items"].append(steps[0])
onboardings.append(item)
if all(step.get("is_complete") or step.get("is_skipped") for step in item["items"]):
return []
return onboardings
def get_onboarding_step_maps(onboarding):
return frappe.get_all("Onboarding Step Map", filters={"parent": onboarding}, pluck="step", order_by="idx")

View file

@ -7,12 +7,9 @@
"engine": "InnoDB",
"field_order": [
"title",
"subtitle",
"module",
"allow_roles",
"column_break_4",
"success_message",
"documentation_url",
"allow_roles",
"is_complete",
"section_break_6",
"steps"
@ -25,12 +22,6 @@
"label": "Title",
"reqd": 1
},
{
"fieldname": "subtitle",
"fieldtype": "Data",
"label": "Subtitle",
"reqd": 1
},
{
"fieldname": "module",
"fieldtype": "Link",
@ -46,18 +37,6 @@
"fieldname": "section_break_6",
"fieldtype": "Section Break"
},
{
"fieldname": "success_message",
"fieldtype": "Data",
"label": "Success Message",
"reqd": 1
},
{
"fieldname": "documentation_url",
"fieldtype": "Data",
"label": "Documentation URL",
"reqd": 1
},
{
"default": "0",
"fieldname": "is_complete",
@ -82,7 +61,7 @@
}
],
"links": [],
"modified": "2024-03-23 16:03:30.074327",
"modified": "2026-02-20 13:30:25.659490",
"modified_by": "Administrator",
"module": "Desk",
"name": "Module Onboarding",
@ -111,8 +90,9 @@
}
],
"read_only": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View file

@ -19,12 +19,9 @@ class ModuleOnboarding(Document):
from frappe.types import DF
allow_roles: DF.TableMultiSelect[OnboardingPermission]
documentation_url: DF.Data
is_complete: DF.Check
module: DF.Link
steps: DF.Table[OnboardingStepMap]
subtitle: DF.Data
success_message: DF.Data
title: DF.Data
# end: auto-generated types

View file

@ -23,7 +23,7 @@
"default": "1",
"fieldname": "enabled",
"fieldtype": "Check",
"label": "Enabled System Notification"
"label": "Enable System Notification"
},
{
"fieldname": "subscribed_documents",
@ -98,7 +98,7 @@
"in_create": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-02-17 13:39:35.159083",
"modified": "2026-02-24 11:06:24.112935",
"modified_by": "Administrator",
"module": "Desk",
"name": "Notification Settings",

View file

@ -32,7 +32,9 @@
"validate_action",
"field",
"value_to_validate",
"video_url"
"video_url",
"section_break_ajog",
"route_options"
],
"fields": [
{
@ -61,7 +63,7 @@
"fieldname": "action",
"fieldtype": "Select",
"label": "Action",
"options": "Create Entry\nUpdate Settings\nShow Form Tour\nView Report\nGo to Page\nWatch Video",
"options": "Create Entry\nUpdate Settings\nShow Form Tour\nView Report\nGo to Page\nView Docs",
"reqd": 1
},
{
@ -145,7 +147,7 @@
"label": "Is Single"
},
{
"depends_on": "eval:doc.action == \"Go to Page\"",
"depends_on": "eval:doc.action == \"Go to Page\" || doc.action === \"View Docs\"",
"description": "Example: #Tree/Account",
"fieldname": "path",
"fieldtype": "Data",
@ -214,10 +216,19 @@
"fieldtype": "Link",
"label": "Form Tour",
"options": "Form Tour"
},
{
"fieldname": "section_break_ajog",
"fieldtype": "Section Break"
},
{
"fieldname": "route_options",
"fieldtype": "Code",
"label": "Route Options"
}
],
"links": [],
"modified": "2024-03-23 16:03:33.078443",
"modified": "2026-02-23 21:03:51.131292",
"modified_by": "Administrator",
"module": "Desk",
"name": "Onboarding Step",
@ -248,8 +259,9 @@
],
"quick_entry": 1,
"read_only": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View file

@ -18,7 +18,7 @@ class OnboardingStep(Document):
from frappe.types import DF
action: DF.Literal[
"Create Entry", "Update Settings", "Show Form Tour", "View Report", "Go to Page", "Watch Video"
"Create Entry", "Update Settings", "Show Form Tour", "View Report", "Go to Page", "View Docs"
]
action_label: DF.Data | None
callback_message: DF.SmallText | None
@ -36,6 +36,7 @@ class OnboardingStep(Document):
report_description: DF.Data | None
report_reference_doctype: DF.Data | None
report_type: DF.Data | None
route_options: DF.Code | None
show_form_tour: DF.Check
show_full_form: DF.Check
title: DF.Data

View file

@ -13,6 +13,7 @@
"column_break_pukb",
"standard",
"app",
"module_onboarding",
"section_break_vdyo",
"items"
],
@ -67,12 +68,18 @@
{
"fieldname": "section_break_vdyo",
"fieldtype": "Section Break"
},
{
"fieldname": "module_onboarding",
"fieldtype": "Link",
"label": "Module Onboarding",
"options": "Module Onboarding"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-02-02 12:35:38.009501",
"modified": "2026-02-20 15:19:27.520469",
"modified_by": "Administrator",
"module": "Desk",
"name": "Workspace Sidebar",

View file

@ -29,6 +29,7 @@ class WorkspaceSidebar(Document):
for_user: DF.Link | None
items: DF.Table[WorkspaceSidebarItem]
module: DF.Text | None
module_onboarding: DF.Link | None
standard: DF.Check
title: DF.Data | None
# end: auto-generated types

View file

@ -185,7 +185,7 @@ def get_milestones(doctype, name):
def get_attachments(dt, dn):
return frappe.get_all(
"File",
fields=["name", "file_name", "file_url", "is_private"],
fields=["name", "file_name", "file_url", "is_private", "attached_to_field", "folder"],
filters={"attached_to_name": str(dn), "attached_to_doctype": dt},
)

View file

@ -156,6 +156,7 @@ def search_widget(
# build from doctype
if txt:
field_types = {
"Autocomplete",
"Data",
"Text",
"Small Text",

View file

@ -173,41 +173,75 @@ class EmailQueue(Document):
force_send: bool = False,
):
"""Send emails to recipients."""
if not self.can_send_now() and not force_send:
return
with SendMailContext(self, smtp_server_instance, frappe_mail_client) as ctx:
ctx.fetch_outgoing_server()
message = None
def validate_and_prepare_message(raw_message: bytes) -> bytes:
"""Validate SIZE extension and return encoded message."""
msg = raw_message if isinstance(raw_message, bytes) else raw_message.encode("utf-8")
if ctx.smtp_server.session.has_extn("SIZE"):
if max_size := ctx.smtp_server.session.esmtp_features.get("size"):
max_size = int(max_size)
msg_size = len(msg)
if msg_size > max_size:
msg_size_mb = msg_size / (1024 * 1024)
max_size_mb = max_size / (1024 * 1024)
frappe.throw(
_(
"Email size {0:.2f} MB exceeds the maximum allowed size of {1:.2f} MB"
).format(msg_size_mb, max_size_mb)
)
return msg
def get_smtp_options() -> tuple[list[str], list[str]]:
mail_options: list[str] = []
rcpt_options: list[str] = []
if not ctx.smtp_server.session.has_extn("DSN"):
return mail_options, rcpt_options
if dsn_notify_type := ctx.email_account_doc.dsn_notify_type:
mail_options.extend(["RET=FULL", f"ENVID={self.name}"])
rcpt_options.append(f"NOTIFY={dsn_notify_type}")
return mail_options, rcpt_options
last_message = None
for recipient in self.recipients:
if recipient.is_mail_sent():
continue
message = ctx.build_message(recipient.recipient)
last_message = message
if method := get_hook_method("override_email_send"):
method(self, self.sender, recipient.recipient, message)
elif not frappe.in_test or frappe.flags.testing_email:
if ctx.email_account_doc.service == "Frappe Mail":
is_newsletter = self.reference_doctype == "Newsletter"
ctx.frappe_mail_client.send_raw(
sender=self.sender,
recipients=recipient.recipient,
message=message,
is_newsletter=is_newsletter,
is_newsletter=self.reference_doctype == "Newsletter",
)
else:
mail_options = []
rcpt_options = []
if ctx.smtp_server.session.has_extn("DSN"):
if dsn_notify_type := ctx.email_account_doc.dsn_notify_type:
mail_options = ["RET=FULL", f"ENVID={self.name}"]
rcpt_options = [f"NOTIFY={dsn_notify_type}"]
msg_bytes = validate_and_prepare_message(message)
mail_options, rcpt_options = get_smtp_options()
ctx.smtp_server.session.sendmail(
from_addr=self.sender,
to_addrs=recipient.recipient,
msg=message.decode("utf-8").encode(),
msg=msg_bytes,
mail_options=mail_options,
rcpt_options=rcpt_options,
)
@ -215,11 +249,11 @@ class EmailQueue(Document):
ctx.update_recipient_status_to_sent(recipient)
if frappe.in_test and not frappe.flags.testing_email:
frappe.flags.sent_mail = message
frappe.flags.sent_mail = last_message
return
if ctx.email_account_doc.append_emails_to_sent_folder:
ctx.email_account_doc.append_email_to_sent_folder(message)
if last_message and ctx.email_account_doc.append_emails_to_sent_folder:
ctx.email_account_doc.append_email_to_sent_folder(last_message)
@staticmethod
def clear_old_logs(days=30):

View file

@ -14,7 +14,7 @@ from frappe.desk.doctype.notification_log.notification_log import enqueue_create
from frappe.integrations.doctype.slack_webhook_url.slack_webhook_url import send_slack_message
from frappe.model.document import Document
from frappe.modules.utils import export_module_json, get_doc_module
from frappe.utils import add_to_date, cast, now_datetime, nowdate, validate_email_address
from frappe.utils import add_to_date, cast, cint, now_datetime, nowdate, validate_email_address
from frappe.utils.data import evaluate_filters
from frappe.utils.jinja import validate_template
from frappe.utils.safe_exec import get_safe_globals
@ -721,8 +721,23 @@ def get_context(context):
self.message = self.get_template(md_as_html=True)
def on_trash(self):
if self.is_standard:
# Prevent deletion of standard notifications outside developer mode to avoid restoration during migration
if not frappe.conf.developer_mode and not frappe.flags.in_migrate and not frappe.flags.in_patch:
frappe.throw(
_("You are not allowed to delete a standard Notification. You can disable it instead.")
)
if frappe.conf.developer_mode and not frappe.flags.in_test:
frappe.db.after_commit(self.delete_notification_folder)
clear_notification_cache()
def delete_notification_folder(self):
from frappe.modules.export_file import delete_folder
delete_folder(self.module, "Notification", self.name)
def clear_notification_cache():
frappe.client_cache.delete_keys("notifications::")

View file

@ -478,46 +478,59 @@ def inline_style_in_html(html, add_css=True):
def add_attachment(fname, fcontent, content_type=None, parent=None, content_id=None, inline=False):
"""Add attachment to parent which must an email object"""
import mimetypes
from email import encoders
from email.mime.audio import MIMEAudio
from email.mime.base import MIMEBase
from email.mime.image import MIMEImage
from email.mime.text import MIMEText
if not content_type:
content_type, _encoding = mimetypes.guess_type(fname)
if not parent:
return
# Guess content type if not provided
if not content_type:
content_type, _encoding = mimetypes.guess_type(fname)
if content_type is None:
# No guess could be made, or the file is encoded (compressed), so
# use a generic bag-of-bits type.
content_type = "application/octet-stream"
maintype, subtype = content_type.split("/", 1)
if maintype == "text":
# Note: we should handle calculating the charset
if isinstance(fcontent, bytes):
# If bytes are provided, assume UTF-8
fcontent = fcontent.decode("utf-8")
part = MIMEText(fcontent, _subtype=subtype, _charset="utf-8")
elif maintype == "image":
if isinstance(fcontent, str):
fcontent = fcontent.encode("utf-8")
part = MIMEText(fcontent, _subtype=subtype, _charset="utf-8")
elif maintype == "image":
part = MIMEImage(fcontent, _subtype=subtype)
elif maintype == "audio":
if isinstance(fcontent, str):
fcontent = fcontent.encode("utf-8")
part = MIMEAudio(fcontent, _subtype=subtype)
else:
if isinstance(fcontent, str):
fcontent = fcontent.encode("utf-8")
part = MIMEBase(maintype, subtype)
part.set_payload(fcontent)
# Encode the payload using Base64
from email import encoders
encoders.encode_base64(part)
# Set the filename parameter
if fname:
attachment_type = "inline" if inline else "attachment"
clean_filename = re.sub("[\r\n]", "", str(fname))
clean_filename = re.sub(r"[\r\n]", "", str(fname))
part.add_header("Content-Disposition", attachment_type, filename=clean_filename)
if content_id:
part.add_header("Content-ID", f"<{content_id}>")

View file

@ -270,14 +270,29 @@ class EmailServer:
return match[0] if match else None
def retrieve_message(self, uid, msg_num, folder):
def retrieve_message(self, uid, msg_num, folder) -> None:
try:
if cint(self.settings.use_imap):
_status, message = self.imap.uid("fetch", uid, "(BODY.PEEK[] BODY.PEEK[HEADER] FLAGS)")
raw = message[0]
_status, data = self.imap.uid("fetch", uid, "(BODY.PEEK[] FLAGS)")
self.get_email_seen_status(uid, raw[0])
self.latest_messages.append(raw[1])
if _status != "OK" or not data:
return
raw_email = next(
(part[1] for part in data if isinstance(part, tuple) and b"BODY[]" in part[0]), None
)
if raw_email is None:
return
flags_line = next(
(part for part in data if isinstance(part, bytes) and b"FLAGS" in part), None
)
if flags_line is not None:
self.get_email_seen_status(uid, flags_line)
self.latest_messages.append(raw_email)
else:
msg = self.pop.retr(msg_num)
self.latest_messages.append(b"\n".join(msg[1]))
@ -559,36 +574,42 @@ class Email:
except Exception:
return part.get_payload()
def get_attachment(self, part):
def get_attachment(self, part) -> None:
# charset = self.get_charset(part)
fcontent = part.get_payload(decode=True)
if fcontent:
content_type = part.get_content_type()
fname = part.get_filename()
if fname:
try:
fname = fname.replace("\n", " ").replace("\r", "")
fname = cstr(decode_header(fname)[0][0])
except Exception:
fname = get_random_filename(content_type=content_type)
else:
fname = get_random_filename(content_type=content_type)
# Don't clobber existing filename
while fname in self.cid_map:
fname = get_random_filename(content_type=content_type)
if not fcontent:
return
self.attachments.append(
{
"content_type": content_type,
"fname": fname,
"fcontent": fcontent,
}
)
attachment_limit = cint(self.email_account.attachment_limit)
if attachment_limit and len(fcontent) > attachment_limit * 1024 * 1024:
return # skip attachments that are larger than the specified limit
cid = (cstr(part.get("Content-Id")) or "").strip("><")
if cid:
self.cid_map[fname] = cid
content_type = part.get_content_type()
fname = part.get_filename()
if fname:
try:
fname = fname.replace("\n", " ").replace("\r", "")
fname = cstr(decode_header(fname)[0][0])
except Exception:
fname = get_random_filename(content_type=content_type)
else:
fname = get_random_filename(content_type=content_type)
# Don't clobber existing filename
while fname in self.cid_map:
fname = get_random_filename(content_type=content_type)
self.attachments.append(
{
"content_type": content_type,
"fname": fname,
"fcontent": fcontent,
}
)
cid = (cstr(part.get("Content-Id")) or "").strip("><")
if cid:
self.cid_map[fname] = cid
def save_attachments_in_doc(self, doc):
"""Save email attachments in given document."""
@ -636,11 +657,11 @@ class InboundMail(Email):
"""Class representation of incoming mail along with mail handlers."""
def __init__(self, content, email_account, uid=None, seen_status=None, append_to=None):
super().__init__(content)
self.email_account = email_account
self.uid = uid or -1
self.append_to = append_to
self.seen_status = seen_status or 0
super().__init__(content)
# System documents related to this mail
self._parent_email_queue = None

View file

@ -265,6 +265,10 @@ def run_doc_method(method, docs=None, dt=None, dn=None, arg=None, args=None):
if dt: # not called from a doctype (from a page)
if not dn:
dn = dt # single
if not isinstance(dn, str | int):
frappe.throw("'dn' must be a string or an integer")
doc = frappe.get_doc(dt, dn, check_permission=True)
else:

View file

@ -424,6 +424,8 @@ def login():
@frappe.whitelist()
def reset_password(user: str, password: str, logout: int):
frappe.only_for("System Manager")
ldap: LDAPSettings = frappe.get_doc("LDAP Settings")
if not ldap.enabled:
frappe.throw(_("LDAP is not enabled."))

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

@ -1324,7 +1324,7 @@ class BaseDocument:
df = self.meta.get_field(key)
db_value = db_values.get(key)
if df and not df.allow_on_submit and (self.get(key) or db_value):
if df and not df.allow_on_submit and not df.is_virtual and (self.get(key) or db_value):
if df.fieldtype in table_fields:
# just check if the table size has changed
# individual fields will be checked in the loop for children

View file

@ -500,7 +500,11 @@ from {tables}
if (name := (token.get_name())) and name.lower() in blacklisted_functions:
_raise_exception()
if token.ttype in (tokens.Keyword, tokens.Name):
if token.ttype in tokens.Keyword:
if any(re.search(rf"\b{kw}\b", token.value.lower()) for kw in blacklisted_keywords):
_raise_exception()
if token.ttype in tokens.Name and not re.match(r"^`\w.*`$", token.value.strip()):
if any(re.search(rf"\b{kw}\b", token.value.lower()) for kw in blacklisted_keywords):
_raise_exception()

View file

@ -598,7 +598,8 @@ class Document(BaseDocument):
"file_name": attach_item.file_name,
"attached_to_name": self.name,
"attached_to_doctype": self.doctype,
"folder": "Home/Attachments",
"attached_to_field": attach_item.attached_to_field,
"folder": attach_item.folder or "Home/Attachments",
"is_private": attach_item.is_private,
}
)

View file

@ -200,7 +200,7 @@ def remove_orphan_doctypes():
def remove_orphan_entities():
entites = ["Workspace", "Dashboard", "Page", "Report"]
entites = ["Workspace", "Dashboard", "Page", "Report", "Notification"]
app_level_entities = ["Workspace Sidebar", "Desktop Icon"]
entity_filter_map = {
"Workspace": [{"public": 1, "module": ["is", "set"], "app": ["is", "set"]}],
@ -209,6 +209,7 @@ def remove_orphan_entities():
"Dashboard": {"is_standard": True},
"Workspace Sidebar": {"standard": True},
"Desktop Icon": {"standard": True},
"Notification": {"is_standard": True},
}
entity_file_map = create_entity_file_map(entites)
@ -238,17 +239,23 @@ def remove_orphan_entities():
all_enitities = frappe.get_all(
app_entity, filters=entity_filter_map.get(app_entity), fields=["name", "app"]
)
for i, w in enumerate(all_enitities):
if w.app and not check_if_record_exists("app", frappe.get_app_path(w.app), app_entity, w.name):
try:
print(f"Deleting entity {app_entity} {w.name}")
frappe.delete_doc(app_entity, w.name, force=True, ignore_missing=True)
update_progress_bar(f"Deleting orphaned {app_entity}", i, len(all_enitities))
print()
except Exception as e:
print(f"Error occurred while deleting entity: {app_entity} {w.name}")
print(e)
for i, entity in enumerate(all_enitities):
try:
if entity.app:
app_path = frappe.get_app_path(entity.app)
if not check_if_record_exists("app", app_path, app_entity, entity.name):
try:
print(f"Deleting entity {app_entity} {entity.name}")
frappe.delete_doc(app_entity, entity.name, force=True, ignore_missing=True)
update_progress_bar(f"Deleting orphaned {app_entity}", i, len(all_enitities))
print()
except Exception as e:
print(f"Error occurred while deleting entity: {app_entity} {entity.name}")
print(e)
except ModuleNotFoundError as e:
print(e)
print(f"Deleting entity {app_entity} {entity.name}")
frappe.db.delete(app_entity, {"name": entity.name})
# save the deleted icons
frappe.db.commit() # nosemgrep

View file

@ -113,3 +113,4 @@ import "./frappe/scanner";
import "./frappe/ui/address_autocomplete/autocomplete_dialog.js";
import "./frappe/ui/desktop_icon.html";
import "./frappe/ui/user_onboarding/user_onboarding.bundle.js";

View file

@ -126,8 +126,8 @@ frappe.ui.form.ControlAttach = class ControlAttach extends frappe.ui.form.Contro
.attr("href", dataurl || this.value);
}
} else {
this.$input.toggle(true);
this.$value.toggle(false);
this.$input?.toggle(true);
this.$value?.toggle(false);
}
}

View file

@ -17,7 +17,11 @@ frappe.ui.form.ControlFloat = class ControlFloat extends frappe.ui.form.ControlI
}
get_number_format() {
if (this.df.fieldtype === "Float" && !this.df.options?.trim()) return;
if (
this.df.fieldtype === "Rating" ||
(this.df.fieldtype === "Float" && !this.df.options?.trim())
)
return;
const currency = frappe.meta.get_field_currency(this.df, this.get_doc());
return get_number_format(currency);

View file

@ -203,6 +203,13 @@ frappe.ui.form.QuickEntryForm = class QuickEntryForm extends frappe.ui.Dialog {
let messagetxt = __("{1} saved", [__(me.doctype), me.doc.name.bold()]);
me.dialog.animation_speed = "slow";
me.dialog.hide();
if (frappe.route_hooks.after_save) {
let route_callback = frappe.route_hooks.after_save;
delete frappe.route_hooks.after_save;
route_callback(me);
}
setTimeout(function () {
frappe.show_alert(
{

View file

@ -145,7 +145,14 @@ frappe.request.call = function (opts) {
opts.error_callback && opts.error_callback();
},
403: function (xhr) {
if (frappe.session.user === "Guest" && frappe.session.logged_in_user !== "Guest") {
const user_id = document.cookie
.split(";")
.find((c) => c.trim().startsWith("user_id="))
?.split("=")[1];
if (
user_id === "Guest" ||
(frappe.session.user === "Guest" && frappe.session.logged_in_user !== "Guest")
) {
// session expired
frappe.app.handle_session_expired();
} else if (xhr.responseJSON && xhr.responseJSON._error_message) {

View file

@ -40,6 +40,12 @@
Save
</button>
</div>
<p>
<a class="onboarding-sidebar">
{%= frappe.utils.icon("user-check" , "sm", "", "", "text-ink-gray-7 current-color", true)%}
<span> {%= __("Getting started") %} </span>
</a>
</p>
<a class="collapse-sidebar-link">
{%= frappe.utils.icon("panel-right-open" , "sm", "", "", "text-ink-gray-7 current-color", true)%}
<span> {%= __("Collapse") %} </span>
@ -84,5 +90,6 @@
</div>
</div>
<div class="overlay" style="z-index: 1021;"></div>
<div class="user-onboarding"></div>
</div>
</div>

View file

@ -78,6 +78,63 @@ frappe.ui.Sidebar = class Sidebar {
}
}
remove_onboarding_wrapper() {
this.$onboarding.empty();
this.wrapper.find(".onboarding-sidebar").removeClass("hidden");
}
setup_onboarding() {
let me = this;
this.$onboarding = this.wrapper.find(".user-onboarding");
if (!this.sidebar_data || !this.sidebar_data.module_onboarding) {
this.remove_onboarding_wrapper();
return;
}
let module_name = this.sidebar_data.module_onboarding;
if (this?.onboarding_widget[module_name]) {
return;
}
this.remove_onboarding_wrapper();
if (module_name) {
if (
this?.onboarding_widget[module_name] &&
this.onboarding_widget[module_name].hide_panel
) {
return;
}
return frappe
.call({
method: "frappe.desk.desktop.get_onboarding_data",
args: {
// send sorted min requirements to increase chance of cache hit
module: module_name,
},
type: "GET",
})
.then((data) => {
if (data.message?.length > 0) {
let onboarding_data = data.message[0];
me.onboarding_widget = {};
me.onboarding_widget[module_name] = new frappe.ui.UserOnboarding({
title: onboarding_data.title,
steps: onboarding_data.items,
wrapper: me.$onboarding,
header_icon: me.sidebar_header.header_icon,
});
} else {
this.wrapper.find(".onboarding-sidebar").addClass("hidden");
}
});
} else {
this.wrapper.find(".onboarding-sidebar").addClass("hidden");
}
}
find_nested_items() {
const me = this;
let currentSection = null;
@ -99,6 +156,10 @@ frappe.ui.Sidebar = class Sidebar {
this.workspace_sidebar_items = updated_items;
}
setup(workspace_title) {
if (!this.onboarding_widget) {
this.onboarding_widget = {};
}
$(document).trigger("sidebar_setup", { sidebar: this });
this.sidebar_title = workspace_title;
this.check_for_private_workspace(workspace_title);
@ -109,6 +170,15 @@ frappe.ui.Sidebar = class Sidebar {
this.sidebar_header = new frappe.ui.SidebarHeader(this);
this.make_sidebar();
this.add_sidebar_cards();
this.setup_onboarding();
this.wrapper.find(".onboarding-sidebar").click(() => {
if (this.sidebar_data?.module_onboarding) {
delete this.onboarding_widget[this.sidebar_data.module_onboarding];
}
this.setup_onboarding();
});
}
add_card(card) {
if (this.cards && this.cards.find((i) => i.title === card.title)) return;

View file

@ -565,8 +565,8 @@ export class SidebarEditor {
case "delete":
this.delete_item(item_data);
break;
case "add_item_below":
this.edit_item(item_data);
case "add_below":
this.add_below(item_data);
break;
case "duplicate":
this.duplicate_item(item_data);

View file

@ -0,0 +1,352 @@
<script setup>
import { ref, computed } from "vue";
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
title: {
type: String,
default: "Welcome",
},
steps: {
type: Array,
default: () => [],
},
minimizeIcon: {
type: String,
default: "—",
},
closeIcon: {
type: String,
default: "✕",
},
headerIcon: {
type: String,
default: "👋",
},
checklistIcon: {
type: String,
default: "✔",
},
completeChecklistIcon: {
type: String,
default: "✔",
},
});
const emit = defineEmits(["update:modelValue", "skip"]);
const collapsed = ref(false);
const visible = computed({
get: () => props.modelValue,
set: (val) => emit("update:modelValue", val),
});
let skippAll = false;
const completedCount = computed(
() => props.steps.filter((step) => step.is_complete || step.is_skipped).length
);
const progress = computed(() => {
if (!props.steps.length) return 0;
return Math.round((completedCount.value / props.steps.length) * 100);
});
function close() {
visible.value = false;
}
function toggleCollapse() {
collapsed.value = !collapsed.value;
}
function skipAll(skips) {
skips.forEach((step) => {
if (!step.is_complete && !step.is_skipped) {
markSkip(step);
}
});
skippAll = true;
}
function resetAll(skips) {
skips.forEach((step) => {
if (!step.is_complete && step.is_skipped) {
markReset(step);
}
});
skippAll = false;
}
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);
}
const actions = {
"Create Entry": createEntry,
"Show Form Tour": showFormTour,
"Update Settings": updateSettings,
"View Report": openReport,
"Go to Page": goToPage,
"View Docs": viewDocs,
};
if (step.action && actions[step.action]) {
actions[step.action](step);
} else if (step.route) {
frappe.set_route(step.route);
}
}
function viewDocs(step) {
window.open(step.path, "_blank");
markComplete(step);
}
function goToPage(step) {
toggleCollapse();
frappe.set_route(step.path).then(() => {
markComplete(step);
});
}
function openReport(step) {
toggleCollapse();
const route = frappe.utils.generate_route({
name: step.reference_report,
type: "report",
is_query_report: step.report_type !== "Report Builder",
doctype: step.report_reference_doctype,
});
frappe.set_route(route).then(() => {
markComplete(step);
});
}
function showFormTour(step) {
let route = step.is_single
? frappe.router.slug(step.reference_document)
: `${frappe.router.slug(step.reference_document)}/new`;
frappe.route_hooks = {};
frappe.route_hooks.after_load = (frm) => {
const tour_name = step.form_tour;
on_finish = () => markComplete(step);
frm.tour
.init({ tour_name, on_finish: () => markComplete(step) })
.then(() => frm.tour.start());
};
frappe.set_route(route);
}
function updateSettings(step) {
frappe.route_hooks = {};
frappe.route_hooks.after_load = (frm) => {
frm.scroll_to_field(step.field);
frm.doc.__unsaved = true;
};
frappe.route_hooks.after_save = (frm) => {
const success = frm.doc[step.field] == step.value_to_validate;
if (success) {
markComplete(step);
}
};
frappe.set_route("Form", step.reference_document);
}
async function createEntry(step) {
toggleCollapse();
frappe.route_hooks = {};
frappe.route_hooks.after_load = (frm) => {
const tour_name = step.form_tour;
if (tour_name) {
on_finish = () => {
console.log("Tour finished");
};
frm.tour.init({ tour_name, on_finish }).then(() => frm.tour.start());
}
};
const callback = () => {
markComplete(step);
};
frappe.route_hooks.after_save = callback;
if (step.show_full_form) {
frappe.set_route("Form", step.reference_document, "new");
} else {
frappe.new_doc(step.reference_document);
}
}
function markComplete(step) {
step.is_complete = true;
frappe.call("frappe.desk.desktop.update_onboarding_step", {
name: step.name,
field: "is_complete",
value: 1,
});
}
function markSkip(step) {
step.is_skipped = true;
frappe.call("frappe.desk.desktop.update_onboarding_step", {
name: step.name,
field: "is_skipped",
value: 1,
});
}
function markReset(step) {
step.is_skipped = false;
frappe.call("frappe.desk.desktop.update_onboarding_step", {
name: step.name,
field: "is_skipped",
value: 0,
});
}
</script>
<template>
<div v-if="visible" class="onb-panel">
<!-- Header -->
<div class="header onb-header-main">
<div class="text-base font-medium">Getting started</div>
<div class="onb-header-actions">
<button @click="toggleCollapse" v-html="minimizeIcon"></button>
<button @click="close" v-html="closeIcon"></button>
</div>
</div>
<div v-if="!collapsed" class="body">
<div class="onb-title">
<div class="onb-title-icon" v-html="headerIcon"></div>
<div class="text-base font-medium">{{ title }}</div>
<div class="onb-title-steps">
{{ completedCount }}/{{ steps.length }} steps completed
</div>
</div>
<div class="onb-progress-row">
<div v-if="progress !== 100">
<div class="onb-progress-badge">{{ progress }}% {{ __("completed") }}</div>
</div>
<div v-else>
<div class="onb-progress-badge-complete">
{{ progress }}% {{ __("completed") }}
</div>
</div>
<div v-if="skippAll">
<span class="onb-skip" @click="resetAll(steps)"> {{ __("Reset all") }}</span>
</div>
<div v-else>
<span class="onb-skip" @click="skipAll(steps)">Skip all</span>
</div>
</div>
<!-- Steps -->
<div class="onb-steps flex flex-col gap-2.5 overflow-hidden">
<div
style="width: 100%"
v-for="(step, i) in steps"
:key="i"
:class="{ is_complete: step.is_complete }"
>
<div
class="onb-group w-full step-title flex items-center"
style="align-items: center"
:class="
step.is_complete
? 'text-extra-muted onb-cursor-disabled'
: 'text-ink-gray-8 onb-select-cursor'
"
>
<div class="onb-step-left" @click="handleAction(step)">
<div class="onb-step-icon" v-if="step.is_complete">
<div v-html="completeChecklistIcon"></div>
</div>
<div class="onb-step-icon" v-else>
<div v-html="checklistIcon"></div>
</div>
<div v-if="!step.is_skipped">
<span class="text-base onb-step-text">
{{ step.action_label }}
</span>
</div>
<div v-else>
<span
class="text-base onb-step-text"
style="text-decoration-line: line-through"
>
{{ step.action_label }}
</span>
</div>
</div>
<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"
>
<span
style="
font-size: 12px;
vertical-align: text-top;
margin-right: 10px;
"
@click="markSkip(step)"
>
{{ __("Skip") }}
</span>
</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"
>
<span
style="
font-size: 12px;
vertical-align: text-top;
margin-right: 10px;
"
@click="markReset(step)"
>
{{ __("Reset") }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,225 @@
import { createApp, ref, h } from "vue";
import OnboardingPanel from "./OnboardingPanel.vue";
class UserOnboarding {
constructor({ title, steps, wrapper, header_icon }) {
this.title = title;
this.steps = steps;
this.$wrapper = $(wrapper);
this.header_icon = header_icon;
this.init();
this.hide_panel = false;
}
init() {
addStyles();
let title = this.title || __("Welcome to Frappe!");
let onboarding_checklist = this.steps || [];
let header_icon = this.header_icon;
let me = this;
const app = createApp({
components: { OnboardingPanel },
setup() {
const showPanel = ref(true);
const steps = ref(onboarding_checklist);
return () =>
h(OnboardingPanel, {
modelValue: showPanel.value,
title: title,
steps: steps.value,
minimizeIcon: frappe.utils.icon("minimize-2", "sm"),
closeIcon: frappe.utils.icon("close", "sm"),
headerIcon: header_icon,
checklistIcon: frappe.utils.icon("circle-check", "sm"),
completeChecklistIcon: frappe.utils.icon(
"circle-check",
"sm",
"",
"",
"",
"",
"var(--green)"
),
"onUpdate:modelValue": (v) => {
showPanel.value = v;
me.hide_panel = !v;
},
});
},
});
SetVueGlobals(app);
app.mount(this.$wrapper.get(0));
}
}
function addStyles() {
if (document.getElementById("user-onboarding-styles")) return;
const style = document.createElement("style");
style.id = "user-onboarding-styles";
style.innerHTML = `
.onb-panel {
position: fixed;
right: 24px;
bottom: 24px;
width: 380px;
max-height: 80vh;
background: #fff;
border-radius: 16px;
box-shadow: 0 12px 40px rgba(0,0,0,0.15);
padding: 16px;
z-index: 9999;
display: flex;
flex-direction: column;
}
.onb-header-main {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.onb-header-actions button {
border: none;
background: transparent;
cursor: pointer;
margin-left: 2px;
}
.onb-step-left {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.onb-step-icon {
margin-bottom: 2px;
align-items: center;
}
.text-base {
font-size: 14px;
line-spacing: 1.15;
letter-spacing: 0.02em;
color: #050505;
}
.font-medium {
font-weight: 600;
}
.onb-step-text {
white-space: nowrap;
margin-top: 2px;
text-align: left;
font-size: 14px;
}
.onb-skip {
color: #6b7280;
cursor: pointer;
font-weight: 500;
}
.onb-skip:hover {
color: #111827;
}
.onb-steps {
margin-top: 16px;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.onb-group:hover {
color: #111827;
background: #f5f5f5;
}
.onb-cursor-disabled {
cursor: not-allowed;
}
.onb-select-cursor {
cursor: pointer;
}
.onb-show-on-hover {
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease;
}
.onb-group:hover .onb-show-on-hover {
opacity: 1;
visibility: visible;
}
.onb-title {
text-align: center;
margin-top: 12px;
margin-bottom: 5px;
}
.onb-title-icon {
display: flex;
justify-content: center;
margin-bottom: 8px;
height: 40px;
}
.onb-title-steps {
color: #6b7280;
font-size: 14px;
margin-bottom: 8px;
}
.onb-progress-badge {
background: #FDFAED;
color: #DB7706;
padding: 4px 10px;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
}
.onb-progress-badge-complete {
background: #E4FAEB;
color: #278F5E;
padding: 4px 10px;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
}
.onb-progress-row {
display: flex;
justify-content: space-between;
align-items: center;
margin: 14px 0 8px;
}
.onb-progress-text {
color: #6b7280;
font-size: 14px;
}
`;
document.head.appendChild(style);
}
frappe.provide("frappe.ui");
frappe.ui.UserOnboarding = UserOnboarding;
export default UserOnboarding;

View file

@ -5,6 +5,8 @@ import localforage from "localforage";
frappe.last_edited_communication = {};
const separator_element = "<div>---</div>";
// Quill uses <p>---</p>; match both when stripping quoted content
const separator_regex = /<(?:div|p)(?:\s[^>]*)?>---<\/(?:div|p)>/i;
frappe.views.CommunicationComposer = class {
constructor(opts) {
@ -566,6 +568,18 @@ frappe.views.CommunicationComposer = class {
const last_edited = this.get_last_edited_communication();
if (!last_edited.content && !last_edited.html_content) return;
// For replies: strip duplicate quoted content (Quill uses <p>---</p>)
if (this.is_a_reply) {
const reply_block = this.get_earlier_reply();
for (const field of ["content", "html_content"]) {
if (last_edited[field]) {
last_edited[field] =
(last_edited[field].split(separator_regex)[0] || "").trimEnd() +
reply_block;
}
}
}
// prevent re-triggering of email template
if (last_edited.email_template) {
const template_field = this.dialog.fields_dict.email_template;
@ -789,7 +803,7 @@ frappe.views.CommunicationComposer = class {
save_as_draft() {
if (this.dialog && this.frm) {
let message = this.get_email_content();
message = message.split(separator_element)[0];
message = message.split(separator_regex)[0];
this.save_item_in_local_forage(this.frm.doctype + this.frm.docname, message);
this.save_item_in_local_forage(
this.frm.doctype + this.frm.docname + "_use_html",
@ -950,7 +964,7 @@ frappe.views.CommunicationComposer = class {
}
if (this.is_a_reply && !this.reply_set) {
message += this.get_earlier_reply();
message = message.split(separator_regex)[0] + this.get_earlier_reply();
}
await this.set_email_content(message);

View file

@ -58,9 +58,14 @@ div#driver-popover-item {
}
}
#driver-page-overlay {
pointer-events: none !important;
}
#driver-highlighted-element-stage {
background-color: var(--bg-color) !important;
border-radius: var(--border-radius) !important;
pointer-events: auto !important;
}
input.driver-highlighted-element {

View file

@ -60,7 +60,7 @@
min-height: 480px;
position: absolute;
top: 0;
box-shadow: var(--shadow-2xl);
box-shadow: rgba(0, 0, 0, 0.1) 8px 0px 8px;
background-color: var(--bg-color);
height: 100vh;
overflow: hidden;

View file

@ -127,6 +127,21 @@
}
}
.onboarding-sidebar {
text-decoration: none;
font-size: var(--text-sm);
display: flex;
align-items: center;
svg {
margin: 0px;
flex: 0 0 auto;
}
span {
margin-left: 10px;
@include truncate();
}
}
.collapse-sidebar-link {
text-decoration: none;
font-size: var(--text-sm);

View file

@ -1679,3 +1679,19 @@ class TestDataUtils(UnitTestCase):
self.assertEqual(comma_or(["a", "b", "c"]), "'a', 'b' ou 'c'")
self.assertEqual(comma_or(["a", "b", "c"], add_quotes=False), "a, b ou c")
class TestMsgPrint(UnitTestCase):
def tearDown(self) -> None:
super().tearDown()
frappe.clear_messages()
def test_msgprint(self):
frappe.msgprint("Validate: <script>alert('bounty')</script>")
message = frappe.get_message_log()[-1]
self.assertNotIn("script", message.message)
frappe.msgprint("<ul><li>abc<li></ul>")
message = frappe.get_message_log()[-1]
self.assertIn("<ul><li>", message.message)

View file

@ -329,6 +329,9 @@ def get_messages_from_doctype(name):
if d.fieldtype == "Select" and d.options:
options = d.options.split("\n")
# for workflow state, we don't want to translate the icon(css classnames)
if d.fieldname == "icon" and name == "Workflow State":
continue
if "icon" not in options[0]:
messages.extend(options)
if d.fieldtype == "HTML" and d.options:

View file

@ -530,14 +530,16 @@ def search(text: str, start: int = 0, limit: int = 20, doctype: str = ""):
if r.doctype == doctype and r.rank > 0.0:
try:
meta = frappe.get_meta(r.doctype)
doc = frappe.get_lazy_doc(r.doctype, r.name)
if meta.image_field:
r.image = frappe.db.get_value(r.doctype, r.name, meta.image_field)
r.image = doc.get(meta.image_field)
if meta.title_field:
r.title = frappe.db.get_value(r.doctype, r.name, meta.title_field)
r.title = doc.get(meta.title_field)
except Exception:
frappe.clear_messages()
sorted_results.extend([r])
if doc.has_permission():
sorted_results.append(r)
return sorted_results

View file

@ -1,4 +1,3 @@
import functools
import sys
from collections.abc import Sequence
from typing import Literal
@ -8,8 +7,6 @@ from frappe import _
from frappe.utils import strip_html_tags
from frappe.utils.data import safe_decode
_strip_html_tags = functools.lru_cache(maxsize=1024)(strip_html_tags)
def msgprint(
msg: str | Sequence[str] | Sequence[Sequence[str]],
@ -24,6 +21,7 @@ def msgprint(
wide: bool = False,
*,
realtime=False,
allow_dangerous_html=False,
) -> None:
"""Print a message to the user (via HTTP response).
Messages are sent in the `__server_messages` property in the
@ -38,9 +36,12 @@ def msgprint(
:param is_minimizable: [optional] Allow users to minimize the modal
:param wide: [optional] Show wide modal
:param realtime: Publish message immediately using websocket.
:param allow_dangerous_html: Allow arbitrary HTML in message.
"""
import inspect
from frappe.utils.html_utils import clean_html
msg = safe_decode(msg)
out = frappe._dict(message=msg)
@ -67,13 +68,21 @@ def msgprint(
else:
out.as_table = as_table
if not allow_dangerous_html:
if out.as_list:
out.message = [clean_html(cell) for cell in msg]
elif out.as_table:
out.message = [[clean_html(cell) for cell in row] for row in msg]
else:
out.message = clean_html(msg)
if sys.stdin and sys.stdin.isatty():
if out.as_list:
msg = [_strip_html_tags(cell) for cell in msg]
msg = [strip_html_tags(cell) for cell in msg]
elif out.as_table:
msg = [[_strip_html_tags(cell) for cell in row] for row in msg]
msg = [[strip_html_tags(cell) for cell in row] for row in msg]
else:
msg = _strip_html_tags(msg)
msg = strip_html_tags(msg)
if frappe.flags.print_messages and out.message:
print(f"Message: {msg}")
@ -134,6 +143,8 @@ def throw(
wide: bool = False,
as_list: bool = False,
primary_action=None,
*,
allow_dangerous_html=False,
) -> None:
"""Throw execption and show message (`msgprint`).
@ -154,6 +165,7 @@ def throw(
wide=wide,
as_list=as_list,
primary_action=primary_action,
allow_dangerous_html=allow_dangerous_html,
)

View file

@ -188,7 +188,7 @@ def get_info_via_oauth(provider: str, code: str, decoder: Callable | None = None
email_dict = next(filter(lambda x: x.get("primary"), emails))
info["email"] = email_dict.get("email")
if not (info.get("email_verified") or info.get("email")):
if not (info.get("email_verified") or get_email(info)):
frappe.throw(_("Email not verified with {0}").format(provider.title()))
return info

View file

@ -417,7 +417,7 @@ def get_context(context):
def load_list_data(self, context):
if not self.list_columns:
self.list_columns = get_in_list_view_fields(self.doc_type)
self.list_columns = get_in_list_view_fields(self.doc_type, self.name)
context.web_form_doc.list_columns = self.list_columns
def load_form_data(self, context):
@ -454,13 +454,10 @@ def get_context(context):
# For Table fields, server-side processing for meta
for field in context.web_form_doc.web_form_fields:
if field.fieldtype == "Table":
field.fields = get_in_list_view_fields(field.options)
field.fields = get_in_list_view_fields(field.options, self.name)
if field.fieldtype == "Link":
field.fieldtype = "Autocomplete"
field.options = get_link_options(
self.name, field.options, field.allow_read_on_all_link_options
)
process_link_field(field, self.name)
context.reference_doc = {}
@ -609,6 +606,14 @@ def get_context(context):
return permitted_attachments
def process_link_field(field, web_form_name):
field.fieldtype = "Autocomplete"
field.options = get_link_options(
web_form_name, field.options, getattr(field, "allow_read_on_all_link_options", False)
)
return field
def get_web_form_module(doc):
if doc.is_standard:
return get_doc_module(doc.module, doc.doctype, doc.name)
@ -795,20 +800,16 @@ def get_form_data(doctype: str, docname: str | None = None, web_form_name: str |
# For Table fields, server-side processing for meta
for field in out.web_form.web_form_fields:
if field.fieldtype == "Table":
field.fields = get_in_list_view_fields(field.options)
field.fields = get_in_list_view_fields(field.options, web_form_name)
out.update({field.fieldname: field.fields})
if field.fieldtype == "Link":
field.fieldtype = "Autocomplete"
field.options = get_link_options(
web_form_name, field.options, field.allow_read_on_all_link_options
)
process_link_field(field, web_form_name)
return out
@frappe.whitelist()
def get_in_list_view_fields(doctype: str):
def get_in_list_view_fields(doctype, web_form_name=None):
meta = frappe.get_meta(doctype)
fields = []
@ -825,20 +826,43 @@ def get_in_list_view_fields(doctype: str):
def get_field_df(fieldname):
if fieldname == "name":
return {"label": "Name", "fieldname": "name", "fieldtype": "Data"}
return meta.get_field(fieldname).as_dict()
df = meta.get_field(fieldname).as_dict()
if df.get("options") and df.get("fieldtype") == "Link":
process_link_field(df, web_form_name)
return df
return [get_field_df(f) for f in fields]
def has_link_option(fields, doctype):
for f in fields:
if f.options == doctype:
return True
if f.fieldtype == "Table" and f.options:
child_doctype = f.options
if not isinstance(child_doctype, str) or not child_doctype.strip():
continue
try:
child_table_fields = frappe.get_meta(child_doctype).fields
except Exception:
continue
for child_field in child_table_fields:
if getattr(child_field, "options", None) == doctype:
return True
return False
def get_link_options(web_form_name, doctype, allow_read_on_all_link_options=False):
web_form: WebForm = frappe.get_lazy_doc("Web Form", web_form_name)
if web_form.login_required and frappe.session.user == "Guest":
frappe.throw(_("You must be logged in to use this form."), frappe.PermissionError)
if not web_form.published or not any(f for f in web_form.web_form_fields if f.options == doctype):
if not web_form.published or not has_link_option(web_form.web_form_fields, doctype):
frappe.throw(
_("You don't have permission to access the {0} DocType.").format(doctype), frappe.PermissionError
_("You don't have permission to access the {0} DocType.").format(doctype),
frappe.PermissionError,
)
link_options, filters = [], {}

View file

@ -173,7 +173,9 @@ def get_list(
or_filters.extend(
[doctype, f, "like", "%" + txt + "%"]
for f in meta.get_search_fields()
if f == "name" or meta.get_field(f).fieldtype in ("Data", "Text", "Small Text", "Text Editor")
if f == "name"
or meta.get_field(f).fieldtype
in ("Autocomplete", "Data", "Text", "Small Text", "Text Editor")
)
else:
if isinstance(filters, dict):