diff --git a/cypress/integration/control_icon.js b/cypress/integration/control_icon.js new file mode 100644 index 0000000000..f92927f267 --- /dev/null +++ b/cypress/integration/control_icon.js @@ -0,0 +1,50 @@ +context('Control Icon', () => { + before(() => { + cy.login(); + cy.visit('/app/website'); + }); + + function get_dialog_with_icon() { + return cy.dialog({ + title: 'Icon', + fields: [{ + label: 'Icon', + fieldname: 'icon', + fieldtype: 'Icon' + }] + }); + } + + it('should set icon', () => { + get_dialog_with_icon().as('dialog'); + cy.get('.frappe-control[data-fieldname=icon] input').first().click(); + + cy.get('.icon-picker .icon-wrapper[id=active]').first().click(); + cy.get('.frappe-control[data-fieldname=icon] input').first().should('have.value', 'active'); + cy.get('@dialog').then(dialog => { + let value = dialog.get_value('icon'); + expect(value).to.equal('active'); + }); + + cy.get('.icon-picker .icon-wrapper[id=resting]').first().click(); + cy.get('.frappe-control[data-fieldname=icon] input').first().should('have.value', 'resting'); + cy.get('@dialog').then(dialog => { + let value = dialog.get_value('icon'); + expect(value).to.equal('resting'); + }); + }); + + it('search for icon and clear search input', () => { + let search_text = 'ed'; + cy.get('.icon-picker input[type=search]').first().click().type(search_text); + cy.get('.icon-section .icon-wrapper:not(.hidden)').then(i => { + cy.get(`.icon-section .icon-wrapper[id*='${search_text}']`).then(icons => { + expect(i.length).to.equal(icons.length); + }); + }); + + cy.get('.icon-picker input[type=search]').clear().blur(); + cy.get('.icon-section .icon-wrapper').should('not.have.class', 'hidden'); + }); + +}); \ No newline at end of file diff --git a/frappe/__init__.py b/frappe/__init__.py index 1c978945c7..b4728f9ac3 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -28,6 +28,8 @@ from .exceptions import * from .utils.jinja import (get_jenv, get_template, render_template, get_email_from_template, get_jloader) from .utils.lazy_loader import lazy_import +from frappe.query_builder import get_query_builder + # Lazy imports faker = lazy_import('faker') @@ -118,6 +120,7 @@ def set_user_lang(user, user_language=None): # local-globals db = local("db") +qb = local("qb") conf = local("conf") form = form_dict = local("form_dict") request = local("request") @@ -202,6 +205,7 @@ def init(site, sites_path=None, new_site=False): local.form_dict = _dict() local.session = _dict() local.dev_server = _dev_server + local.qb = get_query_builder(local.conf.db_type or "mariadb") setup_module_map() diff --git a/frappe/api.py b/frappe/api.py index 36d51e894c..636c6b2888 100644 --- a/frappe/api.py +++ b/frappe/api.py @@ -82,7 +82,7 @@ def handle(): if frappe.local.request.method=="PUT": data = get_request_form_data() - doc = frappe.get_doc(doctype, name) + doc = frappe.get_doc(doctype, name, for_update=True) if "flags" in data: del data["flags"] diff --git a/frappe/auth.py b/frappe/auth.py index fc1cb09e1a..2c875c4437 100644 --- a/frappe/auth.py +++ b/frappe/auth.py @@ -154,7 +154,6 @@ class LoginManager: self.make_session() self.setup_boot_cache() self.set_user_info() - self.clear_preferred_language() def get_user_info(self): self.info = frappe.db.get_value("User", self.user, diff --git a/frappe/cache_manager.py b/frappe/cache_manager.py index 9f09f26be8..c17ae583ed 100644 --- a/frappe/cache_manager.py +++ b/frappe/cache_manager.py @@ -141,17 +141,13 @@ def build_table_count_cache(): return _cache = frappe.cache() - data = frappe.db.multisql({ - "mariadb": """ - SELECT table_name AS name, - table_rows AS count - FROM information_schema.tables""", - "postgres": """ - SELECT "relname" AS name, - "n_tup_ins" AS count - FROM "pg_stat_all_tables" - """ - }, as_dict=1) + table_name = frappe.qb.Field("table_name").as_("name") + table_rows = frappe.qb.Field("table_rows").as_("count") + information_schema = frappe.qb.Schema("information_schema") + + query = frappe.qb.from_(information_schema.tables).select(table_name, table_rows) + + data = frappe.db.sql(query, as_dict=1) counts = {d.get('name').lstrip('tab'): d.get('count', None) for d in data} _cache.set_value("information_schema:counts", counts) diff --git a/frappe/commands/site.py b/frappe/commands/site.py index 22a063651c..45f7706d60 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -561,30 +561,54 @@ def move(dest_dir, site): return final_new_path -@click.command('set-admin-password') -@click.argument('admin-password') +@click.command('set-password') +@click.argument('user') +@click.argument('password', required=False) @click.option('--logout-all-sessions', help='Logout from all sessions', is_flag=True, default=False) @pass_context -def set_admin_password(context, admin_password, logout_all_sessions=False): +def set_password(context, user, password=None, logout_all_sessions=False): + "Set password for a user on a site" + if not context.sites: + raise SiteNotSpecifiedError + + for site in context.sites: + set_user_password(site, user, password, logout_all_sessions) + + +@click.command('set-admin-password') +@click.argument('admin-password', required=False) +@click.option('--logout-all-sessions', help='Logout from all sessions', is_flag=True, default=False) +@pass_context +def set_admin_password(context, admin_password=None, logout_all_sessions=False): "Set Administrator password for a site" + if not context.sites: + raise SiteNotSpecifiedError + + for site in context.sites: + set_user_password(site, "Administrator", admin_password, logout_all_sessions) + + +def set_user_password(site, user, password, logout_all_sessions=False): import getpass from frappe.utils.password import update_password - for site in context.sites: - try: - frappe.init(site=site) + try: + frappe.init(site=site) - while not admin_password: - admin_password = getpass.getpass("Administrator's password for {0}: ".format(site)) + while not password: + password = getpass.getpass(f"{user}'s password for {site}: ") + + frappe.connect() + if not frappe.db.exists("User", user): + print(f"User {user} does not exist") + sys.exit(1) + + update_password(user=user, pwd=password, logout_all_sessions=logout_all_sessions) + frappe.db.commit() + password = None + finally: + frappe.destroy() - frappe.connect() - update_password(user='Administrator', pwd=admin_password, logout_all_sessions=logout_all_sessions) - frappe.db.commit() - admin_password = None - finally: - frappe.destroy() - if not context.sites: - raise SiteNotSpecifiedError @click.command('set-last-active-for-user') @click.option('--user', help="Setup last active date for user") @@ -729,6 +753,7 @@ commands = [ remove_from_installed_apps, restore, run_patch, + set_password, set_admin_password, uninstall, disable_user, diff --git a/frappe/config/__init__.py b/frappe/config/__init__.py index 62a877be24..e7f0f1a763 100644 --- a/frappe/config/__init__.py +++ b/frappe/config/__init__.py @@ -39,18 +39,13 @@ def get_modules_from_app(app): ) def get_all_empty_tables_by_module(): - empty_tables = set(r[0] for r in frappe.db.multisql({ - "mariadb": """ - SELECT table_name - FROM information_schema.tables - WHERE table_rows = 0 and table_schema = "{}" - """.format(frappe.conf.db_name), - "postgres": """ - SELECT "relname" as "table_name" - FROM "pg_stat_all_tables" - WHERE n_tup_ins = 0 - """ - })) + table_rows = frappe.qb.Field("table_rows") + table_name = frappe.qb.Field("table_name") + information_schema = frappe.qb.Schema("information_schema") + + query = frappe.qb.from_(information_schema.tables).select(table_name).where(table_rows == 0) + + empty_tables = {r[0] for r in frappe.db.sql(query)} results = frappe.get_all("DocType", fields=["name", "module"]) empty_tables_by_module = {} diff --git a/frappe/core/doctype/communication/mixins.py b/frappe/core/doctype/communication/mixins.py index 09a8a0ac22..52cd370890 100644 --- a/frappe/core/doctype/communication/mixins.py +++ b/frappe/core/doctype/communication/mixins.py @@ -3,6 +3,7 @@ from frappe import _ from frappe.core.utils import get_parent_doc from frappe.utils import parse_addr, get_formatted_email, get_url from frappe.email.doctype.email_account.email_account import EmailAccount +from frappe.desk.doctype.todo.todo import ToDo class CommunicationEmailMixin: """Mixin class to handle communication mails. @@ -76,6 +77,7 @@ class CommunicationEmailMixin: if is_inbound_mail_communcation: cc.append(self.get_owner()) cc = set(cc) - {self.sender_mailid} + cc.update(self.get_assignees()) cc = set(cc) - set(self.filter_thread_notification_disbled_users(cc)) cc = cc - set(self.mail_recipients(is_inbound_mail_communcation=is_inbound_mail_communcation)) @@ -201,6 +203,13 @@ class CommunicationEmailMixin: self.mail_cc(is_inbound_mail_communcation = is_inbound_mail_communcation, include_sender=include_sender) return set(all_ids) - set(final_ids) + def get_assignees(self): + """Get owners of the reference document. + """ + filters = {'status': 'Open', 'reference_name': self.reference_name, + 'reference_type': self.reference_doctype} + return ToDo.get_owners(filters) + @staticmethod def filter_thread_notification_disbled_users(emails): """Filter users based on notifications for email threads setting is disabled. diff --git a/frappe/core/doctype/docfield/docfield.json b/frappe/core/doctype/docfield/docfield.json index ca134665b8..ce62adc8be 100644 --- a/frappe/core/doctype/docfield/docfield.json +++ b/frappe/core/doctype/docfield/docfield.json @@ -90,7 +90,7 @@ "label": "Type", "oldfieldname": "fieldtype", "oldfieldtype": "Select", - "options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRead Only\nRating\nSection Break\nSelect\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nSignature", + "options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRead Only\nRating\nSection Break\nSelect\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nSignature", "reqd": 1, "search_index": 1 }, @@ -487,7 +487,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-10-29 06:09:26.454990", + "modified": "2021-07-10 21:56:04.167745", "modified_by": "Administrator", "module": "Core", "name": "DocField", diff --git a/frappe/core/doctype/doctype/doctype.js b/frappe/core/doctype/doctype/doctype.js index b4d3fb9a89..3f1b5bb7ad 100644 --- a/frappe/core/doctype/doctype/doctype.js +++ b/frappe/core/doctype/doctype/doctype.js @@ -66,4 +66,92 @@ frappe.ui.form.on('DocType', { autoname: function(frm) { frm.set_df_property('fields', 'reqd', frm.doc.autoname !== 'Prompt'); } -}) +}); + +frappe.ui.form.on("DocField", { + form_render(frm, doctype, docname) { + // Render two select fields for Fetch From instead of Small Text for better UX + let field = frm.cur_grid.grid_form.fields_dict.fetch_from; + $(field.input_area).hide(); + + let $doctype_select = $(``); + let $wrapper = $('
'); + $wrapper.append($doctype_select, $field_select); + field.$input_wrapper.append($wrapper); + $doctype_select.wrap('
'); + $field_select.wrap('
'); + + let row = frappe.get_doc(doctype, docname); + let curr_value = { doctype: null, fieldname: null }; + if (row.fetch_from) { + let [doctype, fieldname] = row.fetch_from.split("."); + curr_value.doctype = doctype; + curr_value.fieldname = fieldname; + } + let curr_df_link_doctype = row.fieldtype == "Link" ? row.options : null; + + let doctypes = frm.doc.fields + .filter(df => df.fieldtype == "Link") + .filter(df => df.options && df.options != curr_df_link_doctype) + .map(df => ({ + label: `${df.options} (${df.fieldname})`, + value: df.fieldname + })); + $doctype_select.add_options([ + { label: __("Select DocType"), value: "", selected: true }, + ...doctypes + ]); + + $doctype_select.on("change", () => { + row.fetch_from = ""; + frm.dirty(); + update_fieldname_options(); + }); + + function update_fieldname_options() { + $field_select.find("option").remove(); + + let link_fieldname = $doctype_select.val(); + if (!link_fieldname) return; + let link_field = frm.doc.fields.find( + df => df.fieldname === link_fieldname + ); + let link_doctype = link_field.options; + frappe.model.with_doctype(link_doctype, () => { + let fields = frappe.meta + .get_docfields(link_doctype, null, { + fieldtype: ["not in", frappe.model.no_value_type] + }) + .map(df => ({ + label: `${df.label} (${df.fieldtype})`, + value: df.fieldname + })); + $field_select.add_options([ + { + label: __("Select Field"), + value: "", + selected: true, + disabled: true + }, + ...fields + ]); + + if (curr_value.fieldname) { + $field_select.val(curr_value.fieldname); + } + }); + } + + $field_select.on("change", () => { + let fetch_from = `${$doctype_select.val()}.${$field_select.val()}`; + row.fetch_from = fetch_from; + frm.dirty(); + }); + + if (curr_value.doctype) { + $doctype_select.val(curr_value.doctype); + update_fieldname_options(); + } + } +}); diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index fac0fc7e2d..12abfc533b 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -396,10 +396,7 @@ class DocType(Document): frappe.db.sql("""update tabSingles set value=%s where doctype=%s and field='name' and value = %s""", (new, new, old)) else: - frappe.db.multisql({ - "mariadb": f"RENAME TABLE `tab{old}` TO `tab{new}`", - "postgres": f"ALTER TABLE `tab{old}` RENAME TO `tab{new}`" - }) + frappe.db.rename_table(old, new) frappe.db.commit() # Do not rename and move files and folders for custom doctype @@ -934,6 +931,9 @@ def validate_fields(meta): if meta.website_search_field not in fieldname_list: frappe.throw(_("Website Search Field must be a valid fieldname"), InvalidFieldNameError) + if "title" not in fieldname_list: + frappe.throw(_('Field "title" is mandatory if "Website Search Field" is set.'), title=_("Missing Field")) + def check_timeline_field(meta): if not meta.timeline_field: return diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index eabbf3e8e8..e79b2bd761 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -703,7 +703,10 @@ def get_web_image(file_url): frappe.msgprint(_("Unable to read file format for {0}").format(file_url)) raise - image = Image.open(StringIO(frappe.safe_decode(r.content))) + try: + image = Image.open(StringIO(frappe.safe_decode(r.content))) + except Exception as e: + frappe.msgprint(_("Image link '{0}' is not valid").format(file_url), raise_exception=e) try: filename, extn = file_url.rsplit("/", 1)[1].rsplit(".", 1) diff --git a/frappe/custom/doctype/custom_field/custom_field.json b/frappe/custom/doctype/custom_field/custom_field.json index 19462e79de..55a7ec5963 100644 --- a/frappe/custom/doctype/custom_field/custom_field.json +++ b/frappe/custom/doctype/custom_field/custom_field.json @@ -120,7 +120,7 @@ "label": "Field Type", "oldfieldname": "fieldtype", "oldfieldtype": "Select", - "options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRead Only\nRating\nSection Break\nSelect\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nSignature", + "options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRead Only\nRating\nSection Break\nSelect\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nSignature", "reqd": 1 }, { @@ -417,7 +417,7 @@ "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2021-07-12 04:54:12.042319", + "modified": "2021-07-12 05:54:13.042319", "modified_by": "Administrator", "module": "Custom", "name": "Custom Field", diff --git a/frappe/custom/doctype/customize_form_field/customize_form_field.json b/frappe/custom/doctype/customize_form_field/customize_form_field.json index 227114137c..0a456b1026 100644 --- a/frappe/custom/doctype/customize_form_field/customize_form_field.json +++ b/frappe/custom/doctype/customize_form_field/customize_form_field.json @@ -82,7 +82,7 @@ "label": "Type", "oldfieldname": "fieldtype", "oldfieldtype": "Select", - "options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRating\nRead Only\nSection Break\nSelect\nSignature\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime", + "options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRating\nRead Only\nSection Break\nSelect\nSignature\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime", "reqd": 1, "search_index": 1 }, @@ -428,7 +428,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-10-29 06:11:57.661039", + "modified": "2021-07-10 21:57:24.479749", "modified_by": "Administrator", "module": "Custom", "name": "Customize Form Field", diff --git a/frappe/database/database.py b/frappe/database/database.py index 2070ba676b..b1dec95139 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -14,7 +14,7 @@ import frappe.model.meta from frappe import _ from time import time -from frappe.utils import now, getdate, cast_fieldtype, get_datetime +from frappe.utils import now, getdate, cast_fieldtype, get_datetime, get_table_name from frappe.model.utils.link_count import flush_local_link_count @@ -104,6 +104,7 @@ class Database(object): {"name": "a%", "owner":"test@example.com"}) """ + query = str(query) if re.search(r'ifnull\(', query, flags=re.IGNORECASE): # replaces ifnull in query with coalesce query = re.sub(r'ifnull\(', 'coalesce(', query, flags=re.IGNORECASE) @@ -960,7 +961,7 @@ class Database(object): """ values = () filters = filters or kwargs.get("conditions") - table = doctype if doctype.startswith("__") else f"tab{doctype}" + table = get_table_name(doctype) query = f"DELETE FROM `{table}`" if "debug" not in kwargs: @@ -1041,6 +1042,7 @@ class Database(object): ), tuple(insert_list)) insert_list = [] + def enqueue_jobs_after_commit(): from frappe.utils.background_jobs import execute_job, get_queue diff --git a/frappe/database/mariadb/database.py b/frappe/database/mariadb/database.py index 879c8394d7..d4a119804b 100644 --- a/frappe/database/mariadb/database.py +++ b/frappe/database/mariadb/database.py @@ -1,3 +1,5 @@ +from typing import List, Tuple, Union + import pymysql from pymysql.constants import ER, FIELD_TYPE from pymysql.converters import conversions, escape_string @@ -5,7 +7,7 @@ from pymysql.converters import conversions, escape_string import frappe from frappe.database.database import Database from frappe.database.mariadb.schema import MariaDBTable -from frappe.utils import UnicodeWithAttrs, cstr, get_datetime +from frappe.utils import UnicodeWithAttrs, cstr, get_datetime, get_table_name class MariaDBDatabase(Database): @@ -49,7 +51,8 @@ class MariaDBDatabase(Database): 'Color': ('varchar', self.VARCHAR_LEN), 'Barcode': ('longtext', ''), 'Geolocation': ('longtext', ''), - 'Duration': ('decimal', '18,6') + 'Duration': ('decimal', '18,6'), + 'Icon': ('varchar', self.VARCHAR_LEN) } def get_connection(self): @@ -123,6 +126,19 @@ class MariaDBDatabase(Database): def is_type_datetime(code): return code in (pymysql.DATE, pymysql.DATETIME) + def rename_table(self, old_name: str, new_name: str) -> Union[List, Tuple]: + old_name = get_table_name(old_name) + new_name = get_table_name(new_name) + return self.sql(f"RENAME TABLE `{old_name}` TO `{new_name}`") + + def describe(self, doctype: str) -> Union[List, Tuple]: + table_name = get_table_name(doctype) + return self.sql(f"DESC `{table_name}`") + + def change_column_type(self, table: str, column: str, type: str) -> Union[List, Tuple]: + table_name = get_table_name(table) + return self.sql(f"ALTER TABLE `{table_name}` MODIFY `{column}` {type} NOT NULL") + # exception types @staticmethod def is_deadlocked(e): diff --git a/frappe/database/postgres/database.py b/frappe/database/postgres/database.py index 8235277e30..00e60fb8d2 100644 --- a/frappe/database/postgres/database.py +++ b/frappe/database/postgres/database.py @@ -1,12 +1,14 @@ import re -import frappe +from typing import List, Tuple, Union + import psycopg2 import psycopg2.extensions -from frappe.utils import cstr from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT +import frappe from frappe.database.database import Database from frappe.database.postgres.schema import PostgresTable +from frappe.utils import cstr, get_table_name # cast decimals as floats DEC2FLOAT = psycopg2.extensions.new_type( @@ -58,7 +60,8 @@ class PostgresDatabase(Database): 'Color': ('varchar', self.VARCHAR_LEN), 'Barcode': ('text', ''), 'Geolocation': ('text', ''), - 'Duration': ('decimal', '18,6') + 'Duration': ('decimal', '18,6'), + 'Icon': ('varchar', self.VARCHAR_LEN) } def get_connection(self): @@ -170,6 +173,19 @@ class PostgresDatabase(Database): def is_data_too_long(e): return e.pgcode == '22001' + def rename_table(self, old_name: str, new_name: str) -> Union[List, Tuple]: + old_name = get_table_name(old_name) + new_name = get_table_name(new_name) + return self.sql(f"ALTER TABLE `{old_name}` RENAME TO `{new_name}`") + + def describe(self, doctype: str)-> Union[List, Tuple]: + table_name = get_table_name(doctype) + return self.sql(f"SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_NAME = '{table_name}'") + + def change_column_type(self, table: str, column: str, type: str) -> Union[List, Tuple]: + table_name = get_table_name(table) + return self.sql(f'ALTER TABLE "{table_name}" ALTER COLUMN "{column}" TYPE {type}') + def create_auth_table(self): self.sql_ddl("""create table if not exists "__Auth" ( "doctype" VARCHAR(140) NOT NULL, @@ -297,6 +313,7 @@ class PostgresDatabase(Database): def modify_query(query): """"Modifies query according to the requirements of postgres""" # replace ` with " for definitions + query = str(query) query = query.replace('`', '"') query = replace_locate_with_strpos(query) # select from requires "" diff --git a/frappe/desk/doctype/todo/todo.py b/frappe/desk/doctype/todo/todo.py index 01eed41b4f..09297b4e5e 100644 --- a/frappe/desk/doctype/todo/todo.py +++ b/frappe/desk/doctype/todo/todo.py @@ -5,11 +5,13 @@ import frappe import json from frappe.model.document import Document -from frappe.utils import get_fullname +from frappe.utils import get_fullname, parse_addr exclude_from_linked_with = True class ToDo(Document): + DocType = 'ToDo' + def validate(self): self._assignment = None if self.is_new(): @@ -85,6 +87,13 @@ class ToDo(Document): else: raise + @classmethod + def get_owners(cls, filters=None): + """Returns list of owners after applying filters on todo's. + """ + rows = frappe.get_all(cls.DocType, filters=filters or {}, fields=['owner']) + return [parse_addr(row.owner)[1] for row in rows if row.owner] + # NOTE: todo is viewable if a user is an owner, or set as assigned_to value, or has any role that is allowed to access ToDo doctype. def on_doctype_update(): frappe.db.add_index("ToDo", ["reference_type", "reference_name"]) diff --git a/frappe/integrations/doctype/webhook/test_webhook.py b/frappe/integrations/doctype/webhook/test_webhook.py index 09ad56a190..b77e311f7e 100644 --- a/frappe/integrations/doctype/webhook/test_webhook.py +++ b/frappe/integrations/doctype/webhook/test_webhook.py @@ -4,7 +4,7 @@ import unittest import frappe -from frappe.integrations.doctype.webhook.webhook import get_webhook_headers, get_webhook_data +from frappe.integrations.doctype.webhook.webhook import get_webhook_headers, get_webhook_data, enqueue_webhook class TestWebhook(unittest.TestCase): @@ -12,6 +12,8 @@ class TestWebhook(unittest.TestCase): def setUpClass(cls): # delete any existing webhooks frappe.db.sql("DELETE FROM tabWebhook") + # Delete existing logs if any + frappe.db.sql("DELETE FROM `tabWebhook Request Log`") # create test webhooks cls.create_sample_webhooks() @@ -162,3 +164,18 @@ class TestWebhook(unittest.TestCase): data = get_webhook_data(doc=self.user, webhook=self.webhook) self.assertEqual(data, {"name": self.user.name}) + + def test_webhook_req_log_creation(self): + if not frappe.db.get_value('User', 'user2@integration.webhooks.test.com'): + user = frappe.get_doc({ + 'doctype': 'User', + 'email': 'user2@integration.webhooks.test.com', + 'first_name': 'user2' + }).insert() + else: + user = frappe.get_doc('User', 'user2@integration.webhooks.test.com') + + webhook = frappe.get_doc('Webhook', {'webhook_doctype': 'User'}) + enqueue_webhook(user, webhook) + + self.assertTrue(frappe.db.get_all('Webhook Request Log', pluck='name')) \ No newline at end of file diff --git a/frappe/integrations/doctype/webhook/webhook.json b/frappe/integrations/doctype/webhook/webhook.json index 85895c052c..880874cb25 100644 --- a/frappe/integrations/doctype/webhook/webhook.json +++ b/frappe/integrations/doctype/webhook/webhook.json @@ -18,6 +18,7 @@ "html_condition", "sb_webhook", "request_url", + "request_method", "cb_webhook", "request_structure", "sb_security", @@ -154,10 +155,18 @@ "fieldname": "enabled", "fieldtype": "Check", "label": "Enabled" + }, + { + "default": "POST", + "fieldname": "request_method", + "fieldtype": "Select", + "label": "Request Method", + "options": "POST\nPUT\nDELETE", + "reqd": 1 } ], "links": [], - "modified": "2021-04-14 05:35:28.532049", + "modified": "2021-05-25 11:11:28.555291", "modified_by": "Administrator", "module": "Integrations", "name": "Webhook", diff --git a/frappe/integrations/doctype/webhook/webhook.py b/frappe/integrations/doctype/webhook/webhook.py index 1fb2bc6743..e3a8bda2aa 100644 --- a/frappe/integrations/doctype/webhook/webhook.py +++ b/frappe/integrations/doctype/webhook/webhook.py @@ -59,7 +59,6 @@ class Webhook(Document): if self.request_structure == "Form URL-Encoded": self.webhook_json = None elif self.request_structure == "JSON": - validate_json(self.webhook_json) validate_template(self.webhook_json) self.webhook_data = [] @@ -83,18 +82,32 @@ def enqueue_webhook(doc, webhook): for i in range(3): try: - r = requests.post(webhook.request_url, data=json.dumps(data, default=str), headers=headers, timeout=5) + r = requests.request(method=webhook.request_method, url=webhook.request_url, + data=json.dumps(data, default=str), headers=headers, timeout=5) r.raise_for_status() frappe.logger().debug({"webhook_success": r.text}) + log_request(webhook.request_url, headers, data, r) break except Exception as e: frappe.logger().debug({"webhook_error": e, "try": i + 1}) + log_request(webhook.request_url, headers, data, r) sleep(3 * i + 1) if i != 2: continue else: raise e +def log_request(url, headers, data, res): + request_log = frappe.get_doc({ + "doctype": "Webhook Request Log", + "user": frappe.session.user if frappe.session.user else None, + "url": url, + "headers": json.dumps(headers, indent=4) if headers else None, + "data": json.dumps(data, indent=4) if isinstance(data, dict) else data, + "response": json.dumps(res.json(), indent=4) if res else None + }) + + request_log.save(ignore_permissions=True) def get_webhook_headers(doc, webhook): headers = {} @@ -129,10 +142,3 @@ def get_webhook_data(doc, webhook): data = json.loads(data) return data - - -def validate_json(string): - try: - json.loads(string) - except (TypeError, ValueError): - frappe.throw(_("Request Body consists of an invalid JSON structure"), title=_("Invalid JSON")) diff --git a/frappe/integrations/doctype/webhook_request_log/__init__.py b/frappe/integrations/doctype/webhook_request_log/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/integrations/doctype/webhook_request_log/test_webhook_request_log.py b/frappe/integrations/doctype/webhook_request_log/test_webhook_request_log.py new file mode 100644 index 0000000000..dd11bf8a01 --- /dev/null +++ b/frappe/integrations/doctype/webhook_request_log/test_webhook_request_log.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies and Contributors +# See license.txt + +# import frappe +import unittest + +class TestWebhookRequestLog(unittest.TestCase): + pass diff --git a/frappe/integrations/doctype/webhook_request_log/webhook_request_log.js b/frappe/integrations/doctype/webhook_request_log/webhook_request_log.js new file mode 100644 index 0000000000..9ec4f11536 --- /dev/null +++ b/frappe/integrations/doctype/webhook_request_log/webhook_request_log.js @@ -0,0 +1,8 @@ +// Copyright (c) 2021, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Webhook Request Log', { + // refresh: function(frm) { + + // } +}); diff --git a/frappe/integrations/doctype/webhook_request_log/webhook_request_log.json b/frappe/integrations/doctype/webhook_request_log/webhook_request_log.json new file mode 100644 index 0000000000..96690f6e8c --- /dev/null +++ b/frappe/integrations/doctype/webhook_request_log/webhook_request_log.json @@ -0,0 +1,81 @@ +{ + "actions": [], + "autoname": "WEBHOOK-REQ-.#####", + "creation": "2021-05-24 21:35:59.104776", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "user", + "headers", + "data", + "column_break_4", + "url", + "response" + ], + "fields": [ + { + "fieldname": "url", + "fieldtype": "Data", + "label": "URL", + "read_only": 1 + }, + { + "fieldname": "headers", + "fieldtype": "Code", + "label": "Headers", + "options": "JSON", + "read_only": 1 + }, + { + "fieldname": "response", + "fieldtype": "Code", + "label": "Response", + "options": "JSON", + "read_only": 1 + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "fieldname": "data", + "fieldtype": "Code", + "label": "Data", + "options": "JSON", + "read_only": 1 + }, + { + "fieldname": "user", + "fieldtype": "Link", + "label": "User", + "options": "User", + "read_only": 1 + } + ], + "in_create": 1, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-05-26 23:57:58.495261", + "modified_by": "Administrator", + "module": "Integrations", + "name": "Webhook Request Log", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/integrations/doctype/webhook_request_log/webhook_request_log.py b/frappe/integrations/doctype/webhook_request_log/webhook_request_log.py new file mode 100644 index 0000000000..493ebfd8f7 --- /dev/null +++ b/frappe/integrations/doctype/webhook_request_log/webhook_request_log.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + +class WebhookRequestLog(Document): + pass diff --git a/frappe/integrations/utils.py b/frappe/integrations/utils.py index 09c20568b5..b897a35062 100644 --- a/frappe/integrations/utils.py +++ b/frappe/integrations/utils.py @@ -8,35 +8,14 @@ from urllib.parse import parse_qs from frappe.utils import get_request_session from frappe import _ -def make_get_request(url, auth=None, headers=None, data=None): - if not auth: - auth = '' - if not data: - data = {} - if not headers: - headers = {} +def make_request(method, url, auth=None, headers=None, data=None): + auth = auth or '' + data = data or {} + headers = headers or {} try: s = get_request_session() - frappe.flags.integration_request = s.get(url, data={}, auth=auth, headers=headers) - frappe.flags.integration_request.raise_for_status() - return frappe.flags.integration_request.json() - - except Exception as exc: - frappe.log_error(frappe.get_traceback()) - raise exc - -def make_post_request(url, auth=None, headers=None, data=None): - if not auth: - auth = '' - if not data: - data = {} - if not headers: - headers = {} - - try: - s = get_request_session() - frappe.flags.integration_request = s.post(url, data=data, auth=auth, headers=headers) + frappe.flags.integration_request = s.request(method, url, data=data, auth=auth, headers=headers) frappe.flags.integration_request.raise_for_status() if frappe.flags.integration_request.headers.get("content-type") == "text/plain; charset=utf-8": @@ -47,6 +26,15 @@ def make_post_request(url, auth=None, headers=None, data=None): frappe.log_error() raise exc +def make_get_request(url, **kwargs): + return make_request('GET', url, **kwargs) + +def make_post_request(url, **kwargs): + return make_request('POST', url, **kwargs) + +def make_put_request(url, **kwargs): + return make_request('PUT', url, **kwargs) + def create_request_log(data, integration_type, service_name, name=None, error=None): if isinstance(data, str): data = json.loads(data) diff --git a/frappe/model/__init__.py b/frappe/model/__init__.py index 79b41936c3..362f4c79b3 100644 --- a/frappe/model/__init__.py +++ b/frappe/model/__init__.py @@ -34,7 +34,8 @@ data_fieldtypes = ( 'Color', 'Barcode', 'Geolocation', - 'Duration' + 'Duration', + 'Icon' ) no_value_fields = ( @@ -167,17 +168,7 @@ def delete_fields(args_dict, delete=0): "field": ("in", fields), }) else: - existing_fields = frappe.db.multisql({ - "mariadb": "DESC `tab%s`" % dt, - "postgres": """ - SELECT - COLUMN_NAME - FROM - information_schema.COLUMNS - WHERE - TABLE_NAME = 'tab%s'; - """ % dt, - }) + existing_fields = frappe.db.describe(dt) existing_fields = existing_fields and [e[0] for e in existing_fields] or [] fields_need_to_delete = set(fields) & set(existing_fields) if not fields_need_to_delete: diff --git a/frappe/patches.txt b/frappe/patches.txt index 7605d8ea2b..493c4dc9f6 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -180,3 +180,4 @@ frappe.patches.v12_0.rename_uploaded_files_with_proper_name frappe.patches.v13_0.queryreport_columns frappe.patches.v13_0.jinja_hook frappe.patches.v13_0.update_notification_channel_if_empty +frappe.patches.v14_0.drop_data_import_legacy diff --git a/frappe/patches/v12_0/set_correct_assign_value_in_docs.py b/frappe/patches/v12_0/set_correct_assign_value_in_docs.py index 65a635c170..90766b5f64 100644 --- a/frappe/patches/v12_0/set_correct_assign_value_in_docs.py +++ b/frappe/patches/v12_0/set_correct_assign_value_in_docs.py @@ -1,32 +1,29 @@ import frappe +from frappe.query_builder.functions import GroupConcat, Coalesce def execute(): - frappe.reload_doc('desk', 'doctype', 'todo') + frappe.reload_doc("desk", "doctype", "todo") - query = ''' - SELECT - name, reference_type, reference_name, {} as assignees - FROM - `tabToDo` - WHERE - COALESCE(reference_type, '') != '' AND - COALESCE(reference_name, '') != '' AND - status != 'Cancelled' - GROUP BY - reference_type, reference_name - ''' + ToDo = frappe.qb.Table("ToDo") + assignees = GroupConcat("owner").distinct().as_("assignees") - assignments = frappe.db.multisql({ - 'mariadb': query.format('GROUP_CONCAT(DISTINCT `owner`)'), - 'postgres': query.format('STRING_AGG(DISTINCT "owner", ",")') - }, as_dict=True) + query = ( + frappe.qb.from_(ToDo) + .select(ToDo.name, ToDo.reference_type, assignees) + .where(Coalesce(ToDo.reference_type, "") != "") + .where(Coalesce(ToDo.reference_name, "") != "") + .where(ToDo.status != "Cancelled") + .groupby(ToDo.reference_type, ToDo.reference_name) + ) + + assignments = frappe.db.sql(query, as_dict=True) for doc in assignments: - assignments = doc.assignees.split(',') + assignments = doc.assignees.split(",") frappe.db.set_value( doc.reference_type, doc.reference_name, - '_assign', + "_assign", frappe.as_json(assignments), update_modified=False - ) + ) \ No newline at end of file diff --git a/frappe/patches/v13_0/increase_password_length.py b/frappe/patches/v13_0/increase_password_length.py index 1bb1979051..62ca2ed779 100644 --- a/frappe/patches/v13_0/increase_password_length.py +++ b/frappe/patches/v13_0/increase_password_length.py @@ -1,7 +1,4 @@ import frappe def execute(): - frappe.db.multisql({ - "mariadb": "ALTER TABLE `__Auth` MODIFY `password` TEXT NOT NULL", - "postgres": 'ALTER TABLE "__Auth" ALTER COLUMN "password" TYPE TEXT' - }) + frappe.db.change_column_type(table="__Auth", column="password", type="TEXT") diff --git a/frappe/patches/v14_0/__init__.py b/frappe/patches/v14_0/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/patches/v14_0/drop_data_import_legacy.py b/frappe/patches/v14_0/drop_data_import_legacy.py new file mode 100644 index 0000000000..2037930c9f --- /dev/null +++ b/frappe/patches/v14_0/drop_data_import_legacy.py @@ -0,0 +1,22 @@ +import frappe +import click + + +def execute(): + doctype = "Data Import Legacy" + table = frappe.utils.get_table_name(doctype) + + # delete the doctype record to avoid broken links + frappe.db.delete("DocType", {"name": doctype}) + + # leaving table in database for manual cleanup + click.secho( + f"`{doctype}` has been deprecated. The DocType is deleted, but the data still" + " exists on the database. If this data is worth recovering, you may export it" + f" using\n\n\tbench --site {frappe.local.site} backup -i '{doctype}'\n\nAfter" + " this, the table will continue to persist in the database, until you choose" + " to remove it yourself. If you want to drop the table, you may run\n\n\tbench" + f" --site {frappe.local.site} execute frappe.db.sql --args \"('DROP TABLE IF" + f" EXISTS `{table}`', )\"\n", + fg="yellow", + ) diff --git a/frappe/permissions.py b/frappe/permissions.py index 33aef4ab41..af7bc3b602 100644 --- a/frappe/permissions.py +++ b/frappe/permissions.py @@ -301,7 +301,7 @@ def has_controller_permissions(doc, ptype, user=None): if not methods: return None - for method in methods: + for method in reversed(methods): controller_permission = frappe.call(frappe.get_attr(method), doc=doc, ptype=ptype, user=user) if controller_permission is not None: return controller_permission diff --git a/frappe/public/icons/timeless/symbol-defs.svg b/frappe/public/icons/timeless/symbol-defs.svg index e48dba362f..9aae6b5650 100644 --- a/frappe/public/icons/timeless/symbol-defs.svg +++ b/frappe/public/icons/timeless/symbol-defs.svg @@ -1,4 +1,4 @@ -
'); + this.picker = new Picker({ + parent: picker_wrapper, + icon: this.get_icon(), + icons: frappe.symbols + }); + + this.$wrapper.popover({ + trigger: 'manual', + offset: `${-this.$wrapper.width() / 4.5}, 5`, + boundary: 'viewport', + placement: 'bottom', + template: ` +
+
+
+
+ `, + content: () => picker_wrapper, + html: true + }).on('show.bs.popover', () => { + setTimeout(() => { + this.picker.refresh(); + }, 10); + }).on('hidden.bs.popover', () => { + $('body').off('click.icon-popover'); + $(window).off('hashchange.icon-popover'); + }); + + this.picker.on_change = (icon) => { + this.set_value(icon); + }; + + if (!this.selected_icon) { + this.selected_icon = $(`
${frappe.utils.icon("folder-normal", "md")}
`); + this.selected_icon.insertAfter(this.$input); + } + + this.$wrapper.find('.selected-icon').parent().on('click', (e) => { + this.$wrapper.popover('toggle'); + if (!this.get_icon()) { + this.$input.val(''); + } + e.stopPropagation(); + $('body').on('click.icon-popover', (ev) => { + if (!$(ev.target).parents().is('.popover')) { + this.$wrapper.popover('hide'); + } + }); + $(window).on('hashchange.icon-popover', () => { + this.$wrapper.popover('hide'); + }); + }); + } + + refresh() { + super.refresh(); + let icon = this.get_icon(); + if (this.picker && this.picker.icon !== icon) { + this.picker.icon = icon; + this.picker.refresh(); + } + } + + set_formatted_input(value) { + super.set_formatted_input(value); + this.$input.val(value); + this.selected_icon.find("use").attr("href", "#icon-"+(value || "folder-normal")); + this.selected_icon.toggleClass('no-value', !value); + } + + get_icon() { + return this.get_value() || 'folder-normal'; + } +}; diff --git a/frappe/public/js/frappe/form/controls/select.js b/frappe/public/js/frappe/form/controls/select.js index 042d86814d..7df2bbfbaa 100644 --- a/frappe/public/js/frappe/form/controls/select.js +++ b/frappe/public/js/frappe/form/controls/select.js @@ -113,6 +113,7 @@ frappe.ui.form.ControlSelect = class ControlSelect extends frappe.ui.form.Contro var is_value_null = is_null(v.value); var is_label_null = is_null(v.label); var is_disabled = Boolean(v.disabled); + var is_selected = Boolean(v.selected); if (is_value_null && is_label_null) { value = v; @@ -126,6 +127,7 @@ frappe.ui.form.ControlSelect = class ControlSelect extends frappe.ui.form.Contro $('
` : ''; + }, + Icon: (value) => { + return value ? `
+
${frappe.utils.icon(value, "md")}
+ ${value} +
` : ''; } }; diff --git a/frappe/public/js/frappe/form/grid.js b/frappe/public/js/frappe/form/grid.js index ebc3fa19f5..cad32954e9 100644 --- a/frappe/public/js/frappe/form/grid.js +++ b/frappe/public/js/frappe/form/grid.js @@ -264,15 +264,16 @@ export default class Grid { make_head() { // labels - if (!this.header_row) { - this.header_row = new GridRow({ - parent: $(this.parent).find(".grid-heading-row"), - parent_df: this.df, - docfields: this.docfields, - frm: this.frm, - grid: this - }); + if (this.header_row) { + $(this.parent).find(".grid-heading-row .grid-row").remove(); } + this.header_row = new GridRow({ + parent: $(this.parent).find(".grid-heading-row"), + parent_df: this.df, + docfields: this.docfields, + frm: this.frm, + grid: this + }); } refresh(force) { diff --git a/frappe/public/js/frappe/form/layout.js b/frappe/public/js/frappe/form/layout.js index 528f485354..88e0463fa5 100644 --- a/frappe/public/js/frappe/form/layout.js +++ b/frappe/public/js/frappe/form/layout.js @@ -250,6 +250,14 @@ frappe.ui.form.Layout = class Layout { // collapse sections this.refresh_section_collapse(); } + + if (document.activeElement) { + document.activeElement.focus(); + + if (document.activeElement.tagName == 'INPUT') { + document.activeElement.select(); + } + } } refresh_sections() { diff --git a/frappe/public/js/frappe/icon_picker/icon_picker.js b/frappe/public/js/frappe/icon_picker/icon_picker.js new file mode 100644 index 0000000000..21805f963b --- /dev/null +++ b/frappe/public/js/frappe/icon_picker/icon_picker.js @@ -0,0 +1,86 @@ +class Picker { + constructor(opts) { + this.parent = opts.parent; + this.width = opts.width; + this.height = opts.height; + this.set_icon(opts.icon); + this.icons = opts.icons; + this.setup_picker(); + } + + refresh() { + this.update_icon_selected(true); + } + + setup_picker() { + this.icon_picker_wrapper = $(` +
+
+ + ${frappe.utils.icon('search', "sm")} +
+
+
+
+
+ `); + this.parent.append(this.icon_picker_wrapper); + this.icon_wrapper = this.icon_picker_wrapper.find('.icons'); + this.search_input = this.icon_picker_wrapper.find('.search-icons > input'); + this.refresh(); + this.setup_icons(); + } + + setup_icons() { + this.icons.forEach(icon => { + let $icon = $(`
${frappe.utils.icon(icon, "md")}
`); + this.icon_wrapper.append($icon); + const set_values = () => { + this.set_icon(icon); + this.update_icon_selected(); + }; + $icon.on('click', () => { + set_values(); + }); + $icon.keydown((e) => { + const key_code = e.keyCode; + if ([13, 32].includes(key_code)) { + e.preventDefault(); + set_values(); + } + }); + this.search_input.keyup((e) => { + e.preventDefault(); + this.filter_icons(); + }); + + this.search_input.on('search', () => { + this.filter_icons(); + }); + }); + } + + filter_icons() { + let value = this.search_input.val(); + if (!value) { + this.icon_wrapper.find(".icon-wrapper").removeClass('hidden'); + } else { + this.icon_wrapper.find(".icon-wrapper").addClass('hidden'); + this.icon_wrapper.find(`.icon-wrapper[id*='${value}']`).removeClass('hidden'); + } + } + + update_icon_selected(silent) { + !silent && this.on_change && this.on_change(this.get_icon()); + } + + set_icon(icon) { + this.icon = icon || ''; + } + + get_icon() { + return this.icon || ''; + } +} + +export default Picker; \ No newline at end of file diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js index c55ec4b3ab..8a0e43c8f3 100644 --- a/frappe/public/js/frappe/list/list_view.js +++ b/frappe/public/js/frappe/list/list_view.js @@ -514,7 +514,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { render_skeleton() { const $row = this.get_list_row_html_skeleton( - '
' + '
' ); this.$result.append($row); } @@ -927,10 +927,12 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { const seen = this.get_seen_class(doc); let subject_html = ` - - - ${this.get_like_html(doc)} + + + + ${this.get_like_html(doc)} +
-
+
{{ __("SQL Query") }} #{{ call.index }} -
- -
@@ -116,7 +113,7 @@
-
+