Merge branch 'develop' into redirect-post-logout
This commit is contained in:
commit
3d30b7e92f
42 changed files with 1072 additions and 190 deletions
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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"]}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -156,6 +156,7 @@ def search_widget(
|
|||
# build from doctype
|
||||
if txt:
|
||||
field_types = {
|
||||
"Autocomplete",
|
||||
"Data",
|
||||
"Text",
|
||||
"Small Text",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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::")
|
||||
|
|
|
|||
|
|
@ -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}>")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -40,6 +40,12 @@
|
|||
Save
|
||||
</button>
|
||||
</div>
|
||||
<p>
|
||||
<a class="onboarding-sidebar">
|
||||
{%= frappe.utils.icon("user-check" , "sm", "", "", "text-ink-gray-7 current-color", true)%}
|
||||
<span> {%= __("Getting started") %} </span>
|
||||
</a>
|
||||
</p>
|
||||
<a class="collapse-sidebar-link">
|
||||
{%= frappe.utils.icon("panel-right-open" , "sm", "", "", "text-ink-gray-7 current-color", true)%}
|
||||
<span> {%= __("Collapse") %} </span>
|
||||
|
|
@ -84,5 +90,6 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="overlay" style="z-index: 1021;"></div>
|
||||
<div class="user-onboarding"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
352
frappe/public/js/frappe/ui/user_onboarding/OnboardingPanel.vue
Normal file
352
frappe/public/js/frappe/ui/user_onboarding/OnboardingPanel.vue
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: "Welcome",
|
||||
},
|
||||
steps: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
minimizeIcon: {
|
||||
type: String,
|
||||
default: "—",
|
||||
},
|
||||
closeIcon: {
|
||||
type: String,
|
||||
default: "✕",
|
||||
},
|
||||
headerIcon: {
|
||||
type: String,
|
||||
default: "👋",
|
||||
},
|
||||
checklistIcon: {
|
||||
type: String,
|
||||
default: "✔",
|
||||
},
|
||||
completeChecklistIcon: {
|
||||
type: String,
|
||||
default: "✔",
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "skip"]);
|
||||
|
||||
const collapsed = ref(false);
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit("update:modelValue", val),
|
||||
});
|
||||
|
||||
let skippAll = false;
|
||||
|
||||
const completedCount = computed(
|
||||
() => props.steps.filter((step) => step.is_complete || step.is_skipped).length
|
||||
);
|
||||
|
||||
const progress = computed(() => {
|
||||
if (!props.steps.length) return 0;
|
||||
return Math.round((completedCount.value / props.steps.length) * 100);
|
||||
});
|
||||
|
||||
function close() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
function toggleCollapse() {
|
||||
collapsed.value = !collapsed.value;
|
||||
}
|
||||
|
||||
function skipAll(skips) {
|
||||
skips.forEach((step) => {
|
||||
if (!step.is_complete && !step.is_skipped) {
|
||||
markSkip(step);
|
||||
}
|
||||
});
|
||||
|
||||
skippAll = true;
|
||||
}
|
||||
|
||||
function resetAll(skips) {
|
||||
skips.forEach((step) => {
|
||||
if (!step.is_complete && step.is_skipped) {
|
||||
markReset(step);
|
||||
}
|
||||
});
|
||||
|
||||
skippAll = false;
|
||||
}
|
||||
|
||||
function handleAction(step) {
|
||||
if (step.is_complete) return;
|
||||
if (step.is_skipped) return;
|
||||
|
||||
if (step.route_options && typeof step.route_options === "string") {
|
||||
frappe.route_options = JSON.parse(step.route_options);
|
||||
}
|
||||
|
||||
const actions = {
|
||||
"Create Entry": createEntry,
|
||||
"Show Form Tour": showFormTour,
|
||||
"Update Settings": updateSettings,
|
||||
"View Report": openReport,
|
||||
"Go to Page": goToPage,
|
||||
"View Docs": viewDocs,
|
||||
};
|
||||
|
||||
if (step.action && actions[step.action]) {
|
||||
actions[step.action](step);
|
||||
} else if (step.route) {
|
||||
frappe.set_route(step.route);
|
||||
}
|
||||
}
|
||||
|
||||
function viewDocs(step) {
|
||||
window.open(step.path, "_blank");
|
||||
markComplete(step);
|
||||
}
|
||||
|
||||
function goToPage(step) {
|
||||
toggleCollapse();
|
||||
|
||||
frappe.set_route(step.path).then(() => {
|
||||
markComplete(step);
|
||||
});
|
||||
}
|
||||
|
||||
function openReport(step) {
|
||||
toggleCollapse();
|
||||
|
||||
const route = frappe.utils.generate_route({
|
||||
name: step.reference_report,
|
||||
type: "report",
|
||||
is_query_report: step.report_type !== "Report Builder",
|
||||
doctype: step.report_reference_doctype,
|
||||
});
|
||||
|
||||
frappe.set_route(route).then(() => {
|
||||
markComplete(step);
|
||||
});
|
||||
}
|
||||
|
||||
function showFormTour(step) {
|
||||
let route = step.is_single
|
||||
? frappe.router.slug(step.reference_document)
|
||||
: `${frappe.router.slug(step.reference_document)}/new`;
|
||||
|
||||
frappe.route_hooks = {};
|
||||
frappe.route_hooks.after_load = (frm) => {
|
||||
const tour_name = step.form_tour;
|
||||
on_finish = () => markComplete(step);
|
||||
|
||||
frm.tour
|
||||
.init({ tour_name, on_finish: () => markComplete(step) })
|
||||
.then(() => frm.tour.start());
|
||||
};
|
||||
|
||||
frappe.set_route(route);
|
||||
}
|
||||
|
||||
function updateSettings(step) {
|
||||
frappe.route_hooks = {};
|
||||
frappe.route_hooks.after_load = (frm) => {
|
||||
frm.scroll_to_field(step.field);
|
||||
frm.doc.__unsaved = true;
|
||||
};
|
||||
|
||||
frappe.route_hooks.after_save = (frm) => {
|
||||
const success = frm.doc[step.field] == step.value_to_validate;
|
||||
|
||||
if (success) {
|
||||
markComplete(step);
|
||||
}
|
||||
};
|
||||
|
||||
frappe.set_route("Form", step.reference_document);
|
||||
}
|
||||
|
||||
async function createEntry(step) {
|
||||
toggleCollapse();
|
||||
|
||||
frappe.route_hooks = {};
|
||||
frappe.route_hooks.after_load = (frm) => {
|
||||
const tour_name = step.form_tour;
|
||||
if (tour_name) {
|
||||
on_finish = () => {
|
||||
console.log("Tour finished");
|
||||
};
|
||||
frm.tour.init({ tour_name, on_finish }).then(() => frm.tour.start());
|
||||
}
|
||||
};
|
||||
|
||||
const callback = () => {
|
||||
markComplete(step);
|
||||
};
|
||||
|
||||
frappe.route_hooks.after_save = callback;
|
||||
if (step.show_full_form) {
|
||||
frappe.set_route("Form", step.reference_document, "new");
|
||||
} else {
|
||||
frappe.new_doc(step.reference_document);
|
||||
}
|
||||
}
|
||||
|
||||
function markComplete(step) {
|
||||
step.is_complete = true;
|
||||
|
||||
frappe.call("frappe.desk.desktop.update_onboarding_step", {
|
||||
name: step.name,
|
||||
field: "is_complete",
|
||||
value: 1,
|
||||
});
|
||||
}
|
||||
|
||||
function markSkip(step) {
|
||||
step.is_skipped = true;
|
||||
|
||||
frappe.call("frappe.desk.desktop.update_onboarding_step", {
|
||||
name: step.name,
|
||||
field: "is_skipped",
|
||||
value: 1,
|
||||
});
|
||||
}
|
||||
|
||||
function markReset(step) {
|
||||
step.is_skipped = false;
|
||||
|
||||
frappe.call("frappe.desk.desktop.update_onboarding_step", {
|
||||
name: step.name,
|
||||
field: "is_skipped",
|
||||
value: 0,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="onb-panel">
|
||||
<!-- Header -->
|
||||
|
||||
<div class="header onb-header-main">
|
||||
<div class="text-base font-medium">Getting started</div>
|
||||
<div class="onb-header-actions">
|
||||
<button @click="toggleCollapse" v-html="minimizeIcon"></button>
|
||||
<button @click="close" v-html="closeIcon"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!collapsed" class="body">
|
||||
<div class="onb-title">
|
||||
<div class="onb-title-icon" v-html="headerIcon"></div>
|
||||
|
||||
<div class="text-base font-medium">{{ title }}</div>
|
||||
|
||||
<div class="onb-title-steps">
|
||||
{{ completedCount }}/{{ steps.length }} steps completed
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="onb-progress-row">
|
||||
<div v-if="progress !== 100">
|
||||
<div class="onb-progress-badge">{{ progress }}% {{ __("completed") }}</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="onb-progress-badge-complete">
|
||||
{{ progress }}% {{ __("completed") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="skippAll">
|
||||
<span class="onb-skip" @click="resetAll(steps)"> {{ __("Reset all") }}</span>
|
||||
</div>
|
||||
<div v-else>
|
||||
<span class="onb-skip" @click="skipAll(steps)">Skip all</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Steps -->
|
||||
<div class="onb-steps flex flex-col gap-2.5 overflow-hidden">
|
||||
<div
|
||||
style="width: 100%"
|
||||
v-for="(step, i) in steps"
|
||||
:key="i"
|
||||
:class="{ is_complete: step.is_complete }"
|
||||
>
|
||||
<div
|
||||
class="onb-group w-full step-title flex items-center"
|
||||
style="align-items: center"
|
||||
:class="
|
||||
step.is_complete
|
||||
? 'text-extra-muted onb-cursor-disabled'
|
||||
: 'text-ink-gray-8 onb-select-cursor'
|
||||
"
|
||||
>
|
||||
<div class="onb-step-left" @click="handleAction(step)">
|
||||
<div class="onb-step-icon" v-if="step.is_complete">
|
||||
<div v-html="completeChecklistIcon"></div>
|
||||
</div>
|
||||
<div class="onb-step-icon" v-else>
|
||||
<div v-html="checklistIcon"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="!step.is_skipped">
|
||||
<span class="text-base onb-step-text">
|
||||
{{ step.action_label }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else>
|
||||
<span
|
||||
class="text-base onb-step-text"
|
||||
style="text-decoration-line: line-through"
|
||||
>
|
||||
{{ step.action_label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!step.is_complete">
|
||||
<div v-if="!step.is_skipped">
|
||||
<div
|
||||
class="ml-auto text-base onb-show-on-hover text-sm w-12 text-right text-ink-gray-8"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
font-size: 12px;
|
||||
vertical-align: text-top;
|
||||
margin-right: 10px;
|
||||
"
|
||||
@click="markSkip(step)"
|
||||
>
|
||||
{{ __("Skip") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="step.is_skipped">
|
||||
<div
|
||||
class="ml-auto text-base onb-show-on-hover text-sm w-12 text-right text-ink-gray-8"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
font-size: 12px;
|
||||
vertical-align: text-top;
|
||||
margin-right: 10px;
|
||||
"
|
||||
@click="markReset(step)"
|
||||
>
|
||||
{{ __("Reset") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -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;
|
||||
|
|
@ -5,6 +5,8 @@ import localforage from "localforage";
|
|||
|
||||
frappe.last_edited_communication = {};
|
||||
const separator_element = "<div>---</div>";
|
||||
// Quill uses <p>---</p>; match both when stripping quoted content
|
||||
const separator_regex = /<(?:div|p)(?:\s[^>]*)?>---<\/(?:div|p)>/i;
|
||||
|
||||
frappe.views.CommunicationComposer = class {
|
||||
constructor(opts) {
|
||||
|
|
@ -566,6 +568,18 @@ frappe.views.CommunicationComposer = class {
|
|||
const last_edited = this.get_last_edited_communication();
|
||||
if (!last_edited.content && !last_edited.html_content) return;
|
||||
|
||||
// For replies: strip duplicate quoted content (Quill uses <p>---</p>)
|
||||
if (this.is_a_reply) {
|
||||
const reply_block = this.get_earlier_reply();
|
||||
for (const field of ["content", "html_content"]) {
|
||||
if (last_edited[field]) {
|
||||
last_edited[field] =
|
||||
(last_edited[field].split(separator_regex)[0] || "").trimEnd() +
|
||||
reply_block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// prevent re-triggering of email template
|
||||
if (last_edited.email_template) {
|
||||
const template_field = this.dialog.fields_dict.email_template;
|
||||
|
|
@ -789,7 +803,7 @@ frappe.views.CommunicationComposer = class {
|
|||
save_as_draft() {
|
||||
if (this.dialog && this.frm) {
|
||||
let message = this.get_email_content();
|
||||
message = message.split(separator_element)[0];
|
||||
message = message.split(separator_regex)[0];
|
||||
this.save_item_in_local_forage(this.frm.doctype + this.frm.docname, message);
|
||||
this.save_item_in_local_forage(
|
||||
this.frm.doctype + this.frm.docname + "_use_html",
|
||||
|
|
@ -950,7 +964,7 @@ frappe.views.CommunicationComposer = class {
|
|||
}
|
||||
|
||||
if (this.is_a_reply && !this.reply_set) {
|
||||
message += this.get_earlier_reply();
|
||||
message = message.split(separator_regex)[0] + this.get_earlier_reply();
|
||||
}
|
||||
|
||||
await this.set_email_content(message);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1679,3 +1679,19 @@ class TestDataUtils(UnitTestCase):
|
|||
|
||||
self.assertEqual(comma_or(["a", "b", "c"]), "'a', 'b' ou 'c'")
|
||||
self.assertEqual(comma_or(["a", "b", "c"], add_quotes=False), "a, b ou c")
|
||||
|
||||
|
||||
class TestMsgPrint(UnitTestCase):
|
||||
def tearDown(self) -> None:
|
||||
super().tearDown()
|
||||
frappe.clear_messages()
|
||||
|
||||
def test_msgprint(self):
|
||||
frappe.msgprint("Validate: <script>alert('bounty')</script>")
|
||||
message = frappe.get_message_log()[-1]
|
||||
|
||||
self.assertNotIn("script", message.message)
|
||||
|
||||
frappe.msgprint("<ul><li>abc<li></ul>")
|
||||
message = frappe.get_message_log()[-1]
|
||||
self.assertIn("<ul><li>", message.message)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -530,14 +530,16 @@ def search(text: str, start: int = 0, limit: int = 20, doctype: str = ""):
|
|||
if r.doctype == doctype and r.rank > 0.0:
|
||||
try:
|
||||
meta = frappe.get_meta(r.doctype)
|
||||
doc = frappe.get_lazy_doc(r.doctype, r.name)
|
||||
if meta.image_field:
|
||||
r.image = frappe.db.get_value(r.doctype, r.name, meta.image_field)
|
||||
r.image = doc.get(meta.image_field)
|
||||
if meta.title_field:
|
||||
r.title = frappe.db.get_value(r.doctype, r.name, meta.title_field)
|
||||
r.title = doc.get(meta.title_field)
|
||||
except Exception:
|
||||
frappe.clear_messages()
|
||||
|
||||
sorted_results.extend([r])
|
||||
if doc.has_permission():
|
||||
sorted_results.append(r)
|
||||
|
||||
return sorted_results
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import functools
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from typing import Literal
|
||||
|
|
@ -8,8 +7,6 @@ from frappe import _
|
|||
from frappe.utils import strip_html_tags
|
||||
from frappe.utils.data import safe_decode
|
||||
|
||||
_strip_html_tags = functools.lru_cache(maxsize=1024)(strip_html_tags)
|
||||
|
||||
|
||||
def msgprint(
|
||||
msg: str | Sequence[str] | Sequence[Sequence[str]],
|
||||
|
|
@ -24,6 +21,7 @@ def msgprint(
|
|||
wide: bool = False,
|
||||
*,
|
||||
realtime=False,
|
||||
allow_dangerous_html=False,
|
||||
) -> None:
|
||||
"""Print a message to the user (via HTTP response).
|
||||
Messages are sent in the `__server_messages` property in the
|
||||
|
|
@ -38,9 +36,12 @@ def msgprint(
|
|||
:param is_minimizable: [optional] Allow users to minimize the modal
|
||||
:param wide: [optional] Show wide modal
|
||||
:param realtime: Publish message immediately using websocket.
|
||||
:param allow_dangerous_html: Allow arbitrary HTML in message.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
from frappe.utils.html_utils import clean_html
|
||||
|
||||
msg = safe_decode(msg)
|
||||
out = frappe._dict(message=msg)
|
||||
|
||||
|
|
@ -67,13 +68,21 @@ def msgprint(
|
|||
else:
|
||||
out.as_table = as_table
|
||||
|
||||
if not allow_dangerous_html:
|
||||
if out.as_list:
|
||||
out.message = [clean_html(cell) for cell in msg]
|
||||
elif out.as_table:
|
||||
out.message = [[clean_html(cell) for cell in row] for row in msg]
|
||||
else:
|
||||
out.message = clean_html(msg)
|
||||
|
||||
if sys.stdin and sys.stdin.isatty():
|
||||
if out.as_list:
|
||||
msg = [_strip_html_tags(cell) for cell in msg]
|
||||
msg = [strip_html_tags(cell) for cell in msg]
|
||||
elif out.as_table:
|
||||
msg = [[_strip_html_tags(cell) for cell in row] for row in msg]
|
||||
msg = [[strip_html_tags(cell) for cell in row] for row in msg]
|
||||
else:
|
||||
msg = _strip_html_tags(msg)
|
||||
msg = strip_html_tags(msg)
|
||||
|
||||
if frappe.flags.print_messages and out.message:
|
||||
print(f"Message: {msg}")
|
||||
|
|
@ -134,6 +143,8 @@ def throw(
|
|||
wide: bool = False,
|
||||
as_list: bool = False,
|
||||
primary_action=None,
|
||||
*,
|
||||
allow_dangerous_html=False,
|
||||
) -> None:
|
||||
"""Throw execption and show message (`msgprint`).
|
||||
|
||||
|
|
@ -154,6 +165,7 @@ def throw(
|
|||
wide=wide,
|
||||
as_list=as_list,
|
||||
primary_action=primary_action,
|
||||
allow_dangerous_html=allow_dangerous_html,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -417,7 +417,7 @@ def get_context(context):
|
|||
|
||||
def load_list_data(self, context):
|
||||
if not self.list_columns:
|
||||
self.list_columns = get_in_list_view_fields(self.doc_type)
|
||||
self.list_columns = get_in_list_view_fields(self.doc_type, self.name)
|
||||
context.web_form_doc.list_columns = self.list_columns
|
||||
|
||||
def load_form_data(self, context):
|
||||
|
|
@ -454,13 +454,10 @@ def get_context(context):
|
|||
# For Table fields, server-side processing for meta
|
||||
for field in context.web_form_doc.web_form_fields:
|
||||
if field.fieldtype == "Table":
|
||||
field.fields = get_in_list_view_fields(field.options)
|
||||
field.fields = get_in_list_view_fields(field.options, self.name)
|
||||
|
||||
if field.fieldtype == "Link":
|
||||
field.fieldtype = "Autocomplete"
|
||||
field.options = get_link_options(
|
||||
self.name, field.options, field.allow_read_on_all_link_options
|
||||
)
|
||||
process_link_field(field, self.name)
|
||||
|
||||
context.reference_doc = {}
|
||||
|
||||
|
|
@ -609,6 +606,14 @@ def get_context(context):
|
|||
return permitted_attachments
|
||||
|
||||
|
||||
def process_link_field(field, web_form_name):
|
||||
field.fieldtype = "Autocomplete"
|
||||
field.options = get_link_options(
|
||||
web_form_name, field.options, getattr(field, "allow_read_on_all_link_options", False)
|
||||
)
|
||||
return field
|
||||
|
||||
|
||||
def get_web_form_module(doc):
|
||||
if doc.is_standard:
|
||||
return get_doc_module(doc.module, doc.doctype, doc.name)
|
||||
|
|
@ -795,20 +800,16 @@ def get_form_data(doctype: str, docname: str | None = None, web_form_name: str |
|
|||
# For Table fields, server-side processing for meta
|
||||
for field in out.web_form.web_form_fields:
|
||||
if field.fieldtype == "Table":
|
||||
field.fields = get_in_list_view_fields(field.options)
|
||||
field.fields = get_in_list_view_fields(field.options, web_form_name)
|
||||
out.update({field.fieldname: field.fields})
|
||||
|
||||
if field.fieldtype == "Link":
|
||||
field.fieldtype = "Autocomplete"
|
||||
field.options = get_link_options(
|
||||
web_form_name, field.options, field.allow_read_on_all_link_options
|
||||
)
|
||||
process_link_field(field, web_form_name)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_in_list_view_fields(doctype: str):
|
||||
def get_in_list_view_fields(doctype, web_form_name=None):
|
||||
meta = frappe.get_meta(doctype)
|
||||
fields = []
|
||||
|
||||
|
|
@ -825,20 +826,43 @@ def get_in_list_view_fields(doctype: str):
|
|||
def get_field_df(fieldname):
|
||||
if fieldname == "name":
|
||||
return {"label": "Name", "fieldname": "name", "fieldtype": "Data"}
|
||||
return meta.get_field(fieldname).as_dict()
|
||||
|
||||
df = meta.get_field(fieldname).as_dict()
|
||||
if df.get("options") and df.get("fieldtype") == "Link":
|
||||
process_link_field(df, web_form_name)
|
||||
return df
|
||||
|
||||
return [get_field_df(f) for f in fields]
|
||||
|
||||
|
||||
def has_link_option(fields, doctype):
|
||||
for f in fields:
|
||||
if f.options == doctype:
|
||||
return True
|
||||
if f.fieldtype == "Table" and f.options:
|
||||
child_doctype = f.options
|
||||
if not isinstance(child_doctype, str) or not child_doctype.strip():
|
||||
continue
|
||||
try:
|
||||
child_table_fields = frappe.get_meta(child_doctype).fields
|
||||
except Exception:
|
||||
continue
|
||||
for child_field in child_table_fields:
|
||||
if getattr(child_field, "options", None) == doctype:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_link_options(web_form_name, doctype, allow_read_on_all_link_options=False):
|
||||
web_form: WebForm = frappe.get_lazy_doc("Web Form", web_form_name)
|
||||
|
||||
if web_form.login_required and frappe.session.user == "Guest":
|
||||
frappe.throw(_("You must be logged in to use this form."), frappe.PermissionError)
|
||||
|
||||
if not web_form.published or not any(f for f in web_form.web_form_fields if f.options == doctype):
|
||||
if not web_form.published or not has_link_option(web_form.web_form_fields, doctype):
|
||||
frappe.throw(
|
||||
_("You don't have permission to access the {0} DocType.").format(doctype), frappe.PermissionError
|
||||
_("You don't have permission to access the {0} DocType.").format(doctype),
|
||||
frappe.PermissionError,
|
||||
)
|
||||
|
||||
link_options, filters = [], {}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue