The license.txt file has been replaced with LICENSE for quite a while now. INAL but it didn't seem accurate to say "hey, checkout license.txt although there's no such file". Apart from this, there were inconsistencies in the headers altogether...this change brings consistency.
274 lines
8.9 KiB
Python
274 lines
8.9 KiB
Python
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
|
# License: MIT. See LICENSE
|
|
|
|
# Search
|
|
import frappe, json
|
|
from frappe.utils import cstr, unique, cint
|
|
from frappe.permissions import has_permission
|
|
from frappe import _, is_whitelisted
|
|
import re
|
|
import wrapt
|
|
|
|
UNTRANSLATED_DOCTYPES = ["DocType", "Role"]
|
|
|
|
def sanitize_searchfield(searchfield):
|
|
blacklisted_keywords = ['select', 'delete', 'drop', 'update', 'case', 'and', 'or', 'like']
|
|
|
|
def _raise_exception(searchfield):
|
|
frappe.throw(_('Invalid Search Field {0}').format(searchfield), frappe.DataError)
|
|
|
|
if len(searchfield) == 1:
|
|
# do not allow special characters to pass as searchfields
|
|
regex = re.compile(r'^.*[=;*,\'"$\-+%#@()_].*')
|
|
if regex.match(searchfield):
|
|
_raise_exception(searchfield)
|
|
|
|
if len(searchfield) >= 3:
|
|
|
|
# to avoid 1=1
|
|
if '=' in searchfield:
|
|
_raise_exception(searchfield)
|
|
|
|
# in mysql -- is used for commenting the query
|
|
elif ' --' in searchfield:
|
|
_raise_exception(searchfield)
|
|
|
|
# to avoid and, or and like
|
|
elif any(' {0} '.format(keyword) in searchfield.split() for keyword in blacklisted_keywords):
|
|
_raise_exception(searchfield)
|
|
|
|
# to avoid select, delete, drop, update and case
|
|
elif any(keyword in searchfield.split() for keyword in blacklisted_keywords):
|
|
_raise_exception(searchfield)
|
|
|
|
else:
|
|
regex = re.compile(r'^.*[=;*,\'"$\-+%#@()].*')
|
|
if any(regex.match(f) for f in searchfield.split()):
|
|
_raise_exception(searchfield)
|
|
|
|
# this is called by the Link Field
|
|
@frappe.whitelist()
|
|
def search_link(doctype, txt, query=None, filters=None, page_length=20, searchfield=None, reference_doctype=None, ignore_user_permissions=False):
|
|
search_widget(doctype, txt.strip(), query, searchfield=searchfield, page_length=page_length, filters=filters, reference_doctype=reference_doctype, ignore_user_permissions=ignore_user_permissions)
|
|
frappe.response['results'] = build_for_autosuggest(frappe.response["values"])
|
|
del frappe.response["values"]
|
|
|
|
# this is called by the search box
|
|
@frappe.whitelist()
|
|
def search_widget(doctype, txt, query=None, searchfield=None, start=0,
|
|
page_length=20, filters=None, filter_fields=None, as_dict=False, reference_doctype=None, ignore_user_permissions=False):
|
|
|
|
start = cint(start)
|
|
|
|
if isinstance(filters, str):
|
|
filters = json.loads(filters)
|
|
|
|
if searchfield:
|
|
sanitize_searchfield(searchfield)
|
|
|
|
if not searchfield:
|
|
searchfield = "name"
|
|
|
|
standard_queries = frappe.get_hooks().standard_queries or {}
|
|
|
|
if query and query.split()[0].lower()!="select":
|
|
# by method
|
|
try:
|
|
is_whitelisted(frappe.get_attr(query))
|
|
frappe.response["values"] = frappe.call(query, doctype, txt,
|
|
searchfield, start, page_length, filters, as_dict=as_dict)
|
|
except frappe.exceptions.PermissionError as e:
|
|
if frappe.local.conf.developer_mode:
|
|
raise e
|
|
else:
|
|
frappe.respond_as_web_page(title='Invalid Method', html='Method not found',
|
|
indicator_color='red', http_status_code=404)
|
|
return
|
|
except Exception as e:
|
|
raise e
|
|
elif not query and doctype in standard_queries:
|
|
# from standard queries
|
|
search_widget(doctype, txt, standard_queries[doctype][0],
|
|
searchfield, start, page_length, filters)
|
|
else:
|
|
meta = frappe.get_meta(doctype)
|
|
|
|
if query:
|
|
frappe.throw(_("This query style is discontinued"))
|
|
# custom query
|
|
# frappe.response["values"] = frappe.db.sql(scrub_custom_query(query, searchfield, txt))
|
|
else:
|
|
if isinstance(filters, dict):
|
|
filters_items = filters.items()
|
|
filters = []
|
|
for f in filters_items:
|
|
if isinstance(f[1], (list, tuple)):
|
|
filters.append([doctype, f[0], f[1][0], f[1][1]])
|
|
else:
|
|
filters.append([doctype, f[0], "=", f[1]])
|
|
|
|
if filters==None:
|
|
filters = []
|
|
or_filters = []
|
|
|
|
|
|
# build from doctype
|
|
if txt:
|
|
search_fields = ["name"]
|
|
if meta.title_field:
|
|
search_fields.append(meta.title_field)
|
|
|
|
if meta.search_fields:
|
|
search_fields.extend(meta.get_search_fields())
|
|
|
|
for f in search_fields:
|
|
fmeta = meta.get_field(f.strip())
|
|
if (doctype not in UNTRANSLATED_DOCTYPES) and (f == "name" or (fmeta and fmeta.fieldtype in ["Data", "Text", "Small Text", "Long Text",
|
|
"Link", "Select", "Read Only", "Text Editor"])):
|
|
or_filters.append([doctype, f.strip(), "like", "%{0}%".format(txt)])
|
|
|
|
if meta.get("fields", {"fieldname":"enabled", "fieldtype":"Check"}):
|
|
filters.append([doctype, "enabled", "=", 1])
|
|
if meta.get("fields", {"fieldname":"disabled", "fieldtype":"Check"}):
|
|
filters.append([doctype, "disabled", "!=", 1])
|
|
|
|
# format a list of fields combining search fields and filter fields
|
|
fields = get_std_fields_list(meta, searchfield or "name")
|
|
if filter_fields:
|
|
fields = list(set(fields + json.loads(filter_fields)))
|
|
formatted_fields = ['`tab%s`.`%s`' % (meta.name, f.strip()) for f in fields]
|
|
|
|
# find relevance as location of search term from the beginning of string `name`. used for sorting results.
|
|
formatted_fields.append("""locate({_txt}, `tab{doctype}`.`name`) as `_relevance`""".format(
|
|
_txt=frappe.db.escape((txt or "").replace("%", "").replace("@", "")), doctype=doctype))
|
|
|
|
|
|
# In order_by, `idx` gets second priority, because it stores link count
|
|
from frappe.model.db_query import get_order_by
|
|
order_by_based_on_meta = get_order_by(doctype, meta)
|
|
# 2 is the index of _relevance column
|
|
order_by = "_relevance, {0}, `tab{1}`.idx desc".format(order_by_based_on_meta, doctype)
|
|
|
|
ptype = 'select' if frappe.only_has_select_perm(doctype) else 'read'
|
|
ignore_permissions = True if doctype == "DocType" else (cint(ignore_user_permissions) and has_permission(doctype, ptype=ptype))
|
|
|
|
if doctype in UNTRANSLATED_DOCTYPES:
|
|
page_length = None
|
|
|
|
values = frappe.get_list(doctype,
|
|
filters=filters,
|
|
fields=formatted_fields,
|
|
or_filters=or_filters,
|
|
limit_start=start,
|
|
limit_page_length=page_length,
|
|
order_by=order_by,
|
|
ignore_permissions=ignore_permissions,
|
|
reference_doctype=reference_doctype,
|
|
as_list=not as_dict,
|
|
strict=False)
|
|
|
|
if doctype in UNTRANSLATED_DOCTYPES:
|
|
# Filtering the values array so that query is included in very element
|
|
values = (
|
|
v for v in values
|
|
if re.search(
|
|
f"{re.escape(txt)}.*", _(v.name if as_dict else v[0]), re.IGNORECASE
|
|
)
|
|
)
|
|
|
|
# Sorting the values array so that relevant results always come first
|
|
# This will first bring elements on top in which query is a prefix of element
|
|
# Then it will bring the rest of the elements and sort them in lexicographical order
|
|
values = sorted(values, key=lambda x: relevance_sorter(x, txt, as_dict))
|
|
|
|
# remove _relevance from results
|
|
if as_dict:
|
|
for r in values:
|
|
r.pop("_relevance")
|
|
frappe.response["values"] = values
|
|
else:
|
|
frappe.response["values"] = [r[:-1] for r in values]
|
|
|
|
def get_std_fields_list(meta, key):
|
|
# get additional search fields
|
|
sflist = ["name"]
|
|
if meta.search_fields:
|
|
for d in meta.search_fields.split(","):
|
|
if d.strip() not in sflist:
|
|
sflist.append(d.strip())
|
|
|
|
if meta.title_field and meta.title_field not in sflist:
|
|
sflist.append(meta.title_field)
|
|
|
|
if key not in sflist:
|
|
sflist.append(key)
|
|
|
|
return sflist
|
|
|
|
def build_for_autosuggest(res):
|
|
results = []
|
|
for r in res:
|
|
out = {"value": r[0], "description": ", ".join(unique(cstr(d) for d in r if d)[1:])}
|
|
results.append(out)
|
|
return results
|
|
|
|
def scrub_custom_query(query, key, txt):
|
|
if '%(key)s' in query:
|
|
query = query.replace('%(key)s', key)
|
|
if '%s' in query:
|
|
query = query.replace('%s', ((txt or '') + '%'))
|
|
return query
|
|
|
|
def relevance_sorter(key, query, as_dict):
|
|
value = _(key.name if as_dict else key[0])
|
|
return (
|
|
value.lower().startswith(query.lower()) is not True,
|
|
value
|
|
)
|
|
|
|
@wrapt.decorator
|
|
def validate_and_sanitize_search_inputs(fn, instance, args, kwargs):
|
|
kwargs.update(dict(zip(fn.__code__.co_varnames, args)))
|
|
sanitize_searchfield(kwargs['searchfield'])
|
|
kwargs['start'] = cint(kwargs['start'])
|
|
kwargs['page_len'] = cint(kwargs['page_len'])
|
|
|
|
if kwargs['doctype'] and not frappe.db.exists('DocType', kwargs['doctype']):
|
|
return []
|
|
|
|
return fn(**kwargs)
|
|
|
|
|
|
@frappe.whitelist()
|
|
def get_names_for_mentions(search_term):
|
|
users_for_mentions = frappe.cache().get_value('users_for_mentions', get_users_for_mentions)
|
|
user_groups = frappe.cache().get_value('user_groups', get_user_groups)
|
|
|
|
filtered_mentions = []
|
|
for mention_data in users_for_mentions + user_groups:
|
|
if search_term.lower() not in mention_data.value.lower():
|
|
continue
|
|
|
|
mention_data['link'] = frappe.utils.get_url_to_form(
|
|
'User Group' if mention_data.get('is_group') else 'User Profile',
|
|
mention_data['id']
|
|
)
|
|
|
|
filtered_mentions.append(mention_data)
|
|
|
|
return sorted(filtered_mentions, key=lambda d: d['value'])
|
|
|
|
def get_users_for_mentions():
|
|
return frappe.get_all('User',
|
|
fields=['name as id', 'full_name as value'],
|
|
filters={
|
|
'name': ['not in', ('Administrator', 'Guest')],
|
|
'allowed_in_mentions': True,
|
|
'user_type': 'System User',
|
|
'enabled': True,
|
|
})
|
|
|
|
def get_user_groups():
|
|
return frappe.get_all('User Group', fields=['name as id', 'name as value'], update={
|
|
'is_group': True
|
|
})
|