From 9a25674c49e6d68fde63bf706eabdbe555bb318b Mon Sep 17 00:00:00 2001 From: "[Kesavan-001]" <[kesavanp0395@gmail.com]> Date: Wed, 11 Feb 2026 12:19:52 +0530 Subject: [PATCH 01/90] fix: Add Item Below not working in Sidebar Editor --- frappe/public/js/frappe/ui/sidebar/sidebar_item.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar_item.js b/frappe/public/js/frappe/ui/sidebar/sidebar_item.js index 760b81e193..efc3a8686c 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar_item.js +++ b/frappe/public/js/frappe/ui/sidebar/sidebar_item.js @@ -128,7 +128,8 @@ frappe.ui.sidebar_item.TypeLink = class SidebarItem { label: "Add Item Below", icon: "add", onClick: () => { - frappe.app.sidebar.editor.perform_action("add_below", me.item); + console.log("Moving"); + frappe.app.sidebar.editor.perform_action("add_item_below", me.item); }, }, { From 361ffab36a40622f0d0e993a707d914c51c3180f Mon Sep 17 00:00:00 2001 From: "[Kesavan-001]" <[kesavanp0395@gmail.com]> Date: Wed, 11 Feb 2026 12:24:20 +0530 Subject: [PATCH 02/90] fix: Add Item Below not working in Sidebar Editor --- frappe/public/js/frappe/ui/sidebar/sidebar_item.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar_item.js b/frappe/public/js/frappe/ui/sidebar/sidebar_item.js index efc3a8686c..23bc095e35 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar_item.js +++ b/frappe/public/js/frappe/ui/sidebar/sidebar_item.js @@ -128,7 +128,6 @@ frappe.ui.sidebar_item.TypeLink = class SidebarItem { label: "Add Item Below", icon: "add", onClick: () => { - console.log("Moving"); frappe.app.sidebar.editor.perform_action("add_item_below", me.item); }, }, @@ -143,7 +142,6 @@ frappe.ui.sidebar_item.TypeLink = class SidebarItem { label: "Delete", icon: "trash-2", onClick: () => { - console.log(me.item); frappe.app.sidebar.editor.perform_action("delete", me.item); }, }, From 319786cd97c812d0c18ffd75530cc820b42a9835 Mon Sep 17 00:00:00 2001 From: "[Kesavan-001]" <[kesavanp0395@gmail.com]> Date: Wed, 11 Feb 2026 12:45:45 +0530 Subject: [PATCH 03/90] fix: Add Item Below not working in Sidebar Editor --- frappe/public/js/frappe/ui/sidebar/sidebar_editor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar_editor.js b/frappe/public/js/frappe/ui/sidebar/sidebar_editor.js index f952727da7..b806c19e10 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar_editor.js +++ b/frappe/public/js/frappe/ui/sidebar/sidebar_editor.js @@ -566,7 +566,7 @@ export class SidebarEditor { this.delete_item(item_data); break; case "add_item_below": - this.edit_item(item_data); + this.add_below(item_data); break; case "duplicate": this.duplicate_item(item_data); From 7958ffdda92ef051be2cb01a13cd59f93b2faf62 Mon Sep 17 00:00:00 2001 From: "[Kesavan-001]" <[kesavanp0395@gmail.com]> Date: Wed, 11 Feb 2026 14:33:42 +0530 Subject: [PATCH 04/90] fix: Add Item Below not working in Sidebar Editor --- frappe/public/js/frappe/ui/sidebar/sidebar_editor.js | 2 +- frappe/public/js/frappe/ui/sidebar/sidebar_item.js | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar_editor.js b/frappe/public/js/frappe/ui/sidebar/sidebar_editor.js index b806c19e10..8b849382e8 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar_editor.js +++ b/frappe/public/js/frappe/ui/sidebar/sidebar_editor.js @@ -565,7 +565,7 @@ export class SidebarEditor { case "delete": this.delete_item(item_data); break; - case "add_item_below": + case "add_below": this.add_below(item_data); break; case "duplicate": diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar_item.js b/frappe/public/js/frappe/ui/sidebar/sidebar_item.js index 23bc095e35..760b81e193 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar_item.js +++ b/frappe/public/js/frappe/ui/sidebar/sidebar_item.js @@ -128,7 +128,7 @@ frappe.ui.sidebar_item.TypeLink = class SidebarItem { label: "Add Item Below", icon: "add", onClick: () => { - frappe.app.sidebar.editor.perform_action("add_item_below", me.item); + frappe.app.sidebar.editor.perform_action("add_below", me.item); }, }, { @@ -142,6 +142,7 @@ frappe.ui.sidebar_item.TypeLink = class SidebarItem { label: "Delete", icon: "trash-2", onClick: () => { + console.log(me.item); frappe.app.sidebar.editor.perform_action("delete", me.item); }, }, From 697978c8f3aa2ad192a518dc4e9d394eaf4dcb27 Mon Sep 17 00:00:00 2001 From: "stravo1@mac" Date: Fri, 13 Feb 2026 21:37:57 +0530 Subject: [PATCH 05/90] fix(website_404): cache website 404 results wrt to users fixes #37007 --- frappe/website/page_renderers/not_found_page.py | 2 +- frappe/website/path_resolver.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/website/page_renderers/not_found_page.py b/frappe/website/page_renderers/not_found_page.py index 704dca77d1..1898d7dece 100644 --- a/frappe/website/page_renderers/not_found_page.py +++ b/frappe/website/page_renderers/not_found_page.py @@ -21,7 +21,7 @@ class NotFoundPage(TemplatePage): def render(self): if self.can_cache_404(): - frappe.cache.hset("website_404", self.request_url, True) + frappe.cache.hset("website_404", f"{frappe.session.user}|{self.request_url}", True) return super().render() def can_cache_404(self): diff --git a/frappe/website/path_resolver.py b/frappe/website/path_resolver.py index 0a2e302118..929438e055 100644 --- a/frappe/website/path_resolver.py +++ b/frappe/website/path_resolver.py @@ -35,7 +35,7 @@ class PathResolver: return "desk", TemplatePage("desk", self.http_status_code) # check if the request url is in 404 list - if request.url and can_cache() and frappe.cache.hget("website_404", request.url): + if request.url and can_cache() and frappe.cache.hget("website_404", f"{frappe.session.user}|{request.url}"): return self.path, NotFoundPage(self.path) try: From a534936726adaaaa5d6b44dfacd036659b2c67df Mon Sep 17 00:00:00 2001 From: "stravo1@mac" Date: Mon, 16 Feb 2026 22:15:01 +0530 Subject: [PATCH 06/90] Revert "fix(website_404): cache website 404 results wrt to users" This reverts commit 697978c8f3aa2ad192a518dc4e9d394eaf4dcb27. --- frappe/website/page_renderers/not_found_page.py | 2 +- frappe/website/path_resolver.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/website/page_renderers/not_found_page.py b/frappe/website/page_renderers/not_found_page.py index 1898d7dece..704dca77d1 100644 --- a/frappe/website/page_renderers/not_found_page.py +++ b/frappe/website/page_renderers/not_found_page.py @@ -21,7 +21,7 @@ class NotFoundPage(TemplatePage): def render(self): if self.can_cache_404(): - frappe.cache.hset("website_404", f"{frappe.session.user}|{self.request_url}", True) + frappe.cache.hset("website_404", self.request_url, True) return super().render() def can_cache_404(self): diff --git a/frappe/website/path_resolver.py b/frappe/website/path_resolver.py index 929438e055..0a2e302118 100644 --- a/frappe/website/path_resolver.py +++ b/frappe/website/path_resolver.py @@ -35,7 +35,7 @@ class PathResolver: return "desk", TemplatePage("desk", self.http_status_code) # check if the request url is in 404 list - if request.url and can_cache() and frappe.cache.hget("website_404", f"{frappe.session.user}|{request.url}"): + if request.url and can_cache() and frappe.cache.hget("website_404", request.url): return self.path, NotFoundPage(self.path) try: From 9dcaab96eef44c54aa3e7d53ae9afc30f21357cb Mon Sep 17 00:00:00 2001 From: "stravo1@mac" Date: Mon, 16 Feb 2026 23:41:32 +0530 Subject: [PATCH 07/90] fix(website_404): skip caching 404 for pages with permission checks --- .../website/page_renderers/not_found_page.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/frappe/website/page_renderers/not_found_page.py b/frappe/website/page_renderers/not_found_page.py index 704dca77d1..9b980f5d51 100644 --- a/frappe/website/page_renderers/not_found_page.py +++ b/frappe/website/page_renderers/not_found_page.py @@ -2,6 +2,7 @@ import os from urllib.parse import urlparse import frappe +from frappe.website.page_renderers.document_page import _find_matching_document_webview from frappe.website.page_renderers.template_page import TemplatePage from frappe.website.utils import can_cache @@ -26,10 +27,26 @@ class NotFoundPage(TemplatePage): def can_cache_404(self): # do not cache 404 for custom homepages - return can_cache() and self.request_url and not self.is_custom_home_page() + # also skip caching docs with website permission checks (access is dynamic) + return ( + can_cache() + and self.request_url + and not self.is_custom_home_page() + and not self.has_website_permission_check() + ) def is_custom_home_page(self): url_parts = urlparse(self.request_url) request_url = os.path.splitext(url_parts.path)[0] request_path = os.path.splitext(self.request_path)[0] return request_url in HOMEPAGE_PATHS and request_path not in HOMEPAGE_PATHS + + def has_website_permission_check(self): + request_path = os.path.splitext(self.request_path)[0] + if not (document := _find_matching_document_webview(request_path)): + return False + doctype, docname = document + doc = frappe.get_cached_doc(doctype, docname) + return hasattr(doc, "has_website_permission") or bool( + frappe.get_hooks("has_website_permission", {}).get(doctype) + ) From b781fa4ee34bfe66651bdcd35ef1dcbac6d9fd8c Mon Sep 17 00:00:00 2001 From: Sumit Jain Date: Wed, 18 Feb 2026 14:04:58 +0530 Subject: [PATCH 08/90] fix: Copy `attached_to_field` and `folder` when amending documents --- frappe/core/doctype/file/test_file.py | 71 +++++++++++++++++++++++++++ frappe/desk/form/load.py | 2 +- frappe/model/document.py | 3 +- 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index 128d74dae8..fb14b30075 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -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}) diff --git a/frappe/desk/form/load.py b/frappe/desk/form/load.py index 87f1954093..1e4af10aea 100644 --- a/frappe/desk/form/load.py +++ b/frappe/desk/form/load.py @@ -182,7 +182,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}, ) diff --git a/frappe/model/document.py b/frappe/model/document.py index 54e7795b62..30f65d9d0e 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -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, } ) From 3de1ed26a45434c7c87a9249b528a4c2d2a1fd77 Mon Sep 17 00:00:00 2001 From: Gursheen Anand Date: Wed, 18 Feb 2026 14:54:52 +0530 Subject: [PATCH 09/90] fix: use autocomplete for showing child table links in web form --- frappe/website/doctype/web_form/web_form.py | 37 ++++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/frappe/website/doctype/web_form/web_form.py b/frappe/website/doctype/web_form/web_form.py index bd12631577..1939271fef 100644 --- a/frappe/website/doctype/web_form/web_form.py +++ b/frappe/website/doctype/web_form/web_form.py @@ -416,7 +416,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): @@ -453,7 +453,7 @@ 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" @@ -794,7 +794,7 @@ 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": @@ -807,7 +807,7 @@ def get_form_data(doctype: str, docname: str | None = None, web_form_name: str | @frappe.whitelist() -def get_in_list_view_fields(doctype): +def get_in_list_view_fields(doctype, web_form_name=None): meta = frappe.get_meta(doctype) fields = [] @@ -824,21 +824,40 @@ def get_in_list_view_fields(doctype): 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": + df["fieldtype"] = "Autocomplete" + df["options"] = get_link_options( + web_form_name, + doctype=df.options, + allow_read_on_all_link_options=df.get("allow_read_on_all_link_options", False), + ) + 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 hasattr(f, "fields") and isinstance(f.fields, list): + if has_link_option(f.fields, 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): - frappe.throw( - _("You don't have permission to access the {0} DocType.").format(doctype), frappe.PermissionError - ) + 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, + ) link_options, filters = [], {} if web_form.login_required and not allow_read_on_all_link_options: From 3d4a14f7b12daab40b41532da9147306f1a146ed Mon Sep 17 00:00:00 2001 From: Gursheen Anand Date: Wed, 18 Feb 2026 20:03:38 +0530 Subject: [PATCH 10/90] refactor: cleanup process link field --- frappe/website/doctype/web_form/web_form.py | 36 ++++++++++----------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/frappe/website/doctype/web_form/web_form.py b/frappe/website/doctype/web_form/web_form.py index 1939271fef..cf480a6d16 100644 --- a/frappe/website/doctype/web_form/web_form.py +++ b/frappe/website/doctype/web_form/web_form.py @@ -456,10 +456,7 @@ def get_context(context): 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 = {} @@ -608,6 +605,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) @@ -798,10 +803,7 @@ def get_form_data(doctype: str, docname: str | None = None, web_form_name: str | 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 @@ -824,14 +826,10 @@ def get_in_list_view_fields(doctype, web_form_name=None): def get_field_df(fieldname): if fieldname == "name": return {"label": "Name", "fieldname": "name", "fieldtype": "Data"} + df = meta.get_field(fieldname).as_dict() if df.get("options") and df.get("fieldtype") == "Link": - df["fieldtype"] = "Autocomplete" - df["options"] = get_link_options( - web_form_name, - doctype=df.options, - allow_read_on_all_link_options=df.get("allow_read_on_all_link_options", False), - ) + process_link_field(df, web_form_name) return df return [get_field_df(f) for f in fields] @@ -853,11 +851,11 @@ def get_link_options(web_form_name, doctype, allow_read_on_all_link_options=Fals 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 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, - ) + 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, + ) link_options, filters = [], {} if web_form.login_required and not allow_read_on_all_link_options: From c97cc1a47164b5bac238b0c25c4bc1b60da76dce Mon Sep 17 00:00:00 2001 From: Gursheen Anand Date: Thu, 19 Feb 2026 13:03:21 +0530 Subject: [PATCH 11/90] chore: remove unused whitelist decorator --- frappe/website/doctype/web_form/web_form.py | 1 - 1 file changed, 1 deletion(-) diff --git a/frappe/website/doctype/web_form/web_form.py b/frappe/website/doctype/web_form/web_form.py index cf480a6d16..629fa58053 100644 --- a/frappe/website/doctype/web_form/web_form.py +++ b/frappe/website/doctype/web_form/web_form.py @@ -808,7 +808,6 @@ def get_form_data(doctype: str, docname: str | None = None, web_form_name: str | return out -@frappe.whitelist() def get_in_list_view_fields(doctype, web_form_name=None): meta = frappe.get_meta(doctype) fields = [] From 356bcbfe7b15c3647eb72731aa12548eff37f348 Mon Sep 17 00:00:00 2001 From: Sumit Jain Date: Thu, 19 Feb 2026 20:44:52 +0530 Subject: [PATCH 12/90] fix(search): add Autocomplete field type to search filters --- frappe/desk/search.py | 1 + frappe/www/list.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/frappe/desk/search.py b/frappe/desk/search.py index fb6b9b249f..7b5c65dca4 100644 --- a/frappe/desk/search.py +++ b/frappe/desk/search.py @@ -156,6 +156,7 @@ def search_widget( # build from doctype if txt: field_types = { + "Autocomplete", "Data", "Text", "Small Text", diff --git a/frappe/www/list.py b/frappe/www/list.py index 4feb9a6943..d912efe9fe 100644 --- a/frappe/www/list.py +++ b/frappe/www/list.py @@ -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): From a879b8dd65ea309a787753462b2adf7049c168c3 Mon Sep 17 00:00:00 2001 From: prathameshkurunkar7 Date: Fri, 20 Feb 2026 12:54:03 +0530 Subject: [PATCH 13/90] fix: prevent translation of icon class names for workflow states in select options --- frappe/translate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frappe/translate.py b/frappe/translate.py index 64c80e7591..c54b8c7dce 100644 --- a/frappe/translate.py +++ b/frappe/translate.py @@ -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: From 2547b710cf4a418e8bc2562aeb0f6736ed6d1a35 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Fri, 20 Feb 2026 14:43:49 +0530 Subject: [PATCH 14/90] fix: validate message size against SMTP SIZE limit --- .../email/doctype/email_queue/email_queue.py | 62 ++++++++++++++----- 1 file changed, 48 insertions(+), 14 deletions(-) diff --git a/frappe/email/doctype/email_queue/email_queue.py b/frappe/email/doctype/email_queue/email_queue.py index 0f021cf055..b0576dfe38 100644 --- a/frappe/email/doctype/email_queue/email_queue.py +++ b/frappe/email/doctype/email_queue/email_queue.py @@ -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): From 14159b72f3c03ae3470db5938f4d41b372732d16 Mon Sep 17 00:00:00 2001 From: KerollesFathy Date: Sat, 21 Feb 2026 19:16:19 +0000 Subject: [PATCH 15/90] fix: avoid append None to pages list --- frappe/desk/desktop.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frappe/desk/desktop.py b/frappe/desk/desktop.py index a2152f63d5..b852102613 100644 --- a/frappe/desk/desktop.py +++ b/frappe/desk/desktop.py @@ -490,7 +490,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, From aed7c5d5893284359b79ca2467796cd5fd147a00 Mon Sep 17 00:00:00 2001 From: Gursheen Anand Date: Sun, 22 Feb 2026 22:52:26 +0530 Subject: [PATCH 16/90] fix: resolve receiver by role for Administrator correctly in notifications --- frappe/core/doctype/role/role.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frappe/core/doctype/role/role.py b/frappe/core/doctype/role/role.py index 5a161f1b97..b0e0f087df 100644 --- a/frappe/core/doctype/role/role.py +++ b/frappe/core/doctype/role/role.py @@ -89,6 +89,10 @@ class Role(Document): def get_info_based_on_role(role, field="email", ignore_permissions=False): """Get information of all users that have been assigned this role""" + if role == "Administrator": + user = frappe.db.get_value("User", "Administrator", field) + return [user] if user else [] + users = frappe.get_list( "Has Role", filters={"role": role, "parenttype": "User"}, From 5640551824279b89094140fca4496b508073c5c7 Mon Sep 17 00:00:00 2001 From: Gursheen Anand Date: Sun, 22 Feb 2026 22:55:35 +0530 Subject: [PATCH 17/90] chore: add get_info helper comment --- frappe/core/doctype/role/role.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frappe/core/doctype/role/role.py b/frappe/core/doctype/role/role.py index b0e0f087df..fbaabb4579 100644 --- a/frappe/core/doctype/role/role.py +++ b/frappe/core/doctype/role/role.py @@ -89,6 +89,8 @@ class Role(Document): def get_info_based_on_role(role, field="email", ignore_permissions=False): """Get information of all users that have been assigned this role""" + # Administrator is a superuser account, not a typical role with assigned users + # so we resolve it directly to the Administrator user if role == "Administrator": user = frappe.db.get_value("User", "Administrator", field) return [user] if user else [] From cc7474abee5c1936fefe25103e25e8d2fd6993b2 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Mon, 23 Feb 2026 09:30:59 +0530 Subject: [PATCH 18/90] fix: ignore attachments over the `attachment_limit` --- frappe/email/receive.py | 58 +++++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/frappe/email/receive.py b/frappe/email/receive.py index ed941a75ad..389ed9f97b 100644 --- a/frappe/email/receive.py +++ b/frappe/email/receive.py @@ -559,36 +559,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 +642,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 From a94312b30f54f76c6dab3c1c84205899bad8e2df Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 23 Feb 2026 11:43:25 +0530 Subject: [PATCH 19/90] fix: Handle missing default workspace (#37359) --- frappe/utils/user.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/frappe/utils/user.py b/frappe/utils/user.py index 8ebd9b16c8..231d6a6e10 100644 --- a/frappe/utils/user.py +++ b/frappe/utils/user.py @@ -244,12 +244,15 @@ class UserPermissions: self.build_permissions() if d.get("default_workspace"): - workspace = frappe.get_cached_doc("Workspace", d.default_workspace) - d.default_workspace = { - "name": workspace.name, - "public": workspace.public, - "title": workspace.title, - } + try: + workspace = frappe.get_cached_doc("Workspace", d.default_workspace) + d.default_workspace = { + "name": workspace.name, + "public": workspace.public, + "title": workspace.title, + } + except frappe.DoesNotExistError: + d.default_workspace = None d.name = self.name d.onboarding_status = frappe.parse_json(d.onboarding_status) From dc11227b733eb2eeedd6f36fd42fff5ecfefca1e Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Mon, 23 Feb 2026 11:45:08 +0530 Subject: [PATCH 20/90] fix(IMAP): safely handle `UID FETCH` response --- frappe/email/receive.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/frappe/email/receive.py b/frappe/email/receive.py index ed941a75ad..fdf257f439 100644 --- a/frappe/email/receive.py +++ b/frappe/email/receive.py @@ -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])) From b98396f4f407e1a2e5d66b6c8b230f3ec7de1903 Mon Sep 17 00:00:00 2001 From: Shrihari Mahabal Date: Mon, 23 Feb 2026 11:54:09 +0530 Subject: [PATCH 21/90] fix: fix: add doc button for long doctype name --- frappe/public/js/frappe/list/list_view.js | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js index d9c6514556..6473db896e 100644 --- a/frappe/public/js/frappe/list/list_view.js +++ b/frappe/public/js/frappe/list/list_view.js @@ -291,8 +291,9 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { set_primary_action() { if (this.can_create && !frappe.boot.read_only) { const doctype_name = __(frappe.router.doctype_layout) || __(this.doctype); + const full_label = __("Add {0}", [doctype_name], "Primary action in list view"); const create_button = this.page.set_primary_action( - __("Add {0}", [doctype_name], "Primary action in list view"), + full_label, () => { if (this.settings.primary_action) { this.settings.primary_action(); @@ -304,12 +305,32 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { ); if (frappe.is_mobile()) { create_button.append(__("Add")); + } else { + this._trim_primary_action_if_overflow(create_button, full_label); } } else { this.page.clear_primary_action(); } } + _trim_primary_action_if_overflow(btn, full_label) { + const container = this.page.wrapper.find(".page-head-content")[0]; + if (!container || !btn[0]) return; + const containerRect = container.getBoundingClientRect(); + const btnRect = btn[0].getBoundingClientRect(); + if (btnRect.right > containerRect.right) { + const short_label = __("Add"); + btn.attr("title", full_label) + .tooltip({ delay: { show: 600, hide: 100 }, trigger: "hover" }) + .html( + `${frappe.utils.icon( + "add", + "xs" + )} ` + ); + } + } + make_new_doc() { const doctype = this.doctype; const options = {}; From c859375d2c32372f8e97616cac7beea696124c95 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Fri, 20 Feb 2026 20:55:50 +0530 Subject: [PATCH 22/90] feat: module onboarding --- frappe/boot.py | 5 +- frappe/desk/desktop.py | 95 +++--- .../module_onboarding/module_onboarding.json | 28 +- .../module_onboarding/module_onboarding.py | 3 - .../onboarding_step/onboarding_step.json | 5 +- .../workspace_sidebar/workspace_sidebar.json | 9 +- .../workspace_sidebar/workspace_sidebar.py | 1 + frappe/public/js/desk.bundle.js | 1 + frappe/public/js/frappe/form/quick_entry.js | 7 + .../public/js/frappe/ui/sidebar/sidebar.html | 7 + frappe/public/js/frappe/ui/sidebar/sidebar.js | 39 +++ .../ui/user_onboarding/OnboardingPanel.vue | 313 ++++++++++++++++++ .../user_onboarding/user_onboarding.bundle.js | 221 +++++++++++++ frappe/public/scss/desk/sidebar.scss | 15 + 14 files changed, 677 insertions(+), 72 deletions(-) create mode 100644 frappe/public/js/frappe/ui/user_onboarding/OnboardingPanel.vue create mode 100644 frappe/public/js/frappe/ui/user_onboarding/user_onboarding.bundle.js diff --git a/frappe/boot.py b/frappe/boot.py index 3eeb7a868a..5042693b77 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -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, } diff --git a/frappe/desk/desktop.py b/frappe/desk/desktop.py index a2152f63d5..e4fdbe98e0 100644 --- a/frappe/desk/desktop.py +++ b/frappe/desk/desktop.py @@ -51,14 +51,9 @@ class Workspace: self.allowed_pages = get_allowed_pages(cache=True) self.allowed_reports = get_allowed_reports(cache=True) + self.onboarding_list = self.get_onboarding_list() if not minimal: - if self.doc.content: - 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 = ( frappe.cache.get_value("domain_restricted_doctypes") or build_domain_restricted_doctype_cache() @@ -67,6 +62,14 @@ class Workspace: frappe.cache.get_value("domain_restricted_pages") or build_domain_restricted_page_cache() ) + def get_onboarding_list(self): + return frappe.get_all( + "Module Onboarding", + filters={"is_complete": 0, "module": self.page_name}, + pluck="name", + order_by="creation", + ) + def is_permitted(self): """Return true if `Has Role` is not set or the user is allowed.""" from frappe.utils import has_common @@ -157,7 +160,6 @@ 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.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 +317,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 = [] @@ -400,7 +370,6 @@ def get_desktop_page(page: str): "charts": workspace.charts, "shortcuts": workspace.shortcuts, "cards": workspace.cards, - "onboardings": workspace.onboardings, "quick_lists": workspace.quick_lists, "number_cards": workspace.number_cards, "custom_blocks": workspace.custom_blocks, @@ -681,7 +650,7 @@ def prepare_widget(config, doctype, parentfield): @frappe.whitelist() -def update_onboarding_step(name: str | int, field: str, value: int | str): +def update_onboarding_step(name: str, field: str, value: any): """Update status of onboaridng step Args: @@ -700,3 +669,47 @@ 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 [] + + item = { + "label": _(module), + "title": _(onboarding_doc.title), + "subtitle": _(onboarding_doc.subtitle), + "success": _(onboarding_doc.success_message), + "docs_url": onboarding_doc.documentation_url, + "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") diff --git a/frappe/desk/doctype/module_onboarding/module_onboarding.json b/frappe/desk/doctype/module_onboarding/module_onboarding.json index 4adda25c8e..099c75083e 100644 --- a/frappe/desk/doctype/module_onboarding/module_onboarding.json +++ b/frappe/desk/doctype/module_onboarding/module_onboarding.json @@ -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 -} \ No newline at end of file +} diff --git a/frappe/desk/doctype/module_onboarding/module_onboarding.py b/frappe/desk/doctype/module_onboarding/module_onboarding.py index 3675d08a30..88c67fcbc1 100644 --- a/frappe/desk/doctype/module_onboarding/module_onboarding.py +++ b/frappe/desk/doctype/module_onboarding/module_onboarding.py @@ -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 diff --git a/frappe/desk/doctype/onboarding_step/onboarding_step.json b/frappe/desk/doctype/onboarding_step/onboarding_step.json index b16ee581e4..45efd216ff 100644 --- a/frappe/desk/doctype/onboarding_step/onboarding_step.json +++ b/frappe/desk/doctype/onboarding_step/onboarding_step.json @@ -217,7 +217,7 @@ } ], "links": [], - "modified": "2024-03-23 16:03:33.078443", + "modified": "2026-02-21 08:37:30.532549", "modified_by": "Administrator", "module": "Desk", "name": "Onboarding Step", @@ -248,8 +248,9 @@ ], "quick_entry": 1, "read_only": 1, + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.json b/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.json index 371c6564df..9b14ba8d4a 100644 --- a/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.json +++ b/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.json @@ -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", diff --git a/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.py b/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.py index b0fe4b5399..8246676e78 100644 --- a/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.py +++ b/frappe/desk/doctype/workspace_sidebar/workspace_sidebar.py @@ -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 diff --git a/frappe/public/js/desk.bundle.js b/frappe/public/js/desk.bundle.js index 071d0cba84..945a4e5ee2 100644 --- a/frappe/public/js/desk.bundle.js +++ b/frappe/public/js/desk.bundle.js @@ -109,6 +109,7 @@ import "./frappe/utils/dashboard_utils.js"; import "./frappe/ui/chart.js"; import "./frappe/ui/datatable.js"; import "./frappe/ui/driver.js"; +import "./frappe/ui/user_onboarding/user_onboarding.bundle.js"; import "./frappe/scanner"; import "./frappe/ui/address_autocomplete/autocomplete_dialog.js"; diff --git a/frappe/public/js/frappe/form/quick_entry.js b/frappe/public/js/frappe/form/quick_entry.js index a2583ac15f..2a051d02fe 100644 --- a/frappe/public/js/frappe/form/quick_entry.js +++ b/frappe/public/js/frappe/form/quick_entry.js @@ -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( { diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar.html b/frappe/public/js/frappe/ui/sidebar/sidebar.html index 226e968608..7e0d700800 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar.html +++ b/frappe/public/js/frappe/ui/sidebar/sidebar.html @@ -40,6 +40,12 @@ Save +

+ + {%= frappe.utils.icon("getting-started" , "sm", "", "", "text-ink-gray-7 current-color", true)%} + {%= __("Continue Onboarding") %} + +

{%= frappe.utils.icon("panel-right-open" , "sm", "", "", "text-ink-gray-7 current-color", true)%} {%= __("Collapse") %} @@ -84,5 +90,6 @@
+
diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar.js b/frappe/public/js/frappe/ui/sidebar/sidebar.js index 65b373a58a..281baa107f 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar.js +++ b/frappe/public/js/frappe/ui/sidebar/sidebar.js @@ -78,6 +78,40 @@ frappe.ui.Sidebar = class Sidebar { } } + setup_onboarding() { + let me = this; + this.$onboarding = this.wrapper.find(".user_onboarding"); + this.$onboarding.empty(); + this.wrapper.find(".onboarding-sidebar").removeClass("hidden"); + + if (this.sidebar_data && this.sidebar_data.module_onboarding) { + return frappe + .call({ + method: "frappe.desk.desktop.get_onboarding_data", + args: { + // send sorted min requirements to increase chance of cache hit + module: this.sidebar_data.module_onboarding, + }, + type: "GET", + }) + .then((data) => { + if (data.message?.length > 0) { + let onboarding_data = data.message[0]; + me.onboarding_widget = 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; @@ -109,6 +143,11 @@ 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(() => { + this.setup_onboarding(); + }); } add_card(card) { if (this.cards && this.cards.find((i) => i.title === card.title)) return; diff --git a/frappe/public/js/frappe/ui/user_onboarding/OnboardingPanel.vue b/frappe/public/js/frappe/ui/user_onboarding/OnboardingPanel.vue new file mode 100644 index 0000000000..923a0d7f13 --- /dev/null +++ b/frappe/public/js/frappe/ui/user_onboarding/OnboardingPanel.vue @@ -0,0 +1,313 @@ + + + diff --git a/frappe/public/js/frappe/ui/user_onboarding/user_onboarding.bundle.js b/frappe/public/js/frappe/ui/user_onboarding/user_onboarding.bundle.js new file mode 100644 index 0000000000..c167d6df4e --- /dev/null +++ b/frappe/public/js/frappe/ui/user_onboarding/user_onboarding.bundle.js @@ -0,0 +1,221 @@ +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(); + } + + init() { + addStyles(); + + let title = this.title || __("Welcome to Frappe!"); + let onboarding_checklist = this.steps || []; + let header_icon = this.header_icon; + + 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), + }); + }, + }); + + 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-left { + display: flex; + align-items: center; + gap: 8px; + } + + .onb-header-icon { + width: 24px; + height: 24px; + } + + .onb-header-title { + margin: 0; + font-size: 20px; + font-weight: 600; + } + + .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; /* takes remaining space */ + min-width: 0; /* allows truncation */ + } + + .onb-step-title { + display: flex; + align-items: center; + gap: 8px; + } + + .onb-step-icon { + margin-bottom: 2px; + align-items: center; + } + + .onb-step-text { + white-space: nowrap; + margin-top: 2px; + text-align: left; + font-size: 14px; + } + + .onb-progress { + height: 6px; + background: #eee; + border-radius: 4px; + margin: 12px 0; + } + + .onb-progress-label { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 13px; + color: #6b7280; + margin-top: 6px; + } + + .onb-skip { + color: #6b7280; + cursor: pointer; + font-weight: 500; + } + + .onb-skip:hover { + color: #111827; + } + + .onboarding-progress-bar { + height: 100%; + background: #ffcd78; + border-radius: 4px; + } + + .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-header-logo { + display: flex; + align-items: center; + gap: 8px; + } + + .onb-header-logo img { + width: 24px; + height: 24px; + } + + .onb-header-logo h4 { + margin: 0; + white-space: nowrap; + } + `; + + document.head.appendChild(style); +} + +frappe.provide("frappe.ui"); +frappe.ui.UserOnboarding = UserOnboarding; +export default UserOnboarding; diff --git a/frappe/public/scss/desk/sidebar.scss b/frappe/public/scss/desk/sidebar.scss index 0f6740c771..739e2c3b16 100644 --- a/frappe/public/scss/desk/sidebar.scss +++ b/frappe/public/scss/desk/sidebar.scss @@ -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); From 04b2a433b6d7a5f25353470cb0ebfba6d2cbd9c9 Mon Sep 17 00:00:00 2001 From: Akhil Narang Date: Mon, 23 Feb 2026 12:21:26 +0530 Subject: [PATCH 23/90] fix(db_query): relax some restrictions (#37314) Allow valid identifiers Signed-off-by: Akhil Narang --- frappe/model/db_query.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index dfb7681059..eecacbd790 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -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() From 57f673425587349164328809178603d0e4fad7c5 Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Mon, 23 Feb 2026 12:59:57 +0530 Subject: [PATCH 24/90] fix(sync): remove `is_standard` notifications records on deletion --- frappe/model/sync.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frappe/model/sync.py b/frappe/model/sync.py index 12718d813a..b28d3de942 100644 --- a/frappe/model/sync.py +++ b/frappe/model/sync.py @@ -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) From 838dffd5029476a11d85ddee5e287af787826d53 Mon Sep 17 00:00:00 2001 From: Akhil Narang Date: Mon, 23 Feb 2026 13:12:10 +0530 Subject: [PATCH 25/90] fix(oauth): use get_email() helper in OAuth email validation (#37373) Co-authored-by: Kaushal Shriwas --- frappe/utils/oauth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/utils/oauth.py b/frappe/utils/oauth.py index d35c8ac2b2..cd08751aac 100644 --- a/frappe/utils/oauth.py +++ b/frappe/utils/oauth.py @@ -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 From b7214c6957c89a73be7b656fdb0e89d0b8a6b82e Mon Sep 17 00:00:00 2001 From: Priyal208 <135015851+Priyal208@users.noreply.github.com> Date: Mon, 23 Feb 2026 14:15:43 +0530 Subject: [PATCH 26/90] fix: report and notification folder deletion on deletion from ui (#37190) Co-authored-by: Sagar Vora <16315650+sagarvora@users.noreply.github.com> --- frappe/core/doctype/report/report.py | 6 ++++++ .../email/doctype/notification/notification.py | 18 +++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/frappe/core/doctype/report/report.py b/frappe/core/doctype/report/report.py index 19be3fe1a4..6d890a6736 100644 --- a/frappe/core/doctype/report/report.py +++ b/frappe/core/doctype/report/report.py @@ -102,6 +102,12 @@ class Report(Document): frappe.throw(_("You are not allowed to delete Standard Report")) delete_custom_role("report", self.name) + def after_delete(self): + from frappe.modules.export_file import delete_folder + + if not frappe.flags.in_test and frappe.conf.developer_mode: + delete_folder(self.module, "Report", self.name) + def get_permission_log_options(self, event=None): return {"fields": ["roles"]} diff --git a/frappe/email/doctype/notification/notification.py b/frappe/email/doctype/notification/notification.py index 875796c395..04fc726272 100644 --- a/frappe/email/doctype/notification/notification.py +++ b/frappe/email/doctype/notification/notification.py @@ -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,24 @@ def get_context(context): self.message = self.get_template(md_as_html=True) def on_trash(self): + # Prevent deletion of standard notifications outside developer mode to avoid restoration during migration + if ( + self.is_standard + and 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.") + ) clear_notification_cache() + def after_delete(self): + from frappe.modules.export_file import delete_folder + + if not frappe.flags.in_test and frappe.conf.developer_mode: + delete_folder(self.module, "Notification", self.name) + def clear_notification_cache(): frappe.client_cache.delete_keys("notifications::") From d8c1e9400557e31e34cedad8da8787c861950246 Mon Sep 17 00:00:00 2001 From: sokumon Date: Mon, 23 Feb 2026 14:17:05 +0530 Subject: [PATCH 27/90] fix: notification panel shadow --- frappe/public/scss/desk/notification.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/scss/desk/notification.scss b/frappe/public/scss/desk/notification.scss index ba919797cc..c5ef9ea68f 100644 --- a/frappe/public/scss/desk/notification.scss +++ b/frappe/public/scss/desk/notification.scss @@ -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; From 29bbb183a1c01577006b61e4c8eda192fe18fb34 Mon Sep 17 00:00:00 2001 From: Sumit Jain <59503001+sumitjain236@users.noreply.github.com> Date: Mon, 23 Feb 2026 14:36:33 +0530 Subject: [PATCH 28/90] fix: duplicate last quoted messagg (#37240) --- frappe/public/js/frappe/views/communication.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/frappe/public/js/frappe/views/communication.js b/frappe/public/js/frappe/views/communication.js index 34871f275b..bfa1706f11 100755 --- a/frappe/public/js/frappe/views/communication.js +++ b/frappe/public/js/frappe/views/communication.js @@ -5,6 +5,8 @@ import localforage from "localforage"; frappe.last_edited_communication = {}; const separator_element = "
---
"; +// Quill uses

---

; 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

---

) + 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); From 9a3d173b62159ff395afbd4b3278b9cdad5de6cf Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Mon, 23 Feb 2026 15:40:14 +0530 Subject: [PATCH 29/90] fix: prevent `UTF-8` corruption in text attachments --- frappe/email/email_body.py | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/frappe/email/email_body.py b/frappe/email/email_body.py index c6be796b49..37729812c1 100755 --- a/frappe/email/email_body.py +++ b/frappe/email/email_body.py @@ -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}>") From 657b1eb50c35343e3a3a519ebf6cd5958fea8da4 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 23 Feb 2026 17:19:15 +0530 Subject: [PATCH 30/90] fix!: Restrict allowed HTML in msgprints (#37399) Co-authored-by: AarDG10 --- frappe/tests/test_utils.py | 16 ++++++++++++++++ frappe/utils/messages.py | 24 ++++++++++++++++++------ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/frappe/tests/test_utils.py b/frappe/tests/test_utils.py index 496083de18..3093fba8b0 100644 --- a/frappe/tests/test_utils.py +++ b/frappe/tests/test_utils.py @@ -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: ") + message = frappe.get_message_log()[-1] + + self.assertNotIn("script", message.message) + + frappe.msgprint("
  • abc
") + message = frappe.get_message_log()[-1] + self.assertIn("