Merge branch 'develop' into fast-list

This commit is contained in:
Faris Ansari 2019-03-08 21:10:06 +05:30 committed by GitHub
commit 095067f11f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
74 changed files with 8981 additions and 7250 deletions

View file

@ -5,7 +5,7 @@
"es6": true
},
"parserOptions": {
"ecmaVersion": 6,
"ecmaVersion": 8,
"sourceType": "module"
},
"extends": "eslint:recommended",

View file

@ -25,7 +25,7 @@ matrix:
exclude:
- python: 2.7
env: DB=postgres
- python: 3.6
- python: 2.7
env: TEST_TYPE=ui
install:

View file

@ -18,6 +18,10 @@ sudo pip install -e ~/bench
rm $TRAVIS_BUILD_DIR/.git/shallow
cd ~/ && bench init frappe-bench --python $(which python) --frappe-path $TRAVIS_BUILD_DIR
cp -r $TRAVIS_BUILD_DIR/test_sites/test_site ~/frappe-bench/sites/
cp -r $TRAVIS_BUILD_DIR/test_sites/test_site_postgres ~/frappe-bench/sites/
cp -r $TRAVIS_BUILD_DIR/test_sites/test_site_ui ~/frappe-bench/sites/
if [[ $DB == 'mariadb' ]]; then
cp -r $TRAVIS_BUILD_DIR/test_sites/test_site ~/frappe-bench/sites/
elif [[ $TEST_TYPE == 'ui' ]]; then
cp -r $TRAVIS_BUILD_DIR/test_sites/test_site_ui ~/frappe-bench/sites/
elif [[ $DB == 'postgres' ]]; then
cp -r $TRAVIS_BUILD_DIR/test_sites/test_site_postgres ~/frappe-bench/sites/
fi

View file

@ -16,8 +16,8 @@ if [[ $DB == 'mariadb' ]]; then
elif [[ $TEST_TYPE == 'ui' ]]; then
setup_mariadb_env 'test_site_ui'
bench --site test_site_ui --force restore ./apps/frappe/test_sites/test_site_ui/test_site_ui-database.sql.gz
bench --site test_site_ui migrate
bench --site test_site_ui reinstall --yes
bench --site test_site_ui execute frappe.utils.install.complete_setup_wizard
bench --site test_site_ui scheduler disable
cd apps/frappe && yarn && yarn cypress:run

View file

@ -1,5 +1,6 @@
context('Awesome Bar', () => {
before(() => {
cy.visit('/login');
cy.login('Administrator', 'qwe');
cy.visit('/desk');
});

View file

@ -3,47 +3,49 @@ context('Table MultiSelect', () => {
cy.login('Administrator', 'qwe');
});
let todo_description = 'table multiselect' + Math.random().toString().slice(2, 8);
let name = 'table multiselect' + Math.random().toString().slice(2, 8);
it('select value from multiselect dropdown', () => {
cy.visit('/desk#Form/ToDo/New ToDo 1');
cy.fill_field('description', todo_description, 'Text Editor').blur();
cy.get('input[data-fieldname="assign_to"]').focus().as('input');
cy.get('input[data-fieldname="assign_to"] + ul').should('be.visible');
cy.get('@input').type('faris{enter}', { delay: 100 });
cy.get('.frappe-control[data-fieldname="assign_to"] .form-control .tb-selected-value')
cy.new_form('Assignment Rule');
cy.fill_field('__newname', name);
cy.fill_field('document_type', 'ToDo');
cy.fill_field('assign_condition', 'status=="Open"');
cy.get('input[data-fieldname="users"]').focus().as('input');
cy.get('input[data-fieldname="users"] + ul').should('be.visible');
cy.get('@input').type('test{enter}', { delay: 100 });
cy.get('.frappe-control[data-fieldname="users"] .form-control .tb-selected-value')
.first().as('selected-value');
cy.get('@selected-value').should('contain', 'faris@erpnext.com');
cy.get('@selected-value').should('contain', 'test@erpnext.com');
cy.server();
cy.route('POST', '/api/method/frappe.desk.form.save.savedocs').as('save_form');
// trigger save
cy.get('.primary-action').click();
cy.wait('@save_form').its('status').should('eq', 200);
cy.get('@selected-value').should('contain', 'faris@erpnext.com');
cy.get('@selected-value').should('contain', 'test@erpnext.com');
});
it('delete value using backspace', () => {
cy.visit('/desk#List/ToDo/List');
cy.go_to_list('Assignment Rule');
cy.get(`.list-subject:contains("table multiselect")`).last().find('a').click();
cy.get('input[data-fieldname="assign_to"]').focus().type('{backspace}');
cy.get('.frappe-control[data-fieldname="assign_to"] .form-control .tb-selected-value')
cy.get('input[data-fieldname="users"]').focus().type('{backspace}');
cy.get('.frappe-control[data-fieldname="users"] .form-control .tb-selected-value')
.should('not.exist');
});
it('delete value using x', () => {
cy.visit('/desk#List/ToDo/List');
cy.go_to_list('Assignment Rule');
cy.get(`.list-subject:contains("table multiselect")`).last().find('a').click();
cy.get('.frappe-control[data-fieldname="assign_to"] .form-control .tb-selected-value').as('existing_value');
cy.get('.frappe-control[data-fieldname="users"] .form-control .tb-selected-value').as('existing_value');
cy.get('@existing_value').find('.btn-remove').click();
cy.get('@existing_value').should('not.exist');
});
it('navigate to selected value', () => {
cy.visit('/desk#List/ToDo/List');
cy.go_to_list('Assignment Rule');
cy.get(`.list-subject:contains("table multiselect")`).last().find('a').click();
cy.get('.frappe-control[data-fieldname="assign_to"] .form-control .tb-selected-value').as('existing_value');
cy.get('.frappe-control[data-fieldname="users"] .form-control .tb-selected-value').as('existing_value');
cy.get('@existing_value').find('.btn-link-to-form').click();
cy.location('hash').should('contain', 'Form/User/faris@erpnext.com');
cy.location('hash').should('contain', 'Form/User/test@erpnext.com');
});
});

View file

@ -25,10 +25,9 @@
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... });
Cypress.Commands.add('login', (email, password) => {
cy.request({
url: '/',
url: '/api/method/login',
method: 'POST',
body: {
cmd: 'login',
usr: email,
pwd: password
}
@ -54,3 +53,11 @@ Cypress.Commands.add('fill_field', (fieldname, value, fieldtype='Data') => {
Cypress.Commands.add('awesomebar', (text) => {
cy.get('#navbar-search').type(`${text}{downarrow}{enter}`, { delay: 100 });
});
Cypress.Commands.add('new_form', (doctype) => {
cy.visit(`/desk#Form/${doctype}/New ${doctype} 1`);
});
Cypress.Commands.add('go_to_list', (doctype) => {
cy.visit(`/desk#List/${doctype}/List`);
});

View file

@ -24,7 +24,7 @@ if sys.version[0] == '2':
reload(sys)
sys.setdefaultencoding("utf-8")
__version__ = '11.1.13'
__version__ = '11.1.14'
__title__ = "Frappe Framework"
local = Local()

View file

@ -380,7 +380,7 @@
"collapsible": 0,
"columns": 0,
"fieldname": "users",
"fieldtype": "Table",
"fieldtype": "Table MultiSelect",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
@ -449,7 +449,7 @@
"issingle": 0,
"istable": 0,
"max_attachments": 0,
"modified": "2019-02-28 17:12:44.413782",
"modified": "2019-03-08 15:13:01.379471",
"modified_by": "Administrator",
"module": "Automation",
"name": "Assignment Rule",
@ -476,7 +476,7 @@
"write": 1
}
],
"quick_entry": 1,
"quick_entry": 0,
"read_only": 0,
"read_only_onload": 0,
"show_name_in_global_search": 0,

View file

@ -16,17 +16,25 @@ class AssignmentRule(Document):
frappe.cache().delete_value('assignment_rule')
def apply(self, doc):
assignments = assign_to.get(doc)
assignments = self.get_assignments(doc)
if not assignments and self.safe_eval('assign_condition', doc):
self.do_assignment(doc)
return True
# try clearing
if assignments and self.unassign_condition:
if (self.unassign_condition and assignments and
self.name in [d.assignment_rule for d in assignments]):
return self.clear_assignment(doc)
return False
def get_assignments(self, doc):
return frappe.get_all('ToDo', fields = ['name', 'assignment_rule'], filters = dict(
reference_type = doc.get('doctype'),
reference_name = doc.get('name'),
status = 'Open'
), limit = 5)
def do_assignment(self, doc):
# clear existing assignment, to reassign
assign_to.clear(doc.get('doctype'), doc.get('name'))
@ -37,7 +45,8 @@ class AssignmentRule(Document):
assign_to = user,
doctype = doc.get('doctype'),
name = doc.get('name'),
description = frappe.render_template(self.description, doc)
description = frappe.render_template(self.description, doc),
assignment_rule = self.name
))
# set for reference in round robin
@ -95,10 +104,10 @@ class AssignmentRule(Document):
def safe_eval(self, fieldname, doc):
try:
return frappe.safe_eval(self.get(fieldname), None, doc)
except Exception:
except Exception as e:
# when assignment fails, don't block the document as it may be
# a part of the email pulling
frappe.msgprint(frappe._('Auto assignment failed'), indicator = 'orange')
frappe.msgprint(frappe._('Auto assignment failed: {0}').format(str(e)), indicator = 'orange')
def apply(doc, method):
if frappe.flags.in_patch or frappe.flags.in_install:

View file

@ -130,7 +130,7 @@ def get_assignment_rule():
priority = 0,
document_type = 'Note',
assign_condition = 'public == 1',
unassign_condition = 'pubic == 0 or notify_on_login == 1',
unassign_condition = 'public == 0 or notify_on_login == 1',
rule = 'Round Robin',
users = [
dict(user = 'test@example.com'),

View file

@ -10,8 +10,6 @@ import frappe
from frappe.chat.doctype.chat_message import chat_message
from frappe.chat.util import create_test_user
session = frappe.session
test_user = create_test_user(__name__)
class TestChatMessage(unittest.TestCase):
def test_send(self):

View file

@ -48,29 +48,31 @@ def authenticate(user):
@frappe.whitelist()
def get(user, fields = None):
duser = frappe.get_doc('User', user)
dprof = frappe.get_doc('Chat Profile', user)
# If you're adding something here, make sure the client recieves it.
profile = dict(
# User
name = duser.name,
email = duser.email,
first_name = duser.first_name,
last_name = duser.last_name,
username = duser.username,
avatar = duser.user_image,
bio = duser.bio,
# Chat Profile
status = dprof.status,
chat_background = dprof.chat_background,
message_preview = bool(dprof.message_preview),
notification_tones = bool(dprof.notification_tones),
conversation_tones = bool(dprof.conversation_tones),
enable_chat = bool(dprof.enable_chat)
)
profile = filter_dict(profile, fields)
if frappe.db.exists('Chat Profile', user):
dprof = frappe.get_doc('Chat Profile', user)
return dictify(profile)
# If you're adding something here, make sure the client recieves it.
profile = dict(
# User
name = duser.name,
email = duser.email,
first_name = duser.first_name,
last_name = duser.last_name,
username = duser.username,
avatar = duser.user_image,
bio = duser.bio,
# Chat Profile
status = dprof.status,
chat_background = dprof.chat_background,
message_preview = bool(dprof.message_preview),
notification_tones = bool(dprof.notification_tones),
conversation_tones = bool(dprof.conversation_tones),
enable_chat = bool(dprof.enable_chat)
)
profile = filter_dict(profile, fields)
return dictify(profile)
@frappe.whitelist()
def create(user, exists_ok = False, fields = None):

View file

@ -10,8 +10,6 @@ import frappe
from frappe.chat.doctype.chat_profile import chat_profile
from frappe.chat.util import get_user_doc, create_test_user
session = frappe.session
test_user = create_test_user(__name__)
class TestChatProfile(unittest.TestCase):
pass

View file

@ -87,7 +87,7 @@
"collapsible": 0,
"columns": 0,
"fieldname": "subject",
"fieldtype": "Data",
"fieldtype": "Text",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
@ -476,7 +476,7 @@
"issingle": 0,
"istable": 0,
"max_attachments": 0,
"modified": "2019-02-08 09:18:33.843171",
"modified": "2019-03-07 18:39:37.598451",
"modified_by": "Administrator",
"module": "Core",
"name": "Comment",

File diff suppressed because it is too large Load diff

View file

@ -66,7 +66,9 @@ class RolePermissionforPageandReport(Document):
def update_disable_prepared_report(self):
if self.report:
frappe.db.set_value('Report', self.report, 'disable_prepared_report', self.disable_prepared_report)
# intentionally written update query in frappe.db.sql instead of frappe.db.set_value
frappe.db.sql(""" update `tabReport` set disable_prepared_report = %s
where name = %s""", (self.disable_prepared_report, self.report))
def get_args(self, row=None):
name = self.page if self.set_role_for == 'Page' else self.report
@ -75,7 +77,7 @@ class RolePermissionforPageandReport(Document):
return {
check_for_field: name
}
def get_roles(self):
roles = []
for data in self.roles:

File diff suppressed because it is too large Load diff

View file

@ -1048,7 +1048,7 @@ def update_roles(role_profile):
user.set('roles', [])
user.add_roles(*roles)
def create_contact(user, ignore_links=False):
def create_contact(user, ignore_links=False, ignore_mandatory=False):
if user.name in ["Administrator", "Guest"]: return
if not frappe.db.get_value("Contact", {"email_id": user.email}):
@ -1061,7 +1061,7 @@ def create_contact(user, ignore_links=False):
"gender": user.gender,
"phone": user.phone,
"mobile_no": user.mobile_no
}).insert(ignore_permissions=True, ignore_links=ignore_links)
}).insert(ignore_permissions=True, ignore_links=ignore_links, ignore_mandatory=ignore_mandatory)
@frappe.whitelist()

File diff suppressed because it is too large Load diff

View file

@ -38,6 +38,7 @@ docfield_properties = {
'fieldtype': 'Select',
'options': 'Text',
'fetch_from': 'Small Text',
'fetch_if_empty': 'Check',
'permlevel': 'Int',
'width': 'Data',
'print_width': 'Data',

View file

@ -46,3 +46,31 @@ class TestToDo(unittest.TestCase):
self.assertEqual(todo.assigned_by_full_name,
frappe.db.get_value('User', todo.assigned_by, 'full_name'))
def test_fetch_if_empty(self):
frappe.db.sql('delete from tabToDo')
# Allow user changes
todo_meta = frappe.get_doc('DocType', 'ToDo')
field = todo_meta.get('fields', dict(fieldname='assigned_by_full_name'))[0]
field.fetch_from = 'assigned_by.full_name'
field.fetch_if_empty = 1
todo_meta.save()
frappe.clear_cache(doctype='ToDo')
todo = frappe.get_doc(dict(doctype='ToDo', description='test todo',
assigned_by='Administrator', assigned_by_full_name='Admin')).insert()
self.assertEqual(todo.assigned_by_full_name, 'Admin')
# Overwrite user changes
todo_meta = frappe.get_doc('DocType', 'ToDo')
todo_meta.get('fields', dict(fieldname='assigned_by_full_name'))[0].fetch_if_empty = 0
todo_meta.save()
todo.reload()
todo.save()
self.assertEqual(todo.assigned_by_full_name,
frappe.db.get_value('User', todo.assigned_by, 'full_name'))

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,7 @@ from __future__ import unicode_literals
import frappe
from frappe import _
from frappe.desk.form.document_follow import follow_document
import frappe.share
class DuplicateToDoError(frappe.ValidationError): pass
@ -28,7 +29,8 @@ def add(args=None):
"assign_to": ,
"doctype": ,
"name": ,
"description":
"description": ,
"assignment_rule":
}
"""
@ -42,7 +44,6 @@ def add(args=None):
AND `status`='Open'
AND `owner`=%(assign_to)s""", args):
frappe.throw(_("Already in user's To Do list"), DuplicateToDoError)
else:
from frappe.utils import nowdate
@ -62,6 +63,7 @@ def add(args=None):
"status": "Open",
"date": args.get('date', nowdate()),
"assigned_by": args.get('assigned_by', frappe.session.user),
'assignment_rule': args.get('assignment_rule')
}).insert(ignore_permissions=True)
# set assigned_to if field exists
@ -75,6 +77,9 @@ def add(args=None):
frappe.share.add(doc.doctype, doc.name, args['assign_to'])
frappe.msgprint(_('Shared with user {0} with read access').format(args['assign_to']), alert=True)
# make this document followed by assigned user
follow_document(args['doctype'], args['name'], args['assign_to'])
# notify
notify_assignment(d.assigned_by, d.owner, d.reference_type, d.reference_name, action='ASSIGN',\
description=args.get("description"), notify=args.get('notify'))

View file

@ -0,0 +1,280 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt
from __future__ import unicode_literals
import frappe
import frappe.utils
from frappe.utils import get_url_to_form
from frappe import _
from itertools import groupby
@frappe.whitelist()
def follow_document(doctype, doc_name, user, force=False):
'''
param:
Doctype name
doc name
user email
condition:
avoided for some doctype
follow only if track changes are set to 1
'''
avoid_follow = ["Communication", "ToDo", "DocShare", "Email Unsubscribe", "Activity Log",
"File", "Version", "View Log", "Document Follow", "Comment"]
track_changes = frappe.get_meta(doctype).track_changes
exists = is_document_followed(doctype, doc_name, user)
if exists == 0:
user_can_follow = frappe.db.get_value("User", user, "document_follow_notify")
if user != "Administrator" and user_can_follow and track_changes and (doctype not in avoid_follow or force):
doc = frappe.new_doc("Document Follow")
doc.update({
"ref_doctype": doctype,
"ref_docname": doc_name,
"user": user
})
doc.save()
return doc
@frappe.whitelist()
def unfollow_document(doctype, doc_name, user):
doc = frappe.get_all(
"Document Follow",
filters={
"ref_doctype": doctype,
"ref_docname": doc_name,
"user": user
},
fields=["name"],
limit=1
)
if doc:
frappe.delete_doc("Document Follow", doc[0].name)
return 1
return 0
def get_message(doc_name, doctype, frequency):
activity_list = get_version(doctype, doc_name, frequency) + get_comments(doctype, doc_name, frequency)
return sorted(activity_list, key=lambda k: k["time"], reverse=True)
def send_email_alert(receiver, docinfo, timeline):
if receiver:
frappe.sendmail(
subject=_("Document Follow Notification"),
recipients=[receiver],
template="document_follow",
args={
"docinfo": docinfo,
"timeline": timeline,
}
)
def send_document_follow_mails(frequency):
'''
param:
frequency for sanding mails
task:
set receiver according to frequency
group document list according to user
get changes, activity, comments on doctype
call method to send mail
'''
users = frappe.get_list("Document Follow",
fields=["*"])
sorted_users = sorted(users, key=lambda k: k['user'])
grouped_by_user = {}
for k, v in groupby(sorted_users, key=lambda k: k['user']):
grouped_by_user[k] = list(v)
for user in grouped_by_user:
user_frequency = frappe.db.get_value("User", user, "document_follow_frequency")
message = []
valid_document_follows = []
if user_frequency == frequency:
for d in grouped_by_user[user]:
content = get_message(d.ref_docname, d.ref_doctype, frequency)
if content:
message = message + content
valid_document_follows.append({
"reference_docname": d.ref_docname,
"reference_doctype": d.ref_doctype,
"reference_url": get_url_to_form(d.ref_doctype, d.ref_docname)
})
if message:
send_email_alert(user, valid_document_follows, message)
def get_version(doctype, doc_name, frequency):
timeline = []
filters = get_filters("docname", doc_name, frequency)
version = frappe.get_all("Version",
filters=filters,
fields=["ref_doctype", "data", "modified", "modified", "modified_by"]
)
if version:
for v in version:
change = frappe.parse_json(v.data)
time = frappe.utils.format_datetime(v.modified, "hh:mm a")
timeline_items = []
if change.changed:
timeline_items = get_field_changed(change.changed, time, doctype, doc_name, v)
if change.row_changed:
timeline_items = get_row_changed(change.row_changed, time, doctype, doc_name, v)
if change.added:
timeline_items = get_added_row(change.added, time, doctype, doc_name, v)
timeline = timeline + timeline_items
return timeline
def get_comments(doctype, doc_name, frequency):
timeline = []
filters = get_filters("reference_name", doc_name, frequency)
comments = frappe.get_all("Comment",
filters=filters,
fields=["content", "modified", "modified_by", "comment_type"]
)
for comment in comments:
if comment.comment_type == "Like":
by = ''' By : <b>{0}<b>'''.format(comment.modified_by)
elif comment.comment_type == "Comment":
by = '''Commented by : <b>{0}<b>'''.format(comment.modified_by)
else:
by = ''
time = frappe.utils.format_datetime(comment.modified, "hh:mm a")
timeline.append({
"time": comment.modified,
"data": {
"time": time,
"comment": frappe.utils.html2text(str(comment.content)),
"by": by
},
"doctype": doctype,
"doc_name": doc_name,
"type": "comment"
})
return timeline
def is_document_followed(doctype, doc_name, user):
docs = frappe.get_all(
"Document Follow",
filters={
"ref_doctype": doctype,
"ref_docname": doc_name,
"user": user
},
limit=1
)
return len(docs)
@frappe.whitelist()
def get_follow_users(doctype, doc_name):
return frappe.get_all(
"Document Follow",
filters={
"ref_doctype": doctype,
"ref_docname":doc_name
},
fields=["user"]
)
def get_row_changed(row_changed, time, doctype, doc_name, v):
items = []
for d in row_changed:
d[2] = d[2] if d[2] else ' '
d[0] = d[0] if d[0] else ' '
d[3][0][1] = d[3][0][1] if d[3][0][1] else ' '
items.append({
"time": v.modified,
"data": {
"time": time,
"table_field": d[0],
"row": str(d[1]),
"field": d[3][0][0],
"from": frappe.utils.html2text(str(d[3][0][1])),
"to": frappe.utils.html2text(str(d[3][0][2]))
},
"doctype": doctype,
"doc_name": doc_name,
"type": "row changed",
"by": v.modified_by
})
return items
def get_added_row(added, time, doctype, doc_name, v):
items = []
for d in added:
items.append({
"time": v.modified,
"data": {
"to": d[0],
"time": time
},
"doctype": doctype,
"doc_name": doc_name,
"type": "row added",
"by": v.modified_by
})
return items
def get_field_changed(changed, time, doctype, doc_name, v):
items = []
for d in changed:
d[1] = d[1] if d[1] else ' '
d[2] = d[2] if d[2] else ' '
d[0] = d[0] if d[0] else ' '
items.append({
"time": v.modified,
"data": {
"time": time,
"field": d[0],
"from": frappe.utils.html2text(str(d[1])),
"to": frappe.utils.html2text(str(d[2]))
},
"doctype": doctype,
"doc_name": doc_name,
"type": "field changed",
"by": v.modified_by
})
return items
def send_hourly_updates():
send_document_follow_mails("Hourly")
def send_daily_updates():
send_document_follow_mails("Daily")
def send_weekly_updates():
send_document_follow_mails("Weekly")
def get_filters(search_by, name, frequency):
filters = []
if frequency == "Weekly":
filters = [
[search_by, "=", name],
["modified", ">", frappe.utils.add_days(frappe.utils.nowdate(),-7)],
["modified", "<", frappe.utils.nowdate()]
]
elif frequency == "Daily":
filters = [
[search_by, "=", name],
["modified", ">", frappe.utils.add_days(frappe.utils.nowdate(),-1)],
["modified", "<", frappe.utils.nowdate()]
]
elif frequency == "Hourly":
filters = [
[search_by, "=", name],
["modified", ">", frappe.utils.add_to_date(frappe.utils.now_datetime(), 0, 0, 0, -1)],
["modified", "<", frappe.utils.now_datetime()]
]
return filters

View file

@ -9,6 +9,7 @@ import frappe.defaults
import frappe.desk.form.meta
from frappe.model.utils.user_settings import get_user_settings
from frappe.permissions import get_doc_permissions
from frappe.desk.form.document_follow import is_document_followed
from frappe import _
@frappe.whitelist()
@ -90,7 +91,6 @@ def get_docinfo(doc=None, doctype=None, name=None):
doc = frappe.get_doc(doctype, name)
if not doc.has_permission("read"):
raise frappe.PermissionError
frappe.response["docinfo"] = {
"attachments": get_attachments(doc.doctype, doc.name),
"communications": _get_communications(doc.doctype, doc.name),
@ -101,7 +101,9 @@ def get_docinfo(doc=None, doctype=None, name=None):
"permissions": get_doc_permissions(doc),
"shared": frappe.share.get_users(doc.doctype, doc.name),
"rating": get_feedback_rating(doc.doctype, doc.name),
"views": get_view_logs(doc.doctype, doc.name)
"views": get_view_logs(doc.doctype, doc.name),
"is_document_followed": is_document_followed(doc.doctype, doc.name, frappe.session.user),
"document_follow_enabled": frappe.db.get_value("User", frappe.session.user, "document_follow_notify")
}
def get_attachments(dt, dn):

View file

@ -6,6 +6,7 @@ import frappe, json
import frappe.desk.form.meta
import frappe.desk.form.load
from frappe.utils.html_utils import clean_email_html
from frappe.desk.form.document_follow import follow_document
from frappe import _
from six import string_types
@ -68,6 +69,7 @@ def add_comment(reference_doctype, reference_name, content, comment_email):
comment_type = 'Comment'
)).insert(ignore_permissions = True)
follow_document(doc.reference_doctype, doc.reference_name, frappe.session.user)
return doc.as_dict()
@frappe.whitelist()

View file

@ -8,6 +8,7 @@ from __future__ import unicode_literals
import frappe, json
from frappe.database.schema import add_column
from frappe import _
from frappe.desk.form.document_follow import follow_document
from frappe.utils import get_link_to_form
@frappe.whitelist()
@ -46,7 +47,7 @@ def _toggle_like(doctype, name, add, user=None):
if user not in liked_by:
liked_by.append(user)
add_comment(doctype, name)
follow_document(doctype, name, user)
else:
if user in liked_by:
liked_by.remove(user)

View file

@ -0,0 +1,6 @@
// Copyright (c) 2019, Frappe Technologies and contributors
// For license information, please see license.txt
frappe.ui.form.on('Document Follow', {
});

View file

@ -0,0 +1,181 @@
{
"allow_copy": 0,
"allow_events_in_timeline": 0,
"allow_guest_to_view": 0,
"allow_import": 0,
"allow_rename": 0,
"beta": 0,
"creation": "2019-01-09 16:39:23.746535",
"custom": 0,
"docstatus": 0,
"doctype": "DocType",
"document_type": "",
"editable_grid": 1,
"engine": "InnoDB",
"fields": [
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "ref_doctype",
"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": "Doctype",
"length": 0,
"no_copy": 0,
"options": "DocType",
"permlevel": 0,
"precision": "",
"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,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "ref_docname",
"fieldtype": "Dynamic 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": "Document Name",
"length": 0,
"no_copy": 0,
"options": "ref_doctype",
"permlevel": 0,
"precision": "",
"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,
"translatable": 0,
"unique": 0
},
{
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "user",
"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": "User",
"length": 0,
"no_copy": 0,
"options": "User",
"permlevel": 0,
"precision": "",
"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,
"translatable": 0,
"unique": 0
}
],
"has_web_view": 0,
"hide_heading": 0,
"hide_toolbar": 0,
"idx": 0,
"image_view": 0,
"in_create": 1,
"is_submittable": 0,
"issingle": 0,
"istable": 0,
"max_attachments": 0,
"modified": "2019-02-26 15:43:44.330348",
"modified_by": "Administrator",
"module": "Email",
"name": "Document Follow",
"name_case": "",
"owner": "Administrator",
"permissions": [
{
"amend": 0,
"cancel": 0,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"if_owner": 0,
"import": 0,
"permlevel": 0,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"set_user_permissions": 0,
"share": 1,
"submit": 0,
"write": 1
},
{
"amend": 0,
"cancel": 0,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"if_owner": 0,
"import": 0,
"permlevel": 0,
"print": 1,
"read": 1,
"report": 1,
"role": "All",
"set_user_permissions": 0,
"share": 1,
"submit": 0,
"write": 1
}
],
"quick_entry": 0,
"read_only": 1,
"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
}

View file

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

View file

@ -0,0 +1,23 @@
/* eslint-disable */
// rename this file from _test_[name] to test_[name] to activate
// and remove above this line
QUnit.test("test: Document Follow", function (assert) {
let done = assert.async();
// number of asserts
assert.expect(1);
frappe.run_serially([
// insert a new Document Follow
() => frappe.tests.make('Document Follow', [
// values to be set
{key: 'value'}
]),
() => {
assert.equal(cur_frm.doc.key, 'value');
},
() => done()
]);
});

View file

@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and Contributors
# See license.txt
from __future__ import unicode_literals
import frappe
import unittest
import frappe.desk.form.document_follow as document_follow
class TestDocumentFollow(unittest.TestCase):
def test_add_subscription_and_send_mail(self):
user = get_user()
event_doc = get_event()
event_doc.description = "This is a test description for sending mail"
event_doc.save()
doc = document_follow.follow_document("Event", event_doc.name , user.name, force=True)
self.assertEquals(doc.user, user.name)
document_follow.send_hourly_updates()
email_queue_entry_name = frappe.get_all("Email Queue", limit=1)[0].name
email_queue_entry_doc = frappe.get_doc("Email Queue", email_queue_entry_name)
self.assertEquals((email_queue_entry_doc.recipients[0].recipient), user.name)
self.assertIn(event_doc.doctype, email_queue_entry_doc.message)
self.assertIn(event_doc.name, email_queue_entry_doc.message)
def tearDown(self):
frappe.db.rollback()
def get_event():
doc = frappe.get_doc({
'doctype': 'Event',
'subject': "_Test_Doc_Follow",
'doc.starts_on': frappe.utils.now(),
'doc.ends_on': frappe.utils.add_days(frappe.utils.now(),5),
'doc.description': "Hello"
})
doc.insert()
return doc
def get_user():
doc = frappe.new_doc("User")
doc.email = "test@docsub.com"
doc.first_name = "Test"
doc.last_name = "User"
doc.send_welcome_email = 0
doc.document_follow_notify = 1
doc.document_follow_frequency = "Hourly"
doc.insert()
return doc

View file

@ -108,6 +108,7 @@ class TestNotification(unittest.TestCase):
{ "email_by_document_field": "owner" }
]
}).insert()
frappe.db.commit()
event = frappe.new_doc("Event")
event.subject = "test-2",

View file

@ -157,7 +157,8 @@ scheduler_events = {
"frappe.desk.page.backups.backups.delete_downloadable_backups",
"frappe.limits.update_space_usage",
"frappe.desk.doctype.auto_repeat.auto_repeat.make_auto_repeat_entry",
"frappe.deferred_insert.save_to_db"
"frappe.deferred_insert.save_to_db",
"frappe.desk.form.document_follow.send_hourly_updates"
],
"daily": [
"frappe.email.queue.clear_outbox",
@ -172,6 +173,7 @@ scheduler_events = {
"frappe.email.doctype.auto_email_report.auto_email_report.send_daily",
"frappe.core.doctype.feedback_request.feedback_request.delete_feedback_request",
"frappe.core.doctype.activity_log.activity_log.clear_authentication_logs",
"frappe.desk.form.document_follow.send_daily_updates"
],
"daily_long": [
"frappe.integrations.doctype.dropbox_settings.dropbox_settings.take_backups_daily",
@ -181,7 +183,8 @@ scheduler_events = {
"frappe.integrations.doctype.dropbox_settings.dropbox_settings.take_backups_weekly",
"frappe.integrations.doctype.s3_backup_settings.s3_backup_settings.take_backups_weekly",
"frappe.utils.change_log.check_for_update",
"frappe.desk.doctype.route_history.route_history.flush_old_route_records"
"frappe.desk.doctype.route_history.route_history.flush_old_route_records",
"frappe.desk.form.document_follow.send_weekly_updates"
],
"monthly": [
"frappe.email.doctype.auto_email_report.auto_email_report.send_monthly"

View file

@ -459,9 +459,12 @@ class BaseDocument(object):
# that are mapped as link_fieldname.source_fieldname in Options of
# Readonly or Data or Text type fields
# NOTE: All fields will be replaced, if you want manual changes to stay
# use `frm.add_fetch`
fields_to_fetch = self.meta.get_fields_to_fetch(df.fieldname)
fields_to_fetch = [
_df for _df in self.meta.get_fields_to_fetch(df.fieldname)
if
not _df.get('fetch_if_empty')
or (_df.get('fetch_if_empty') and not self.get(_df.fieldname))
]
if not fields_to_fetch:
# cache a single value type

View file

@ -173,6 +173,9 @@ class DatabaseQuery(object):
except ValueError:
self.fields = [f.strip() for f in self.fields.split(",")]
# remove empty strings / nulls in fields
self.fields = [f for f in self.fields if f]
for filter_name in ["filters", "or_filters"]:
filters = getattr(self, filter_name)
if isinstance(filters, string_types):
@ -192,7 +195,6 @@ class DatabaseQuery(object):
field which may leads to sql injection.
example :
field = "`DocType`.`issingle`, version()"
As field contains `,` and mysql function `version()`, with the help of regex
the system will filter out this field.
'''
@ -326,7 +328,6 @@ class DatabaseQuery(object):
def prepare_filter_condition(self, f):
"""Returns a filter condition in the format:
ifnull(`tabDocType`.`fieldname`, fallback) operator "value"
"""
@ -810,4 +811,4 @@ def get_between_date_filter(value, df=None):
frappe.db.format_date(from_date),
frappe.db.format_date(to_date))
return data
return data

View file

@ -195,7 +195,7 @@ def check_if_doc_is_linked(doc, method="Delete"):
for item in frappe.db.get_values(link_dt, {link_field:doc.name},
["name", "parent", "parenttype", "docstatus"], as_dict=True):
linked_doctype = item.parenttype if item.parent else link_dt
if linked_doctype in ("Communication", "ToDo", "DocShare", "Email Unsubscribe", 'File', 'Version', "Activity Log", 'Comment'):
if linked_doctype in ("Communication", "ToDo", "DocShare", "Email Unsubscribe", 'File', 'Version', "Activity Log", "Document Follow"):
# don't check for communication and todo!
continue
@ -220,7 +220,7 @@ def check_if_doc_is_linked(doc, method="Delete"):
def check_if_doc_is_dynamically_linked(doc, method="Delete"):
'''Raise `frappe.LinkExistsError` if the document is dynamically linked'''
for df in get_dynamic_link_map().get(doc.doctype, []):
if df.parent in ("Communication", "ToDo", "DocShare", "Email Unsubscribe", "Activity Log", 'File', 'Version', 'View Log', 'Comment'):
if df.parent in ("Communication", "ToDo", "DocShare", "Email Unsubscribe", "Activity Log", 'File', 'Version', 'View Log', "Document Follow"):
# don't check for communication and todo!
continue
@ -272,6 +272,7 @@ def delete_dynamic_links(doctype, name):
delete_references('Version', doctype, name, 'ref_doctype', 'docname')
delete_references('Comment', doctype, name)
delete_references('View Log', doctype, name)
delete_references('Document Follow', doctype, name, 'ref_doctype', 'ref_docname')
# unlink communications
clear_references('Communication', doctype, name)

View file

@ -16,6 +16,7 @@ from frappe.model import optional_fields, table_fields
from frappe.model.workflow import validate_workflow
from frappe.utils.global_search import update_global_search
from frappe.integrations.doctype.webhook import run_webhooks
from frappe.desk.form.document_follow import follow_document
# once_only validation
# methods
@ -1014,6 +1015,7 @@ class Document(BaseDocument):
version = frappe.new_doc('Version')
if version.set_diff(self._doc_before_save, self):
version.insert(ignore_permissions=True)
follow_document(self.doctype, self.name, frappe.session.user)
@staticmethod
def whitelist(f):
@ -1215,7 +1217,7 @@ class Document(BaseDocument):
if file_lock.lock_exists(self.get_signature()):
frappe.throw(_('This document is currently queued for execution. Please try again'),
title=_('Document Queued'), indicator='red')
title=_('Document Queued'))
self.lock()
enqueue('frappe.model.document.execute_action', doctype=self.doctype, name=self.name,

View file

@ -232,7 +232,7 @@ class Meta(Document):
are to be fetched and updated for a particular link field
These fields are of type Data, Link, Text, Readonly and their
options property is set as `link_fieldname`.`source_fieldname`'''
fetch_from property is set as `link_fieldname`.`source_fieldname`'''
out = []

View file

@ -231,6 +231,7 @@ frappe.patches.v11_0.set_missing_creation_and_modified_value_for_user_permission
frappe.patches.v11_0.remove_doctype_user_permissions_for_page_and_report
frappe.patches.v11_0.set_default_letter_head_source
frappe.patches.v12_0.set_primary_key_in_series
execute:frappe.reload_doc('email', 'doctype', 'document_follow')
execute:frappe.delete_doc("Page", "modules", ignore_missing=True)
frappe.patches.v11_0.set_default_letter_head_source
frappe.patches.v12_0.setup_comments_from_communications

View file

@ -13,4 +13,4 @@ def execute():
user.first_name = re.sub("[<>]+", '', frappe.safe_decode(user.first_name))
if user.last_name:
user.last_name = re.sub("[<>]+", '', frappe.safe_decode(user.last_name))
create_contact(user, ignore_links=True)
create_contact(user, ignore_links=True, ignore_mandatory=True)

View file

@ -3,6 +3,8 @@ from __future__ import unicode_literals
import frappe
def execute():
frappe.reload_doctype("Comment")
for comment in frappe.get_all('Communication', fields = ['*'],
filters = dict(communication_type = 'Comment')):

View file

@ -268,6 +268,7 @@
],
"js/form.min.js": [
"public/js/frappe/form/templates/print_layout.html",
"public/js/frappe/form/document_follow.js",
"public/js/frappe/form/templates/users_in_sidebar.html",
"public/js/frappe/form/templates/set_sharing.html",
"public/js/frappe/form/templates/form_sidebar.html",

View file

@ -164,6 +164,10 @@ hr {
margin: 8px 0;
max-width: 100%;
}
.list-unstyled {
list-style-type: none;
padding: 0;
}
/* auto email report */
.report-title {
margin-bottom: 20px;

View file

@ -20,6 +20,25 @@ Table.create = (value) => {
}
Quill.register(Table, true);
// link without href
var Link = Quill.import('formats/link');
class MyLink extends Link {
static create(value) {
let node = super.create(value);
value = this.sanitize(value);
node.setAttribute('href', value);
if(value.startsWith('/') || value.indexOf(window.location.host)) {
// no href if internal link
node.removeAttribute('target');
}
return node;
}
}
Quill.register(MyLink);
// hidden blot
class HiddenBlock extends Block {
static create(value) {
@ -44,13 +63,11 @@ Uploader.DEFAULTS.mimetypes.push('image/gif');
// inline style
const BackgroundStyle = Quill.import('attributors/style/background');
const ColorStyle = Quill.import('attributors/style/color');
const SizeStyle = Quill.import('attributors/style/size');
const FontStyle = Quill.import('attributors/style/font');
const AlignStyle = Quill.import('attributors/style/align');
const DirectionStyle = Quill.import('attributors/style/direction');
Quill.register(BackgroundStyle, true);
Quill.register(ColorStyle, true);
Quill.register(SizeStyle, true);
Quill.register(FontStyle, true);
Quill.register(AlignStyle, true);
Quill.register(DirectionStyle, true);
@ -140,6 +157,7 @@ frappe.ui.form.ControlTextEditor = frappe.ui.form.ControlCode.extend({
return [
[{ 'header': [1, 2, 3, false] }],
['bold', 'italic', 'underline'],
[{ 'color': [] }, { 'background': [] }],
['blockquote', 'code-block'],
['link', 'image'],
[{ 'list': 'ordered' }, { 'list': 'bullet' }],

View file

@ -0,0 +1,144 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// MIT License. See license.txt
frappe.provide('frappe.ui.form');
frappe.ui.form.DocumentFollow = class DocumentFollow {
constructor(opts) {
$.extend(this, opts);
this.follow_document_link = this.parent.find('.follow-document-link');
this.unfollow_document_link = this.parent.find('.unfollow-document-link');
this.follow_span = this.parent.find('.anchor-document-follow > span');
this.followed_by = this.parent.find('.followed-by');
this.followed_by_label = this.parent.find('.followed-by-label');
}
refresh() {
this.set_followers();
this.render_sidebar();
}
render_sidebar() {
const docinfo = this.frm.get_docinfo();
const document_follow_enabled = docinfo && docinfo.document_follow_enabled;
const document_can_be_followed = frappe.get_meta(this.frm.doctype).track_changes;
if (frappe.session.user === 'Administrator'
|| !document_follow_enabled
|| !document_can_be_followed
) {
this.hide_follow_section();
return;
}
this.bind_events();
const is_followed = docinfo && docinfo.is_document_followed;
if(is_followed > 0) {
this.unfollow_document_link.removeClass('hidden');
this.follow_document_link.addClass('hidden');
} else {
this.followed_by_label.addClass('hidden');
this.followed_by.addClass('hidden');
this.unfollow_document_link.addClass('hidden');
this.follow_document_link.removeClass('hidden');
}
}
bind_events() {
this.follow_document_link.on('click', () => {
this.follow_document_link.addClass('text-muted disable-click');
frappe.call({
method: 'frappe.desk.form.document_follow.follow_document',
args: {
'doctype': this.frm.doctype,
'doc_name': this.frm.doc.name,
'user': frappe.session.user,
'force': true
},
callback: (r) => {
if (r.message) {
this.follow_action();
}
}
});
});
this.unfollow_document_link.on('click', () => {
this.unfollow_document_link.addClass('text-muted disable-click');
frappe.call({
method: 'frappe.desk.form.document_follow.unfollow_document',
args: {
'doctype': this.frm.doctype,
'doc_name': this.frm.doc.name,
'user': frappe.session.user
},
callback: (r) => {
if(r.message) {
this.unfollow_action();
}
}
});
});
}
hide_follow_section() {
this.parent.hide();
}
set_followers() {
this.followed_by.removeClass('hidden');
this.followed_by_label.removeClass('hidden');
this.followed_by.empty();
this.get_followed_user().then(user => {
$(user).appendTo(this.followed_by);
});
}
get_followed_user() {
var html = '';
return new Promise(resolve => {
frappe.call({
method: 'frappe.desk.form.document_follow.get_follow_users',
args: {
'doctype': this.frm.doctype,
'doc_name': this.frm.doc.name,
},
}).then(r => {
this.count_others = 0;
for (var d in r.message) {
this.count_others++;
if(this.count_others < 4){
html += frappe.avatar(r.message[d].user, 'avatar-small');
}
if(this.count_others === 0){
this.followed_by.addClass('hidden');
}
}
resolve(html);
});
});
}
follow_action() {
frappe.show_alert({
message: __('You are now following this document. You will receive daily updates via email. You can change this in User Settings.'),
indicator: 'orange'
});
this.follow_document_link.removeClass('text-muted disable-click');
this.follow_document_link.addClass('hidden');
this.unfollow_document_link.removeClass('hidden');
this.set_followers();
}
unfollow_action() {
frappe.show_alert({
message: __('You unfollowed this document'),
indicator: 'red'
});
this.unfollow_document_link.removeClass('text-muted disable-click');
this.unfollow_document_link.addClass('hidden');
this.follow_document_link.removeClass('hidden');
this.followed_by.addClass('hidden');
this.followed_by_label.addClass('hidden');
}
};

View file

@ -308,7 +308,7 @@ frappe.ui.form.Timeline = class Timeline {
}
prepare_timeline_item(c) {
if(!c.sender) c.sender = c.owner;
if(!c.sender) c.sender = c.owner || 'Guest';
if(c.sender && c.sender.indexOf("<")!==-1) {
c.sender = c.sender.split("<")[1].split(">")[0];

View file

@ -16,13 +16,13 @@ frappe.ui.form.Sidebar = Class.extend({
this.user_actions = this.sidebar.find(".user-actions");
this.image_section = this.sidebar.find(".sidebar-image-section");
this.image_wrapper = this.image_section.find('.sidebar-image-wrapper');
this.make_assignments();
this.make_attachments();
this.make_shared();
this.make_viewers();
this.make_tags();
this.make_like();
this.make_follow();
this.bind_events();
frappe.ui.form.setup_user_image_event(this.frm);
@ -54,6 +54,7 @@ frappe.ui.form.Sidebar = Class.extend({
this.frm.assign_to.refresh();
this.frm.attachments.refresh();
this.frm.shared.refresh();
this.frm.follow.refresh();
this.frm.viewers.refresh();
this.frm.tags && this.frm.tags.refresh(this.frm.doc._user_tags);
this.sidebar.find(".modified-by").html(__("{0} edited this {1}",
@ -131,7 +132,12 @@ frappe.ui.form.Sidebar = Class.extend({
this.like_count = this.sidebar.find(".liked-by .likes-count");
frappe.ui.setup_like_popover(this.sidebar.find(".liked-by-parent"), ".liked-by");
},
make_follow: function(){
this.frm.follow = new frappe.ui.form.DocumentFollow({
frm: this.frm,
parent: this.sidebar.find(".followed-by-section")
});
},
refresh_like: function() {
if (!this.like_icon) {
return;
@ -149,7 +155,6 @@ frappe.ui.form.Sidebar = Class.extend({
refresh_image: function() {
},
setup_ratings: function() {
var _ratings = this.frm.get_docinfo().rating || 0;
@ -158,5 +163,5 @@ frappe.ui.form.Sidebar = Class.extend({
var rating_icons = frappe.render_template("rating_icons", {rating: _ratings, show_label: false});
this.ratings.find(".rating-icons").html(rating_icons);
}
}
},
});

View file

@ -68,13 +68,27 @@
<li class="h6 viewers-label">{%= __("Currently Viewing") %}</li>
<li class="form-viewers"></li>
</ul>
<ul class="list-unstyled sidebar-menu text-muted">
<ul class="list-unstyled sidebar-menu">
<li class="liked-by-parent">
<span class="liked-by">
<i class="octicon octicon-heart like-action text-extra-muted fa-fw"></i>
<span class="likes-count"></span>
</span>
</li>
</ul>
<ul class="list-unstyled sidebar-menu followed-by-section">
<li class="h6 followed-by-label text-medium hidden">{%= __("Followed by") %}</li>
<li class="followed-by"></li>
<li class="document-follow">
<a class="strong badge-hover follow-document-link hidden">
{%= __("Follow") %}
</a>
<a class="strong badge-hover unfollow-document-link hidden">
{%= __("Unfollow") %}
</a>
</li>
</ul>
<ul class="list-unstyled sidebar-menu text-muted">
<li class="modified-by"></li>
<li class="created-by"></li>
</ul>

View file

@ -90,7 +90,7 @@ $.extend(frappe.model, {
for(var fid=0;fid<docfields.length;fid++) {
var f = docfields[fid];
if(!in_list(frappe.model.no_value_type, f.fieldtype) && doc[f.fieldname]==null) {
var v = frappe.model.get_default_value(f, doc, parent_doc);
var v = !f.depends_on || doc[f.depends_on] ? frappe.model.get_default_value(f, doc, parent_doc) : null;
if(v) {
if(in_list(["Int", "Check"], f.fieldtype))
v = cint(v);

View file

@ -77,13 +77,9 @@ frappe.ui.Tags = class {
}
removeTag(label) {
label = frappe.utils.xss_sanitise(label);
if(this.tagsList.includes(label)) {
let $tag = this.$ul.find(`.frappe-tag[data-tag-label="${label}"]`);
// Just don't remove tag, but also the li DOM.
$tag.parent('.tags-list-item').remove();
this.tagsList.splice(this.tagsList.indexOf(label), 1);
this.onTagRemove && this.onTagRemove(label);
}
}
@ -119,6 +115,7 @@ frappe.ui.Tags = class {
$removeTag.on("click", () => {
this.removeTag($removeTag.attr('data-tag-label'));
$removeTag.closest('.tags-list-item').remove();
});
if(this.onTagClick) {

View file

@ -43,18 +43,20 @@ frappe.views.pageview = {
name = (frappe.boot ? frappe.boot.home_page : window.page_name);
if(name === "desktop") {
let page = frappe.container.add_page('desktop');
if(!frappe.pages.desktop) {
let page = frappe.container.add_page('desktop');
let container = $('<div class="container"></div>').appendTo(page);
container = $('<div></div>').appendTo(container);
Vue.prototype.__ = window.__;
Vue.prototype.frappe = window.frappe;
new Vue({
el: container[0],
render: h => h(Desktop)
});
}
frappe.container.change_to('desktop');
let container = $('<div class="container"></div>').appendTo(page);
container = $('<div></div>').appendTo(container);
Vue.prototype.__ = window.__;
Vue.prototype.frappe = window.frappe;
new Vue({
el: container[0],
render: h => h(Desktop)
});
return;
}
}

View file

@ -52,10 +52,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList {
this.secondary_action = {
label: __('Refresh'),
action: () => {
if(this.execution_time > 2) {
this.setup_progress_bar();
}
this.setup_progress_bar();
this.refresh();
}
};
@ -171,8 +168,9 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList {
setup_progress_bar() {
let seconds_elapsed = 0;
const execution_time = this.report_settings.execution_time < 10
? 10 : this.report_settings.execution_time;
const execution_time = this.report_settings.execution_time || 0;
if (execution_time < 5) return;
this.interval = setInterval(function() {
seconds_elapsed += 1;

View file

@ -201,6 +201,11 @@ hr {
max-width: 100%;
}
.list-unstyled {
list-style-type: none;
padding: 0;
}
/* auto email report */
.report-title {
margin-bottom: 20px;

View file

@ -363,6 +363,11 @@ h6.uppercase, .h6.uppercase {
.timeline-items {
position: relative;
.timeline-item-content {
max-height: 400px;
overflow: auto;
}
}
.timeline {
@ -943,3 +948,7 @@ body[data-route^="Form/Communication"] textarea[data-fieldname="subject"] {
}
}
}
.followed-by-label{
margin-top: 30px;
}

View file

@ -9,6 +9,9 @@
}
.ql-editor {
font-family: @font-stack;
line-height: 1.6;
h1, h2, h3, h4, h5 {
margin-top: 0.5em;
margin-bottom: 0.25em;

View file

@ -12,6 +12,7 @@ import traceback
import frappe
import sqlparse
from frappe import _
RECORDER_INTERCEPT_FLAG = "recorder-intercept"
RECORDER_REQUEST_SPARSE_HASH = "recorder-requests-sparse"

View file

@ -4,6 +4,7 @@
from __future__ import unicode_literals
import frappe
from frappe import _
from frappe.desk.form.document_follow import follow_document
from frappe.utils import cint
@frappe.whitelist()
@ -41,6 +42,8 @@ def add(doctype, name, user=None, read=1, write=0, share=0, everyone=0, flags=No
doc.save(ignore_permissions=True)
notify_assignment(user, doctype, name, description=None, notify=notify)
follow_document(doctype, name, user)
return doc
def remove(doctype, name, user, flags=None):

View file

@ -0,0 +1,88 @@
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<h3>Document Follow Notification</h3>
</tr>
</table>
{% for doc in docinfo%}
<table class="panel-header" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr height="10"></tr>
<tr>
<td width="15"></td>
<td>
<div class="text-medium text-muted">
<span><a href="{{doc.reference_url}}">{{ doc.reference_doctype }}: {{doc.reference_docname }}</a></span>
</div>
</td>
<td width="15"></td>
</tr>
<tr height="10"></tr>
</table>
<table class="panel-body" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr height="10"></tr>
<tr>
<td width="15"></td>
<td>
<div>
<ul class="list-unstyled" style="line-height: 1.7">
{% for data in timeline %}
{% if (data.doctype == doc.reference_doctype and data.doc_name == doc.reference_docname) %}
{% if data.type == "comment" %}
<li>
<span style ='color:#8d99a6!important'>
{{data.data.time}}:
</span>
<b>"{{data.data.comment}}"</b>
{{data.data.by}}
</li>
{% elif data.type == "row added" %}
<li>
<span style ='color:#8d99a6!important'>
{{data.data.time}}:
</span>
Row Added to Table Field
<b>{{data.data.to}}</b>
By:
<b>{{data.by}}</b>
</li>
{% elif data.type == "field changed" %}
<li>
<span style ='color:#8d99a6!important'>
{{data.data.time}}:
</span>Field:
<b>"{{data.data.field}}"</b>
changed from
<b>"{{data.data.from}}"</b>
to
<b>"{{data.data.to}}"</b>
By:
<b>{{data.by}}</b>
</li>
{% elif data.type == "row changed" %}
<li>
<span style ='color:#8d99a6!important'>
{{data.data.time}}:
</span>
Table Field:
<b>"{{data.data.table_field}}"</b>
Row# {{data.data.row}} Field:
<b>"{{data.data.field}}"</b>
changed from
<b>"{{data.data.from}}" </b>
to <b>"{{data.data.to}}"</b>
By:
<b>{{data.by}}</b>
</li>
{% endif %}
{% endif %}
{% endfor %}
</ul>
</div>
</td>
<td width="15"></td>
</tr>
<tr height="10"></tr>
</table>
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tr height="20"></tr>
</table>
{% endfor %}

View file

@ -339,12 +339,12 @@ class TestReportview(unittest.TestCase):
def test_is_set_is_not_set(self):
res = DatabaseQuery("DocType").execute(filters={"autoname": ["is", "not set"]})
res = DatabaseQuery('DocType').execute(filters={'autoname': ['is', 'not set']})
self.assertTrue({'name': 'Integration Request'} in res)
self.assertTrue({'name': 'User'} in res)
self.assertFalse({'name': 'Blogger'} in res)
res = DatabaseQuery("DocType").execute(filters={"autoname": ["is", "set"]})
res = DatabaseQuery('DocType').execute(filters={'autoname': ['is', 'set']})
self.assertTrue({'name': 'DocField'} in res)
self.assertTrue({'name': 'Prepared Report'} in res)
self.assertFalse({'name': 'Property Setter'} in res)

View file

@ -10,6 +10,12 @@ def set_request(**kwargs):
builder = EnvironBuilder(**kwargs)
frappe.local.request = Request(builder.get_environ())
def get_html_for_route(route):
set_request(method='GET', path=route)
response = render.render()
html = frappe.safe_decode(response.get_data())
return html
class TestWebsite(unittest.TestCase):
def test_page_load(self):

View file

@ -564,7 +564,9 @@ def parse_json(val):
Parses json if string else return
"""
if isinstance(val, string_types):
return json.loads(val)
val = json.loads(val)
if isinstance(val, dict):
val = frappe._dict(val)
return val
def cast_fieldtype(fieldtype, value):

View file

@ -326,7 +326,6 @@ def ceil(s):
def cstr(s, encoding='utf-8'):
return frappe.as_unicode(s, encoding)
def rounded(num, precision=0):
"""round method for round halfs to nearest even algorithm aka banker's rounding - compatible with python3"""
precision = cint(precision)

View file

@ -205,9 +205,9 @@ def get_frame_locals():
frames = []
if traceback:
frames = inspect.getinnerframes(traceback, context=0)
_locals = ['Locals (most recent call last):']
for frame, filename, lineno, function, __, __ in frames:
if '/apps/' in filename:
_locals.append('File "{}", line {}, in {}\n{}'.format(filename, lineno, function, json.dumps(frame.f_locals, default=str, indent=4)))
_locals = ['Locals (most recent call last):']
for frame, filename, lineno, function, __, __ in frames:
if '/apps/' in filename:
_locals.append('File "{}", line {}, in {}\n{}'.format(filename, lineno, function, json.dumps(frame.f_locals, default=str, indent=4)))
return '\n'.join(_locals)
return '\n'.join(_locals)

View file

@ -0,0 +1,453 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt
from __future__ import unicode_literals
import frappe
import os, base64, re, json
import hashlib
import mimetypes
import io
from frappe.utils import get_hook_method, get_files_path, random_string, encode, cstr, call_hook_method, cint
from frappe import _
from frappe import conf
from copy import copy
from six.moves.urllib.parse import unquote
from six import text_type, PY2, string_types
class MaxFileSizeReachedError(frappe.ValidationError):
pass
def get_file_url(file_data_name):
data = frappe.db.get_value("File", file_data_name, ["file_name", "file_url"], as_dict=True)
return data.file_url or data.file_name
def upload():
# get record details
dt = frappe.form_dict.doctype
dn = frappe.form_dict.docname
file_url = frappe.form_dict.file_url
filename = frappe.form_dict.filename
frappe.form_dict.is_private = cint(frappe.form_dict.is_private)
if not filename and not file_url:
frappe.msgprint(_("Please select a file or url"),
raise_exception=True)
file_doc = get_file_doc()
comment = {}
if dt and dn:
comment = frappe.get_doc(dt, dn).add_comment("Attachment",
_("added {0}").format("<a href='{file_url}' target='_blank'>{file_name}</a>{icon}".format(**{
"icon": ' <i class="fa fa-lock text-warning"></i>' \
if file_doc.is_private else "",
"file_url": file_doc.file_url.replace("#", "%23") \
if file_doc.file_name else file_doc.file_url,
"file_name": file_doc.file_name or file_doc.file_url
})))
return {
"name": file_doc.name,
"file_name": file_doc.file_name,
"file_url": file_doc.file_url,
"is_private": file_doc.is_private,
"comment": comment.as_dict() if comment else {}
}
def get_file_doc(dt=None, dn=None, folder=None, is_private=None, df=None):
'''returns File object (Document) from given parameters or form_dict'''
r = frappe.form_dict
if dt is None: dt = r.doctype
if dn is None: dn = r.docname
if df is None: df = r.docfield
if folder is None: folder = r.folder
if is_private is None: is_private = r.is_private
if r.filedata:
file_doc = save_uploaded(dt, dn, folder, is_private, df)
elif r.file_url:
file_doc = save_url(r.file_url, r.filename, dt, dn, folder, is_private, df)
return file_doc
def save_uploaded(dt, dn, folder, is_private, df=None):
fname, content = get_uploaded_content()
if content:
return save_file(fname, content, dt, dn, folder, is_private=is_private, df=df);
else:
raise Exception
def save_url(file_url, filename, dt, dn, folder, is_private, df=None):
# if not (file_url.startswith("http://") or file_url.startswith("https://")):
# frappe.msgprint("URL must start with 'http://' or 'https://'")
# return None, None
file_url = unquote(file_url)
file_size = frappe.form_dict.file_size
f = frappe.get_doc({
"doctype": "File",
"file_url": file_url,
"file_name": filename,
"attached_to_doctype": dt,
"attached_to_name": dn,
"attached_to_field": df,
"folder": folder,
"file_size": file_size,
"is_private": is_private
})
f.flags.ignore_permissions = True
try:
f.insert()
except frappe.DuplicateEntryError:
return frappe.get_doc("File", f.duplicate_entry)
return f
def get_uploaded_content():
# should not be unicode when reading a file, hence using frappe.form
if 'filedata' in frappe.form_dict:
if "," in frappe.form_dict.filedata:
frappe.form_dict.filedata = frappe.form_dict.filedata.rsplit(",", 1)[1]
frappe.uploaded_content = base64.b64decode(frappe.form_dict.filedata)
frappe.uploaded_filename = frappe.form_dict.filename
return frappe.uploaded_filename, frappe.uploaded_content
else:
frappe.msgprint(_('No file attached'))
return None, None
def save_file(fname, content, dt, dn, folder=None, decode=False, is_private=0, df=None):
if decode:
if isinstance(content, text_type):
content = content.encode("utf-8")
if b"," in content:
content = content.split(b",")[1]
content = base64.b64decode(content)
file_size = check_max_file_size(content)
content_hash = get_content_hash(content)
content_type = mimetypes.guess_type(fname)[0]
fname = get_file_name(fname, content_hash[-6:])
file_data = get_file_data_from_hash(content_hash, is_private=is_private)
if not file_data:
call_hook_method("before_write_file", file_size=file_size)
write_file_method = get_hook_method('write_file', fallback=save_file_on_filesystem)
file_data = write_file_method(fname, content, content_type=content_type, is_private=is_private)
file_data = copy(file_data)
file_data.update({
"doctype": "File",
"attached_to_doctype": dt,
"attached_to_name": dn,
"attached_to_field": df,
"folder": folder,
"file_size": file_size,
"content_hash": content_hash,
"is_private": is_private
})
f = frappe.get_doc(file_data)
f.flags.ignore_permissions = True
try:
f.insert()
except frappe.DuplicateEntryError:
return frappe.get_doc("File", f.duplicate_entry)
return f
def get_file_data_from_hash(content_hash, is_private=0):
for name in frappe.db.sql_list("select name from `tabFile` where content_hash=%s and is_private=%s", (content_hash, is_private)):
b = frappe.get_doc('File', name)
return {k: b.get(k) for k in frappe.get_hooks()['write_file_keys']}
return False
def save_file_on_filesystem(fname, content, content_type=None, is_private=0):
fpath = write_file(content, fname, is_private)
if is_private:
file_url = "/private/files/{0}".format(fname)
else:
file_url = "/files/{0}".format(fname)
return {
'file_name': os.path.basename(fpath),
'file_url': file_url
}
def get_max_file_size():
return conf.get('max_file_size') or 10485760
def check_max_file_size(content):
max_file_size = get_max_file_size()
file_size = len(content)
if file_size > max_file_size:
frappe.msgprint(_("File size exceeded the maximum allowed size of {0} MB").format(
max_file_size / 1048576),
raise_exception=MaxFileSizeReachedError)
return file_size
def write_file(content, fname, is_private=0):
"""write file to disk with a random name (to compare)"""
file_path = get_files_path(is_private=is_private)
# create directory (if not exists)
frappe.create_folder(file_path)
# write the file
if isinstance(content, text_type):
content = content.encode()
with open(os.path.join(file_path.encode('utf-8'), fname.encode('utf-8')), 'wb+') as f:
f.write(content)
return get_files_path(fname, is_private=is_private)
def remove_all(dt, dn, from_delete=False):
"""remove all files in a transaction"""
try:
for fid in frappe.db.sql_list("""select name from `tabFile` where
attached_to_doctype=%s and attached_to_name=%s""", (dt, dn)):
remove_file(fid, dt, dn, from_delete)
except Exception as e:
if e.args[0]!=1054: raise # (temp till for patched)
def remove_file_by_url(file_url, doctype=None, name=None):
if doctype and name:
fid = frappe.db.get_value("File", {"file_url": file_url,
"attached_to_doctype": doctype, "attached_to_name": name})
else:
fid = frappe.db.get_value("File", {"file_url": file_url})
if fid:
return remove_file(fid)
def remove_file(fid, attached_to_doctype=None, attached_to_name=None, from_delete=False):
"""Remove file and File entry"""
file_name = None
if not (attached_to_doctype and attached_to_name):
attached = frappe.db.get_value("File", fid,
["attached_to_doctype", "attached_to_name", "file_name"])
if attached:
attached_to_doctype, attached_to_name, file_name = attached
ignore_permissions, comment = False, None
if attached_to_doctype and attached_to_name and not from_delete:
doc = frappe.get_doc(attached_to_doctype, attached_to_name)
ignore_permissions = doc.has_permission("write") or False
if frappe.flags.in_web_form:
ignore_permissions = True
if not file_name:
file_name = frappe.db.get_value("File", fid, "file_name")
comment = doc.add_comment("Attachment Removed", _("Removed {0}").format(file_name))
frappe.delete_doc("File", fid, ignore_permissions=ignore_permissions)
return comment
def delete_file_data_content(doc, only_thumbnail=False):
method = get_hook_method('delete_file_data_content', fallback=delete_file_from_filesystem)
method(doc, only_thumbnail=only_thumbnail)
def delete_file_from_filesystem(doc, only_thumbnail=False):
"""Delete file, thumbnail from File document"""
if only_thumbnail:
delete_file(doc.thumbnail_url)
else:
delete_file(doc.file_url)
delete_file(doc.thumbnail_url)
def delete_file(path):
"""Delete file from `public folder`"""
if path:
if ".." in path.split("/"):
frappe.msgprint(_("It is risky to delete this file: {0}. Please contact your System Manager.").format(path))
parts = os.path.split(path.strip("/"))
if parts[0]=="files":
path = frappe.utils.get_site_path("public", "files", parts[-1])
else:
path = frappe.utils.get_site_path("private", "files", parts[-1])
path = encode(path)
if os.path.exists(path):
os.remove(path)
def get_file(fname):
"""Returns [`file_name`, `content`] for given file name `fname`"""
file_path = get_file_path(fname)
# read the file
if PY2:
with open(encode(file_path)) as f:
content = f.read()
else:
with io.open(encode(file_path), mode='rb') as f:
content = f.read()
try:
# for plain text files
content = content.decode()
except UnicodeDecodeError:
# for .png, .jpg, etc
pass
return [file_path.rsplit("/", 1)[-1], content]
def get_file_path(file_name):
"""Returns file path from given file name"""
f = frappe.db.sql("""select file_url from `tabFile`
where name=%s or file_name=%s""", (file_name, file_name))
if f:
file_name = f[0][0]
file_path = file_name
if "/" not in file_path:
file_path = "/files/" + file_path
if file_path.startswith("/private/files/"):
file_path = get_files_path(*file_path.split("/private/files/", 1)[1].split("/"), is_private=1)
elif file_path.startswith("/files/"):
file_path = get_files_path(*file_path.split("/files/", 1)[1].split("/"))
else:
frappe.throw(_("There is some problem with the file url: {0}").format(file_path))
return file_path
def get_content_hash(content):
if isinstance(content, text_type):
content = content.encode()
return hashlib.md5(content).hexdigest()
def get_file_name(fname, optional_suffix):
# convert to unicode
fname = cstr(fname)
n_records = frappe.db.sql("select name from `tabFile` where file_name=%s", fname)
if len(n_records) > 0 or os.path.exists(encode(get_files_path(fname))):
f = fname.rsplit('.', 1)
if len(f) == 1:
partial, extn = f[0], ""
else:
partial, extn = f[0], "." + f[1]
return '{partial}{suffix}{extn}'.format(partial=partial, extn=extn, suffix=optional_suffix)
return fname
@frappe.whitelist()
def download_file(file_url):
"""
Download file using token and REST API. Valid session or
token is required to download private files.
Method : GET
Endpoint : frappe.utils.file_manager.download_file
URL Params : file_name = /path/to/file relative to site path
"""
file_doc = frappe.get_doc("File", {"file_url":file_url})
file_doc.check_permission("read")
path = os.path.join(get_files_path(), os.path.basename(file_url))
with open(path, "rb") as fileobj:
filedata = fileobj.read()
frappe.local.response.filename = os.path.basename(file_url)
frappe.local.response.filecontent = filedata
frappe.local.response.type = "download"
def extract_images_from_doc(doc, fieldname):
content = doc.get(fieldname)
content = extract_images_from_html(doc, content)
if frappe.flags.has_dataurl:
doc.set(fieldname, content)
def extract_images_from_html(doc, content):
frappe.flags.has_dataurl = False
def _save_file(match):
data = match.group(1)
data = data.split("data:")[1]
headers, content = data.split(",")
if "filename=" in headers:
filename = headers.split("filename=")[-1]
# decode filename
if not isinstance(filename, text_type):
filename = text_type(filename, 'utf-8')
else:
mtype = headers.split(";")[0]
filename = get_random_filename(content_type=mtype)
doctype = doc.parenttype if doc.parent else doc.doctype
name = doc.parent or doc.name
# TODO fix this
file_url = save_file(filename, content, doctype, name, decode=True).get("file_url")
if not frappe.flags.has_dataurl:
frappe.flags.has_dataurl = True
return '<img src="{file_url}"'.format(file_url=file_url)
if content:
content = re.sub('<img[^>]*src\s*=\s*["\'](?=data:)(.*?)["\']', _save_file, content)
return content
def get_random_filename(extn=None, content_type=None):
if extn:
if not extn.startswith("."):
extn = "." + extn
elif content_type:
extn = mimetypes.guess_extension(content_type)
return random_string(7) + (extn or "")
@frappe.whitelist(allow_guest=True)
def validate_filename(filename):
from frappe.utils import now_datetime
timestamp = now_datetime().strftime(" %Y-%m-%d %H:%M:%S")
fname = get_file_name(filename, timestamp)
return fname
@frappe.whitelist()
def add_attachments(doctype, name, attachments):
'''Add attachments to the given DocType'''
if isinstance(attachments, string_types):
attachments = json.loads(attachments)
# loop through attachments
files =[]
for a in attachments:
if isinstance(a, string_types):
attach = frappe.db.get_value("File", {"name":a}, ["file_name", "file_url", "is_private"], as_dict=1)
# save attachments to new doc
f = save_url(attach.file_url, attach.file_name, doctype, name, "Home/Attachments", attach.is_private)
files.append(f)
return files

View file

@ -104,21 +104,24 @@ def before_tests():
frappe.clear_cache()
# complete setup if missing
from frappe.desk.page.setup_wizard.setup_wizard import setup_complete
if not int(frappe.db.get_single_value('System Settings', 'setup_complete') or 0):
setup_complete({
"language" :"English",
"email" :"test@erpnext.com",
"full_name" :"Test User",
"password" :"test",
"country" :"United States",
"timezone" :"America/New_York",
"currency" :"USD"
})
complete_setup_wizard()
frappe.db.commit()
frappe.clear_cache()
def complete_setup_wizard():
from frappe.desk.page.setup_wizard.setup_wizard import setup_complete
setup_complete({
"language" :"English",
"email" :"test@erpnext.com",
"full_name" :"Test User",
"password" :"test",
"country" :"United States",
"timezone" :"America/New_York",
"currency" :"USD"
})
def import_country_and_currency():
from frappe.geo.country_info import get_all
from frappe.utils import update_progress_bar

View file

@ -61,10 +61,17 @@ def handle_html(data):
obj = HTML2Text()
obj.ignore_links = True
obj.body_width = 0
value = obj.handle(h)
try:
value = obj.handle(h)
except Exception:
# unable to parse html, send it raw
return value
value = ", ".join(value.split(' \n'))
value = " ".join(value.split('\n'))
value = ", ".join(value.split('# '))
return value
def read_xlsx_file_from_attached_file(file_url=None, fcontent=None, filepath=None):

View file

@ -153,8 +153,8 @@
<!-- save/next button -->
{% if (loop.index == layout|len or frappe.form_dict.new) %}
{% if not read_only %}
<button type="submit" class="btn btn-primary btn-sm btn-form-submit">
{{ _(button_label or "Save") }}</button>
<button type="submit" class="btn btn-primary btn-sm btn-form-submit footer-button">
{{ _("Save") }}</button>
{% endif %}
{% elif layout|len > 1 %}
<button class="btn btn-primary btn-sm btn-change-section"

View file

@ -36,6 +36,11 @@ frappe.ready(function() {
setTimeout(() => {
$('body').css('display', 'block');
// remove footer save button if form height is less than window height
if($('.webform-wrapper').height() < window.innerHeight) {
$(".footer-button").addClass("hide");
}
if (frappe.init_client_script) {
frappe.init_client_script();

View file

@ -13,35 +13,34 @@ $.extend(frappe, {
lang: 'en'
},
_assets_loaded: [],
require: function(url, callback) {
let async = false;
if (callback) {
async = true;
require: async function(links, callback) {
if (typeof (links) === 'string') {
links = [links];
}
if(frappe._assets_loaded.indexOf(url)!==-1) {
callback && callback();
return;
for (let link of links) {
await this.add_asset_to_head(link);
}
return $.ajax({
url: url,
async: async,
dataType: "text",
success: function(data) {
var el;
if(url.split(".").splice(-1) == "js") {
el = document.createElement('script');
} else {
el = document.createElement('style');
}
el.appendChild(document.createTextNode(data));
document.getElementsByTagName('head')[0].appendChild(el);
frappe._assets_loaded.push(url);
callback && callback();
callback && callback();
},
add_asset_to_head(link) {
return new Promise(resolve => {
if (frappe._assets_loaded.includes(link)) return resolve();
let el;
if(link.split('.').pop() === 'js') {
el = document.createElement('script');
el.type = 'text/javascript';
el.src = link;
} else {
el = document.createElement('link');
el.type = 'text/css';
el.rel = 'stylesheet';
el.href = link;
}
document.getElementsByTagName('head')[0].appendChild(el);
el.onload = () => {
frappe._assets_loaded.push(link);
resolve();
};
});
},
hide_message: function() {