diff --git a/cypress/integration/web_form.js b/cypress/integration/web_form.js index 5f1c4474a5..36f65a80bc 100644 --- a/cypress/integration/web_form.js +++ b/cypress/integration/web_form.js @@ -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", () => { 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/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 0b5557202e..a5f0cc3d52 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -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}" diff --git a/frappe/core/doctype/report/report.py b/frappe/core/doctype/report/report.py index 19be3fe1a4..828a28a075 100644 --- a/frappe/core/doctype/report/report.py +++ b/frappe/core/doctype/report/report.py @@ -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"]} diff --git a/frappe/core/doctype/role/role.py b/frappe/core/doctype/role/role.py index 5a161f1b97..fbaabb4579 100644 --- a/frappe/core/doctype/role/role.py +++ b/frappe/core/doctype/role/role.py @@ -89,6 +89,12 @@ 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 [] + users = frappe.get_list( "Has Role", filters={"role": role, "parenttype": "User"}, diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index cebd245531..b1831d59f9 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -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: diff --git a/frappe/desk/desktop.py b/frappe/desk/desktop.py index a2152f63d5..db51ecafbf 100644 --- a/frappe/desk/desktop.py +++ b/frappe/desk/desktop.py @@ -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,53 @@ 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), + "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..7037c334bf 100644 --- a/frappe/desk/doctype/onboarding_step/onboarding_step.json +++ b/frappe/desk/doctype/onboarding_step/onboarding_step.json @@ -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 -} \ No newline at end of file +} diff --git a/frappe/desk/doctype/onboarding_step/onboarding_step.py b/frappe/desk/doctype/onboarding_step/onboarding_step.py index e12e847244..6a6a8b9ea1 100644 --- a/frappe/desk/doctype/onboarding_step/onboarding_step.py +++ b/frappe/desk/doctype/onboarding_step/onboarding_step.py @@ -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 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/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/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): diff --git a/frappe/email/doctype/notification/notification.py b/frappe/email/doctype/notification/notification.py index 875796c395..3a800c6311 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,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::") 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}>") diff --git a/frappe/email/receive.py b/frappe/email/receive.py index ed941a75ad..81e539d706 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])) @@ -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 diff --git a/frappe/integrations/doctype/ldap_settings/ldap_settings.py b/frappe/integrations/doctype/ldap_settings/ldap_settings.py index bb84b7241f..e010f93b79 100644 --- a/frappe/integrations/doctype/ldap_settings/ldap_settings.py +++ b/frappe/integrations/doctype/ldap_settings/ldap_settings.py @@ -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.")) diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index af11b43948..c4930e2048 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -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 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() 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) diff --git a/frappe/public/js/desk.bundle.js b/frappe/public/js/desk.bundle.js index 071d0cba84..b5dad9619f 100644 --- a/frappe/public/js/desk.bundle.js +++ b/frappe/public/js/desk.bundle.js @@ -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"; diff --git a/frappe/public/js/frappe/form/controls/attach.js b/frappe/public/js/frappe/form/controls/attach.js index 3922c06fc6..8b41663156 100644 --- a/frappe/public/js/frappe/form/controls/attach.js +++ b/frappe/public/js/frappe/form/controls/attach.js @@ -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); } } 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..ef5e6d630a 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("user-check" , "sm", "", "", "text-ink-gray-7 current-color", true)%} + {%= __("Getting started") %} + +

{%= 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..84f4665ffd 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar.js +++ b/frappe/public/js/frappe/ui/sidebar/sidebar.js @@ -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; diff --git a/frappe/public/js/frappe/ui/sidebar/sidebar_editor.js b/frappe/public/js/frappe/ui/sidebar/sidebar_editor.js index f952727da7..8b849382e8 100644 --- a/frappe/public/js/frappe/ui/sidebar/sidebar_editor.js +++ b/frappe/public/js/frappe/ui/sidebar/sidebar_editor.js @@ -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); 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..68150e9744 --- /dev/null +++ b/frappe/public/js/frappe/ui/user_onboarding/OnboardingPanel.vue @@ -0,0 +1,352 @@ + + + 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..86a994519e --- /dev/null +++ b/frappe/public/js/frappe/ui/user_onboarding/user_onboarding.bundle.js @@ -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; 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); diff --git a/frappe/public/scss/desk/driver.scss b/frappe/public/scss/desk/driver.scss index 9a2636e4a8..4db25abcbe 100644 --- a/frappe/public/scss/desk/driver.scss +++ b/frappe/public/scss/desk/driver.scss @@ -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 { 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; 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); 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("") + message = frappe.get_message_log()[-1] + self.assertIn("