Merge branch 'develop' into f1007

This commit is contained in:
Suraj Shetty 2024-08-12 14:39:33 +05:30 committed by GitHub
commit 5f9fdf2c0e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
139 changed files with 4723 additions and 2471 deletions

View file

@ -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
1 hooks.py frappe.gettext.extractors.navbar.extract
7 **.py frappe.gettext.extractors.python.extract
8 **.js frappe.gettext.extractors.javascript.extract
9 **.html frappe.gettext.extractors.html_template.extract
10 **.vue frappe.gettext.extractors.html_template.extract
11 **/custom/*.json frappe.gettext.extractors.customization.extract
12 **/fixtures/custom_field.json frappe.gettext.extractors.custom_field.extract

View file

@ -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")

View file

@ -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");

View file

@ -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):

View file

@ -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")

View file

@ -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):

View file

@ -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()

View file

@ -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]

View file

@ -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):

View file

@ -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": []
}
}

View file

@ -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

View file

@ -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();

View file

@ -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",

View file

@ -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)

View file

@ -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:

View file

@ -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()

View file

@ -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,
// }},
],
}};

View file

@ -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],
]

View file

@ -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",

View file

@ -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

View file

@ -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",

View file

@ -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

View file

@ -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()

View file

@ -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)

View file

@ -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>`);
});

View file

@ -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",

View file

@ -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):

View file

@ -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]
)

View file

@ -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"),

View file

@ -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();

View file

@ -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],

View file

@ -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",

View file

@ -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,
)

View file

@ -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

View file

@ -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();

View file

@ -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,
)

View file

@ -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:

View file

@ -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",

View file

@ -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

View 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

View 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"));
},
});

View file

@ -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": []
}

View 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

View file

@ -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

View file

@ -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():

View file

@ -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",
};

View file

@ -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];

View file

@ -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: {

View file

@ -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",

View file

@ -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

View file

@ -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()

View file

@ -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())

View file

@ -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");

View file

@ -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
}
}

View file

@ -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
View 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)

View file

@ -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",

View file

@ -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", [])

View file

@ -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}")

View file

@ -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,
},
{

View file

@ -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": [],

View file

@ -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):

View file

@ -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", {

View file

@ -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
}
}

View file

@ -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)

View file

@ -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()

View file

@ -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

View file

@ -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()

View file

@ -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):

View file

@ -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)

View file

@ -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"

View file

@ -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():

View file

@ -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)

View file

@ -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/";

View file

@ -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 }
);

View file

@ -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) {

View file

@ -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);

View file

@ -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();

View file

@ -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();
}

View file

@ -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]) {

View file

@ -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("");

View file

@ -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();
}

View file

@ -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();
}

View file

@ -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]));
}
}

View file

@ -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) {

View file

@ -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,
};

View file

@ -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);

View file

@ -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");
}

View file

@ -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;

View file

@ -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) {

View file

@ -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();

View file

@ -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() {

View file

@ -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();
}

View file

@ -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>`;

View file

@ -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]) ||

View file

@ -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