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