diff --git a/cypress/integration/sidebar.js b/cypress/integration/sidebar.js
new file mode 100644
index 0000000000..e05f1877bf
--- /dev/null
+++ b/cypress/integration/sidebar.js
@@ -0,0 +1,57 @@
+context('Sidebar', () => {
+ before(() => {
+ cy.visit('/login');
+ cy.login();
+ cy.visit('/app/doctype');
+ });
+
+ it('Test for checking "Assigned To" counter value, adding filter and adding & removing an assignment', () => {
+ cy.click_sidebar_button(0);
+
+ //To check if no filter is available in "Assigned To" dropdown
+ cy.get('.empty-state').should('contain', 'No filters found');
+
+ cy.click_sidebar_button(1);
+
+ //To check if "Created By" dropdown contains filter
+ cy.get('.group-by-item > .dropdown-item').should('contain', 'Me');
+
+ //Assigning a doctype to a user
+ cy.click_listview_row_item(0);
+ cy.get('.form-assignments > .flex > .text-muted').click();
+ cy.get_field('assign_to_me', 'Check').click();
+ cy.get('.modal-footer > .standard-actions > .btn-primary').click();
+ cy.visit('/app/doctype');
+ cy.click_sidebar_button(0);
+
+ //To check if filter is added in "Assigned To" dropdown after assignment
+ cy.get('.group-by-field.show > .dropdown-menu > .group-by-item > .dropdown-item').should('contain', '1');
+
+ //To check if there is no filter added to the listview
+ cy.get('.filter-selector > .btn').should('contain', 'Filter');
+
+ //To add a filter to display data into the listview
+ cy.get('.group-by-field.show > .dropdown-menu > .group-by-item > .dropdown-item').click();
+
+ //To check if filter is applied
+ cy.click_filter_button().should('contain', '1 filter');
+ cy.get('.fieldname-select-area > .awesomplete > .form-control').should('have.value', 'Assigned To');
+ cy.get('.condition').should('have.value', 'like');
+ cy.get('.filter-field > .form-group > .input-with-feedback').should('have.value', '%Administrator%');
+
+ //To remove the applied filter
+ cy.get('.filter-action-buttons > div > .btn-secondary').contains('Clear Filters').click();
+ cy.click_filter_button();
+ cy.get('.filter-selector > .btn').should('contain', 'Filter');
+
+ //To remove the assignment
+ cy.visit('/app/doctype');
+ cy.click_listview_row_item(0);
+ cy.get('.assignments > .avatar-group > .avatar > .avatar-frame').click();
+ cy.get('.remove-btn').click({force: true});
+ cy.get('.modal.show > .modal-dialog > .modal-content > .modal-header > .modal-actions > .btn-modal-close').click();
+ cy.visit('/app/doctype');
+ cy.click_sidebar_button(0);
+ cy.get('.empty-state').should('contain', 'No filters found');
+ });
+});
\ No newline at end of file
diff --git a/cypress/integration/table_multiselect.js b/cypress/integration/table_multiselect.js
index 25cab78ba2..f873461efb 100644
--- a/cypress/integration/table_multiselect.js
+++ b/cypress/integration/table_multiselect.js
@@ -9,6 +9,7 @@ context('Table MultiSelect', () => {
cy.new_form('Assignment Rule');
cy.fill_field('__newname', name);
cy.fill_field('document_type', 'Blog Post');
+ cy.get('.section-head').contains('Assignment Rules').scrollIntoView();
cy.fill_field('assign_condition', 'status=="Open"', 'Code');
cy.get('input[data-fieldname="users"]').focus().as('input');
cy.get('input[data-fieldname="users"] + ul').should('be.visible');
diff --git a/cypress/integration/timeline.js b/cypress/integration/timeline.js
new file mode 100644
index 0000000000..84616cfbe6
--- /dev/null
+++ b/cypress/integration/timeline.js
@@ -0,0 +1,53 @@
+context('Timeline', () => {
+ before(() => {
+ cy.visit('/login');
+ cy.login();
+ cy.visit('/app/todo');
+ });
+
+ it('Adding new ToDo, adding new comment, verifying comment addition & deletion and deleting ToDo', () => {
+ //Adding new ToDo
+ cy.click_listview_primary_button('Add ToDo');
+ cy.get('.modal-footer > .custom-actions > .btn').contains('Edit in full page').click();
+ cy.get('.row > .section-body > .form-column > form > .frappe-control > .form-group > .control-input-wrapper > .control-input > .ql-container > .ql-editor').eq(0).type('Test ToDo', {force: true});
+ cy.wait(200);
+ cy.get('#page-ToDo > .page-head > .container > .row > .col > .standard-actions > .primary-action').contains('Save').click();
+ cy.wait(700);
+ cy.visit('/app/todo');
+ cy.get('.list-row > .level-left > .list-subject > .level-item.ellipsis > .ellipsis').eq(0).click();
+
+ //To check if the comment box is initially empty and tying some text into it
+ cy.get('.comment-input-container > .frappe-control > .ql-container > .ql-editor').should('contain', '').type('Testing Timeline');
+
+ //Adding new comment
+ cy.get('.comment-input-wrapper > .btn').contains('Comment').click();
+
+ //To check if the commented text is visible in the timeline content
+ cy.get('.timeline-content').should('contain', 'Testing Timeline');
+
+ //Editing comment
+ cy.click_timeline_action_btn(0);
+ cy.get('.timeline-content > .timeline-message-box > .comment-edit-box > .frappe-control > .ql-container > .ql-editor').first().type(' 123');
+ cy.click_timeline_action_btn(0);
+
+ //To check if the edited comment text is visible in timeline content
+ cy.get('.timeline-content').should('contain', 'Testing Timeline 123');
+
+ //Discarding comment
+ cy.click_timeline_action_btn(0);
+ cy.get('.actions > .btn').eq(1).first().click();
+
+ //To check if after discarding the timeline content is same as previous
+ cy.get('.timeline-content').should('contain', 'Testing Timeline 123');
+
+ //Deleting the added comment
+ cy.get('.actions > .btn > .icon').first().click();
+ cy.get('.modal-footer > .standard-actions > .btn-primary').contains('Yes').click();
+ cy.click_modal_primary_button('Yes');
+
+ //Deleting the added ToDo
+ cy.get('#page-ToDo > .page-head > .container > .row > .col > .standard-actions > .menu-btn-group > .btn').click({force: true});
+ cy.get('.menu-btn-group > .dropdown-menu > li > .grey-link').eq(17).click({force: true});
+ cy.get('.modal.show > .modal-dialog > .modal-content > .modal-footer > .standard-actions > .btn-primary').contains('Yes').click({force: true});
+ });
+});
\ No newline at end of file
diff --git a/cypress/integration/timeline_email.js b/cypress/integration/timeline_email.js
new file mode 100644
index 0000000000..e5b3ebeb7c
--- /dev/null
+++ b/cypress/integration/timeline_email.js
@@ -0,0 +1,71 @@
+context('Timeline Email', () => {
+ before(() => {
+ cy.visit('/login');
+ cy.login();
+ cy.visit('/app/todo');
+ });
+
+ it('Adding new ToDo, adding email and verifying timeline content for email attachment, deleting attachment and ToDo', () => {
+ //Adding new ToDo
+ cy.click_listview_primary_button('Add ToDo');
+ cy.get('.custom-actions > .btn').trigger('click', {delay: 500});
+ cy.get('.row > .section-body > .form-column > form > .frappe-control > .form-group > .control-input-wrapper > .control-input > .ql-container > .ql-editor').eq(0).type('Test ToDo', {force: true});
+ cy.wait(500);
+ //cy.click_listview_primary_button('Save');
+ cy.get('.primary-action').contains('Save').click({force: true});
+ cy.wait(700);
+ cy.visit('/app/todo');
+ cy.get('.list-row > .level-left > .list-subject > .level-item.ellipsis > .ellipsis').eq(0).click();
+
+ //Creating a new email
+ cy.get('.timeline-actions > .btn').click();
+ cy.fill_field('recipients', 'test@example.com', 'MultiSelect');
+ cy.get('.modal.show > .modal-dialog > .modal-content > .modal-body > :nth-child(1) > .form-layout > .form-page > :nth-child(3) > .section-body > .form-column > form > [data-fieldtype="Text Editor"] > .form-group > .control-input-wrapper > .control-input > .ql-container > .ql-editor').type('Test Mail');
+
+ //Adding attachment to the email
+ cy.get('.add-more-attachments > .btn').click();
+ cy.get('.mt-2 > .btn > .mt-1').eq(2).click();
+ cy.get('.input-group > .form-control').type('https://wallpaperplay.com/walls/full/8/2/b/72402.jpg');
+ cy.get('.btn-primary').contains('Upload').click();
+
+ //Sending the email
+ cy.click_modal_primary_button('Send', {delay: 500});
+
+ //To check if the sent mail content is shown in the timeline content
+ cy.get('[data-doctype="Communication"] > .timeline-content').should('contain', 'Test Mail');
+
+ //To check if the attachment of email is shown in the timeline content
+ cy.get('.timeline-content').should('contain', 'Added 72402.jpg');
+
+ //Deleting the sent email
+ cy.get('[title="Open Communication"] > .icon').first().click({force: true});
+ cy.get('#page-Communication > .page-head > .container > .row > .col > .standard-actions > .menu-btn-group > .btn').click();
+ cy.get('#page-Communication > .page-head > .container > .row > .col > .standard-actions > .menu-btn-group > .dropdown-menu > li > .grey-link').eq(9).click();
+ cy.get('.modal.show > .modal-dialog > .modal-content > .modal-footer > .standard-actions > .btn-primary').click();
+ cy.visit('/app/todo');
+ cy.get('.list-row > .level-left > .list-subject > .level-item.ellipsis > .ellipsis').eq(0).click();
+
+ //Removing the added attachment
+ cy.get('.attachment-row > .data-pill > .remove-btn > .icon').click();
+ cy.get('.modal-dialog > .modal-content > .modal-footer > .standard-actions > .btn-primary').contains('Yes').click();
+
+ //To check if the removed attachment is shown in the timeline content
+ cy.get('.timeline-content').should('contain', 'Removed 72402.jpg');
+ cy.wait(500);
+
+ //To check if the discard button functionality in email is working correctly
+ cy.get('.timeline-actions > .btn').click();
+ cy.fill_field('recipients', 'test@example.com', 'MultiSelect');
+ cy.get('.modal-footer > .standard-actions > .btn-secondary').contains('Discard').click();
+ cy.wait(500);
+ cy.get('.timeline-actions > .btn').click();
+ cy.wait(500);
+ cy.get_field('recipients', 'MultiSelect').should('have.text', '');
+ cy.get('.modal.show > .modal-dialog > .modal-content > .modal-header > .modal-actions > .btn-modal-close > .icon').click();
+
+ //Deleting the added ToDo
+ cy.get('#page-ToDo > .page-head > .container > .row > .col > .standard-actions > .menu-btn-group > .btn').click();
+ cy.get('.menu-btn-group > .dropdown-menu > li > .grey-link').eq(17).click();
+ cy.get('.modal.show > .modal-dialog > .modal-content > .modal-footer > .standard-actions > .btn-primary').click();
+ });
+});
diff --git a/cypress/support/commands.js b/cypress/support/commands.js
index 1964b96d70..a81ba60fb0 100644
--- a/cypress/support/commands.js
+++ b/cypress/support/commands.js
@@ -192,16 +192,16 @@ Cypress.Commands.add('fill_field', (fieldname, value, fieldtype = 'Data') => {
});
Cypress.Commands.add('get_field', (fieldname, fieldtype = 'Data') => {
- let selector = `.form-control[data-fieldname="${fieldname}"]`;
+ let selector = `[data-fieldname="${fieldname}"] input:visible`;
if (fieldtype === 'Text Editor') {
- selector = `[data-fieldname="${fieldname}"] .ql-editor[contenteditable=true]`;
+ selector = `[data-fieldname="${fieldname}"] .ql-editor[contenteditable=true]:visible`;
}
if (fieldtype === 'Code') {
selector = `[data-fieldname="${fieldname}"] .ace_text-input`;
}
- return cy.get(selector);
+ return cy.get(selector).first();
});
Cypress.Commands.add('fill_table_field', (tablefieldname, row_idx, fieldname, value, fieldtype = 'Data') => {
@@ -323,4 +323,30 @@ Cypress.Commands.add('clear_filters', () => {
cy.window().its('cur_list').then(cur_list => {
cur_list && cur_list.filter_area && cur_list.filter_area.clear();
});
+
+
});
+
+Cypress.Commands.add('click_modal_primary_button', (btn_name) => {
+ cy.get('.modal-footer > .standard-actions > .btn-primary').contains(btn_name).trigger('click', {force: true});
+});
+
+Cypress.Commands.add('click_sidebar_button', (btn_no) => {
+ cy.get('.list-group-by-fields > .group-by-field > .btn').eq(btn_no).click();
+});
+
+Cypress.Commands.add('click_listview_row_item', (row_no) => {
+ cy.get('.list-row > .level-left > .list-subject > .bold > .ellipsis').eq(row_no).click({force: true});
+});
+
+Cypress.Commands.add('click_filter_button', () => {
+ cy.get('.filter-selector > .btn').click();
+});
+
+Cypress.Commands.add('click_listview_primary_button', (btn_name) => {
+ cy.get('.primary-action').contains(btn_name).click({force: true});
+});
+
+Cypress.Commands.add('click_timeline_action_btn', (btn_no) => {
+ cy.get('.timeline-content > .timeline-message-box > .justify-between > .actions > .btn').eq(btn_no).first().click();
+});
\ No newline at end of file
diff --git a/frappe/automation/doctype/assignment_rule/assignment_rule.json b/frappe/automation/doctype/assignment_rule/assignment_rule.json
index 0a57e06da6..541d176967 100644
--- a/frappe/automation/doctype/assignment_rule/assignment_rule.json
+++ b/frappe/automation/doctype/assignment_rule/assignment_rule.json
@@ -72,6 +72,7 @@
"fieldtype": "Code",
"in_list_view": 1,
"label": "Assign Condition",
+ "options": "PythonExpression",
"reqd": 1
},
{
@@ -82,7 +83,8 @@
"description": "Simple Python Expression, Example: Status in (\"Closed\", \"Cancelled\")",
"fieldname": "unassign_condition",
"fieldtype": "Code",
- "label": "Unassign Condition"
+ "label": "Unassign Condition",
+ "options": "PythonExpression"
},
{
"fieldname": "assign_to_users_section",
@@ -120,7 +122,8 @@
"description": "Simple Python Expression, Example: Status in (\"Invalid\")",
"fieldname": "close_condition",
"fieldtype": "Code",
- "label": "Close Condition"
+ "label": "Close Condition",
+ "options": "PythonExpression"
},
{
"fieldname": "sb",
@@ -151,7 +154,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2020-10-20 14:47:20.662954",
+ "modified": "2021-07-16 22:51:35.505575",
"modified_by": "Administrator",
"module": "Automation",
"name": "Assignment Rule",
diff --git a/frappe/commands/site.py b/frappe/commands/site.py
index 45f7706d60..9098e31738 100755
--- a/frappe/commands/site.py
+++ b/frappe/commands/site.py
@@ -193,7 +193,7 @@ def install_app(context, apps):
print("App {} is Incompatible with Site {}{}".format(app, site, err_msg))
exit_code = 1
except Exception as err:
- err_msg = ":\n{}".format(err if str(err) else frappe.get_traceback())
+ err_msg = ": {}\n{}".format(str(err), frappe.get_traceback())
print("An error occurred while installing {}{}".format(app, err_msg))
exit_code = 1
diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py
index 12abfc533b..a7f4dd9def 100644
--- a/frappe/core/doctype/doctype/doctype.py
+++ b/frappe/core/doctype/doctype/doctype.py
@@ -722,6 +722,19 @@ def validate_links_table_fieldnames(meta):
message = _("Row #{0}: Could not find field {1} in {2} DocType").format(index+1, frappe.bold(link.link_fieldname), frappe.bold(link.link_doctype))
frappe.throw(message, InvalidFieldNameError, _("Invalid Fieldname"))
+ if link.is_child_table and not meta.get_field(link.table_fieldname):
+ message = _("Row #{0}: Could not find field {1} in {2} DocType").format(index+1, frappe.bold(link.table_fieldname), frappe.bold(meta.name))
+ frappe.throw(message, frappe.ValidationError, _("Invalid Table Fieldname"))
+
+ if link.is_child_table:
+ if not link.parent_doctype:
+ message = _("Row #{0}: Parent DocType is mandatory for internal links").format(index+1)
+ frappe.throw(message, frappe.ValidationError, _("Parent Missing"))
+
+ if not link.table_fieldname:
+ message = _("Row #{0}: Table Fieldname is mandatory for internal links").format(index+1)
+ frappe.throw(message, frappe.ValidationError, _("Table Fieldname Missing"))
+
def validate_fields_for_doctype(doctype):
meta = frappe.get_meta(doctype, cached=False)
validate_links_table_fieldnames(meta)
diff --git a/frappe/core/doctype/doctype_link/doctype_link.json b/frappe/core/doctype/doctype_link/doctype_link.json
index 0453894467..4baec6746d 100644
--- a/frappe/core/doctype/doctype_link/doctype_link.json
+++ b/frappe/core/doctype/doctype_link/doctype_link.json
@@ -7,8 +7,11 @@
"field_order": [
"link_doctype",
"link_fieldname",
+ "parent_doctype",
+ "table_fieldname",
"group",
"hidden",
+ "is_child_table",
"custom"
],
"fields": [
@@ -45,12 +48,33 @@
"fieldtype": "Check",
"hidden": 1,
"label": "Custom"
+ },
+ {
+ "depends_on": "is_child_table",
+ "fieldname": "parent_doctype",
+ "fieldtype": "Link",
+ "label": "Parent DocType",
+ "mandatory_depends_on": "is_child_table",
+ "options": "DocType"
+ },
+ {
+ "default": "0",
+ "fetch_from": "link_doctype.istable",
+ "fieldname": "is_child_table",
+ "fieldtype": "Check",
+ "label": "Is Child Table",
+ "read_only": 1
+ },
+ {
+ "fieldname": "table_fieldname",
+ "fieldtype": "Data",
+ "label": "Table Fieldname"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2020-09-24 14:19:25.189511",
+ "modified": "2021-07-31 15:23:12.237491",
"modified_by": "Administrator",
"module": "Core",
"name": "DocType Link",
diff --git a/frappe/core/doctype/server_script/server_script.py b/frappe/core/doctype/server_script/server_script.py
index d26fe5a188..ebd581ce87 100644
--- a/frappe/core/doctype/server_script/server_script.py
+++ b/frappe/core/doctype/server_script/server_script.py
@@ -15,7 +15,6 @@ from frappe import _
class ServerScript(Document):
def validate(self):
frappe.only_for("Script Manager", True)
- self.validate_script()
self.sync_scheduled_jobs()
self.clear_scheduled_events()
@@ -36,10 +35,6 @@ class ServerScript(Document):
fields=["name", "stopped"],
)
- def validate_script(self):
- """Utilizes the ast module to check for syntax errors
- """
- ast.parse(self.script)
def sync_scheduled_jobs(self):
"""Sync Scheduled Job Type statuses if Server Script's disabled status is changed
diff --git a/frappe/custom/doctype/customize_form/test_customize_form.py b/frappe/custom/doctype/customize_form/test_customize_form.py
index 58bdcf9a18..aef95cd676 100644
--- a/frappe/custom/doctype/customize_form/test_customize_form.py
+++ b/frappe/custom/doctype/customize_form/test_customize_form.py
@@ -232,6 +232,32 @@ class TestCustomizeForm(unittest.TestCase):
testdt.delete()
testdt1.delete()
+ def test_custom_internal_links(self):
+ # add a custom internal link
+ frappe.clear_cache()
+ d = self.get_customize_form("User Group")
+
+ d.append('links', dict(link_doctype='User Group Member', parent_doctype='User',
+ link_fieldname='user', table_fieldname='user_group_members', group='Tests', custom=1))
+
+ d.run_method("save_customization")
+
+ frappe.clear_cache()
+ user_group = frappe.get_meta('User Group')
+
+ # check links exist
+ self.assertTrue([d.name for d in user_group.links if d.link_doctype == 'User Group Member'])
+ self.assertTrue([d.name for d in user_group.links if d.parent_doctype == 'User'])
+
+ # remove the link
+ d = self.get_customize_form("User Group")
+ d.links = []
+ d.run_method("save_customization")
+
+ frappe.clear_cache()
+ user_group = frappe.get_meta('Event')
+ self.assertFalse([d.name for d in (user_group.links or []) if d.link_doctype == 'User Group Member'])
+
def test_custom_action(self):
test_route = '/app/List/DocType'
diff --git a/frappe/database/database.py b/frappe/database/database.py
index b1dec95139..d6ecf0795d 100644
--- a/frappe/database/database.py
+++ b/frappe/database/database.py
@@ -543,7 +543,7 @@ class Database(object):
"""
if not doctype in self.value_cache:
- self.value_cache = self.value_cache[doctype] = {}
+ self.value_cache[doctype] = {}
if fieldname in self.value_cache[doctype]:
return self.value_cache[doctype][fieldname]
diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py
index 9447e60529..752543f46a 100644
--- a/frappe/model/base_document.py
+++ b/frappe/model/base_document.py
@@ -727,6 +727,18 @@ class BaseDocument(object):
if abs(cint(value)) > max_length:
self.throw_length_exceeded_error(df, max_length, value)
+ def _validate_code_fields(self):
+ for field in self.meta.get_code_fields():
+ code_string = self.get(field.fieldname)
+ language = field.get("options")
+
+ if language == "Python":
+ frappe.utils.validate_python_code(code_string, fieldname=field.label, is_expression=False)
+
+ elif language == "PythonExpression":
+ frappe.utils.validate_python_code(code_string, fieldname=field.label)
+
+
def throw_length_exceeded_error(self, df, max_length, value):
if self.parentfield and self.idx:
reference = _("{0}, Row {1}").format(_(self.doctype), self.idx)
diff --git a/frappe/model/document.py b/frappe/model/document.py
index b44d95716e..7443c92ab4 100644
--- a/frappe/model/document.py
+++ b/frappe/model/document.py
@@ -495,6 +495,7 @@ class Document(BaseDocument):
self._validate_selects()
self._validate_non_negative()
self._validate_length()
+ self._validate_code_fields()
self._extract_images_from_text_editor()
self._sanitize_content()
self._save_passwords()
diff --git a/frappe/model/meta.py b/frappe/model/meta.py
index b212324208..de794ba77f 100644
--- a/frappe/model/meta.py
+++ b/frappe/model/meta.py
@@ -141,6 +141,9 @@ class Meta(Document):
def get_image_fields(self):
return self.get("fields", {"fieldtype": "Attach Image"})
+ def get_code_fields(self):
+ return self.get("fields", {"fieldtype": "Code"})
+
def get_set_only_once_fields(self):
'''Return fields with `set_only_once` set'''
if not hasattr(self, "_set_only_once_fields"):
@@ -504,6 +507,9 @@ class Meta(Document):
if not data.non_standard_fieldnames:
data.non_standard_fieldnames = {}
+ if not data.internal_links:
+ data.internal_links = {}
+
for link in dashboard_links:
link.added = False
if link.hidden:
@@ -511,24 +517,32 @@ class Meta(Document):
for group in data.transactions:
group = frappe._dict(group)
+
+ # For internal links parent doctype will be the key
+ doctype = link.parent_doctype or link.link_doctype
# group found
if link.group and group.label == link.group:
- if link.link_doctype not in group.get('items'):
- group.get('items').append(link.link_doctype)
+ if doctype not in group.get('items'):
+ group.get('items').append(doctype)
link.added = True
if not link.added:
# group not found, make a new group
data.transactions.append(dict(
label = link.group,
- items = [link.link_doctype]
+ items = [link.parent_doctype or link.link_doctype]
))
-
- if link.link_fieldname != data.fieldname:
- if data.fieldname:
- data.non_standard_fieldnames[link.link_doctype] = link.link_fieldname
- else:
+
+ if not link.is_child_table:
+ if link.link_fieldname != data.fieldname:
+ if data.fieldname:
+ data.non_standard_fieldnames[link.link_doctype] = link.link_fieldname
+ else:
+ data.fieldname = link.link_fieldname
+ elif link.is_child_table:
+ if not data.fieldname:
data.fieldname = link.link_fieldname
+ data.internal_links[link.parent_doctype] = [link.table_fieldname, link.link_fieldname]
def get_row_template(self):
diff --git a/frappe/tests/test_utils.py b/frappe/tests/test_utils.py
index c6f8541ada..95ba763482 100644
--- a/frappe/tests/test_utils.py
+++ b/frappe/tests/test_utils.py
@@ -6,6 +6,7 @@ import frappe
from frappe.utils import evaluate_filters, money_in_words, scrub_urls, get_url
from frappe.utils import validate_url, validate_email_address
from frappe.utils import ceil, floor
+from frappe.utils.data import validate_python_code
from PIL import Image
from frappe.utils.image import strip_exif_data, optimize_image
@@ -201,4 +202,31 @@ class TestImage(unittest.TestCase):
self.assertLessEqual(width, 500)
self.assertLessEqual(height, 500)
- self.assertLess(len(optimized_content), len(original_content))
\ No newline at end of file
+ self.assertLess(len(optimized_content), len(original_content))
+
+class TestPythonExpressions(unittest.TestCase):
+
+ def test_validation_for_good_python_expression(self):
+ valid_expressions = [
+ "foo == bar",
+ "foo == 42",
+ "password != 'hunter2'",
+ "complex != comparison and more_complex == condition",
+ "escaped_values == 'str with newline\\n'",
+ "check_box_field",
+ ]
+ for expr in valid_expressions:
+ try:
+ validate_python_code(expr)
+ except Exception as e:
+ self.fail(f"Invalid error thrown for valid expression: {expr}: {str(e)}")
+
+ def test_validation_for_bad_python_expression(self):
+ invalid_expressions = [
+ "these_are && js_conditions",
+ "more || js_conditions",
+ "curly_quotes_bad == “const”",
+ "oops = forgot_equals",
+ ]
+ for expr in invalid_expressions:
+ self.assertRaises(frappe.ValidationError, validate_python_code, expr)
\ No newline at end of file
diff --git a/frappe/utils/data.py b/frappe/utils/data.py
index df36524c16..77096ecea0 100644
--- a/frappe/utils/data.py
+++ b/frappe/utils/data.py
@@ -5,6 +5,7 @@ import frappe
import operator
import json
import re, datetime, math, time
+from code import compile_command
from urllib.parse import quote, urljoin
from frappe.desk.utils import slug
@@ -1511,6 +1512,34 @@ def get_user_info_for_avatar(user_id):
return user_info
+def validate_python_code(string: str, fieldname=None, is_expression: bool = True) -> None:
+ """ Validate python code fields by using compile_command to ensure that expression is valid python.
+
+ args:
+ fieldname: name of field being validated.
+ is_expression: true for validating simple single line python expression, else validated as script.
+ """
+
+ if not string:
+ return
+
+ try:
+ compile_command(string, symbol="eval" if is_expression else "exec")
+ except SyntaxError as se:
+ line_no = se.lineno - 1 or 0
+ offset = se.offset - 1 or 0
+ error_line = string if is_expression else string.split("\n")[line_no]
+ msg = (frappe._("{} Invalid python code on line {}")
+ .format(fieldname + ":" if fieldname else "", line_no+1))
+ msg += f"
{error_line}"
+ msg += f"{' ' * offset}^"
+
+ frappe.throw(msg, title=frappe._("Syntax Error"))
+ except Exception as e:
+ frappe.msgprint(frappe._("{} Possibly invalid python code. doc.grand_total > 0\n\nConditions should be written in simple Python. Please use properties available in the form only.
\nAllowed functions: \n
Example:
doc.creation > frappe.utils.add_to_date(frappe.utils.now_datetime(), days=-5, as_string=True, as_datetime=True) ",
- "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
+ "options": "doc.grand_total > 0\n\nConditions should be written in simple Python. Please use properties available in the form only.
\nAllowed functions: \n
Example:
doc.creation > frappe.utils.add_to_date(frappe.utils.now_datetime(), days=-5, as_string=True, as_datetime=True) "
}
],
- "has_web_view": 0,
- "hide_heading": 0,
- "hide_toolbar": 0,
"idx": 1,
- "image_view": 0,
- "in_create": 0,
- "is_submittable": 0,
- "issingle": 0,
"istable": 1,
- "max_attachments": 0,
- "modified": "2020-11-08 12:11:00.294908",
+ "links": [],
+ "modified": "2021-07-21 13:24:59.084836",
"modified_by": "Administrator",
"module": "Workflow",
"name": "Workflow Transition",
"owner": "Administrator",
"permissions": [],
- "quick_entry": 0,
- "read_only": 0,
- "read_only_onload": 0,
- "show_name_in_global_search": 0,
- "track_changes": 0,
- "track_seen": 0
+ "sort_field": "modified",
+ "sort_order": "DESC"
}
\ No newline at end of file