Merge branch 'develop' into image-processing
This commit is contained in:
commit
fa6e26f5e1
79 changed files with 2158 additions and 571 deletions
50
cypress/integration/control_icon.js
Normal file
50
cypress/integration/control_icon.js
Normal file
|
|
@ -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');
|
||||
});
|
||||
|
||||
});
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = $(`<select class="form-control">`);
|
||||
let $field_select = $(`<select class="form-control">`);
|
||||
let $wrapper = $('<div class="fetch-from-select row"><div>');
|
||||
$wrapper.append($doctype_select, $field_select);
|
||||
field.$input_wrapper.append($wrapper);
|
||||
$doctype_select.wrap('<div class="col"></div>');
|
||||
$field_select.wrap('<div class="col"></div>');
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
# Copyright (c) 2021, Frappe Technologies and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
import unittest
|
||||
|
||||
class TestWebhookRequestLog(unittest.TestCase):
|
||||
pass
|
||||
|
|
@ -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) {
|
||||
|
||||
// }
|
||||
});
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
0
frappe/patches/v14_0/__init__.py
Normal file
0
frappe/patches/v14_0/__init__.py
Normal file
22
frappe/patches/v14_0/drop_data_import_legacy.py
Normal file
22
frappe/patches/v14_0/drop_data_import_legacy.py
Normal file
|
|
@ -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",
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<svg aria-hidden="true" style="position: absolute; width: 0; height: 0; overflow: hidden;" class="d-block" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg id="frappe-symbols" aria-hidden="true" style="position: absolute; width: 0; height: 0; overflow: hidden;" class="d-block" xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" id="icon-resting">
|
||||
<path d="M7.606 3.799L8 4.302l.394-.503.106-.14c.048-.065.08-.108.129-.159a3.284 3.284 0 0 1 4.72 0c.424.434.655 1.245.65 2.278-.006 1.578-.685 2.931-1.728 4.159-1.05 1.234-2.439 2.308-3.814 3.328a.763.763 0 0 1-.914 0c-1.375-1.02-2.764-2.094-3.814-3.328C2.686 8.709 2.007 7.357 2 5.778c-.004-1.033.227-1.844.651-2.278a3.284 3.284 0 0 1 4.72 0c.05.05.081.094.129.158.028.038.061.083.106.14z"
|
||||
stroke="var(--icon-stroke)"></path>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
|
@ -1,18 +1,4 @@
|
|||
import "air-datepicker/dist/js/datepicker.min.js";
|
||||
import "air-datepicker/dist/js/i18n/datepicker.cs.js";
|
||||
import "air-datepicker/dist/js/i18n/datepicker.da.js";
|
||||
import "air-datepicker/dist/js/i18n/datepicker.de.js";
|
||||
import "air-datepicker/dist/js/i18n/datepicker.en.js";
|
||||
import "air-datepicker/dist/js/i18n/datepicker.es.js";
|
||||
import "air-datepicker/dist/js/i18n/datepicker.fi.js";
|
||||
import "air-datepicker/dist/js/i18n/datepicker.fr.js";
|
||||
import "air-datepicker/dist/js/i18n/datepicker.hu.js";
|
||||
import "air-datepicker/dist/js/i18n/datepicker.nl.js";
|
||||
import "air-datepicker/dist/js/i18n/datepicker.pl.js";
|
||||
import "air-datepicker/dist/js/i18n/datepicker.pt-BR.js";
|
||||
import "air-datepicker/dist/js/i18n/datepicker.pt.js";
|
||||
import "air-datepicker/dist/js/i18n/datepicker.ro.js";
|
||||
import "air-datepicker/dist/js/i18n/datepicker.sk.js";
|
||||
import "air-datepicker/dist/js/i18n/datepicker.zh.js";
|
||||
import "./frappe/form/controls/datepicker_i18n.js";
|
||||
import "./frappe/ui/capture.js";
|
||||
import "./frappe/form/controls/control.js";
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import './multiselect_pills';
|
|||
import './multiselect_list';
|
||||
import './rating';
|
||||
import './duration';
|
||||
import './icon';
|
||||
|
||||
frappe.ui.form.make_control = function (opts) {
|
||||
var control_class_name = "Control" + opts.df.fieldtype.replace(/ /g, "");
|
||||
|
|
|
|||
62
frappe/public/js/frappe/form/controls/datepicker_i18n.js
Normal file
62
frappe/public/js/frappe/form/controls/datepicker_i18n.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import "air-datepicker/dist/js/i18n/datepicker.cs.js";
|
||||
import "air-datepicker/dist/js/i18n/datepicker.da.js";
|
||||
import "air-datepicker/dist/js/i18n/datepicker.de.js";
|
||||
import "air-datepicker/dist/js/i18n/datepicker.en.js";
|
||||
import "air-datepicker/dist/js/i18n/datepicker.es.js";
|
||||
import "air-datepicker/dist/js/i18n/datepicker.fi.js";
|
||||
import "air-datepicker/dist/js/i18n/datepicker.fr.js";
|
||||
import "air-datepicker/dist/js/i18n/datepicker.hu.js";
|
||||
import "air-datepicker/dist/js/i18n/datepicker.nl.js";
|
||||
import "air-datepicker/dist/js/i18n/datepicker.pl.js";
|
||||
import "air-datepicker/dist/js/i18n/datepicker.pt-BR.js";
|
||||
import "air-datepicker/dist/js/i18n/datepicker.pt.js";
|
||||
import "air-datepicker/dist/js/i18n/datepicker.ro.js";
|
||||
import "air-datepicker/dist/js/i18n/datepicker.sk.js";
|
||||
import "air-datepicker/dist/js/i18n/datepicker.zh.js";
|
||||
|
||||
(function ($) {
|
||||
$.fn.datepicker.language['ar'] = {
|
||||
days: ['الأحد', 'الأثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعه', 'السبت'],
|
||||
daysShort: ['الأحد', 'الأثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعه', 'السبت'],
|
||||
daysMin: ['الأحد', 'الأثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعه', 'السبت'],
|
||||
months: ['يناير', 'فبراير', 'مارس', 'أبريل', 'مايو', 'يونيو', 'يوليو', 'أغسطس', 'سبتمبر', 'اكتوبر', 'نوفمبر', 'ديسمبر'],
|
||||
monthsShort: ['يناير', 'فبراير', 'مارس', 'أبريل', 'مايو', 'يونيو', 'يوليو', 'أغسطس', 'سبتمبر', 'اكتوبر', 'نوفمبر', 'ديسمبر'],
|
||||
today: 'اليوم',
|
||||
clear: 'Clear',
|
||||
dateFormat: 'dd/mm/yyyy',
|
||||
timeFormat: 'hh:ii aa',
|
||||
firstDay: 0
|
||||
};
|
||||
})(jQuery);
|
||||
|
||||
(function ($) {
|
||||
$.fn.datepicker.language['gr'] = {
|
||||
days: ['Κυριακή', 'Δευτέρα', 'Τρίτη', 'Τετάρτη', 'Πέμπτη', 'Παρασκευή', 'Σάββατο'],
|
||||
daysShort: ['Κυρ', 'Δευ', 'Τρι', 'Τετ', 'Πεμ', 'Παρ', 'Σαβ'],
|
||||
daysMin: ['Κυ', 'Δε', 'Τρ', 'Τε', 'Πε', 'Πα', 'Σα'],
|
||||
months: ['Ιανουάριος', 'Φεβρουάριος', 'Μάρτιος', 'Απρίλιος', 'Μάιος', 'Ιούνιος', 'Ιούλιος', 'Αύγουστος', 'Σεπτέμβριος', 'Οκτώβριος', 'Νοέμβριος', 'Δεκέμβριος'],
|
||||
monthsShort: ['Ιαν', 'Φεβ', 'Μαρ', 'Απρ', 'Μάι', 'Ι/ν', 'Ι/λ', 'Αυγ', 'Σεπ', 'Οκτ', 'Νοε', 'Δεκ'],
|
||||
today: 'Σήμερα',
|
||||
clear: 'Καθαρισμός',
|
||||
dateFormat: 'dd/mm/yyyy',
|
||||
timeFormat: 'hh:ii aa',
|
||||
firstDay: 0
|
||||
};
|
||||
})(jQuery);
|
||||
|
||||
|
||||
(function ($) {
|
||||
$.fn.datepicker.language['it'] = {
|
||||
days: ['Domenica', 'Lunedì', 'Martedì', 'Mercoledì', 'Giovedì', 'Venerdì', 'Sabato'],
|
||||
daysShort: ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'],
|
||||
daysMin: ['Do', 'Lu', 'Ma', 'Me', 'Gi', 'Ve', 'Sa'],
|
||||
months: ['Gennaio', 'Febbraio', 'Marzo', 'Aprile', 'Maggio', 'Giugno', 'Luglio', 'Agosto',
|
||||
'Settembre', 'Ottobre', 'Novembre', 'Dicembre'],
|
||||
monthsShort: ['Gen', 'Feb', 'Mar', 'Apr', 'Mag', 'Giu', 'Lug', 'Ago', 'Set', 'Ott', 'Nov', 'Dic'],
|
||||
today: 'Oggi',
|
||||
clear: 'Reset',
|
||||
dateFormat: 'dd/mm/yyyy',
|
||||
timeFormat: 'hh:ii',
|
||||
firstDay: 1
|
||||
};
|
||||
})(jQuery);
|
||||
93
frappe/public/js/frappe/form/controls/icon.js
Normal file
93
frappe/public/js/frappe/form/controls/icon.js
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import Picker from '../../icon_picker/icon_picker';
|
||||
|
||||
frappe.ui.form.ControlIcon = class ControlIcon extends frappe.ui.form.ControlData {
|
||||
make_input() {
|
||||
this.df.placeholder = this.df.placeholder || __('Choose an icon');
|
||||
super.make_input();
|
||||
this.get_all_icons();
|
||||
this.make_icon_input();
|
||||
}
|
||||
|
||||
get_all_icons() {
|
||||
frappe.symbols = [];
|
||||
$("#frappe-symbols > symbol[id]").each(function() {
|
||||
frappe.symbols.push(this.id.replace('icon-', ''));
|
||||
});
|
||||
}
|
||||
|
||||
make_icon_input() {
|
||||
let picker_wrapper = $('<div>');
|
||||
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: `
|
||||
<div class="popover icon-picker-popover">
|
||||
<div class="picker-arrow arrow"></div>
|
||||
<div class="popover-body popover-content"></div>
|
||||
</div>
|
||||
`,
|
||||
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 = $(`<div class="selected-icon">${frappe.utils.icon("folder-normal", "md")}</div>`);
|
||||
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';
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
|||
$('<option>').html(cstr(label))
|
||||
.attr('value', value)
|
||||
.prop('disabled', is_disabled)
|
||||
.prop('selected', is_selected)
|
||||
.appendTo(this);
|
||||
}
|
||||
// select the first option
|
||||
|
|
|
|||
|
|
@ -152,6 +152,7 @@ function get_version_comment(version_doc, text) {
|
|||
let unlinked_content = "";
|
||||
|
||||
try {
|
||||
text += '</>';
|
||||
Array.from($(text)).forEach(element => {
|
||||
if ($(element).is('a')) {
|
||||
version_comment += unlinked_content ? frappe.utils.get_form_link('Version', version_doc.name, true, unlinked_content) : "";
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ frappe.ui.form.FormTour = class FormTour {
|
|||
return {
|
||||
element,
|
||||
name,
|
||||
popover: { title, description, position: frappe.router.slug(position) },
|
||||
popover: { title, description, position: frappe.router.slug(position || 'Bottom') },
|
||||
onNext: on_next
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -303,6 +303,12 @@ frappe.form.formatters = {
|
|||
<div class="selected-color" style="background-color: ${value}"></div>
|
||||
<span class="color-value">${value}</span>
|
||||
</div>` : '';
|
||||
},
|
||||
Icon: (value) => {
|
||||
return value ? `<div>
|
||||
<div class="selected-icon">${frappe.utils.icon(value, "md")}</div>
|
||||
<span class="icon-value">${value}</span>
|
||||
</div>` : '';
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
86
frappe/public/js/frappe/icon_picker/icon_picker.js
Normal file
86
frappe/public/js/frappe/icon_picker/icon_picker.js
Normal file
|
|
@ -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 = $(`
|
||||
<div class="icon-picker">
|
||||
<div class="search-icons">
|
||||
<input type="search" placeholder="Search for icons.." class="form-control">
|
||||
<span class="search-icon">${frappe.utils.icon('search', "sm")}</span>
|
||||
</div>
|
||||
<div class="icon-section">
|
||||
<div class="icons"></div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
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 = $(`<div id="${icon}" class="icon-wrapper">${frappe.utils.icon(icon, "md")}</div>`);
|
||||
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;
|
||||
|
|
@ -514,7 +514,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
|
|||
|
||||
render_skeleton() {
|
||||
const $row = this.get_list_row_html_skeleton(
|
||||
'<div><input type="checkbox" /></div>'
|
||||
'<div><input type="checkbox" class="render-list-checkbox"/></div>'
|
||||
);
|
||||
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 = `
|
||||
<input class="level-item list-row-checkbox hidden-xs" type="checkbox"
|
||||
data-name="${escape(doc.name)}">
|
||||
<span class="level-item" style="margin-bottom: 1px;">
|
||||
${this.get_like_html(doc)}
|
||||
<span class="level-item select-like">
|
||||
<input class="list-row-checkbox hidden-xs" type="checkbox"
|
||||
data-name="${escape(doc.name)}">
|
||||
<span class="list-row-like style="margin-bottom: 1px;">
|
||||
${this.get_like_html(doc)}
|
||||
</span>
|
||||
</span>
|
||||
<span class="level-item ${seen} ellipsis" title="${escaped_subject}">
|
||||
<a class="ellipsis"
|
||||
|
|
@ -1127,7 +1129,8 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
|
|||
// don't open form when checkbox, like, filterable are clicked
|
||||
if (
|
||||
$target.hasClass("filterable") ||
|
||||
$target.hasClass("icon-heart") ||
|
||||
$target.hasClass("select-like") ||
|
||||
$target.hasClass("list-row-like") ||
|
||||
$target.is(":checkbox")
|
||||
) {
|
||||
e.stopPropagation();
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@
|
|||
<div class="grid-body">
|
||||
<div class="rows">
|
||||
<div class="grid-row" :class="showing == call.index ? 'grid-row-open' : ''" v-for="call in paginated(sorted(grouped(request.calls)))" :key="call.index">
|
||||
<div class="data-row row" v-if="showing != call.index" style="display: block;" @click="showing = call.index" >
|
||||
<div class="data-row row" @click="showing = showing == call.index ? null : call.index" >
|
||||
<div class="row-index col col-xs-1"><span>{{ call.index }}</span></div>
|
||||
<div class="col grid-static-col col-xs-6" data-fieldtype="Code">
|
||||
<div class="static-area"><span>{{ call.query }}</span></div>
|
||||
|
|
@ -76,16 +76,13 @@
|
|||
<div class="static-area ellipsis text-right">{{ call.exact_copies }}</div>
|
||||
</div>
|
||||
<div class="col col-xs-1"><a class="close btn-open-row">
|
||||
<span class="octicon octicon-triangle-down"></span></a>
|
||||
<span class="octicon" :class="showing == call.index? 'octicon-triangle-up' : 'octicon-triangle-down'"></span></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="recorder-form-in-grid" v-if="showing == call.index">
|
||||
<div class="grid-form-heading" @click="showing = null">
|
||||
<div class="toolbar grid-header-toolbar">
|
||||
<span class="panel-title">{{ __("SQL Query") }} #<span class="grid-form-row-index">{{ call.index }}</span></span>
|
||||
<div class="btn btn-default btn-xs pull-right" style="margin-left: 7px;">
|
||||
<span class="hidden-xs octicon octicon-triangle-up"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-form-body">
|
||||
|
|
@ -116,7 +113,7 @@
|
|||
</div>
|
||||
<div class="frappe-control">
|
||||
<div class="form-group">
|
||||
<div class="clearfix"><label class="control-label"{{ __("Stack Trace") }}</label></div>
|
||||
<div class="clearfix"><label class="control-label">{{ __("Stack Trace") }}</label></div>
|
||||
<div class="control-value like-disabled-input for-description" style="overflow:auto">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ $('body').on('click', 'a', function(e) {
|
|||
return override('/app');
|
||||
}
|
||||
|
||||
if (href.startsWith('#')) {
|
||||
if (href && href.startsWith('#')) {
|
||||
// target startswith "#", this is a v1 style route, so remake it.
|
||||
return override(e.currentTarget.hash);
|
||||
}
|
||||
|
|
@ -169,10 +169,8 @@ frappe.router = {
|
|||
standard_route = ['Tree', doctype_route.doctype];
|
||||
} else {
|
||||
standard_route = ['List', doctype_route.doctype, frappe.utils.to_title_case(route[2])];
|
||||
if (route[3]) {
|
||||
// calendar / kanban / dashboard / folder name
|
||||
standard_route.push([...route].splice(3, route.length));
|
||||
}
|
||||
// calendar / kanban / dashboard / folder
|
||||
if (route[3]) standard_route.push(...route.slice(3, route.length));
|
||||
}
|
||||
return standard_route;
|
||||
},
|
||||
|
|
@ -245,7 +243,7 @@ frappe.router = {
|
|||
// example 1: frappe.set_route('a', 'b', 'c');
|
||||
// example 2: frappe.set_route(['a', 'b', 'c']);
|
||||
// example 3: frappe.set_route('a/b/c');
|
||||
let route = arguments;
|
||||
let route = Array.from(arguments);
|
||||
|
||||
return new Promise(resolve => {
|
||||
route = this.get_route_from_arguments(route);
|
||||
|
|
@ -297,7 +295,7 @@ frappe.router = {
|
|||
new_route = [this.slug(route[1]), 'view', route[2].toLowerCase()];
|
||||
|
||||
// calendar / inbox / file folder
|
||||
if (route[3]) new_route.push([...route].slice(3, route.length));
|
||||
if (route[3]) new_route.push(...route.slice(3, route.length));
|
||||
} else {
|
||||
if ($.isPlainObject(route[2])) {
|
||||
frappe.route_options = route[2];
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup {
|
|||
me.focus_on_first_input();
|
||||
me.on_page_show && me.on_page_show();
|
||||
$(document).trigger('frappe.ui.Dialog:shown');
|
||||
$(document).off('focusin.modal');
|
||||
})
|
||||
.on('scroll', function() {
|
||||
var $input = $('input:focus');
|
||||
|
|
|
|||
|
|
@ -518,6 +518,9 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList {
|
|||
} else {
|
||||
this.page.show_form();
|
||||
}
|
||||
|
||||
this.page.body[0].style.setProperty('--report-filter-height', this.page.page_form.css('height'));
|
||||
this.page.body.parent().css('margin-bottom', 'unset');
|
||||
}
|
||||
|
||||
set_filters(filters) {
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView {
|
|||
this.setup_columns();
|
||||
super.setup_new_doc_event();
|
||||
this.page.main.addClass('report-view');
|
||||
this.page.body[0].style.setProperty('--report-filter-height', this.page.page_form.css('height'));
|
||||
this.page.body.parent().css('margin-bottom', 'unset');
|
||||
}
|
||||
|
||||
toggle_side_bar() {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
@import "grid";
|
||||
@import "color_picker";
|
||||
@import "icon_picker";
|
||||
@import "datepicker";
|
||||
|
||||
// password
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
/*rtl:begin:ignore*/
|
||||
@import "~air-datepicker/dist/css/datepicker.min";
|
||||
/*rtl:end:ignore*/
|
||||
|
||||
.datepicker {
|
||||
|
||||
direction: ltr;
|
||||
font-family: inherit;
|
||||
z-index: 9999 !important;
|
||||
background: var(--fg-color);
|
||||
|
|
|
|||
95
frappe/public/scss/common/icon_picker.scss
Normal file
95
frappe/public/scss/common/icon_picker.scss
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
.icon-picker {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
--icon-picker-width: 240px;
|
||||
width: var(--icon-picker-width);
|
||||
.icons {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
overflow-y: scroll;
|
||||
max-height: 210px;
|
||||
cursor: pointer;
|
||||
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
display: flex;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.search-icons {
|
||||
position: relative;
|
||||
|
||||
input[type='search'] {
|
||||
height: inherit;
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
left: 7px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.icon-picker-popover {
|
||||
.picker-arrow {
|
||||
left: 15px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.frappe-control[data-fieldtype='Icon'] {
|
||||
input {
|
||||
padding-left: 40px;
|
||||
}
|
||||
.selected-icon {
|
||||
cursor: pointer;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 5px;
|
||||
position: absolute;
|
||||
top: calc(50% + 1px);
|
||||
left: 8px;
|
||||
content: ' ';
|
||||
}
|
||||
.like-disabled-input {
|
||||
.icon-value {
|
||||
padding-left: 25px;
|
||||
}
|
||||
.selected-icon {
|
||||
top: 20%;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.data-row.row {
|
||||
.selected-icon {
|
||||
top: calc(50% - 11px);
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.dt-cell__content {
|
||||
.selected-icon {
|
||||
display: contents;
|
||||
}
|
||||
}
|
||||
|
||||
.dt-cell__edit, .filter-field {
|
||||
.selected-icon {
|
||||
top: 5px !important;
|
||||
}
|
||||
}
|
||||
|
|
@ -58,7 +58,7 @@
|
|||
}
|
||||
|
||||
.list-row {
|
||||
padding: 15px;
|
||||
padding: 15px 15px 15px 0px;
|
||||
height: 45px;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
|
|
@ -130,10 +130,15 @@
|
|||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.select-like {
|
||||
padding: 15px 0px 15px 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.list-row-head {
|
||||
@extend .list-row;
|
||||
padding: 15px;
|
||||
cursor: default;
|
||||
|
||||
.list-subject {
|
||||
|
|
@ -200,6 +205,10 @@ input.list-check-all, input.list-row-checkbox {
|
|||
--checkbox-right-margin: calc(var(--checkbox-size) / 2 + #{$level-margin-right});
|
||||
}
|
||||
|
||||
.render-list-checkbox {
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
.filterable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,14 +84,37 @@
|
|||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.layout-main-section .frappe-card {
|
||||
--report-filter-height: 0px;
|
||||
}
|
||||
|
||||
.report-wrapper {
|
||||
overflow: auto;
|
||||
|
||||
.datatable {
|
||||
height: calc(100vh - var(--report-filter-height) - 205px);
|
||||
|
||||
.dt-scrollable {
|
||||
height: calc(100vh - var(--report-filter-height) - 275px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.report-view {
|
||||
.dt-row:last-child:not(.dt-row-filter) {
|
||||
.dt-cell {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
.result {
|
||||
min-height: 50vh !important;
|
||||
.dt-row:last-child:not(.dt-row-filter) {
|
||||
.dt-cell {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
}
|
||||
|
||||
.datatable {
|
||||
height: calc(100vh - var(--report-filter-height) - 225px);
|
||||
|
||||
.dt-scrollable {
|
||||
height: calc(100vh - var(--report-filter-height) - 295px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
frappe/query_builder/__init__.py
Normal file
1
frappe/query_builder/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from frappe.query_builder.utils import get_query_builder
|
||||
55
frappe/query_builder/builder.py
Normal file
55
frappe/query_builder/builder.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
from pypika import MySQLQuery, Order, PostgreSQLQuery, terms
|
||||
from pypika.queries import Schema, Table
|
||||
from frappe.utils import get_table_name
|
||||
|
||||
|
||||
class Base:
|
||||
terms = terms
|
||||
desc = Order.desc
|
||||
Schema = Schema
|
||||
|
||||
@staticmethod
|
||||
def Table(table_name: str, *args, **kwargs) -> Table:
|
||||
table_name = get_table_name(table_name)
|
||||
return Table(table_name, *args, **kwargs)
|
||||
|
||||
|
||||
class MariaDB(Base, MySQLQuery):
|
||||
Field = terms.Field
|
||||
|
||||
@classmethod
|
||||
def from_(cls, table, *args, **kwargs):
|
||||
if isinstance(table, str):
|
||||
table = cls.Table(table)
|
||||
return super().from_(table, *args, **kwargs)
|
||||
|
||||
|
||||
class Postgres(Base, PostgreSQLQuery):
|
||||
field_translation = {"table_name": "relname", "table_rows": "n_tup_ins"}
|
||||
schema_translation = {"tables": "pg_stat_all_tables"}
|
||||
# TODO: Find a better way to do this
|
||||
# These are interdependent query changes that need fixing. These
|
||||
# translations happen in the same query. But there is no check to see if
|
||||
# the Fields are changed only when a particular `information_schema` schema
|
||||
# is used. Replacing them is not straightforward because the "from_"
|
||||
# function can not see the arguments passed to the "select" function as
|
||||
# they are two different objects. The quick fix used here is to replace the
|
||||
# Field names in the "Field" function.
|
||||
|
||||
@classmethod
|
||||
def Field(cls, field_name, *args, **kwargs):
|
||||
if field_name in cls.field_translation:
|
||||
field_name = cls.field_translation[field_name]
|
||||
return terms.Field(field_name, *args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def from_(cls, table, *args, **kwargs):
|
||||
if isinstance(table, Table):
|
||||
if table._schema:
|
||||
if table._schema._name == "information_schema":
|
||||
table = cls.schema_translation[table._table_name]
|
||||
|
||||
elif isinstance(table, str):
|
||||
table = cls.Table(table)
|
||||
|
||||
return super().from_(table, *args, **kwargs)
|
||||
83
frappe/query_builder/custom.py
Normal file
83
frappe/query_builder/custom.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
from typing import Optional
|
||||
|
||||
from pypika.functions import DistinctOptionFunction
|
||||
from pypika.utils import builder
|
||||
|
||||
import frappe
|
||||
|
||||
|
||||
class GROUP_CONCAT(DistinctOptionFunction):
|
||||
def __init__(self, column: str, alias: Optional[str] = None):
|
||||
"""[ Implements the group concat function read more about it at https://www.geeksforgeeks.org/mysql-group_concat-function ]
|
||||
Args:
|
||||
column (str): [ name of the column you want to concat]
|
||||
alias (Optional[str], optional): [ is this an alias? ]. Defaults to None.
|
||||
"""
|
||||
super(GROUP_CONCAT, self).__init__("GROUP_CONCAT", column, alias=alias)
|
||||
|
||||
|
||||
class STRING_AGG(DistinctOptionFunction):
|
||||
def __init__(self, column: str, separator: str = ",", alias: Optional[str] = None):
|
||||
"""[ Implements the group concat function read more about it at https://docs.microsoft.com/en-us/sql/t-sql/functions/string-agg-transact-sql?view=sql-server-ver15 ]
|
||||
|
||||
Args:
|
||||
column (str): [ name of the column you want to concat ]
|
||||
separator (str, optional): [separator to be used]. Defaults to ",".
|
||||
alias (Optional[str], optional): [description]. Defaults to None.
|
||||
"""
|
||||
super(STRING_AGG, self).__init__("STRING_AGG", column, separator, alias=alias)
|
||||
|
||||
|
||||
class MATCH(DistinctOptionFunction):
|
||||
def __init__(self, column: str, *args, **kwargs):
|
||||
"""[ Implementation of Match Against read more about it https://dev.mysql.com/doc/refman/8.0/en/fulltext-search.html#function_match ]
|
||||
|
||||
Args:
|
||||
column (str):[ column to search in ]
|
||||
"""
|
||||
alias = kwargs.get("alias")
|
||||
super(MATCH, self).__init__(" MATCH", column, *args, alias=alias)
|
||||
self._Against = False
|
||||
|
||||
def get_function_sql(self, **kwargs):
|
||||
s = super(DistinctOptionFunction, self).get_function_sql(**kwargs)
|
||||
|
||||
if self._Against:
|
||||
return f"{s} AGAINST ({frappe.db.escape(f'+{self._Against}*')} IN BOOLEAN MODE)"
|
||||
return s
|
||||
|
||||
@builder
|
||||
def Against(self, text: str):
|
||||
"""[ Text that has to be searched against ]
|
||||
|
||||
Args:
|
||||
text (str): [ the text string that we match it against ]
|
||||
"""
|
||||
self._Against = text
|
||||
|
||||
|
||||
class TO_TSVECTOR(DistinctOptionFunction):
|
||||
def __init__(self, column: str, *args, **kwargs):
|
||||
"""[ Implementation of TO_TSVECTOR read more about it https://www.postgresql.org/docs/9.1/textsearch-controls.html]
|
||||
|
||||
Args:
|
||||
column (str): [ column to search in ]
|
||||
"""
|
||||
alias = kwargs.get("alias")
|
||||
super(TO_TSVECTOR, self).__init__("TO_TSVECTOR", column, *args, alias=alias)
|
||||
self._PLAINTO_TSQUERY = False
|
||||
|
||||
def get_function_sql(self, **kwargs):
|
||||
s = super(DistinctOptionFunction, self).get_function_sql(**kwargs)
|
||||
if self._PLAINTO_TSQUERY:
|
||||
return f"{s} @@ PLAINTO_TSQUERY({frappe.db.escape(self._PLAINTO_TSQUERY)})"
|
||||
return s
|
||||
|
||||
@builder
|
||||
def Against(self, text: str):
|
||||
"""[ Text that has to be searched against ]
|
||||
|
||||
Args:
|
||||
text (str): [ the text string that we match it against ]
|
||||
"""
|
||||
self._PLAINTO_TSQUERY = text
|
||||
17
frappe/query_builder/functions.py
Normal file
17
frappe/query_builder/functions.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from pypika.functions import *
|
||||
from frappe.query_builder.utils import ImportMapper, db_type_is
|
||||
from frappe.query_builder.custom import GROUP_CONCAT, STRING_AGG, MATCH, TO_TSVECTOR
|
||||
|
||||
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
|
||||
}
|
||||
)
|
||||
34
frappe/query_builder/utils.py
Normal file
34
frappe/query_builder/utils.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from enum import Enum
|
||||
from typing import Any, Callable, Dict
|
||||
|
||||
from pypika import Query
|
||||
|
||||
import frappe
|
||||
from .builder import MariaDB, Postgres
|
||||
|
||||
|
||||
class db_type_is(Enum):
|
||||
MARIADB = "mariadb"
|
||||
POSTGRES = "postgres"
|
||||
|
||||
class ImportMapper:
|
||||
def __init__(self, func_map: Dict[db_type_is, Callable]) -> None:
|
||||
self.func_map = func_map
|
||||
|
||||
def __call__(self, *args: Any, **kwds: Any) -> Callable:
|
||||
db = db_type_is(frappe.conf.db_type or "mariadb")
|
||||
return self.func_map[db](*args, **kwds)
|
||||
|
||||
|
||||
def get_query_builder(type_of_db: str) -> Query:
|
||||
"""[return the query builder object]
|
||||
|
||||
Args:
|
||||
type_of_db (str): [string value of the db used]
|
||||
|
||||
Returns:
|
||||
Query: [Query object]
|
||||
"""
|
||||
db = db_type_is(type_of_db)
|
||||
picks = {db_type_is.MARIADB: MariaDB, db_type_is.POSTGRES: Postgres}
|
||||
return picks[db]
|
||||
|
|
@ -90,19 +90,22 @@ class WebsiteSearch(FullTextSearch):
|
|||
def slugs_with_web_view(_items_to_index):
|
||||
all_routes = []
|
||||
filters = { "has_web_view": 1, "allow_guest_to_view": 1, "index_web_pages_for_search": 1}
|
||||
fields = ["name", "is_published_field", 'website_search_field']
|
||||
fields = ["name", "is_published_field", "website_search_field"]
|
||||
doctype_with_web_views = frappe.get_all("DocType", filters=filters, fields=fields)
|
||||
|
||||
for doctype in doctype_with_web_views:
|
||||
if doctype.is_published_field:
|
||||
docs = frappe.get_all(doctype.name, filters={doctype.is_published_field: 1}, fields=["route", doctype.website_search_field, 'title'])
|
||||
fields=["route", doctype.website_search_field]
|
||||
filters={doctype.is_published_field: 1},
|
||||
if doctype.website_search_field:
|
||||
docs = frappe.get_all(doctype.name, filters=filters, fields=fields.append("title"))
|
||||
for doc in docs:
|
||||
content = frappe.utils.md_to_html(getattr(doc, doctype.website_search_field))
|
||||
soup = BeautifulSoup(content, "html.parser")
|
||||
text_content = soup.text if soup else ""
|
||||
_items_to_index += [frappe._dict(title=doc.title, content=text_content, path=doc.route)]
|
||||
else:
|
||||
docs = frappe.get_all(doctype.name, filters=filters, fields=fields)
|
||||
all_routes += [route.route for route in docs]
|
||||
|
||||
return all_routes
|
||||
|
|
|
|||
|
|
@ -436,3 +436,16 @@ class TestCommands(BaseTestCommands):
|
|||
|
||||
self.execute("bench version -f invalid")
|
||||
self.assertEqual(self.returncode, 2)
|
||||
|
||||
def test_set_password(self):
|
||||
from frappe.utils.password import check_password
|
||||
|
||||
self.execute("bench --site {site} set-password Administrator test1")
|
||||
self.assertEqual(self.returncode, 0)
|
||||
self.assertEqual(check_password('Administrator', 'test1'), 'Administrator')
|
||||
# to release the lock taken by check_password
|
||||
frappe.db.commit()
|
||||
|
||||
self.execute("bench --site {site} set-admin-password test2")
|
||||
self.assertEqual(self.returncode, 0)
|
||||
self.assertEqual(check_password('Administrator', 'test2'), 'Administrator')
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ from frappe.custom.doctype.custom_field.custom_field import create_custom_field
|
|||
from frappe.utils import random_string
|
||||
from frappe.utils.testutils import clear_custom_fields
|
||||
|
||||
from .test_query_builder import run_only_if, db_type_is
|
||||
|
||||
|
||||
class TestDB(unittest.TestCase):
|
||||
def test_get_value(self):
|
||||
|
|
@ -146,7 +148,7 @@ class TestDB(unittest.TestCase):
|
|||
|
||||
# Create documents under that doctype and query them via ORM
|
||||
for _ in range(10):
|
||||
docfields = { key.lower(): random_string(10) for key in fields }
|
||||
docfields = {key.lower(): random_string(10) for key in fields}
|
||||
doc = frappe.get_doc({"doctype": test_doctype, "description": random_string(20), **docfields})
|
||||
doc.insert()
|
||||
created_docs.append(doc.name)
|
||||
|
|
@ -189,3 +191,98 @@ class TestDB(unittest.TestCase):
|
|||
for doc in created_docs:
|
||||
frappe.delete_doc(test_doctype, doc)
|
||||
clear_custom_fields(test_doctype)
|
||||
|
||||
@run_only_if(db_type_is.MARIADB)
|
||||
class TestDDLCommandsMaria(unittest.TestCase):
|
||||
test_table_name = "TestNotes"
|
||||
|
||||
def setUp(self) -> None:
|
||||
frappe.db.commit()
|
||||
frappe.db.sql(
|
||||
f"""
|
||||
CREATE TABLE `tab{self.test_table_name}` (`id` INT NULL,PRIMARY KEY (`id`));
|
||||
"""
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
frappe.db.sql(f"DROP TABLE tab{self.test_table_name};")
|
||||
self.test_table_name = "TestNotes"
|
||||
|
||||
def test_rename(self) -> None:
|
||||
new_table_name = f"{self.test_table_name}_new"
|
||||
frappe.db.rename_table(self.test_table_name, new_table_name)
|
||||
check_exists = frappe.db.sql(
|
||||
f"""
|
||||
SELECT * FROM INFORMATION_SCHEMA.TABLES
|
||||
WHERE TABLE_NAME = N'tab{new_table_name}';
|
||||
"""
|
||||
)
|
||||
self.assertGreater(len(check_exists), 0)
|
||||
self.assertIn(f"tab{new_table_name}", check_exists[0])
|
||||
|
||||
# * so this table is deleted after the rename
|
||||
self.test_table_name = new_table_name
|
||||
|
||||
def test_describe(self) -> None:
|
||||
self.assertEqual(
|
||||
(("id", "int(11)", "NO", "PRI", None, ""),),
|
||||
frappe.db.describe(self.test_table_name),
|
||||
)
|
||||
|
||||
def test_change_type(self) -> None:
|
||||
frappe.db.change_column_type("TestNotes", "id", "varchar(255)")
|
||||
test_table_description = frappe.db.sql(f"DESC tab{self.test_table_name};")
|
||||
self.assertGreater(len(test_table_description), 0)
|
||||
self.assertIn("varchar(255)", test_table_description[0])
|
||||
|
||||
|
||||
@run_only_if(db_type_is.POSTGRES)
|
||||
class TestDDLCommandsPost(unittest.TestCase):
|
||||
test_table_name = "TestNotes"
|
||||
|
||||
def setUp(self) -> None:
|
||||
frappe.db.sql(
|
||||
f"""
|
||||
CREATE TABLE "tab{self.test_table_name}" ("id" INT NULL,PRIMARY KEY ("id"))
|
||||
"""
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
frappe.db.sql(f'DROP TABLE "tab{self.test_table_name}"')
|
||||
self.test_table_name = "TestNotes"
|
||||
|
||||
def test_rename(self) -> None:
|
||||
new_table_name = f"{self.test_table_name}_new"
|
||||
frappe.db.rename_table(self.test_table_name, new_table_name)
|
||||
check_exists = frappe.db.sql(
|
||||
f"""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_name = 'tab{new_table_name}'
|
||||
);
|
||||
"""
|
||||
)
|
||||
self.assertTrue(check_exists[0][0])
|
||||
|
||||
# * so this table is deleted after the rename
|
||||
self.test_table_name = new_table_name
|
||||
|
||||
def test_describe(self) -> None:
|
||||
self.assertEqual([("id",)], frappe.db.describe(self.test_table_name))
|
||||
|
||||
def test_change_type(self) -> None:
|
||||
frappe.db.change_column_type(self.test_table_name, "id", "varchar(255)")
|
||||
check_change = frappe.db.sql(
|
||||
f"""
|
||||
SELECT
|
||||
table_name,
|
||||
column_name,
|
||||
data_type
|
||||
FROM
|
||||
information_schema.columns
|
||||
WHERE
|
||||
table_name = 'tab{self.test_table_name}'
|
||||
"""
|
||||
)
|
||||
self.assertGreater(len(check_change), 0)
|
||||
self.assertIn("character varying", check_change[0])
|
||||
|
|
|
|||
|
|
@ -31,6 +31,44 @@ class TestHooks(unittest.TestCase):
|
|||
todo = frappe.get_doc(doctype='ToDo', description='asdf')
|
||||
self.assertTrue(isinstance(todo, CustomToDo))
|
||||
|
||||
def test_has_permission(self):
|
||||
from frappe import hooks
|
||||
|
||||
# Set hook
|
||||
address_has_permission_hook = hooks.has_permission.get('Address', [])
|
||||
if isinstance(address_has_permission_hook, str):
|
||||
address_has_permission_hook = [address_has_permission_hook]
|
||||
|
||||
address_has_permission_hook.append(
|
||||
'frappe.tests.test_hooks.custom_has_permission'
|
||||
)
|
||||
|
||||
hooks.has_permission['Address'] = address_has_permission_hook
|
||||
|
||||
# Clear cache
|
||||
frappe.cache().delete_value('app_hooks')
|
||||
|
||||
# Init User and Address
|
||||
username = "test@example.com"
|
||||
user = frappe.get_doc("User", username)
|
||||
user.add_roles("System Manager")
|
||||
address = frappe.new_doc("Address")
|
||||
|
||||
# Test!
|
||||
self.assertTrue(
|
||||
frappe.has_permission("Address", doc=address, user=username)
|
||||
)
|
||||
|
||||
address.flags.dont_touch_me = True
|
||||
self.assertFalse(
|
||||
frappe.has_permission("Address", doc=address, user=username)
|
||||
)
|
||||
|
||||
|
||||
def custom_has_permission(doc, ptype, user):
|
||||
if doc.flags.dont_touch_me:
|
||||
return False
|
||||
|
||||
|
||||
class CustomToDo(ToDo):
|
||||
pass
|
||||
|
|
|
|||
74
frappe/tests/test_query_builder.py
Normal file
74
frappe/tests/test_query_builder.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import unittest
|
||||
from typing import Callable
|
||||
|
||||
import frappe
|
||||
from frappe.query_builder.functions import GroupConcat, Match
|
||||
from frappe.query_builder.utils import db_type_is
|
||||
|
||||
|
||||
def run_only_if(dbtype: db_type_is) -> Callable:
|
||||
return unittest.skipIf(
|
||||
db_type_is(frappe.conf.db_type) != dbtype, f"Only runs for {dbtype.value}"
|
||||
)
|
||||
|
||||
|
||||
@run_only_if(db_type_is.MARIADB)
|
||||
class TestCustomFunctionsMariaDB(unittest.TestCase):
|
||||
def test_concat(self):
|
||||
self.assertEqual("GROUP_CONCAT('Notes')", GroupConcat("Notes").get_sql())
|
||||
|
||||
def test_match(self):
|
||||
query = Match("Notes").Against("text")
|
||||
self.assertEqual(
|
||||
" MATCH('Notes') AGAINST ('+text*' IN BOOLEAN MODE)", query.get_sql()
|
||||
)
|
||||
|
||||
|
||||
@run_only_if(db_type_is.POSTGRES)
|
||||
class TestCustomFunctionsPostgres(unittest.TestCase):
|
||||
def test_concat(self):
|
||||
self.assertEqual("STRING_AGG('Notes',',')", GroupConcat("Notes").get_sql())
|
||||
|
||||
def test_match(self):
|
||||
query = Match("Notes").Against("text")
|
||||
self.assertEqual(
|
||||
"TO_TSVECTOR('Notes') @@ PLAINTO_TSQUERY('text')", query.get_sql()
|
||||
)
|
||||
|
||||
|
||||
class TestBuilderBase(object):
|
||||
def test_adding_tabs(self):
|
||||
self.assertEqual("tabNotes", frappe.qb.Table("Notes").get_sql())
|
||||
self.assertEqual("__Auth", frappe.qb.Table("__Auth").get_sql())
|
||||
|
||||
|
||||
@run_only_if(db_type_is.MARIADB)
|
||||
class TestBuilderMaria(unittest.TestCase, TestBuilderBase):
|
||||
def test_adding_tabs_in_from(self):
|
||||
self.assertEqual(
|
||||
"SELECT * FROM `tabNotes`", frappe.qb.from_("Notes").select("*").get_sql()
|
||||
)
|
||||
self.assertEqual(
|
||||
"SELECT * FROM `__Auth`", frappe.qb.from_("__Auth").select("*").get_sql()
|
||||
)
|
||||
|
||||
|
||||
@run_only_if(db_type_is.POSTGRES)
|
||||
class TestBuilderPostgres(unittest.TestCase, TestBuilderBase):
|
||||
def test_adding_tabs_in_from(self):
|
||||
self.assertEqual(
|
||||
'SELECT * FROM "tabNotes"', frappe.qb.from_("Notes").select("*").get_sql()
|
||||
)
|
||||
self.assertEqual(
|
||||
'SELECT * FROM "__Auth"', frappe.qb.from_("__Auth").select("*").get_sql()
|
||||
)
|
||||
|
||||
def test_replace_tables(self):
|
||||
info_schema = frappe.qb.Schema("information_schema")
|
||||
self.assertEqual(
|
||||
'SELECT * FROM "pg_stat_all_tables"',
|
||||
frappe.qb.from_(info_schema.tables).select("*").get_sql(),
|
||||
)
|
||||
|
||||
def test_replace_fields_post(self):
|
||||
self.assertEqual("relname", frappe.qb.Field("table_name").get_sql())
|
||||
|
|
@ -18,8 +18,19 @@ first_lang, second_lang, third_lang, fourth_lang, fifth_lang = choices(
|
|||
)
|
||||
|
||||
class TestTranslate(unittest.TestCase):
|
||||
guest_sessions_required = [
|
||||
"test_guest_request_language_resolution_with_cookie",
|
||||
"test_guest_request_language_resolution_with_request_header"
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
if self._testMethodName in self.guest_sessions_required:
|
||||
frappe.set_user("Guest")
|
||||
|
||||
def tearDown(self):
|
||||
frappe.form_dict.pop("_lang", None)
|
||||
if self._testMethodName in self.guest_sessions_required:
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def test_extract_message_from_file(self):
|
||||
data = frappe.translate.get_messages_from_file(translation_string_file)
|
||||
|
|
@ -52,20 +63,44 @@ class TestTranslate(unittest.TestCase):
|
|||
Case 2: frappe.form_dict._lang is not set, but preferred_language cookie is
|
||||
"""
|
||||
|
||||
with patch.object(frappe.translate, "get_preferred_language_cookie", return_value=second_lang):
|
||||
set_request(method="POST", path="/", headers=[("Accept-Language", third_lang)])
|
||||
return_val = get_language()
|
||||
|
||||
self.assertNotIn(return_val, [second_lang, get_parent_language(second_lang)])
|
||||
|
||||
def test_guest_request_language_resolution_with_cookie(self):
|
||||
"""Test for frappe.translate.get_language
|
||||
|
||||
Case 3: frappe.form_dict._lang is not set, but preferred_language cookie is [Guest User]
|
||||
"""
|
||||
|
||||
with patch.object(frappe.translate, "get_preferred_language_cookie", return_value=second_lang):
|
||||
set_request(method="POST", path="/", headers=[("Accept-Language", third_lang)])
|
||||
return_val = get_language()
|
||||
|
||||
self.assertIn(return_val, [second_lang, get_parent_language(second_lang)])
|
||||
|
||||
|
||||
def test_guest_request_language_resolution_with_request_header(self):
|
||||
"""Test for frappe.translate.get_language
|
||||
|
||||
Case 4: frappe.form_dict._lang & preferred_language cookie is not set, but Accept-Language header is [Guest User]
|
||||
"""
|
||||
|
||||
set_request(method="POST", path="/", headers=[("Accept-Language", third_lang)])
|
||||
return_val = get_language()
|
||||
self.assertIn(return_val, [third_lang, get_parent_language(third_lang)])
|
||||
|
||||
def test_request_language_resolution_with_request_header(self):
|
||||
"""Test for frappe.translate.get_language
|
||||
|
||||
Case 3: frappe.form_dict._lang & preferred_language cookie is not set, but Accept-Language header is
|
||||
Case 5: frappe.form_dict._lang & preferred_language cookie is not set, but Accept-Language header is
|
||||
"""
|
||||
|
||||
set_request(method="POST", path="/", headers=[("Accept-Language", third_lang)])
|
||||
return_val = get_language()
|
||||
self.assertIn(return_val, [third_lang, get_parent_language(third_lang)])
|
||||
self.assertNotIn(return_val, [third_lang, get_parent_language(third_lang)])
|
||||
|
||||
|
||||
expected_output = [
|
||||
|
|
|
|||
|
|
@ -27,11 +27,12 @@ def get_language(lang_list: List = None) -> str:
|
|||
|
||||
Order of priority for setting language:
|
||||
1. Form Dict => _lang
|
||||
2. Cookie => preferred_language
|
||||
3. Request Header => Accept-Language
|
||||
2. Cookie => preferred_language (Non authorized user)
|
||||
3. Request Header => Accept-Language (Non authorized user)
|
||||
4. User document => language
|
||||
5. System Settings => language
|
||||
"""
|
||||
is_logged_in = frappe.session.user != "Guest"
|
||||
|
||||
# fetch language from form_dict
|
||||
if frappe.form_dict._lang:
|
||||
|
|
@ -41,6 +42,10 @@ def get_language(lang_list: List = None) -> str:
|
|||
if language:
|
||||
return language
|
||||
|
||||
# use language set in User or System Settings if user is logged in
|
||||
if is_logged_in:
|
||||
return frappe.local.lang
|
||||
|
||||
lang_set = set(lang_list or get_all_languages() or [])
|
||||
|
||||
# fetch language from cookie
|
||||
|
|
|
|||
|
|
@ -849,3 +849,6 @@ def groupby_metric(iterable: typing.Dict[str, list], key: str):
|
|||
for item in items:
|
||||
records.setdefault(item[key], {}).setdefault(category, []).append(item)
|
||||
return records
|
||||
|
||||
def get_table_name(table_name: str) -> str:
|
||||
return f"tab{table_name}" if not table_name.startswith("__") else table_name
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ from frappe.utils.commands import log
|
|||
|
||||
default_timeout = 300
|
||||
queue_timeout = {
|
||||
'background': 2500,
|
||||
'long': 1500,
|
||||
'default': 300,
|
||||
'short': 300
|
||||
|
|
|
|||
|
|
@ -116,16 +116,16 @@ class BackupGenerator:
|
|||
|
||||
def setup_backup_tables(self):
|
||||
"""Sets self.backup_includes, self.backup_excludes based on passed args"""
|
||||
existing_doctypes = set([x.name for x in frappe.get_all("DocType")])
|
||||
existing_tables = frappe.db.get_tables()
|
||||
|
||||
def get_tables(doctypes):
|
||||
tables = []
|
||||
for doctype in doctypes:
|
||||
if doctype and doctype in existing_doctypes:
|
||||
if doctype.startswith("tab"):
|
||||
tables.append(doctype)
|
||||
else:
|
||||
tables.append("tab" + doctype)
|
||||
if not doctype:
|
||||
continue
|
||||
table = frappe.utils.get_table_name(doctype)
|
||||
if table in existing_tables:
|
||||
tables.append(table)
|
||||
return tables
|
||||
|
||||
passed_tables = {
|
||||
|
|
|
|||
|
|
@ -411,51 +411,41 @@ def search(text, start=0, limit=20, doctype=""):
|
|||
:param limit: number of results to return, default 20
|
||||
:return: Array of result objects
|
||||
"""
|
||||
from frappe.desk.doctype.global_search_settings.global_search_settings import get_doctypes_for_global_search
|
||||
from frappe.desk.doctype.global_search_settings.global_search_settings import (
|
||||
get_doctypes_for_global_search,
|
||||
)
|
||||
from frappe.query_builder.functions import Match
|
||||
|
||||
results = []
|
||||
sorted_results = []
|
||||
|
||||
allowed_doctypes = get_doctypes_for_global_search()
|
||||
|
||||
for text in set(text.split('&')):
|
||||
for text in set(text.split("&")):
|
||||
text = text.strip()
|
||||
if not text:
|
||||
continue
|
||||
|
||||
conditions = '1=1'
|
||||
offset = ''
|
||||
|
||||
mariadb_text = frappe.db.escape('+' + text + '*')
|
||||
|
||||
mariadb_fields = '`doctype`, `name`, `content`, MATCH (`content`) AGAINST ({} IN BOOLEAN MODE) AS rank'.format(mariadb_text)
|
||||
postgres_fields = '`doctype`, `name`, `content`, TO_TSVECTOR("content") @@ PLAINTO_TSQUERY({}) AS rank'.format(frappe.db.escape(text))
|
||||
|
||||
values = {}
|
||||
global_search = frappe.qb.Table("__global_search")
|
||||
rank = Match(global_search.content).Against(text).as_("rank")
|
||||
query = (
|
||||
frappe.qb.from_(global_search)
|
||||
.select(
|
||||
global_search.doctype, global_search.name, global_search.content, rank
|
||||
)
|
||||
.orderby("rank", order=frappe.qb.desc)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
if doctype:
|
||||
conditions = '`doctype` = %(doctype)s'
|
||||
values['doctype'] = doctype
|
||||
query = query.where(global_search.doctype == doctype)
|
||||
elif allowed_doctypes:
|
||||
conditions = '`doctype` IN %(allowed_doctypes)s'
|
||||
values['allowed_doctypes'] = tuple(allowed_doctypes)
|
||||
query = query.where(global_search.doctype.isin(allowed_doctypes))
|
||||
|
||||
if int(start) > 0:
|
||||
offset = 'OFFSET {}'.format(start)
|
||||
if start > 0:
|
||||
query = query.offset(start)
|
||||
|
||||
common_query = """
|
||||
SELECT {fields}
|
||||
FROM `__global_search`
|
||||
WHERE {conditions}
|
||||
ORDER BY rank DESC
|
||||
LIMIT {limit}
|
||||
{offset}
|
||||
"""
|
||||
|
||||
result = frappe.db.multisql({
|
||||
'mariadb': common_query.format(fields=mariadb_fields, conditions=conditions, limit=limit, offset=offset),
|
||||
'postgres': common_query.format(fields=postgres_fields, conditions=conditions, limit=limit, offset=offset)
|
||||
}, values=values, as_dict=True)
|
||||
result = frappe.db.sql(query, as_dict=True)
|
||||
|
||||
results.extend(result)
|
||||
|
||||
|
|
@ -466,7 +456,9 @@ def search(text, start=0, limit=20, doctype=""):
|
|||
try:
|
||||
meta = frappe.get_meta(r.doctype)
|
||||
if meta.image_field:
|
||||
r.image = frappe.db.get_value(r.doctype, r.name, meta.image_field)
|
||||
r.image = frappe.db.get_value(
|
||||
r.doctype, r.name, meta.image_field
|
||||
)
|
||||
except Exception:
|
||||
frappe.clear_messages()
|
||||
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ def update_add_node(doc, parent, parent_field):
|
|||
|
||||
# get the last sibling of the parent
|
||||
if parent:
|
||||
left, right = frappe.db.sql("select lft, rgt from `tab{0}` where name=%s"
|
||||
left, right = frappe.db.sql("select lft, rgt from `tab{0}` where name=%s for update"
|
||||
.format(doctype), parent)[0]
|
||||
validate_loop(doc.doctype, doc.name, left, right)
|
||||
else: # root
|
||||
|
|
@ -89,7 +89,7 @@ def update_move_node(doc, parent_field):
|
|||
|
||||
if parent:
|
||||
new_parent = frappe.db.sql("""select lft, rgt from `tab{0}`
|
||||
where name = %s""".format(doc.doctype), parent, as_dict=1)[0]
|
||||
where name = %s for update""".format(doc.doctype), parent, as_dict=1)[0]
|
||||
|
||||
validate_loop(doc.doctype, doc.name, new_parent.lft, new_parent.rgt)
|
||||
|
||||
|
|
@ -108,7 +108,7 @@ def update_move_node(doc, parent_field):
|
|||
|
||||
if parent:
|
||||
new_parent = frappe.db.sql("""select lft, rgt from `tab%s`
|
||||
where name = %s""" % (doc.doctype, '%s'), parent, as_dict=1)[0]
|
||||
where name = %s for update""" % (doc.doctype, '%s'), parent, as_dict=1)[0]
|
||||
|
||||
|
||||
# set parent lft, rgt
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
# Copyright (c) 2013, Frappe Technologies and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from datetime import datetime
|
||||
|
||||
import frappe
|
||||
from frappe.query_builder.functions import Coalesce, Count
|
||||
from frappe.utils import getdate
|
||||
from frappe.utils.dateutils import get_dates_from_timegrain
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
return WebsiteAnalytics(filters).run()
|
||||
|
||||
|
|
@ -56,33 +59,21 @@ class WebsiteAnalytics(object):
|
|||
]
|
||||
|
||||
def get_data(self):
|
||||
pg_query = """
|
||||
SELECT
|
||||
path,
|
||||
COUNT(*) as count,
|
||||
COUNT(CASE WHEN CAST(is_unique as Integer) = 1 THEN 1 END) as unique_count
|
||||
FROM `tabWeb Page View`
|
||||
WHERE coalesce("tabWeb Page View".creation, '0001-01-01') BETWEEN %s AND %s
|
||||
GROUP BY path
|
||||
ORDER BY count desc
|
||||
"""
|
||||
WebPageView = frappe.qb.Table("Web Page View")
|
||||
count_all = Count("*").as_("count")
|
||||
case = frappe.qb.terms.Case().when(WebPageView.is_unique == "1", "1")
|
||||
count_is_unique = Count(case).as_("unique_count")
|
||||
|
||||
mariadb_query = """
|
||||
SELECT
|
||||
path,
|
||||
COUNT(*) as count,
|
||||
COUNT(CASE WHEN is_unique = 1 THEN 1 END) as unique_count
|
||||
FROM `tabWeb Page View`
|
||||
WHERE creation BETWEEN %s AND %s
|
||||
GROUP BY path
|
||||
ORDER BY count desc
|
||||
"""
|
||||
|
||||
data = frappe.db.multisql({
|
||||
"mariadb": mariadb_query,
|
||||
"postgres": pg_query
|
||||
}, (self.filters.from_date, self.filters.to_date))
|
||||
return data
|
||||
query = (
|
||||
frappe.qb.from_(WebPageView)
|
||||
.select("path", count_all, count_is_unique)
|
||||
.where(
|
||||
Coalesce(WebPageView.creation, "0001-01-01")[self.filters.from_date:self.filters.to_date]
|
||||
)
|
||||
.groupby(WebPageView.path)
|
||||
.orderby("count", Order=frappe.qb.desc)
|
||||
)
|
||||
return frappe.db.sql(query)
|
||||
|
||||
def _get_query_for_mariadb(self):
|
||||
filters_range = self.filters.range
|
||||
|
|
|
|||
|
|
@ -488,11 +488,12 @@ def set_content_type(response, data, path):
|
|||
return data
|
||||
|
||||
def add_preload_headers(response):
|
||||
from bs4 import BeautifulSoup
|
||||
from bs4 import BeautifulSoup, SoupStrainer
|
||||
|
||||
try:
|
||||
preload = []
|
||||
soup = BeautifulSoup(response.data, "lxml")
|
||||
strainer = SoupStrainer(re.compile("script|link"))
|
||||
soup = BeautifulSoup(response.data, "lxml", parse_only=strainer)
|
||||
for elem in soup.find_all('script', src=re.compile(".*")):
|
||||
preload.append(("script", elem.get("src")))
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@
|
|||
"qz-tray": "^2.0.8",
|
||||
"redis": "^3.1.1",
|
||||
"showdown": "^1.9.1",
|
||||
"snyk": "^1.518.0",
|
||||
"snyk": "^1.667.0",
|
||||
"socket.io": "^2.4.0",
|
||||
"superagent": "^3.8.2",
|
||||
"touch": "^3.1.0",
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ pyngrok~=5.0.5
|
|||
pyOpenSSL~=20.0.1
|
||||
pyotp~=2.6.0
|
||||
PyPDF2~=1.26.0
|
||||
PyPika~=0.48.6
|
||||
pypng~=0.0.20
|
||||
PyQRCode~=1.2.1
|
||||
python-dateutil~=2.8.1
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue