Merge branch 'develop' of https://github.com/frappe/frappe into toggle_full_width

This commit is contained in:
Deepesh Garg 2020-08-15 13:05:04 +05:30
commit 8b1d3a098a
55 changed files with 1509 additions and 785 deletions

View file

@ -763,7 +763,7 @@ def get_doc(*args, **kwargs):
# insert a new document
todo = frappe.get_doc({"doctype":"ToDo", "description": "test"})
tood.insert()
todo.insert()
# open an existing document
todo = frappe.get_doc("ToDo", "TD0001")

View file

@ -340,6 +340,11 @@ class CookieManager:
def set_cookie(self, key, value, expires=None, secure=False, httponly=False, samesite="Lax"):
if not secure and hasattr(frappe.local, 'request'):
secure = frappe.local.request.scheme == "https"
# Cordova does not work with Lax
if frappe.local.session.data.device == "mobile":
samesite = None
self.cookies[key] = {
"value": value,
"expires": expires,

View file

@ -274,8 +274,9 @@ def disable_user(context, email):
@click.command('migrate')
@click.option('--rebuild-website', help="Rebuild webpages after migration")
@click.option('--skip-failing', is_flag=True, help="Skip patches that fail to run")
@click.option('--skip-search-index', is_flag=True, help="Skip search indexing for web documents")
@pass_context
def migrate(context, rebuild_website=False, skip_failing=False):
def migrate(context, rebuild_website=False, skip_failing=False, skip_search_index=False):
"Run patches, sync schema and rebuild files/translations"
from frappe.migrate import migrate
@ -284,7 +285,12 @@ def migrate(context, rebuild_website=False, skip_failing=False):
frappe.init(site=site)
frappe.connect()
try:
migrate(context.verbose, rebuild_website=rebuild_website, skip_failing=skip_failing)
migrate(
context.verbose,
rebuild_website=rebuild_website,
skip_failing=skip_failing,
skip_search_index=skip_search_index
)
finally:
frappe.destroy()
if not context.sites:
@ -655,6 +661,22 @@ def start_ngrok(context):
frappe.destroy()
ngrok.kill()
@click.command('build-search-index')
@pass_context
def build_search_index(context):
from frappe.search.website_search import build_index_for_all_routes
site = get_site(context)
if not site:
raise SiteNotSpecifiedError
print('Building search index for {}'.format(site))
frappe.init(site=site)
frappe.connect()
try:
build_index_for_all_routes()
finally:
frappe.destroy()
commands = [
add_system_manager,
backup,
@ -680,5 +702,6 @@ commands = [
start_recording,
stop_recording,
add_to_hosts,
start_ngrok
start_ngrok,
build_search_index
]

View file

@ -3,17 +3,26 @@
# For license information, please see license.txt
from __future__ import unicode_literals
import frappe, json
import frappe
import json
from frappe.desk.doctype.bulk_update.bulk_update import show_progress
from frappe.model.document import Document
from frappe import _
class DeletedDocument(Document):
pass
@frappe.whitelist()
def restore(name):
def restore(name, alert=True):
deleted = frappe.get_doc('Deleted Document', name)
if deleted.restored:
frappe.throw(_("Document {0} Already Restored").format(name), exc=frappe.DocumentAlreadyRestored)
doc = frappe.get_doc(json.loads(deleted.data))
try:
doc.insert()
except frappe.DocstatusTransitionError:
@ -27,4 +36,34 @@ def restore(name):
deleted.restored = 1
deleted.db_update()
frappe.msgprint(_('Document Restored'))
if alert:
frappe.msgprint(_('Document Restored'))
@frappe.whitelist()
def bulk_restore(docnames):
docnames = frappe.parse_json(docnames)
message = _('Restoring Deleted Document')
restored, invalid, failed = [], [], []
for i, d in enumerate(docnames):
try:
show_progress(docnames, message, i + 1, d)
restore(d, alert=False)
frappe.db.commit()
restored.append(d)
except frappe.DocumentAlreadyRestored:
frappe.message_log.pop()
invalid.append(d)
except Exception:
frappe.message_log.pop()
failed.append(d)
frappe.db.rollback()
return {
"restored": restored,
"invalid": invalid,
"failed": failed
}

View file

@ -0,0 +1,40 @@
frappe.listview_settings["Deleted Document"] = {
onload: function (doclist) {
const action = () => {
const selected_docs = doclist.get_checked_items();
if (selected_docs.length > 0) {
let docnames = selected_docs.map(doc => doc.name);
frappe.call({
method: "frappe.core.doctype.deleted_document.deleted_document.bulk_restore",
args: { docnames },
callback: function (r) {
if (r.message) {
function body(docnames) {
const html = docnames.map(docname => {
return `<li><a href='/desk#Form/Deleted Document/${docname}'>${docname}</a></li>`;
});
return "<br><ul>" + html.join("");
}
function message(title, docnames) {
return (docnames.length > 0) ? title + body(docnames) + "</ul>": "";
}
const { restored, invalid, failed } = r.message;
const restored_summary = message(__("Documents restored successfully"), restored);
const invalid_summary = message(__("Documents that were already restored"), invalid);
const failed_summary = message(__("Documents that failed to restore"), failed);
const summary = restored_summary + invalid_summary + failed_summary;
frappe.msgprint(summary, __("Document Restoration Summary"), true);
if (restored.length > 0) {
doclist.refresh();
}
}
},
});
}
};
doclist.page.add_actions_menu_item(__("Restore"), action, false);
},
};

View file

@ -70,6 +70,7 @@
"web_view",
"has_web_view",
"allow_guest_to_view",
"index_web_pages_for_search",
"route",
"is_published_field",
"advanced",
@ -517,12 +518,18 @@
"fieldname": "email_settings_sb",
"fieldtype": "Section Break",
"label": "Email Settings"
},
{
"default": "1",
"fieldname": "index_web_pages_for_search",
"fieldtype": "Check",
"label": "Index Web Pages for Search"
}
],
"icon": "fa fa-bolt",
"idx": 6,
"links": [],
"modified": "2020-03-27 14:51:44.581128",
"modified": "2020-07-21 16:20:57.028802",
"modified_by": "Administrator",
"module": "Core",
"name": "DocType",

View file

@ -376,3 +376,96 @@ class TestDocType(unittest.TestCase):
link_doc.delete()
doc.delete()
frappe.db.commit()
def test_ignore_cancelation_of_linked_doctype_during_cancell(self):
import json
from frappe.desk.form.linked_with import get_submitted_linked_docs, cancel_all_linked_docs
#create linked doctype
link_doc = self.new_doctype('Test Linked Doctype 1')
link_doc.is_submittable = 1
for data in link_doc.get('permissions'):
data.submit = 1
data.cancel = 1
link_doc.insert()
#create first parent doctype
test_doc_1 = self.new_doctype('Test Doctype 1')
test_doc_1.is_submittable = 1
field_2 = test_doc_1.append('fields', {})
field_2.label = 'Test Linked Doctype 1'
field_2.fieldname = 'test_linked_doctype_a'
field_2.fieldtype = 'Link'
field_2.options = 'Test Linked Doctype 1'
for data in test_doc_1.get('permissions'):
data.submit = 1
data.cancel = 1
test_doc_1.insert()
#crete second parent doctype
doc = self.new_doctype('Test Doctype 2')
doc.is_submittable = 1
field_2 = doc.append('fields', {})
field_2.label = 'Test Linked Doctype 1'
field_2.fieldname = 'test_linked_doctype_a'
field_2.fieldtype = 'Link'
field_2.options = 'Test Linked Doctype 1'
for data in link_doc.get('permissions'):
data.submit = 1
data.cancel = 1
doc.insert()
# create doctype data
data_link_doc_1 = frappe.new_doc('Test Linked Doctype 1')
data_link_doc_1.some_fieldname = 'Data1'
data_link_doc_1.insert()
data_link_doc_1.save()
data_link_doc_1.submit()
data_doc_2 = frappe.new_doc('Test Doctype 1')
data_doc_2.some_fieldname = 'Data1'
data_doc_2.test_linked_doctype_a = data_link_doc_1.name
data_doc_2.insert()
data_doc_2.save()
data_doc_2.submit()
data_doc = frappe.new_doc('Test Doctype 2')
data_doc.some_fieldname = 'Data1'
data_doc.test_linked_doctype_a = data_link_doc_1.name
data_doc.insert()
data_doc.save()
data_doc.submit()
docs = get_submitted_linked_docs(link_doc.name, data_link_doc_1.name)
dump_docs = json.dumps(docs.get('docs'))
cancel_all_linked_docs(dump_docs, ignore_doctypes_on_cancel_all=["Test Doctype 2"])
# checking that doc for Test Doctype 2 is not canceled
self.assertRaises(frappe.LinkExistsError, data_link_doc_1.cancel)
data_doc.load_from_db()
data_doc_2.load_from_db()
self.assertEqual(data_link_doc_1.docstatus, 2)
#linked doc is canceled
self.assertEqual(data_doc_2.docstatus, 2)
#ignored doctype 2 during cancel
self.assertEqual(data_doc.docstatus, 1)
# delete doctype record
data_doc.cancel()
data_doc.delete()
data_doc_2.delete()
data_link_doc_1.delete()
# delete doctype
link_doc.delete()
doc.delete()
test_doc_1.delete()
frappe.db.commit()

View file

@ -33,16 +33,22 @@ class Role(Document):
if user_type != user.user_type:
user.save()
# Get email addresses of all users that have been assigned this role
def get_emails_from_role(role):
emails = []
for user in get_users(role):
user_email, enabled = frappe.db.get_value("User", user, ["email", "enabled"])
if enabled and user_email not in ["admin@example.com", "guest@example.com"]:
emails.append(user_email)
def get_info_based_on_role(role, field='email'):
''' Get information of all users that have been assigned this role '''
users = frappe.get_list("Has Role", filters={"role": role, "parenttype": "User"},
fields=["parent"])
return emails
return get_user_info(users, field)
def get_user_info(users, field='email'):
''' Fetch details about users for the specified field '''
info_list = []
for user in users:
user_info, enabled = frappe.db.get_value("User", user.parent, [field, "enabled"])
if enabled and user_info not in ["admin@example.com", "guest@example.com"]:
info_list.append(user_info)
return info_list
def get_users(role):
return [d.parent for d in frappe.get_all("Has Role", filters={"role": role, "parenttype": "User"},

View file

@ -18,6 +18,9 @@ class SMSSettings(Document):
def validate_receiver_nos(receiver_list):
validated_receiver_list = []
for d in receiver_list:
if not d:
break
# remove invalid character
for x in [' ','-', '(', ')']:
d = d.replace(x, '')

View file

@ -11,7 +11,6 @@ from frappe import _
from frappe.model.meta import is_single
from frappe.modules import load_doctype_module
@frappe.whitelist()
def get_submitted_linked_docs(doctype, name, docs=None, visited=None):
"""
@ -78,7 +77,7 @@ def get_submitted_linked_docs(doctype, name, docs=None, visited=None):
@frappe.whitelist()
def cancel_all_linked_docs(docs):
def cancel_all_linked_docs(docs, ignore_doctypes_on_cancel_all=[]):
"""
Cancel all linked doctype
@ -87,14 +86,16 @@ def cancel_all_linked_docs(docs):
"""
docs = json.loads(docs)
if isinstance(ignore_doctypes_on_cancel_all, string_types):
ignore_doctypes_on_cancel_all = json.loads(ignore_doctypes_on_cancel_all)
for i, doc in enumerate(docs, 1):
if validate_linked_doc(doc) is True:
frappe.publish_progress(percent=i * 100 / len(docs), title=_("Cancelling documents"))
if validate_linked_doc(doc, ignore_doctypes_on_cancel_all) is True:
frappe.publish_progress(percent=i * 100 / ((len(docs) - len(ignore_doctypes_on_cancel_all))), title=_("Cancelling documents"))
linked_doc = frappe.get_doc(doc.get("doctype"), doc.get("name"))
linked_doc.cancel()
def validate_linked_doc(docinfo):
def validate_linked_doc(docinfo, ignore_doctypes_on_cancel_all=[]):
"""
Validate a document to be submitted and non-exempted from auto-cancel.
@ -105,6 +106,10 @@ def validate_linked_doc(docinfo):
bool: True if linked document passes all validations, else False
"""
#ignore doctype to cancel
if docinfo.get("doctype") in ignore_doctypes_on_cancel_all:
return False
# skip non-submittable doctypes since they don't need to be cancelled
if not frappe.get_meta(docinfo.get('doctype')).is_submittable:
return False

View file

@ -132,10 +132,11 @@
"has_web_view": 1,
"icon": "fa fa-envelope",
"idx": 1,
"index_web_pages_for_search": 1,
"is_published_field": "published",
"links": [],
"max_attachments": 3,
"modified": "2020-05-12 18:09:40.137138",
"modified": "2020-07-21 16:25:17.687476",
"modified_by": "Administrator",
"module": "Email",
"name": "Newsletter",

View file

@ -3,104 +3,175 @@
this.frm.add_fetch('sender', 'email_id', 'sender_email');
this.frm.fields_dict.sender.get_query = function(){
this.frm.fields_dict.sender.get_query = function() {
return {
filters: {
'enable_outgoing': 1
enable_outgoing: 1
}
}
};
};
frappe.notification = {
setup_fieldname_select: function(frm) {
// get the doctype to update fields
if(!frm.doc.document_type) {
if (!frm.doc.document_type) {
return;
}
frappe.model.with_doctype(frm.doc.document_type, function() {
let get_select_options = function(df) {
return {value: df.fieldname, label: df.fieldname + " (" + __(df.label) + ")"};
}
return {
value: df.fieldname,
label: df.fieldname + ' (' + __(df.label) + ')'
};
};
let get_date_change_options = function() {
let date_options = $.map(fields, function(d) {
return (d.fieldtype=="Date" || d.fieldtype=="Datetime")?
get_select_options(d) : null;
return d.fieldtype == 'Date' || d.fieldtype == 'Datetime'
? get_select_options(d)
: null;
});
// append creation and modified date to Date Change field
return date_options.concat([
{ value: "creation", label: `creation (${__('Created On')})` },
{ value: "modified", label: `modified (${__('Last Modified Date')})` }
{ value: 'creation', label: `creation (${__('Created On')})` },
{ value: 'modified', label: `modified (${__('Last Modified Date')})` }
]);
}
};
let fields = frappe.get_doc("DocType", frm.doc.document_type).fields;
let options = $.map(fields,
function(d) { return in_list(frappe.model.no_value_type, d.fieldtype) ?
null : get_select_options(d); });
let fields = frappe.get_doc('DocType', frm.doc.document_type).fields;
let options = $.map(fields, function(d) {
return in_list(frappe.model.no_value_type, d.fieldtype)
? null : get_select_options(d);
});
// set value changed options
frm.set_df_property("value_changed", "options", [""].concat(options));
frm.set_df_property("set_property_after_alert", "options", [""].concat(options));
frm.set_df_property('value_changed', 'options', [''].concat(options));
frm.set_df_property(
'set_property_after_alert',
'options',
[''].concat(options)
);
// set date changed options
frm.set_df_property("date_changed", "options", get_date_change_options());
frm.set_df_property('date_changed', 'options', get_date_change_options());
let email_fields = $.map(fields,
function(d) { return (d.options == "Email" ||
(d.options=='User' && d.fieldtype=='Link')) ?
get_select_options(d) : null; });
let receiver_fields = [];
if (frm.doc.channel === 'Email') {
receiver_fields = $.map(fields, function(d) {
return d.options == 'Email' ||
(d.options == 'User' && d.fieldtype == 'Link')
? get_select_options(d) : null;
});
} else if (in_list(['WhatsApp', 'SMS'], frm.doc.channel)) {
receiver_fields = $.map(fields, function(d) {
return d.options == 'Phone' ? get_select_options(d) : null;
});
}
// set email recipient options
frappe.meta.get_docfield("Notification Recipient", "email_by_document_field",
frappe.meta.get_docfield(
'Notification Recipient',
'receiver_by_document_field',
// set first option as blank to allow notification not to be defaulted to the owner
frm.doc.name).options = [""].concat(["owner"].concat(email_fields));
frm.doc.name
).options = [''].concat(["owner"]).concat(receiver_fields);
frm.fields_dict.recipients.grid.refresh();
});
}
}
},
setup_example_message: function(frm) {
let template = '';
if (frm.doc.channel === 'WhatsApp') {
template = `<h5 style='display: inline-block'>Warning:</h5> Only Use Pre-Approved WhatsApp for Business Template
<h5>Message Example</h5>
frappe.ui.form.on("Notification", {
<pre>
Your {{ doc.name }} order of {{ doc.total }} has shipped and should be delivered on {{ doc.date }}. Details : {{doc.customer}}
</pre>`;
} else if (frm.doc.channel === 'Email') {
template = `<h5>Message Example</h5>
<pre>&lt;h3&gt;Order Overdue&lt;/h3&gt;
&lt;p&gt;Transaction {{ doc.name }} has exceeded Due Date. Please take necessary action.&lt;/p&gt;
&lt;!-- show last comment --&gt;
{% if comments %}
Last comment: {{ comments[-1].comment }} by {{ comments[-1].by }}
{% endif %}
&lt;h4&gt;Details&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Customer: {{ doc.customer }}
&lt;li&gt;Amount: {{ doc.grand_total }}
&lt;/ul&gt;
</pre>
`;
} else {
template = `<h5>Message Example</h5>
<pre>*Order Overdue*
Transaction {{ doc.name }} has exceeded Due Date. Please take necessary action.
<!-- show last comment -->
{% if comments %}
Last comment: {{ comments[-1].comment }} by {{ comments[-1].by }}
{% endif %}
*Details*
Customer: {{ doc.customer }}
Amount: {{ doc.grand_total }}
</pre>`;
}
frm.set_df_property('message_examples', 'options', template);
}
};
frappe.ui.form.on('Notification', {
onload: function(frm) {
frm.set_query("document_type", function() {
frm.set_query('document_type', function() {
return {
"filters": {
"istable": 0
filters: {
istable: 0
}
}
};
});
frm.set_query("print_format", function() {
frm.set_query('print_format', function() {
return {
"filters": {
"doc_type": frm.doc.document_type
filters: {
doc_type: frm.doc.document_type
}
}
};
});
},
refresh: function(frm) {
frappe.notification.setup_fieldname_select(frm);
frm.get_field("is_standard").toggle(frappe.boot.developer_mode);
frm.get_field('is_standard').toggle(frappe.boot.developer_mode);
frm.trigger('event');
},
document_type: function(frm) {
frappe.notification.setup_fieldname_select(frm);
},
view_properties: function(frm) {
frappe.route_options = {doc_type:frm.doc.document_type};
frappe.set_route("Form", "Customize Form");
frappe.route_options = { doc_type: frm.doc.document_type };
frappe.set_route('Form', 'Customize Form');
},
event: function(frm) {
if(in_list(['Days Before', 'Days After'], frm.doc.event)) {
if (in_list(['Days Before', 'Days After'], frm.doc.event)) {
frm.add_custom_button(__('Get Alerts for Today'), function() {
frappe.call({
method: 'frappe.email.doctype.notification.notification.get_documents_for_today',
method:
'frappe.email.doctype.notification.notification.get_documents_for_today',
args: {
notification: frm.doc.name
},
callback: function(r) {
if(r.message) {
if (r.message) {
frappe.msgprint(r.message);
} else {
frappe.msgprint(__('No alerts for today'));
@ -111,6 +182,14 @@ frappe.ui.form.on("Notification", {
}
},
channel: function(frm) {
frm.toggle_reqd("recipients", frm.doc.channel=="Email");
frm.toggle_reqd('recipients', frm.doc.channel == 'Email');
frappe.notification.setup_fieldname_select(frm);
frappe.notification.setup_example_message(frm);
if (frm.doc.channel === 'SMS' && frm.doc.__islocal) {
frm.set_df_property('channel',
'description', `To use SMS Channel, initialize <a href="#Form/SMS Settings">SMS Settings</a>.`);
} else {
frm.set_df_property('channel', 'description', ` `);
}
}
});

View file

@ -10,6 +10,7 @@
"enabled",
"column_break_2",
"channel",
"twilio_number",
"slack_webhook_url",
"filters",
"subject",
@ -37,7 +38,6 @@
"message_sb",
"message",
"message_examples",
"slack_message_examples",
"view_properties",
"column_break_25",
"attach_print",
@ -60,11 +60,13 @@
"fieldname": "channel",
"fieldtype": "Select",
"label": "Channel",
"options": "Email\nSlack\nSystem Notification",
"reqd": 1
"options": "Email\nSlack\nSystem Notification\nWhatsApp\nSMS",
"reqd": 1,
"set_only_once": 1
},
{
"depends_on": "eval:doc.channel=='Slack'",
"description": "To use Slack Channel, add a <a href=\"\\#Form/Slack Webhook URL\">Slack Webhook URL</a>.",
"fieldname": "slack_webhook_url",
"fieldtype": "Link",
"label": "Slack Channel",
@ -77,13 +79,14 @@
"label": "Filters"
},
{
"depends_on": "eval: !in_list(['SMS', 'WhatsApp'], doc.channel)",
"description": "To add dynamic subject, use jinja tags like\n\n<div><pre><code>{{ doc.name }} Delivered</code></pre></div>",
"fieldname": "subject",
"fieldtype": "Data",
"ignore_xss_filter": 1,
"in_list_view": 1,
"label": "Subject",
"reqd": 1
"mandatory_depends_on": "eval:!in_list(['SMS', 'WhatsApp'], doc.channel)"
},
{
"fieldname": "document_type",
@ -153,6 +156,7 @@
"label": "Value Changed"
},
{
"depends_on": "eval: doc.channel == 'Email'",
"fieldname": "sender",
"fieldtype": "Link",
"label": "Sender",
@ -203,7 +207,7 @@
"label": "Value To Be Set"
},
{
"depends_on": "eval:doc.channel!=='Slack'",
"depends_on": "eval:in_list(['Email', 'SMS', 'WhatsApp'], doc.channel)",
"fieldname": "column_break_5",
"fieldtype": "Section Break",
"label": "Recipients"
@ -228,19 +232,11 @@
"label": "Message"
},
{
"depends_on": "eval:doc.channel=='Email'",
"fieldname": "message_examples",
"fieldtype": "HTML",
"label": "Message Examples",
"options": "<h5>Message Example</h5>\n\n<pre>&lt;h3&gt;Order Overdue&lt;/h3&gt;\n\n&lt;p&gt;Transaction {{ doc.name }} has exceeded Due Date. Please take necessary action.&lt;/p&gt;\n\n&lt;!-- show last comment --&gt;\n{% if comments %}\nLast comment: {{ comments[-1].comment }} by {{ comments[-1].by }}\n{% endif %}\n\n&lt;h4&gt;Details&lt;/h4&gt;\n\n&lt;ul&gt;\n&lt;li&gt;Customer: {{ doc.customer }}\n&lt;li&gt;Amount: {{ doc.grand_total }}\n&lt;/ul&gt;\n</pre>"
},
{
"depends_on": "eval:doc.channel=='Slack'",
"fieldname": "slack_message_examples",
"fieldtype": "HTML",
"label": "Message Examples",
"options": "<h5>Message Example</h5>\n\n<pre>*Order Overdue*\n\nTransaction {{ doc.name }} has exceeded Due Date. Please take necessary action.\n\n<!-- show last comment -->\n{% if comments %}\nLast comment: {{ comments[-1].comment }} by {{ comments[-1].by }}\n{% endif %}\n\n*Details*\n\n\u2022 Customer: {{ doc.customer }}\n\u2022 Amount: {{ doc.grand_total }}\n</pre>"
},
{
"fieldname": "view_properties",
"fieldtype": "Button",
@ -266,6 +262,14 @@
"label": "Print Format",
"options": "Print Format"
},
{
"depends_on": "eval: doc.channel==='WhatsApp'",
"description": "To use WhatsApp for Business, initialize <a href=\"#Form/Twilio Settings\">Twilio Settings</a>.",
"fieldname": "twilio_number",
"fieldtype": "Link",
"label": "Twilio Number",
"options": "Twilio Number Group"
},
{
"default": "0",
"depends_on": "eval: doc.channel !== 'System Notification'",
@ -277,7 +281,7 @@
],
"icon": "fa fa-envelope",
"links": [],
"modified": "2020-06-23 14:01:25.462544",
"modified": "2020-08-11 19:24:35.479373",
"modified_by": "Administrator",
"module": "Email",
"name": "Notification",

View file

@ -7,12 +7,14 @@ import frappe
import json, os
from frappe import _
from frappe.model.document import Document
from frappe.core.doctype.role.role import get_emails_from_role
from frappe.core.doctype.role.role import get_info_based_on_role, get_user_info
from frappe.utils import validate_email_address, nowdate, parse_val, is_html, add_to_date
from frappe.utils.jinja import validate_template
from frappe.modules.utils import export_module_json, get_doc_module
from six import string_types
from frappe.integrations.doctype.slack_webhook_url.slack_webhook_url import send_slack_message
from frappe.integrations.doctype.twilio_settings.twilio_settings import send_whatsapp_message
from frappe.core.doctype.sms_settings.sms_settings import send_sms
from frappe.desk.doctype.notification_log.notification_log import enqueue_create_notification
class Notification(Document):
@ -26,7 +28,9 @@ class Notification(Document):
self.name = self.subject
def validate(self):
validate_template(self.subject)
if self.channel not in ('WhatsApp', 'SMS'):
validate_template(self.subject)
validate_template(self.message)
if self.event in ("Days Before", "Days After") and not self.date_changed:
@ -126,8 +130,15 @@ def get_context(context):
if self.channel == 'Slack':
self.send_a_slack_msg(doc, context)
if self.channel == 'WhatsApp':
self.send_whatsapp_msg(doc, context)
if self.channel == 'SMS':
self.send_sms(doc, context)
if self.channel == 'System Notification' or self.send_system_notification:
self.create_system_notification(doc, context)
except:
frappe.log_error(title='Failed to send notification', message=frappe.get_traceback())
@ -195,11 +206,24 @@ def get_context(context):
and attachments[0].get('print_letterhead')) or False))
def send_a_slack_msg(self, doc, context):
send_slack_message(
webhook_url=self.slack_webhook_url,
message=frappe.render_template(self.message, context),
reference_doctype = doc.doctype,
reference_name = doc.name)
send_slack_message(
webhook_url=self.slack_webhook_url,
message=frappe.render_template(self.message, context),
reference_doctype=doc.doctype,
reference_name=doc.name)
def send_whatsapp_msg(self, doc, context):
send_whatsapp_message(
sender=self.twilio_number,
receiver_list=self.get_receiver_list(doc, context),
message=frappe.render_template(self.message, context),
)
def send_sms(self, doc, context):
send_sms(
receiver_list=self.get_receiver_list(doc, context),
msg=frappe.render_template(self.message, context)
)
def get_list_of_recipients(self, doc, context):
recipients = []
@ -209,8 +233,8 @@ def get_context(context):
if recipient.condition:
if not frappe.safe_eval(recipient.condition, None, context):
continue
if recipient.email_by_document_field:
email_ids_value = doc.get(recipient.email_by_document_field)
if recipient.receiver_by_document_field:
email_ids_value = doc.get(recipient.receiver_by_document_field)
if validate_email_address(email_ids_value):
email_ids = email_ids_value.replace(",", "\n")
recipients = recipients + email_ids.split("\n")
@ -232,8 +256,8 @@ def get_context(context):
bcc = bcc + recipient.bcc.split("\n")
#For sending emails to specified role
if recipient.email_by_role:
emails = get_emails_from_role(recipient.email_by_role)
if recipient.receiver_by_role:
emails = get_info_based_on_role(recipient.receiver_by_role, 'email')
for email in emails:
recipients = recipients + email.split("\n")
@ -242,6 +266,27 @@ def get_context(context):
return None, None, None
return list(set(recipients)), list(set(cc)), list(set(bcc))
def get_receiver_list(self, doc, context):
''' return receiver list based on the doc field and role specified '''
receiver_list = []
for recipient in self.recipients:
if recipient.condition:
if not frappe.safe_eval(recipient.condition, None, context):
continue
# For sending messages to the owner's mobile phone number
if recipient.receiver_by_document_field == 'owner':
receiver_list.append(get_user_info(doc.get('owner'), 'mobile_no'))
# For sending messages to the number specified in the receiver field
elif recipient.receiver_by_document_field:
receiver_list.append(doc.get(recipient.receiver_by_document_field))
#For sending messages to specified role
if recipient.receiver_by_role:
receiver_list += get_info_based_on_role(recipient.receiver_by_role, 'mobile_no')
return receiver_list
def get_attachment(self, doc):
""" check print settings are attach the pdf """
if not self.attach_print:

View file

@ -63,7 +63,7 @@ class TestNotification(unittest.TestCase):
notification.message = "test"
recipent = frappe.new_doc("Notification Recipient")
recipent.email_by_document_field = "owner"
recipent.receiver_by_document_field = "owner"
notification.recipents = recipent
notification.condition = "test"
@ -105,7 +105,7 @@ class TestNotification(unittest.TestCase):
"value_changed": "description1",
"message": "Description changed",
"recipients": [
{ "email_by_document_field": "owner" }
{ "receiver_by_document_field": "owner" }
]
}).insert()
frappe.db.commit()

View file

@ -8,7 +8,7 @@
"message": "New comment {{ doc.content }} created",
"condition": "doc.communication_type=='Comment'",
"recipients": [
{ "email_by_document_field": "owner" }
{ "receiver_by_document_field": "owner" }
]
},
{
@ -20,7 +20,7 @@
"message": "New comment {{ doc.content }} saved",
"condition": "doc.communication_type=='Comment'",
"recipients": [
{ "email_by_document_field": "owner" }
{ "receiver_by_document_field": "owner" }
],
"set_property_after_alert": "subject",
"property_value": "__testing__"
@ -34,7 +34,7 @@
"condition": "doc.event_type=='Public'",
"message": "A new public event {{ doc.subject }} on {{ doc.starts_on }} is created",
"recipients": [
{ "email_by_document_field": "owner" }
{ "receiver_by_document_field": "owner" }
]
},
{
@ -46,7 +46,7 @@
"value_changed": "description",
"message": "Description changed",
"recipients": [
{ "email_by_document_field": "owner" }
{ "receiver_by_document_field": "owner" }
]
},
{
@ -59,7 +59,7 @@
"days_in_advance": 2,
"message": "Description changed",
"recipients": [
{ "email_by_document_field": "owner" }
{ "receiver_by_document_field": "owner" }
]
},
{
@ -70,7 +70,7 @@
"attach_print": 0,
"message": "New user {{ doc.name }} created",
"recipients": [
{ "email_by_document_field": "owner", "cc": "{{ doc.email }}" }
{ "receiver_by_document_field": "owner", "cc": "{{ doc.email }}" }
]
}
]

View file

@ -1,204 +1,60 @@
{
"allow_copy": 0,
"allow_guest_to_view": 0,
"allow_import": 0,
"allow_rename": 0,
"beta": 0,
"creation": "2014-07-11 17:19:37.037109",
"custom": 0,
"docstatus": 0,
"doctype": "DocType",
"document_type": "",
"editable_grid": 1,
"engine": "InnoDB",
"actions": [],
"creation": "2014-07-11 17:19:37.037109",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"receiver_by_document_field",
"receiver_by_role",
"cc",
"bcc",
"condition"
],
"fields": [
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"description": "",
"fieldname": "email_by_document_field",
"fieldtype": "Select",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Email By Document Field",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
"depends_on": "eval:parent.channel=='Email'",
"description": "Optional: Always send to these ids. Each Email Address on a new row",
"fieldname": "cc",
"fieldtype": "Code",
"label": "CC"
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "email_by_role",
"fieldtype": "Link",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Email By Role",
"length": 0,
"no_copy": 0,
"options": "Role",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
"depends_on": "eval:parent.channel=='Email'",
"fieldname": "bcc",
"fieldtype": "Code",
"label": "BCC"
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"description": "Optional: Always send to these ids. Each Email Address on a new row",
"fieldname": "cc",
"fieldtype": "Code",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "CC",
"length": 0,
"no_copy": 0,
"options": "",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
"description": "Expression, Optional",
"fieldname": "condition",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Condition"
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "bcc",
"fieldtype": "Code",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "BCC",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
"fieldname": "receiver_by_document_field",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Receiver By Document Field"
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"description": "Expression, Optional",
"fieldname": "condition",
"fieldtype": "Data",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Condition",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
"fieldname": "receiver_by_role",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Receiver By Role",
"options": "Role"
}
],
"has_web_view": 0,
"hide_heading": 0,
"hide_toolbar": 0,
"idx": 0,
"image_view": 0,
"in_create": 0,
"is_submittable": 0,
"issingle": 0,
"istable": 1,
"max_attachments": 0,
"modified": "2018-09-03 18:37:57.043251",
"modified_by": "Administrator",
"module": "Email",
"name": "Notification Recipient",
"name_case": "",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"read_only": 0,
"read_only_onload": 0,
"show_name_in_global_search": 0,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 0,
"track_seen": 0,
"track_views": 0
],
"istable": 1,
"links": [],
"modified": "2020-02-21 11:18:40.125233",
"modified_by": "Administrator",
"module": "Email",
"name": "Notification Recipient",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC"
}

View file

@ -104,6 +104,7 @@ class IncompatibleApp(ValidationError): pass
class InvalidDates(ValidationError): pass
class DataTooLongException(ValidationError): pass
class FileAlreadyAttachedException(Exception): pass
class DocumentAlreadyRestored(Exception): pass
# OAuth exceptions
class InvalidAuthorizationHeader(CSRFTokenError): pass
class InvalidAuthorizationPrefix(CSRFTokenError): pass

View file

@ -272,9 +272,6 @@ setup_wizard_exception = [
]
before_migrate = ['frappe.patches.v11_0.sync_user_permission_doctype_before_migrate.execute']
after_migrate = [
'frappe.modules.full_text_search.build_index_for_all_routes'
]
otp_methods = ['OTP App','Email','SMS']
user_privacy_documents = [

View file

@ -0,0 +1,32 @@
{
"actions": [],
"autoname": "field:phone_number",
"creation": "2020-02-24 13:58:58.036914",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"phone_number"
],
"fields": [
{
"fieldname": "phone_number",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Phone Number",
"unique": 1
}
],
"istable": 1,
"links": [],
"modified": "2020-03-02 14:54:34.396254",
"modified_by": "Administrator",
"module": "Integrations",
"name": "Twilio Number Group",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View file

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and contributors
# For license information, please see license.txt
from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
class TwilioNumberGroup(Document):
pass

View file

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and Contributors
# See license.txt
from __future__ import unicode_literals
# import frappe
import unittest
class TestTwilioSettings(unittest.TestCase):
pass

View file

@ -0,0 +1,8 @@
// Copyright (c) 2020, Frappe Technologies and contributors
// For license information, please see license.txt
frappe.ui.form.on('Twilio Settings', {
refresh: function(frm) {
frm.dashboard.set_headline(__("For more information, {0}.", [`<a href='https://docs.erpnext.com/docs/user/manual/en/setting-up/notifications'>${__('Click here')}</a>`]));
}
});

View file

@ -0,0 +1,57 @@
{
"actions": [],
"creation": "2020-01-28 15:21:44.457163",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"account_sid",
"auth_token",
"column_break_2",
"twilio_number"
],
"fields": [
{
"fieldname": "account_sid",
"fieldtype": "Data",
"label": "Account SID"
},
{
"fieldname": "auth_token",
"fieldtype": "Password",
"label": "Auth Token"
},
{
"fieldname": "column_break_2",
"fieldtype": "Column Break"
},
{
"fieldname": "twilio_number",
"fieldtype": "Table",
"label": "Twilio Number",
"options": "Twilio Number Group"
}
],
"issingle": 1,
"links": [],
"modified": "2020-08-11 15:28:57.860554",
"modified_by": "Administrator",
"module": "Integrations",
"name": "Twilio Settings",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View file

@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and contributors
# For license information, please see license.txt
from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
from twilio.rest import Client
from frappe import _
from frappe.utils.password import get_decrypted_password
from six import string_types
class TwilioSettings(Document):
pass
def send_whatsapp_message(sender, receiver_list, message):
import json
if isinstance(receiver_list, string_types):
receiver_list = json.loads(receiver_list)
if not isinstance(receiver_list, list):
receiver_list = [receiver_list]
twilio_settings = frappe.get_doc("Twilio Settings")
auth_token = get_decrypted_password("Twilio Settings", "Twilio Settings", 'auth_token')
client = Client(twilio_settings.account_sid, auth_token)
args = {
"from_": 'whatsapp:+{}'.format(sender),
"body": message
}
failed_delivery = []
for rec in receiver_list:
args.update({"to": 'whatsapp:{}'.format(rec)})
resp = _send_whatsapp(args, client)
if not resp or resp.error_message:
failed_delivery.append(rec)
if failed_delivery:
frappe.log_error(_("The message wasn't correctly delivered to: {}".format(", ".join(failed_delivery))), _('Delivery Failed'))
def _send_whatsapp(message_dict, client):
response = frappe._dict()
try:
response = client.messages.create(**message_dict)
except Exception as e:
frappe.log_error(e, title = _('Twilio WhatsApp Message Error'))
return response

View file

@ -12,8 +12,10 @@ from frappe.utils import split_emails, get_backups_path
def send_email(success, service_name, doctype, email_field, error_status=None):
recipients = get_recipients(doctype, email_field)
if not recipients:
frappe.log_error("No Email Recipient found for {0}".format(service_name),
"{0}: Failed to send backup status email".format(service_name))
frappe.log_error(
"No Email Recipient found for {0}".format(service_name),
"{0}: Failed to send backup status email".format(service_name),
)
return
if success:
@ -23,7 +25,9 @@ def send_email(success, service_name, doctype, email_field, error_status=None):
subject = "Backup Upload Successful"
message = """
<h3>Backup Uploaded Successfully!</h3>
<p>Hi there, this is just to inform you that your backup was successfully uploaded to your {0} bucket. So relax!</p>""".format(service_name)
<p>Hi there, this is just to inform you that your backup was successfully uploaded to your {0} bucket. So relax!</p>""".format(
service_name
)
else:
subject = "[Warning] Backup Upload Failed"
@ -31,7 +35,9 @@ def send_email(success, service_name, doctype, email_field, error_status=None):
<h3>Backup Upload Failed!</h3>
<p>Oops, your automated backup to {0} failed.</p>
<p>Error message: {1}</p>
<p>Please contact your system manager for more information.</p>""".format(service_name, error_status)
<p>Please contact your system manager for more information.</p>""".format(
service_name, error_status
)
frappe.sendmail(recipients=recipients, subject=subject, message=message)
@ -44,29 +50,31 @@ def get_recipients(doctype, email_field):
def get_latest_backup_file(with_files=False):
from frappe.utils.backups import BackupGenerator
def get_latest(file_ext):
file_list = glob.glob(os.path.join(get_backups_path(), file_ext))
return max(file_list, key=os.path.getctime) if file_list else None
latest_file = get_latest('*.sql.gz')
latest_site_config = get_latest('*.json')
odb = BackupGenerator(
frappe.conf.db_name,
frappe.conf.db_name,
frappe.conf.db_password,
db_host=frappe.db.host,
db_type=frappe.conf.db_type,
db_port=frappe.conf.db_port,
)
database, public, private, config = odb.get_recent_backup(older_than=24 * 30)
if with_files:
latest_public_file_bak = get_latest('*-files.tar')
latest_private_file_bak = get_latest('*-private-files.tar')
return latest_file, latest_site_config, latest_public_file_bak, latest_private_file_bak
return database, config, public, private
return latest_file, latest_site_config
return database, config
def get_file_size(file_path, unit):
if not unit:
unit = 'MB'
unit = "MB"
file_size = os.path.getsize(file_path)
memory_size_unit_mapper = {'KB': 1, 'MB': 2, 'GB': 3, 'TB': 4}
memory_size_unit_mapper = {"KB": 1, "MB": 2, "GB": 3, "TB": 4}
i = 0
while i < memory_size_unit_mapper[unit]:
file_size = file_size / 1000.0
@ -78,7 +86,7 @@ def get_file_size(file_path, unit):
def validate_file_size():
frappe.flags.create_new_backup = True
latest_file, site_config = get_latest_backup_file()
file_size = get_file_size(latest_file, unit='GB')
file_size = get_file_size(latest_file, unit="GB")
if file_size > 1:
frappe.flags.create_new_backup = False

View file

@ -19,10 +19,10 @@ from frappe.website import render
from frappe.core.doctype.language.language import sync_languages
from frappe.modules.utils import sync_customizations
from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs
from frappe.utils import global_search
from frappe.search.website_search import build_index_for_all_routes
def migrate(verbose=True, rebuild_website=False, skip_failing=False):
def migrate(verbose=True, rebuild_website=False, skip_failing=False, skip_search_index=False):
'''Migrate all apps to the latest version, will:
- run before migrate hooks
- run patches
@ -80,9 +80,6 @@ Otherwise, check the server logs and ensure that all the required services are r
# syncs statics
render.clear_cache()
# add static pages to global search
global_search.update_global_search_for_all_web_pages()
# updating installed applications data
frappe.get_single('Installed Applications').update_versions()
@ -91,6 +88,12 @@ Otherwise, check the server logs and ensure that all the required services are r
for fn in frappe.get_hooks('after_migrate', app_name=app):
frappe.get_attr(fn)()
# build web_routes index
if not skip_search_index:
# Run this last as it updates the current session
print('Building search index for {}'.format(frappe.local.site))
build_index_for_all_routes()
frappe.db.commit()
clear_notifications()

View file

@ -1,106 +0,0 @@
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt
from __future__ import unicode_literals
import frappe
from whoosh.index import create_in, open_dir
from whoosh.fields import TEXT, ID, Schema
from whoosh.qparser import MultifieldParser, FieldsPlugin, WildcardPlugin
from whoosh.query import Prefix
from bs4 import BeautifulSoup
from frappe.website.render import render_page
from frappe.utils import set_request, cint
from frappe.utils.global_search import get_routes_to_index
def build_index_for_all_routes():
print("Building search index for all web routes...")
routes = get_routes_to_index()
documents = [get_document_to_index(route) for route in routes]
build_index("web_routes", documents)
@frappe.whitelist(allow_guest=True)
def web_search(index_name, query, scope=None, limit=20):
limit = cint(limit)
return search(index_name, query, scope, limit)
def get_document_to_index(route):
frappe.set_user("Guest")
frappe.local.no_cache = True
try:
set_request(method="GET", path=route)
content = render_page(route)
soup = BeautifulSoup(content, "html.parser")
page_content = soup.find(class_="page_content")
text_content = page_content.text if page_content else ""
title = soup.title.text.strip() if soup.title else route
frappe.set_user("Administrator")
return frappe._dict(title=title, content=text_content, path=route)
except (
frappe.PermissionError,
frappe.DoesNotExistError,
frappe.ValidationError,
Exception,
):
pass
def build_index(index_name, documents):
schema = Schema(
title=TEXT(stored=True), path=ID(stored=True), content=TEXT(stored=True)
)
index_dir = get_index_path(index_name)
frappe.create_folder(index_dir)
ix = create_in(index_dir, schema)
writer = ix.writer()
for document in documents:
if document:
writer.add_document(
title=document.title, path=document.path, content=document.content
)
writer.commit()
def search(index_name, text, scope=None, limit=20):
index_dir = get_index_path(index_name)
ix = open_dir(index_dir)
results = None
out = []
with ix.searcher() as searcher:
parser = MultifieldParser(["title", "content"], ix.schema)
parser.remove_plugin_class(FieldsPlugin)
parser.remove_plugin_class(WildcardPlugin)
query = parser.parse(text)
filter_scoped = None
if scope:
filter_scoped = Prefix("path", scope)
results = searcher.search(query, limit=limit, filter=filter_scoped)
for r in results:
title_highlights = r.highlights("title")
content_highlights = r.highlights("content")
out.append(
frappe._dict(
title=r["title"],
path=r["path"],
title_highlights=title_highlights,
content_highlights=content_highlights,
)
)
return out
def get_index_path(index_name):
return frappe.get_site_path("indexes", index_name)

View file

@ -9,7 +9,7 @@ from frappe.utils import get_datetime_str
from frappe.model.base_document import get_controller
ignore_values = {
"Report": ["disabled", "prepared_report"],
"Report": ["disabled", "prepared_report", "add_total_row"],
"Print Format": ["disabled"],
"Notification": ["enabled"],
"Print Style": ["disabled"],

View file

@ -300,3 +300,4 @@ frappe.patches.v13_0.add_standard_navbar_items
frappe.patches.v13_0.generate_theme_files_in_public_folder
frappe.patches.v13_0.increase_password_length
frappe.patches.v13_0.add_toggle_width_in_navbar_settings
frappe.patches.v13_0.rename_notification_fields

View file

@ -0,0 +1,16 @@
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt
from __future__ import unicode_literals
import frappe
from frappe.model.utils.rename_field import rename_field
def execute():
"""
Change notification recipient fields from email to receiver fields
"""
frappe.reload_doc("Email", "doctype", "Notification Recipient")
frappe.reload_doc("Email", "doctype", "Notification")
rename_field("Notification Recipient", "email_by_document_field", "receiver_by_document_field")
rename_field("Notification Recipient", "email_by_role", "receiver_by_role")

View file

@ -46,6 +46,8 @@ frappe.ui.form.ControlCode = frappe.ui.form.ControlText.extend({
const language_map = {
'Javascript': 'ace/mode/javascript',
'JS': 'ace/mode/javascript',
'Python': 'ace/mode/python',
'Py': 'ace/mode/python',
'HTML': 'ace/mode/html',
'CSS': 'ace/mode/css',
'Markdown': 'ace/mode/markdown',
@ -57,7 +59,7 @@ frappe.ui.form.ControlCode = frappe.ui.form.ControlText.extend({
const language = this.df.options;
const valid_languages = Object.keys(language_map);
if (!valid_languages.includes(language)) {
if (language && !valid_languages.includes(language)) {
// eslint-disable-next-line
console.warn(`Invalid language option provided for field "${this.df.label}". Valid options are ${valid_languages.join(', ')}.`);
}

View file

@ -667,22 +667,29 @@ frappe.ui.form.Form = class FrappeForm {
savecancel(btn, callback, on_error) {
const me = this;
this.validate_form_action('Cancel');
me.ignore_doctypes_on_cancel_all = me.ignore_doctypes_on_cancel_all || [];
frappe.call({
method: "frappe.desk.form.linked_with.get_submitted_linked_docs",
args: {
doctype: me.doc.doctype,
name: me.doc.name
},
freeze: true,
callback: (r) => {
if (!r.exc && r.message.count > 0) {
me._cancel_all(r, btn, callback, on_error);
} else {
me._cancel(btn, callback, on_error, false);
freeze: true
}).then(r => {
if (!r.exc) {
let doctypes_to_cancel = (r.message.docs || []).map(value => {
return value.doctype;
}).filter(value => {
return !me.ignore_doctypes_on_cancel_all.includes(value);
});
if (doctypes_to_cancel.length) {
return me._cancel_all(r, btn, callback, on_error);
}
}
});
return me._cancel(btn, callback, on_error, false);
}
);
}
_cancel_all(r, btn, callback, on_error) {
@ -693,12 +700,16 @@ frappe.ui.form.Form = class FrappeForm {
let links = r.message.docs;
const doctypes = Array.from(new Set(links.map(link => link.doctype)));
me.ignore_doctypes_on_cancel_all = me.ignore_doctypes_on_cancel_all || [];
for (let doctype of doctypes) {
let docnames = links
.filter((link) => link.doctype == doctype)
.map((link) => frappe.utils.get_form_link(link.doctype, link.name, true))
.join(", ");
links_text += `<li><strong>${doctype}</strong>: ${docnames}</li>`;
if (!me.ignore_doctypes_on_cancel_all.includes(doctype)) {
let docnames = links
.filter((link) => link.doctype == doctype)
.map((link) => frappe.utils.get_form_link(link.doctype, link.name, true))
.join(", ");
links_text += `<li><strong>${doctype}</strong>: ${docnames}</li>`;
}
}
links_text = `<ul>${links_text}</ul>`;
@ -728,7 +739,8 @@ frappe.ui.form.Form = class FrappeForm {
frappe.call({
method: "frappe.desk.form.linked_with.cancel_all_linked_docs",
args: {
docs: links
docs: links,
ignore_doctypes_on_cancel_all: me.ignore_doctypes_on_cancel_all || []
},
freeze: true,
callback: (resp) => {
@ -742,7 +754,7 @@ frappe.ui.form.Form = class FrappeForm {
}
d.show();
};
}
_cancel(btn, callback, on_error, skip_confirm) {
const me = this;

View file

@ -396,7 +396,7 @@ export default class GridRow {
var field_on_change_function = field.df.onchange;
field.df.onchange = function(e) {
field_on_change_function && field_on_change_function(e);
me.grid.grid_rows[this.doc.idx - 1].refresh_field(this.df.fieldname);
me.grid.grid_rows[this.doc.idx - 1].refresh_field(field.df.fieldname);
};
field.refresh();
if(field.$input) {

View file

@ -836,8 +836,15 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView {
const child_table_fields = frappe.meta.get_docfields(cdt).filter(standard_fields_filter);
out[cdt] = child_table_fields;
});
// add index column for child tables
out[cdt].push({
label: __('Index'),
fieldname: 'idx',
fieldtype: 'Int',
parent: cdt
});
});
return out;
}
@ -857,7 +864,7 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView {
.map(df => ({
label: __(df.label),
value: df.fieldname,
checked: this.fields.find(f => f[0] === df.fieldname)
checked: this.fields.find(f => f[0] === df.fieldname && f[1] === this.doctype)
}))
});
@ -936,6 +943,15 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView {
docfield = this.group_by_control.get_group_by_docfield();
}
// child table index column
if (fieldname === 'idx' && doctype !== this.doctype) {
docfield = {
label: "Index",
fieldtype: "Int",
parent: doctype,
};
}
if (!docfield) {
docfield = frappe.model.get_std_field(fieldname, true);

View file

@ -81,45 +81,10 @@ $navbar-height-lg: 4.5rem;
}
.doc-search {
position: relative;
width: 100%;
@include media-breakpoint-up(lg) {
padding-left: 4rem;
padding-right: 4rem;
}
.search-icon {
position: absolute;
left: 0;
top: 0;
width: 2.5rem;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
svg {
color: $gray-600;
}
input {
padding-left: 2.5rem;
}
.dropdown-menu {
.dropdown-item {
padding: 1rem 0.75rem;
}
.match {
background-color: $primary-light;
color: $primary;
font-weight: 500;
padding: 0 0.125rem;
}
}
}
.doc-sidebar {

View file

@ -0,0 +1,40 @@
.website-search {
position: relative;
width: 100%;
.search-icon {
position: absolute;
left: 0;
top: 0;
width: 2.5rem;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
svg {
color: $gray-600;
}
input {
padding-left: 2.5rem;
}
.dropdown-menu {
.dropdown-item {
padding: 1rem 0.75rem;
&:focus {
background-color: $gray-100;
}
}
.match {
background-color: $primary-light;
color: $primary;
font-weight: 500;
padding: 0 0.125rem;
}
}
}

View file

@ -10,6 +10,7 @@
@import 'markdown';
@import 'sidebar';
@import 'portal';
@import 'search';
@import 'doc';
.ql-editor.read-mode {

13
frappe/search/__init__.py Normal file
View file

@ -0,0 +1,13 @@
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt
import frappe
from frappe.utils import cint
from frappe.search.website_search import WebsiteSearch
from frappe.search.full_text_search import FullTextSearch
@frappe.whitelist(allow_guest=True)
def web_search(query, scope=None, limit=20):
limit = cint(limit)
ws = WebsiteSearch(index_name="web_routes")
return ws.search(query, scope, limit)

View file

@ -0,0 +1,136 @@
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt
from __future__ import unicode_literals
import frappe
from whoosh.index import create_in, open_dir, EmptyIndexError
from whoosh.fields import TEXT, ID, Schema
from whoosh.qparser import MultifieldParser, FieldsPlugin, WildcardPlugin
from whoosh.query import Prefix
class FullTextSearch:
""" Frappe Wrapper for Whoosh """
def __init__(self, index_name):
self.index_name = index_name
self.index_path = get_index_path(index_name)
self.schema = self.get_schema()
self.id = self.get_id()
def get_schema(self):
return Schema(name=ID(stored=True), content=TEXT(stored=True))
def get_id(self):
return "name"
def get_items_to_index(self):
"""Get all documents to be indexed conforming to the schema"""
return []
def get_document_to_index(self):
return {}
def build(self):
""" Build search index for all documents """
self.documents = self.get_items_to_index()
self.build_index()
def update_index_by_name(self, doc_name):
"""Wraps `update_index` method, gets the document from name
and updates the index. This function changes the current user
and should only be run as administrator or in a background job.
Args:
self (object): FullTextSearch Instance
doc_name (str): name of the document to be updated
"""
document = self.get_document_to_index(doc_name)
self.update_index(document)
def remove_document_from_index(self, doc_name):
"""Remove document from search index
Args:
self (object): FullTextSearch Instance
doc_name (str): name of the document to be removed
"""
if not doc_name:
return
ix = self.get_index()
with ix.searcher():
writer = ix.writer()
writer.delete_by_term(self.id, doc_name)
writer.commit(optimize=True)
def update_index(self, document):
"""Update search index for a document
Args:
self (object): FullTextSearch Instance
document (_dict): A dictionary with title, path and content
"""
ix = self.get_index()
with ix.searcher():
writer = ix.writer()
writer.delete_by_term(self.id, document[self.id])
writer.add_document(**document)
writer.commit(optimize=True)
def get_index(self):
try:
return open_dir(self.index_path)
except EmptyIndexError:
return self.create_index()
def create_index(self):
frappe.create_folder(self.index_path)
return create_in(self.index_path, self.schema)
def build_index(self):
"""Build index for all parsed documents"""
ix = self.create_index()
writer = ix.writer()
for document in self.documents:
if document:
writer.add_document(**document)
writer.commit(optimize=True)
def search(self, text, scope=None, limit=20):
"""Search from the current index
Args:
text (str): String to search for
scope (str, optional): Scope to limit the search. Defaults to None.
limit (int, optional): Limit number of search results. Defaults to 20.
Returns:
[List(_dict)]: Search results
"""
ix = self.get_index()
results = None
out = []
with ix.searcher() as searcher:
parser = MultifieldParser(["title", "content"], ix.schema)
parser.remove_plugin_class(FieldsPlugin)
parser.remove_plugin_class(WildcardPlugin)
query = parser.parse(text)
filter_scoped = None
if scope:
filter_scoped = Prefix(self.id, scope)
results = searcher.search(query, limit=limit, filter=filter_scoped)
for r in results:
out.append(self.parse_result(r))
return out
def get_index_path(index_name):
return frappe.get_site_path("indexes", index_name)

View file

@ -0,0 +1,128 @@
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt
import unittest
from frappe.search.full_text_search import FullTextSearch
class TestFullTextSearch(unittest.TestCase):
def setUp(self):
index = get_index()
index.build()
self.index = index
def test_search_term(self):
# Search Wikipedia
res = self.index.search("multilingual online encyclopedia")
self.assertEqual(res[0], 'site/wikipedia')
res = self.index.search("Linux kernel")
self.assertEqual(res[0], 'os/linux')
res = self.index.search("Enterprise Resource Planning")
self.assertEqual(res[0], 'sw/erpnext')
def test_search_limit(self):
res = self.index.search("CommonSearchTerm")
self.assertEqual(len(res), 5)
res = self.index.search("CommonSearchTerm", limit=3)
self.assertEqual(len(res), 3)
res = self.index.search("CommonSearchTerm", limit=20)
self.assertEqual(len(res), 5)
def test_search_scope(self):
# Search outside scope
res = self.index.search("multilingual online encyclopedia", scope=["os"])
self.assertEqual(len(res), 0)
# Search inside scope
res = self.index.search("CommonSearchTerm", scope=["os"])
self.assertEqual(len(res), 2)
self.assertTrue('os/linux' in res)
self.assertTrue('os/gnu' in res)
def test_remove_document_from_index(self):
self.index.remove_document_from_index("os/gnu")
res = self.index.search("GNU")
self.assertEqual(len(res), 0)
def test_update_index(self):
# Update existing index
self.index.update_index({
'name': "sw/erpnext",
'content': """AwesomeERPNext"""
})
res = self.index.search("CommonSearchTerm")
self.assertTrue('sw/erpnext' not in res)
res = self.index.search("AwesomeERPNext")
self.assertEqual(res[0], "sw/erpnext")
# Update new doc
self.index.update_index({
'name': "sw/frappebooks",
'content': """DesktopAccounting"""
})
res = self.index.search("DesktopAccounting")
self.assertEqual(res[0], "sw/frappebooks")
class TestWrapper(FullTextSearch):
def get_items_to_index(self):
return get_documents()
def get_document_to_index(self, name):
documents = get_documents()
for doc in documents:
if doc["name"] == name:
return doc
def parse_result(self, result):
return result["name"]
def get_index():
return TestWrapper("test_frappe_index")
def get_documents():
docs = []
docs.append({
'name': "site/wikipedia",
'content': """Wikipedia is a multilingual online encyclopedia created and maintained
as an open collaboration project by a community of volunteer editors using a wiki-based editing system.
It is the largest and most popular general reference work on the World Wide Web. CommonSearchTerm"""
})
docs.append({
'name': "os/linux",
'content': """Linux is a family of open source Unix-like operating systems based on the
Linux kernel, an operating system kernel first released on September 17, 1991, by Linus Torvalds.
Linux is typically packaged in a Linux distribution. CommonSearchTerm"""
})
docs.append({
'name': "os/gnu",
'content': """GNU is an operating system and an extensive collection of computer software.
GNU is composed wholly of free software, most of which is licensed under the GNU Project's own
General Public License. GNU is a recursive acronym for "GNU's Not Unix! ",
chosen because GNU's design is Unix-like, but differs from Unix by being free software and containing no Unix code. CommonSearchTerm"""
})
docs.append({
'name': "sw/erpnext",
'content': """ERPNext is a free and open-source integrated Enterprise Resource Planning software developed by
Frappe Technologies Pvt. Ltd. and is built on MariaDB database system using a Python based server-side framework.
ERPNext is a generic ERP software used by manufacturers, distributors and services companies. CommonSearchTerm"""
})
docs.append({
'name': "sw/frappe",
'content': """Frappe Framework is a full-stack web framework, that includes everything you need to build and
deploy business applications with Rich Admin Interface. CommonSearchTerm"""
})
return docs

View file

@ -0,0 +1,117 @@
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt
from __future__ import unicode_literals
import frappe
from bs4 import BeautifulSoup
from whoosh.fields import TEXT, ID, Schema
from frappe.search.full_text_search import FullTextSearch
from frappe.website.render import render_page
from frappe.utils import set_request
import os
INDEX_NAME = "web_routes"
class WebsiteSearch(FullTextSearch):
""" Wrapper for WebsiteSearch """
def get_schema(self):
return Schema(
title=TEXT(stored=True), path=ID(stored=True), content=TEXT(stored=True)
)
def get_id(self):
return "path"
def get_items_to_index(self):
"""Get all routes to be indexed, this includes the static pages
in www/ and routes from published documents
Returns:
self (object): FullTextSearch Instance
"""
routes = get_static_pages_from_all_apps()
routes += get_doctype_routes_with_web_view()
documents = [self.get_document_to_index(route) for route in routes]
return documents
def get_document_to_index(self, route):
"""Render a page and parse it using BeautifulSoup
Args:
path (str): route of the page to be parsed
Returns:
document (_dict): A dictionary with title, path and content
"""
frappe.set_user("Guest")
frappe.local.no_cache = True
try:
set_request(method="GET", path=route)
content = render_page(route)
soup = BeautifulSoup(content, "html.parser")
page_content = soup.find(class_="page_content")
text_content = page_content.text if page_content else ""
title = soup.title.text.strip() if soup.title else route
return frappe._dict(title=title, content=text_content, path=route)
except Exception:
pass
finally:
frappe.set_user("Administrator")
def parse_result(self, result):
title_highlights = result.highlights("title")
content_highlights = result.highlights("content")
return frappe._dict(
title=result["title"],
path=result["path"],
title_highlights=title_highlights,
content_highlights=content_highlights,
)
def get_doctype_routes_with_web_view():
all_routes = []
filters = { "has_web_view": 1, "allow_guest_to_view": 1, "index_web_pages_for_search": 1}
fields = ["name", "is_published_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:
routes = frappe.get_all(doctype.name, filters={doctype.is_published_field: 1}, fields="route")
all_routes += [route.route for route in routes]
return all_routes
def get_static_pages_from_all_apps():
from glob import glob
apps = frappe.get_installed_apps()
routes_to_index = []
for app in apps:
path_to_index = frappe.get_app_path(app, 'www')
files_to_index = glob(path_to_index + '/**/*.html', recursive=True)
files_to_index.extend(glob(path_to_index + '/**/*.md', recursive=True))
for file in files_to_index:
route = os.path.relpath(file, path_to_index).split('.')[0]
if route.endswith('index'):
route = route.rsplit('index', 1)[0]
routes_to_index.append(route)
return routes_to_index
def update_index_for_path(path):
ws = WebsiteSearch(INDEX_NAME)
return ws.update_index_by_name(path)
def remove_document_from_index(path):
ws = WebsiteSearch(INDEX_NAME)
return ws.remove_document_from_index(path)
def build_index_for_all_routes():
ws = WebsiteSearch(INDEX_NAME)
return ws.build()

View file

@ -22,7 +22,7 @@
</div>
<div class="col-12 col-lg-8">
<div class="doc-search-container">
<div class="doc-search">
<div class="website-search doc-search" id="search-container">
<div class="dropdown">
<div class="search-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
@ -34,7 +34,7 @@
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</div>
<input type="search" class="form-control" placeholder="Search the docs (Press ? to focus)" />
<input type="search" class="form-control" placeholder="Search the docs (Press / to focus)" />
<div class="overflow-hidden shadow dropdown-menu w-100">
</div>
</div>
@ -117,73 +117,11 @@ id="page-{{ name or route | e }}" data-path="{{ pathname | e }}"
{%- block script -%}
<script>
frappe.ready(() => {
setup_search();
frappe.setup_search('#search-container', '{{ docs_search_scope or "" }}');
$('.web-footer .container')
.removeClass('container')
.addClass('container-fluid doc-container');
});
function setup_search() {
let $dropdown = $('.doc-search .dropdown');
let $dropdown_menu = $('.doc-search .dropdown-menu');
let $input = $('.doc-search input');
$(document).on('keypress', e => {
if (e.key === '/') {
e.preventDefault();
$input.focus();
}
});
$input.on('input', frappe.utils.debounce(() => {
if (!$input.val()) {
clear_dropdown();
return;
}
frappe.call({
method: 'frappe.modules.full_text_search.web_search',
args: {
index_name: 'web_routes',
scope: '{{ docs_search_scope or "" }}' || null,
query: $input.val(),
limit: 5
}
}).then(r => {
let results = r.message || [];
let dropdown_html;
if (results.length == 0) {
dropdown_html = `<div class="dropdown-item">No results found</div>`;
} else {
dropdown_html = results.map(r => {
return `<a class="dropdown-item" href="/${r.path}">
<h6>${r.title_highlights || r.title}</h6>
<div style="white-space: normal;">${r.content_highlights}</div>
</a>`
}).join('')
}
$dropdown_menu.html(dropdown_html);
$dropdown_menu.addClass('show');
});
}, 500));
$input.on('focus', () => {
if (!$input.val()) {
clear_dropdown();
}
});
$input.on('blur', () => {
setTimeout(() => {
clear_dropdown();
}, 300);
});
function clear_dropdown() {
$dropdown_menu.html('');
$dropdown_menu.removeClass('show');
}
}
</script>
{%- endblock -%}

View file

@ -7,7 +7,9 @@ from __future__ import print_function, unicode_literals
import os
import json
from calendar import timegm
from datetime import datetime
from glob import glob
import frappe
from frappe import _, conf
@ -94,26 +96,47 @@ class BackupGenerator:
self.backup_path_private_files = os.path.join(backup_path, for_private_files)
def get_recent_backup(self, older_than):
file_list = os.listdir(get_backup_path())
backup_path_files = None
backup_path_db = None
backup_path_private_files = None
site_config_backup_path = None
backup_path = get_backup_path()
for this_file in file_list:
this_file = cstr(this_file)
this_file_path = os.path.join(get_backup_path(), this_file)
if not is_file_old(this_file_path, older_than):
if "-private-files" in this_file_path:
backup_path_private_files = this_file_path
elif "-files" in this_file_path:
backup_path_files = this_file_path
elif "-database" in this_file_path:
backup_path_db = this_file_path
elif "site_config" in this_file_path:
site_config_backup_path = this_file_path
file_type_slugs = {
"database": "*-{}-database.sql.gz",
"public": "*-{}-files.tar",
"private": "*-{}-private-files.tar",
"config": "*-{}-site_config_backup.json",
}
return (backup_path_db, backup_path_files, backup_path_private_files, site_config_backup_path)
def backup_time(file_path):
file_name = file_path.split(os.sep)[-1]
file_timestamp = file_name.split("-")[0]
return timegm(datetime.strptime(file_timestamp, "%Y%m%d_%H%M%S").utctimetuple())
def get_latest(file_pattern):
file_pattern = os.path.join(backup_path, file_pattern.format(self.site_slug))
file_list = glob(file_pattern)
if file_list:
return max(file_list, key=backup_time)
def old_enough(file_path):
if file_path:
if not os.path.isfile(file_path) or is_file_old(file_path, older_than):
return None
return file_path
latest_backups = {
file_type: get_latest(pattern)
for file_type, pattern in file_type_slugs.items()
}
recent_backups = {
file_type: old_enough(file_name) for file_type, file_name in latest_backups.items()
}
return (
recent_backups.get("database"),
recent_backups.get("public"),
recent_backups.get("private"),
recent_backups.get("config"),
)
def zip_files(self):
for folder in ("public", "private"):
@ -216,7 +239,14 @@ def fetch_latest_backups():
dict: relative Backup Paths
"""
frappe.only_for("System Manager")
odb = BackupGenerator(frappe.conf.db_name, frappe.conf.db_name, frappe.conf.db_password, db_host=frappe.db.host, db_type=frappe.conf.db_type, db_port=frappe.conf.db_port)
odb = BackupGenerator(
frappe.conf.db_name,
frappe.conf.db_name,
frappe.conf.db_password,
db_host=frappe.db.host,
db_type=frappe.conf.db_type,
db_port=frappe.conf.db_port,
)
database, public, private, config = odb.get_recent_backup(older_than=24 * 30)
return {

View file

@ -1,196 +1,82 @@
{
"allow_copy": 0,
"allow_guest_to_view": 1,
"allow_import": 1,
"allow_rename": 0,
"autoname": "field:category_name",
"beta": 0,
"creation": "2013-03-08 09:41:11",
"custom": 0,
"docstatus": 0,
"doctype": "DocType",
"document_type": "Setup",
"editable_grid": 0,
"actions": [],
"allow_guest_to_view": 1,
"allow_import": 1,
"autoname": "field:category_name",
"creation": "2013-03-08 09:41:11",
"doctype": "DocType",
"document_type": "Setup",
"engine": "InnoDB",
"field_order": [
"category_name",
"title",
"published",
"route"
],
"fields": [
{
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "category_name",
"fieldtype": "Data",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Category Name",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
"fieldname": "category_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Category Name",
"reqd": 1,
"unique": 1
},
{
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "title",
"fieldtype": "Data",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Title",
"length": 0,
"no_copy": 1,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
"fieldname": "title",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Title",
"no_copy": 1,
"reqd": 1
},
{
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "published",
"fieldtype": "Check",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Published",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
"default": "0",
"fieldname": "published",
"fieldtype": "Check",
"in_list_view": 1,
"label": "Published"
},
{
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"depends_on": "published",
"fieldname": "route",
"fieldtype": "Data",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Route",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"depends_on": "published",
"fieldname": "route",
"fieldtype": "Data",
"label": "Route",
"unique": 1
}
],
"has_web_view": 1,
"hide_heading": 0,
"hide_toolbar": 0,
"icon": "fa fa-tag",
"idx": 1,
"image_view": 0,
"in_create": 0,
"is_published_field": "published",
"is_submittable": 0,
"issingle": 0,
"istable": 0,
"max_attachments": 0,
"modified": "2017-03-06 16:29:05.035486",
"modified_by": "Administrator",
"module": "Website",
"name": "Blog Category",
"owner": "Administrator",
],
"has_web_view": 1,
"icon": "fa fa-tag",
"idx": 1,
"is_published_field": "published",
"links": [],
"modified": "2020-07-29 21:14:47.210446",
"modified_by": "Administrator",
"module": "Website",
"name": "Blog Category",
"owner": "Administrator",
"permissions": [
{
"amend": 0,
"apply_user_permissions": 0,
"cancel": 0,
"create": 1,
"delete": 1,
"email": 1,
"export": 0,
"if_owner": 0,
"import": 0,
"permlevel": 0,
"print": 1,
"read": 1,
"report": 1,
"role": "Website Manager",
"set_user_permissions": 1,
"share": 1,
"submit": 0,
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Website Manager",
"set_user_permissions": 1,
"share": 1,
"write": 1
},
},
{
"amend": 0,
"apply_user_permissions": 0,
"cancel": 0,
"create": 0,
"delete": 0,
"email": 1,
"export": 0,
"if_owner": 0,
"import": 0,
"permlevel": 0,
"print": 1,
"read": 1,
"report": 0,
"role": "Blogger",
"set_user_permissions": 0,
"share": 0,
"submit": 0,
"write": 0
"email": 1,
"print": 1,
"read": 1,
"role": "Blogger"
}
],
"quick_entry": 1,
"read_only": 0,
"read_only_onload": 0,
"show_name_in_global_search": 0,
"sort_order": "DESC",
"track_changes": 1,
"track_seen": 0
],
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View file

@ -189,10 +189,11 @@
"has_web_view": 1,
"icon": "fa fa-quote-left",
"idx": 1,
"index_web_pages_for_search": 1,
"is_published_field": "published",
"links": [],
"max_attachments": 5,
"modified": "2020-06-01 13:37:57.465434",
"modified": "2020-07-21 16:25:17.154911",
"modified_by": "Administrator",
"module": "Website",
"name": "Blog Post",

View file

@ -4,6 +4,7 @@
"allow_import": 1,
"creation": "2014-10-30 14:25:53.780105",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"title",
"category",
@ -15,11 +16,7 @@
"content",
"likes",
"route",
"owner",
"feedback",
"helpful",
"cb_00",
"not_helpful"
"owner"
],
"fields": [
{
@ -27,8 +24,7 @@
"fieldtype": "Data",
"in_global_search": 1,
"label": "Title",
"reqd": 1,
"search_index": 1
"reqd": 1
},
{
"fieldname": "category",
@ -90,39 +86,14 @@
"fieldtype": "Link",
"label": "Owner",
"options": "User"
},
{
"collapsible": 1,
"fieldname": "feedback",
"fieldtype": "Section Break",
"label": "Feedback"
},
{
"default": "0",
"fieldname": "helpful",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Helpful",
"read_only": 1
},
{
"fieldname": "cb_00",
"fieldtype": "Column Break"
},
{
"default": "0",
"fieldname": "not_helpful",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Not Helpful",
"read_only": 1
}
],
"has_web_view": 1,
"icon": "icon-file-alt",
"index_web_pages_for_search": 1,
"is_published_field": "published",
"links": [],
"modified": "2020-05-08 10:48:19.997789",
"modified": "2020-07-21 16:25:18.577325",
"modified_by": "Administrator",
"module": "Website",
"name": "Help Article",

View file

@ -359,7 +359,7 @@
"icon": "icon-edit",
"is_published_field": "published",
"links": [],
"modified": "2020-06-30 21:49:18.237443",
"modified": "2020-07-21 16:25:37.028459",
"modified_by": "Administrator",
"module": "Website",
"name": "Web Form",

View file

@ -343,6 +343,9 @@ def get_context(context):
frappe.throw(_('Mandatory Information missing:') + '<br><br>'
+ '<br>'.join(['{0} ({1})'.format(d.label, d.fieldtype) for d in missing]))
def allow_website_search_indexing(self):
return False
def has_web_form_permission(self, doctype, name, ptype='read'):
if frappe.session.user=="Guest":
return False
@ -364,7 +367,6 @@ def get_context(context):
return False
@frappe.whitelist(allow_guest=True)
def accept(web_form, data, docname=None, for_payment=False):
'''Save the web form'''

View file

@ -288,10 +288,11 @@
"has_web_view": 1,
"icon": "fa fa-file-alt",
"idx": 1,
"index_web_pages_for_search": 1,
"is_published_field": "published",
"links": [],
"max_attachments": 20,
"modified": "2020-04-25 20:40:39.253548",
"modified": "2020-07-21 16:25:17.899069",
"modified_by": "Administrator",
"module": "Website",
"name": "Web Page",

View file

@ -380,9 +380,139 @@ $.extend(frappe, {
}
});
frappe.setup_search = function (target, search_scope) {
if (typeof target === "string") {
target = $(target);
}
let $search_input = $(`<div class="dropdown" id="dropdownMenuSearch">
<div class="search-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round"
class="feather feather-search">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</div>
<input type="search" class="form-control" placeholder="Search the docs (Press / to focus)" />
<div class="overflow-hidden shadow dropdown-menu w-100" aria-labelledby="dropdownMenuSearch">
</div>
</div>`);
target.empty();
$search_input.appendTo(target);
// let $dropdown = $search_input.find('.dropdown');
let $dropdown_menu = $search_input.find('.dropdown-menu');
let $input = $search_input.find('input');
let dropdownItems;
let offsetIndex = 0;
$(document).on('keypress', e => {
if (e.key === '/') {
e.preventDefault();
$input.focus();
}
});
$input.on('input', frappe.utils.debounce(() => {
if (!$input.val()) {
clear_dropdown();
return;
}
frappe.call({
method: 'frappe.search.web_search',
args: {
scope: search_scope || null,
query: $input.val(),
limit: 5
}
}).then(r => {
let results = r.message || [];
let dropdown_html;
if (results.length == 0) {
dropdown_html = `<div class="dropdown-item">No results found</div>`;
} else {
dropdown_html = results.map(r => {
return `<a class="dropdown-item" href="/${r.path}">
<h6>${r.title_highlights || r.title}</h6>
<div style="white-space: normal;">${r.content_highlights}</div>
</a>`;
}).join('');
}
$dropdown_menu.html(dropdown_html);
$dropdown_menu.addClass('show');
dropdownItems = $dropdown_menu.find(".dropdown-item");
});
}, 500));
$input.on('focus', () => {
if (!$input.val()) {
clear_dropdown();
} else {
$input.trigger('input');
}
});
$input.keydown(function(e) {
// up: 38, down: 40
if (e.which == 40) {
navigate(0);
}
});
$dropdown_menu.keydown(function(e) {
// up: 38, down: 40
if (e.which == 38) {
navigate(-1);
} else if (e.which == 40) {
navigate(1);
} else if (e.which == 27) {
setTimeout(() => {
clear_dropdown();
}, 300);
}
});
// Clear dropdown when clicked
$(window).click(function() {
clear_dropdown();
});
$search_input.click(function(event) {
event.stopPropagation();
});
// Navigate the list
var navigate = function(diff) {
offsetIndex += diff;
if (offsetIndex >= dropdownItems.length)
offsetIndex = 0;
if (offsetIndex < 0)
offsetIndex = dropdownItems.length - 1;
$input.off('blur');
dropdownItems.eq(offsetIndex).focus();
};
function clear_dropdown() {
offsetIndex = 0;
$dropdown_menu.html('');
$dropdown_menu.removeClass('show');
dropdownItems = undefined;
}
// Remove focus state on hover
$dropdown_menu.mouseover(function() {
dropdownItems.blur();
});
};
// Utility functions
window.valid_email = function(id) {
// eslint-disable-next-line
// copied regex from frappe/utils.js validate_type

View file

@ -7,6 +7,7 @@ from frappe.model.document import Document
from frappe.website.utils import cleanup_page_name
from frappe.website.render import clear_cache
from frappe.modules import get_module_name
from frappe.search.website_search import update_index_for_path, remove_document_from_index
class WebsiteGenerator(Document):
website = frappe._dict()
@ -83,10 +84,19 @@ class WebsiteGenerator(Document):
def on_update(self):
self.send_indexing_request()
self.remove_old_route_from_index()
def on_change(self):
# Update the index on change
# On change is triggered last in the event lifecycle
self.update_website_search_index()
def on_trash(self):
self.clear_cache()
self.send_indexing_request('URL_DELETED')
# On deleting the doc, remove the page from the web_routes index
if self.allow_website_search_indexing():
remove_document_from_index(self.route)
def is_website_published(self):
"""Return true if published in website"""
@ -129,4 +139,34 @@ class WebsiteGenerator(Document):
url = frappe.utils.get_url(self.route)
frappe.enqueue('frappe.website.doctype.website_settings.google_indexing.publish_site', \
url=url, operation_type=operation_type)
url=url, operation_type=operation_type)
# Change the field value in doctype
# Override this method to disable indexing
def allow_website_search_indexing(self):
return self.meta.index_web_pages_for_search
def remove_old_route_from_index(self):
"""Remove page from the website index if the route has changed."""
if self.allow_website_search_indexing() or frappe.flags.in_test:
return
old_doc = self.get_doc_before_save()
# Check if the route is changed
if old_doc and old_doc.route != self.route:
# Remove the route from index if the route has changed
remove_document_from_index("web_routes", old_doc.route)
def update_website_search_index(self):
"""
Update the full test index executed on document change event.
- remove document from index if document is unpublished
- update index otherwise
"""
if not self.allow_website_search_indexing() or frappe.flags.in_test:
return
if self.is_website_published():
frappe.enqueue(update_index_for_path, path=self.route)
elif self.route:
# If the website is not published
remove_document_from_index("web_routes", self.route)

View file

@ -70,4 +70,5 @@ xlrd==1.2.0
zxcvbn-python==4.4.24
pycryptodome==3.9.8
paytmchecksum==1.7.0
wrapt==1.10.11
wrapt==1.10.11
twilio==6.44.2