diff --git a/frappe/database/database.py b/frappe/database/database.py
index 2d38a6dea8..59c514991a 100644
--- a/frappe/database/database.py
+++ b/frappe/database/database.py
@@ -1119,21 +1119,6 @@ class Database:
"""Returns True if column exists in database."""
return column in self.get_table_columns(doctype)
- def get_column_type(self, doctype, column):
- """Returns column type from database."""
- information_schema = frappe.qb.Schema("information_schema")
- table = get_table_name(doctype)
-
- return (
- frappe.qb.from_(information_schema.columns)
- .select(information_schema.columns.column_type)
- .where(
- (information_schema.columns.table_name == table)
- & (information_schema.columns.column_name == column)
- )
- .run(pluck=True)[0]
- )
-
def has_index(self, table_name, index_name):
raise NotImplementedError
diff --git a/frappe/database/mariadb/database.py b/frappe/database/mariadb/database.py
index 43540956e0..8e52cc7ffd 100644
--- a/frappe/database/mariadb/database.py
+++ b/frappe/database/mariadb/database.py
@@ -318,6 +318,21 @@ class MariaDBDatabase(MariaDBConnectionUtil, MariaDBExceptionUtil, Database):
as_dict=1,
)
+ def get_column_type(self, doctype, column):
+ """Returns column type from database."""
+ information_schema = frappe.qb.Schema("information_schema")
+ table = get_table_name(doctype)
+
+ return (
+ frappe.qb.from_(information_schema.columns)
+ .select(information_schema.columns.column_type)
+ .where(
+ (information_schema.columns.table_name == table)
+ & (information_schema.columns.column_name == column)
+ )
+ .run(pluck=True)[0]
+ )
+
def has_index(self, table_name, index_name):
return self.sql(
"""SHOW INDEX FROM `{table_name}`
diff --git a/frappe/database/postgres/database.py b/frappe/database/postgres/database.py
index d082afceaf..836a689251 100644
--- a/frappe/database/postgres/database.py
+++ b/frappe/database/postgres/database.py
@@ -394,6 +394,21 @@ class PostgresDatabase(PostgresExceptionUtil, Database):
as_dict=1,
)
+ def get_column_type(self, doctype, column):
+ """Returns column type from database."""
+ information_schema = frappe.qb.Schema("information_schema")
+ table = get_table_name(doctype)
+
+ return (
+ frappe.qb.from_(information_schema.columns)
+ .select(information_schema.columns.data_type)
+ .where(
+ (information_schema.columns.table_name == table)
+ & (information_schema.columns.column_name == column)
+ )
+ .run(pluck=True)[0]
+ )
+
def get_database_list(self):
return self.sql("SELECT datname FROM pg_database", pluck=True)
diff --git a/frappe/email/receive.py b/frappe/email/receive.py
index 382fd2ac99..525703c8a2 100644
--- a/frappe/email/receive.py
+++ b/frappe/email/receive.py
@@ -459,6 +459,10 @@ class Email:
if content_type == "text/plain":
self.text_content += self.get_payload(part)
+ # attach txt file from received email as well aside from saving to text_content if it has filename
+ if part.get_filename():
+ self.get_attachment(part)
+
elif content_type == "text/html":
self.html_content += self.get_payload(part)
diff --git a/frappe/model/sync.py b/frappe/model/sync.py
index 6272c9cb7d..0b344b892a 100644
--- a/frappe/model/sync.py
+++ b/frappe/model/sync.py
@@ -11,6 +11,28 @@ from frappe.modules.import_file import import_file_by_path
from frappe.modules.patch_handler import _patch_mode
from frappe.utils import update_progress_bar
+IMPORTABLE_DOCTYPES = [
+ ("core", "doctype"),
+ ("core", "page"),
+ ("core", "report"),
+ ("desk", "dashboard_chart_source"),
+ ("printing", "print_format"),
+ ("website", "web_page"),
+ ("website", "website_theme"),
+ ("website", "web_form"),
+ ("website", "web_template"),
+ ("email", "notification"),
+ ("printing", "print_style"),
+ ("desk", "workspace"),
+ ("desk", "onboarding_step"),
+ ("desk", "module_onboarding"),
+ ("desk", "form_tour"),
+ ("custom", "client_script"),
+ ("core", "server_script"),
+ ("custom", "custom_field"),
+ ("custom", "property_setter"),
+]
+
def sync_all(force=0, reset_permissions=False):
_patch_mode(True)
@@ -71,6 +93,11 @@ def sync_for(app_name, force=0, reset_permissions=False):
]:
files.append(os.path.join(FRAPPE_PATH, "desk", "doctype", desk_module, f"{desk_module}.json"))
+ for module_name, document_type in IMPORTABLE_DOCTYPES:
+ file = os.path.join(FRAPPE_PATH, module_name, "doctype", document_type, f"{document_type}.json")
+ if file not in files:
+ files.append(file)
+
for module_name in frappe.local.app_modules.get(app_name) or []:
folder = os.path.dirname(frappe.get_module(app_name + "." + module_name).__file__)
files = get_doc_files(files=files, start_path=folder)
@@ -97,29 +124,7 @@ def get_doc_files(files, start_path):
files = files or []
- # load in sequence - warning for devs
- document_types = [
- "doctype",
- "page",
- "report",
- "dashboard_chart_source",
- "print_format",
- "web_page",
- "website_theme",
- "web_form",
- "web_template",
- "notification",
- "print_style",
- "workspace",
- "onboarding_step",
- "module_onboarding",
- "form_tour",
- "client_script",
- "server_script",
- "custom_field",
- "property_setter",
- ]
- for doctype in document_types:
+ for _module, doctype in IMPORTABLE_DOCTYPES:
doctype_path = os.path.join(start_path, doctype)
if os.path.exists(doctype_path):
for docname in os.listdir(doctype_path):
diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js
index ac9a18785b..095b04c931 100644
--- a/frappe/public/js/frappe/utils/utils.js
+++ b/frappe/public/js/frappe/utils/utils.js
@@ -1440,6 +1440,67 @@ Object.assign(frappe.utils, {
prepend && wrapper.prepend(button);
},
+ add_select_group_button(wrapper, actions, btn_type, icon = "", prepend) {
+ // actions = [{
+ // label: "Action 1",
+ // description: "Description 1", (optional)
+ // action: () => {},
+ // },
+ // {
+ // label: "Action 2",
+ // description: "Description 2", (optional)
+ // action: () => {},
+ // }]
+ let selected_action = actions[0];
+
+ let $select_group_button = $(`
+
+
+
+
+
+
+
+ `);
+
+ actions.forEach((action) => {
+ $(`
+
+ ${frappe.utils.icon("check", "xs")}
+
+
${action.label}
+
${action.description || ""}
+
+
+ `)
+ .appendTo($select_group_button.find(".dropdown-menu"))
+ .click((e) => {
+ selected_action = action;
+ $select_group_button.find(".selected-button .label").text(action.label);
+
+ $(e.currentTarget).find(".tick-icon").addClass("selected");
+ $(e.currentTarget).siblings().find(".tick-icon").removeClass("selected");
+ });
+ });
+
+ $select_group_button.find(".dropdown-menu li:first-child .tick-icon").addClass("selected");
+
+ $select_group_button.find(".selected-button").click((event) => {
+ event.stopPropagation();
+ selected_action.action && selected_action.action(event);
+ });
+
+ !prepend && $select_group_button.appendTo(wrapper);
+ prepend && wrapper.prepend($select_group_button);
+
+ return $select_group_button;
+ },
+
sleep(time) {
return new Promise((resolve) => setTimeout(resolve, time));
},
diff --git a/frappe/public/js/frappe/views/communication.js b/frappe/public/js/frappe/views/communication.js
index 3505199d3f..c3e998788d 100755
--- a/frappe/public/js/frappe/views/communication.js
+++ b/frappe/public/js/frappe/views/communication.js
@@ -56,7 +56,7 @@ frappe.views.CommunicationComposer = class {
},
{
fieldtype: "Button",
- label: frappe.utils.icon("down"),
+ label: frappe.utils.icon("down", "xs"),
fieldname: "option_toggle_button",
click: () => {
this.toggle_more_options();
@@ -77,12 +77,22 @@ frappe.views.CommunicationComposer = class {
fieldtype: "MultiSelect",
fieldname: "bcc",
},
+ {
+ fieldtype: "Section Break",
+ fieldname: "email_template_section_break",
+ hidden: 1,
+ },
{
label: __("Email Template"),
fieldtype: "Link",
options: "Email Template",
fieldname: "email_template",
},
+ {
+ fieldtype: "HTML",
+ label: __("Clear & Add template"),
+ fieldname: "clear_and_add_template",
+ },
{ fieldtype: "Section Break" },
{
label: __("Subject"),
@@ -170,8 +180,9 @@ frappe.views.CommunicationComposer = class {
toggle_more_options(show_options) {
show_options = show_options || this.dialog.fields_dict.more_options.df.hidden;
this.dialog.set_df_property("more_options", "hidden", !show_options);
+ this.dialog.set_df_property("email_template_section_break", "hidden", !show_options);
- const label = frappe.utils.icon(show_options ? "up-line" : "down");
+ const label = frappe.utils.icon(show_options ? "up-line" : "down", "xs");
this.dialog.get_field("option_toggle_button").set_label(label);
}
@@ -266,13 +277,14 @@ frappe.views.CommunicationComposer = class {
setup_email_template() {
const me = this;
- this.dialog.fields_dict["email_template"].df.onchange = () => {
+ const fields = this.dialog.fields_dict;
+ const clear_and_add_template = $(fields.clear_and_add_template.wrapper);
+
+ function add_template() {
const email_template = me.dialog.fields_dict.email_template.get_value();
if (!email_template) return;
function prepend_reply(reply) {
- if (me.reply_added === email_template) return;
-
const content_field = me.dialog.fields_dict.content;
const subject_field = me.dialog.fields_dict.subject;
@@ -280,8 +292,6 @@ frappe.views.CommunicationComposer = class {
content_field.set_value(`${reply.message}
${content}`);
subject_field.set_value(reply.subject);
-
- me.reply_added = email_template;
}
frappe.call({
@@ -294,7 +304,25 @@ frappe.views.CommunicationComposer = class {
prepend_reply(r.message);
},
});
- };
+ }
+
+ let email_template_actions = [
+ {
+ label: __("Add Template"),
+ description: __("Prepend the template to the email message"),
+ action: () => add_template(),
+ },
+ {
+ label: __("Clear & Add Template"),
+ description: __("Clear the email message and add the template"),
+ action: () => {
+ me.dialog.fields_dict.content.set_value("");
+ add_template();
+ },
+ },
+ ];
+
+ frappe.utils.add_select_group_button(clear_and_add_template, email_template_actions);
}
setup_last_edited_communication() {
diff --git a/frappe/public/scss/common/modal.scss b/frappe/public/scss/common/modal.scss
index 8e69a956e5..4b7f028c79 100644
--- a/frappe/public/scss/common/modal.scss
+++ b/frappe/public/scss/common/modal.scss
@@ -222,12 +222,30 @@ body.modal-open[style^="padding-right"] {
margin-bottom: -24px;
button {
// same as form-control input
- height: calc(1.5em + .75rem + 2px);
+ height: calc(1.5em + .7rem);
}
}
}
}
+.modal [data-fieldname="email_template_section_break"] {
+ form {
+ display: flex;
+ align-items: center;
+
+ .frappe-control:first-child {
+ &[data-fieldname="email_template"] {
+ margin-right: 10px;
+ }
+ flex: 1;
+ }
+
+ .frappe-control:last-child {
+ margin-bottom: -8px;
+ }
+ }
+}
+
// modal is xs (for grids)
.modal .hidden-xs {
display: none !important;
diff --git a/frappe/public/scss/desk/global.scss b/frappe/public/scss/desk/global.scss
index 765e51cab9..fa27ef99ad 100644
--- a/frappe/public/scss/desk/global.scss
+++ b/frappe/public/scss/desk/global.scss
@@ -249,6 +249,26 @@ h2 {
}
}
+.select-group-btn {
+ .dropdown-toggle-split::after {
+ display: none;
+ }
+
+ .dropdown-item {
+ .tick-icon {
+ visibility: hidden;
+
+ &.selected {
+ visibility: visible;
+ }
+ }
+
+ .item-label {
+ font-weight: 500;
+ }
+ }
+}
+
.btn-xs {
@extend .btn-sm;
line-height: 1.2;
diff --git a/frappe/query_builder/functions.py b/frappe/query_builder/functions.py
index 512df8835c..aa25fa1215 100644
--- a/frappe/query_builder/functions.py
+++ b/frappe/query_builder/functions.py
@@ -41,6 +41,16 @@ class Timestamp(Function):
super().__init__("TIMESTAMP", term, alias=alias)
+class Round(Function):
+ def __init__(self, term, decimal=0, **kwargs):
+ super().__init__("ROUND", term, decimal, **kwargs)
+
+
+class Truncate(Function):
+ def __init__(self, term, decimal, **kwargs):
+ super().__init__("TRUNCATE", term, decimal, **kwargs)
+
+
GroupConcat = ImportMapper({db_type_is.MARIADB: GROUP_CONCAT, db_type_is.POSTGRES: STRING_AGG})
Match = ImportMapper({db_type_is.MARIADB: MATCH, db_type_is.POSTGRES: TO_TSVECTOR})
diff --git a/frappe/tests/test_query_builder.py b/frappe/tests/test_query_builder.py
index a16c2a23ae..e3ca63abf1 100644
--- a/frappe/tests/test_query_builder.py
+++ b/frappe/tests/test_query_builder.py
@@ -13,6 +13,8 @@ from frappe.query_builder.functions import (
Date,
GroupConcat,
Match,
+ Round,
+ Truncate,
UnixTimestamp,
)
from frappe.query_builder.utils import db_type_is
@@ -153,6 +155,20 @@ class TestCustomFunctionsMariaDB(FrappeTestCase):
"SELECT `tabred`.`other`,CONCAT(`tabNote`.`name`,'') FROM `tabred`,`tabNote`",
)
+ def test_round(self):
+ note = frappe.qb.DocType("Note")
+
+ query = frappe.qb.from_(note).select(Round(note.price))
+ self.assertEqual("select round(`price`,0) from `tabnote`", str(query).lower())
+
+ query = frappe.qb.from_(note).select(Round(note.price, 3))
+ self.assertEqual("select round(`price`,3) from `tabnote`", str(query).lower())
+
+ def test_truncate(self):
+ note = frappe.qb.DocType("Note")
+ query = frappe.qb.from_(note).select(Truncate(note.price, 3))
+ self.assertEqual("select truncate(`price`,3) from `tabnote`", str(query).lower())
+
@run_only_if(db_type_is.POSTGRES)
class TestCustomFunctionsPostgres(FrappeTestCase):
@@ -283,6 +299,20 @@ class TestCustomFunctionsPostgres(FrappeTestCase):
'SELECT "tabred"."other",CAST("tabNote"."name" AS VARCHAR) FROM "tabred","tabNote"',
)
+ def test_round(self):
+ note = frappe.qb.DocType("Note")
+
+ query = frappe.qb.from_(note).select(Round(note.price))
+ self.assertEqual('select round("price",0) from "tabnote"', str(query).lower())
+
+ query = frappe.qb.from_(note).select(Round(note.price, 3))
+ self.assertEqual('select round("price",3) from "tabnote"', str(query).lower())
+
+ def test_truncate(self):
+ note = frappe.qb.DocType("Note")
+ query = frappe.qb.from_(note).select(Truncate(note.price, 3))
+ self.assertEqual('select truncate("price",3) from "tabnote"', str(query).lower())
+
class TestBuilderBase:
def test_adding_tabs(self):