Merge branch 'develop' into f1007
This commit is contained in:
commit
5f9fdf2c0e
139 changed files with 4723 additions and 2471 deletions
|
|
@ -7,5 +7,6 @@ hooks.py,frappe.gettext.extractors.navbar.extract
|
|||
**.py,frappe.gettext.extractors.python.extract
|
||||
**.js,frappe.gettext.extractors.javascript.extract
|
||||
**.html,frappe.gettext.extractors.html_template.extract
|
||||
**.vue,frappe.gettext.extractors.html_template.extract
|
||||
**/custom/*.json,frappe.gettext.extractors.customization.extract
|
||||
**/fixtures/custom_field.json,frappe.gettext.extractors.custom_field.extract
|
||||
|
|
|
@ -18,7 +18,7 @@ context("Workspace 2.0", () => {
|
|||
}).as("new_page");
|
||||
|
||||
cy.get(".codex-editor__redactor .ce-block");
|
||||
cy.get('.custom-actions button[data-label="Create%20Workspace"]').click();
|
||||
cy.get(".btn-new-workspace").click();
|
||||
cy.fill_field("title", "Test Private Page", "Data");
|
||||
cy.get_open_dialog().find(".modal-header").click();
|
||||
cy.get_open_dialog().find(".btn-primary").click();
|
||||
|
|
@ -48,7 +48,7 @@ context("Workspace 2.0", () => {
|
|||
}).as("new_page");
|
||||
|
||||
cy.get(".codex-editor__redactor .ce-block");
|
||||
cy.get('.custom-actions button[data-label="Create%20Workspace"]').click();
|
||||
cy.get(".btn-new-workspace").click();
|
||||
cy.fill_field("title", "Test Child Page", "Data");
|
||||
cy.fill_field("parent", "Test Private Page", "Select");
|
||||
cy.get_open_dialog().find(".modal-header").click();
|
||||
|
|
@ -79,7 +79,7 @@ context("Workspace 2.0", () => {
|
|||
}).as("page_duplicated");
|
||||
|
||||
cy.get(".codex-editor__redactor .ce-block");
|
||||
cy.get(".standard-actions .btn-secondary[data-label=Edit]").click();
|
||||
cy.get(".btn-edit-workspace").click();
|
||||
|
||||
cy.get('.sidebar-item-container[item-name="Test Private Page"]').as("sidebar-item");
|
||||
|
||||
|
|
@ -196,7 +196,7 @@ context("Workspace 2.0", () => {
|
|||
}).as("hide_page");
|
||||
|
||||
cy.get(".codex-editor__redactor .ce-block");
|
||||
cy.get(".standard-actions .btn-secondary[data-label=Edit]").click();
|
||||
cy.get(".btn-edit-workspace").click();
|
||||
|
||||
cy.get('.sidebar-item-container[item-name="Duplicate Page"]')
|
||||
.find(".sidebar-item-control .setting-btn")
|
||||
|
|
@ -217,7 +217,7 @@ context("Workspace 2.0", () => {
|
|||
}).as("unhide_page");
|
||||
|
||||
cy.get(".codex-editor__redactor .ce-block");
|
||||
cy.get(".standard-actions .btn-secondary[data-label=Edit]").click();
|
||||
cy.get(".btn-edit-workspace").click();
|
||||
|
||||
cy.get('.sidebar-item-container[item-name="Duplicate Page"]')
|
||||
.find('[title="Unhide Workspace"]')
|
||||
|
|
@ -237,7 +237,7 @@ context("Workspace 2.0", () => {
|
|||
}).as("page_deleted");
|
||||
|
||||
cy.get(".codex-editor__redactor .ce-block");
|
||||
cy.get(".standard-actions .btn-secondary[data-label=Edit]").click();
|
||||
cy.get(".btn-edit-workspace").click();
|
||||
|
||||
cy.get('.sidebar-item-container[item-name="Duplicate Page"]')
|
||||
.find(".sidebar-item-control .setting-btn")
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ context("Workspace Blocks", () => {
|
|||
|
||||
cy.visit("/app/website");
|
||||
cy.get(".codex-editor__redactor .ce-block");
|
||||
cy.get('.custom-actions button[data-label="Create%20Workspace"]').click();
|
||||
cy.get(".btn-new-workspace").click();
|
||||
cy.fill_field("title", "Test Block Page", "Data");
|
||||
cy.get_open_dialog().find(".modal-header").click();
|
||||
cy.get_open_dialog().find(".btn-primary").click();
|
||||
|
|
@ -71,7 +71,7 @@ context("Workspace Blocks", () => {
|
|||
}).as("get_doctype");
|
||||
|
||||
cy.get(".codex-editor__redactor .ce-block");
|
||||
cy.get(".standard-actions .btn-secondary[data-label=Edit]").click();
|
||||
cy.get(".btn-edit-workspace").click();
|
||||
|
||||
// test quick list creation
|
||||
cy.get(".ce-block").first().click({ force: true }).type("{enter}");
|
||||
|
|
@ -159,7 +159,7 @@ context("Workspace Blocks", () => {
|
|||
]);
|
||||
|
||||
cy.get(".codex-editor__redactor .ce-block");
|
||||
cy.get(".standard-actions .btn-secondary[data-label=Edit]").click();
|
||||
cy.get(".btn-edit-workspace").click();
|
||||
|
||||
cy.get(".ce-block").first().click({ force: true }).type("{enter}");
|
||||
cy.get(".block-list-container .block-list-item").contains("Number Card").click();
|
||||
|
|
@ -174,7 +174,7 @@ context("Workspace Blocks", () => {
|
|||
cy.get("@number_card").find(".widget-title").should("contain", "Test Number Card");
|
||||
|
||||
// edit number card
|
||||
cy.get(".standard-actions .btn-secondary[data-label=Edit]").click();
|
||||
cy.get(".btn-edit-workspace").click();
|
||||
cy.get("@number_card").realHover().find(".widget-control .edit-button").click();
|
||||
cy.get_field("label", "Data").invoke("val", "ToDo Count");
|
||||
cy.click_modal_primary_button("Save");
|
||||
|
|
|
|||
|
|
@ -442,6 +442,12 @@ def get_site_config(sites_path: str | None = None, site_path: str | None = None)
|
|||
# Set the user as database name if not set in config
|
||||
config["db_user"] = os.environ.get("FRAPPE_DB_USER") or config.get("db_user") or config.get("db_name")
|
||||
|
||||
# vice versa for dbname if not defined
|
||||
config["db_name"] = os.environ.get("FRAPPE_DB_NAME") or config.get("db_name") or config["db_user"]
|
||||
|
||||
# read password
|
||||
config["db_password"] = os.environ.get("FRAPPE_DB_PASSWORD") or config.get("db_password")
|
||||
|
||||
# Allow externally extending the config with hooks
|
||||
if extra_config := config.get("extra_config"):
|
||||
if isinstance(extra_config, str):
|
||||
|
|
|
|||
|
|
@ -675,19 +675,11 @@ def migrate(context, skip_failing=False, skip_search_index=False):
|
|||
|
||||
|
||||
@click.command("migrate-to")
|
||||
@click.argument("frappe_provider")
|
||||
@pass_context
|
||||
def migrate_to(context, frappe_provider):
|
||||
def migrate_to():
|
||||
"Migrates site to the specified provider"
|
||||
from frappe.integrations.frappe_providers import migrate_to
|
||||
|
||||
for site in context.sites:
|
||||
frappe.init(site=site)
|
||||
frappe.connect()
|
||||
migrate_to(site, frappe_provider)
|
||||
frappe.destroy()
|
||||
if not context.sites:
|
||||
raise SiteNotSpecifiedError
|
||||
migrate_to()
|
||||
|
||||
|
||||
@click.command("run-patch")
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
from collections import Counter
|
||||
from email.utils import getaddresses
|
||||
from urllib.parse import unquote
|
||||
from urllib.parse import unquote_plus
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
|
@ -593,47 +593,62 @@ def parse_email(email_strings):
|
|||
When automatic email linking is enabled, an email from email_strings can contain
|
||||
a doctype and docname ie in the format `admin+doctype+docname@example.com` or `admin+doctype=docname@example.com`,
|
||||
the email is parsed and doctype and docname is extracted.
|
||||
|
||||
see: RFC5233
|
||||
"""
|
||||
for email_string in email_strings:
|
||||
if not email_string:
|
||||
continue
|
||||
|
||||
for email in email_string.split(","):
|
||||
email_username = email.split("@", 1)[0]
|
||||
email_local_parts = email_username.split("+")
|
||||
docname = doctype = None
|
||||
if len(email_local_parts) == 3:
|
||||
doctype = unquote(email_local_parts[1])
|
||||
docname = unquote(email_local_parts[2])
|
||||
local_part = email.split("@", 1)[0].strip('"')
|
||||
user, detail = None, None
|
||||
if "+" in local_part:
|
||||
user, detail = local_part.split("+", 1)
|
||||
elif "--" in local_part:
|
||||
detail, user = local_part.rsplit("--", 1)
|
||||
|
||||
elif len(email_local_parts) == 2:
|
||||
document_parts = email_local_parts[1].split("=", 1)
|
||||
if len(document_parts) != 2:
|
||||
continue
|
||||
if not detail:
|
||||
continue
|
||||
|
||||
doctype = unquote(document_parts[0])
|
||||
docname = unquote(document_parts[1])
|
||||
document_parts = None
|
||||
if "=" in detail:
|
||||
document_parts = detail.split("=", 1)
|
||||
elif "+" in detail:
|
||||
document_parts = detail.split("+", 1)
|
||||
|
||||
if doctype and docname:
|
||||
yield doctype, docname
|
||||
if not document_parts or len(document_parts) != 2:
|
||||
continue
|
||||
|
||||
doctype = unquote_plus(document_parts[0])
|
||||
docname = unquote_plus(document_parts[1])
|
||||
yield doctype, docname
|
||||
|
||||
|
||||
def get_email_without_link(email):
|
||||
"""Return email address without doctype links.
|
||||
|
||||
e.g. 'admin@example.com' is returned for email 'admin+doctype+docname@example.com'
|
||||
|
||||
see: RFC5233
|
||||
"""
|
||||
if not frappe.get_all("Email Account", filters={"enable_automatic_linking": 1}):
|
||||
return email
|
||||
|
||||
try:
|
||||
_email = email.split("@")
|
||||
email_id = _email[0].split("+", 1)[0]
|
||||
email_host = _email[1]
|
||||
_local_part = _email[0].strip('"')
|
||||
if "+" in _local_part:
|
||||
user = _local_part.split("+", 1)[0]
|
||||
elif "--" in _local_part:
|
||||
user = _local_part.split("--", 1)[1]
|
||||
else:
|
||||
user = _local_part
|
||||
domain = _email[1]
|
||||
except IndexError:
|
||||
return email
|
||||
|
||||
return f"{email_id}@{email_host}"
|
||||
return f"{user}@{domain}"
|
||||
|
||||
|
||||
def update_parent_document_on_communication(doc):
|
||||
|
|
|
|||
|
|
@ -221,11 +221,20 @@ class TestCommunication(FrappeTestCase):
|
|||
def test_parse_email(self):
|
||||
to = "Jon Doe <jon.doe@example.org>"
|
||||
cc = """=?UTF-8?Q?Max_Mu=C3=9F?= <max.muss@examle.org>,
|
||||
erp+Customer+that%20company@example.org"""
|
||||
erp+Customer=Plus%2BCompany@example.org,
|
||||
erp+Customer+Space%20Company@example.org,
|
||||
erp+Customer+Space+Company+Plus+Encoded@example.org"""
|
||||
bcc = ""
|
||||
|
||||
results = list(parse_email([to, cc, bcc]))
|
||||
self.assertEqual([("Customer", "that company")], results)
|
||||
self.assertEqual(
|
||||
[
|
||||
("Customer", "Plus+Company"),
|
||||
("Customer", "Space Company"),
|
||||
("Customer", "Space Company Plus Encoded"),
|
||||
],
|
||||
results,
|
||||
)
|
||||
|
||||
results = list(parse_email([to, bcc]))
|
||||
self.assertEqual(results, [])
|
||||
|
|
@ -380,7 +389,8 @@ class TestCommunicationEmailMixin(FrappeTestCase):
|
|||
user = self.new_user(email="bcc+2@test.com", enabled=0)
|
||||
comm = self.new_communication(bcc=bcc_list)
|
||||
res = comm.get_mail_bcc_with_displayname()
|
||||
self.assertCountEqual(res, bcc_list)
|
||||
# Disabled users have thread_notify disabled, so they'll be removed from the list
|
||||
self.assertCountEqual(res, bcc_list[:1])
|
||||
user.delete()
|
||||
comm.delete()
|
||||
|
||||
|
|
|
|||
|
|
@ -296,7 +296,16 @@ def export_json(doctype, path, filters=None, or_filters=None, name=None, order_b
|
|||
for v in doc.values():
|
||||
if isinstance(v, list):
|
||||
for child in v:
|
||||
for key in (*del_keys, "docstatus", "doctype", "modified", "name"):
|
||||
for key in (
|
||||
*del_keys,
|
||||
"docstatus",
|
||||
"doctype",
|
||||
"modified",
|
||||
"name",
|
||||
"parent",
|
||||
"parentfield",
|
||||
"parenttype",
|
||||
):
|
||||
if key in child:
|
||||
del child[key]
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ class DeletedDocument(Document):
|
|||
restored: DF.Check
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
no_feed_on_delete = True
|
||||
|
||||
@staticmethod
|
||||
def clear_old_logs(days=180):
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
"fetch_if_empty",
|
||||
"visibility_section",
|
||||
"hidden",
|
||||
"show_on_timeline",
|
||||
"bold",
|
||||
"allow_in_quick_entry",
|
||||
"translatable",
|
||||
|
|
@ -578,13 +579,20 @@
|
|||
"fieldname": "not_nullable",
|
||||
"fieldtype": "Check",
|
||||
"label": "Not Nullable"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval: doc.hidden",
|
||||
"fieldname": "show_on_timeline",
|
||||
"fieldtype": "Check",
|
||||
"label": "Show on Timeline"
|
||||
}
|
||||
],
|
||||
"idx": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-04-12 16:27:34.546314",
|
||||
"modified": "2024-07-30 13:15:32.037892",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "DocField",
|
||||
|
|
@ -594,4 +602,4 @@
|
|||
"sort_field": "creation",
|
||||
"sort_order": "ASC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
|
@ -112,6 +112,7 @@ class DocField(Document):
|
|||
search_index: DF.Check
|
||||
set_only_once: DF.Check
|
||||
show_dashboard: DF.Check
|
||||
show_on_timeline: DF.Check
|
||||
sort_options: DF.Check
|
||||
translatable: DF.Check
|
||||
unique: DF.Check
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ frappe.ui.form.on("DocType", {
|
|||
}
|
||||
}
|
||||
|
||||
const customize_form_link = "<a href='/app/customize-form'>Customize Form</a>";
|
||||
const customize_form_link = `<a href="/app/customize-form">${__("Customize Form")}</a>`;
|
||||
if (!frappe.boot.developer_mode && !frm.doc.custom) {
|
||||
// make the document read-only
|
||||
frm.set_read_only();
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@
|
|||
"field_order": [
|
||||
"form_builder_tab",
|
||||
"form_builder",
|
||||
"permissions_tab",
|
||||
"permissions",
|
||||
"restrict_to_domain",
|
||||
"read_only",
|
||||
"in_create",
|
||||
"settings_tab",
|
||||
"sb0",
|
||||
"module",
|
||||
|
|
@ -70,11 +75,6 @@
|
|||
"sender_field",
|
||||
"sender_name_field",
|
||||
"subject_field",
|
||||
"sb2",
|
||||
"permissions",
|
||||
"restrict_to_domain",
|
||||
"read_only",
|
||||
"in_create",
|
||||
"actions_section",
|
||||
"actions",
|
||||
"links_section",
|
||||
|
|
@ -152,8 +152,8 @@
|
|||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval:!doc.istable && !doc.issingle",
|
||||
"description": "Open a dialog with mandatory fields to create a new record quickly",
|
||||
"depends_on": "eval:!doc.istable",
|
||||
"description": "Open a dialog with mandatory fields to create a new record quickly. There must be at least one mandatory field to show in dialog.",
|
||||
"fieldname": "quick_entry",
|
||||
"fieldtype": "Check",
|
||||
"label": "Quick Entry"
|
||||
|
|
@ -381,12 +381,6 @@
|
|||
"fieldtype": "Check",
|
||||
"label": "Make \"name\" searchable in Global Search"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.istable",
|
||||
"fieldname": "sb2",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Permission Rules"
|
||||
},
|
||||
{
|
||||
"fieldname": "permissions",
|
||||
"fieldtype": "Table",
|
||||
|
|
@ -418,6 +412,7 @@
|
|||
"oldfieldtype": "Check"
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"depends_on": "eval:doc.custom===0 && !doc.istable",
|
||||
"fieldname": "web_view",
|
||||
"fieldtype": "Section Break",
|
||||
|
|
@ -668,6 +663,11 @@
|
|||
"fieldname": "sender_name_field",
|
||||
"fieldtype": "Data",
|
||||
"label": "Sender Name Field"
|
||||
},
|
||||
{
|
||||
"fieldname": "permissions_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Permissions"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-bolt",
|
||||
|
|
@ -750,7 +750,7 @@
|
|||
"link_fieldname": "reference_doctype"
|
||||
}
|
||||
],
|
||||
"modified": "2024-03-29 16:09:26.114720",
|
||||
"modified": "2024-08-02 14:48:12.911702",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "DocType",
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ from frappe.core.api.file import (
|
|||
unzip_file,
|
||||
)
|
||||
from frappe.core.doctype.file.exceptions import FileTypeNotAllowed
|
||||
from frappe.core.doctype.file.utils import get_extension
|
||||
from frappe.core.doctype.file.utils import get_corrupted_image_msg, get_extension
|
||||
from frappe.desk.form.utils import add_comment
|
||||
from frappe.exceptions import ValidationError
|
||||
from frappe.tests.utils import FrappeTestCase, change_settings
|
||||
|
|
@ -768,6 +768,25 @@ class TestFileUtils(FrappeTestCase):
|
|||
)
|
||||
self.assertRegex(communication.content, r"<img src=\"(.*)/files/pix\.png(.*)\">")
|
||||
|
||||
def test_broken_image(self):
|
||||
"""Ensure that broken inline images don't cause errors."""
|
||||
is_private = not frappe.get_meta("Communication").make_attachments_public
|
||||
communication = frappe.get_doc(
|
||||
doctype="Communication",
|
||||
communication_type="Communication",
|
||||
communication_medium="Email",
|
||||
content='<div class="ql-editor read-mode"><img src="data:image/png;filename=pix.png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CY="></div>',
|
||||
recipients="to <to@test.com>",
|
||||
cc=None,
|
||||
bcc=None,
|
||||
sender="sender@test.com",
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
self.assertFalse(
|
||||
frappe.db.exists("File", {"attached_to_name": communication.name, "is_private": is_private})
|
||||
)
|
||||
self.assertIn(f'<img src="#broken-image" alt="{get_corrupted_image_msg()}">', communication.content)
|
||||
|
||||
def test_create_new_folder(self):
|
||||
folder = create_new_folder("test_folder", "Home")
|
||||
self.assertTrue(folder.is_folder)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import hashlib
|
|||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
from binascii import Error as BinasciiError
|
||||
from io import BytesIO
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from urllib.parse import unquote
|
||||
|
|
@ -234,7 +235,12 @@ def extract_images_from_html(doc: "Document", content: str, is_private: bool = F
|
|||
content = content.encode("utf-8")
|
||||
if b"," in content:
|
||||
content = content.split(b",")[1]
|
||||
content = safe_b64decode(content)
|
||||
|
||||
try:
|
||||
content = safe_b64decode(content)
|
||||
except BinasciiError:
|
||||
frappe.flags.has_dataurl = True
|
||||
return f'<img src="#broken-image" alt="{get_corrupted_image_msg()}"'
|
||||
|
||||
if "filename=" in headers:
|
||||
filename = headers.split("filename=")[-1]
|
||||
|
|
@ -273,6 +279,10 @@ def extract_images_from_html(doc: "Document", content: str, is_private: bool = F
|
|||
return content
|
||||
|
||||
|
||||
def get_corrupted_image_msg():
|
||||
return _("Image: Corrupted Data Stream")
|
||||
|
||||
|
||||
def get_random_filename(content_type: str | None = None) -> str:
|
||||
extn = None
|
||||
if content_type:
|
||||
|
|
|
|||
|
|
@ -22,26 +22,6 @@ class NavbarSettings(Document):
|
|||
settings_dropdown: DF.Table[NavbarItem]
|
||||
# end: auto-generated types
|
||||
|
||||
def validate(self):
|
||||
self.validate_standard_navbar_items()
|
||||
|
||||
def validate_standard_navbar_items(self):
|
||||
doc_before_save = self.get_doc_before_save()
|
||||
|
||||
if not doc_before_save:
|
||||
return
|
||||
|
||||
before_save_items = [
|
||||
item
|
||||
for item in doc_before_save.help_dropdown + doc_before_save.settings_dropdown
|
||||
if item.is_standard
|
||||
]
|
||||
|
||||
after_save_items = [item for item in self.help_dropdown + self.settings_dropdown if item.is_standard]
|
||||
|
||||
if not frappe.flags.in_patch and (len(before_save_items) > len(after_save_items)):
|
||||
frappe.throw(_("Please hide the standard navbar items instead of deleting them"))
|
||||
|
||||
|
||||
def get_app_logo():
|
||||
app_logo = frappe.db.get_single_value("Navbar Settings", "app_logo", cache=True)
|
||||
|
|
@ -53,3 +33,31 @@ def get_app_logo():
|
|||
|
||||
def get_navbar_settings():
|
||||
return frappe.get_single("Navbar Settings")
|
||||
|
||||
|
||||
def sync_standard_items():
|
||||
"""Syncs standard items from hooks. Called in migrate"""
|
||||
|
||||
sync_table("settings_dropdown", "standard_navbar_items")
|
||||
sync_table("help_dropdown", "standard_help_items")
|
||||
|
||||
|
||||
def sync_table(key, hook):
|
||||
navbar_settings = NavbarSettings("Navbar Settings")
|
||||
existing_items = {d.item_label: d for d in navbar_settings.get(key)}
|
||||
new_standard_items = {}
|
||||
|
||||
# add new items
|
||||
count = 0 # matain count because list may come from seperate apps
|
||||
for item in frappe.get_hooks(hook):
|
||||
if item.get("item_label") not in existing_items:
|
||||
navbar_settings.append(key, item, count)
|
||||
new_standard_items[item.get("item_label")] = True
|
||||
count += 1
|
||||
|
||||
# remove unused items
|
||||
items = navbar_settings.get(key)
|
||||
items = [item for item in items if not (item.is_standard and (item.item_label not in new_standard_items))]
|
||||
navbar_settings.set(key, items)
|
||||
|
||||
navbar_settings.save()
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@
|
|||
// For license information, please see license.txt
|
||||
|
||||
frappe.query_reports["{name}"] = {{
|
||||
"filters": [
|
||||
|
||||
]
|
||||
filters: [
|
||||
// {{
|
||||
// "fieldname": "my_filter",
|
||||
// "label": __("My Filter"),
|
||||
// "fieldtype": "Data",
|
||||
// "reqd": 1,
|
||||
// }},
|
||||
],
|
||||
}};
|
||||
|
|
|
|||
|
|
@ -2,8 +2,47 @@
|
|||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe import _
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
columns, data = [], []
|
||||
def execute(filters: dict | None = None):
|
||||
"""Return columns and data for the report.
|
||||
|
||||
This is the main entry point for the report. It accepts the filters as a
|
||||
dictionary and should return columns and data. It is called by the framework
|
||||
every time the report is refreshed or a filter is updated.
|
||||
"""
|
||||
columns = get_columns()
|
||||
data = get_data()
|
||||
|
||||
return columns, data
|
||||
|
||||
|
||||
def get_columns() -> list[dict]:
|
||||
"""Return columns for the report.
|
||||
|
||||
One field definition per column, just like a DocType field definition.
|
||||
"""
|
||||
return [
|
||||
{{
|
||||
"label": _("Column 1"),
|
||||
"fieldname": "column_1",
|
||||
"fieldtype": "Data",
|
||||
}},
|
||||
{{
|
||||
"label": _("Column 2"),
|
||||
"fieldname": "column_2",
|
||||
"fieldtype": "Int",
|
||||
}},
|
||||
]
|
||||
|
||||
|
||||
def get_data() -> list[list]:
|
||||
"""Return data for the report.
|
||||
|
||||
The report data is a list of rows, with each row being a list of cell values.
|
||||
"""
|
||||
return [
|
||||
["Row 1", 1],
|
||||
["Row 2", 2],
|
||||
]
|
||||
|
|
|
|||
|
|
@ -237,7 +237,8 @@
|
|||
"options": "Has Role",
|
||||
"permlevel": 1,
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
"read_only": 1,
|
||||
"show_on_timeline": 1
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
|
|
@ -428,7 +429,8 @@
|
|||
"hidden": 1,
|
||||
"label": "Block Modules",
|
||||
"options": "Block Module",
|
||||
"permlevel": 1
|
||||
"permlevel": 1,
|
||||
"show_on_timeline": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "home_settings",
|
||||
|
|
@ -796,7 +798,7 @@
|
|||
"link_fieldname": "user"
|
||||
}
|
||||
],
|
||||
"modified": "2024-04-12 23:25:04.628007",
|
||||
"modified": "2024-07-15 18:40:18.842915",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "User",
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ from frappe.desk.doctype.notification_settings.notification_settings import (
|
|||
toggle_notifications,
|
||||
)
|
||||
from frappe.desk.notifications import clear_notifications
|
||||
from frappe.model.delete_doc import check_if_doc_is_linked
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder import DocType
|
||||
from frappe.rate_limiter import rate_limit
|
||||
|
|
@ -183,6 +182,12 @@ class User(Document):
|
|||
if (self.name not in ["Administrator", "Guest"]) and (not self.get_social_login_userid("frappe")):
|
||||
self.set_social_login_userid("frappe", frappe.generate_hash(length=39))
|
||||
|
||||
def disable_email_fields_if_user_disabled(self):
|
||||
if not self.enabled:
|
||||
self.thread_notify = 0
|
||||
self.send_me_a_copy = 0
|
||||
self.allowed_in_mentions = 0
|
||||
|
||||
@frappe.whitelist()
|
||||
def populate_role_profile_roles(self):
|
||||
if not self.role_profiles:
|
||||
|
|
@ -285,6 +290,7 @@ class User(Document):
|
|||
|
||||
# toggle notifications based on the user's status
|
||||
toggle_notifications(self.name, enable=cint(self.enabled), ignore_permissions=True)
|
||||
self.disable_email_fields_if_user_disabled()
|
||||
|
||||
def email_new_password(self, new_password=None):
|
||||
if new_password and not self.flags.in_insert:
|
||||
|
|
@ -356,7 +362,11 @@ class User(Document):
|
|||
user=self.name, pwd=new_password, logout_all_sessions=self.logout_all_sessions
|
||||
)
|
||||
|
||||
if not self.flags.no_welcome_mail and cint(self.send_welcome_email):
|
||||
if (
|
||||
not self.flags.no_welcome_mail
|
||||
and cint(self.send_welcome_email)
|
||||
and not self.flags.email_sent
|
||||
):
|
||||
self.send_welcome_mail_to_user()
|
||||
self.flags.email_sent = 1
|
||||
if frappe.session.user != "Guest":
|
||||
|
|
@ -536,11 +546,20 @@ class User(Document):
|
|||
# Delete EPS data
|
||||
frappe.db.delete("Energy Point Log", {"user": self.name})
|
||||
|
||||
# Ask user to disable instead if document is still linked
|
||||
try:
|
||||
check_if_doc_is_linked(self)
|
||||
except frappe.LinkExistsError:
|
||||
frappe.throw(_("You can disable the user instead of deleting it."), frappe.LinkExistsError)
|
||||
# Remove user link from Workflow Action
|
||||
frappe.db.set_value("Workflow Action", {"user": self.name}, "user", None)
|
||||
|
||||
# Delete user's List Filters
|
||||
frappe.db.delete("List Filter", {"for_user": self.name})
|
||||
|
||||
# Remove user from Note's Seen By table
|
||||
seen_notes = frappe.get_all("Note", filters=[["Note Seen By", "user", "=", self.name]], pluck="name")
|
||||
for note_id in seen_notes:
|
||||
note = frappe.get_doc("Note", note_id)
|
||||
for row in note.seen_by:
|
||||
if row.user == self.name:
|
||||
note.remove(row)
|
||||
note.save(ignore_permissions=True)
|
||||
|
||||
def before_rename(self, old_name, new_name, merge=False):
|
||||
# if merging, delete the old user notification settings
|
||||
|
|
|
|||
|
|
@ -16,7 +16,12 @@
|
|||
"submit",
|
||||
"cancel",
|
||||
"amend",
|
||||
"delete"
|
||||
"delete",
|
||||
"additional_permissions_section",
|
||||
"email",
|
||||
"column_break_fjuk",
|
||||
"share",
|
||||
"print"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
|
|
@ -92,12 +97,39 @@
|
|||
"fieldname": "delete",
|
||||
"fieldtype": "Check",
|
||||
"label": "Delete"
|
||||
},
|
||||
{
|
||||
"fieldname": "additional_permissions_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Additional Permissions"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "email",
|
||||
"fieldtype": "Check",
|
||||
"label": "Email"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_fjuk",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "share",
|
||||
"fieldtype": "Check",
|
||||
"label": "Share"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "print",
|
||||
"fieldtype": "Check",
|
||||
"label": "Print"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-23 16:04:00.313525",
|
||||
"modified": "2024-07-12 17:32:15.721862",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "User Document Type",
|
||||
|
|
|
|||
|
|
@ -19,11 +19,14 @@ class UserDocumentType(Document):
|
|||
create: DF.Check
|
||||
delete: DF.Check
|
||||
document_type: DF.Link
|
||||
email: DF.Check
|
||||
is_custom: DF.Check
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
print: DF.Check
|
||||
read: DF.Check
|
||||
share: DF.Check
|
||||
submit: DF.Check
|
||||
write: DF.Check
|
||||
# end: auto-generated types
|
||||
|
|
|
|||
|
|
@ -31,6 +31,18 @@ class TestUserType(FrappeTestCase):
|
|||
for entry in link_fields:
|
||||
self.assertTrue(entry.options in select_doctypes)
|
||||
|
||||
def test_print_share_email_default(self):
|
||||
"""Test if print, share & email values default to 1. (for backward compatibility)"""
|
||||
# create user type with read, write permissions
|
||||
create_user_type("Test User Type")
|
||||
|
||||
# check if print, share & email values are set to 1
|
||||
perm = frappe.get_all("Custom DocPerm", filters={"role": "_Test User Type"}, fields=["*"])[0]
|
||||
|
||||
self.assertTrue(perm.print == 1)
|
||||
self.assertTrue(perm.share == 1)
|
||||
self.assertTrue(perm.email == 1)
|
||||
|
||||
def tearDown(self):
|
||||
frappe.db.rollback()
|
||||
|
||||
|
|
|
|||
|
|
@ -137,13 +137,10 @@ class UserType(Document):
|
|||
user.set("block_modules", block_modules)
|
||||
|
||||
def add_role_permissions_for_user_doctypes(self):
|
||||
perms = ["read", "write", "create", "submit", "cancel", "amend", "delete"]
|
||||
perms = ["read", "write", "create", "submit", "cancel", "amend", "delete", "print", "email", "share"]
|
||||
for row in self.user_doctypes:
|
||||
docperm = add_role_permissions(row.document_type, self.role)
|
||||
|
||||
values = {perm: row.get(perm, default=0) for perm in perms}
|
||||
for perm in ["print", "email", "share"]:
|
||||
values[perm] = 1
|
||||
|
||||
frappe.db.set_value("Custom DocPerm", docperm, values)
|
||||
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ frappe.PermissionEngine = class PermissionEngine {
|
|||
|
||||
reset_std_permissions(data) {
|
||||
let doctype = this.get_doctype();
|
||||
let d = frappe.confirm(__("Reset Permissions for {0}?", [doctype]), () => {
|
||||
let d = frappe.confirm(__("Reset Permissions for {0}?", [__(doctype)]), () => {
|
||||
return frappe
|
||||
.call({
|
||||
module: "frappe.core",
|
||||
|
|
@ -122,7 +122,7 @@ frappe.PermissionEngine = class PermissionEngine {
|
|||
// show standard permissions
|
||||
let $d = $(d.wrapper)
|
||||
.find(".frappe-confirm-message")
|
||||
.append("<hr><h5>Standard Permissions:</h5><br>");
|
||||
.append(`<hr><h5>${__("Standard Permissions")}:</h5><br>`);
|
||||
let $wrapper = $("<p></p>").appendTo($d);
|
||||
data.message.forEach((d) => {
|
||||
let rights = this.rights
|
||||
|
|
@ -134,7 +134,7 @@ frappe.PermissionEngine = class PermissionEngine {
|
|||
d.rights = rights.join(", ");
|
||||
|
||||
$wrapper.append(`<div class="row">\
|
||||
<div class="col-xs-5"><b>${d.role}</b>, Level ${d.permlevel || 0}</div>\
|
||||
<div class="col-xs-5"><b>${__(d.role)}</b>, ${__("Level")} ${d.permlevel || 0}</div>\
|
||||
<div class="col-xs-7">${d.rights}</div>\
|
||||
</div><br>`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@
|
|||
"link_to": "Document Share Report",
|
||||
"link_type": "Report",
|
||||
"onboard": 0,
|
||||
"report_ref_doctype": "DocShare",
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
|
|
@ -158,7 +159,7 @@
|
|||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2024-01-02 15:39:13.811700",
|
||||
"modified": "2024-08-03 13:14:36.129599",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "Users",
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ class Database:
|
|||
def _transform_query(self, query: Query, values: QueryValues) -> tuple:
|
||||
return query, values
|
||||
|
||||
def _transform_result(self, result: list[tuple]) -> list[tuple]:
|
||||
def _transform_result(self, result: list[tuple] | tuple[tuple]) -> tuple[tuple]:
|
||||
return result
|
||||
|
||||
def _clean_up(self):
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import re
|
|||
|
||||
import psycopg2
|
||||
import psycopg2.extensions
|
||||
from psycopg2 import sql
|
||||
from psycopg2.errorcodes import (
|
||||
CLASS_INTEGRITY_CONSTRAINT_VIOLATION,
|
||||
DEADLOCK_DETECTED,
|
||||
|
|
@ -169,6 +170,15 @@ class PostgresDatabase(PostgresExceptionUtil, Database):
|
|||
def last_query(self):
|
||||
return LazyDecode(self._cursor.query)
|
||||
|
||||
@property
|
||||
def db_schema(self):
|
||||
return frappe.conf.get("db_schema", "public").replace("'", "").replace('"', "")
|
||||
|
||||
def connect(self):
|
||||
super().connect()
|
||||
|
||||
self._cursor.execute("SET search_path TO %s", (self.db_schema,))
|
||||
|
||||
def get_connection(self):
|
||||
conn_settings = {
|
||||
"dbname": self.cur_db_name,
|
||||
|
|
@ -215,6 +225,9 @@ class PostgresDatabase(PostgresExceptionUtil, Database):
|
|||
)
|
||||
return db_size[0].get("database_size")
|
||||
|
||||
def _transform_result(self, result: list[tuple] | tuple[tuple]) -> tuple[tuple]:
|
||||
return tuple(result) if isinstance(result, list) else result
|
||||
|
||||
# pylint: disable=W0221
|
||||
def sql(self, query, values=EmptyQueryValues, *args, **kwargs):
|
||||
return super().sql(modify_query(query), modify_values(values), *args, **kwargs)
|
||||
|
|
@ -228,12 +241,34 @@ class PostgresDatabase(PostgresExceptionUtil, Database):
|
|||
for d in self.sql(
|
||||
"""select table_name
|
||||
from information_schema.tables
|
||||
where table_catalog='{}'
|
||||
where table_catalog=%s
|
||||
and table_type = 'BASE TABLE'
|
||||
and table_schema='{}'""".format(self.cur_db_name, frappe.conf.get("db_schema", "public"))
|
||||
and table_schema=%s""",
|
||||
(self.cur_db_name, self.db_schema),
|
||||
)
|
||||
]
|
||||
|
||||
def get_db_table_columns(self, table) -> list[str]:
|
||||
"""Returns list of column names from given table."""
|
||||
if (columns := frappe.cache.hget("table_columns", table)) is not None:
|
||||
return columns
|
||||
|
||||
information_schema = frappe.qb.Schema("information_schema")
|
||||
|
||||
columns = (
|
||||
frappe.qb.from_(information_schema.columns)
|
||||
.select(information_schema.columns.column_name)
|
||||
.where(
|
||||
(information_schema.columns.table_name == table)
|
||||
& (information_schema.columns.table_schema == self.db_schema)
|
||||
)
|
||||
.run(pluck=True)
|
||||
)
|
||||
|
||||
frappe.cache.hset("table_columns", table, columns)
|
||||
|
||||
return columns
|
||||
|
||||
def format_date(self, date):
|
||||
if not date:
|
||||
return "0001-01-01"
|
||||
|
|
@ -260,7 +295,7 @@ class PostgresDatabase(PostgresExceptionUtil, Database):
|
|||
def describe(self, doctype: str) -> list | tuple:
|
||||
table_name = get_table_name(doctype)
|
||||
return self.sql(
|
||||
f"SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_NAME = '{table_name}'"
|
||||
f"SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_NAME = '{table_name}' and table_schema='{frappe.conf.get('db_schema', 'public')}'"
|
||||
)
|
||||
|
||||
def change_column_type(
|
||||
|
|
@ -349,8 +384,10 @@ class PostgresDatabase(PostgresExceptionUtil, Database):
|
|||
|
||||
def has_index(self, table_name, index_name):
|
||||
return self.sql(
|
||||
f"""SELECT 1 FROM pg_indexes WHERE tablename='{table_name}'
|
||||
and indexname='{index_name}' limit 1"""
|
||||
"""SELECT 1 FROM pg_indexes WHERE tablename=%s
|
||||
and schemaname = %s
|
||||
and indexname=%s limit 1""",
|
||||
(table_name, self.db_schema, index_name),
|
||||
)
|
||||
|
||||
def add_index(self, doctype: str, fields: list, index_name: str | None = None):
|
||||
|
|
@ -360,7 +397,9 @@ class PostgresDatabase(PostgresExceptionUtil, Database):
|
|||
index_name = index_name or self.get_index_name(fields)
|
||||
fields_str = '", "'.join(re.sub(r"\(.*\)", "", field) for field in fields)
|
||||
|
||||
self.sql_ddl(f'CREATE INDEX IF NOT EXISTS "{index_name}" ON `{table_name}` ("{fields_str}")')
|
||||
self.sql_ddl(
|
||||
f'CREATE INDEX IF NOT EXISTS "{index_name}" ON "{self.db_schema}"."{table_name}" ("{fields_str}")'
|
||||
)
|
||||
|
||||
def add_unique(self, doctype, fields, constraint_name=None):
|
||||
if isinstance(fields, str):
|
||||
|
|
@ -374,13 +413,24 @@ class PostgresDatabase(PostgresExceptionUtil, Database):
|
|||
FROM information_schema.TABLE_CONSTRAINTS
|
||||
WHERE table_name=%s
|
||||
AND constraint_type='UNIQUE'
|
||||
AND constraint_schema=%s
|
||||
AND CONSTRAINT_NAME=%s""",
|
||||
("tab" + doctype, constraint_name),
|
||||
("tab" + doctype, self.db_schema, constraint_name),
|
||||
):
|
||||
self.commit()
|
||||
|
||||
self.sql(
|
||||
"""ALTER TABLE `tab{}`
|
||||
ADD CONSTRAINT {} UNIQUE ({})""".format(doctype, constraint_name, ", ".join(fields))
|
||||
sql.SQL(
|
||||
"""ALTER TABLE {schema}.{table}
|
||||
ADD CONSTRAINT {constraint} UNIQUE ({fields})"""
|
||||
)
|
||||
.format(
|
||||
schema=sql.Identifier(self.db_schema),
|
||||
table=sql.Identifier("tab" + doctype),
|
||||
constraint=sql.Identifier(constraint_name),
|
||||
fields=sql.SQL(", ").join(sql.Identifier(field) for field in fields),
|
||||
)
|
||||
.as_string(self._conn)
|
||||
)
|
||||
|
||||
def get_table_columns_description(self, table_name):
|
||||
|
|
@ -404,9 +454,10 @@ class PostgresDatabase(PostgresExceptionUtil, Database):
|
|||
indexdef LIKE '%UNIQUE INDEX%' AS unique,
|
||||
indexdef NOT LIKE '%UNIQUE INDEX%' AS index
|
||||
FROM pg_indexes
|
||||
WHERE tablename='{table_name}') b
|
||||
WHERE tablename='{table_name}' AND schemaname='{self.db_schema}') b
|
||||
ON SUBSTRING(b.indexdef, '(.*)') LIKE CONCAT('%', a.column_name, '%')
|
||||
WHERE a.table_name = '{table_name}'
|
||||
AND a.table_schema = '{self.db_schema}'
|
||||
GROUP BY a.column_name, a.data_type, a.column_default, a.character_maximum_length, a.is_nullable;
|
||||
""",
|
||||
as_dict=1,
|
||||
|
|
@ -423,6 +474,7 @@ class PostgresDatabase(PostgresExceptionUtil, Database):
|
|||
.where(
|
||||
(information_schema.columns.table_name == table)
|
||||
& (information_schema.columns.column_name == column)
|
||||
& (information_schema.columns.table_schema == self.db_schema)
|
||||
)
|
||||
.run(pluck=True)[0]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
# Author - Shivam Mishra <shivam@frappe.io>
|
||||
|
||||
from functools import wraps
|
||||
from json import dumps, loads
|
||||
from json import JSONDecodeError, dumps, loads
|
||||
|
||||
import frappe
|
||||
from frappe import DoesNotExistError, ValidationError, _, _dict
|
||||
|
|
@ -450,6 +450,14 @@ def get_workspace_sidebar_items():
|
|||
pages = []
|
||||
private_pages = []
|
||||
|
||||
# get additional settings from Work Settings
|
||||
try:
|
||||
workspace_visibilty = loads(
|
||||
frappe.db.get_single_value("Workspace Settings", "workspace_visibility_json") or "{}"
|
||||
)
|
||||
except JSONDecodeError:
|
||||
workspace_visibilty = {}
|
||||
|
||||
# Filter Page based on Permission
|
||||
for page in all_pages:
|
||||
try:
|
||||
|
|
@ -460,6 +468,10 @@ def get_workspace_sidebar_items():
|
|||
elif page.for_user == frappe.session.user:
|
||||
private_pages.append(page)
|
||||
page["label"] = _(page.get("name"))
|
||||
|
||||
if page["name"] in workspace_visibilty:
|
||||
page["visibility"] = workspace_visibilty[page["name"]]
|
||||
|
||||
except frappe.PermissionError:
|
||||
pass
|
||||
if private_pages:
|
||||
|
|
@ -470,6 +482,9 @@ def get_workspace_sidebar_items():
|
|||
pages[0]["label"] = _("Welcome Workspace")
|
||||
|
||||
return {
|
||||
"workspace_setup_completed": frappe.db.get_single_value(
|
||||
"Workspace Settings", "workspace_setup_completed"
|
||||
),
|
||||
"pages": pages,
|
||||
"has_access": has_access,
|
||||
"has_create_access": frappe.has_permission(doctype="Workspace", ptype="create"),
|
||||
|
|
|
|||
|
|
@ -397,7 +397,7 @@ frappe.ui.form.on("Dashboard Chart", {
|
|||
}
|
||||
}
|
||||
},
|
||||
primary_action_label: "Set",
|
||||
primary_action_label: __("Set"),
|
||||
});
|
||||
frappe.dashboards.filters_dialog = dialog;
|
||||
|
||||
|
|
@ -484,7 +484,7 @@ frappe.ui.form.on("Dashboard Chart", {
|
|||
}
|
||||
frm.trigger("set_dynamic_filters_in_table");
|
||||
},
|
||||
primary_action_label: "Set",
|
||||
primary_action_label: __("Set"),
|
||||
});
|
||||
|
||||
dialog.show();
|
||||
|
|
|
|||
|
|
@ -279,6 +279,16 @@ def get_group_by_chart_config(chart, filters) -> dict | None:
|
|||
ignore_ifnull=True,
|
||||
)
|
||||
|
||||
group_by_field_field = frappe.get_meta(doctype).get_field(
|
||||
group_by_field
|
||||
) # get info about @group_by_field
|
||||
|
||||
if data and group_by_field_field.fieldtype == "Link": # if @group_by_field is link
|
||||
title_field = frappe.get_meta(group_by_field_field.options) # get title field
|
||||
if title_field.title_field: # if has title_field
|
||||
for item in data: # replace chart labels from name to title value
|
||||
item.name = frappe.get_value(group_by_field_field.options, item.name, title_field.title_field)
|
||||
|
||||
if data:
|
||||
return {
|
||||
"labels": [item.get("name", "Not Specified") for item in data],
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@
|
|||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Type",
|
||||
"options": "Mention\nEnergy Point\nAssignment\nShare\nAlert"
|
||||
"options": "\nMention\nEnergy Point\nAssignment\nShare\nAlert"
|
||||
},
|
||||
{
|
||||
"fieldname": "email_content",
|
||||
|
|
@ -103,7 +103,7 @@
|
|||
"hide_toolbar": 1,
|
||||
"in_create": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-23 16:03:31.715461",
|
||||
"modified": "2024-08-03 09:38:10.497711",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Desk",
|
||||
"name": "Notification Log",
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ class NotificationLog(Document):
|
|||
link: DF.Data | None
|
||||
read: DF.Check
|
||||
subject: DF.Text | None
|
||||
type: DF.Literal["Mention", "Energy Point", "Assignment", "Share", "Alert"]
|
||||
type: DF.Literal["", "Mention", "Energy Point", "Assignment", "Share", "Alert"]
|
||||
# end: auto-generated types
|
||||
|
||||
def after_insert(self):
|
||||
|
|
@ -93,6 +93,7 @@ def enqueue_create_notification(users: list[str] | str, doc: dict):
|
|||
doc=doc,
|
||||
users=users,
|
||||
now=frappe.flags.in_test,
|
||||
enqueue_after_commit=not frappe.flags.in_test,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -59,9 +59,10 @@ def is_email_notifications_enabled_for_type(user, notification_type):
|
|||
return False
|
||||
|
||||
fieldname = "enable_email_" + frappe.scrub(notification_type)
|
||||
enabled = frappe.db.get_value("Notification Settings", user, fieldname)
|
||||
enabled = frappe.db.get_value("Notification Settings", user, fieldname, ignore=True)
|
||||
if enabled is None:
|
||||
return True
|
||||
|
||||
return enabled
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ frappe.ui.form.on("Number Card", {
|
|||
},
|
||||
|
||||
create_add_to_dashboard_button: function (frm) {
|
||||
frm.add_custom_button("Add Card to Dashboard", () => {
|
||||
frm.add_custom_button(__("Add Card to Dashboard"), () => {
|
||||
const dialog = frappe.dashboard_utils.get_add_to_dashboard_dialog(
|
||||
frm.doc.name,
|
||||
"Number Card",
|
||||
|
|
@ -292,7 +292,7 @@ frappe.ui.form.on("Number Card", {
|
|||
frm.trigger("render_filters_table");
|
||||
}
|
||||
},
|
||||
primary_action_label: "Set",
|
||||
primary_action_label: __("Set"),
|
||||
});
|
||||
|
||||
if (is_document_type) {
|
||||
|
|
@ -384,7 +384,7 @@ frappe.ui.form.on("Number Card", {
|
|||
}
|
||||
frm.trigger("set_dynamic_filters_in_table");
|
||||
},
|
||||
primary_action_label: "Set",
|
||||
primary_action_label: __("Set"),
|
||||
});
|
||||
|
||||
dialog.show();
|
||||
|
|
|
|||
|
|
@ -186,19 +186,40 @@ class SystemHealthReport(Document):
|
|||
# Exclude "maybe" curently executing job
|
||||
upper_threshold = add_to_date(None, minutes=-30, as_datetime=True)
|
||||
self.scheduler_status = get_scheduler_status().get("status")
|
||||
failing_jobs = frappe.db.sql(
|
||||
"""
|
||||
select scheduled_job_type,
|
||||
avg(CASE WHEN status != 'Complete' THEN 1 ELSE 0 END) * 100 as failure_rate
|
||||
from `tabScheduled Job Log`
|
||||
where
|
||||
creation > %(lower_threshold)s
|
||||
and modified > %(lower_threshold)s
|
||||
and creation < %(upper_threshold)s
|
||||
group by scheduled_job_type
|
||||
having failure_rate > 0
|
||||
order by failure_rate desc
|
||||
limit 5""",
|
||||
|
||||
mariadb_query = """
|
||||
SELECT scheduled_job_type,
|
||||
AVG(CASE WHEN status != 'Complete' THEN 1 ELSE 0 END) * 100 AS failure_rate
|
||||
FROM `tabScheduled Job Log`
|
||||
WHERE
|
||||
creation > %(lower_threshold)s
|
||||
AND modified > %(lower_threshold)s
|
||||
AND creation < %(upper_threshold)s
|
||||
GROUP BY scheduled_job_type
|
||||
HAVING failure_rate > 0
|
||||
ORDER BY failure_rate DESC
|
||||
LIMIT 5
|
||||
"""
|
||||
|
||||
postgres_query = """
|
||||
SELECT scheduled_job_type,
|
||||
AVG(CASE WHEN status != 'Complete' THEN 1 ELSE 0 END) * 100 AS "failure_rate"
|
||||
FROM "tabScheduled Job Log"
|
||||
WHERE
|
||||
creation > %(lower_threshold)s
|
||||
AND modified > %(lower_threshold)s
|
||||
AND creation < %(upper_threshold)s
|
||||
GROUP BY scheduled_job_type
|
||||
HAVING AVG(CASE WHEN status != 'Complete' THEN 1 ELSE 0 END) * 100 > 0
|
||||
ORDER BY "failure_rate" DESC
|
||||
LIMIT 5
|
||||
"""
|
||||
|
||||
failing_jobs = frappe.db.multisql(
|
||||
{
|
||||
"mariadb": mariadb_query,
|
||||
"postgres": postgres_query,
|
||||
},
|
||||
{"lower_threshold": lower_threshold, "upper_threshold": upper_threshold},
|
||||
as_dict=True,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -80,6 +80,10 @@ class Workspace(Document):
|
|||
except Exception:
|
||||
frappe.throw(_("Content data shoud be a list"))
|
||||
|
||||
for d in self.get("links"):
|
||||
if d.link_type == "Report" and d.is_query_report != 1:
|
||||
d.report_ref_doctype = frappe.get_value("Report", d.link_to, "ref_doctype")
|
||||
|
||||
def clear_cache(self):
|
||||
super().clear_cache()
|
||||
if self.for_user:
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
"link_details_section",
|
||||
"link_type",
|
||||
"link_to",
|
||||
"report_ref_doctype",
|
||||
"column_break_7",
|
||||
"dependencies",
|
||||
"only_for",
|
||||
|
|
@ -116,12 +117,19 @@
|
|||
"ignore_xss_filter": 1,
|
||||
"label": "Description",
|
||||
"max_height": "7rem"
|
||||
},
|
||||
{
|
||||
"fieldname": "report_ref_doctype",
|
||||
"fieldtype": "Link",
|
||||
"label": "Report Ref DocType",
|
||||
"options": "DocType",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-23 16:04:06.025772",
|
||||
"modified": "2024-06-10 16:04:00.746903",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Desk",
|
||||
"name": "Workspace Link",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ class WorkspaceLink(Document):
|
|||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
report_ref_doctype: DF.Link | None
|
||||
type: DF.Literal["Link", "Card Break"]
|
||||
# end: auto-generated types
|
||||
|
||||
|
|
|
|||
0
frappe/desk/doctype/workspace_settings/__init__.py
Normal file
0
frappe/desk/doctype/workspace_settings/__init__.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Copyright (c) 2024, Frappe Technologies and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
|
||||
|
||||
class TestWorkspaceSettings(FrappeTestCase):
|
||||
pass
|
||||
44
frappe/desk/doctype/workspace_settings/workspace_settings.js
Normal file
44
frappe/desk/doctype/workspace_settings/workspace_settings.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// Copyright (c) 2024, Frappe Technologies and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on("Workspace Settings", {
|
||||
setup(frm) {
|
||||
frm.hide_full_form_button = true;
|
||||
frm.docfields = [];
|
||||
let workspace_visibilty = JSON.parse(frm.doc.workspace_visibility_json || "{}");
|
||||
|
||||
// build fields from workspaces
|
||||
let cnt = 0,
|
||||
column_added = false;
|
||||
for (let w of frappe.boot.allowed_workspaces) {
|
||||
if (w.public) {
|
||||
cnt++;
|
||||
frm.docfields.push({
|
||||
fieldtype: "Check",
|
||||
fieldname: w.name,
|
||||
label: w.title,
|
||||
initial_value: workspace_visibilty[w.name] !== 0, // not set is also visible
|
||||
});
|
||||
}
|
||||
|
||||
if (cnt >= frappe.boot.allowed_workspaces.length / 2 && !column_added) {
|
||||
// add column break to split into 2 columns
|
||||
frm.docfields.push({ fieldtype: "Column Break" });
|
||||
column_added = true;
|
||||
}
|
||||
}
|
||||
|
||||
frappe.temp = frm;
|
||||
},
|
||||
validate(frm) {
|
||||
frm.doc.workspace_visibility_json = JSON.stringify(frm.dialog.get_values());
|
||||
frm.doc.workspace_setup_completed = 1;
|
||||
},
|
||||
after_save(frm) {
|
||||
// reload page to show latest sidebar
|
||||
window.location.reload();
|
||||
},
|
||||
refresh(frm) {
|
||||
frm.dialog.set_alert(__("Select modules you want to see in the sidebar"));
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2024-08-02 14:20:30.177818",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"select_workspaces_section",
|
||||
"workspace_visibility_json",
|
||||
"workspace_setup_completed"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "select_workspaces_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Select Workspaces"
|
||||
},
|
||||
{
|
||||
"fieldname": "workspace_visibility_json",
|
||||
"fieldtype": "JSON",
|
||||
"in_list_view": 1,
|
||||
"label": "Workspace Visibility",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "workspace_setup_completed",
|
||||
"fieldtype": "Check",
|
||||
"label": "Workspace Setup Completed"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2024-08-03 19:34:16.757871",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Desk",
|
||||
"name": "Workspace Settings",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
21
frappe/desk/doctype/workspace_settings/workspace_settings.py
Normal file
21
frappe/desk/doctype/workspace_settings/workspace_settings.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Copyright (c) 2024, Frappe Technologies and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class WorkspaceSettings(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
workspace_setup_completed: DF.Check
|
||||
workspace_visibility_json: DF.JSON
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
|
|
@ -421,99 +421,85 @@ def get_linked_docs(doctype: str, name: str, linkinfo: dict | None = None) -> di
|
|||
if not linkinfo:
|
||||
return results
|
||||
|
||||
for dt, link in linkinfo.items():
|
||||
filters = []
|
||||
link["doctype"] = dt
|
||||
try:
|
||||
link_meta_bundle = frappe.desk.form.load.get_meta_bundle(dt)
|
||||
except Exception as e:
|
||||
if isinstance(e, frappe.DoesNotExistError):
|
||||
frappe.clear_last_message()
|
||||
is_target_doctype_table = frappe.get_meta(doctype).istable
|
||||
|
||||
for linked_doctype, link_context in linkinfo.items():
|
||||
linked_doctype_meta = frappe.get_meta(linked_doctype)
|
||||
|
||||
if linked_doctype_meta.issingle:
|
||||
continue
|
||||
linkmeta = link_meta_bundle[0]
|
||||
|
||||
if not linkmeta.get("issingle"):
|
||||
fields = [
|
||||
d.fieldname
|
||||
for d in linkmeta.get(
|
||||
"fields",
|
||||
{
|
||||
"in_list_view": 1,
|
||||
"fieldtype": ["not in", ("Image", "HTML", "Button", *frappe.model.table_fields)],
|
||||
},
|
||||
)
|
||||
] + ["name", "modified", "docstatus"]
|
||||
filters = []
|
||||
ret = None
|
||||
parent_info = None
|
||||
|
||||
if link.get("add_fields"):
|
||||
fields += link["add_fields"]
|
||||
fields = [
|
||||
d.fieldname
|
||||
for d in linked_doctype_meta.get(
|
||||
"fields",
|
||||
{
|
||||
"in_list_view": 1,
|
||||
"fieldtype": ["not in", ("Image", "HTML", "Button", *frappe.model.table_fields)],
|
||||
},
|
||||
)
|
||||
] + ["name", "modified", "docstatus"]
|
||||
|
||||
fields = [f"`tab{dt}`.`{sf.strip()}`" for sf in fields if sf and "`tab" not in sf]
|
||||
if add_fields := link_context.get("add_fields"):
|
||||
fields += add_fields
|
||||
|
||||
try:
|
||||
if link.get("filters"):
|
||||
ret = frappe.get_all(
|
||||
doctype=dt, fields=fields, filters=link.get("filters"), order_by=None
|
||||
)
|
||||
fields = [f"`tab{linked_doctype}`.`{sf.strip()}`" for sf in fields if sf and "`tab" not in sf]
|
||||
|
||||
elif link.get("get_parent"):
|
||||
ret = None
|
||||
|
||||
# check for child table
|
||||
if not frappe.get_meta(doctype).istable:
|
||||
continue
|
||||
|
||||
me = frappe.db.get_value(
|
||||
doctype, name, ["parenttype", "parent"], as_dict=True, order_by=None
|
||||
)
|
||||
if me and me.parenttype == dt:
|
||||
ret = frappe.get_all(
|
||||
doctype=dt, fields=fields, filters=[[dt, "name", "=", me.parent]], order_by=None
|
||||
)
|
||||
|
||||
elif link.get("child_doctype"):
|
||||
or_filters = [
|
||||
[link.get("child_doctype"), link_fieldnames, "=", name]
|
||||
for link_fieldnames in link.get("fieldname")
|
||||
]
|
||||
|
||||
# dynamic link
|
||||
if link.get("doctype_fieldname"):
|
||||
filters.append(
|
||||
[link.get("child_doctype"), link.get("doctype_fieldname"), "=", doctype]
|
||||
)
|
||||
|
||||
ret = frappe.get_all(
|
||||
doctype=dt,
|
||||
fields=fields,
|
||||
filters=filters,
|
||||
or_filters=or_filters,
|
||||
distinct=True,
|
||||
order_by=None,
|
||||
)
|
||||
|
||||
else:
|
||||
link_fieldnames = link.get("fieldname")
|
||||
if link_fieldnames:
|
||||
if isinstance(link_fieldnames, str):
|
||||
link_fieldnames = [link_fieldnames]
|
||||
or_filters = [[dt, fieldname, "=", name] for fieldname in link_fieldnames]
|
||||
# dynamic link
|
||||
if link.get("doctype_fieldname"):
|
||||
filters.append([dt, link.get("doctype_fieldname"), "=", doctype])
|
||||
ret = frappe.get_all(
|
||||
doctype=dt, fields=fields, filters=filters, or_filters=or_filters, order_by=None
|
||||
)
|
||||
|
||||
else:
|
||||
ret = None
|
||||
|
||||
except frappe.PermissionError:
|
||||
frappe.clear_last_message()
|
||||
if filters_ctx := link_context.get("filters"):
|
||||
ret = frappe.get_list(doctype=linked_doctype, fields=fields, filters=filters_ctx, order_by=None)
|
||||
|
||||
elif link_context.get("get_parent"):
|
||||
# check for child table
|
||||
if not is_target_doctype_table:
|
||||
continue
|
||||
|
||||
if ret:
|
||||
results[dt] = ret
|
||||
parent_info = parent_info or frappe.db.get_value(
|
||||
doctype, name, ["parenttype", "parent"], as_dict=True, order_by=None
|
||||
)
|
||||
|
||||
if parent_info and parent_info.parenttype == linked_doctype:
|
||||
ret = frappe.get_list(
|
||||
doctype=linked_doctype,
|
||||
fields=fields,
|
||||
filters=[[linked_doctype, "name", "=", parent_info.parent]],
|
||||
order_by=None,
|
||||
)
|
||||
|
||||
elif child_doctype := link_context.get("child_doctype"):
|
||||
or_filters = [
|
||||
[child_doctype, link_fieldnames, "=", name] for link_fieldnames in link_context["fieldname"]
|
||||
]
|
||||
|
||||
# dynamic link_context
|
||||
if doctype_fieldname := link_context.get("doctype_fieldname"):
|
||||
filters.append([child_doctype, doctype_fieldname, "=", doctype])
|
||||
|
||||
ret = frappe.get_list(
|
||||
doctype=linked_doctype,
|
||||
fields=fields,
|
||||
filters=filters,
|
||||
or_filters=or_filters,
|
||||
distinct=True,
|
||||
order_by=None,
|
||||
)
|
||||
|
||||
elif link_fieldnames := link_context.get("fieldname"):
|
||||
if isinstance(link_fieldnames, str):
|
||||
link_fieldnames = [link_fieldnames]
|
||||
or_filters = [[linked_doctype, fieldname, "=", name] for fieldname in link_fieldnames]
|
||||
# dynamic link_context
|
||||
if doctype_fieldname := link_context.get("doctype_fieldname"):
|
||||
filters.append([linked_doctype, doctype_fieldname, "=", doctype])
|
||||
ret = frappe.get_list(
|
||||
doctype=linked_doctype, fields=fields, filters=filters, or_filters=or_filters, order_by=None
|
||||
)
|
||||
|
||||
if ret:
|
||||
results[linked_doctype] = ret
|
||||
|
||||
return results
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
import json
|
||||
import typing
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
import frappe
|
||||
import frappe.defaults
|
||||
|
|
@ -38,7 +38,7 @@ def getdoc(doctype, name, user=None):
|
|||
|
||||
if not doc.has_permission("read"):
|
||||
frappe.flags.error_message = _("Insufficient Permission for {0}").format(
|
||||
frappe.bold(doctype + " " + name)
|
||||
frappe.bold(_(doctype) + " " + name)
|
||||
)
|
||||
raise frappe.PermissionError(("read", doctype, name))
|
||||
|
||||
|
|
@ -384,7 +384,7 @@ def get_document_email(doctype, name):
|
|||
return None
|
||||
|
||||
email = email.split("@")
|
||||
return f"{email[0]}+{quote(doctype)}={quote(cstr(name))}@{email[1]}"
|
||||
return f"{email[0]}+{quote_plus(doctype)}={quote_plus(cstr(name))}@{email[1]}"
|
||||
|
||||
|
||||
def get_automatic_email_link():
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ class UserProfile {
|
|||
render_heatmap() {
|
||||
this.heatmap = new frappe.Chart(".performance-heatmap", {
|
||||
type: "heatmap",
|
||||
countLabel: "Energy Points",
|
||||
countLabel: __("Energy Points"),
|
||||
data: {},
|
||||
discreteDomains: 1,
|
||||
radius: 3,
|
||||
|
|
@ -111,7 +111,7 @@ class UserProfile {
|
|||
value_based_on: "points",
|
||||
chart_type: "Sum",
|
||||
document_type: "Energy Point Log",
|
||||
name: "Energy Points",
|
||||
name: __("Energy Points"),
|
||||
width: "half",
|
||||
based_on: "creation",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -71,6 +71,9 @@ frappe.ui.form.on("Auto Email Report", {
|
|||
}
|
||||
},
|
||||
show_filters: async function (frm) {
|
||||
if (!frm.doc.report) {
|
||||
return;
|
||||
}
|
||||
var wrapper = $(frm.get_field("filters_display").wrapper);
|
||||
wrapper.empty();
|
||||
let reference_report = frappe.query_reports[frm.doc.report];
|
||||
|
|
|
|||
|
|
@ -1,4 +1,24 @@
|
|||
frappe.email_defaults = {
|
||||
"Frappe Mail": {
|
||||
domain: null,
|
||||
password: null,
|
||||
awaiting_password: 0,
|
||||
ascii_encode_password: 0,
|
||||
login_id_is_different: 0,
|
||||
login_id: null,
|
||||
use_imap: 0,
|
||||
use_ssl: 0,
|
||||
validate_ssl_certificate: 0,
|
||||
use_starttls: 0,
|
||||
email_server: null,
|
||||
incoming_port: 0,
|
||||
always_use_account_email_id_as_sender: 1,
|
||||
use_tls: 0,
|
||||
use_ssl_for_outgoing: 0,
|
||||
smtp_server: null,
|
||||
smtp_port: null,
|
||||
no_smtp_authentication: 0,
|
||||
},
|
||||
GMail: {
|
||||
email_server: "imap.gmail.com",
|
||||
incoming_port: 993,
|
||||
|
|
@ -144,11 +164,11 @@ frappe.ui.form.on("Email Account", {
|
|||
frm.refresh_field("imap_folder");
|
||||
}
|
||||
set_default_max_attachment_size(frm);
|
||||
frm.events.show_oauth_authorization_message(frm);
|
||||
},
|
||||
|
||||
refresh: function (frm) {
|
||||
frm.events.enable_incoming(frm);
|
||||
frm.events.show_oauth_authorization_message(frm);
|
||||
|
||||
if (frappe.route_flags.delete_user_from_locals && frappe.route_flags.linked_user) {
|
||||
delete frappe.route_flags.delete_user_from_locals;
|
||||
|
|
@ -175,8 +195,21 @@ frappe.ui.form.on("Email Account", {
|
|||
oauth_access(frm);
|
||||
},
|
||||
|
||||
validate_frappe_mail_settings: function (frm) {
|
||||
if (frm.doc.service == "Frappe Mail") {
|
||||
frappe.call({
|
||||
doc: frm.doc,
|
||||
method: "validate_frappe_mail_settings",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
show_oauth_authorization_message(frm) {
|
||||
if (frm.doc.auth_method === "OAuth" && frm.doc.connected_app) {
|
||||
if (
|
||||
frm.doc.auth_method === "OAuth" &&
|
||||
frm.doc.connected_app &&
|
||||
!frm.doc.backend_app_flow
|
||||
) {
|
||||
frappe.call({
|
||||
method: "frappe.integrations.doctype.connected_app.connected_app.has_token",
|
||||
args: {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:email_account_name",
|
||||
"creation": "2014-09-11 12:04:34.163728",
|
||||
"creation": "2024-06-11 16:39:01.323289",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Setup",
|
||||
"engine": "InnoDB",
|
||||
|
|
@ -13,22 +13,31 @@
|
|||
"enable_incoming",
|
||||
"enable_outgoing",
|
||||
"column_break_3",
|
||||
"domain",
|
||||
"service",
|
||||
"domain",
|
||||
"frappe_mail_site",
|
||||
"authentication_column",
|
||||
"auth_method",
|
||||
"backend_app_flow",
|
||||
"authorize_api_access",
|
||||
"validate_frappe_mail_settings",
|
||||
"password",
|
||||
"awaiting_password",
|
||||
"ascii_encode_password",
|
||||
"column_break_10",
|
||||
"api_key",
|
||||
"api_secret",
|
||||
"connected_app",
|
||||
"connected_user",
|
||||
"login_id_is_different",
|
||||
"login_id",
|
||||
"incoming_popimap_tab",
|
||||
"mailbox_settings",
|
||||
"section_break_uc6h",
|
||||
"default_incoming",
|
||||
"column_break_uynb",
|
||||
"attachment_limit",
|
||||
"last_synced_at",
|
||||
"mailbox_settings",
|
||||
"use_imap",
|
||||
"use_ssl",
|
||||
"validate_ssl_certificate",
|
||||
|
|
@ -36,7 +45,6 @@
|
|||
"email_server",
|
||||
"incoming_port",
|
||||
"column_break_18",
|
||||
"attachment_limit",
|
||||
"email_sync_option",
|
||||
"initial_sync_count",
|
||||
"section_break_25",
|
||||
|
|
@ -50,19 +58,19 @@
|
|||
"notify_if_unreplied",
|
||||
"unreplied_for_mins",
|
||||
"send_notification_to",
|
||||
"outgoing_smtp_tab",
|
||||
"outgoing_tab",
|
||||
"default_outgoing",
|
||||
"column_break_h5pd",
|
||||
"always_use_account_email_id_as_sender",
|
||||
"always_use_account_name_as_sender_name",
|
||||
"send_unsubscribe_message",
|
||||
"track_email_status",
|
||||
"outgoing_mail_settings",
|
||||
"column_break_bidn",
|
||||
"use_tls",
|
||||
"use_ssl_for_outgoing",
|
||||
"smtp_server",
|
||||
"smtp_port",
|
||||
"column_break_38",
|
||||
"default_outgoing",
|
||||
"always_use_account_email_id_as_sender",
|
||||
"always_use_account_name_as_sender_name",
|
||||
"send_unsubscribe_message",
|
||||
"track_email_status",
|
||||
"no_smtp_authentication",
|
||||
"signature_section",
|
||||
"add_signature",
|
||||
|
|
@ -92,6 +100,7 @@
|
|||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval: doc.service != \"Frappe Mail\" && !doc.backend_app_flow",
|
||||
"fieldname": "login_id_is_different",
|
||||
"fieldtype": "Check",
|
||||
"hide_days": 1,
|
||||
|
|
@ -107,7 +116,7 @@
|
|||
"label": "Alternative Email ID"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.auth_method === \"Basic\"",
|
||||
"depends_on": "eval: doc.auth_method === \"Basic\" && doc.service != \"Frappe Mail\"",
|
||||
"fieldname": "password",
|
||||
"fieldtype": "Password",
|
||||
"hide_days": 1,
|
||||
|
|
@ -116,7 +125,7 @@
|
|||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval: doc.auth_method === \"Basic\"",
|
||||
"depends_on": "eval: doc.auth_method === \"Basic\" && doc.service != \"Frappe Mail\"",
|
||||
"fieldname": "awaiting_password",
|
||||
"fieldtype": "Check",
|
||||
"hide_days": 1,
|
||||
|
|
@ -125,7 +134,7 @@
|
|||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval: doc.auth_method === \"Basic\"",
|
||||
"depends_on": "eval: doc.auth_method === \"Basic\" && doc.service != \"Frappe Mail\"",
|
||||
"fieldname": "ascii_encode_password",
|
||||
"fieldtype": "Check",
|
||||
"hide_days": 1,
|
||||
|
|
@ -159,9 +168,10 @@
|
|||
"hide_days": 1,
|
||||
"hide_seconds": 1,
|
||||
"label": "Service",
|
||||
"options": "\nGMail\nSendgrid\nSparkPost\nYahoo Mail\nOutlook.com\nYandex.Mail"
|
||||
"options": "\nFrappe Mail\nGMail\nSendgrid\nSparkPost\nYahoo Mail\nOutlook.com\nYandex.Mail"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.service != \"Frappe Mail\"",
|
||||
"fieldname": "mailbox_settings",
|
||||
"fieldtype": "Section Break",
|
||||
"hide_days": 1,
|
||||
|
|
@ -289,6 +299,7 @@
|
|||
"mandatory_depends_on": "notify_if_unreplied"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.service != \"Frappe Mail\"",
|
||||
"fieldname": "outgoing_mail_settings",
|
||||
"fieldtype": "Section Break",
|
||||
"hide_days": 1,
|
||||
|
|
@ -571,7 +582,7 @@
|
|||
"label": "IMAP Details"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.auth_method === \"OAuth\" && doc.connected_app && doc.connected_user",
|
||||
"depends_on": "eval: doc.auth_method === \"OAuth\" && doc.connected_app && doc.connected_user && !doc.backend_app_flow",
|
||||
"fieldname": "authorize_api_access",
|
||||
"fieldtype": "Button",
|
||||
"label": "Authorize API Access"
|
||||
|
|
@ -600,11 +611,11 @@
|
|||
"options": "Connected App"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.auth_method === \"OAuth\"",
|
||||
"depends_on": "eval: doc.auth_method === \"OAuth\" && !doc.backend_app_flow",
|
||||
"fieldname": "connected_user",
|
||||
"fieldtype": "Link",
|
||||
"label": "Connected User",
|
||||
"mandatory_depends_on": "eval: doc.auth_method === \"OAuth\"",
|
||||
"mandatory_depends_on": "eval: doc.auth_method === \"OAuth\" && !doc.backend_app_flow",
|
||||
"options": "User"
|
||||
},
|
||||
{
|
||||
|
|
@ -621,23 +632,72 @@
|
|||
"depends_on": "enable_incoming",
|
||||
"fieldname": "incoming_popimap_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Incoming (POP/IMAP)"
|
||||
"label": "Incoming"
|
||||
},
|
||||
{
|
||||
"default": "https://frappemail.com",
|
||||
"depends_on": "eval: doc.service == \"Frappe Mail\"",
|
||||
"fieldname": "frappe_mail_site",
|
||||
"fieldtype": "Data",
|
||||
"label": "Frappe Mail Site",
|
||||
"mandatory_depends_on": "eval: doc.service == \"Frappe Mail\""
|
||||
},
|
||||
{
|
||||
"depends_on": "enable_outgoing",
|
||||
"fieldname": "outgoing_smtp_tab",
|
||||
"fieldname": "outgoing_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Outgoing (SMTP)"
|
||||
"label": "Outgoing"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_bidn",
|
||||
"fieldname": "column_break_h5pd",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_uc6h",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_uynb",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.service == \"Frappe Mail\"",
|
||||
"fieldname": "last_synced_at",
|
||||
"fieldtype": "Datetime",
|
||||
"label": "Last Synced At"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: (doc.service == \"Frappe Mail\" && doc.auth_method != \"Basic\" && !doc.__islocal && !doc.__unsaved)",
|
||||
"fieldname": "validate_frappe_mail_settings",
|
||||
"fieldtype": "Button",
|
||||
"label": "Validate Frappe Mail Settings"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.service == \"Frappe Mail\" && doc.auth_method == \"Basic\"",
|
||||
"fieldname": "api_key",
|
||||
"fieldtype": "Data",
|
||||
"label": "API Key",
|
||||
"mandatory_depends_on": "eval: doc.service == \"Frappe Mail\" && doc.auth_method == \"Basic\""
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.service == \"Frappe Mail\" && doc.auth_method == \"Basic\"",
|
||||
"fieldname": "api_secret",
|
||||
"fieldtype": "Password",
|
||||
"label": "API Secret",
|
||||
"mandatory_depends_on": "eval: doc.service == \"Frappe Mail\" && doc.auth_method == \"Basic\""
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval: doc.auth_method === \"OAuth\"",
|
||||
"fieldname": "backend_app_flow",
|
||||
"fieldtype": "Check",
|
||||
"label": "Authenticate as Service Principal"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-inbox",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-04-17 14:46:38.836631",
|
||||
"modified": "2024-07-18 11:05:57.193762",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Email",
|
||||
"name": "Email Account",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import frappe
|
|||
from frappe import _, are_emails_muted, safe_encode
|
||||
from frappe.desk.form import assign_to
|
||||
from frappe.email.doctype.email_domain.email_domain import EMAIL_DOMAIN_FIELDS
|
||||
from frappe.email.frappemail import FrappeMail
|
||||
from frappe.email.receive import EmailServer, InboundMail, SentEmailInInboxError
|
||||
from frappe.email.smtp import SMTPServer
|
||||
from frappe.email.utils import get_port
|
||||
|
|
@ -61,6 +62,8 @@ class EmailAccount(Document):
|
|||
add_signature: DF.Check
|
||||
always_use_account_email_id_as_sender: DF.Check
|
||||
always_use_account_name_as_sender_name: DF.Check
|
||||
api_key: DF.Data | None
|
||||
api_secret: DF.Password | None
|
||||
append_emails_to_sent_folder: DF.Check
|
||||
append_to: DF.Link | None
|
||||
ascii_encode_password: DF.Check
|
||||
|
|
@ -68,6 +71,7 @@ class EmailAccount(Document):
|
|||
auth_method: DF.Literal["Basic", "OAuth"]
|
||||
auto_reply_message: DF.TextEditor | None
|
||||
awaiting_password: DF.Check
|
||||
backend_app_flow: DF.Check
|
||||
brand_logo: DF.AttachImage | None
|
||||
connected_app: DF.Link | None
|
||||
connected_user: DF.Link | None
|
||||
|
|
@ -84,9 +88,11 @@ class EmailAccount(Document):
|
|||
enable_incoming: DF.Check
|
||||
enable_outgoing: DF.Check
|
||||
footer: DF.TextEditor | None
|
||||
frappe_mail_site: DF.Data | None
|
||||
imap_folder: DF.Table[IMAPFolder]
|
||||
incoming_port: DF.Data | None
|
||||
initial_sync_count: DF.Literal["100", "250", "500"]
|
||||
last_synced_at: DF.Datetime | None
|
||||
login_id: DF.Data | None
|
||||
login_id_is_different: DF.Check
|
||||
no_failed: DF.Int
|
||||
|
|
@ -96,13 +102,7 @@ class EmailAccount(Document):
|
|||
send_notification_to: DF.SmallText | None
|
||||
send_unsubscribe_message: DF.Check
|
||||
service: DF.Literal[
|
||||
"",
|
||||
"GMail",
|
||||
"Sendgrid",
|
||||
"SparkPost",
|
||||
"Yahoo Mail",
|
||||
"Outlook.com",
|
||||
"Yandex.Mail",
|
||||
"", "Frappe Mail", "GMail", "Sendgrid", "SparkPost", "Yahoo Mail", "Outlook.com", "Yandex.Mail"
|
||||
]
|
||||
signature: DF.TextEditor | None
|
||||
smtp_port: DF.Data | None
|
||||
|
|
@ -142,6 +142,13 @@ class EmailAccount(Document):
|
|||
else:
|
||||
self.login_id = None
|
||||
|
||||
if self.service == "Frappe Mail":
|
||||
self.use_imap = 0
|
||||
self.always_use_account_email_id_as_sender = 1
|
||||
|
||||
if self.auth_method == "Basic" or self.get_oauth_token():
|
||||
self.validate_frappe_mail_settings()
|
||||
|
||||
# validate the imap settings
|
||||
if self.enable_incoming and self.use_imap and len(self.imap_folder) <= 0:
|
||||
frappe.throw(_("You need to set one IMAP folder for {0}").format(frappe.bold(self.email_id)))
|
||||
|
|
@ -158,7 +165,11 @@ class EmailAccount(Document):
|
|||
self.awaiting_password = 0
|
||||
self.password = None
|
||||
|
||||
if not frappe.local.flags.in_install and not self.awaiting_password:
|
||||
if (
|
||||
not frappe.local.flags.in_install
|
||||
and not self.awaiting_password
|
||||
and not self.service == "Frappe Mail"
|
||||
):
|
||||
if validate_oauth or self.password or self.smtp_server in ("127.0.0.1", "localhost"):
|
||||
if self.enable_incoming:
|
||||
self.get_incoming_server()
|
||||
|
|
@ -184,6 +195,12 @@ class EmailAccount(Document):
|
|||
if folder.append_to not in valid_doctypes:
|
||||
frappe.throw(_("Append To can be one of {0}").format(comma_or(valid_doctypes)))
|
||||
|
||||
@frappe.whitelist()
|
||||
def validate_frappe_mail_settings(self):
|
||||
if self.service == "Frappe Mail":
|
||||
frappe_mail_client = self.get_frappe_mail_client()
|
||||
frappe_mail_client.validate(for_inbound=self.enable_incoming, for_outbound=self.enable_outgoing)
|
||||
|
||||
def validate_smtp_conn(self):
|
||||
if not self.smtp_server:
|
||||
frappe.throw(_("SMTP Server is required"))
|
||||
|
|
@ -476,9 +493,11 @@ class EmailAccount(Document):
|
|||
|
||||
return account_details
|
||||
|
||||
def sendmail_config(self):
|
||||
def get_access_token(self) -> str | None:
|
||||
oauth_token = self.get_oauth_token()
|
||||
return oauth_token.get_password("access_token") if oauth_token else None
|
||||
|
||||
def sendmail_config(self):
|
||||
return {
|
||||
"email_account": self.name,
|
||||
"server": self.smtp_server,
|
||||
|
|
@ -488,7 +507,7 @@ class EmailAccount(Document):
|
|||
"use_ssl": cint(self.use_ssl_for_outgoing),
|
||||
"use_tls": cint(self.use_tls),
|
||||
"use_oauth": self.auth_method == "OAuth",
|
||||
"access_token": oauth_token.get_password("access_token") if oauth_token else None,
|
||||
"access_token": self.get_access_token(),
|
||||
}
|
||||
|
||||
def get_smtp_server(self):
|
||||
|
|
@ -504,6 +523,26 @@ class EmailAccount(Document):
|
|||
config = self.sendmail_config()
|
||||
return SMTPServer(**config)
|
||||
|
||||
def get_frappe_mail_client(self):
|
||||
return self._frappe_mail_client
|
||||
|
||||
@functools.cached_property
|
||||
def _frappe_mail_client(self):
|
||||
if self.auth_method == "OAuth":
|
||||
if access_token := self.get_access_token():
|
||||
return FrappeMail(self.frappe_mail_site, self.email_id, access_token=access_token)
|
||||
|
||||
frappe.throw(
|
||||
_("Please Authorize OAuth for Email Account {0}").format(
|
||||
frappe.bold(self.email_account_name)
|
||||
),
|
||||
title=_("Frappe Mail OAuth Error"),
|
||||
)
|
||||
else:
|
||||
return FrappeMail(
|
||||
self.frappe_mail_site, self.email_id, self.api_key, self.get_password("api_secret")
|
||||
)
|
||||
|
||||
def remove_unpicklable_values(self, state):
|
||||
super().remove_unpicklable_values(state)
|
||||
state.pop("_smtp_server_instance", None)
|
||||
|
|
@ -561,10 +600,15 @@ class EmailAccount(Document):
|
|||
frappe.db.rollback()
|
||||
except Exception:
|
||||
frappe.db.rollback()
|
||||
self.log_error(title="EmailAccount.receive")
|
||||
if self.use_imap:
|
||||
self.handle_bad_emails(mail.uid, mail.raw_message, frappe.get_traceback())
|
||||
exceptions.append(frappe.get_traceback())
|
||||
try:
|
||||
self.log_error(title="EmailAccount.receive")
|
||||
if self.use_imap:
|
||||
self.handle_bad_emails(mail.uid, mail.raw_message, frappe.get_traceback())
|
||||
exceptions.append(frappe.get_traceback())
|
||||
except Exception:
|
||||
frappe.db.rollback()
|
||||
else:
|
||||
frappe.db.commit()
|
||||
else:
|
||||
frappe.db.commit()
|
||||
|
||||
|
|
@ -594,25 +638,33 @@ class EmailAccount(Document):
|
|||
if not self.enable_incoming:
|
||||
return []
|
||||
|
||||
email_sync_rule = self.build_email_sync_rule()
|
||||
try:
|
||||
email_server = self.get_incoming_server(in_receive=True, email_sync_rule=email_sync_rule)
|
||||
if self.use_imap:
|
||||
# process all given imap folder
|
||||
for folder in self.imap_folder:
|
||||
if email_server.select_imap_folder(folder.folder_name):
|
||||
email_server.settings["uid_validity"] = folder.uidvalidity
|
||||
messages = email_server.get_messages(folder=f'"{folder.folder_name}"') or {}
|
||||
process_mail(messages, folder.append_to)
|
||||
else:
|
||||
# process the pop3 account
|
||||
messages = email_server.get_messages() or {}
|
||||
if self.service == "Frappe Mail":
|
||||
frappe_mail_client = self.get_frappe_mail_client()
|
||||
messages = frappe_mail_client.pull_raw(last_synced_at=self.last_synced_at)
|
||||
process_mail(messages)
|
||||
# close connection to mailserver
|
||||
email_server.logout()
|
||||
self.db_set("last_synced_at", messages["last_synced_at"], update_modified=False)
|
||||
else:
|
||||
email_sync_rule = self.build_email_sync_rule()
|
||||
email_server = self.get_incoming_server(in_receive=True, email_sync_rule=email_sync_rule)
|
||||
if self.use_imap:
|
||||
# process all given imap folder
|
||||
for folder in self.imap_folder:
|
||||
if email_server.select_imap_folder(folder.folder_name):
|
||||
email_server.settings["uid_validity"] = folder.uidvalidity
|
||||
messages = email_server.get_messages(folder=f'"{folder.folder_name}"') or {}
|
||||
process_mail(messages, folder.append_to)
|
||||
else:
|
||||
# process the pop3 account
|
||||
messages = email_server.get_messages() or {}
|
||||
process_mail(messages)
|
||||
|
||||
# close connection to mailserver
|
||||
email_server.logout()
|
||||
except Exception:
|
||||
self.log_error(title=_("Error while connecting to email account {0}").format(self.name))
|
||||
return []
|
||||
|
||||
return mails
|
||||
|
||||
def handle_bad_emails(self, uid, raw, reason):
|
||||
|
|
@ -729,7 +781,12 @@ class EmailAccount(Document):
|
|||
def get_oauth_token(self):
|
||||
if self.auth_method == "OAuth":
|
||||
connected_app = frappe.get_doc("Connected App", self.connected_app)
|
||||
return connected_app.get_active_token(self.connected_user)
|
||||
if self.backend_app_flow:
|
||||
token = connected_app.get_backend_app_token()
|
||||
else:
|
||||
token = connected_app.get_active_token(self.connected_user)
|
||||
|
||||
return token
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
|
@ -828,8 +885,10 @@ def pull(now=False):
|
|||
)
|
||||
|
||||
for email_account in email_accounts:
|
||||
if email_account.auth_method == "OAuth" and not has_token(
|
||||
email_account.connected_app, email_account.connected_user
|
||||
if (
|
||||
email_account.auth_method == "OAuth"
|
||||
and not email_account.backend_app_flow
|
||||
and not has_token(email_account.connected_app, email_account.connected_user)
|
||||
):
|
||||
# don't try to pull from accounts which dont have access token (for Oauth)
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from frappe.core.utils import html2text
|
|||
from frappe.database.database import savepoint
|
||||
from frappe.email.doctype.email_account.email_account import EmailAccount
|
||||
from frappe.email.email_body import add_attachment, get_email, get_formatted_html
|
||||
from frappe.email.frappemail import FrappeMail
|
||||
from frappe.email.queue import get_unsubcribed_url, get_unsubscribe_message
|
||||
from frappe.email.smtp import SMTPServer
|
||||
from frappe.model.document import Document
|
||||
|
|
@ -153,13 +154,13 @@ class EmailQueue(Document):
|
|||
|
||||
return True
|
||||
|
||||
def send(self, smtp_server_instance: SMTPServer = None):
|
||||
def send(self, smtp_server_instance: SMTPServer = None, frappe_mail_client: FrappeMail = None):
|
||||
"""Send emails to recipients."""
|
||||
if not self.can_send_now():
|
||||
return
|
||||
|
||||
with SendMailContext(self, smtp_server_instance) as ctx:
|
||||
ctx.fetch_smtp_server()
|
||||
with SendMailContext(self, smtp_server_instance, frappe_mail_client) as ctx:
|
||||
ctx.fetch_outgoing_server()
|
||||
message = None
|
||||
for recipient in self.recipients:
|
||||
if recipient.is_mail_sent():
|
||||
|
|
@ -168,8 +169,21 @@ class EmailQueue(Document):
|
|||
message = ctx.build_message(recipient.recipient)
|
||||
if method := get_hook_method("override_email_send"):
|
||||
method(self, self.sender, recipient.recipient, message)
|
||||
else:
|
||||
if not frappe.flags.in_test or frappe.flags.testing_email:
|
||||
elif not frappe.flags.in_test or frappe.flags.testing_email:
|
||||
if ctx.email_account_doc.service == "Frappe Mail":
|
||||
if self.reference_doctype == "Newsletter":
|
||||
ctx.frappe_mail_client.send_newsletter(
|
||||
sender=self.sender,
|
||||
recipients=recipient.recipient,
|
||||
message=message.decode("utf-8"),
|
||||
)
|
||||
else:
|
||||
ctx.frappe_mail_client.send_raw(
|
||||
sender=self.sender,
|
||||
recipients=recipient.recipient,
|
||||
message=message.decode("utf-8"),
|
||||
)
|
||||
else:
|
||||
ctx.smtp_server.session.sendmail(
|
||||
from_addr=self.sender,
|
||||
to_addrs=recipient.recipient,
|
||||
|
|
@ -231,17 +245,23 @@ class SendMailContext:
|
|||
self,
|
||||
queue_doc: Document,
|
||||
smtp_server_instance: SMTPServer = None,
|
||||
frappe_mail_client: FrappeMail = None,
|
||||
):
|
||||
self.queue_doc: EmailQueue = queue_doc
|
||||
self.smtp_server: SMTPServer = smtp_server_instance
|
||||
self.frappe_mail_client: FrappeMail = frappe_mail_client
|
||||
self.sent_to_atleast_one_recipient = any(
|
||||
rec.recipient for rec in self.queue_doc.recipients if rec.is_mail_sent()
|
||||
)
|
||||
self.email_account_doc = None
|
||||
|
||||
def fetch_smtp_server(self):
|
||||
def fetch_outgoing_server(self):
|
||||
self.email_account_doc = self.queue_doc.get_email_account(raise_error=True)
|
||||
if not self.smtp_server:
|
||||
|
||||
if self.email_account_doc.service == "Frappe Mail":
|
||||
if not self.frappe_mail_client:
|
||||
self.frappe_mail_client = self.email_account_doc.get_frappe_mail_client()
|
||||
elif not self.smtp_server:
|
||||
self.smtp_server = self.email_account_doc.get_smtp_server()
|
||||
|
||||
def __enter__(self):
|
||||
|
|
@ -751,18 +771,24 @@ class QueueBuilder:
|
|||
def send_emails(self, queue_data, final_recipients):
|
||||
# This is used to bulk send emails from same sender to multiple recipients separately
|
||||
# This re-uses smtp server instance to minimize the cost of new session creation
|
||||
frappe_mail_client = None
|
||||
smtp_server_instance = None
|
||||
for r in final_recipients:
|
||||
recipients = list(set([r, *self.final_cc(), *self.bcc]))
|
||||
q = EmailQueue.new({**queue_data, **{"recipients": recipients}}, ignore_permissions=True)
|
||||
if not smtp_server_instance:
|
||||
if not frappe_mail_client and not smtp_server_instance:
|
||||
email_account = q.get_email_account(raise_error=True)
|
||||
smtp_server_instance = email_account.get_smtp_server()
|
||||
|
||||
if email_account.service == "Frappe Mail":
|
||||
frappe_mail_client = email_account.get_frappe_mail_client()
|
||||
else:
|
||||
smtp_server_instance = email_account.get_smtp_server()
|
||||
|
||||
with suppress(Exception):
|
||||
q.send(smtp_server_instance=smtp_server_instance)
|
||||
q.send(smtp_server_instance=smtp_server_instance, frappe_mail_client=frappe_mail_client)
|
||||
|
||||
smtp_server_instance.quit()
|
||||
if smtp_server_instance:
|
||||
smtp_server_instance.quit()
|
||||
|
||||
def as_dict(self, include_recipients=True):
|
||||
email_account = self.get_outgoing_email_account()
|
||||
|
|
|
|||
|
|
@ -439,7 +439,11 @@ def newsletter_email_read(recipient_email=None, reference_doctype=None, referenc
|
|||
).run()
|
||||
|
||||
except Exception:
|
||||
doc.log_error(f"Unable to mark as viewed for {recipient_email}")
|
||||
frappe.log_error(
|
||||
title=f"Unable to mark as viewed for {recipient_email}",
|
||||
reference_doctype="Newsletter",
|
||||
reference_name=reference_name,
|
||||
)
|
||||
|
||||
finally:
|
||||
frappe.response.update(frappe.utils.get_imaginary_pixel_response())
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
// Copyright (c) 2018, Frappe Technologies and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
const DATE_BASED_EVENTS = ["Days Before", "Days After"];
|
||||
|
||||
frappe.notification = {
|
||||
setup_fieldname_select: function (frm) {
|
||||
// get the doctype to update fields
|
||||
|
|
@ -12,10 +14,11 @@ frappe.notification = {
|
|||
let get_select_options = function (df, parent_field) {
|
||||
// Append parent_field name along with fieldname for child table fields
|
||||
let select_value = parent_field ? df.fieldname + "," + parent_field : df.fieldname;
|
||||
let path = parent_field ? parent_field + " > " + df.fieldname : df.fieldname;
|
||||
|
||||
return {
|
||||
value: select_value,
|
||||
label: df.fieldname + " (" + __(df.label, null, df.parent) + ")",
|
||||
label: path + " (" + __(df.label, null, df.parent) + ")",
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -31,6 +34,38 @@ frappe.notification = {
|
|||
{ value: "modified", label: `modified (${__("Last Modified Date")})` },
|
||||
]);
|
||||
};
|
||||
let get_receiver_fields = function (
|
||||
fields,
|
||||
is_extra_receiver_field = (_) => {
|
||||
return false;
|
||||
}
|
||||
) {
|
||||
// finds receiver fields from the fields or any child table
|
||||
// by default finds any link to the User doctype
|
||||
// however an additional optional predicate can be passed as argument
|
||||
// to find additional fields
|
||||
let is_receiver_field = function (df) {
|
||||
return (
|
||||
is_extra_receiver_field(df) ||
|
||||
(df.options == "User" && df.fieldtype == "Link") ||
|
||||
(df.options == "Customer" && df.fieldtype == "Link")
|
||||
);
|
||||
};
|
||||
let extract_receiver_field = function (df) {
|
||||
// Add recipients from child doctypes into select dropdown
|
||||
if (frappe.model.table_fields.includes(df.fieldtype)) {
|
||||
let child_fields = frappe.get_doc("DocType", df.options).fields;
|
||||
return $.map(child_fields, function (cdf) {
|
||||
return is_receiver_field(cdf)
|
||||
? get_select_options(cdf, df.fieldname)
|
||||
: null;
|
||||
});
|
||||
} else {
|
||||
return is_receiver_field(df) ? get_select_options(df) : null;
|
||||
}
|
||||
};
|
||||
return $.map(fields, extract_receiver_field);
|
||||
};
|
||||
|
||||
let fields = frappe.get_doc("DocType", frm.doc.document_type).fields;
|
||||
let options = $.map(fields, function (d) {
|
||||
|
|
@ -48,27 +83,12 @@ frappe.notification = {
|
|||
|
||||
let receiver_fields = [];
|
||||
if (frm.doc.channel === "Email") {
|
||||
receiver_fields = $.map(fields, function (d) {
|
||||
// Add User and Email fields from child into select dropdown
|
||||
if (frappe.model.table_fields.includes(d.fieldtype)) {
|
||||
let child_fields = frappe.get_doc("DocType", d.options).fields;
|
||||
return $.map(child_fields, function (df) {
|
||||
return df.options == "Email" ||
|
||||
(df.options == "User" && df.fieldtype == "Link")
|
||||
? get_select_options(df, d.fieldname)
|
||||
: null;
|
||||
});
|
||||
// Add User and Email fields from parent into select dropdown
|
||||
} else {
|
||||
return d.options == "Email" ||
|
||||
(d.options == "User" && d.fieldtype == "Link")
|
||||
? get_select_options(d)
|
||||
: null;
|
||||
}
|
||||
receiver_fields = get_receiver_fields(fields, function (df) {
|
||||
return df.options == "Email";
|
||||
});
|
||||
} else if (["WhatsApp", "SMS"].includes(frm.doc.channel)) {
|
||||
receiver_fields = $.map(fields, function (d) {
|
||||
return d.options == "Phone" ? get_select_options(d) : null;
|
||||
receiver_fields = get_receiver_fields(fields, function (df) {
|
||||
df.options == "Phone" || df.options == "Mobile";
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -129,6 +149,8 @@ Last comment: {{ comments[-1].comment }} by {{ comments[-1].by }}
|
|||
frappe.ui.form.on("Notification", {
|
||||
onload: function (frm) {
|
||||
frm.set_query("document_type", function () {
|
||||
if (DATE_BASED_EVENTS.includes(frm.doc.event)) return;
|
||||
|
||||
return {
|
||||
filters: {
|
||||
istable: 0,
|
||||
|
|
@ -157,6 +179,25 @@ frappe.ui.form.on("Notification", {
|
|||
});
|
||||
frm.get_field("is_standard").toggle(frappe.boot.developer_mode);
|
||||
frm.trigger("event");
|
||||
if (frm.doc.document_type) {
|
||||
frm.add_custom_button(__("Preview"), () => {
|
||||
const args = {
|
||||
doc: frm.doc,
|
||||
doctype: frm.doc.document_type,
|
||||
preview_fields: [
|
||||
{
|
||||
label: __("Meets Condition?"),
|
||||
fieldtype: "Data",
|
||||
method: "preview_meets_condition",
|
||||
},
|
||||
{ label: __("Subject"), fieldtype: "Data", method: "preview_subject" },
|
||||
{ label: __("Message"), fieldtype: "Code", method: "preview_message" },
|
||||
],
|
||||
};
|
||||
let dialog = new frappe.views.RenderPreviewer(args);
|
||||
return dialog;
|
||||
});
|
||||
}
|
||||
},
|
||||
document_type: function (frm) {
|
||||
frappe.notification.setup_fieldname_select(frm);
|
||||
|
|
@ -166,23 +207,23 @@ frappe.ui.form.on("Notification", {
|
|||
frappe.set_route("Form", "Customize Form");
|
||||
},
|
||||
event: function (frm) {
|
||||
if (["Days Before", "Days After"].includes(frm.doc.event)) {
|
||||
frm.add_custom_button(__("Get Alerts for Today"), function () {
|
||||
frappe.call({
|
||||
method: "frappe.email.doctype.notification.notification.get_documents_for_today",
|
||||
args: {
|
||||
notification: frm.doc.name,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.message && r.message.length > 0) {
|
||||
frappe.msgprint(r.message.toString());
|
||||
} else {
|
||||
frappe.msgprint(__("No alerts for today"));
|
||||
}
|
||||
},
|
||||
});
|
||||
if (!DATE_BASED_EVENTS.includes(frm.doc.event) || frm.is_new()) return;
|
||||
|
||||
frm.add_custom_button(__("Get Alerts for Today"), function () {
|
||||
frappe.call({
|
||||
method: "frappe.email.doctype.notification.notification.get_documents_for_today",
|
||||
args: {
|
||||
notification: frm.doc.name,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.message && r.message.length > 0) {
|
||||
frappe.msgprint(r.message.toString());
|
||||
} else {
|
||||
frappe.msgprint(__("No alerts for today"));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
channel: function (frm) {
|
||||
frm.toggle_reqd("recipients", frm.doc.channel == "Email");
|
||||
|
|
|
|||
|
|
@ -8,16 +8,16 @@
|
|||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"enabled",
|
||||
"is_standard",
|
||||
"module",
|
||||
"column_break_2",
|
||||
"channel",
|
||||
"slack_webhook_url",
|
||||
"filters",
|
||||
"subject",
|
||||
"document_type",
|
||||
"is_standard",
|
||||
"module",
|
||||
"col_break_1",
|
||||
"event",
|
||||
"document_type",
|
||||
"col_break_1",
|
||||
"method",
|
||||
"date_changed",
|
||||
"days_in_advance",
|
||||
|
|
@ -119,7 +119,6 @@
|
|||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.document_type",
|
||||
"fieldname": "event",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
|
|
@ -292,7 +291,7 @@
|
|||
"icon": "fa fa-envelope",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-06-17 04:03:22.591781",
|
||||
"modified": "2024-07-04 05:53:40.595130",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Email",
|
||||
"name": "Notification",
|
||||
|
|
@ -315,4 +314,4 @@
|
|||
"states": [],
|
||||
"title_field": "subject",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,8 @@ from frappe.utils.jinja import validate_template
|
|||
from frappe.utils.safe_exec import get_safe_globals
|
||||
|
||||
FORMATS = {"HTML": ".html", "Markdown": ".md", "Plain Text": ".txt"}
|
||||
FORBIDDEN_DOCUMENT_TYPES = frozenset(("Email Queue",))
|
||||
DATE_BASED_EVENTS = frozenset(("Days Before", "Days After"))
|
||||
|
||||
|
||||
class Notification(Document):
|
||||
|
|
@ -27,9 +29,7 @@ class Notification(Document):
|
|||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.email.doctype.notification_recipient.notification_recipient import (
|
||||
NotificationRecipient,
|
||||
)
|
||||
from frappe.email.doctype.notification_recipient.notification_recipient import NotificationRecipient
|
||||
from frappe.types import DF
|
||||
|
||||
attach_print: DF.Check
|
||||
|
|
@ -78,6 +78,59 @@ class Notification(Document):
|
|||
if not self.name:
|
||||
self.name = self.subject
|
||||
|
||||
# START: PreviewRenderer API
|
||||
|
||||
@frappe.whitelist()
|
||||
def preview_meets_condition(self, preview_document):
|
||||
if not self.condition:
|
||||
return _("Yes")
|
||||
try:
|
||||
doc = frappe.get_cached_doc(self.document_type, preview_document)
|
||||
context = get_context(doc)
|
||||
if self.is_standard:
|
||||
self.load_standard_properties(context)
|
||||
return _("Yes") if frappe.safe_eval(self.condition, eval_locals=context) else _("No")
|
||||
except Exception as e:
|
||||
frappe.local.message_log = []
|
||||
return _("Failed to evaluate conditions: {}").format(e)
|
||||
|
||||
@frappe.whitelist()
|
||||
def preview_message(self, preview_document):
|
||||
try:
|
||||
doc = frappe.get_cached_doc(self.document_type, preview_document)
|
||||
context = get_context(doc)
|
||||
context.update({"alert": self, "comments": None})
|
||||
if doc.get("_comments"):
|
||||
context["comments"] = json.loads(doc.get("_comments"))
|
||||
if self.is_standard:
|
||||
self.load_standard_properties(context)
|
||||
msg = frappe.render_template(self.message, context)
|
||||
if self.channel == "SMS":
|
||||
return frappe.utils.strip_html_tags(msg)
|
||||
return msg
|
||||
except Exception as e:
|
||||
return _("Failed to render message: {}").format(e)
|
||||
|
||||
@frappe.whitelist()
|
||||
def preview_subject(self, preview_document):
|
||||
try:
|
||||
doc = frappe.get_cached_doc(self.document_type, preview_document)
|
||||
context = get_context(doc)
|
||||
context.update({"alert": self, "comments": None})
|
||||
if doc.get("_comments"):
|
||||
context["comments"] = json.loads(doc.get("_comments"))
|
||||
if self.is_standard:
|
||||
self.load_standard_properties(context)
|
||||
if not self.subject:
|
||||
return _("No subject")
|
||||
if "{" in self.subject:
|
||||
return frappe.render_template(self.subject, context)
|
||||
return self.subject
|
||||
except Exception as e:
|
||||
return _("Failed to render subject: {}").format(e)
|
||||
|
||||
# END: PreviewRenderer API
|
||||
|
||||
def validate(self):
|
||||
if self.channel in ("Email", "Slack", "System Notification"):
|
||||
validate_template(self.subject)
|
||||
|
|
@ -90,7 +143,7 @@ class Notification(Document):
|
|||
if self.event == "Value Change" and not self.value_changed:
|
||||
frappe.throw(_("Please specify which value field must be checked"))
|
||||
|
||||
self.validate_forbidden_types()
|
||||
self.validate_forbidden_document_types()
|
||||
self.validate_condition()
|
||||
self.validate_standard()
|
||||
frappe.cache.hdel("notifications", self.document_type)
|
||||
|
|
@ -130,12 +183,16 @@ def get_context(context):
|
|||
except Exception:
|
||||
frappe.throw(_("The Condition '{0}' is invalid").format(self.condition))
|
||||
|
||||
def validate_forbidden_types(self):
|
||||
forbidden_document_types = ("Email Queue",)
|
||||
if self.document_type in forbidden_document_types or frappe.get_meta(self.document_type).istable:
|
||||
# currently notifications don't work on child tables as events are not fired for each record of child table
|
||||
|
||||
frappe.throw(_("Cannot set Notification on Document Type {0}").format(self.document_type))
|
||||
def validate_forbidden_document_types(self):
|
||||
if self.document_type in FORBIDDEN_DOCUMENT_TYPES or (
|
||||
frappe.get_meta(self.document_type).istable and self.event not in DATE_BASED_EVENTS
|
||||
):
|
||||
# only date based events are allowed for child tables
|
||||
frappe.throw(
|
||||
_("Cannot set Notification with event {0} on Document Type {1}").format(
|
||||
_(self.event), _(self.document_type)
|
||||
)
|
||||
)
|
||||
|
||||
def get_documents_for_today(self):
|
||||
"""get list of documents that will be triggered today"""
|
||||
|
|
@ -172,7 +229,7 @@ def get_context(context):
|
|||
"""Build recipients and send Notification"""
|
||||
|
||||
context = get_context(doc)
|
||||
context = {"doc": doc, "alert": self, "comments": None}
|
||||
context.update({"alert": self, "comments": None})
|
||||
if doc.get("_comments"):
|
||||
context["comments"] = json.loads(doc.get("_comments"))
|
||||
|
||||
|
|
@ -237,8 +294,8 @@ def get_context(context):
|
|||
|
||||
notification_doc = {
|
||||
"type": "Alert",
|
||||
"document_type": doc.doctype,
|
||||
"document_name": doc.name,
|
||||
"document_type": get_reference_doctype(doc),
|
||||
"document_name": get_reference_name(doc),
|
||||
"subject": subject,
|
||||
"from_user": doc.modified_by or doc.owner,
|
||||
"email_content": frappe.render_template(self.message, context),
|
||||
|
|
@ -270,8 +327,8 @@ def get_context(context):
|
|||
# No need to add if it is already a communication.
|
||||
if doc.doctype != "Communication":
|
||||
communication = make_communication(
|
||||
doctype=doc.doctype,
|
||||
name=doc.name,
|
||||
doctype=get_reference_doctype(doc),
|
||||
name=get_reference_name(doc),
|
||||
content=message,
|
||||
subject=subject,
|
||||
sender=sender,
|
||||
|
|
@ -294,8 +351,8 @@ def get_context(context):
|
|||
cc=cc,
|
||||
bcc=bcc,
|
||||
message=message,
|
||||
reference_doctype=doc.doctype,
|
||||
reference_name=doc.name,
|
||||
reference_doctype=get_reference_doctype(doc),
|
||||
reference_name=get_reference_name(doc),
|
||||
attachments=attachments,
|
||||
expose_recipients="header",
|
||||
print_letterhead=((attachments and attachments[0].get("print_letterhead")) or False),
|
||||
|
|
@ -306,16 +363,47 @@ def get_context(context):
|
|||
send_slack_message(
|
||||
webhook_url=self.slack_webhook_url,
|
||||
message=frappe.render_template(self.message, context),
|
||||
reference_doctype=doc.doctype,
|
||||
reference_name=doc.name,
|
||||
reference_doctype=get_reference_doctype(doc),
|
||||
reference_name=get_reference_name(doc),
|
||||
)
|
||||
|
||||
def send_sms(self, doc, context):
|
||||
send_sms(
|
||||
receiver_list=self.get_receiver_list(doc, context),
|
||||
receiver_list=self.get_receiver_list(doc, context, "mobile_no", self.get_mobile_no),
|
||||
msg=frappe.utils.strip_html_tags(frappe.render_template(self.message, context)),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_mobile_no(doc, field):
|
||||
option = doc.meta.get_field(field).options.strip()
|
||||
# users may sometimes register mobile numbers under Phone type fields
|
||||
if option == "Phone" or option == "Mobile":
|
||||
mobile_no = doc.get(field)
|
||||
if not mobile_no:
|
||||
doc.log_error(
|
||||
_("Notification: document {0} has no {1} number set (field: {2})").format(
|
||||
field, doc.name, option, field
|
||||
)
|
||||
)
|
||||
# but on user & customer it's expected to be set on the proper field
|
||||
elif option == "User":
|
||||
user = doc.get(field)
|
||||
mobile_no = frappe.get_value("User", user, "mobile_no")
|
||||
if not mobile_no:
|
||||
doc.log_error(_("Notification: user {0} has no Mobile number set").format(user))
|
||||
elif option == "Customer":
|
||||
customer = doc.get(field)
|
||||
mobile_no = frappe.get_value("Customer", customer, "mobile_no")
|
||||
if not mobile_no:
|
||||
doc.log_error(_("Notification: customer {0} has no Mobile number set").format(customer))
|
||||
else:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Field {0} on document {1} is neither a Mobile number field nor a Customer or User link"
|
||||
).format(field, doc.name)
|
||||
)
|
||||
return mobile_no
|
||||
|
||||
def get_list_of_recipients(self, doc, context):
|
||||
recipients = []
|
||||
cc = []
|
||||
|
|
@ -325,16 +413,17 @@ def get_context(context):
|
|||
if not frappe.safe_eval(recipient.condition, None, context):
|
||||
continue
|
||||
if recipient.receiver_by_document_field:
|
||||
fields = recipient.receiver_by_document_field.split(",")
|
||||
# fields from child table
|
||||
if len(fields) > 1:
|
||||
for d in doc.get(fields[1]):
|
||||
email_id = d.get(fields[0])
|
||||
data_field, child_field = _parse_receiver_by_document_field(
|
||||
recipient.receiver_by_document_field
|
||||
)
|
||||
if child_field:
|
||||
for d in doc.get(child_field):
|
||||
email_id = d.get(data_field)
|
||||
if validate_email_address(email_id):
|
||||
recipients.append(email_id)
|
||||
# field from parent doc
|
||||
# field from current doc
|
||||
else:
|
||||
email_ids_value = doc.get(fields[0])
|
||||
email_ids_value = doc.get(data_field)
|
||||
if validate_email_address(email_ids_value):
|
||||
email_ids = email_ids_value.replace(",", "\n")
|
||||
recipients = recipients + email_ids.split("\n")
|
||||
|
|
@ -354,8 +443,10 @@ def get_context(context):
|
|||
|
||||
return list(set(recipients)), list(set(cc)), list(set(bcc))
|
||||
|
||||
def get_receiver_list(self, doc, context):
|
||||
def get_receiver_list(self, doc, context, field_on_user="mobile_no", recipient_extractor_func=None):
|
||||
"""return receiver list based on the doc field and role specified"""
|
||||
if not recipient_extractor_func:
|
||||
recipient_extractor_func = self.get_mobile_no
|
||||
receiver_list = []
|
||||
for recipient in self.recipients:
|
||||
if recipient.condition:
|
||||
|
|
@ -364,18 +455,28 @@ def get_context(context):
|
|||
|
||||
# For sending messages to the owner's mobile phone number
|
||||
if recipient.receiver_by_document_field == "owner":
|
||||
receiver_list += get_user_info([dict(user_name=doc.get("owner"))], "mobile_no")
|
||||
receiver_list += get_user_info([dict(user_name=doc.get("owner"))], field_on_user)
|
||||
# For sending messages to the number specified in the receiver field
|
||||
elif recipient.receiver_by_document_field:
|
||||
receiver_list.append(doc.get(recipient.receiver_by_document_field))
|
||||
data_field, child_field = _parse_receiver_by_document_field(
|
||||
recipient.receiver_by_document_field
|
||||
)
|
||||
if child_field:
|
||||
for d in doc.get(child_field):
|
||||
if recv := recipient_extractor_func(d, data_field):
|
||||
receiver_list.append(recv)
|
||||
# field from current doc
|
||||
else:
|
||||
if recv := recipient_extractor_func(doc, data_field):
|
||||
receiver_list.append(recv)
|
||||
|
||||
# For sending messages to specified role
|
||||
if recipient.receiver_by_role:
|
||||
receiver_list += get_info_based_on_role(
|
||||
recipient.receiver_by_role, "mobile_no", ignore_permissions=True
|
||||
recipient.receiver_by_role, field_on_user, ignore_permissions=True
|
||||
)
|
||||
|
||||
return receiver_list
|
||||
return list(set(receiver_list))
|
||||
|
||||
def get_attachment(self, doc):
|
||||
"""check print settings are attach the pdf"""
|
||||
|
|
@ -543,3 +644,21 @@ def get_emails_from_template(template, context):
|
|||
|
||||
emails = frappe.render_template(template, context) if "{" in template else template
|
||||
return filter(None, emails.replace(",", "\n").split("\n"))
|
||||
|
||||
|
||||
def get_reference_doctype(doc):
|
||||
return doc.parenttype if doc.meta.istable else doc.doctype
|
||||
|
||||
|
||||
def get_reference_name(doc):
|
||||
return doc.parent if doc.meta.istable else doc.name
|
||||
|
||||
|
||||
def _parse_receiver_by_document_field(s):
|
||||
fragments = s.split(",")
|
||||
# fields from child table or linked doctype
|
||||
if len(fragments) > 1:
|
||||
data_field, child_field = fragments
|
||||
else:
|
||||
data_field, child_field = fragments[0], None
|
||||
return data_field, child_field
|
||||
|
|
|
|||
129
frappe/email/frappemail.py
Normal file
129
frappe/email/frappemail.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
from datetime import datetime
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import pytz
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.frappeclient import FrappeClient, FrappeOAuth2Client
|
||||
from frappe.utils import convert_utc_to_system_timezone, get_datetime, get_datetime_str, get_system_timezone
|
||||
|
||||
|
||||
class FrappeMail:
|
||||
"""Class to interact with the Frappe Mail API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
site: str,
|
||||
mailbox: str,
|
||||
api_key: str | None = None,
|
||||
api_secret: str | None = None,
|
||||
access_token: str | None = None,
|
||||
) -> None:
|
||||
self.site = site
|
||||
self.mailbox = mailbox
|
||||
self.api_key = api_key
|
||||
self.api_secret = api_secret
|
||||
self.access_token = access_token
|
||||
self.client = self.get_client(
|
||||
self.site, self.mailbox, self.api_key, self.api_secret, self.access_token
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_client(
|
||||
site: str,
|
||||
mailbox: str,
|
||||
api_key: str | None = None,
|
||||
api_secret: str | None = None,
|
||||
access_token: str | None = None,
|
||||
) -> FrappeClient | FrappeOAuth2Client:
|
||||
"""Returns a FrappeClient or FrappeOAuth2Client instance."""
|
||||
|
||||
if hasattr(frappe.local, "frappe_mail_clients"):
|
||||
if client := frappe.local.frappe_mail_clients.get(mailbox):
|
||||
return client
|
||||
else:
|
||||
frappe.local.frappe_mail_clients = {}
|
||||
|
||||
client = (
|
||||
FrappeOAuth2Client(url=site, access_token=access_token)
|
||||
if access_token
|
||||
else FrappeClient(url=site, api_key=api_key, api_secret=api_secret)
|
||||
)
|
||||
frappe.local.frappe_mail_clients[mailbox] = client
|
||||
|
||||
return client
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
params: dict | None = None,
|
||||
data: dict | None = None,
|
||||
json: dict | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: int | tuple[int, int] = (60, 120),
|
||||
raise_exception: bool = True,
|
||||
) -> Any | None:
|
||||
"""Makes a request to the Frappe Mail API."""
|
||||
|
||||
url = urljoin(self.client.url, endpoint)
|
||||
|
||||
headers = headers or {}
|
||||
headers.update(self.client.headers)
|
||||
|
||||
response = self.client.session.request(
|
||||
method=method, url=url, params=params, data=data, json=json, headers=headers, timeout=timeout
|
||||
)
|
||||
|
||||
return self.client.post_process(response)
|
||||
|
||||
def validate(self, for_outbound: bool = False, for_inbound: bool = False) -> None:
|
||||
"""Validates the mailbox for inbound and outbound emails."""
|
||||
|
||||
endpoint = "/api/method/mail.api.auth.validate"
|
||||
data = {"mailbox": self.mailbox, "for_outbound": for_outbound, "for_inbound": for_inbound}
|
||||
self.request("POST", endpoint=endpoint, data=data)
|
||||
|
||||
def send_raw(self, sender: str, recipients: str | list, message: str) -> None:
|
||||
"""Sends an email using the Frappe Mail API."""
|
||||
|
||||
endpoint = "/api/method/mail.api.outbound.send_raw"
|
||||
data = {"from_": sender, "to": recipients, "raw_message": message}
|
||||
self.request("POST", endpoint=endpoint, data=data)
|
||||
|
||||
def send_newsletter(self, sender: str, recipients: str | list, message: str) -> None:
|
||||
"""Sends an newsletter using the Frappe Mail API."""
|
||||
|
||||
endpoint = "/api/method/mail.api.outbound.send_newsletter"
|
||||
data = {"from_": sender, "to": recipients, "raw_message": message}
|
||||
self.request("POST", endpoint=endpoint, json=data)
|
||||
|
||||
def pull_raw(self, limit: int = 50, last_synced_at: str | None = None) -> dict[str, list[str] | str]:
|
||||
"""Pulls emails from the mailbox using the Frappe Mail API."""
|
||||
|
||||
endpoint = "/api/method/mail.api.inbound.pull_raw"
|
||||
if last_synced_at:
|
||||
last_synced_at = add_or_update_tzinfo(last_synced_at)
|
||||
|
||||
data = {"mailbox": self.mailbox, "limit": limit, "last_synced_at": last_synced_at}
|
||||
headers = {"X-Site": frappe.utils.get_url()}
|
||||
response = self.request("GET", endpoint=endpoint, data=data, headers=headers)
|
||||
last_synced_at = convert_utc_to_system_timezone(get_datetime(response["last_synced_at"]))
|
||||
|
||||
return {"latest_messages": response["mails"], "last_synced_at": last_synced_at}
|
||||
|
||||
|
||||
def add_or_update_tzinfo(date_time: datetime | str, timezone: str | None = None) -> str:
|
||||
"""Adds or updates timezone to the datetime."""
|
||||
|
||||
date_time = get_datetime(date_time)
|
||||
target_tz = pytz.timezone(timezone or get_system_timezone())
|
||||
|
||||
if date_time.tzinfo is None:
|
||||
date_time = target_tz.localize(date_time)
|
||||
else:
|
||||
date_time = date_time.astimezone(target_tz)
|
||||
|
||||
return str(date_time)
|
||||
|
|
@ -2731,18 +2731,6 @@
|
|||
],
|
||||
"isd": "+216"
|
||||
},
|
||||
"Türkiye": {
|
||||
"code": "tr",
|
||||
"currency": "TRY",
|
||||
"currency_fraction": "Kuru\u015f",
|
||||
"currency_fraction_units": 100,
|
||||
"currency_symbol": "\u20ba",
|
||||
"number_format": "#.###,##",
|
||||
"timezones": [
|
||||
"Europe/Istanbul"
|
||||
],
|
||||
"isd": "+90"
|
||||
},
|
||||
"Turkmenistan": {
|
||||
"code": "tm",
|
||||
"currency": "TMM",
|
||||
|
|
@ -2774,6 +2762,18 @@
|
|||
"Pacific/Funafuti"
|
||||
],
|
||||
"isd": "+688"
|
||||
},
|
||||
"T\u00fcrkiye": {
|
||||
"code": "tr",
|
||||
"currency": "TRY",
|
||||
"currency_fraction": "Kuru\u015f",
|
||||
"currency_fraction_units": 100,
|
||||
"currency_symbol": "\u20ba",
|
||||
"number_format": "#.###,##",
|
||||
"timezones": [
|
||||
"Europe/Istanbul"
|
||||
],
|
||||
"isd": "+90"
|
||||
},
|
||||
"Uganda": {
|
||||
"code": "ug",
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ def extract(fileobj, *args, **kwargs):
|
|||
yield from (
|
||||
(
|
||||
None,
|
||||
"pgettext",
|
||||
(link.get("link_to") if link.get("link_type") == "DocType" else None, link.get("label")),
|
||||
"_",
|
||||
link.get("label"),
|
||||
[f"Label of a {link.get('type')} in the {workspace_name} Workspace"],
|
||||
)
|
||||
for link in data.get("links", [])
|
||||
|
|
@ -38,8 +38,8 @@ def extract(fileobj, *args, **kwargs):
|
|||
yield from (
|
||||
(
|
||||
None,
|
||||
"pgettext",
|
||||
(link.get("link_to") if link.get("link_type") == "DocType" else None, link.get("description")),
|
||||
"_",
|
||||
link.get("description"),
|
||||
[f"Description of a {link.get('type')} in the {workspace_name} Workspace"],
|
||||
)
|
||||
for link in data.get("links", [])
|
||||
|
|
@ -47,8 +47,8 @@ def extract(fileobj, *args, **kwargs):
|
|||
yield from (
|
||||
(
|
||||
None,
|
||||
"pgettext",
|
||||
(shortcut.get("link_to") if shortcut.get("type") == "DocType" else None, shortcut.get("label")),
|
||||
"_",
|
||||
shortcut.get("label"),
|
||||
[f"Label of a shortcut in the {workspace_name} Workspace"],
|
||||
)
|
||||
for shortcut in data.get("shortcuts", [])
|
||||
|
|
@ -56,8 +56,8 @@ def extract(fileobj, *args, **kwargs):
|
|||
yield from (
|
||||
(
|
||||
None,
|
||||
"pgettext",
|
||||
(shortcut.get("link_to") if shortcut.get("type") == "DocType" else None, shortcut.get("format")),
|
||||
"_",
|
||||
shortcut.get("format"),
|
||||
[f"Count format of shortcut in the {workspace_name} Workspace"],
|
||||
)
|
||||
for shortcut in data.get("shortcuts", [])
|
||||
|
|
|
|||
|
|
@ -218,7 +218,7 @@ def update_po(target_app: str | None = None, locale: str | None = None):
|
|||
pot_catalog = get_catalog(app)
|
||||
for locale in locales:
|
||||
po_catalog = get_catalog(app, locale)
|
||||
po_catalog.update(pot_catalog)
|
||||
po_catalog.update(pot_catalog, no_fuzzy_matching=True)
|
||||
po_path = write_catalog(app, po_catalog, locale)
|
||||
print(f"PO file modified at {po_path}")
|
||||
|
||||
|
|
|
|||
|
|
@ -33,8 +33,8 @@ app_include_css = [
|
|||
"report.bundle.css",
|
||||
]
|
||||
app_include_icons = [
|
||||
"frappe/icons/timeless/icons.svg",
|
||||
"frappe/icons/espresso/icons.svg",
|
||||
"/assets/frappe/icons/timeless/icons.svg",
|
||||
"/assets/frappe/icons/espresso/icons.svg",
|
||||
]
|
||||
|
||||
doctype_js = {
|
||||
|
|
@ -43,8 +43,11 @@ doctype_js = {
|
|||
}
|
||||
|
||||
web_include_js = ["website_script.js"]
|
||||
|
||||
web_include_css = []
|
||||
web_include_icons = [
|
||||
"/assets/frappe/icons/timeless/icons.svg",
|
||||
"/assets/frappe/icons/espresso/icons.svg",
|
||||
]
|
||||
|
||||
email_css = ["email.bundle.css"]
|
||||
|
||||
|
|
@ -457,15 +460,15 @@ export_python_type_annotations = True
|
|||
|
||||
standard_navbar_items = [
|
||||
{
|
||||
"item_label": "My Profile",
|
||||
"item_type": "Route",
|
||||
"route": "/app/user-profile",
|
||||
"item_label": "User Settings",
|
||||
"item_type": "Action",
|
||||
"action": "frappe.ui.toolbar.route_to_user()",
|
||||
"is_standard": 1,
|
||||
},
|
||||
{
|
||||
"item_label": "My Settings",
|
||||
"item_label": "Workspace Settings",
|
||||
"item_type": "Action",
|
||||
"action": "frappe.ui.toolbar.route_to_user()",
|
||||
"action": "frappe.quick_edit('Workspace Settings')",
|
||||
"is_standard": 1,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@
|
|||
"link_fieldname": "connected_app"
|
||||
}
|
||||
],
|
||||
"modified": "2024-03-23 16:01:30.633764",
|
||||
"modified": "2024-07-05 08:24:50.182706",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Integrations",
|
||||
"name": "Connected App",
|
||||
|
|
@ -162,6 +162,7 @@
|
|||
"role": "All"
|
||||
}
|
||||
],
|
||||
"show_title_field_in_link": 1,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import os
|
||||
from urllib.parse import urlencode, urljoin
|
||||
|
||||
from oauthlib.oauth2 import BackendApplicationClient
|
||||
from requests_oauthlib import OAuth2Session
|
||||
|
||||
import frappe
|
||||
|
|
@ -147,6 +148,29 @@ class ConnectedApp(Document):
|
|||
|
||||
return token_cache
|
||||
|
||||
def get_backend_app_token(self):
|
||||
"""Get an Access Token for the Cloud-Registered Service Principal"""
|
||||
# There is no User assigned to the app, so we give it an empty string,
|
||||
# otherwise it will assign the logged in user.
|
||||
token_cache = self.get_token_cache("")
|
||||
if token_cache is None:
|
||||
token_cache = frappe.new_doc("Token Cache")
|
||||
token_cache.connected_app = self.name
|
||||
elif not token_cache.is_expired():
|
||||
return token_cache
|
||||
|
||||
# Get a new Access token for the App
|
||||
client = BackendApplicationClient(client_id=self.client_id, scope=self.get_scopes())
|
||||
oauth_session = OAuth2Session(client=client)
|
||||
|
||||
token = oauth_session.fetch_token(self.token_uri, client_secret=self.get_password("client_secret"))
|
||||
|
||||
token_cache.update_data(token)
|
||||
token_cache.save(ignore_permissions=True)
|
||||
frappe.db.commit()
|
||||
|
||||
return token_cache
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["GET"], allow_guest=True)
|
||||
def callback(code=None, state=None):
|
||||
|
|
|
|||
|
|
@ -85,6 +85,29 @@ frappe.ui.form.on("Webhook", {
|
|||
"background_jobs_queue",
|
||||
"frappe.integrations.doctype.webhook.webhook.get_all_queues"
|
||||
);
|
||||
|
||||
if (frm.doc.webhook_doctype) {
|
||||
frm.add_custom_button(__("Preview"), () => {
|
||||
const args = {
|
||||
doc: frm.doc,
|
||||
doctype: frm.doc.webhook_doctype,
|
||||
preview_fields: [
|
||||
{
|
||||
label: __("Meets Condition?"),
|
||||
fieldtype: "Data",
|
||||
method: "preview_meets_condition",
|
||||
},
|
||||
{
|
||||
label: __("Request Body"),
|
||||
fieldtype: "Code",
|
||||
method: "preview_request_body",
|
||||
},
|
||||
],
|
||||
};
|
||||
let dialog = new frappe.views.RenderPreviewer(args);
|
||||
return dialog;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
request_structure: (frm) => {
|
||||
|
|
@ -98,17 +121,6 @@ frappe.ui.form.on("Webhook", {
|
|||
enable_security: (frm) => {
|
||||
frm.toggle_reqd("webhook_secret", frm.doc.enable_security);
|
||||
},
|
||||
|
||||
preview_document: (frm) => {
|
||||
frappe.call({
|
||||
method: "generate_preview",
|
||||
doc: frm.doc,
|
||||
callback: (r) => {
|
||||
frm.refresh_field("meets_condition");
|
||||
frm.refresh_field("preview_request_body");
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Webhook Data", {
|
||||
|
|
|
|||
|
|
@ -30,13 +30,7 @@
|
|||
"webhook_headers",
|
||||
"sb_webhook_data",
|
||||
"webhook_data",
|
||||
"webhook_json",
|
||||
"preview_tab",
|
||||
"preview_document",
|
||||
"column_break_26",
|
||||
"meets_condition",
|
||||
"section_break_28",
|
||||
"preview_request_body"
|
||||
"webhook_json"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
|
|
@ -169,37 +163,6 @@
|
|||
"options": "POST\nPUT\nDELETE",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "preview_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Preview"
|
||||
},
|
||||
{
|
||||
"fieldname": "preview_document",
|
||||
"fieldtype": "Dynamic Link",
|
||||
"label": "Select Document",
|
||||
"options": "webhook_doctype"
|
||||
},
|
||||
{
|
||||
"fieldname": "preview_request_body",
|
||||
"fieldtype": "Code",
|
||||
"is_virtual": 1,
|
||||
"label": "Request Body"
|
||||
},
|
||||
{
|
||||
"fieldname": "meets_condition",
|
||||
"fieldtype": "Data",
|
||||
"is_virtual": 1,
|
||||
"label": "Meets Condition?"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_26",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_28",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "On checking this option, URL will be treated like a jinja template string",
|
||||
|
|
@ -226,7 +189,7 @@
|
|||
"link_fieldname": "webhook"
|
||||
}
|
||||
],
|
||||
"modified": "2024-03-23 16:04:03.108172",
|
||||
"modified": "2024-07-22 09:23:32.642172",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Integrations",
|
||||
"name": "Webhook",
|
||||
|
|
@ -250,4 +213,4 @@
|
|||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,9 +36,6 @@ class Webhook(Document):
|
|||
enable_security: DF.Check
|
||||
enabled: DF.Check
|
||||
is_dynamic_url: DF.Check
|
||||
meets_condition: DF.Data | None
|
||||
preview_document: DF.DynamicLink | None
|
||||
preview_request_body: DF.Code | None
|
||||
request_method: DF.Literal["POST", "PUT", "DELETE"]
|
||||
request_structure: DF.Literal["", "Form URL-Encoded", "JSON"]
|
||||
request_url: DF.SmallText
|
||||
|
|
@ -119,35 +116,24 @@ class Webhook(Document):
|
|||
frappe.throw(_("Invalid Webhook Secret"))
|
||||
|
||||
@frappe.whitelist()
|
||||
def generate_preview(self):
|
||||
# This function doesn't need to do anything specific as virtual fields
|
||||
# get evaluated automatically.
|
||||
pass
|
||||
|
||||
@property
|
||||
def meets_condition(self):
|
||||
def preview_meets_condition(self, preview_document):
|
||||
if not self.condition:
|
||||
return _("Yes")
|
||||
|
||||
if not (self.preview_document and self.webhook_doctype):
|
||||
return _("Select a document to check if it meets conditions.")
|
||||
|
||||
try:
|
||||
doc = frappe.get_cached_doc(self.webhook_doctype, self.preview_document)
|
||||
doc = frappe.get_cached_doc(self.webhook_doctype, preview_document)
|
||||
met_condition = frappe.safe_eval(self.condition, eval_locals=get_context(doc))
|
||||
except Exception as e:
|
||||
frappe.local.message_log = []
|
||||
return _("Failed to evaluate conditions: {}").format(e)
|
||||
return _("Yes") if met_condition else _("No")
|
||||
|
||||
@property
|
||||
def preview_request_body(self):
|
||||
if not (self.preview_document and self.webhook_doctype):
|
||||
return _("Select a document to preview request data")
|
||||
|
||||
@frappe.whitelist()
|
||||
def preview_request_body(self, preview_document):
|
||||
try:
|
||||
doc = frappe.get_cached_doc(self.webhook_doctype, self.preview_document)
|
||||
doc = frappe.get_cached_doc(self.webhook_doctype, preview_document)
|
||||
return frappe.as_json(get_webhook_data(doc, self))
|
||||
except Exception as e:
|
||||
frappe.local.message_log = []
|
||||
return _("Failed to compute request body: {}").format(e)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,8 @@
|
|||
# imports - standard imports
|
||||
import sys
|
||||
|
||||
# imports - module imports
|
||||
|
||||
from frappe.integrations.frappe_providers.frappecloud import frappecloud_migrator
|
||||
|
||||
|
||||
def migrate_to(local_site, frappe_provider):
|
||||
if frappe_provider in ("frappe.cloud", "frappecloud.com"):
|
||||
return frappecloud_migrator(local_site)
|
||||
else:
|
||||
print(f"{frappe_provider} is not supported yet")
|
||||
sys.exit(1)
|
||||
def migrate_to():
|
||||
return frappecloud_migrator()
|
||||
|
|
|
|||
|
|
@ -5,9 +5,8 @@ import frappe
|
|||
from frappe.core.utils import html2text
|
||||
|
||||
|
||||
def frappecloud_migrator(local_site):
|
||||
def get_remote_script(remote_site):
|
||||
print("Retrieving Site Migrator...")
|
||||
remote_site = frappe.conf.frappecloud_url or "frappecloud.com"
|
||||
request_url = f"https://{remote_site}/api/method/press.api.script"
|
||||
request = requests.get(request_url)
|
||||
|
||||
|
|
@ -19,8 +18,12 @@ def frappecloud_migrator(local_site):
|
|||
)
|
||||
return
|
||||
|
||||
script_contents = request.json()["message"]
|
||||
return request.json()["message"]
|
||||
|
||||
|
||||
def frappecloud_migrator():
|
||||
remote_site_name = "frappecloud.com"
|
||||
script_contents = get_remote_script(remote_site=remote_site_name)
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
|
@ -29,4 +32,4 @@ def frappecloud_migrator(local_site):
|
|||
script = tempfile.NamedTemporaryFile(mode="w")
|
||||
script.write(script_contents)
|
||||
print(f"Site Migrator stored at {script.name}")
|
||||
os.execv(py, [py, script.name, local_site])
|
||||
os.execv(py, [py, script.name])
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -13,6 +13,7 @@ import frappe.modules.patch_handler
|
|||
import frappe.translate
|
||||
from frappe.cache_manager import clear_global_cache
|
||||
from frappe.core.doctype.language.language import sync_languages
|
||||
from frappe.core.doctype.navbar_settings.navbar_settings import sync_standard_items
|
||||
from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs
|
||||
from frappe.database.schema import add_column
|
||||
from frappe.deferred_insert import save_to_db as flush_deferred_inserts
|
||||
|
|
@ -141,6 +142,7 @@ class SiteMigration:
|
|||
|
||||
print("Syncing fixtures...")
|
||||
sync_fixtures()
|
||||
sync_standard_items()
|
||||
|
||||
print("Syncing dashboards...")
|
||||
sync_dashboards()
|
||||
|
|
|
|||
|
|
@ -252,7 +252,7 @@ class BaseDocument:
|
|||
if key in self.__dict__:
|
||||
del self.__dict__[key]
|
||||
|
||||
def append(self, key: str, value: D | dict | None = None) -> D:
|
||||
def append(self, key: str, value: D | dict | None = None, position: int = -1) -> D:
|
||||
"""Append an item to a child table.
|
||||
|
||||
Example:
|
||||
|
|
@ -268,13 +268,22 @@ class BaseDocument:
|
|||
if (table := self.__dict__.get(key)) is None:
|
||||
self.__dict__[key] = table = []
|
||||
|
||||
ret_value = self._init_child(value, key)
|
||||
table.append(ret_value)
|
||||
d = self._init_child(value, key)
|
||||
|
||||
if position == -1:
|
||||
table.append(d)
|
||||
else:
|
||||
# insert at specific position
|
||||
table.insert(position, d)
|
||||
|
||||
# re number idx
|
||||
for i, _d in enumerate(table):
|
||||
_d.idx = i + 1
|
||||
|
||||
# reference parent document but with weak reference, parent_doc will be deleted if self is garbage collected.
|
||||
ret_value.parent_doc = weakref.ref(self)
|
||||
d.parent_doc = weakref.ref(self)
|
||||
|
||||
return ret_value
|
||||
return d
|
||||
|
||||
@property
|
||||
def parent_doc(self):
|
||||
|
|
|
|||
|
|
@ -127,8 +127,17 @@ def delete_doc(
|
|||
|
||||
# check if links exist
|
||||
if not force:
|
||||
check_if_doc_is_linked(doc)
|
||||
check_if_doc_is_dynamically_linked(doc)
|
||||
try:
|
||||
check_if_doc_is_linked(doc)
|
||||
check_if_doc_is_dynamically_linked(doc)
|
||||
except frappe.LinkExistsError as e:
|
||||
if doc.meta.has_field("enabled") or doc.meta.has_field("disabled"):
|
||||
frappe.throw(
|
||||
_("You can disable this {0} instead of deleting it.").format(_(doctype)),
|
||||
frappe.LinkExistsError,
|
||||
)
|
||||
else:
|
||||
raise e
|
||||
|
||||
update_naming_series(doc)
|
||||
delete_from_table(doctype, name, ignore_doctypes, doc)
|
||||
|
|
|
|||
|
|
@ -238,7 +238,7 @@ class Document(BaseDocument):
|
|||
def raise_no_permission_to(self, perm_type):
|
||||
"""Raise `frappe.PermissionError`."""
|
||||
frappe.flags.error_message = (
|
||||
_("Insufficient Permission for {0}").format(self.doctype) + f" ({frappe.bold(_(perm_type))})"
|
||||
_("Insufficient Permission for {0}").format(_(self.doctype)) + f" ({frappe.bold(_(perm_type))})"
|
||||
)
|
||||
raise frappe.PermissionError
|
||||
|
||||
|
|
@ -458,7 +458,7 @@ class Document(BaseDocument):
|
|||
d: Document
|
||||
d.db_update()
|
||||
|
||||
def get_doc_before_save(self) -> "Document":
|
||||
def get_doc_before_save(self) -> "Self":
|
||||
return getattr(self, "_doc_before_save", None)
|
||||
|
||||
def has_value_changed(self, fieldname):
|
||||
|
|
@ -1031,7 +1031,7 @@ class Document(BaseDocument):
|
|||
"on_cancel": "Cancel",
|
||||
}
|
||||
|
||||
if not self.flags.in_insert:
|
||||
if not self.flags.in_insert and not self.flags.in_delete:
|
||||
# value change is not applicable in insert
|
||||
event_map["on_change"] = "Value Change"
|
||||
|
||||
|
|
|
|||
|
|
@ -64,16 +64,20 @@ def export_customizations(
|
|||
frappe.throw(_("Only allowed to export customizations in developer mode"))
|
||||
|
||||
custom = {
|
||||
"custom_fields": frappe.get_all("Custom Field", fields="*", filters={"dt": doctype}),
|
||||
"property_setters": frappe.get_all("Property Setter", fields="*", filters={"doc_type": doctype}),
|
||||
"custom_fields": frappe.get_all("Custom Field", fields="*", filters={"dt": doctype}, order_by="name"),
|
||||
"property_setters": frappe.get_all(
|
||||
"Property Setter", fields="*", filters={"doc_type": doctype}, order_by="name"
|
||||
),
|
||||
"custom_perms": [],
|
||||
"links": frappe.get_all("DocType Link", fields="*", filters={"parent": doctype}),
|
||||
"links": frappe.get_all("DocType Link", fields="*", filters={"parent": doctype}, order_by="name"),
|
||||
"doctype": doctype,
|
||||
"sync_on_migrate": sync_on_migrate,
|
||||
}
|
||||
|
||||
if with_permissions:
|
||||
custom["custom_perms"] = frappe.get_all("Custom DocPerm", fields="*", filters={"parent": doctype})
|
||||
custom["custom_perms"] = frappe.get_all(
|
||||
"Custom DocPerm", fields="*", filters={"parent": doctype}, order_by="name"
|
||||
)
|
||||
|
||||
# also update the custom fields and property setters for all child tables
|
||||
for d in frappe.get_meta(doctype).get_table_fields():
|
||||
|
|
|
|||
|
|
@ -237,3 +237,5 @@ frappe.patches.v15_0.migrate_session_data
|
|||
frappe.custom.doctype.property_setter.patches.remove_invalid_fetch_from_expressions
|
||||
frappe.patches.v16_0.switch_default_sort_order
|
||||
frappe.integrations.doctype.oauth_client.patches.set_default_allowed_role_in_oauth_client
|
||||
execute:frappe.db.set_single_value("Workspace Settings", "workspace_setup_completed", 1)
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import "./frappe/form/templates/timeline_message_box.html";
|
|||
import "./frappe/form/templates/users_in_sidebar.html";
|
||||
|
||||
import "./frappe/views/formview.js";
|
||||
import "./frappe/views/render_preview.js";
|
||||
import "./frappe/form/form.js";
|
||||
import "./frappe/meta_tag.js";
|
||||
import "./frappe/doctype/";
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ let field_df = computedAsync(async () => {
|
|||
watch(
|
||||
() => props.value,
|
||||
(value) => {
|
||||
[doctype.value, fieldname.value] = value?.split(".") || ["", ""];
|
||||
if (value) [doctype.value, fieldname.value] = value.split(".") || ["", ""];
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ frappe.ui.form.ControlInput = class ControlInput extends frappe.ui.form.Control
|
|||
|
||||
// set description
|
||||
this.set_max_width();
|
||||
|
||||
// set initial value if set
|
||||
if (this.df.initial_value) {
|
||||
this.set_value(this.df.initial_value);
|
||||
}
|
||||
}
|
||||
make_wrapper() {
|
||||
if (this.only_input) {
|
||||
|
|
|
|||
|
|
@ -7,10 +7,11 @@ frappe.ui.form.ControlData = class ControlData extends frappe.ui.form.ControlInp
|
|||
make_input() {
|
||||
if (this.$input) return;
|
||||
|
||||
let { html_element, input_type } = this.constructor;
|
||||
let { html_element, input_type, input_mode } = this.constructor;
|
||||
|
||||
this.$input = $("<" + html_element + ">")
|
||||
.attr("type", input_type)
|
||||
.attr("inputmode", input_mode)
|
||||
.attr("autocomplete", "off")
|
||||
.addClass("input-with-feedback form-control")
|
||||
.prependTo(this.input_area);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,13 @@ frappe.ui.form.ControlDuration = class ControlDuration extends frappe.ui.form.Co
|
|||
this.make_picker();
|
||||
}
|
||||
|
||||
validate(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return super.validate(value);
|
||||
}
|
||||
|
||||
make_picker() {
|
||||
this.inputs = [];
|
||||
this.set_duration_options();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
frappe.ui.form.ControlInt = class ControlInt extends frappe.ui.form.ControlData {
|
||||
static trigger_change_on_input_event = false;
|
||||
static input_mode = "numeric";
|
||||
make() {
|
||||
super.make();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -255,7 +255,7 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
|
|||
doctype: doctype,
|
||||
ignore_user_permissions: me.df.ignore_user_permissions,
|
||||
reference_doctype: me.get_reference_doctype() || "",
|
||||
page_length: cint(frappe.boot.sysdefaults.link_field_results_limit) || 10,
|
||||
page_length: cint(frappe.boot.sysdefaults?.link_field_results_limit) || 10,
|
||||
};
|
||||
|
||||
me.set_custom_query(args);
|
||||
|
|
@ -638,13 +638,18 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
|
|||
if (value) {
|
||||
field_value = response[source_field];
|
||||
}
|
||||
frappe.model.set_value(
|
||||
this.df.parent,
|
||||
this.docname,
|
||||
target_field,
|
||||
field_value,
|
||||
this.df.fieldtype
|
||||
);
|
||||
|
||||
if (this.layout?.set_value) {
|
||||
this.layout.set_value(target_field, field_value);
|
||||
} else if (this.frm) {
|
||||
frappe.model.set_value(
|
||||
this.df.parent,
|
||||
this.docname,
|
||||
target_field,
|
||||
field_value,
|
||||
this.df.fieldtype
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -669,8 +674,73 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
|
|||
}
|
||||
}
|
||||
|
||||
fetch_map_for_quick_entry() {
|
||||
let me = this;
|
||||
let fetch_map = {};
|
||||
function add_fetch(link_field, source_field, target_field, target_doctype) {
|
||||
if (!target_doctype) target_doctype = "*";
|
||||
|
||||
if (!me.layout.fetch_dict) {
|
||||
me.layout.fetch_dict = {};
|
||||
}
|
||||
|
||||
// Target field kept as key because source field could be non-unique
|
||||
me.layout.fetch_dict.setDefault(target_doctype, {}).setDefault(link_field, {})[
|
||||
target_field
|
||||
] = source_field;
|
||||
}
|
||||
|
||||
function setup_add_fetch(df) {
|
||||
let is_read_only_field =
|
||||
[
|
||||
"Data",
|
||||
"Read Only",
|
||||
"Text",
|
||||
"Small Text",
|
||||
"Currency",
|
||||
"Check",
|
||||
"Text Editor",
|
||||
"Attach Image",
|
||||
"Code",
|
||||
"Link",
|
||||
"Float",
|
||||
"Int",
|
||||
"Date",
|
||||
"Select",
|
||||
"Duration",
|
||||
"Time",
|
||||
].includes(df.fieldtype) ||
|
||||
df.read_only == 1 ||
|
||||
df.is_virtual == 1;
|
||||
|
||||
if (is_read_only_field && df.fetch_from && df.fetch_from.indexOf(".") != -1) {
|
||||
var parts = df.fetch_from.split(".");
|
||||
add_fetch(parts[0], parts[1], df.fieldname, df.parent);
|
||||
}
|
||||
}
|
||||
|
||||
$.each(this.layout.fields, (i, field) => setup_add_fetch(field));
|
||||
|
||||
for (const key of ["*", this.df.parent]) {
|
||||
if (!this.layout.fetch_dict) {
|
||||
this.layout.fetch_dict = {};
|
||||
}
|
||||
if (this.layout.fetch_dict[key] && this.layout.fetch_dict[key][this.df.fieldname]) {
|
||||
Object.assign(fetch_map, this.layout.fetch_dict[key][this.df.fieldname]);
|
||||
}
|
||||
}
|
||||
|
||||
return fetch_map;
|
||||
}
|
||||
|
||||
get fetch_map() {
|
||||
const fetch_map = {};
|
||||
|
||||
// Create fetch_map from quick entry fields
|
||||
if (!this.frm && this.layout && this.layout.fields) {
|
||||
return this.fetch_map_for_quick_entry();
|
||||
}
|
||||
|
||||
if (!this.frm) return fetch_map;
|
||||
|
||||
for (const key of ["*", this.df.parent]) {
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ frappe.ui.form.ControlMultiSelectList = class ControlMultiSelectList extends (
|
|||
//Unselect old values
|
||||
this.values.forEach((value) => {
|
||||
this.$list_wrapper
|
||||
.find(`.selectable-item[data-value=${value}]`)
|
||||
.find(`.selectable-item[data-value=${CSS.escape(value)}]`)
|
||||
.toggleClass("selected");
|
||||
});
|
||||
this.values = value;
|
||||
|
|
@ -147,7 +147,7 @@ frappe.ui.form.ControlMultiSelectList = class ControlMultiSelectList extends (
|
|||
this.update_selected_values(value);
|
||||
//Select new values
|
||||
this.$list_wrapper
|
||||
.find(`.selectable-item[data-value=${value}]`)
|
||||
.find(`.selectable-item[data-value=${CSS.escape(value)}]`)
|
||||
.toggleClass("selected");
|
||||
});
|
||||
this.parse_validate_and_set_in_model("");
|
||||
|
|
|
|||
|
|
@ -633,8 +633,8 @@ frappe.ui.form.Dashboard = class FormDashboard {
|
|||
}
|
||||
|
||||
// TODO: Review! code related to headline should be the part of layout/form
|
||||
set_headline(html, color) {
|
||||
this.frm.layout.show_message(html, color);
|
||||
set_headline(html, color, permanent = false) {
|
||||
this.frm.layout.show_message(html, color, permanent);
|
||||
}
|
||||
|
||||
clear_headline() {
|
||||
|
|
@ -642,7 +642,7 @@ frappe.ui.form.Dashboard = class FormDashboard {
|
|||
}
|
||||
|
||||
add_comment(text, alert_class, permanent) {
|
||||
this.set_headline_alert(text, alert_class);
|
||||
this.set_headline_alert(text, alert_class, permanent);
|
||||
if (!permanent) {
|
||||
setTimeout(() => {
|
||||
this.clear_headline();
|
||||
|
|
@ -654,9 +654,9 @@ frappe.ui.form.Dashboard = class FormDashboard {
|
|||
this.clear_headline();
|
||||
}
|
||||
|
||||
set_headline_alert(text, color) {
|
||||
set_headline_alert(text, color, permanent = false) {
|
||||
if (text) {
|
||||
this.set_headline(`<div>${text}</div>`, color);
|
||||
this.set_headline(`<div>${text}</div>`, color, permanent);
|
||||
} else {
|
||||
this.clear_headline();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,12 +22,14 @@ class FormTimeline extends BaseTimeline {
|
|||
}
|
||||
|
||||
setup_timeline_actions() {
|
||||
this.add_action_button(
|
||||
__("New Email"),
|
||||
() => this.compose_mail(),
|
||||
"es-line-add",
|
||||
"btn-secondary"
|
||||
);
|
||||
if (frappe.model.can_email(null, this.frm)) {
|
||||
this.add_action_button(
|
||||
__("New Email"),
|
||||
() => this.compose_mail(),
|
||||
"es-line-add",
|
||||
"btn-secondary"
|
||||
);
|
||||
}
|
||||
this.setup_new_event_button();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -83,13 +83,17 @@ function get_version_timeline_content(version_doc, frm) {
|
|||
}
|
||||
} else {
|
||||
const df = frappe.meta.get_docfield(frm.doctype, p[0], frm.docname);
|
||||
if (df && !df.hidden) {
|
||||
if (df && (!df.hidden || df.show_on_timeline)) {
|
||||
const field_display_status = frappe.perm.get_field_display_status(
|
||||
df,
|
||||
null,
|
||||
frm.perm
|
||||
);
|
||||
if (field_display_status === "Read" || field_display_status === "Write") {
|
||||
if (
|
||||
field_display_status === "Read" ||
|
||||
field_display_status === "Write" ||
|
||||
(df.hidden && df.show_on_timeline)
|
||||
) {
|
||||
parts.push(
|
||||
__("{0} from {1} to {2}", [
|
||||
__(df.label, null, df.parent),
|
||||
|
|
@ -142,14 +146,18 @@ function get_version_timeline_content(version_doc, frm) {
|
|||
frm.docname
|
||||
);
|
||||
|
||||
if (df && !df.hidden) {
|
||||
if (df && (!df.hidden || df.show_on_timeline)) {
|
||||
var field_display_status = frappe.perm.get_field_display_status(
|
||||
df,
|
||||
null,
|
||||
frm.perm
|
||||
);
|
||||
|
||||
if (field_display_status === "Read" || field_display_status === "Write") {
|
||||
if (
|
||||
field_display_status === "Read" ||
|
||||
field_display_status === "Write" ||
|
||||
(df.hidden && df.show_on_timeline)
|
||||
) {
|
||||
parts.push(
|
||||
__("{0} from {1} to {2} in row #{3}", [
|
||||
frappe.meta.get_label(frm.fields_dict[row[0]].grid.doctype, p[0]),
|
||||
|
|
@ -197,14 +205,19 @@ function get_version_timeline_content(version_doc, frm) {
|
|||
if (data[key] && data[key].length) {
|
||||
let parts = (data[key] || []).map(function (p) {
|
||||
var df = frappe.meta.get_docfield(frm.doctype, p[0], frm.docname);
|
||||
if (df && !df.hidden) {
|
||||
|
||||
if (df && (!df.hidden || df.show_on_timeline)) {
|
||||
var field_display_status = frappe.perm.get_field_display_status(
|
||||
df,
|
||||
null,
|
||||
frm.perm
|
||||
);
|
||||
|
||||
if (field_display_status === "Read" || field_display_status === "Write") {
|
||||
if (
|
||||
field_display_status === "Read" ||
|
||||
field_display_status === "Write" ||
|
||||
(df.hidden && df.show_on_timeline)
|
||||
) {
|
||||
return __(frappe.meta.get_label(frm.doctype, p[0]));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -418,7 +418,7 @@ frappe.ui.form.Form = class FrappeForm {
|
|||
// read only (workflow)
|
||||
this.read_only = frappe.workflow.is_read_only(this.doctype, this.docname);
|
||||
if (this.read_only) {
|
||||
this.set_read_only(true);
|
||||
this.set_read_only();
|
||||
frappe.show_alert(__("This form is not editable due to a Workflow."));
|
||||
}
|
||||
|
||||
|
|
@ -515,7 +515,7 @@ frappe.ui.form.Form = class FrappeForm {
|
|||
|
||||
// feedback
|
||||
frappe.msgprint({
|
||||
message: __("{} Complete", [action.label]),
|
||||
message: __("{} Complete", [__(action.label)]),
|
||||
alert: true,
|
||||
});
|
||||
});
|
||||
|
|
@ -550,7 +550,6 @@ frappe.ui.form.Form = class FrappeForm {
|
|||
}
|
||||
|
||||
trigger_onload(switched) {
|
||||
this.cscript.is_onload = false;
|
||||
if (!this.opendocs[this.docname]) {
|
||||
var me = this;
|
||||
this.cscript.is_onload = true;
|
||||
|
|
@ -628,6 +627,7 @@ frappe.ui.form.Form = class FrappeForm {
|
|||
() => this.cscript.is_onload && this.is_new() && this.focus_on_first_input(),
|
||||
() => this.run_after_load_hook(),
|
||||
() => this.dashboard.after_refresh(),
|
||||
() => (this.cscript.is_onload = false),
|
||||
]);
|
||||
} else {
|
||||
this.refresh_header(switched);
|
||||
|
|
@ -671,6 +671,10 @@ frappe.ui.form.Form = class FrappeForm {
|
|||
}
|
||||
|
||||
refresh_fields() {
|
||||
if (this.layout === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.layout.refresh(this.doc);
|
||||
this.layout.primary_button = this.$wrapper.find(".btn-primary");
|
||||
|
||||
|
|
@ -1843,6 +1847,7 @@ frappe.ui.form.Form = class FrappeForm {
|
|||
email: p.email,
|
||||
};
|
||||
});
|
||||
this.refresh_fields();
|
||||
}
|
||||
|
||||
trigger(event, doctype, docname) {
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@ frappe.ui.form.FormTour = class FormTour {
|
|||
padding: 10,
|
||||
overlayClickNext: true,
|
||||
keyboardControl: true,
|
||||
nextBtnText: "Next",
|
||||
prevBtnText: "Previous",
|
||||
nextBtnText: __("Next"),
|
||||
prevBtnText: __("Previous"),
|
||||
doneBtnText: __("Done"),
|
||||
closeBtnText: __("Close"),
|
||||
opacity: 0.25,
|
||||
onHighlighted: (step) => {
|
||||
// if last step is to save, then attach a listener to save button
|
||||
|
|
@ -135,7 +137,11 @@ frappe.ui.form.FormTour = class FormTour {
|
|||
return {
|
||||
element,
|
||||
name,
|
||||
popover: { title, description, position: frappe.router.slug(position || "Bottom") },
|
||||
popover: {
|
||||
title: __(title),
|
||||
description: __(description),
|
||||
position: frappe.router.slug(position || "Bottom"),
|
||||
},
|
||||
onNext: on_next,
|
||||
onPrevious: on_prev,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -95,11 +95,10 @@ export default class GridRow {
|
|||
remove() {
|
||||
var me = this;
|
||||
if (this.grid.is_editable()) {
|
||||
if (this.get_open_form()) {
|
||||
this.hide_form();
|
||||
}
|
||||
if (this.frm) {
|
||||
if (this.get_open_form()) {
|
||||
this.hide_form();
|
||||
}
|
||||
|
||||
frappe
|
||||
.run_serially([
|
||||
() => {
|
||||
|
|
@ -840,10 +839,12 @@ export default class GridRow {
|
|||
delete this.grid.filter[df.fieldname];
|
||||
}
|
||||
|
||||
this.grid.grid_sortable.option(
|
||||
"disabled",
|
||||
Object.keys(this.grid.filter).length !== 0
|
||||
);
|
||||
if (this.grid.grid_sortable) {
|
||||
this.grid.grid_sortable.option(
|
||||
"disabled",
|
||||
Object.keys(this.grid.filter).length !== 0
|
||||
);
|
||||
}
|
||||
|
||||
this.grid.prevent_build = true;
|
||||
this.grid.grid_pagination.go_to_page(1);
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ frappe.ui.form.Layout = class Layout {
|
|||
return fields;
|
||||
}
|
||||
|
||||
show_message(html, color) {
|
||||
show_message(html, color, permanent = false) {
|
||||
if (this.message_color) {
|
||||
// remove previous color
|
||||
this.message.removeClass(this.message_color);
|
||||
|
|
@ -112,8 +112,10 @@ frappe.ui.form.Layout = class Layout {
|
|||
}
|
||||
this.message.removeClass("hidden").addClass(this.message_color);
|
||||
$(html).appendTo(this.message);
|
||||
close_message.appendTo(this.message);
|
||||
close_message.on("click", () => this.message.empty().addClass("hidden"));
|
||||
if (!permanent) {
|
||||
close_message.appendTo(this.message);
|
||||
close_message.on("click", () => this.message.empty().addClass("hidden"));
|
||||
}
|
||||
} else {
|
||||
this.message.empty().addClass("hidden");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
frappe.provide("frappe.ui.form");
|
||||
|
||||
frappe.quick_edit = function (doctype, name) {
|
||||
if (!name) name = doctype; // single
|
||||
frappe.db.get_doc(doctype, name).then((doc) => {
|
||||
frappe.ui.form.make_quick_entry(doctype, null, null, doc);
|
||||
});
|
||||
|
|
@ -24,13 +25,15 @@ frappe.ui.form.make_quick_entry = (doctype, after_insert, init_callback, doc, fo
|
|||
return frappe.quick_entry.setup();
|
||||
};
|
||||
|
||||
frappe.ui.form.QuickEntryForm = class QuickEntryForm {
|
||||
frappe.ui.form.QuickEntryForm = class QuickEntryForm extends frappe.ui.Dialog {
|
||||
constructor(doctype, after_insert, init_callback, doc, force) {
|
||||
super({ auto_make: false });
|
||||
this.doctype = doctype;
|
||||
this.after_insert = after_insert;
|
||||
this.init_callback = init_callback;
|
||||
this.doc = doc;
|
||||
this.force = force ? force : false;
|
||||
this.dialog = this; // for backward compatibility
|
||||
}
|
||||
|
||||
setup() {
|
||||
|
|
@ -39,6 +42,7 @@ frappe.ui.form.QuickEntryForm = class QuickEntryForm {
|
|||
this.check_quick_entry_doc();
|
||||
this.set_meta_and_mandatory_fields();
|
||||
if (this.is_quick_entry() || this.force) {
|
||||
this.setup_script_manager();
|
||||
this.render_dialog();
|
||||
resolve(this);
|
||||
} else {
|
||||
|
|
@ -60,7 +64,7 @@ frappe.ui.form.QuickEntryForm = class QuickEntryForm {
|
|||
this.meta = frappe.get_meta(this.doctype);
|
||||
let fields = this.meta.fields;
|
||||
|
||||
this.mandatory = fields.filter((df) => {
|
||||
this.docfields = fields.filter((df) => {
|
||||
return (
|
||||
(df.reqd || df.allow_in_quick_entry) &&
|
||||
!df.read_only &&
|
||||
|
|
@ -83,7 +87,7 @@ frappe.ui.form.QuickEntryForm = class QuickEntryForm {
|
|||
|
||||
this.validate_for_prompt_autoname();
|
||||
|
||||
if (this.has_child_table() || !this.mandatory.length) {
|
||||
if (this.has_child_table() || !this.docfields.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -91,7 +95,7 @@ frappe.ui.form.QuickEntryForm = class QuickEntryForm {
|
|||
}
|
||||
|
||||
too_many_mandatory_fields() {
|
||||
if (this.mandatory.length > 7) {
|
||||
if (this.docfields.length > 7) {
|
||||
// too many fields, show form
|
||||
return true;
|
||||
}
|
||||
|
|
@ -100,7 +104,7 @@ frappe.ui.form.QuickEntryForm = class QuickEntryForm {
|
|||
|
||||
has_child_table() {
|
||||
if (
|
||||
$.map(this.mandatory, function (d) {
|
||||
$.map(this.docfields, function (d) {
|
||||
return d.fieldtype === "Table" ? d : null;
|
||||
}).length
|
||||
) {
|
||||
|
|
@ -112,53 +116,73 @@ frappe.ui.form.QuickEntryForm = class QuickEntryForm {
|
|||
|
||||
validate_for_prompt_autoname() {
|
||||
if (this.meta.autoname && this.meta.autoname.toLowerCase() === "prompt") {
|
||||
this.mandatory = [
|
||||
this.docfields = [
|
||||
{
|
||||
fieldname: "__newname",
|
||||
label: __("{0} Name", [__(this.meta.name)]),
|
||||
reqd: 1,
|
||||
fieldtype: "Data",
|
||||
},
|
||||
].concat(this.mandatory);
|
||||
].concat(this.docfields);
|
||||
}
|
||||
}
|
||||
|
||||
setup_script_manager() {
|
||||
this.script_manager = new frappe.ui.form.ScriptManager({
|
||||
frm: this,
|
||||
});
|
||||
this.script_manager.setup();
|
||||
}
|
||||
|
||||
get mandatory() {
|
||||
// Backwards compatibility
|
||||
console.warn("QuickEntryForm: .mandatory is deprecated, use .docfields instead");
|
||||
return this.docfields;
|
||||
}
|
||||
|
||||
set mandatory(value) {
|
||||
// Backwards compatibility
|
||||
console.warn("QuickEntryForm: .mandatory is deprecated, use .docfields instead");
|
||||
this.docfields = value;
|
||||
}
|
||||
|
||||
render_dialog() {
|
||||
var me = this;
|
||||
this.dialog = new frappe.ui.Dialog({
|
||||
title: __("New {0}", [__(this.doctype)]),
|
||||
fields: this.mandatory,
|
||||
doc: this.doc,
|
||||
});
|
||||
|
||||
this.fields = this.docfields;
|
||||
this.title = this.get_title();
|
||||
|
||||
super.make();
|
||||
this.register_primary_action();
|
||||
!this.force && this.render_edit_in_full_page_link();
|
||||
// ctrl+enter to save
|
||||
this.dialog.wrapper.keydown(function (e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.which == 13) {
|
||||
if (!frappe.request.ajax_count) {
|
||||
// not already working -- double entry
|
||||
me.dialog.get_primary_btn().trigger("click");
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
this.render_edit_in_full_page_link();
|
||||
this.setup_cmd_enter_for_save();
|
||||
|
||||
this.dialog.onhide = () => (frappe.quick_entry = null);
|
||||
this.dialog.show();
|
||||
this.onhide = () => (frappe.quick_entry = null);
|
||||
this.show();
|
||||
|
||||
this.dialog.refresh_dependency();
|
||||
this.refresh_dependency();
|
||||
this.set_defaults();
|
||||
|
||||
this.script_manager.trigger("refresh");
|
||||
|
||||
if (this.init_callback) {
|
||||
this.init_callback(this.dialog);
|
||||
this.init_callback(this);
|
||||
}
|
||||
}
|
||||
|
||||
get_title() {
|
||||
if (this.title) {
|
||||
return this.title;
|
||||
} else if (this.meta.issingle) {
|
||||
return __(this.doctype);
|
||||
} else {
|
||||
return __("New {0}", [__(this.doctype)]);
|
||||
}
|
||||
}
|
||||
|
||||
register_primary_action() {
|
||||
var me = this;
|
||||
this.dialog.set_primary_action(__("Save"), function () {
|
||||
this.set_primary_action(__("Save"), function () {
|
||||
if (me.dialog.working) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -166,16 +190,15 @@ frappe.ui.form.QuickEntryForm = class QuickEntryForm {
|
|||
|
||||
if (data) {
|
||||
me.dialog.working = true;
|
||||
me.insert().then(() => {
|
||||
let messagetxt = __("New {0} {1} created", [
|
||||
__(me.doctype),
|
||||
this.doc.name.bold(),
|
||||
]);
|
||||
me.dialog.animation_speed = "slow";
|
||||
me.dialog.hide();
|
||||
setTimeout(function () {
|
||||
frappe.show_alert({ message: messagetxt, indicator: "green" }, 3);
|
||||
}, 500);
|
||||
me.script_manager.trigger("validate").then(() => {
|
||||
me.insert().then(() => {
|
||||
let messagetxt = __("{1} saved", [__(me.doctype), this.doc.name.bold()]);
|
||||
me.dialog.animation_speed = "slow";
|
||||
me.dialog.hide();
|
||||
setTimeout(function () {
|
||||
frappe.show_alert({ message: messagetxt, indicator: "green" }, 3);
|
||||
}, 500);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -236,20 +259,38 @@ frappe.ui.form.QuickEntryForm = class QuickEntryForm {
|
|||
|
||||
process_after_insert(r) {
|
||||
// delete the old doc
|
||||
frappe.model.clear_doc(this.dialog.doc.doctype, this.dialog.doc.name);
|
||||
this.dialog.doc = r.message;
|
||||
if (frappe._from_link) {
|
||||
frappe.ui.form.update_calling_link(this.dialog.doc);
|
||||
frappe.model.clear_doc(this.doc.doctype, this.doc.name);
|
||||
this.doc = r.message;
|
||||
if (this.script_manager.has_handler("after_save")) {
|
||||
return this.script_manager.trigger("after_save");
|
||||
} else if (frappe._from_link) {
|
||||
frappe.ui.form.update_calling_link(this.doc);
|
||||
} else if (this.after_insert) {
|
||||
this.after_insert(this.dialog.doc);
|
||||
this.after_insert(this.doc);
|
||||
} else {
|
||||
this.open_form_if_not_list();
|
||||
}
|
||||
}
|
||||
|
||||
setup_cmd_enter_for_save() {
|
||||
var me = this;
|
||||
// ctrl+enter to save
|
||||
this.wrapper.keydown(function (e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.which == 13) {
|
||||
if (!frappe.request.ajax_count) {
|
||||
// not already working -- double entry
|
||||
me.dialog.get_primary_btn().trigger("click");
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
open_form_if_not_list() {
|
||||
if (this.meta.issingle) return;
|
||||
let route = frappe.get_route();
|
||||
let doc = this.dialog.doc;
|
||||
let doc = this.doc;
|
||||
if (route && !(route[0] === "List" && route[1] === doc.doctype)) {
|
||||
frappe.run_serially([() => frappe.set_route("Form", doc.doctype, doc.name)]);
|
||||
}
|
||||
|
|
@ -257,17 +298,17 @@ frappe.ui.form.QuickEntryForm = class QuickEntryForm {
|
|||
|
||||
update_doc() {
|
||||
var me = this;
|
||||
var data = this.dialog.get_values(true);
|
||||
var data = this.get_values(true);
|
||||
$.each(data, function (key, value) {
|
||||
if (!is_null(value)) {
|
||||
me.dialog.doc[key] = value;
|
||||
}
|
||||
});
|
||||
return this.dialog.doc;
|
||||
return this.doc;
|
||||
}
|
||||
|
||||
open_doc(set_hooks) {
|
||||
this.dialog.hide();
|
||||
this.hide();
|
||||
this.update_doc();
|
||||
if (set_hooks && this.after_insert) {
|
||||
frappe.route_options = frappe.route_options || {};
|
||||
|
|
@ -279,14 +320,14 @@ frappe.ui.form.QuickEntryForm = class QuickEntryForm {
|
|||
}
|
||||
|
||||
render_edit_in_full_page_link() {
|
||||
var me = this;
|
||||
this.dialog.add_custom_action(__("Edit Full Form"), () => me.open_doc(true));
|
||||
if (this.force || this.hide_full_form_button) return;
|
||||
this.add_custom_action(__("Edit Full Form"), () => this.open_doc(true));
|
||||
}
|
||||
|
||||
set_defaults() {
|
||||
var me = this;
|
||||
// set defaults
|
||||
$.each(this.dialog.fields_dict, function (fieldname, field) {
|
||||
$.each(this.fields_dict, function (fieldname, field) {
|
||||
field.doctype = me.doc.doctype;
|
||||
field.docname = me.doc.name;
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ window.refresh_field = function (n, docname, table_field) {
|
|||
};
|
||||
|
||||
window.set_field_options = function (n, txt) {
|
||||
cur_frm.set_df_property(n, "options", txt);
|
||||
cur_frm?.set_df_property(n, "options", txt);
|
||||
};
|
||||
|
||||
window.toggle_field = function (n, hidden) {
|
||||
|
|
|
|||
|
|
@ -140,6 +140,13 @@ frappe.ui.form.ScriptManager = class ScriptManager {
|
|||
// run them serially
|
||||
return frappe.run_serially(tasks);
|
||||
}
|
||||
has_handler(event_name) {
|
||||
// return true if there exist an event handler (new style only)
|
||||
return (
|
||||
frappe.ui.form.handlers[this.frm.doctype] &&
|
||||
frappe.ui.form.handlers[this.frm.doctype][event_name]
|
||||
);
|
||||
}
|
||||
has_handlers(event_name, doctype) {
|
||||
let handlers = this.get_handlers(event_name, doctype);
|
||||
return handlers && (handlers.old_style.length || handlers.new_style.length);
|
||||
|
|
@ -156,10 +163,10 @@ frappe.ui.form.ScriptManager = class ScriptManager {
|
|||
handlers.new_style.push(fn);
|
||||
});
|
||||
}
|
||||
if (this.frm.cscript[event_name]) {
|
||||
if (this.frm.cscript && this.frm.cscript[event_name]) {
|
||||
handlers.old_style.push(event_name);
|
||||
}
|
||||
if (this.frm.cscript["custom_" + event_name]) {
|
||||
if (this.frm.cscript && this.frm.cscript["custom_" + event_name]) {
|
||||
handlers.old_style.push("custom_" + event_name);
|
||||
}
|
||||
return handlers;
|
||||
|
|
@ -238,6 +245,7 @@ frappe.ui.form.ScriptManager = class ScriptManager {
|
|||
|
||||
this.trigger("setup");
|
||||
}
|
||||
|
||||
log_error(caller, e) {
|
||||
frappe.show_alert({ message: __("Error in Client Script."), indicator: "error" });
|
||||
console.group && console.group();
|
||||
|
|
|
|||
|
|
@ -455,12 +455,9 @@ frappe.views.BaseList = class BaseList {
|
|||
get_filter_value(fieldname) {
|
||||
const filter = this.get_filters_for_args().filter((f) => f[1] == fieldname)[0];
|
||||
if (!filter) return;
|
||||
return (
|
||||
{
|
||||
like: filter[3]?.replace(/^%?|%$/g, ""),
|
||||
"not set": null,
|
||||
}[filter[2]] || filter[3]
|
||||
);
|
||||
if (filter[2] === "like") return filter[3]?.replace(/^%?|%$/g, "");
|
||||
else if (filter[2] === "not set") return null;
|
||||
else return filter[3];
|
||||
}
|
||||
|
||||
get_filters_for_args() {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export default class BulkOperations {
|
|||
const allow_print_for_cancelled = cint(print_settings.allow_print_for_cancelled);
|
||||
const letterheads = this.get_letterhead_options();
|
||||
const MAX_PRINT_LIMIT = 500;
|
||||
const BACKGROUND_PRINT_THRESHOLD = 25;
|
||||
|
||||
const valid_docs = docs
|
||||
.filter((doc) => {
|
||||
|
|
@ -81,6 +82,13 @@ export default class BulkOperations {
|
|||
depends_on: 'eval:doc.page_size == "Custom"',
|
||||
default: print_settings.pdf_page_width,
|
||||
},
|
||||
{
|
||||
fieldtype: "Check",
|
||||
label: __("Background Print (required for >25 documents)"),
|
||||
fieldname: "background_print",
|
||||
default: valid_docs.length > BACKGROUND_PRINT_THRESHOLD,
|
||||
read_only: valid_docs.length > BACKGROUND_PRINT_THRESHOLD,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
|
@ -105,33 +113,55 @@ export default class BulkOperations {
|
|||
pdf_options = JSON.stringify({ "page-size": args.page_size });
|
||||
}
|
||||
|
||||
frappe
|
||||
.call("frappe.utils.print_format.download_multi_pdf_async", {
|
||||
doctype: this.doctype,
|
||||
name: json_string,
|
||||
format: print_format,
|
||||
no_letterhead: with_letterhead ? "0" : "1",
|
||||
letterhead: letterhead,
|
||||
options: pdf_options,
|
||||
})
|
||||
.then((response) => {
|
||||
let task_id = response.message.task_id;
|
||||
frappe.realtime.task_subscribe(task_id);
|
||||
frappe.realtime.on(`task_complete:${task_id}`, (data) => {
|
||||
frappe.msgprint({
|
||||
title: __("Bulk PDF Export"),
|
||||
message: __("Your PDF is ready for download"),
|
||||
primary_action: {
|
||||
label: __("Download PDF"),
|
||||
client_action: "window.open",
|
||||
args: data.file_url,
|
||||
},
|
||||
if (args.background_print) {
|
||||
frappe
|
||||
.call("frappe.utils.print_format.download_multi_pdf_async", {
|
||||
doctype: this.doctype,
|
||||
name: json_string,
|
||||
format: print_format,
|
||||
no_letterhead: with_letterhead ? "0" : "1",
|
||||
letterhead: letterhead,
|
||||
options: pdf_options,
|
||||
})
|
||||
.then((response) => {
|
||||
let task_id = response.message.task_id;
|
||||
frappe.realtime.task_subscribe(task_id);
|
||||
frappe.realtime.on(`task_complete:${task_id}`, (data) => {
|
||||
frappe.msgprint({
|
||||
title: __("Bulk PDF Export"),
|
||||
message: __("Your PDF is ready for download"),
|
||||
primary_action: {
|
||||
label: __("Download PDF"),
|
||||
client_action: "window.open",
|
||||
args: data.file_url,
|
||||
},
|
||||
});
|
||||
frappe.realtime.task_unsubscribe(task_id);
|
||||
frappe.realtime.off(`task_complete:${task_id}`);
|
||||
});
|
||||
frappe.realtime.task_unsubscribe(task_id);
|
||||
frappe.realtime.off(`task_complete:${task_id}`);
|
||||
});
|
||||
dialog.hide();
|
||||
});
|
||||
} else {
|
||||
const w = window.open(
|
||||
"/api/method/frappe.utils.print_format.download_multi_pdf?" +
|
||||
"doctype=" +
|
||||
encodeURIComponent(this.doctype) +
|
||||
"&name=" +
|
||||
encodeURIComponent(json_string) +
|
||||
"&format=" +
|
||||
encodeURIComponent(print_format) +
|
||||
"&no_letterhead=" +
|
||||
(with_letterhead ? "0" : "1") +
|
||||
"&letterhead=" +
|
||||
encodeURIComponent(letterhead) +
|
||||
"&options=" +
|
||||
encodeURIComponent(pdf_options)
|
||||
);
|
||||
|
||||
if (!w) {
|
||||
frappe.msgprint(__("Please enable pop-ups"));
|
||||
}
|
||||
}
|
||||
dialog.hide();
|
||||
});
|
||||
dialog.show();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -260,12 +260,8 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
|
|||
set_primary_action() {
|
||||
if (this.can_create && !frappe.boot.read_only) {
|
||||
const doctype_name = __(frappe.router.doctype_layout) || __(this.doctype);
|
||||
|
||||
// Better style would be __("Add {0}", [doctype_name], "Primary action in list view")
|
||||
// Keeping it like this to not disrupt existing translations
|
||||
const label = `${__("Add", null, "Primary action in list view")} ${doctype_name}`;
|
||||
this.page.set_primary_action(
|
||||
label,
|
||||
__("Add {0}", [doctype_name], "Primary action in list view"),
|
||||
() => {
|
||||
if (this.settings.primary_action) {
|
||||
this.settings.primary_action();
|
||||
|
|
@ -548,7 +544,9 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
|
|||
|
||||
toggle_result_area() {
|
||||
super.toggle_result_area();
|
||||
this.toggle_actions_menu_button(this.$result.find(".list-row-check:checked").length > 0);
|
||||
this.toggle_actions_menu_button(
|
||||
this.$result.find(".list-row-checkbox:checked").length > 0
|
||||
);
|
||||
}
|
||||
|
||||
toggle_actions_menu_button(toggle) {
|
||||
|
|
@ -788,9 +786,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
|
|||
}
|
||||
|
||||
const format = () => {
|
||||
if (df.fieldtype === "Code") {
|
||||
return value;
|
||||
} else if (df.fieldtype === "Percent") {
|
||||
if (df.fieldtype === "Percent") {
|
||||
return `<div class="progress" style="margin: 0px;">
|
||||
<div class="progress-bar progress-bar-success" role="progressbar"
|
||||
aria-valuenow="${value}"
|
||||
|
|
@ -842,11 +838,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
|
|||
data-filter="${fieldname},=,${value}">
|
||||
${_value}
|
||||
</a>`;
|
||||
} else if (
|
||||
["Text Editor", "Text", "Small Text", "HTML Editor", "Markdown Editor"].includes(
|
||||
df.fieldtype
|
||||
)
|
||||
) {
|
||||
} else if (frappe.model.html_fieldtypes.includes(df.fieldtype)) {
|
||||
html = `<span class="ellipsis">
|
||||
${_value}
|
||||
</span>`;
|
||||
|
|
|
|||
|
|
@ -286,13 +286,19 @@ $.extend(frappe.meta, {
|
|||
if (!doc && cur_frm) doc = cur_frm.doc;
|
||||
|
||||
if (df && df.options) {
|
||||
if (doc && df.options.indexOf(":") != -1) {
|
||||
if (df.options.indexOf(":") != -1) {
|
||||
var options = df.options.split(":");
|
||||
if (options.length == 3) {
|
||||
// get reference record e.g. Company
|
||||
var docname = doc[options[1]];
|
||||
if (!docname && cur_frm) {
|
||||
docname = cur_frm.doc[options[1]];
|
||||
let docname = null;
|
||||
if (doc) {
|
||||
// get reference record e.g. Company
|
||||
docname = doc[options[1]];
|
||||
if (!docname && cur_frm) {
|
||||
docname = cur_frm.doc[options[1]];
|
||||
}
|
||||
} else {
|
||||
// Try to get default value, useful for cases like Company overridden in session defaults
|
||||
docname = frappe.defaults.get_user_default(options[1]);
|
||||
}
|
||||
currency =
|
||||
frappe.model.get_value(options[0], docname, options[2]) ||
|
||||
|
|
|
|||
|
|
@ -790,7 +790,9 @@ $.extend(frappe.model, {
|
|||
}
|
||||
for (var i = 0, j = fieldnames.length; i < j; i++) {
|
||||
var fieldname = fieldnames[i];
|
||||
doc[fieldname] = flt(doc[fieldname], precision(fieldname, doc));
|
||||
if (doc[fieldname]) {
|
||||
doc[fieldname] = flt(doc[fieldname], precision(fieldname, doc));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue