From 252850be9845d50dac1441e70a05b938a5eae45a Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Sat, 31 Jul 2021 15:39:56 +0530 Subject: [PATCH 1/8] fix: Internal link support for custom document links --- frappe/core/doctype/doctype/doctype.py | 9 +++++++ .../doctype/doctype_link/doctype_link.json | 26 ++++++++++++++++++- frappe/model/meta.py | 22 ++++++++++------ 3 files changed, 48 insertions(+), 9 deletions(-) diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 3cdc45ea08..f2afd62f93 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -725,6 +725,15 @@ 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: + 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/model/meta.py b/frappe/model/meta.py index b212324208..00d729cd1d 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -511,10 +511,13 @@ 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: @@ -523,12 +526,15 @@ class Meta(Document): label = link.group, items = [link.link_doctype] )) - - 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 + + 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: + data.internal_links[link.parent_doctype] = [link.table_fieldname, link.link_fieldname] def get_row_template(self): From b3e264781359f9c1478bef604c4221b7cab2590e Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Sat, 31 Jul 2021 15:52:37 +0530 Subject: [PATCH 2/8] fix: Linting issues --- frappe/core/doctype/doctype/doctype.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index f2afd62f93..cb429d86a3 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -731,7 +731,7 @@ def validate_links_table_fieldnames(meta): 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) + 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): From 4c400bd6c9e190a916c06b4f74211d49cd17d5e8 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Wed, 4 Aug 2021 17:10:33 +0530 Subject: [PATCH 3/8] test: Custom internal links addition --- frappe/core/doctype/doctype/doctype.py | 4 +++ .../customize_form/test_customize_form.py | 26 +++++++++++++++++++ frappe/model/meta.py | 7 ++++- 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 58423c9d88..810d6bb60a 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -722,6 +722,10 @@ 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) 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/model/meta.py b/frappe/model/meta.py index 00d729cd1d..ed85f466de 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -504,6 +504,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: @@ -524,7 +527,7 @@ class Meta(Document): # 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 not link.is_child_table: @@ -534,6 +537,8 @@ class Meta(Document): 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] From 85e5512c80a01be3493c18926abbd500bd3b2129 Mon Sep 17 00:00:00 2001 From: ChillarAnand Date: Wed, 4 Aug 2021 22:24:42 +0530 Subject: [PATCH 4/8] fix: Show traceback if custom app installation fails with exception --- frappe/commands/site.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 257723cb0acd3b7966c85963f3c7f4ac1534b2ec Mon Sep 17 00:00:00 2001 From: Ankush Date: Fri, 6 Aug 2021 09:40:09 +0530 Subject: [PATCH 5/8] feat: `PythonExpression` and `Python` option with syntax validation for `Code` field types (#13707) * feat: `PythonExpression` and `Python` options for `Code` fields * fix: check python expressions in assignment rule * fix: replace server script syntax validation * fix: validate condition in workflow transition Add PythonExpression in Options. --- .../assignment_rule/assignment_rule.json | 9 +- .../doctype/server_script/server_script.py | 5 - frappe/model/base_document.py | 12 + frappe/model/document.py | 1 + frappe/model/meta.py | 3 + frappe/tests/test_utils.py | 30 +- frappe/utils/data.py | 29 ++ .../workflow_transition.json | 279 ++---------------- 8 files changed, 102 insertions(+), 266 deletions(-) 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/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/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 ed85f466de..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"): diff --git a/frappe/tests/test_utils.py b/frappe/tests/test_utils.py index c2e5d99731..6fb6f6a379 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 @@ -187,4 +188,31 @@ class TestImage(unittest.TestCase): new_image = Image.open(io.BytesIO(new_image_content)) self.assertEqual(new_image._getexif(), None) - self.assertNotEqual(original_image._getexif(), new_image._getexif()) \ No newline at end of file + self.assertNotEqual(original_image._getexif(), new_image._getexif()) + +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) 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.
{}") + .format(fieldname + ": " or "", str(e)), indicator="orange") + + class UnicodeWithAttrs(str): def __init__(self, text): self.toc_html = text.toc_html diff --git a/frappe/workflow/doctype/workflow_transition/workflow_transition.json b/frappe/workflow/doctype/workflow_transition/workflow_transition.json index 5e5cec5880..79daee61b8 100644 --- a/frappe/workflow/doctype/workflow_transition/workflow_transition.json +++ b/frappe/workflow/doctype/workflow_transition/workflow_transition.json @@ -1,335 +1,100 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, + "actions": [], "creation": "2013-02-22 01:27:36", - "custom": 0, "description": "Defines actions on states and the next step and allowed roles.", - "docstatus": 0, "doctype": "DocType", "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "state", + "action", + "next_state", + "allowed", + "allow_self_approval", + "conditions", + "condition", + "column_break_7", + "example" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "state", "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": "State", - "length": 0, - "no_copy": 0, "options": "Workflow State", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, "print_width": "200px", - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, "width": "200px" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "action", "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": "Action", - "length": 0, - "no_copy": 0, "options": "Workflow Action Master", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, "print_width": "200px", - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, "width": "200px" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "next_state", "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": "Next State", - "length": 0, - "no_copy": 0, "options": "Workflow State", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, "print_width": "200px", - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, "width": "200px" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "allowed", "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": "Allowed", - "length": 0, - "no_copy": 0, "options": "Role", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, "print_width": "200px", - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, "width": "200px" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "default": "1", "description": "Allow approval for creator of the document", "fieldname": "allow_self_approval", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Allow Self Approval", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Allow Self Approval" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "conditions", "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Conditions", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Conditions" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "condition", "fieldtype": "Code", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, "label": "Condition", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "options": "PythonExpression" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "column_break_7", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldtype": "Column Break" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "example", "fieldtype": "HTML", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, "label": "Example", - "length": 0, - "no_copy": 0, - "options": "
doc.grand_total > 0
\n\n

Conditions should be written in simple Python. Please use properties available in the form only.

\n

Allowed functions: \n

    \n
  • frappe.db.get_value
  • \n
  • frappe.db.get_list
  • \n
  • frappe.session
  • \n
  • frappe.utils.now_datetime
  • \n
  • frappe.utils.get_datetime
  • \n
  • frappe.utils.add_to_date
  • \n
  • frappe.utils.now
  • \n
\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\n

Conditions should be written in simple Python. Please use properties available in the form only.

\n

Allowed functions: \n

    \n
  • frappe.db.get_value
  • \n
  • frappe.db.get_list
  • \n
  • frappe.session
  • \n
  • frappe.utils.now_datetime
  • \n
  • frappe.utils.get_datetime
  • \n
  • frappe.utils.add_to_date
  • \n
  • frappe.utils.now
  • \n
\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 From 770ca727644a5eb0bad27480fe702593bfccad81 Mon Sep 17 00:00:00 2001 From: Devin Slauenwhite Date: Fri, 6 Aug 2021 00:53:10 -0400 Subject: [PATCH 6/8] fix: standard pages message in website settings. (#13711) * fix: description of standard home page links. * fix: invalid standard pages message. * updated modified timestamp. Co-authored-by: Gavin D'souza --- .../website/doctype/website_settings/website_settings.json | 6 +++--- frappe/website/doctype/website_settings/website_settings.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frappe/website/doctype/website_settings/website_settings.json b/frappe/website/doctype/website_settings/website_settings.json index 9e04cf3795..f4eee7231e 100644 --- a/frappe/website/doctype/website_settings/website_settings.json +++ b/frappe/website/doctype/website_settings/website_settings.json @@ -77,7 +77,7 @@ "label": "Landing Page" }, { - "description": "Link that is the website home page. Standard Links (index, login, products, blog, about, contact)", + "description": "Link that is the website home page. Standard Links (home, login, products, blog, about, contact)", "fieldname": "home_page", "fieldtype": "Data", "in_list_view": 1, @@ -433,7 +433,7 @@ "issingle": 1, "links": [], "max_attachments": 10, - "modified": "2021-04-14 17:39:56.609771", + "modified": "2021-07-15 17:39:56.609771", "modified_by": "Administrator", "module": "Website", "name": "Website Settings", @@ -457,4 +457,4 @@ "sort_field": "modified", "sort_order": "ASC", "track_changes": 1 -} \ No newline at end of file +} diff --git a/frappe/website/doctype/website_settings/website_settings.py b/frappe/website/doctype/website_settings/website_settings.py index 1ccd106c38..cf45a94459 100644 --- a/frappe/website/doctype/website_settings/website_settings.py +++ b/frappe/website/doctype/website_settings/website_settings.py @@ -23,7 +23,7 @@ class WebsiteSettings(Document): return from frappe.website.path_resolver import PathResolver if self.home_page and not PathResolver(self.home_page).is_valid_path(): - frappe.msgprint(_("Invalid Home Page") + " (Standard pages - index, login, products, blog, about, contact)") + frappe.msgprint(_("Invalid Home Page") + " (Standard pages - home, login, products, blog, about, contact)") self.home_page = '' def validate_top_bar_items(self): From 8ed2c87e00ca26d9439c85c66b840557a1799bd3 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 6 Aug 2021 13:02:23 +0530 Subject: [PATCH 7/8] perf: fix unneneccsary clearing of db.value_cache --- frappe/database/database.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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] From a8fe3a86687b77a4fb300096211f95a945006407 Mon Sep 17 00:00:00 2001 From: Komal-Saraf0609 <81952590+Komal-Saraf0609@users.noreply.github.com> Date: Fri, 6 Aug 2021 13:22:24 +0530 Subject: [PATCH 8/8] test: Adding Cypress tests for sidebar, timeline and email testing (#13729) * test: fix get_field command * test: Add timeline email test cases * test: Add sidebar test cases * test: Add timeline test cases * test: Added new commands * test: Added proper name for test case, added comments and removed redundancy * test: Added proper name for test case, added comments and removed redundancy * test: Added proper name for test case, added comments and removed redundancy * test: Added new commands * test: Added proper name for test case, added comments and removed redundancy * test: Added proper name for test case, added comments and removed redundancy * fix: Sider issues * fix: Sider issues * fix: Sider issues * fix: UI Tests * fix: UI tests * fix: UI tests * fix: UI tests * fix: UI tests * fix: UI tests * fix: UI tests * fix: UI tests * fix: UI tests * fix: UI tests * fix: UI tests * fix: UI tests * fix: UI test * fix: UI tests * fix: UI tests * fix: Context correction * test: fix fill_field command * test: fixed get_field command (removed :visible for code) * test: Fixed fill_field command (removed .blur()) Co-authored-by: Suraj Shetty <13928957+surajshetty3416@users.noreply.github.com> --- cypress/integration/sidebar.js | 57 +++++++++++++++++++ cypress/integration/table_multiselect.js | 1 + cypress/integration/timeline.js | 53 ++++++++++++++++++ cypress/integration/timeline_email.js | 71 ++++++++++++++++++++++++ cypress/support/commands.js | 32 ++++++++++- 5 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 cypress/integration/sidebar.js create mode 100644 cypress/integration/timeline.js create mode 100644 cypress/integration/timeline_email.js 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