From 6bdcae1201de560971dc882c901ad18eb7f71f26 Mon Sep 17 00:00:00 2001 From: Kenneth Sequeira Date: Fri, 28 May 2021 21:19:21 +0530 Subject: [PATCH 001/244] feat: add number format parameter in doc.get_formatted --- frappe/model/base_document.py | 4 ++-- frappe/utils/formatters.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 54d77ba988..1ac07f5fb7 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -862,7 +862,7 @@ class BaseDocument(object): return self._precision[cache_key][fieldname] - def get_formatted(self, fieldname, doc=None, currency=None, absolute_value=False, translated=False): + def get_formatted(self, fieldname, doc=None, currency=None, absolute_value=False, translated=False, format=None): from frappe.utils.formatters import format_value df = self.meta.get_field(fieldname) @@ -886,7 +886,7 @@ class BaseDocument(object): if (absolute_value or doc.get('absolute_value')) and isinstance(val, (int, float)): val = abs(self.get(fieldname)) - return format_value(val, df=df, doc=doc, currency=currency) + return format_value(val, df=df, doc=doc, currency=currency, format=format) def is_print_hide(self, fieldname, df=None, for_print=True): """Returns true if fieldname is to be hidden for print. diff --git a/frappe/utils/formatters.py b/frappe/utils/formatters.py index 7913413878..c0c7e4bca0 100644 --- a/frappe/utils/formatters.py +++ b/frappe/utils/formatters.py @@ -9,7 +9,7 @@ from frappe.model.meta import get_field_currency, get_field_precision import re from six import string_types -def format_value(value, df=None, doc=None, currency=None, translated=False): +def format_value(value, df=None, doc=None, currency=None, translated=False, format=None): '''Format value based on given fieldtype, document reference, currency reference. If docfield info (df) is not given, it will try and guess based on the datatype of the value''' if isinstance(df, string_types): @@ -58,7 +58,7 @@ def format_value(value, df=None, doc=None, currency=None, translated=False): elif df.get("fieldtype") == "Currency": default_currency = frappe.db.get_default("currency") currency = currency or get_field_currency(df, doc) or default_currency - return fmt_money(value, precision=get_field_precision(df, doc), currency=currency) + return fmt_money(value, precision=get_field_precision(df, doc), currency=currency, format=format) elif df.get("fieldtype") == "Float": precision = get_field_precision(df, doc) From 4bafae6e38d898096c49ce2bf5600f0772f05f17 Mon Sep 17 00:00:00 2001 From: Kenneth Sequeira Date: Sat, 12 Jun 2021 22:04:00 +0530 Subject: [PATCH 002/244] fix: add test cases --- frappe/tests/test_document.py | 26 ++++++++++++++++++++++++++ frappe/tests/test_fmt_money.py | 3 +++ frappe/tests/test_formatter.py | 23 ++++++++++++++++++++++- 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/frappe/tests/test_document.py b/frappe/tests/test_document.py index 1a5a8721fd..eca547e9bd 100644 --- a/frappe/tests/test_document.py +++ b/frappe/tests/test_document.py @@ -229,3 +229,29 @@ class TestDocument(unittest.TestCase): self.assertEqual(frappe.db.get_value("Currency", d.name), d.name) frappe.delete_doc_if_exists("Currency", "Frappe Coin", 1) + + def test_get_formatted(self): + frappe.get_doc({ + 'doctype': 'DocType', + 'name': 'Test Formatted', + 'module': 'Custom', + 'custom': 1, + 'istable': 1, + 'fields': [ + {'label': 'Currency', 'fieldname': 'currency', 'reqd': 1, 'fieldtype': 'Currency'}, + ] + }).insert() + + frappe.delete_doc_if_exists("Currency", "INR", 1) + + d = frappe.get_doc({ + 'doctype': 'Currency', + 'currency_name': 'INR', + 'symbol': '₹', + }).insert() + + d = frappe.get_doc({ + 'doctype': 'Test Formatted', + 'currency': 100000 + }) + self.assertEquals(d.get_formatted('curency', currency='INR', format="#,###.##"), '₹ 100,000.00') \ No newline at end of file diff --git a/frappe/tests/test_fmt_money.py b/frappe/tests/test_fmt_money.py index a1321658b7..8d76b4dcb4 100644 --- a/frappe/tests/test_fmt_money.py +++ b/frappe/tests/test_fmt_money.py @@ -94,6 +94,9 @@ class TestFmtMoney(unittest.TestCase): self.assertEqual(fmt_money(1000.456), "1.000,456") frappe.db.set_default("currency_precision", "") + def test_custom_fmt_money_format(self): + self.assertEqual(fmt_money(100000, format="#,###.##"), '100,000.00') + if __name__=="__main__": frappe.connect() unittest.main() \ No newline at end of file diff --git a/frappe/tests/test_formatter.py b/frappe/tests/test_formatter.py index 5257e1c717..5423d92520 100644 --- a/frappe/tests/test_formatter.py +++ b/frappe/tests/test_formatter.py @@ -22,4 +22,25 @@ class TestFormatter(unittest.TestCase): doc.currency = 'USD' self.assertEqual(format(100, df, doc), "$ 100.00") - frappe.db.set_default("currency", None) \ No newline at end of file + frappe.db.set_default("currency", None) + + def test_custom_currency_formatting(self): + df = frappe._dict({ + 'fieldname': 'amount', + 'fieldtype': 'Currency', + 'options': 'currency' + }) + + doc = frappe._dict({ + 'amount': 5 + }) + frappe.db.set_default("currency", 'INR') + + # if currency field is not passed then default currency should be used. + self.assertEqual(format(100000, df, doc, format="#,###.##"), '₹ 100,000.00') + + doc.currency = 'USD' + self.assertEqual(format(100000, df, doc, format="#,###.##"), '$ 100,000.00') + + frappe.db.set_default("currency", None) + \ No newline at end of file From 3336915dae72836ff85396f2c344422348bc9e8d Mon Sep 17 00:00:00 2001 From: Kenneth Sequeira Date: Sat, 12 Jun 2021 23:15:47 +0530 Subject: [PATCH 003/244] fix: test_document test --- frappe/tests/test_document.py | 1 - 1 file changed, 1 deletion(-) diff --git a/frappe/tests/test_document.py b/frappe/tests/test_document.py index eca547e9bd..a70f99aaad 100644 --- a/frappe/tests/test_document.py +++ b/frappe/tests/test_document.py @@ -236,7 +236,6 @@ class TestDocument(unittest.TestCase): 'name': 'Test Formatted', 'module': 'Custom', 'custom': 1, - 'istable': 1, 'fields': [ {'label': 'Currency', 'fieldname': 'currency', 'reqd': 1, 'fieldtype': 'Currency'}, ] From 13caf6910a4bc041a863db0106a9ec1b7b62668b Mon Sep 17 00:00:00 2001 From: Kenneth Sequeira Date: Tue, 15 Jun 2021 22:33:06 +0530 Subject: [PATCH 004/244] fix: test_document test fix --- frappe/tests/test_document.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/tests/test_document.py b/frappe/tests/test_document.py index a70f99aaad..ed4901a79a 100644 --- a/frappe/tests/test_document.py +++ b/frappe/tests/test_document.py @@ -253,4 +253,4 @@ class TestDocument(unittest.TestCase): 'doctype': 'Test Formatted', 'currency': 100000 }) - self.assertEquals(d.get_formatted('curency', currency='INR', format="#,###.##"), '₹ 100,000.00') \ No newline at end of file + self.assertEquals(d.get_formatted('currency', currency='INR', format="#,###.##"), '₹ 100,000.00') \ No newline at end of file From f5e40141af794ae770df3c39ee130a8509b07f50 Mon Sep 17 00:00:00 2001 From: Kenneth Sequeira Date: Tue, 15 Jun 2021 22:46:05 +0530 Subject: [PATCH 005/244] fix: readding test for test_formatter --- frappe/tests/test_formatter.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frappe/tests/test_formatter.py b/frappe/tests/test_formatter.py index 5423d92520..636f3d970d 100644 --- a/frappe/tests/test_formatter.py +++ b/frappe/tests/test_formatter.py @@ -40,7 +40,6 @@ class TestFormatter(unittest.TestCase): self.assertEqual(format(100000, df, doc, format="#,###.##"), '₹ 100,000.00') doc.currency = 'USD' - self.assertEqual(format(100000, df, doc, format="#,###.##"), '$ 100,000.00') + self.assertEqual(format(100000, df, doc, format="#,###.##"), "$ 100,000.00") frappe.db.set_default("currency", None) - \ No newline at end of file From bfc7c6b10c4dd234f9534e6de5089a4bcbb8cc6b Mon Sep 17 00:00:00 2001 From: shariquerik Date: Mon, 21 Jun 2021 14:42:19 +0530 Subject: [PATCH 006/244] fix: datetime field form validation fix --- frappe/public/js/frappe/form/controls/base_control.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/form/controls/base_control.js b/frappe/public/js/frappe/form/controls/base_control.js index d6c268a28a..ee40bbe43d 100644 --- a/frappe/public/js/frappe/form/controls/base_control.js +++ b/frappe/public/js/frappe/form/controls/base_control.js @@ -160,7 +160,13 @@ frappe.ui.form.Control = class BaseControl { validate_and_set_in_model(value, e) { var me = this; let force_value_set = (this.doc && this.doc.__run_link_triggers); - let is_value_same = (this.get_model_value() === value); + let model_value = this.get_model_value(); + + if (this.df && this.df.fieldtype == 'Datetime') { + model_value = frappe.datetime.get_datetime_as_string(model_value); + } + + let is_value_same = (model_value === value); if (this.inside_change_event || (!force_value_set && is_value_same)) { return Promise.resolve(); From 7ad1e2d11db2261dc34693d68526104e0a51ce78 Mon Sep 17 00:00:00 2001 From: shariquerik Date: Tue, 22 Jun 2021 17:13:20 +0530 Subject: [PATCH 007/244] fix: Webform Permission for custom doctypem --- frappe/www/list.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frappe/www/list.py b/frappe/www/list.py index 5e4e491c80..975347adac 100644 --- a/frappe/www/list.py +++ b/frappe/www/list.py @@ -161,6 +161,14 @@ def get_list_context(context, doctype, web_form_name=None): module = load_doctype_module(doctype) list_context = update_context_from_module(module, list_context) + # get context for custom doctype + if meta.custom: + get_custom_website_context = frappe.get_hooks('get_custom_website_context') + if get_custom_website_context: + out = frappe._dict(frappe.get_attr(get_custom_website_context[0])() or {}) + if out: + list_context = out + # get context from web form module if web_form_name: web_form = frappe.get_doc('Web Form', web_form_name) From 61deecbd7c1267d170206969b60e326e217ef6fa Mon Sep 17 00:00:00 2001 From: Mohammad Hasnain Mohsin Rajan Date: Mon, 28 Jun 2021 18:30:55 +0530 Subject: [PATCH 008/244] chore: fix tests --- frappe/tests/test_formatter.py | 1 + 1 file changed, 1 insertion(+) diff --git a/frappe/tests/test_formatter.py b/frappe/tests/test_formatter.py index 636f3d970d..a837573dbc 100644 --- a/frappe/tests/test_formatter.py +++ b/frappe/tests/test_formatter.py @@ -37,6 +37,7 @@ class TestFormatter(unittest.TestCase): frappe.db.set_default("currency", 'INR') # if currency field is not passed then default currency should be used. + print(doc.currency) self.assertEqual(format(100000, df, doc, format="#,###.##"), '₹ 100,000.00') doc.currency = 'USD' From 4b0c67f57cd68ec591ecf100eb2ec7de77b025ba Mon Sep 17 00:00:00 2001 From: Mohammad Hasnain Mohsin Rajan Date: Mon, 28 Jun 2021 18:43:03 +0530 Subject: [PATCH 009/244] chore: debug tests --- frappe/tests/test_formatter.py | 1 + 1 file changed, 1 insertion(+) diff --git a/frappe/tests/test_formatter.py b/frappe/tests/test_formatter.py index a837573dbc..5ebfb3ee6d 100644 --- a/frappe/tests/test_formatter.py +++ b/frappe/tests/test_formatter.py @@ -37,6 +37,7 @@ class TestFormatter(unittest.TestCase): frappe.db.set_default("currency", 'INR') # if currency field is not passed then default currency should be used. + print("doc.currency") print(doc.currency) self.assertEqual(format(100000, df, doc, format="#,###.##"), '₹ 100,000.00') From 64e37d6ab9ba7b9d3b9804d34b65a280b0b2a74b Mon Sep 17 00:00:00 2001 From: Mohammad Hasnain Mohsin Rajan Date: Tue, 29 Jun 2021 10:16:32 +0530 Subject: [PATCH 010/244] chore: debug tests --- frappe/tests/test_formatter.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frappe/tests/test_formatter.py b/frappe/tests/test_formatter.py index 5ebfb3ee6d..cd7b8add9d 100644 --- a/frappe/tests/test_formatter.py +++ b/frappe/tests/test_formatter.py @@ -39,6 +39,9 @@ class TestFormatter(unittest.TestCase): # if currency field is not passed then default currency should be used. print("doc.currency") print(doc.currency) + print("frappe.db.get_default('currency')") + print(frappe.db.get_default("currency")) + self.assertEqual(format(100000, df, doc, format="#,###.##"), '₹ 100,000.00') doc.currency = 'USD' From 82c1c56671032e4528876e091b03d53fd150410f Mon Sep 17 00:00:00 2001 From: GangaManoj Date: Fri, 2 Jul 2021 14:37:09 +0530 Subject: [PATCH 011/244] fix: Check for key, not attribute, for dictionary --- frappe/desk/doctype/workspace/workspace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/desk/doctype/workspace/workspace.py b/frappe/desk/doctype/workspace/workspace.py index 0b5babc8d9..5bf0f472e8 100644 --- a/frappe/desk/doctype/workspace/workspace.py +++ b/frappe/desk/doctype/workspace/workspace.py @@ -55,7 +55,7 @@ class Workspace(Document): for link in self.links: link = link.as_dict() if link.type == "Card Break": - if card_links and (not current_card.only_for or current_card.only_for == frappe.get_system_settings('country')): + if card_links and (not current_card['only_for'] or current_card['only_for'] == frappe.get_system_settings('country')): current_card['links'] = card_links cards.append(current_card) From d39a389fd5a066bb08b5805550f6b86d0b4618d3 Mon Sep 17 00:00:00 2001 From: shariquerik Date: Mon, 12 Jul 2021 17:01:30 +0530 Subject: [PATCH 012/244] fix: app check condition for getting correct list_context --- frappe/www/list.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frappe/www/list.py b/frappe/www/list.py index 975347adac..3ed103b69d 100644 --- a/frappe/www/list.py +++ b/frappe/www/list.py @@ -161,11 +161,11 @@ def get_list_context(context, doctype, web_form_name=None): module = load_doctype_module(doctype) list_context = update_context_from_module(module, list_context) - # get context for custom doctype - if meta.custom: - get_custom_website_context = frappe.get_hooks('get_custom_website_context') - if get_custom_website_context: - out = frappe._dict(frappe.get_attr(get_custom_website_context[0])() or {}) + # get context for custom webform + if meta.custom and web_form_name: + list_context_for_custom_webform = frappe.get_hooks('get_list_context_for_custom_webform') + if list_context_for_custom_webform: + out = frappe._dict(frappe.get_attr(list_context_for_custom_webform[0])(meta.module) or {}) if out: list_context = out From 75cb917a7beee86d1d22a100f083a77b6f7ba925 Mon Sep 17 00:00:00 2001 From: shariquerik Date: Tue, 13 Jul 2021 21:31:03 +0530 Subject: [PATCH 013/244] test: UI test for datetime field form validation --- .../datetime_field_form_validation.js | 18 ++++++++++++++++++ frappe/tests/ui_test_helpers.py | 12 ++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 cypress/integration/datetime_field_form_validation.js diff --git a/cypress/integration/datetime_field_form_validation.js b/cypress/integration/datetime_field_form_validation.js new file mode 100644 index 0000000000..6a6ea4c7af --- /dev/null +++ b/cypress/integration/datetime_field_form_validation.js @@ -0,0 +1,18 @@ +context('Datetime Validation', () => { + before(() => { + cy.login(); + cy.visit('/app/communication'); + cy.window().its('frappe').then(frappe => { + frappe.call("frappe.tests.ui_test_helpers.create_communication_records"); + }); + }); + + it('datetime field form validation', () => { + cy.visit('/app/communication'); + cy.get('a[title="Test Form Communication 1"]').invoke('attr', 'data-name') + .then((name) => { + cy.visit(`/app/communication/${name}`); + cy.get('.indicator-pill').should('contain', 'Open').should('have.class', 'red'); + }) + }); +}); \ No newline at end of file diff --git a/frappe/tests/ui_test_helpers.py b/frappe/tests/ui_test_helpers.py index f56311b2e3..f4a6f474ce 100644 --- a/frappe/tests/ui_test_helpers.py +++ b/frappe/tests/ui_test_helpers.py @@ -60,6 +60,18 @@ def create_todo_records(): "description": "this is fourth todo" }).insert() +@frappe.whitelist() +def create_communication_records(): + if frappe.db.get_all('Communication', {'subject': 'Test Form Communication 1'}): + return + + frappe.get_doc({ + "doctype": "Communication", + "recipients": "test@gmail.com", + "subject": "Test Form Communication 1", + "communication_date": frappe.utils.now_datetime(), + }).insert() + @frappe.whitelist() def setup_workflow(): from frappe.workflow.doctype.workflow.test_workflow import create_todo_workflow From 98f33b59811f2cdcf9b66f1736e5bedfb77735cc Mon Sep 17 00:00:00 2001 From: shariquerik Date: Tue, 13 Jul 2021 21:56:57 +0530 Subject: [PATCH 014/244] fix: sider fix --- cypress/integration/datetime_field_form_validation.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cypress/integration/datetime_field_form_validation.js b/cypress/integration/datetime_field_form_validation.js index 6a6ea4c7af..dff1f0562c 100644 --- a/cypress/integration/datetime_field_form_validation.js +++ b/cypress/integration/datetime_field_form_validation.js @@ -10,9 +10,9 @@ context('Datetime Validation', () => { it('datetime field form validation', () => { cy.visit('/app/communication'); cy.get('a[title="Test Form Communication 1"]').invoke('attr', 'data-name') - .then((name) => { - cy.visit(`/app/communication/${name}`); - cy.get('.indicator-pill').should('contain', 'Open').should('have.class', 'red'); - }) + .then((name) => { + cy.visit(`/app/communication/${name}`); + cy.get('.indicator-pill').should('contain', 'Open').should('have.class', 'red'); + }); }); }); \ No newline at end of file From e3a1f6f1d09c4973a20c6a562697cb850cb3bc03 Mon Sep 17 00:00:00 2001 From: shariquerik Date: Wed, 14 Jul 2021 13:25:17 +0530 Subject: [PATCH 015/244] chore: Added comment --- cypress/integration/datetime_field_form_validation.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cypress/integration/datetime_field_form_validation.js b/cypress/integration/datetime_field_form_validation.js index dff1f0562c..66fdde6863 100644 --- a/cypress/integration/datetime_field_form_validation.js +++ b/cypress/integration/datetime_field_form_validation.js @@ -1,4 +1,4 @@ -context('Datetime Validation', () => { +context('Datetime Field Validation', () => { before(() => { cy.login(); cy.visit('/app/communication'); @@ -7,6 +7,7 @@ context('Datetime Validation', () => { }); }); + // validating datetime field value when value is set from backend and get validated on form load. it('datetime field form validation', () => { cy.visit('/app/communication'); cy.get('a[title="Test Form Communication 1"]').invoke('attr', 'data-name') From 151678d71973d16916574f7e39d1fbb0fc815d79 Mon Sep 17 00:00:00 2001 From: GangaManoj Date: Sat, 17 Jul 2021 05:38:08 +0530 Subject: [PATCH 016/244] fix: Add test for checking workspaces with cards specific to a country --- .../desk/doctype/workspace/test_workspace.py | 92 ++++++++++++++++++- 1 file changed, 89 insertions(+), 3 deletions(-) diff --git a/frappe/desk/doctype/workspace/test_workspace.py b/frappe/desk/doctype/workspace/test_workspace.py index 619b3608eb..8aa3d57adf 100644 --- a/frappe/desk/doctype/workspace/test_workspace.py +++ b/frappe/desk/doctype/workspace/test_workspace.py @@ -1,8 +1,94 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and Contributors # See license.txt -# import frappe +import frappe import unittest - class TestWorkspace(unittest.TestCase): - pass + def setUp(self): + create_module("Test Module") + + def tearDown(self): + frappe.db.delete("Workspace", {"module": "Test Module"}) + frappe.db.delete("DocType", {"module": "Test Module"}) + frappe.delete_doc("Module Def", "Test Module") + + def test_workspace_with_cards_specific_to_a_country(self): + workspace = create_workspace() + insert_card(workspace, "Card Label 1", "DocType 1", "DocType 2", "France") + insert_card(workspace, "Card Label 2", "DocType A", "DocType B") + + workspace.insert(ignore_if_duplicate = True) + + cards = workspace.get_link_groups() + + if frappe.get_system_settings('country') == "France": + self.assertEqual(len(cards), 2) + else: + self.assertEqual(len(cards), 1) + +def create_module(module_name): + module = frappe.get_doc({ + "doctype": "Module Def", + "module_name": module_name, + "app_name": "frappe" + }) + module.insert(ignore_if_duplicate = True) + + return module + +def create_workspace(**args): + workspace = frappe.new_doc("Workspace") + args = frappe._dict(args) + + workspace.name = args.name or "Test Workspace" + workspace.label = args.label or "Test Workspace" + workspace.category = args.category or "Modules" + workspace.is_standard = args.is_standard or 1 + workspace.module = "Test Module" + + return workspace + +def insert_card(workspace, card_label, doctype1, doctype2, country=None): + workspace.append("links", { + "type": "Card Break", + "label": card_label, + "only_for": country + }) + + create_doctype(doctype1, "Test Module") + workspace.append("links", { + "type": "Link", + "label": doctype1, + "only_for": country, + "link_type": "DocType", + "link_to": doctype1 + }) + + create_doctype(doctype2, "Test Module") + workspace.append("links", { + "type": "Link", + "label": doctype2, + "only_for": country, + "link_type": "DocType", + "link_to": doctype2 + }) + +def create_doctype(doctype_name, module): + frappe.get_doc({ + 'doctype': 'DocType', + 'name': doctype_name, + 'module': module, + 'custom': 1, + 'autoname': 'field:title', + 'fields': [ + {'label': 'Title', 'fieldname': 'title', 'reqd': 1, 'fieldtype': 'Data'}, + {'label': 'Description', 'fieldname': 'description', 'fieldtype': 'Small Text'}, + {'label': 'Date', 'fieldname': 'date', 'fieldtype': 'Date'}, + {'label': 'Duration', 'fieldname': 'duration', 'fieldtype': 'Duration'}, + {'label': 'Number', 'fieldname': 'number', 'fieldtype': 'Int'}, + {'label': 'Number', 'fieldname': 'another_number', 'fieldtype': 'Int'} + ], + 'permissions': [ + {'role': 'System Manager'} + ] + }).insert(ignore_if_duplicate = True) \ No newline at end of file From 4a546942e4711ce5e25c78970faa8e4b016d9137 Mon Sep 17 00:00:00 2001 From: Kenneth Sequeira Date: Tue, 20 Jul 2021 20:09:18 +0530 Subject: [PATCH 017/244] fix: currency test --- frappe/tests/test_currency_formatter.py | 30 +++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 frappe/tests/test_currency_formatter.py diff --git a/frappe/tests/test_currency_formatter.py b/frappe/tests/test_currency_formatter.py new file mode 100644 index 0000000000..75cb42dc49 --- /dev/null +++ b/frappe/tests/test_currency_formatter.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +import frappe +from frappe import format +import unittest + +class TestFormatter(unittest.TestCase): + def test_custom_currency_formatting(self): + df = frappe._dict({ + 'fieldname': 'amount', + 'fieldtype': 'Currency', + 'options': 'currency' + }) + + doc = frappe._dict({ + 'amount': 5 + }) + frappe.db.set_default("currency", 'INR') + + # if currency field is not passed then default currency should be used. + print("doc.currency") + print(doc.currency) + print("frappe.db.get_default('currency')") + print(frappe.db.get_default("currency")) + + self.assertEqual(format(100000, df, doc, format="#,###.##"), '₹ 100,000.00') + + doc.currency = 'USD' + self.assertEqual(format(100000, df, doc, format="#,###.##"), "$ 100,000.00") + + frappe.db.set_default("currency", None) From e0e1c15d760e71c78ac2a415a91f69a491a7d4c4 Mon Sep 17 00:00:00 2001 From: Kenneth Sequeira Date: Tue, 20 Jul 2021 20:10:15 +0530 Subject: [PATCH 018/244] fix: remove redundant code --- frappe/tests/test_formatter.py | 27 +-------------------------- 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/frappe/tests/test_formatter.py b/frappe/tests/test_formatter.py index cd7b8add9d..5257e1c717 100644 --- a/frappe/tests/test_formatter.py +++ b/frappe/tests/test_formatter.py @@ -22,29 +22,4 @@ class TestFormatter(unittest.TestCase): doc.currency = 'USD' self.assertEqual(format(100, df, doc), "$ 100.00") - frappe.db.set_default("currency", None) - - def test_custom_currency_formatting(self): - df = frappe._dict({ - 'fieldname': 'amount', - 'fieldtype': 'Currency', - 'options': 'currency' - }) - - doc = frappe._dict({ - 'amount': 5 - }) - frappe.db.set_default("currency", 'INR') - - # if currency field is not passed then default currency should be used. - print("doc.currency") - print(doc.currency) - print("frappe.db.get_default('currency')") - print(frappe.db.get_default("currency")) - - self.assertEqual(format(100000, df, doc, format="#,###.##"), '₹ 100,000.00') - - doc.currency = 'USD' - self.assertEqual(format(100000, df, doc, format="#,###.##"), "$ 100,000.00") - - frappe.db.set_default("currency", None) + frappe.db.set_default("currency", None) \ No newline at end of file From e7e8fcd6221dc662feb7b5421ef40f7eb7b9414d Mon Sep 17 00:00:00 2001 From: Kenneth Sequeira Date: Tue, 20 Jul 2021 20:32:24 +0530 Subject: [PATCH 019/244] fix: old currency test --- frappe/tests/test_currency_formatter.py | 4 ---- frappe/tests/test_formatter.py | 2 ++ 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/frappe/tests/test_currency_formatter.py b/frappe/tests/test_currency_formatter.py index 75cb42dc49..c85f2d3c40 100644 --- a/frappe/tests/test_currency_formatter.py +++ b/frappe/tests/test_currency_formatter.py @@ -17,10 +17,6 @@ class TestFormatter(unittest.TestCase): frappe.db.set_default("currency", 'INR') # if currency field is not passed then default currency should be used. - print("doc.currency") - print(doc.currency) - print("frappe.db.get_default('currency')") - print(frappe.db.get_default("currency")) self.assertEqual(format(100000, df, doc, format="#,###.##"), '₹ 100,000.00') diff --git a/frappe/tests/test_formatter.py b/frappe/tests/test_formatter.py index 5257e1c717..701e81bb9a 100644 --- a/frappe/tests/test_formatter.py +++ b/frappe/tests/test_formatter.py @@ -20,6 +20,8 @@ class TestFormatter(unittest.TestCase): self.assertEqual(format(100, df, doc), '₹ 100.00') doc.currency = 'USD' + print('doc.currency') + print(doc.currency) self.assertEqual(format(100, df, doc), "$ 100.00") frappe.db.set_default("currency", None) \ No newline at end of file From 48d1d11656a61c737427dc7d488d5ce639b9ed15 Mon Sep 17 00:00:00 2001 From: Kenneth Sequeira Date: Tue, 20 Jul 2021 21:06:27 +0530 Subject: [PATCH 020/244] fix: modified original test --- frappe/tests/test_currency_formatter.py | 26 ------------------------- frappe/tests/test_formatter.py | 6 ++---- 2 files changed, 2 insertions(+), 30 deletions(-) delete mode 100644 frappe/tests/test_currency_formatter.py diff --git a/frappe/tests/test_currency_formatter.py b/frappe/tests/test_currency_formatter.py deleted file mode 100644 index c85f2d3c40..0000000000 --- a/frappe/tests/test_currency_formatter.py +++ /dev/null @@ -1,26 +0,0 @@ -# -*- coding: utf-8 -*- -import frappe -from frappe import format -import unittest - -class TestFormatter(unittest.TestCase): - def test_custom_currency_formatting(self): - df = frappe._dict({ - 'fieldname': 'amount', - 'fieldtype': 'Currency', - 'options': 'currency' - }) - - doc = frappe._dict({ - 'amount': 5 - }) - frappe.db.set_default("currency", 'INR') - - # if currency field is not passed then default currency should be used. - - self.assertEqual(format(100000, df, doc, format="#,###.##"), '₹ 100,000.00') - - doc.currency = 'USD' - self.assertEqual(format(100000, df, doc, format="#,###.##"), "$ 100,000.00") - - frappe.db.set_default("currency", None) diff --git a/frappe/tests/test_formatter.py b/frappe/tests/test_formatter.py index 701e81bb9a..5454c2b1cd 100644 --- a/frappe/tests/test_formatter.py +++ b/frappe/tests/test_formatter.py @@ -17,11 +17,9 @@ class TestFormatter(unittest.TestCase): frappe.db.set_default("currency", 'INR') # if currency field is not passed then default currency should be used. - self.assertEqual(format(100, df, doc), '₹ 100.00') + self.assertEqual(format(100000, df, doc, format="#,###.##"), '₹ 100,000.00') doc.currency = 'USD' - print('doc.currency') - print(doc.currency) - self.assertEqual(format(100, df, doc), "$ 100.00") + self.assertEqual(format(100000, df, doc, format="#,###.##"), "$ 100,000.00") frappe.db.set_default("currency", None) \ No newline at end of file From d014084ae5f3b2d97a4ae9c3ff7f1f41f9bd7732 Mon Sep 17 00:00:00 2001 From: shariquerik Date: Mon, 2 Aug 2021 20:23:44 +0530 Subject: [PATCH 021/244] fix: moved datetime logic to datetime.js --- frappe/public/js/frappe/form/controls/base_control.js | 4 +--- frappe/public/js/frappe/form/controls/datetime.js | 4 ++++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/frappe/public/js/frappe/form/controls/base_control.js b/frappe/public/js/frappe/form/controls/base_control.js index ee40bbe43d..7977458ad7 100644 --- a/frappe/public/js/frappe/form/controls/base_control.js +++ b/frappe/public/js/frappe/form/controls/base_control.js @@ -162,9 +162,7 @@ frappe.ui.form.Control = class BaseControl { let force_value_set = (this.doc && this.doc.__run_link_triggers); let model_value = this.get_model_value(); - if (this.df && this.df.fieldtype == 'Datetime') { - model_value = frappe.datetime.get_datetime_as_string(model_value); - } + model_value = this.parse_model_value && this.parse_model_value(model_value); let is_value_same = (model_value === value); diff --git a/frappe/public/js/frappe/form/controls/datetime.js b/frappe/public/js/frappe/form/controls/datetime.js index 341a933066..7a3cef2304 100644 --- a/frappe/public/js/frappe/form/controls/datetime.js +++ b/frappe/public/js/frappe/form/controls/datetime.js @@ -36,4 +36,8 @@ frappe.ui.form.ControlDatetime = class ControlDatetime extends frappe.ui.form.Co $tp.$secondsText.prev().css('display', 'none'); } } + + parse_model_value(value) { + return frappe.datetime.get_datetime_as_string(value); + } }; From b2b44b5939c66ffc84d16b903146c2697e6b0874 Mon Sep 17 00:00:00 2001 From: Pruthvi Patel Date: Thu, 5 Aug 2021 16:49:20 +0530 Subject: [PATCH 022/244] feat: add autoreloader for ipython --- frappe/commands/utils.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index 8fc6877d4f..73e8002bc6 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -494,7 +494,11 @@ def console(context): frappe.connect() frappe.local.lang = frappe.db.get_default("lang") - import IPython + from IPython.terminal import embed + terminal = embed.InteractiveShellEmbed() + terminal.extension_manager.load_extension("autoreload") + terminal.run_line_magic("autoreload", "2") + all_apps = frappe.get_installed_apps() failed_to_import = [] @@ -508,8 +512,10 @@ def console(context): print("Apps in this namespace:\n{}".format(", ".join(all_apps))) if failed_to_import: print("\nFailed to import:\n{}".format(", ".join(failed_to_import))) - - IPython.embed(display_banner="", header="", colors="neutral") + + terminal.colors = "neutral" + terminal.display_banner = False + terminal() @click.command('run-tests') From 6db4b6f2e629f05f7695b81b18ea8db270b348e6 Mon Sep 17 00:00:00 2001 From: Pruthvi Patel Date: Thu, 5 Aug 2021 17:51:38 +0530 Subject: [PATCH 023/244] fix: autoreload for only developer mode --- frappe/commands/utils.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index 73e8002bc6..765028dfed 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -494,10 +494,12 @@ def console(context): frappe.connect() frappe.local.lang = frappe.db.get_default("lang") - from IPython.terminal import embed - terminal = embed.InteractiveShellEmbed() - terminal.extension_manager.load_extension("autoreload") - terminal.run_line_magic("autoreload", "2") + from IPython.terminal.embed import InteractiveShellEmbed + + terminal = InteractiveShellEmbed() + if frappe.conf.developer_mode: + terminal.extension_manager.load_extension("autoreload") + terminal.run_line_magic("autoreload", "2") all_apps = frappe.get_installed_apps() failed_to_import = [] @@ -512,7 +514,7 @@ def console(context): print("Apps in this namespace:\n{}".format(", ".join(all_apps))) if failed_to_import: print("\nFailed to import:\n{}".format(", ".join(failed_to_import))) - + terminal.colors = "neutral" terminal.display_banner = False terminal() From ed99915a7909e0cb53d910409531fbc13a9a1ce7 Mon Sep 17 00:00:00 2001 From: shariquerik Date: Tue, 10 Aug 2021 11:20:23 +0530 Subject: [PATCH 024/244] refactor: Refactored code --- frappe/public/js/frappe/form/controls/base_control.js | 6 +----- frappe/public/js/frappe/form/controls/datetime.js | 3 ++- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/frappe/public/js/frappe/form/controls/base_control.js b/frappe/public/js/frappe/form/controls/base_control.js index 7977458ad7..d6c268a28a 100644 --- a/frappe/public/js/frappe/form/controls/base_control.js +++ b/frappe/public/js/frappe/form/controls/base_control.js @@ -160,11 +160,7 @@ frappe.ui.form.Control = class BaseControl { validate_and_set_in_model(value, e) { var me = this; let force_value_set = (this.doc && this.doc.__run_link_triggers); - let model_value = this.get_model_value(); - - model_value = this.parse_model_value && this.parse_model_value(model_value); - - let is_value_same = (model_value === value); + let is_value_same = (this.get_model_value() === value); if (this.inside_change_event || (!force_value_set && is_value_same)) { return Promise.resolve(); diff --git a/frappe/public/js/frappe/form/controls/datetime.js b/frappe/public/js/frappe/form/controls/datetime.js index 7a3cef2304..3fb00a6f26 100644 --- a/frappe/public/js/frappe/form/controls/datetime.js +++ b/frappe/public/js/frappe/form/controls/datetime.js @@ -37,7 +37,8 @@ frappe.ui.form.ControlDatetime = class ControlDatetime extends frappe.ui.form.Co } } - parse_model_value(value) { + get_model_value() { + let value = super.get_model_value() return frappe.datetime.get_datetime_as_string(value); } }; From 9da6fcadc8b1062afd14d4c6943a8f1a2d07e7eb Mon Sep 17 00:00:00 2001 From: shariquerik Date: Tue, 10 Aug 2021 11:31:16 +0530 Subject: [PATCH 025/244] fix: sider fix --- frappe/public/js/frappe/form/controls/datetime.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/form/controls/datetime.js b/frappe/public/js/frappe/form/controls/datetime.js index 3fb00a6f26..f7a2798a99 100644 --- a/frappe/public/js/frappe/form/controls/datetime.js +++ b/frappe/public/js/frappe/form/controls/datetime.js @@ -38,7 +38,7 @@ frappe.ui.form.ControlDatetime = class ControlDatetime extends frappe.ui.form.Co } get_model_value() { - let value = super.get_model_value() + let value = super.get_model_value(); return frappe.datetime.get_datetime_as_string(value); } }; From 3fbacb97f42e06da35ea8747e360566f3a73c388 Mon Sep 17 00:00:00 2001 From: Komal-Saraf0609 Date: Tue, 10 Aug 2021 18:53:20 +0530 Subject: [PATCH 026/244] test: Added test cases for folder navigation and checking if the nested folder contains the added file --- cypress/integration/folder_navigation.js | 79 ++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 cypress/integration/folder_navigation.js diff --git a/cypress/integration/folder_navigation.js b/cypress/integration/folder_navigation.js new file mode 100644 index 0000000000..4a389101b5 --- /dev/null +++ b/cypress/integration/folder_navigation.js @@ -0,0 +1,79 @@ +context('Folder Navigation', () => { + before(() => { + cy.visit('/login'); + cy.login(); + cy.visit('/app/file'); + }); + + it('Adding Folders', () => { + //Adding filter to go into the home folder + cy.get('.filter-selector > .btn').click(); + cy.get('.filter-action-buttons > div > .btn-secondary').contains('Clear Filters').click(); + cy.get('.filter-action-buttons > .text-muted').click(); + cy.get('.fieldname-select-area > .awesomplete > .form-control').type('Fol{enter}'); + cy.get('.filter-field > .form-group > .link-field > .awesomplete > .input-with-feedback').type('Home{enter}'); + cy.get('.filter-action-buttons > div > .btn-primary').click(); + + //Adding folder (Test Folder) + cy.get('.menu-btn-group > .btn').click(); + cy.get('.menu-btn-group > .dropdown-menu > .user-action > .grey-link').eq(2).click(); + cy.get('form > .frappe-control > .form-group > .control-input-wrapper > .control-input > .input-with-feedback').type('Test Folder'); + cy.get('.modal-footer > .standard-actions > .btn-primary').click(); + }); + + it('Navigating the nested folders, checking if the URL formed is correct, checking if the added content in the child folder is correct', () => { + //Navigating inside the Attachments folder + cy.get('[title="Attachments"] > span').click(); + + //To check if the URL formed after visiting the attachments folder is correct + cy.location('pathname').should('eq', '/app/file/view/home/Attachments'); + cy.visit('/app/file/view/home/Attachments'); + + //Adding folder inside the attachments folder + cy.get('.menu-btn-group > .btn').click(); + cy.get('.menu-btn-group > .dropdown-menu > .user-action > .grey-link').eq(2).click(); + cy.get('form > .frappe-control > .form-group > .control-input-wrapper > .control-input > .input-with-feedback').type('Test Folder'); + cy.get('.modal-footer > .standard-actions > .btn-primary').click(); + + //Navigating inside the added folder in the Attachments folder + cy.get('[title="Test Folder"] > span').click(); + + //To check if the URL is correct after visiting the Test Folder + cy.location('pathname').should('eq', '/app/file/view/home/Attachments/Test%20Folder'); + cy.visit('/app/file/view/home/Attachments/Test%20Folder'); + + //Adding a file inside the Test Folder + cy.get('.primary-action').contains('Add File').eq(0).click({force : true}); + 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(); + + //To check if the added file is present in the Test Folder + cy.get('span.level-item > span').should('contain','Test Folder'); + cy.get('.list-row-container').eq(0).should('contain.text','72402.jpg'); + cy.get('.list-row-checkbox').eq(0).click(); + + //Deleting the added file from the Test folder + cy.get('.actions-btn-group > .btn').click(); + cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(5).click(); + cy.wait(700); + cy.click_modal_primary_button('Yes', {force : true, delay: 700}); + cy.wait(700); + + //Deleting the Test Folder + cy.visit('/app/file/view/home/Attachments'); + cy.get('.list-row-checkbox').eq(0).click(); + cy.get('.actions-btn-group > .btn').click(); + cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(5).click(); + cy.click_modal_primary_button('Yes'); + }); + + it('Deleting Test Folder from the home', () => { + //Deleting the Test Folder added in the home directory + cy.visit('/app/file/view/home'); + cy.get('.list-row-container > .list-row > .level-left > .list-subject > .list-row-checkbox').eq(0).click({force : true, delay : 500}); + cy.get('.actions-btn-group > .btn').click(); + cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(5).click(); + cy.click_modal_primary_button('Yes', {force : true}); + }); +}); \ No newline at end of file From 166574b1d065ac0c0461a671d7fe837a9d35b7ce Mon Sep 17 00:00:00 2001 From: Komal-Saraf0609 Date: Tue, 10 Aug 2021 19:10:27 +0530 Subject: [PATCH 027/244] test: Fixed sider issues --- cypress/integration/folder_navigation.js | 122 +++++++++++------------ 1 file changed, 61 insertions(+), 61 deletions(-) diff --git a/cypress/integration/folder_navigation.js b/cypress/integration/folder_navigation.js index 4a389101b5..d4fe569828 100644 --- a/cypress/integration/folder_navigation.js +++ b/cypress/integration/folder_navigation.js @@ -5,75 +5,75 @@ context('Folder Navigation', () => { cy.visit('/app/file'); }); - it('Adding Folders', () => { - //Adding filter to go into the home folder - cy.get('.filter-selector > .btn').click(); - cy.get('.filter-action-buttons > div > .btn-secondary').contains('Clear Filters').click(); - cy.get('.filter-action-buttons > .text-muted').click(); - cy.get('.fieldname-select-area > .awesomplete > .form-control').type('Fol{enter}'); - cy.get('.filter-field > .form-group > .link-field > .awesomplete > .input-with-feedback').type('Home{enter}'); - cy.get('.filter-action-buttons > div > .btn-primary').click(); - - //Adding folder (Test Folder) - cy.get('.menu-btn-group > .btn').click(); - cy.get('.menu-btn-group > .dropdown-menu > .user-action > .grey-link').eq(2).click(); - cy.get('form > .frappe-control > .form-group > .control-input-wrapper > .control-input > .input-with-feedback').type('Test Folder'); - cy.get('.modal-footer > .standard-actions > .btn-primary').click(); - }); + it('Adding Folders', () => { + //Adding filter to go into the home folder + cy.get('.filter-selector > .btn').click(); + cy.get('.filter-action-buttons > div > .btn-secondary').contains('Clear Filters').click(); + cy.get('.filter-action-buttons > .text-muted').click(); + cy.get('.fieldname-select-area > .awesomplete > .form-control').type('Fol{enter}'); + cy.get('.filter-field > .form-group > .link-field > .awesomplete > .input-with-feedback').type('Home{enter}'); + cy.get('.filter-action-buttons > div > .btn-primary').click(); - it('Navigating the nested folders, checking if the URL formed is correct, checking if the added content in the child folder is correct', () => { - //Navigating inside the Attachments folder - cy.get('[title="Attachments"] > span').click(); + //Adding folder (Test Folder) + cy.get('.menu-btn-group > .btn').click(); + cy.get('.menu-btn-group > .dropdown-menu > .user-action > .grey-link').eq(2).click(); + cy.get('form > .frappe-control > .form-group > .control-input-wrapper > .control-input > .input-with-feedback').type('Test Folder'); + cy.get('.modal-footer > .standard-actions > .btn-primary').click(); + }); - //To check if the URL formed after visiting the attachments folder is correct - cy.location('pathname').should('eq', '/app/file/view/home/Attachments'); - cy.visit('/app/file/view/home/Attachments'); + it('Navigating the nested folders, checking if the URL formed is correct, checking if the added content in the child folder is correct', () => { + //Navigating inside the Attachments folder + cy.get('[title="Attachments"] > span').click(); - //Adding folder inside the attachments folder - cy.get('.menu-btn-group > .btn').click(); - cy.get('.menu-btn-group > .dropdown-menu > .user-action > .grey-link').eq(2).click(); - cy.get('form > .frappe-control > .form-group > .control-input-wrapper > .control-input > .input-with-feedback').type('Test Folder'); - cy.get('.modal-footer > .standard-actions > .btn-primary').click(); + //To check if the URL formed after visiting the attachments folder is correct + cy.location('pathname').should('eq', '/app/file/view/home/Attachments'); + cy.visit('/app/file/view/home/Attachments'); - //Navigating inside the added folder in the Attachments folder - cy.get('[title="Test Folder"] > span').click(); + //Adding folder inside the attachments folder + cy.get('.menu-btn-group > .btn').click(); + cy.get('.menu-btn-group > .dropdown-menu > .user-action > .grey-link').eq(2).click(); + cy.get('form > .frappe-control > .form-group > .control-input-wrapper > .control-input > .input-with-feedback').type('Test Folder'); + cy.get('.modal-footer > .standard-actions > .btn-primary').click(); - //To check if the URL is correct after visiting the Test Folder - cy.location('pathname').should('eq', '/app/file/view/home/Attachments/Test%20Folder'); - cy.visit('/app/file/view/home/Attachments/Test%20Folder'); + //Navigating inside the added folder in the Attachments folder + cy.get('[title="Test Folder"] > span').click(); - //Adding a file inside the Test Folder - cy.get('.primary-action').contains('Add File').eq(0).click({force : true}); - 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(); + //To check if the URL is correct after visiting the Test Folder + cy.location('pathname').should('eq', '/app/file/view/home/Attachments/Test%20Folder'); + cy.visit('/app/file/view/home/Attachments/Test%20Folder'); - //To check if the added file is present in the Test Folder - cy.get('span.level-item > span').should('contain','Test Folder'); - cy.get('.list-row-container').eq(0).should('contain.text','72402.jpg'); - cy.get('.list-row-checkbox').eq(0).click(); + //Adding a file inside the Test Folder + cy.get('.primary-action').contains('Add File').eq(0).click({force: true}); + 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(); - //Deleting the added file from the Test folder - cy.get('.actions-btn-group > .btn').click(); - cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(5).click(); - cy.wait(700); - cy.click_modal_primary_button('Yes', {force : true, delay: 700}); - cy.wait(700); + //To check if the added file is present in the Test Folder + cy.get('span.level-item > span').should('contain', 'Test Folder'); + cy.get('.list-row-container').eq(0).should('contain.text', '72402.jpg'); + cy.get('.list-row-checkbox').eq(0).click(); - //Deleting the Test Folder - cy.visit('/app/file/view/home/Attachments'); - cy.get('.list-row-checkbox').eq(0).click(); - cy.get('.actions-btn-group > .btn').click(); - cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(5).click(); - cy.click_modal_primary_button('Yes'); - }); + //Deleting the added file from the Test folder + cy.get('.actions-btn-group > .btn').click(); + cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(5).click(); + cy.wait(700); + cy.click_modal_primary_button('Yes', {force: true, delay: 700}); + cy.wait(700); - it('Deleting Test Folder from the home', () => { - //Deleting the Test Folder added in the home directory - cy.visit('/app/file/view/home'); - cy.get('.list-row-container > .list-row > .level-left > .list-subject > .list-row-checkbox').eq(0).click({force : true, delay : 500}); - cy.get('.actions-btn-group > .btn').click(); - cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(5).click(); - cy.click_modal_primary_button('Yes', {force : true}); - }); + //Deleting the Test Folder + cy.visit('/app/file/view/home/Attachments'); + cy.get('.list-row-checkbox').eq(0).click(); + cy.get('.actions-btn-group > .btn').click(); + cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(5).click(); + cy.click_modal_primary_button('Yes'); + }); + + it('Deleting Test Folder from the home', () => { + //Deleting the Test Folder added in the home directory + cy.visit('/app/file/view/home'); + cy.get('.list-row-container > .list-row > .level-left > .list-subject > .list-row-checkbox').eq(0).click({force: true, delay: 500}); + cy.get('.actions-btn-group > .btn').click(); + cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(5).click(); + cy.click_modal_primary_button('Yes', {force: true}); + }); }); \ No newline at end of file From 46c6b6621e1d5b331070ce521dc80f2c4cc2c4bc Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 11 Aug 2021 20:00:46 +0530 Subject: [PATCH 028/244] refactor: Newsletter * Re-send Newsletter only to the recipients who haven't received it yet (Check Email Queue before setting email recipients) * Re-structed code, broken larger methods to "byte" sized "bits" (hehe) * Added validations for recipients list * Added Newsletter.newsletter_recipients property * Used newer APIs and Fixed namespaces for usages/imports * Added convenience methods for Newsletter APIs * Added type annotations for all(?) methods --- frappe/email/doctype/newsletter/newsletter.py | 394 ++++++++++-------- 1 file changed, 231 insertions(+), 163 deletions(-) diff --git a/frappe/email/doctype/newsletter/newsletter.py b/frappe/email/doctype/newsletter/newsletter.py index 97d77549b7..5f420785e1 100755 --- a/frappe/email/doctype/newsletter/newsletter.py +++ b/frappe/email/doctype/newsletter/newsletter.py @@ -1,241 +1,309 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt +from typing import Dict, List + import frappe import frappe.utils -from frappe import throw, _ + +from frappe import _ from frappe.website.website_generator import WebsiteGenerator from frappe.utils.verified_command import get_signed_params, verify_request from frappe.email.doctype.email_group.email_group import add_subscribers -from frappe.utils import parse_addr, now_datetime, markdown, validate_email_address + class Newsletter(WebsiteGenerator): def onload(self): - if self.email_sent: - self.get("__onload").status_count = dict(frappe.db.sql("""select status, count(name) - from `tabEmail Queue` where reference_doctype=%s and reference_name=%s - group by status""", (self.doctype, self.name))) or None + self.setup_newsletter_status() def validate(self): - self.route = "newsletters/" + self.name - if self.send_from: - validate_email_address(self.send_from, True) + self.route = f"newsletters/{self.name}" + self.validate_sender_address() + self.validate_recipient_address() + + @property + def newsletter_recipients(self) -> List[str]: + if getattr(self, "_recipients", None) == None: + self._recipients = self.get_recipients() + return self._recipients @frappe.whitelist() - def test_send(self, doctype="Lead"): - self.recipients = frappe.utils.split_emails(self.test_email_id) - self.queue_all(test_email=True) + def test_send(self): + test_emails = frappe.utils.split_emails(self.test_email_id) + self.queue_all(test_email=test_emails) frappe.msgprint(_("Test email sent to {0}").format(self.test_email_id)) @frappe.whitelist() def send_emails(self): """send emails to leads and customers""" if self.email_sent: - throw(_("Newsletter has already been sent")) + frappe.throw(_("Newsletter has already been sent")) - self.recipients = self.get_recipients() + if not self.newsletter_recipients: + frappe.throw(_("Newsletter should have atleast one recipient")) - if self.recipients: - self.queue_all() - frappe.msgprint(_("Email queued to {0} recipients").format(len(self.recipients))) + self.queue_all() + frappe.msgprint(_("Email queued to {0} recipients").format(len(self.newsletter_recipients))) - else: - frappe.msgprint(_("Newsletter should have atleast one recipient")) + def setup_newsletter_status(self): + """Setup analytical status for current Newsletter. Can be accessible from desk. + """ + if self.email_sent: + status_count = frappe.get_all("Email Queue", + filters={"reference_doctype": self.doctype, "reference_name": self.name}, + fields=["status", "count(name)"], + group_by="status", + as_list=True, + ) + self.get("__onload").status_count = dict(status_count) - def queue_all(self, test_email=False): - if not self.get("recipients"): - # in case it is called via worker - self.recipients = self.get_recipients() + def validate_sender_address(self): + """Validate self.send_from is a valid email address or not. + """ + if self.send_from: + frappe.utils.validate_email_address(self.send_from, throw=True) - self.validate_send() - - sender = self.send_from or frappe.utils.get_formatted_email(self.owner) - - if not frappe.flags.in_test: - frappe.db.auto_commit_on_many_writes = True - - attachments = [] - if self.send_attachments: - files = frappe.get_all("File", fields=["name"], filters={"attached_to_doctype": "Newsletter", - "attached_to_name": self.name}, order_by="creation desc") - - for file in files: - try: - # these attachments will be attached on-demand - # and won't be stored in the message - attachments.append({"fid": file.name}) - except IOError: - frappe.throw(_("Unable to find attachment {0}").format(file.name)) - - args = { - "message": self.get_message(), - "name": self.name - } - frappe.sendmail(recipients=self.recipients, sender=sender, - subject=self.subject, message=self.get_message(), template="newsletter", - reference_doctype=self.doctype, reference_name=self.name, - add_unsubscribe_link=self.send_unsubscribe_link, attachments=attachments, - unsubscribe_method="/unsubscribe", - unsubscribe_params={"name": self.name}, - send_priority=0, queue_separately=True, args=args) - - if not frappe.flags.in_test: - frappe.db.auto_commit_on_many_writes = False - - if not test_email: - self.db_set("email_sent", 1) - self.db_set("schedule_send", now_datetime()) - self.db_set("scheduled_to_send", len(self.recipients)) - - def get_message(self): - if self.content_type == "HTML": - return frappe.render_template(self.message_html, {"doc": self.as_dict()}) - return { - 'Rich Text': self.message, - 'Markdown': markdown(self.message_md) - }[self.content_type or 'Rich Text'] - - def get_recipients(self): - """Get recipients from Email Group""" - recipients_list = [] - for email_group in get_email_groups(self.name): - for d in frappe.db.get_all("Email Group Member", ["email"], - {"unsubscribed": 0, "email_group": email_group.email_group}): - recipients_list.append(d.email) - return list(set(recipients_list)) + def validate_recipient_address(self): + """Validate if self.newsletter_recipients are all valid email addresses or not. + """ + for recipient in self.newsletter_recipients: + frappe.utils.validate_email_address(recipient, throw=True) def validate_send(self): + """Validate if Newsletter can be sent. + """ if self.get("__islocal"): - throw(_("Please save the Newsletter before sending")) + frappe.throw(_("Please save the Newsletter before sending")) - if not self.recipients: + if not self.newsletter_recipients: frappe.throw(_("Newsletter should have at least one recipient")) + def get_linked_email_queue(self) -> List[str]: + """Get list of email queue linked to this newsletter. + """ + return frappe.get_all("Email Queue", + filters={ + "reference_doctype": self.doctype, + "reference_name": self.name, + }, + pluck="name", + ) + + def get_success_recipients(self) -> List[str]: + """Recipients who have already recieved the newsletter. + + Couldn't think of a better name ;) + """ + return frappe.get_all("Email Queue Recipient", + filters={ + "status": ("in", ["Not Sent", "Sending", "Sent"]), + "parentfield": ("in", self.get_linked_email_queue()), + }, + pluck="recipient", + ) + + def get_pending_recipients(self) -> List[str]: + """Get list of pending recipients of the newsletter. These + recipients may not have receive the newsletter in the previous iteration. + """ + return [ + x for x in self.newsletter_recipients if x not in self.get_success_recipients() + ] + + def queue_all(self, test_email: str = None): + """Queue Newsletter to all the recipients generated from the `Email Group` + table + + Args: + test_email (str, optional): Send test Newsletter to set email. Defaults to None. + """ + if test_email: + frappe.utils.validate_email_address(test_email, throw=True) + else: + self.validate() + self.validate_send() + + newsletter_recipients = test_email or self.get_pending_recipients() + self.send_newsletter(emails=newsletter_recipients) + + if not test_email: + self.email_sent = True + self.schedule_send = frappe.utils.now_datetime() + self.scheduled_to_send = len(newsletter_recipients) + self.save() + + def get_attachments(self) -> List[Dict[str, str]]: + """Get list of attachments on current Newsletter + """ + attachments = [] + + if self.send_attachments: + files = frappe.get_all( + "File", + filters={"attached_to_doctype": "Newsletter", "attached_to_name": self.name}, + order_by="creation desc", + pluck="name", + ) + attachments.extend({"fid": file} for file in files) + + return attachments + + def send_newsletter(self, emails: List[str]): + # TODO: get rid of this maybe? + message = self.get_message() + attachments = self.get_attachments() + sender = self.send_from or frappe.utils.get_formatted_email(self.owner) + args = {"message": message, "name": self.name} + + frappe.db.auto_commit_on_many_writes = not frappe.flags.in_test + + frappe.sendmail( + subject=self.subject, + sender=sender, + recipients=emails, + message=message, + attachments=attachments, + template="newsletter", + add_unsubscribe_link=self.send_unsubscribe_link, + unsubscribe_method="/unsubscribe", + unsubscribe_params={"name": self.name}, + reference_doctype=self.doctype, + reference_name=self.name, + queue_separately=True, + send_priority=0, + args=args, + ) + + frappe.db.auto_commit_on_many_writes = not frappe.flags.in_test + + def get_message(self) -> str: + if self.content_type == "HTML": + return frappe.render_template(self.message_html, {"doc": self.as_dict()}) + if self.content_type == "Markdown": + return frappe.utils.markdown(self.message_md) + # fallback to Rich Text + return self.message + + def get_recipients(self) -> List[str]: + """Get recipients from Email Group""" + emails = frappe.get_all( + "Email Group Member", + filters={"unsubscribed": 0, "email_group": ("in", self.get_email_groups())}, + pluck="email", + ) + return list(set(emails)) + + def get_email_groups(self) -> List[str]: + return frappe.db.get_all( + "Newsletter Email Group", + filters={"parent": self.name, "parenttype": "Newsletter"}, + pluck="email_group", + ) + + def get_attachments(self) -> List[Dict]: + return frappe.get_all( + "File", + fields=["name", "file_name", "file_url", "is_private"], + filters={ + "attached_to_name": self.name, + "attached_to_doctype": "Newsletter", + "is_private": 0, + }, + ) + def get_context(self, context): newsletters = get_newsletter_list("Newsletter", None, None, 0) if newsletters: newsletter_list = [d.name for d in newsletters] if self.name not in newsletter_list: - frappe.redirect_to_message(_('Permission Error'), - _("You are not permitted to view the newsletter.")) + frappe.redirect_to_message( + _("Permission Error"), _("You are not permitted to view the newsletter.") + ) frappe.local.flags.redirect_location = frappe.local.response.location raise frappe.Redirect else: - context.attachments = get_attachments(self.name) + context.attachments = self.get_attachments() context.no_cache = 1 context.show_sidebar = True -def get_attachments(name): - return frappe.get_all("File", - fields=["name", "file_name", "file_url", "is_private"], - filters = {"attached_to_name": name, "attached_to_doctype": "Newsletter", "is_private":0}) - - -def get_email_groups(name): - return frappe.db.get_all("Newsletter Email Group", ["email_group"],{"parent":name, "parenttype":"Newsletter"}) - - @frappe.whitelist(allow_guest=True) def confirmed_unsubscribe(email, group): """ unsubscribe the email(user) from the mailing list(email_group) """ - frappe.flags.ignore_permissions=True + frappe.flags.ignore_permissions = True doc = frappe.get_doc("Email Group Member", {"email": email, "email_group": group}) if not doc.unsubscribed: doc.unsubscribed = 1 - doc.save(ignore_permissions = True) - -def create_lead(email_id): - """create a lead if it does not exist""" - from frappe.model.naming import get_default_naming_series - full_name, email_id = parse_addr(email_id) - if frappe.db.get_value("Lead", {"email_id": email_id}): - return - - lead = frappe.get_doc({ - "doctype": "Lead", - "email_id": email_id, - "lead_name": full_name or email_id, - "status": "Lead", - "naming_series": get_default_naming_series("Lead"), - "company": frappe.db.get_default("Company"), - "source": "Email" - }) - lead.insert() + doc.save(ignore_permissions=True) @frappe.whitelist(allow_guest=True) -def subscribe(email, email_group=_('Website')): - url = frappe.utils.get_url("/api/method/frappe.email.doctype.newsletter.newsletter.confirm_subscription") +\ - "?" + get_signed_params({"email": email, "email_group": email_group}) +def subscribe(email, email_group=_("Website")): + """API endpoint to subscribe an email to a particular email group. Triggers a confirmation email. + """ - email_template = frappe.db.get_value('Email Group', email_group, ['confirmation_email_template']) + # build subscription confirmation URL + api_endpoint = frappe.utils.get_url( + "/api/method/frappe.email.doctype.newsletter.newsletter.confirm_subscription" + ) + signed_params = get_signed_params({"email": email, "email_group": email_group}) + confirm_subscription_url = f"{api_endpoint}?{signed_params}" - content='' - if email_template: - args = dict( - email=email, - confirmation_url=url, - email_group=email_group - ) + # fetch custom template if available + email_confirmation_template = frappe.db.get_value( + "Email Group", email_group, "confirmation_email_template" + ) - email_template = frappe.get_doc("Email Template", email_template) + # build email and send + if email_confirmation_template: + args = {"email": email, "confirmation_url": confirm_subscription_url, "email_group": email_group} + email_template = frappe.get_doc("Email Template", email_confirmation_template) + email_subject = email_template.subject content = frappe.render_template(email_template.response, args) - - if not content: - messages = ( + else: + email_subject = _("Confirm Your Email") + translatable_content = ( _("Thank you for your interest in subscribing to our updates"), _("Please verify your Email Address"), - url, - _("Click here to verify") + confirm_subscription_url, + _("Click here to verify"), ) - content = """ -

{0}. {1}.

-

{3}

- """.format(*messages) +

{0}. {1}.

+

{3}

+ """.format(*translatable_content) + + frappe.sendmail( + email, + subject=email_subject, + content=content, + now=True, + ) - frappe.sendmail(email, subject=getattr('email_template', 'subject', '') or _("Confirm Your Email"), content=content, now=True) @frappe.whitelist(allow_guest=True) -def confirm_subscription(email, email_group=_('Website')): +def confirm_subscription(email, email_group=_("Website")): + """API endpoint to confirm email subscription. + This endpoint is called when user clicks on the link sent to their mail. + """ if not verify_request(): return if not frappe.db.exists("Email Group", email_group): - frappe.get_doc({ - "doctype": "Email Group", - "title": email_group - }).insert(ignore_permissions=True) + frappe.get_doc({"doctype": "Email Group", "title": email_group}).insert( + ignore_permissions=True + ) frappe.flags.ignore_permissions = True add_subscribers(email_group, email) frappe.db.commit() - frappe.respond_as_web_page(_("Confirmed"), + frappe.respond_as_web_page( + _("Confirmed"), _("{0} has been successfully added to the Email Group.").format(email), - indicator_color='green') - - -def send_newsletter(newsletter): - try: - doc = frappe.get_doc("Newsletter", newsletter) - doc.queue_all() - - except: - frappe.db.rollback() - - # wasn't able to send emails :( - doc.db_set("email_sent", 0) - frappe.db.commit() - - frappe.log_error(title='Send Newsletter') - - raise - - else: - frappe.db.commit() + indicator_color="green", + ) def get_list_context(context=None): From e80d5f25c6259ef2fb6fdddff7dfc61a8f77354c Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 11 Aug 2021 20:12:50 +0530 Subject: [PATCH 029/244] refactor: Newsletter failure handling Don't raise after handling generic Exception class. Just log error and go on. This way, other newsletters will continue to loop instead of breaking after one failure Added more context (docname) to logged error --- frappe/email/doctype/newsletter/newsletter.py | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/frappe/email/doctype/newsletter/newsletter.py b/frappe/email/doctype/newsletter/newsletter.py index 5f420785e1..8857952a29 100755 --- a/frappe/email/doctype/newsletter/newsletter.py +++ b/frappe/email/doctype/newsletter/newsletter.py @@ -336,12 +336,34 @@ def get_newsletter_list(doctype, txt, filters, limit_start, limit_page_length=20 '''.format(','.join(['%s'] * len(email_group_list)), limit_page_length, limit_start), email_group_list, as_dict=1) + def send_scheduled_email(): """Send scheduled newsletter to the recipients.""" - scheduled_newsletter = frappe.get_all('Newsletter', filters = { - 'schedule_send': ('<=', now_datetime()), - 'email_sent': 0, - 'schedule_sending': 1 - }, fields = ['name'], ignore_ifnull=True) + scheduled_newsletter = frappe.get_all( + "Newsletter", + filters={ + "schedule_send": ("<=", frappe.utils.now_datetime()), + "email_sent": False, + "schedule_sending": True, + }, + ignore_ifnull=True, + pluck="name", + ) + for newsletter in scheduled_newsletter: - send_newsletter(newsletter.name) + try: + frappe.get_doc("Newsletter", newsletter).queue_all() + + except Exception: + frappe.db.rollback() + + # wasn't able to send emails :( + frappe.db.set_value("Newsletter", newsletter, "email_sent", 0) + message = ( + f"Newsletter {newsletter} failed to send" + "\n\n" + f"Traceback: {frappe.get_traceback()}" + ) + frappe.log_error(title="Send Newsletter", message=message) + + frappe.db.commit() From cda48832f459a2eefbead43d5004cbe72759d5db Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 11 Aug 2021 20:42:57 +0530 Subject: [PATCH 030/244] chore(test): Update API usages Striving for better readability * Use frappe.db.delete instead of frappe.db.sql * Use named kwargs instead of positional --- .../doctype/newsletter/test_newsletter.py | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/frappe/email/doctype/newsletter/test_newsletter.py b/frappe/email/doctype/newsletter/test_newsletter.py index 3abd339ed9..c1f6c7d4a9 100644 --- a/frappe/email/doctype/newsletter/test_newsletter.py +++ b/frappe/email/doctype/newsletter/test_newsletter.py @@ -24,7 +24,7 @@ emails = [ class TestNewsletter(unittest.TestCase): def setUp(self): frappe.set_user("Administrator") - frappe.db.sql("delete from `tabEmail Group Member`") + frappe.db.delete("Email Group Member") if not frappe.db.exists("Email Group", "_Test Email Group"): frappe.get_doc({"doctype": "Email Group", "title": "_Test Email Group"}).insert() @@ -66,9 +66,10 @@ class TestNewsletter(unittest.TestCase): @staticmethod def send_newsletter(published=0, schedule_send=None): - frappe.db.sql("delete from `tabEmail Queue`") - frappe.db.sql("delete from `tabEmail Queue Recipient`") - frappe.db.sql("delete from `tabNewsletter`") + frappe.db.delete("Email Queue") + frappe.db.delete("Email Queue Recipient") + frappe.db.delete("Newsletter") + newsletter = frappe.get_doc({ "doctype": "Newsletter", "subject": "_Test Newsletter", @@ -78,26 +79,26 @@ class TestNewsletter(unittest.TestCase): "published": published, "schedule_sending": bool(schedule_send), "schedule_send": schedule_send - }).insert(ignore_permissions=True) - + }) + newsletter.insert(ignore_permissions=True) newsletter.append("email_group", {"email_group": "_Test Email Group"}) newsletter.save() + if schedule_send: send_scheduled_email() - return - - newsletter.send_emails() - return newsletter.name + else: + newsletter.send_emails() + return newsletter.name def test_portal(self): - self.send_newsletter(1) + self.send_newsletter(published=1) frappe.set_user("test1@example.com") newsletters = get_newsletter_list("Newsletter", None, None, 0) self.assertEqual(len(newsletters), 1) def test_newsletter_context(self): context = frappe._dict() - newsletter_name = self.send_newsletter(1) + newsletter_name = self.send_newsletter(published=1) frappe.set_user("test2@example.com") doc = frappe.get_doc("Newsletter", newsletter_name) doc.get_context(context) From b28a5beb539dd54cda18d9c1015f0715a414d322 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 11 Aug 2021 21:01:56 +0530 Subject: [PATCH 031/244] refactor(minor): Newsletter Controller * Resolve conflicting methods in the controller * Add docstring --- frappe/email/doctype/newsletter/newsletter.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/frappe/email/doctype/newsletter/newsletter.py b/frappe/email/doctype/newsletter/newsletter.py index 8857952a29..1ad0538036 100755 --- a/frappe/email/doctype/newsletter/newsletter.py +++ b/frappe/email/doctype/newsletter/newsletter.py @@ -23,7 +23,7 @@ class Newsletter(WebsiteGenerator): @property def newsletter_recipients(self) -> List[str]: - if getattr(self, "_recipients", None) == None: + if getattr(self, "_recipients", None) is None: self._recipients = self.get_recipients() return self._recipients @@ -132,7 +132,7 @@ class Newsletter(WebsiteGenerator): self.scheduled_to_send = len(newsletter_recipients) self.save() - def get_attachments(self) -> List[Dict[str, str]]: + def get_newsletter_attachments(self) -> List[Dict[str, str]]: """Get list of attachments on current Newsletter """ attachments = [] @@ -149,9 +149,11 @@ class Newsletter(WebsiteGenerator): return attachments def send_newsletter(self, emails: List[str]): + """Trigger email generation for `emails` and add it in Email Queue. + """ # TODO: get rid of this maybe? message = self.get_message() - attachments = self.get_attachments() + attachments = self.get_newsletter_attachments() sender = self.send_from or frappe.utils.get_formatted_email(self.owner) args = {"message": message, "name": self.name} @@ -200,7 +202,7 @@ class Newsletter(WebsiteGenerator): pluck="email_group", ) - def get_attachments(self) -> List[Dict]: + def get_attachments(self) -> List[Dict[str, str]]: return frappe.get_all( "File", fields=["name", "file_name", "file_url", "is_private"], From b897a41b088a564f63f80b28d4413bd2aaa1485f Mon Sep 17 00:00:00 2001 From: Komal-Saraf0609 Date: Thu, 12 Aug 2021 13:25:24 +0530 Subject: [PATCH 032/244] test: Added test script for dashboard links (tests if any doc is added to any dashboard connection, the counter for it is updated or not) --- cypress/integration/dashboard_links.js | 39 ++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 cypress/integration/dashboard_links.js diff --git a/cypress/integration/dashboard_links.js b/cypress/integration/dashboard_links.js new file mode 100644 index 0000000000..4a3a7e25ed --- /dev/null +++ b/cypress/integration/dashboard_links.js @@ -0,0 +1,39 @@ +context('Dashboard links', () => { + before(() => { + cy.visit('/login'); + cy.login(); + cy.visit('/app/user'); + }); + + it('Adding a new contact, checking for the counter on the dashboard and deleting the created contact', () => { + cy.get('.list-row-col > .level-item > .ellipsis').eq(0).click(); + + //To check if initially the dashboard contains only the "Contact" link and there is no counter + cy.get('[data-doctype="Contact"]').should('contain','Contact'); + + //Adding a new contact + cy.get('.btn[data-doctype="Contact"]').click(); + cy.get('.has-error > .form-group > .control-input-wrapper > .control-input > .input-with-feedback').type('Admin'); + cy.get('#page-Contact > .page-head > .container > .row > .col > .standard-actions > .primary-action').click(); + cy.visit('/app/user'); + cy.get('.list-row-col > .level-item > .ellipsis').eq(0).click(); + + //To check if the counter for contact doc is "1" after adding the contact + cy.get('[data-doctype="Contact"] > .count').should('contain','1'); + cy.get('[data-doctype="Contact"]').contains('Contact').click(); + + //Deleting the newly created contact + cy.visit('/app/contact'); + cy.get('.list-subject > .select-like > .list-row-checkbox').eq(0).click(); + cy.get('.actions-btn-group > .btn').contains('Actions').click(); + cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(5).click(); + cy.get('.modal-footer > .standard-actions > .btn-primary').contains('Yes').click({delay : 700}); + + + //To check if the counter from the "Contact" doc link is removed + cy.visit('/app/user'); + cy.wait(700); + cy.get('.list-row-col > .level-item > .ellipsis').eq(0).click(); + cy.get('[data-doctype="Contact"]').should('contain','Contact'); + }); +}); \ No newline at end of file From 06efca0fa659afb6437cc8ad525807baa37c14e2 Mon Sep 17 00:00:00 2001 From: Komal-Saraf0609 Date: Thu, 12 Aug 2021 13:48:03 +0530 Subject: [PATCH 033/244] test: Fixed sider issues --- cypress/integration/dashboard_links.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cypress/integration/dashboard_links.js b/cypress/integration/dashboard_links.js index 4a3a7e25ed..6bf2d22dad 100644 --- a/cypress/integration/dashboard_links.js +++ b/cypress/integration/dashboard_links.js @@ -9,7 +9,7 @@ context('Dashboard links', () => { cy.get('.list-row-col > .level-item > .ellipsis').eq(0).click(); //To check if initially the dashboard contains only the "Contact" link and there is no counter - cy.get('[data-doctype="Contact"]').should('contain','Contact'); + cy.get('[data-doctype="Contact"]').should('contain', 'Contact'); //Adding a new contact cy.get('.btn[data-doctype="Contact"]').click(); @@ -19,7 +19,7 @@ context('Dashboard links', () => { cy.get('.list-row-col > .level-item > .ellipsis').eq(0).click(); //To check if the counter for contact doc is "1" after adding the contact - cy.get('[data-doctype="Contact"] > .count').should('contain','1'); + cy.get('[data-doctype="Contact"] > .count').should('contain', '1'); cy.get('[data-doctype="Contact"]').contains('Contact').click(); //Deleting the newly created contact @@ -27,13 +27,13 @@ context('Dashboard links', () => { cy.get('.list-subject > .select-like > .list-row-checkbox').eq(0).click(); cy.get('.actions-btn-group > .btn').contains('Actions').click(); cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(5).click(); - cy.get('.modal-footer > .standard-actions > .btn-primary').contains('Yes').click({delay : 700}); + cy.get('.modal-footer > .standard-actions > .btn-primary').contains('Yes').click({delay: 700}); //To check if the counter from the "Contact" doc link is removed cy.visit('/app/user'); cy.wait(700); cy.get('.list-row-col > .level-item > .ellipsis').eq(0).click(); - cy.get('[data-doctype="Contact"]').should('contain','Contact'); + cy.get('[data-doctype="Contact"]').should('contain', 'Contact'); }); }); \ No newline at end of file From c5eb78edd7dbd4ccb4c9397a9caca290d427628a Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 12 Aug 2021 16:12:04 +0530 Subject: [PATCH 034/244] refactor: Simplify validations, fix test_send --- frappe/email/doctype/newsletter/newsletter.py | 57 +++++++++++-------- 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/frappe/email/doctype/newsletter/newsletter.py b/frappe/email/doctype/newsletter/newsletter.py index 1ad0538036..c575cc9ce0 100755 --- a/frappe/email/doctype/newsletter/newsletter.py +++ b/frappe/email/doctype/newsletter/newsletter.py @@ -1,5 +1,5 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See LICENSE from typing import Dict, List @@ -11,6 +11,8 @@ from frappe.website.website_generator import WebsiteGenerator from frappe.utils.verified_command import get_signed_params, verify_request from frappe.email.doctype.email_group.email_group import add_subscribers +from .exceptions import NewsletterAlreadySentError, NoRecipientFoundError, NewsletterNotSavedError + class Newsletter(WebsiteGenerator): def onload(self): @@ -30,17 +32,14 @@ class Newsletter(WebsiteGenerator): @frappe.whitelist() def test_send(self): test_emails = frappe.utils.split_emails(self.test_email_id) - self.queue_all(test_email=test_emails) + self.queue_all(test_emails=test_emails) frappe.msgprint(_("Test email sent to {0}").format(self.test_email_id)) @frappe.whitelist() def send_emails(self): """send emails to leads and customers""" - if self.email_sent: - frappe.throw(_("Newsletter has already been sent")) - - if not self.newsletter_recipients: - frappe.throw(_("Newsletter should have atleast one recipient")) + self.validate_newsletter_status() + self.validate_newsletter_recipients() self.queue_all() frappe.msgprint(_("Email queued to {0} recipients").format(len(self.newsletter_recipients))) @@ -53,10 +52,29 @@ class Newsletter(WebsiteGenerator): filters={"reference_doctype": self.doctype, "reference_name": self.name}, fields=["status", "count(name)"], group_by="status", + order_by="status", as_list=True, ) self.get("__onload").status_count = dict(status_count) + def validate_send(self): + """Validate if Newsletter can be sent. + """ + self.validate_newsletter_status() + self.validate_newsletter_recipients() + + def validate_newsletter_status(self): + if self.email_sent: + frappe.throw(_("Newsletter has already been sent"), exc=NewsletterAlreadySentError) + + if self.get("__islocal"): + frappe.throw(_("Please save the Newsletter before sending"), exc=NewsletterNotSavedError) + + def validate_newsletter_recipients(self): + if not self.newsletter_recipients: + frappe.throw(_("Newsletter should have atleast one recipient"), exc=NoRecipientFoundError) + self.validate_recipient_address() + def validate_sender_address(self): """Validate self.send_from is a valid email address or not. """ @@ -69,15 +87,6 @@ class Newsletter(WebsiteGenerator): for recipient in self.newsletter_recipients: frappe.utils.validate_email_address(recipient, throw=True) - def validate_send(self): - """Validate if Newsletter can be sent. - """ - if self.get("__islocal"): - frappe.throw(_("Please save the Newsletter before sending")) - - if not self.newsletter_recipients: - frappe.throw(_("Newsletter should have at least one recipient")) - def get_linked_email_queue(self) -> List[str]: """Get list of email queue linked to this newsletter. """ @@ -110,23 +119,25 @@ class Newsletter(WebsiteGenerator): x for x in self.newsletter_recipients if x not in self.get_success_recipients() ] - def queue_all(self, test_email: str = None): + def queue_all(self, test_emails: List[str] = None): """Queue Newsletter to all the recipients generated from the `Email Group` table Args: - test_email (str, optional): Send test Newsletter to set email. Defaults to None. + test_email (List[str], optional): Send test Newsletter to the passed set of emails. + Defaults to None. """ - if test_email: - frappe.utils.validate_email_address(test_email, throw=True) + if test_emails: + for test_email in test_emails: + frappe.utils.validate_email_address(test_email, throw=True) else: self.validate() self.validate_send() - newsletter_recipients = test_email or self.get_pending_recipients() + newsletter_recipients = test_emails or self.get_pending_recipients() self.send_newsletter(emails=newsletter_recipients) - if not test_email: + if not test_emails: self.email_sent = True self.schedule_send = frappe.utils.now_datetime() self.scheduled_to_send = len(newsletter_recipients) From a5010af92a022fcea6dcee1efa35e1a5d8020d12 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 12 Aug 2021 16:12:55 +0530 Subject: [PATCH 035/244] test: Update tests for Newsletter Added tests for better coverage: Increased to 77% ~ near complete coverage of controller class, missing tests for API endpoints --- .../doctype/newsletter/test_newsletter.py | 183 ++++++++++++++---- 1 file changed, 149 insertions(+), 34 deletions(-) diff --git a/frappe/email/doctype/newsletter/test_newsletter.py b/frappe/email/doctype/newsletter/test_newsletter.py index c1f6c7d4a9..2d5b8864f1 100644 --- a/frappe/email/doctype/newsletter/test_newsletter.py +++ b/frappe/email/doctype/newsletter/test_newsletter.py @@ -1,17 +1,26 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See LICENSE + import unittest from random import choice +from typing import Union +from unittest.mock import MagicMock, PropertyMock, patch import frappe -from frappe.email.doctype.newsletter.newsletter import ( - confirmed_unsubscribe, - send_scheduled_email, +from frappe.desk.form.load import run_onload +from frappe.email.doctype.newsletter.exceptions import ( + NewsletterAlreadySentError, NoRecipientFoundError +) +from frappe.email.doctype.newsletter.newsletter import ( + Newsletter, + confirmed_unsubscribe, + get_newsletter_list, + send_scheduled_email ) -from frappe.email.doctype.newsletter.newsletter import get_newsletter_list from frappe.email.queue import flush from frappe.utils import add_days, getdate + test_dependencies = ["Email Group"] emails = [ "test_subscriber1@example.com", @@ -20,14 +29,33 @@ emails = [ "test1@example.com", ] +def get_dotted_path(obj: type) -> str: + klass = obj.__class__ + module = klass.__module__ + if module == 'builtins': + return klass.__qualname__ # avoid outputs like 'builtins.str' + return f"{module}.{klass.__qualname__}" + + +class TestNewsletterMixin: + @classmethod + def setUpclass(self): + frappe.db.delete("Newsletter") -class TestNewsletter(unittest.TestCase): def setUp(self): frappe.set_user("Administrator") frappe.db.delete("Email Group Member") + self.setup_email_group() + def tearDown(self): + frappe.db.delete("Newsletter") + + def setup_email_group(self): if not frappe.db.exists("Email Group", "_Test Email Group"): - frappe.get_doc({"doctype": "Email Group", "title": "_Test Email Group"}).insert() + frappe.get_doc({ + "doctype": "Email Group", + "title": "_Test Email Group" + }).insert() for email in emails: frappe.get_doc({ @@ -36,6 +64,54 @@ class TestNewsletter(unittest.TestCase): "email_group": "_Test Email Group" }).insert() + def send_newsletter(self, published=0, schedule_send=None) -> Union[str, None]: + frappe.db.delete("Email Queue") + frappe.db.delete("Email Queue Recipient") + frappe.db.delete("Newsletter") + newsletter_options = { + "published": published, + "schedule_sending": bool(schedule_send), + "schedule_send": schedule_send + } + newsletter = self.get_newsletter(**newsletter_options) + + if schedule_send: + send_scheduled_email() + else: + newsletter.send_emails() + return newsletter.name + + @staticmethod + def get_newsletter(**kwargs) -> "Newsletter": + """Generate and return Newsletter object + """ + newsletter_content = { + "doctype": "Newsletter", + "subject": "_Test Newsletter", + "send_from": "Test Sender ", + "content_type": "Rich Text", + "message": "Testing my news.", + } + newsletter = frappe.get_doc({**newsletter_content, **kwargs}) + newsletter.append("email_group", {"email_group": "_Test Email Group"}) + + newsletter.insert(ignore_permissions=True) + newsletter.save() + newsletter.reload() + + attached_files = frappe.get_all("File", { + "attached_to_doctype": newsletter.doctype, + "attached_to_name": newsletter.name, + }, + pluck="name", + ) + for file in attached_files: + frappe.delete_doc("File", file) + + return newsletter + + +class TestNewsletter(TestNewsletterMixin, unittest.TestCase): def test_send(self): self.send_newsletter() @@ -64,32 +140,6 @@ class TestNewsletter(unittest.TestCase): if email != to_unsubscribe: self.assertTrue(email in recipients) - @staticmethod - def send_newsletter(published=0, schedule_send=None): - frappe.db.delete("Email Queue") - frappe.db.delete("Email Queue Recipient") - frappe.db.delete("Newsletter") - - newsletter = frappe.get_doc({ - "doctype": "Newsletter", - "subject": "_Test Newsletter", - "send_from": "Test Sender ", - "content_type": "Rich Text", - "message": "Testing my news.", - "published": published, - "schedule_sending": bool(schedule_send), - "schedule_send": schedule_send - }) - newsletter.insert(ignore_permissions=True) - newsletter.append("email_group", {"email_group": "_Test Email Group"}) - newsletter.save() - - if schedule_send: - send_scheduled_email() - else: - newsletter.send_emails() - return newsletter.name - def test_portal(self): self.send_newsletter(published=1) frappe.set_user("test1@example.com") @@ -113,3 +163,68 @@ class TestNewsletter(unittest.TestCase): recipients = [e.recipients[0].recipient for e in email_queue_list] for email in emails: self.assertTrue(email in recipients) + + def test_newsletter_test_send(self): + """Test "Test Send" functionality of Newsletter + """ + newsletter = self.get_newsletter() + newsletter.test_email_id = choice(emails) + newsletter.test_send() + + self.assertFalse(newsletter.email_sent) + newsletter.save = MagicMock() + self.assertFalse(newsletter.save.called) + + def test_newsletter_status(self): + """Test for Newsletter's stats on onload event + """ + newsletter = self.get_newsletter() + newsletter.email_sent = True + # had to use run_onload as calling .onload directly bought weird errors + # like TestNewsletter has no attribute "_TestNewsletter__onload" + run_onload(newsletter) + self.assertIsInstance(newsletter.get("__onload").status_count, dict) + + def test_already_sent_newsletter(self): + newsletter = self.get_newsletter() + newsletter.send_emails() + + with self.assertRaises(NewsletterAlreadySentError): + newsletter.send_emails() + + def test_newsletter_with_no_recipient(self): + newsletter = self.get_newsletter() + property_path = f"{get_dotted_path(newsletter)}.newsletter_recipients" + + with patch(property_path, new_callable=PropertyMock) as mock_newsletter_recipients: + mock_newsletter_recipients.return_value = [] + with self.assertRaises(NoRecipientFoundError): + newsletter.send_emails() + + def test_send_newsletter_with_attachments(self): + newsletter = self.get_newsletter() + newsletter.reload() + file_attachment = frappe.get_doc({ + "doctype": "File", + "file_name": "test1.txt", + "attached_to_doctype": newsletter.doctype, + "attached_to_name": newsletter.name, + "content": frappe.mock("paragraph") + }) + file_attachment.save() + newsletter.send_attachments = True + newsletter_attachments = newsletter.get_newsletter_attachments() + self.assertEqual(len(newsletter_attachments), 1) + self.assertEqual(newsletter_attachments[0]["fid"], file_attachment.name) + + def test_send_scheduled_email_error_handling(self): + newsletter = self.get_newsletter(schedule_send=add_days(getdate(), -1)) + job_path = "frappe.email.doctype.newsletter.newsletter.Newsletter.queue_all" + m = MagicMock(side_effect=frappe.OutgoingEmailError) + + with self.assertRaises(frappe.OutgoingEmailError): + with patch(job_path, new_callable=m): + send_scheduled_email() + + newsletter.reload() + self.assertEqual(newsletter.email_sent, 0) From d5739cd43bb7b2cbc566158bedb4f080ae965f6f Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 12 Aug 2021 16:46:09 +0530 Subject: [PATCH 036/244] fix: Add exceptions classes for Newsletter --- frappe/email/doctype/newsletter/exceptions.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 frappe/email/doctype/newsletter/exceptions.py diff --git a/frappe/email/doctype/newsletter/exceptions.py b/frappe/email/doctype/newsletter/exceptions.py new file mode 100644 index 0000000000..a6c688dbe8 --- /dev/null +++ b/frappe/email/doctype/newsletter/exceptions.py @@ -0,0 +1,13 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See LICENSE + +from frappe.exceptions import ValidationError + +class NewsletterAlreadySentError(ValidationError): + pass + +class NoRecipientFoundError(ValidationError): + pass + +class NewsletterNotSavedError(ValidationError): + pass From 43b9b452ff34b6318e967b01d4374d2ee2542211 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 12 Aug 2021 17:30:23 +0530 Subject: [PATCH 037/244] fix: Don't clear Newsletter table at setupClass --- frappe/email/doctype/newsletter/test_newsletter.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/frappe/email/doctype/newsletter/test_newsletter.py b/frappe/email/doctype/newsletter/test_newsletter.py index 2d5b8864f1..8662b6d726 100644 --- a/frappe/email/doctype/newsletter/test_newsletter.py +++ b/frappe/email/doctype/newsletter/test_newsletter.py @@ -38,10 +38,6 @@ def get_dotted_path(obj: type) -> str: class TestNewsletterMixin: - @classmethod - def setUpclass(self): - frappe.db.delete("Newsletter") - def setUp(self): frappe.set_user("Administrator") frappe.db.delete("Email Group Member") From 189f9f81ae454842de9bc3bb36beb06c50b0ecd6 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 12 Aug 2021 17:47:38 +0530 Subject: [PATCH 038/244] fix(Newsletter): Remove "double" validations Validations for checking recipients and statuses were being checked again later on, via validate_send. This gets rid of doing it twice --- frappe/email/doctype/newsletter/newsletter.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/frappe/email/doctype/newsletter/newsletter.py b/frappe/email/doctype/newsletter/newsletter.py index c575cc9ce0..b08964c006 100755 --- a/frappe/email/doctype/newsletter/newsletter.py +++ b/frappe/email/doctype/newsletter/newsletter.py @@ -38,9 +38,6 @@ class Newsletter(WebsiteGenerator): @frappe.whitelist() def send_emails(self): """send emails to leads and customers""" - self.validate_newsletter_status() - self.validate_newsletter_recipients() - self.queue_all() frappe.msgprint(_("Email queued to {0} recipients").format(len(self.newsletter_recipients))) From 4598b26c73814ec7170d533f637ce60f84ee091d Mon Sep 17 00:00:00 2001 From: Sagar Vora Date: Sat, 31 Jul 2021 13:24:18 +0530 Subject: [PATCH 039/244] fix: multiple issues with Email Inbox --- frappe/public/js/frappe/ui/toolbar/awesome_bar.js | 7 ++++++- frappe/public/js/frappe/views/inbox/inbox_view.js | 11 ++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/frappe/public/js/frappe/ui/toolbar/awesome_bar.js b/frappe/public/js/frappe/ui/toolbar/awesome_bar.js index b85de18be2..952fd62aa1 100644 --- a/frappe/public/js/frappe/ui/toolbar/awesome_bar.js +++ b/frappe/public/js/frappe/ui/toolbar/awesome_bar.js @@ -194,9 +194,14 @@ frappe.search.AwesomeBar = class AwesomeBar { var out = [], routes = []; options.forEach(function(option) { if(option.route) { - if(option.route[0] === "List" && option.route[2] !== 'Report') { + if ( + option.route[0] === "List" && + option.route[2] !== 'Report' && + option.route[2] !== 'Inbox' + ) { option.route.splice(2); } + var str_route = (typeof option.route==='string') ? option.route : option.route.join('/'); if(routes.indexOf(str_route)===-1) { diff --git a/frappe/public/js/frappe/views/inbox/inbox_view.js b/frappe/public/js/frappe/views/inbox/inbox_view.js index 8b53bd49a9..6c5b330a9f 100644 --- a/frappe/public/js/frappe/views/inbox/inbox_view.js +++ b/frappe/public/js/frappe/views/inbox/inbox_view.js @@ -94,19 +94,20 @@ frappe.views.InboxView = class InboxView extends frappe.views.ListView { this.render_list(); this.on_row_checked(); this.render_count(); - this.render_tags(); } get_meta_html(email) { const attachment = email.has_attachment ? `` : ''; - const form_link = frappe.utils.get_form_link(email.reference_doctype, email.reference_name); - const link = email.reference_doctype && email.reference_doctype !== this.doctype ? - ` - ` : ''; + `; + } const communication_date = comment_when(email.communication_date, true); const status = From cefa75c8dc06d7f0604b1ed874d1963a9f0c9ae9 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 13 Aug 2021 12:44:25 +0530 Subject: [PATCH 040/244] test: Setup email group at the start of testcase --- frappe/email/doctype/newsletter/test_newsletter.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frappe/email/doctype/newsletter/test_newsletter.py b/frappe/email/doctype/newsletter/test_newsletter.py index 8662b6d726..f67b99bd94 100644 --- a/frappe/email/doctype/newsletter/test_newsletter.py +++ b/frappe/email/doctype/newsletter/test_newsletter.py @@ -38,10 +38,12 @@ def get_dotted_path(obj: type) -> str: class TestNewsletterMixin: + @classmethod + def setUpClass(cls): + cls.setup_email_group() + def setUp(self): frappe.set_user("Administrator") - frappe.db.delete("Email Group Member") - self.setup_email_group() def tearDown(self): frappe.db.delete("Newsletter") From f2641c37f2c910ca317751411d6a87357975eb11 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 13 Aug 2021 13:02:58 +0530 Subject: [PATCH 041/244] test: Delete newsletters and email docs created in test --- .../email/doctype/newsletter/test_newsletter.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/frappe/email/doctype/newsletter/test_newsletter.py b/frappe/email/doctype/newsletter/test_newsletter.py index f67b99bd94..961d4d3377 100644 --- a/frappe/email/doctype/newsletter/test_newsletter.py +++ b/frappe/email/doctype/newsletter/test_newsletter.py @@ -28,6 +28,8 @@ emails = [ "test_subscriber3@example.com", "test1@example.com", ] +newsletters = [] + def get_dotted_path(obj: type) -> str: klass = obj.__class__ @@ -46,7 +48,14 @@ class TestNewsletterMixin: frappe.set_user("Administrator") def tearDown(self): - frappe.db.delete("Newsletter") + frappe.set_user("Administrator") + for newsletter in newsletters: + frappe.db.delete("Email Queue", { + "reference_doctype": "Newsletter", + "reference_name": newsletter, + }) + frappe.delete_doc("Newsletter", newsletter) + newsletters.remove(newsletter) def setup_email_group(self): if not frappe.db.exists("Email Group", "_Test Email Group"): @@ -63,9 +72,6 @@ class TestNewsletterMixin: }).insert() def send_newsletter(self, published=0, schedule_send=None) -> Union[str, None]: - frappe.db.delete("Email Queue") - frappe.db.delete("Email Queue Recipient") - frappe.db.delete("Newsletter") newsletter_options = { "published": published, "schedule_sending": bool(schedule_send), @@ -96,6 +102,7 @@ class TestNewsletterMixin: newsletter.insert(ignore_permissions=True) newsletter.save() newsletter.reload() + newsletters.add(newsletter.name) attached_files = frappe.get_all("File", { "attached_to_doctype": newsletter.doctype, From 6feae59b20a739602d408c15a874b5cbb5f0de81 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 13 Aug 2021 17:23:01 +0530 Subject: [PATCH 042/244] fix: Hanlde Newsletter.get_email_groups & tests cleanup --- frappe/email/doctype/newsletter/newsletter.py | 8 +++- .../doctype/newsletter/test_newsletter.py | 42 ++++++++++++------- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/frappe/email/doctype/newsletter/newsletter.py b/frappe/email/doctype/newsletter/newsletter.py index b08964c006..667d0fb34c 100755 --- a/frappe/email/doctype/newsletter/newsletter.py +++ b/frappe/email/doctype/newsletter/newsletter.py @@ -204,7 +204,10 @@ class Newsletter(WebsiteGenerator): return list(set(emails)) def get_email_groups(self) -> List[str]: - return frappe.db.get_all( + # wondering why the 'or'? i can't figure out why both aren't equivalent - @gavin + return [ + x.email_group for x in self.email_group + ] or frappe.get_all( "Newsletter Email Group", filters={"parent": self.name, "parenttype": "Newsletter"}, pluck="email_group", @@ -376,4 +379,5 @@ def send_scheduled_email(): ) frappe.log_error(title="Send Newsletter", message=message) - frappe.db.commit() + if not frappe.flags.in_test: + frappe.db.commit() diff --git a/frappe/email/doctype/newsletter/test_newsletter.py b/frappe/email/doctype/newsletter/test_newsletter.py index 961d4d3377..abbcc6440c 100644 --- a/frappe/email/doctype/newsletter/test_newsletter.py +++ b/frappe/email/doctype/newsletter/test_newsletter.py @@ -40,12 +40,9 @@ def get_dotted_path(obj: type) -> str: class TestNewsletterMixin: - @classmethod - def setUpClass(cls): - cls.setup_email_group() - def setUp(self): frappe.set_user("Administrator") + self.setup_email_group() def tearDown(self): frappe.set_user("Administrator") @@ -55,6 +52,7 @@ class TestNewsletterMixin: "reference_name": newsletter, }) frappe.delete_doc("Newsletter", newsletter) + frappe.db.delete("Newsletter Email Group", newsletter) newsletters.remove(newsletter) def setup_email_group(self): @@ -65,13 +63,24 @@ class TestNewsletterMixin: }).insert() for email in emails: - frappe.get_doc({ - "doctype": "Email Group Member", + doctype = "Email Group Member" + email_filters = { "email": email, "email_group": "_Test Email Group" - }).insert() + } + try: + frappe.get_doc({ + "doctype": doctype, + **email_filters, + }).insert() + except Exception: + frappe.db.update(doctype, email_filters, "unsubscribed", 0) def send_newsletter(self, published=0, schedule_send=None) -> Union[str, None]: + frappe.db.delete("Email Queue") + frappe.db.delete("Email Queue Recipient") + frappe.db.delete("Newsletter") + newsletter_options = { "published": published, "schedule_sending": bool(schedule_send), @@ -89,20 +98,23 @@ class TestNewsletterMixin: def get_newsletter(**kwargs) -> "Newsletter": """Generate and return Newsletter object """ + doctype = "Newsletter" newsletter_content = { - "doctype": "Newsletter", "subject": "_Test Newsletter", "send_from": "Test Sender ", "content_type": "Rich Text", "message": "Testing my news.", } - newsletter = frappe.get_doc({**newsletter_content, **kwargs}) - newsletter.append("email_group", {"email_group": "_Test Email Group"}) + similar_newsletters = frappe.db.get_all(doctype, newsletter_content, pluck="name") - newsletter.insert(ignore_permissions=True) - newsletter.save() + for similar_newsletter in similar_newsletters: + frappe.delete_doc(doctype, similar_newsletter) + + newsletter = frappe.get_doc({"doctype": doctype, **newsletter_content, **kwargs}) + newsletter.append("email_group", {"email_group": "_Test Email Group"}) + newsletter.save(ignore_permissions=True) newsletter.reload() - newsletters.add(newsletter.name) + newsletters.append(newsletter.name) attached_files = frappe.get_all("File", { "attached_to_doctype": newsletter.doctype, @@ -148,8 +160,8 @@ class TestNewsletter(TestNewsletterMixin, unittest.TestCase): def test_portal(self): self.send_newsletter(published=1) frappe.set_user("test1@example.com") - newsletters = get_newsletter_list("Newsletter", None, None, 0) - self.assertEqual(len(newsletters), 1) + newsletter_list = get_newsletter_list("Newsletter", None, None, 0) + self.assertEqual(len(newsletter_list), 1) def test_newsletter_context(self): context = frappe._dict() From 1e38d5a281d0438be3a45134fff632cbce410313 Mon Sep 17 00:00:00 2001 From: Samuel Danieli <23150094+scdanieli@users.noreply.github.com> Date: Mon, 16 Aug 2021 18:55:09 +0200 Subject: [PATCH 043/244] fix: broken link --- frappe/automation/doctype/auto_repeat/auto_repeat.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.js b/frappe/automation/doctype/auto_repeat/auto_repeat.js index 896a10dfe0..80f2255f47 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.js +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.js @@ -30,7 +30,7 @@ frappe.ui.form.on('Auto Repeat', { refresh: function(frm) { // auto repeat message if (frm.is_new()) { - let customize_form_link = `${__('Customize Form')}`; + let customize_form_link = `${__('Customize Form')}`; frm.dashboard.set_headline(__('To configure Auto Repeat, enable "Allow Auto Repeat" from {0}.', [customize_form_link])); } From 82b4a1ebc6d3768db04303c51e80d67ff63e3f81 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Tue, 17 Aug 2021 09:29:17 +0530 Subject: [PATCH 044/244] refactor(user): Remove unused code --- frappe/core/doctype/user/user.py | 67 -------------------------------- 1 file changed, 67 deletions(-) diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 5d799f8ee9..307ed69789 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -1048,73 +1048,6 @@ def update_gravatar(name): if gravatar: frappe.db.set_value('User', name, 'user_image', gravatar) -@frappe.whitelist(allow_guest=True) -def send_token_via_sms(tmp_id,phone_no=None,user=None): - try: - from frappe.core.doctype.sms_settings.sms_settings import send_request - except: - return False - - if not frappe.cache().ttl(tmp_id + '_token'): - return False - ss = frappe.get_doc('SMS Settings', 'SMS Settings') - if not ss.sms_gateway_url: - return False - - token = frappe.cache().get(tmp_id + '_token') - args = {ss.message_parameter: 'verification code is {}'.format(token)} - - for d in ss.get("parameters"): - args[d.parameter] = d.value - - if user: - user_phone = frappe.db.get_value('User', user, ['phone','mobile_no'], as_dict=1) - usr_phone = user_phone.mobile_no or user_phone.phone - if not usr_phone: - return False - else: - if phone_no: - usr_phone = phone_no - else: - return False - - args[ss.receiver_parameter] = usr_phone - status = send_request(ss.sms_gateway_url, args, use_post=ss.use_post) - - if 200 <= status < 300: - frappe.cache().delete(tmp_id + '_token') - return True - else: - return False - -@frappe.whitelist(allow_guest=True) -def send_token_via_email(tmp_id,token=None): - import pyotp - - user = frappe.cache().get(tmp_id + '_user') - count = token or frappe.cache().get(tmp_id + '_token') - - if ((not user) or (user == 'None') or (not count)): - return False - user_email = frappe.db.get_value('User',user, 'email') - if not user_email: - return False - - otpsecret = frappe.cache().get(tmp_id + '_otp_secret') - hotp = pyotp.HOTP(otpsecret) - - frappe.sendmail( - recipients=user_email, - sender=None, - subject="Verification Code", - template="verification_code", - args=dict(code=hotp.at(int(count))), - delayed=False, - retry=3 - ) - - return True - @frappe.whitelist(allow_guest=True) def reset_otp_secret(user): otp_issuer = frappe.db.get_value('System Settings', 'System Settings', 'otp_issuer_name') From 4c5959afd66f01c17d1fdda4421e2a982e66e12a Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Tue, 17 Aug 2021 09:54:19 +0530 Subject: [PATCH 045/244] refactor: Move reset_otp_secret to twofactor.py --- frappe/core/doctype/user/user.js | 2 +- frappe/core/doctype/user/user.py | 18 ------------------ frappe/twofactor.py | 20 ++++++++++++++++++++ 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/frappe/core/doctype/user/user.js b/frappe/core/doctype/user/user.js index 8c5b89c5fc..96726d875c 100644 --- a/frappe/core/doctype/user/user.js +++ b/frappe/core/doctype/user/user.js @@ -166,7 +166,7 @@ frappe.ui.form.on('User', { frm.add_custom_button(__("Reset OTP Secret"), function() { frappe.call({ - method: "frappe.core.doctype.user.user.reset_otp_secret", + method: "frappe.twofactor.reset_otp_secret", args: { "user": frm.doc.name } diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 307ed69789..590af3af25 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -1048,24 +1048,6 @@ def update_gravatar(name): if gravatar: frappe.db.set_value('User', name, 'user_image', gravatar) -@frappe.whitelist(allow_guest=True) -def reset_otp_secret(user): - otp_issuer = frappe.db.get_value('System Settings', 'System Settings', 'otp_issuer_name') - user_email = frappe.db.get_value('User',user, 'email') - if frappe.session.user in ["Administrator", user] : - frappe.defaults.clear_default(user + '_otplogin') - frappe.defaults.clear_default(user + '_otpsecret') - email_args = { - 'recipients':user_email, 'sender':None, 'subject':'OTP Secret Reset - {}'.format(otp_issuer or "Frappe Framework"), - 'message':'

Your OTP secret on {} has been reset. If you did not perform this reset and did not request it, please contact your System Administrator immediately.

'.format(otp_issuer or "Frappe Framework"), - 'delayed':False, - 'retry':3 - } - enqueue(method=frappe.sendmail, queue='short', timeout=300, event=None, is_async=True, job_name=None, now=False, **email_args) - return frappe.msgprint(_("OTP Secret has been reset. Re-registration will be required on next login.")) - else: - return frappe.throw(_("OTP secret can only be reset by the Administrator.")) - def throttle_user_creation(): if frappe.flags.in_import: return diff --git a/frappe/twofactor.py b/frappe/twofactor.py index c2fb6d5de9..b2f562c20d 100644 --- a/frappe/twofactor.py +++ b/frappe/twofactor.py @@ -398,3 +398,23 @@ def should_remove_barcode_image(barcode): def disable(): frappe.db.set_value('System Settings', None, 'enable_two_factor_auth', 0) + +@frappe.whitelist() +def reset_otp_secret(user): + otp_issuer = frappe.db.get_value('System Settings', 'System Settings', 'otp_issuer_name') + user_email = frappe.db.get_value('User', user, 'email') + if frappe.session.user in ["Administrator", user] : + frappe.defaults.clear_default(user + '_otplogin') + frappe.defaults.clear_default(user + '_otpsecret') + email_args = { + 'recipients': user_email, + 'sender': None, + 'subject': _('OTP Secret Reset - {0}').format(otp_issuer or "Frappe Framework"), + 'message': _('

Your OTP secret on {0} has been reset. If you did not perform this reset and did not request it, please contact your System Administrator immediately.

').format(otp_issuer or "Frappe Framework"), + 'delayed':False, + 'retry':3 + } + enqueue(method=frappe.sendmail, queue='short', timeout=300, event=None, is_async=True, job_name=None, now=False, **email_args) + return frappe.msgprint(_("OTP Secret has been reset. Re-registration will be required on next login.")) + else: + return frappe.throw(_("OTP secret can only be reset by the Administrator.")) \ No newline at end of file From 87f05c2e6bae9b8cef7ccf4e7bd38f086bd4057e Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 17 Aug 2021 09:57:32 +0530 Subject: [PATCH 046/244] ci: Handle payload elements as not set/None alt motive: Re-trigger GHAs that have been showing waiting for too long ;) --- .github/helper/documentation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/helper/documentation.py b/.github/helper/documentation.py index 08d1d1aa9c..f8ee3fa10b 100644 --- a/.github/helper/documentation.py +++ b/.github/helper/documentation.py @@ -32,9 +32,9 @@ if __name__ == "__main__": if response.ok: payload = response.json() - title = payload.get("title", "").lower() - head_sha = payload.get("head", {}).get("sha") - body = payload.get("body", "").lower() + title = (payload.get("title") or "").lower() + head_sha = (payload.get("head") or {}).get("sha") + body = (payload.get("body") or "").lower() if title.startswith("feat") and head_sha and "no-docs" not in body: if docs_link_exists(body): From 3f2a93201b90bd7c130f576c5510aaf71d3c37c0 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Tue, 17 Aug 2021 10:00:39 +0530 Subject: [PATCH 047/244] refactor: Move email account related code to email_account.py --- frappe/core/doctype/user/user.py | 71 ------------------- .../doctype/email_account/email_account.py | 70 ++++++++++++++++-- 2 files changed, 66 insertions(+), 75 deletions(-) diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 590af3af25..5e80083791 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -21,11 +21,6 @@ from frappe.core.doctype.user_type.user_type import user_linked_with_permission_ STANDARD_USERS = ("Guest", "Administrator") - -class MaxUsersReachedError(frappe.ValidationError): - pass - - class User(Document): __new_password = None @@ -731,72 +726,6 @@ def set_email_password(email_account, user, password): return False return True - -def setup_user_email_inbox(email_account, awaiting_password, email_id, enable_outgoing): - """ setup email inbox for user """ - def add_user_email(user): - user = frappe.get_doc("User", user) - row = user.append("user_emails", {}) - - row.email_id = email_id - row.email_account = email_account - row.awaiting_password = awaiting_password or 0 - row.enable_outgoing = enable_outgoing or 0 - - user.save(ignore_permissions=True) - - udpate_user_email_settings = False - if not all([email_account, email_id]): - return - - user_names = frappe.db.get_values("User", { "email": email_id }, as_dict=True) - if not user_names: - return - - for user in user_names: - user_name = user.get("name") - - # check if inbox is alreay configured - user_inbox = frappe.db.get_value("User Email", { - "email_account": email_account, - "parent": user_name - }, ["name"]) or None - - if not user_inbox: - add_user_email(user_name) - else: - # update awaiting password for email account - udpate_user_email_settings = True - - if udpate_user_email_settings: - frappe.db.sql("""UPDATE `tabUser Email` SET awaiting_password = %(awaiting_password)s, - enable_outgoing = %(enable_outgoing)s WHERE email_account = %(email_account)s""", { - "email_account": email_account, - "enable_outgoing": enable_outgoing, - "awaiting_password": awaiting_password or 0 - }) - else: - users = " and ".join([frappe.bold(user.get("name")) for user in user_names]) - frappe.msgprint(_("Enabled email inbox for user {0}").format(users)) - - ask_pass_update() - -def remove_user_email_inbox(email_account): - """ remove user email inbox settings if email account is deleted """ - if not email_account: - return - - users = frappe.get_all("User Email", filters={ - "email_account": email_account - }, fields=["parent as name"]) - - for user in users: - doc = frappe.get_doc("User", user.get("name")) - to_remove = [ row for row in doc.user_emails if row.email_account == email_account ] - [ doc.remove(row) for row in to_remove ] - - doc.save(ignore_permissions=True) - def ask_pass_update(): # update the sys defaults as to awaiting users from frappe.utils import set_default diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index ecd59f42bb..78e0be1017 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -137,8 +137,6 @@ class EmailAccount(Document): def on_update(self): """Check there is only one default of each type.""" - from frappe.core.doctype.user.user import setup_user_email_inbox - self.check_automatic_linking_email_account() self.there_must_be_only_one_default() setup_user_email_inbox(email_account=self.name, awaiting_password=self.awaiting_password, @@ -532,8 +530,6 @@ class EmailAccount(Document): def on_trash(self): """Clear communications where email account is linked""" - from frappe.core.doctype.user.user import remove_user_email_inbox - frappe.db.sql("update `tabCommunication` set email_account='' where email_account=%s", self.name) remove_user_email_inbox(email_account=self.name) @@ -724,3 +720,69 @@ def get_max_email_uid(email_account): else: max_uid = cint(result[0].get("uid", 0)) + 1 return max_uid + + +def setup_user_email_inbox(email_account, awaiting_password, email_id, enable_outgoing): + """ setup email inbox for user """ + from frappe.core.doctype.user import ask_pass_update + def add_user_email(user): + user = frappe.get_doc("User", user) + row = user.append("user_emails", {}) + + row.email_id = email_id + row.email_account = email_account + row.awaiting_password = awaiting_password or 0 + row.enable_outgoing = enable_outgoing or 0 + + user.save(ignore_permissions=True) + + udpate_user_email_settings = False + if not all([email_account, email_id]): + return + + user_names = frappe.db.get_values("User", { "email": email_id }, as_dict=True) + if not user_names: + return + + for user in user_names: + user_name = user.get("name") + + # check if inbox is alreay configured + user_inbox = frappe.db.get_value("User Email", { + "email_account": email_account, + "parent": user_name + }, ["name"]) or None + + if not user_inbox: + add_user_email(user_name) + else: + # update awaiting password for email account + udpate_user_email_settings = True + + if udpate_user_email_settings: + frappe.db.sql("""UPDATE `tabUser Email` SET awaiting_password = %(awaiting_password)s, + enable_outgoing = %(enable_outgoing)s WHERE email_account = %(email_account)s""", { + "email_account": email_account, + "enable_outgoing": enable_outgoing, + "awaiting_password": awaiting_password or 0 + }) + else: + users = " and ".join([frappe.bold(user.get("name")) for user in user_names]) + frappe.msgprint(_("Enabled email inbox for user {0}").format(users)) + ask_pass_update() + +def remove_user_email_inbox(email_account): + """ remove user email inbox settings if email account is deleted """ + if not email_account: + return + + users = frappe.get_all("User Email", filters={ + "email_account": email_account + }, fields=["parent as name"]) + + for user in users: + doc = frappe.get_doc("User", user.get("name")) + to_remove = [ row for row in doc.user_emails if row.email_account == email_account ] + [ doc.remove(row) for row in to_remove ] + + doc.save(ignore_permissions=True) From f6a757be5087f4d3bc262fcc0003ac09e213e6f9 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Tue, 17 Aug 2021 10:08:12 +0530 Subject: [PATCH 048/244] test: Add test case for user rename --- frappe/core/doctype/user/test_user.py | 93 ++++++++++----------------- 1 file changed, 33 insertions(+), 60 deletions(-) diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py index 392128834d..383967a549 100644 --- a/frappe/core/doctype/user/test_user.py +++ b/frappe/core/doctype/user/test_user.py @@ -1,13 +1,10 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # MIT License. See license.txt -import frappe, unittest, uuid +import frappe, unittest from frappe.model.delete_doc import delete_doc -from frappe.utils.data import today, add_to_date -from frappe import _dict from frappe.utils import get_url -from frappe.core.doctype.user.user import get_total_users -from frappe.core.doctype.user.user import MaxUsersReachedError, test_password_strength +from frappe.core.doctype.user.user import test_password_strength from frappe.core.doctype.user.user import extract_mentions from frappe.frappeclient import FrappeClient @@ -119,37 +116,6 @@ class TestUser(unittest.TestCase): # system manager now added by Administrator self.assertTrue("System Manager" in [d.role for d in me.get("roles")]) - # def test_deny_multiple_sessions(self): - # from frappe.installer import update_site_config - # clear_limit('users') - # - # # allow one session - # user = frappe.get_doc('User', 'test@example.com') - # user.simultaneous_sessions = 1 - # user.new_password = 'Eastern_43A1W' - # user.save() - # - # def test_request(conn): - # value = conn.get_value('User', 'first_name', {'name': 'test@example.com'}) - # self.assertTrue('first_name' in value) - # - # from frappe.frappeclient import FrappeClient - # update_site_config('deny_multiple_sessions', 0) - # - # conn1 = FrappeClient(get_url(), "test@example.com", "Eastern_43A1W", verify=False) - # test_request(conn1) - # - # conn2 = FrappeClient(get_url(), "test@example.com", "Eastern_43A1W", verify=False) - # test_request(conn2) - # - # update_site_config('deny_multiple_sessions', 1) - # conn3 = FrappeClient(get_url(), "test@example.com", "Eastern_43A1W", verify=False) - # test_request(conn3) - # - # # first connection should fail - # test_request(conn1) - - def test_delete_user(self): new_user = frappe.get_doc(dict(doctype='User', email='test-for-delete@example.com', first_name='Tester Delete User')).insert() @@ -227,6 +193,7 @@ class TestUser(unittest.TestCase): self.assertEqual(extract_mentions(comment)[0], "test_user@example.com") self.assertEqual(extract_mentions(comment)[1], "test.again@example1.com") + frappe.delete_doc("User Group", "Team") doc = frappe.get_doc({ 'doctype': 'User Group', 'name': 'Team', @@ -236,7 +203,8 @@ class TestUser(unittest.TestCase): 'user': 'test1@example.com' }] }) - doc.insert(ignore_if_duplicate=True) + + doc.insert() comment = '''
@@ -267,31 +235,36 @@ class TestUser(unittest.TestCase): self.assertEqual(res1.status_code, 200) self.assertEqual(res2.status_code, 417) - # def test_user_rollback(self): - # """ - # FIXME: This is failing with PR #12693 as Rollback can't happen if notifications sent on user creation. - # Make sure that notifications disabled. - # """ - # frappe.db.commit() - # frappe.db.begin() - # user_id = str(uuid.uuid4()) - # email = f'{user_id}@example.com' - # try: - # frappe.flags.in_import = True # disable throttling - # frappe.get_doc(dict( - # doctype='User', - # email=email, - # first_name=user_id, - # )).insert() - # finally: - # frappe.flags.in_import = False + def test_user_rename(self): + old_name = "test_user_rename@example.com" + new_name = "test_user_rename_new@example.com" + user = frappe.get_doc({ + "doctype": "User", + "email": old_name, + "enabled": 1, + "first_name": "_Test", + "new_password": "Eastern_43A1W", + "roles": [ + { + "doctype": "Has Role", + "parentfield": "roles", + "role": "System Manager" + }] + }).insert(ignore_permissions=True, ignore_if_duplicate=True) - # # Check user has been added - # self.assertIsNotNone(frappe.db.get("User", {"email": email})) + frappe.rename_doc('User', user.name, new_name) + self.assertTrue(frappe.db.exists("Notification Settings", new_name)) - # # Check that rollback works - # frappe.db.rollback() - # self.assertIsNone(frappe.db.get("User", {"email": email})) + frappe.delete_doc("User", new_name) + + def test_signup(self): + pass + + def test_password_update(self): + pass + + def test_password_verification(self): + pass def delete_contact(user): frappe.db.sql("DELETE FROM `tabContact` WHERE `email_id`= %s", user) From 475c3ec8b8eb13aa09922805a722f0d2f4d05c4f Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Tue, 17 Aug 2021 10:24:12 +0530 Subject: [PATCH 049/244] fix: import error --- frappe/email/doctype/email_account/email_account.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index 78e0be1017..b29d4ee9cd 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -724,7 +724,7 @@ def get_max_email_uid(email_account): def setup_user_email_inbox(email_account, awaiting_password, email_id, enable_outgoing): """ setup email inbox for user """ - from frappe.core.doctype.user import ask_pass_update + from frappe.core.doctype.user.user import ask_pass_update def add_user_email(user): user = frappe.get_doc("User", user) row = user.append("user_emails", {}) From 9b15009e7a359f6a543b9690a67c3d6aa20e395c Mon Sep 17 00:00:00 2001 From: shariquerik Date: Tue, 17 Aug 2021 11:08:35 +0530 Subject: [PATCH 050/244] fix: Enable paragraph text styling (Bold, Italic, link) --- .../public/js/frappe/views/workspace/blocks/paragraph.js | 7 +++++-- frappe/public/scss/desk/desktop.scss | 6 +++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/frappe/public/js/frappe/views/workspace/blocks/paragraph.js b/frappe/public/js/frappe/views/workspace/blocks/paragraph.js index b594f3459a..26afa65d51 100644 --- a/frappe/public/js/frappe/views/workspace/blocks/paragraph.js +++ b/frappe/public/js/frappe/views/workspace/blocks/paragraph.js @@ -123,10 +123,10 @@ export default class Paragraph extends Block { return true; } - save(toolsContent) { + save() { this.wrapper = this._element; return { - text: toolsContent.innerText, + text: this.wrapper.innerHTML, col: this.get_col(), }; } @@ -155,6 +155,9 @@ export default class Paragraph extends Block { return { text: { br: true, + b: true, + i: true, + a: true } }; } diff --git a/frappe/public/scss/desk/desktop.scss b/frappe/public/scss/desk/desktop.scss index 0db526978f..49ed07bbce 100644 --- a/frappe/public/scss/desk/desktop.scss +++ b/frappe/public/scss/desk/desktop.scss @@ -946,7 +946,11 @@ body { &.new-widget { align-items: inherit; } - + + &.ce-paragraph { + display: block; + } + .paragraph-control { display: flex; flex-direction: row-reverse; From 0215bd06ecce32b22033f98afaa69f6a34748982 Mon Sep 17 00:00:00 2001 From: shariquerik Date: Tue, 17 Aug 2021 11:09:22 +0530 Subject: [PATCH 051/244] fix: Make Shortcut's & Card's default size col-4 --- frappe/public/js/frappe/views/workspace/blocks/card.js | 2 +- frappe/public/js/frappe/views/workspace/blocks/shortcut.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/public/js/frappe/views/workspace/blocks/card.js b/frappe/public/js/frappe/views/workspace/blocks/card.js index 975b32eea7..15e27fed40 100644 --- a/frappe/public/js/frappe/views/workspace/blocks/card.js +++ b/frappe/public/js/frappe/views/workspace/blocks/card.js @@ -14,7 +14,7 @@ export default class Card extends Block { constructor({ data, api, config, readOnly, block }) { super({ data, api, config, readOnly, block }); this.sections = {}; - this.col = this.data.col ? this.data.col : "12"; + this.col = this.data.col ? this.data.col : "4"; this.allow_customization = !this.readOnly; this.options = { allow_sorting: this.allow_customization, diff --git a/frappe/public/js/frappe/views/workspace/blocks/shortcut.js b/frappe/public/js/frappe/views/workspace/blocks/shortcut.js index 0943de202d..f7482a06f3 100644 --- a/frappe/public/js/frappe/views/workspace/blocks/shortcut.js +++ b/frappe/public/js/frappe/views/workspace/blocks/shortcut.js @@ -13,7 +13,7 @@ export default class Shortcut extends Block { constructor({ data, api, config, readOnly, block }) { super({ data, api, config, readOnly, block }); - this.col = this.data.col ? this.data.col : "12"; + this.col = this.data.col ? this.data.col : "4"; this.allow_customization = !this.readOnly; this.options = { allow_sorting: this.allow_customization, From 013f1f471df8010c81ab65874085e7e11f78e64e Mon Sep 17 00:00:00 2001 From: shariquerik Date: Tue, 17 Aug 2021 11:11:15 +0530 Subject: [PATCH 052/244] fix: Switch Public-Private, Change Customize to Edit, Add Settings button to redirect to workspace document --- frappe/desk/desktop.py | 2 +- .../js/frappe/views/workspace/workspace.js | 30 +++++++++++-------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/frappe/desk/desktop.py b/frappe/desk/desktop.py index 27b985e429..e9036b98b0 100644 --- a/frappe/desk/desktop.py +++ b/frappe/desk/desktop.py @@ -368,7 +368,7 @@ def get_desktop_page(page): on desk. Args: - page (string): page name + page (json): page data Returns: dict: dictionary of cards, charts and shortcuts to be displayed on website diff --git a/frappe/public/js/frappe/views/workspace/workspace.js b/frappe/public/js/frappe/views/workspace/workspace.js index b46c220d9d..719645feea 100644 --- a/frappe/public/js/frappe/views/workspace/workspace.js +++ b/frappe/public/js/frappe/views/workspace/workspace.js @@ -32,8 +32,8 @@ frappe.views.Workspace = class Workspace { 'private': {} }; this.sidebar_categories = [ - 'Public', - frappe.user.first_name() || 'Private' + 'My Workspaces', + 'Public' ]; this.tools = { header: { @@ -357,7 +357,7 @@ frappe.views.Workspace = class Workspace { let current_page = pages.filter(p => p.title == page.name)[0]; if (!this.is_read_only) { - this.setup_customization_buttons(current_page.is_editable); + this.setup_customization_buttons(current_page); return; } @@ -365,20 +365,20 @@ frappe.views.Workspace = class Workspace { this.page.clear_secondary_action(); this.page.clear_inner_toolbar(); - current_page.is_editable && this.page.set_secondary_action(__("Customize"), () => { + current_page.is_editable && this.page.set_secondary_action(__("Edit"), () => { if (!this.editor || !this.editor.readOnly) return; this.is_read_only = false; this.editor.readOnly.toggle(); this.editor.isReady.then(() => { this.initialize_editorjs_undo(); - this.setup_customization_buttons(true); + this.setup_customization_buttons(current_page); this.show_sidebar_actions(); this.make_sidebar_sortable(); this.make_blocks_sortable(); }); }); - this.page.add_inner_button(__("Create Page"), () => { + this.page.add_inner_button(__("Create Workspace"), () => { this.initialize_new_page(); }); } @@ -389,13 +389,13 @@ frappe.views.Workspace = class Workspace { this.undo.readOnly = false; } - setup_customization_buttons(is_editable) { + setup_customization_buttons(page) { let me = this; this.page.clear_primary_action(); this.page.clear_secondary_action(); this.page.clear_inner_toolbar(); - is_editable && this.page.set_primary_action( + page.is_editable && this.page.set_primary_action( __("Save Customizations"), () => { this.page.clear_primary_action(); @@ -424,6 +424,10 @@ frappe.views.Workspace = class Workspace { } ); + page.name && this.page.add_inner_button(__("Settings"), () => { + frappe.set_route(`workspace/${page.name}`); + }); + Object.keys(this.blocks).forEach(key => { this.page.add_inner_button(` ${this.blocks[key].toolbox.icon} @@ -446,7 +450,7 @@ frappe.views.Workspace = class Workspace { $(`${frappe.utils.icon("lock", "sm")}`) .appendTo(sidebar_control); sidebar_control.parent().click(() => { - frappe.show_alert({ + !this.is_read_only && frappe.show_alert({ message: __("Only Workspace Manager can sort or edit this page"), indicator: 'info' }, 5); @@ -498,9 +502,9 @@ frappe.views.Workspace = class Workspace { prepare_sorted_sidebar(is_public) { if (is_public) { - this.sorted_public_items = this.sort_sidebar(this.sidebar.find('.standard-sidebar-section').first()); + this.sorted_public_items = this.sort_sidebar(this.sidebar.find('.standard-sidebar-section').last()); } else { - this.sorted_private_items = this.sort_sidebar(this.sidebar.find('.standard-sidebar-section').last()); + this.sorted_private_items = this.sort_sidebar(this.sidebar.find('.standard-sidebar-section').first()); } } @@ -578,7 +582,7 @@ frappe.views.Workspace = class Workspace { if (!this.validate_page(values)) return; d.hide(); this.initialize_editorjs_undo(); - this.setup_customization_buttons(true); + this.setup_customization_buttons({is_editable: true}); this.title = values.title; this.icon = values.icon; this.parent = values.parent; @@ -647,7 +651,7 @@ frappe.views.Workspace = class Workspace { ); $sidebar_item.find('.sidebar-item-control .drag-handle').css('margin-right', '8px'); - let $sidebar_section = is_public ? $sidebar[0] : $sidebar[1]; + let $sidebar_section = is_public ? $sidebar[1] : $sidebar[0]; if (!parent) { !is_public && $sidebar.last().removeClass('hidden'); From 3e7e35351b043e6d36cc6e23a8250e6cf6e1d437 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Tue, 17 Aug 2021 11:20:05 +0530 Subject: [PATCH 053/244] refactor: Move set_email_password code to email_account.py --- frappe/core/doctype/user/user.py | 13 ------------- .../email/doctype/email_account/email_account.py | 14 ++++++++++++++ frappe/public/js/frappe/desk.js | 2 +- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 5e80083791..86d2f7726e 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -713,19 +713,6 @@ def get_email_awaiting(user): where parent = %(user)s""",{"user":user}) return False -@frappe.whitelist(allow_guest=False) -def set_email_password(email_account, user, password): - account = frappe.get_doc("Email Account", email_account) - if account.awaiting_password: - account.awaiting_password = 0 - account.password = password - try: - account.save(ignore_permissions=True) - except Exception: - frappe.db.rollback() - return False - - return True def ask_pass_update(): # update the sys defaults as to awaiting users from frappe.utils import set_default diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index b29d4ee9cd..a1ddbf7a68 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -786,3 +786,17 @@ def remove_user_email_inbox(email_account): [ doc.remove(row) for row in to_remove ] doc.save(ignore_permissions=True) + +@frappe.whitelist(allow_guest=False) +def set_email_password(email_account, user, password): + account = frappe.get_doc("Email Account", email_account) + if account.awaiting_password: + account.awaiting_password = 0 + account.password = password + try: + account.save(ignore_permissions=True) + except Exception: + frappe.db.rollback() + return False + + return True \ No newline at end of file diff --git a/frappe/public/js/frappe/desk.js b/frappe/public/js/frappe/desk.js index 810b6a404a..99fc4da182 100644 --- a/frappe/public/js/frappe/desk.js +++ b/frappe/public/js/frappe/desk.js @@ -230,7 +230,7 @@ frappe.Application = class Application { s.fields_dict.checking.$wrapper.html(''); s.show(); frappe.call({ - method: 'frappe.core.doctype.user.user.set_email_password', + method: 'frappe.email.doctype.email_account.email_account.set_email_password', args: { "email_account": email_account[i]["email_account"], "user": user, From 7e72a0f304401c212a6460ba3cf5658ca28f7e26 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Tue, 17 Aug 2021 11:21:31 +0530 Subject: [PATCH 054/244] fix: Disabled user check in signup --- frappe/core/doctype/user/user.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 86d2f7726e..378e10a409 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -764,10 +764,10 @@ def sign_up(email, full_name, redirect_to): user = frappe.db.get("User", {"email": email}) if user: - if user.disabled: - return 0, _("Registered but disabled") - else: + if user.enabled: return 0, _("Already Registered") + else: + return 0, _("Registered but disabled") else: if frappe.db.sql("""select count(*) from tabUser where HOUR(TIMEDIFF(CURRENT_TIMESTAMP, TIMESTAMP(modified)))=1""")[0][0] > 300: From 8924fd071606bb96ee01a383e11e96d077d85b87 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Tue, 17 Aug 2021 11:22:04 +0530 Subject: [PATCH 055/244] test: Add signup test case --- frappe/core/doctype/user/test_user.py | 28 ++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py index 383967a549..41479fb05c 100644 --- a/frappe/core/doctype/user/test_user.py +++ b/frappe/core/doctype/user/test_user.py @@ -4,10 +4,11 @@ import frappe, unittest from frappe.model.delete_doc import delete_doc from frappe.utils import get_url -from frappe.core.doctype.user.user import test_password_strength -from frappe.core.doctype.user.user import extract_mentions +from frappe.core.doctype.user.user import test_password_strength, extract_mentions, sign_up from frappe.frappeclient import FrappeClient +from unittest.mock import patch + test_records = frappe.get_test_records('User') class TestUser(unittest.TestCase): @@ -258,7 +259,28 @@ class TestUser(unittest.TestCase): frappe.delete_doc("User", new_name) def test_signup(self): - pass + import frappe.website.utils + import frappe.exceptions + random_user = frappe.mock('email') + random_user_name = frappe.mock('name') + # disabled signup + with patch.object(frappe.core.doctype.user.user, "is_signup_disabled", return_value=True): + self.assertRaisesRegex(frappe.exceptions.ValidationError, "Sign Up is disabled", + sign_up, random_user, random_user_name, "/signup") + + self.assertTupleEqual(sign_up(random_user, random_user_name, "/welcome"), (1, "Please check your email for verification")) + self.assertEqual(frappe.cache().hget('redirect_after_login', random_user), "/welcome") + + # re-register + self.assertTupleEqual(sign_up(random_user, random_user_name, "/welcome"), (0, "Already Registered")) + + # disabled user + user = frappe.get_doc("User", random_user) + user.enabled = 0 + user.save() + + self.assertTupleEqual(sign_up(random_user, random_user_name, "/welcome"), (0, "Registered but disabled")) + def test_password_update(self): pass From e401366cf9480468d8b5bd4c0af385a0f71575e8 Mon Sep 17 00:00:00 2001 From: shariquerik Date: Tue, 17 Aug 2021 12:12:46 +0530 Subject: [PATCH 056/244] test: Renamed changed values --- cypress/integration/workspace.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cypress/integration/workspace.js b/cypress/integration/workspace.js index 9701e54c5e..f18e48aadc 100644 --- a/cypress/integration/workspace.js +++ b/cypress/integration/workspace.js @@ -14,7 +14,7 @@ context('Workspace 2.0', () => { it('Create Private Page', () => { cy.get('.codex-editor__redactor .ce-block'); - cy.get('.custom-actions button[data-label="Create%20Page"]').click(); + cy.get('.custom-actions button[data-label="Create%20Workspace"]').click(); cy.fill_field('title', 'Test Private Page', 'Data'); cy.fill_field('icon', 'edit', 'Icon'); cy.get_open_dialog().find('.modal-header').click(); @@ -29,7 +29,7 @@ context('Workspace 2.0', () => { cy.wait(500); cy.get('.codex-editor__redactor .ce-block'); - cy.get('.standard-actions .btn-secondary[data-label=Customize]').click(); + cy.get('.standard-actions .btn-secondary[data-label=Edit]').click(); }); it('Add New Block', () => { @@ -77,7 +77,7 @@ context('Workspace 2.0', () => { it('Delete Private Page', () => { cy.get('.codex-editor__redactor .ce-block'); - cy.get('.standard-actions .btn-secondary[data-label=Customize]').click(); + cy.get('.standard-actions .btn-secondary[data-label=Edit]').click(); cy.get('.sidebar-item-container[item-name="Test Private Page"]').find('.sidebar-item-control .delete-page').click(); cy.wait(300); From d36d6055ae24469933993bf30458350a2b42dee5 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Tue, 17 Aug 2021 13:45:39 +0530 Subject: [PATCH 057/244] refactor: Remove check demo and redundant code --- frappe/core/doctype/user/user.py | 46 ++++++++++++-------------------- 1 file changed, 17 insertions(+), 29 deletions(-) diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 378e10a409..b33e6916f5 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -51,8 +51,6 @@ class User(Document): frappe.cache().delete_key('enabled_users') def validate(self): - self.check_demo() - # clear new password self.__new_password = self.new_password self.new_password = "" @@ -132,10 +130,6 @@ class User(Document): """Returns true if current user is the session user""" return self.name == frappe.session.user - def check_demo(self): - if frappe.session.user == 'demo@erpnext.com': - frappe.throw(_('Cannot change user details in demo. Please signup for a new account at https://erpnext.com'), title=_('Not Allowed')) - def set_full_name(self): self.full_name = " ".join(filter(None, [self.first_name, self.last_name])) @@ -393,7 +387,6 @@ class User(Document): def before_rename(self, old_name, new_name, merge=False): - self.check_demo() frappe.clear_cache(user=old_name) self.validate_rename(old_name, new_name) @@ -725,24 +718,19 @@ def ask_pass_update(): def _get_user_for_update_password(key, old_password): # verify old password + result = frappe._dict() if key: - user = frappe.db.get_value("User", {"reset_password_key": key}) - if not user: - return { - 'message': _("The Link specified has either been used before or Invalid") - } + result.user = frappe.db.get_value("User", {"reset_password_key": key}) + if not result.user: + result.message = _("The Link specified has either been used before or Invalid") elif old_password: # verify old password frappe.local.login_manager.check_password(frappe.session.user, old_password) user = frappe.session.user + result.user = user - else: - return - - return { - 'user': user - } + return result def reset_user_data(user): user_doc = frappe.get_doc("User", user) @@ -1048,18 +1036,18 @@ def generate_keys(user): :param user: str """ - if "System Manager" in frappe.get_roles(): - user_details = frappe.get_doc("User", user) - api_secret = frappe.generate_hash(length=15) - # if api key is not set generate api key - if not user_details.api_key: - api_key = frappe.generate_hash(length=15) - user_details.api_key = api_key - user_details.api_secret = api_secret - user_details.save() + frappe.only_for("System Manager") + user_details = frappe.get_doc("User", user) + api_secret = frappe.generate_hash(length=15) + # if api key is not set generate api key + if not user_details.api_key: + api_key = frappe.generate_hash(length=15) + user_details.api_key = api_key + user_details.api_secret = api_secret + user_details.save() + + return {"api_secret": api_secret} - return {"api_secret": api_secret} - frappe.throw(frappe._("Not Permitted"), frappe.PermissionError) @frappe.whitelist() def switch_theme(theme): From 81a59377518a566aca8c92a01eec8c2cfa05c7cc Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Tue, 17 Aug 2021 13:47:58 +0530 Subject: [PATCH 058/244] test: Add "password update" test --- frappe/core/doctype/user/test_user.py | 59 +++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py index 41479fb05c..72650679ae 100644 --- a/frappe/core/doctype/user/test_user.py +++ b/frappe/core/doctype/user/test_user.py @@ -4,11 +4,14 @@ import frappe, unittest from frappe.model.delete_doc import delete_doc from frappe.utils import get_url -from frappe.core.doctype.user.user import test_password_strength, extract_mentions, sign_up +from frappe.core.doctype.user.user import (test_password_strength, + extract_mentions, sign_up, update_password, verify_password) from frappe.frappeclient import FrappeClient from unittest.mock import patch +import frappe.exceptions +user_module = frappe.core.doctype.user.user test_records = frappe.get_test_records('User') class TestUser(unittest.TestCase): @@ -21,7 +24,7 @@ class TestUser(unittest.TestCase): def test_user_type(self): new_user = frappe.get_doc(dict(doctype='User', email='test-for-type@example.com', - first_name='Tester')).insert() + first_name='Tester')).insert(ignore_if_duplicate=True) self.assertEqual(new_user.user_type, 'Website User') # social login userid for frappe @@ -119,7 +122,7 @@ class TestUser(unittest.TestCase): def test_delete_user(self): new_user = frappe.get_doc(dict(doctype='User', email='test-for-delete@example.com', - first_name='Tester Delete User')).insert() + first_name='Tester Delete User')).insert(ignore_if_duplicate=True) self.assertEqual(new_user.user_type, 'Website User') # role with desk access @@ -260,11 +263,10 @@ class TestUser(unittest.TestCase): def test_signup(self): import frappe.website.utils - import frappe.exceptions random_user = frappe.mock('email') random_user_name = frappe.mock('name') # disabled signup - with patch.object(frappe.core.doctype.user.user, "is_signup_disabled", return_value=True): + with patch.object(user_module, "is_signup_disabled", return_value=True): self.assertRaisesRegex(frappe.exceptions.ValidationError, "Sign Up is disabled", sign_up, random_user, random_user_name, "/signup") @@ -282,8 +284,51 @@ class TestUser(unittest.TestCase): self.assertTupleEqual(sign_up(random_user, random_user_name, "/welcome"), (0, "Registered but disabled")) - def test_password_update(self): - pass + def test_reset_password(self): + from frappe.utils import set_request + from frappe.auth import CookieManager + from frappe.auth import LoginManager + old_password = "Eastern_43A1W" + new_password = "easy_password" + + set_request(path="/random") + frappe.local.cookie_manager = CookieManager() + frappe.local.login_manager = LoginManager() + + frappe.set_user("test@example.com") + test_user = frappe.get_doc("User", "test@example.com") + test_user.reset_password() + frappe.cache().hset('redirect_after_login', test_user.email, "/some_portal_page") + self.assertEqual(update_password(new_password, key=test_user.reset_password_key), "/app") + self.assertEqual(update_password(new_password, key="wrong_key"), "The Link specified has either been used before or Invalid") + + # password verification should fail with old password + self.assertRaises(frappe.exceptions.AuthenticationError, verify_password, old_password) + verify_password(new_password) + + # reset password + update_password(old_password, old_password=new_password) + + self.assertRaisesRegex(frappe.exceptions.ValidationError, "Invalid key type", update_password, "test", 1, ['like', '%']) + + password_strength_response = { + "feedback": { + "password_policy_validation_passed": False, + "suggestions": ["Fix password"] + } + } + + # password strength failure test + with patch.object(user_module, "test_password_strength", return_value=password_strength_response): + self.assertRaisesRegex(frappe.exceptions.ValidationError, "Fix password", update_password, new_password, 0, test_user.reset_password_key) + + + # test redirect URL for website users + frappe.set_user("test2@example.com") + self.assertEqual(update_password(new_password, old_password=old_password), "/") + # reset password + update_password(old_password, old_password=new_password) + def test_password_verification(self): pass From 4a6b0e35df26edbf26f1cdee9fd4ef704f8b42cb Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Tue, 17 Aug 2021 14:06:39 +0530 Subject: [PATCH 059/244] fix: dom element for sections breaks in form tours --- frappe/public/js/frappe/form/form_tour.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/form/form_tour.js b/frappe/public/js/frappe/form/form_tour.js index 17547b243d..0694aa634a 100644 --- a/frappe/public/js/frappe/form/form_tour.js +++ b/frappe/public/js/frappe/form/form_tour.js @@ -82,10 +82,16 @@ frappe.ui.form.FormTour = class FormTour { get_step(step_info, on_next) { const { name, fieldname, title, description, position, is_table_field } = step_info; + let element = `.frappe-control[data-fieldname='${fieldname}']`; + const field = this.frm.get_field(fieldname); - let element = field ? field.wrapper : `.frappe-control[data-fieldname='${fieldname}']`; + if (field) { + // wrapper for section breaks returns in a list + element = field.wrapper[0] ? field.wrapper[0] : field.wrapper; + } if (is_table_field) { + // TODO: fix wrapper for grid sections element = `.grid-row-open .frappe-control[data-fieldname='${fieldname}']`; } From ddf4bb80b88403f1fa61f05e1d0e4d06e2bb5332 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Tue, 17 Aug 2021 14:30:17 +0530 Subject: [PATCH 060/244] fix: Signup throttling for postgres --- frappe/core/doctype/user/user.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index b33e6916f5..3f91ebe54c 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -15,7 +15,6 @@ from frappe.desk.doctype.notification_settings.notification_settings import crea from frappe.utils.user import get_system_managers from frappe.website.utils import is_signup_disabled from frappe.rate_limiter import rate_limit -from frappe.utils.background_jobs import enqueue from frappe.core.doctype.user_type.user_type import user_linked_with_permission_on_doctype @@ -757,9 +756,7 @@ def sign_up(email, full_name, redirect_to): else: return 0, _("Registered but disabled") else: - if frappe.db.sql("""select count(*) from tabUser where - HOUR(TIMEDIFF(CURRENT_TIMESTAMP, TIMESTAMP(modified)))=1""")[0][0] > 300: - + if frappe.db.get_creation_count('User', 60) > 300: frappe.respond_as_web_page(_('Temporarily Disabled'), _('Too many users signed up recently, so the registration is disabled. Please try back in an hour'), http_status_code=429) From ba369da61b395c63e7aad096a36dbff22b446c9d Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Tue, 17 Aug 2021 14:33:34 +0530 Subject: [PATCH 061/244] test: Add test case to validate user signup throttling --- frappe/core/doctype/user/test_user.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py index 72650679ae..d901bfa608 100644 --- a/frappe/core/doctype/user/test_user.py +++ b/frappe/core/doctype/user/test_user.py @@ -283,6 +283,11 @@ class TestUser(unittest.TestCase): self.assertTupleEqual(sign_up(random_user, random_user_name, "/welcome"), (0, "Registered but disabled")) + # throttle user creation + with patch.object(user_module.frappe.db, "get_creation_count", return_value=301): + self.assertRaisesRegex(frappe.exceptions.ValidationError, "Throttled", + sign_up, frappe.mock('email'), random_user_name, "/signup") + def test_reset_password(self): from frappe.utils import set_request From d41121643e6339a59fed3c134ec91f532cb24a76 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 17 Aug 2021 15:59:12 +0530 Subject: [PATCH 062/244] fix: broken template in password reset --- frappe/templates/emails/password_reset.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/templates/emails/password_reset.html b/frappe/templates/emails/password_reset.html index d9e38e38f2..cd0d820615 100644 --- a/frappe/templates/emails/password_reset.html +++ b/frappe/templates/emails/password_reset.html @@ -3,5 +3,5 @@

{{_("Reset your password")}}

{{_("Thank you")}},
- {{ user_fullname }} + {{ created_by }}

From b417218f1d6261478f4c7e6ef41e53633965df1f Mon Sep 17 00:00:00 2001 From: MitulDavid Date: Tue, 17 Aug 2021 19:06:01 +0530 Subject: [PATCH 063/244] test: Add support for Testing-Library queries within Cypress tests --- cypress/integration/awesome_bar.js | 14 ++++++------- cypress/integration/control_barcode.js | 6 +++--- cypress/integration/control_icon.js | 10 ++++----- cypress/integration/control_link.js | 4 ++-- cypress/integration/control_select.js | 2 ++ cypress/integration/depends_on.js | 6 +++--- cypress/integration/file_uploader.js | 29 +++++++++++++------------- cypress/support/commands.js | 1 + frappe/commands/utils.py | 4 +++- 9 files changed, 41 insertions(+), 35 deletions(-) diff --git a/cypress/integration/awesome_bar.js b/cypress/integration/awesome_bar.js index 3e12101532..08a2a71843 100644 --- a/cypress/integration/awesome_bar.js +++ b/cypress/integration/awesome_bar.js @@ -10,9 +10,9 @@ context('Awesome Bar', () => { }); it('navigates to doctype list', () => { - cy.get('#navbar-search').type('todo', { delay: 200 }); - cy.get('#navbar-search + ul').should('be.visible'); - cy.get('#navbar-search').type('{downarrow}{enter}', { delay: 100 }); + cy.findByPlaceholderText('Search or type a command (Ctrl + G)').type('todo', { delay: 200 }); + cy.findByRole('listbox').should('be.visible'); + cy.findByPlaceholderText('Search or type a command (Ctrl + G)').type('{downarrow}{enter}', { delay: 100 }); cy.get('.title-text').should('contain', 'To Do'); @@ -20,24 +20,24 @@ context('Awesome Bar', () => { }); it('find text in doctype list', () => { - cy.get('#navbar-search') + cy.findByPlaceholderText('Search or type a command (Ctrl + G)') .type('test in todo{downarrow}{enter}', { delay: 200 }); cy.get('.title-text').should('contain', 'To Do'); - cy.get('[data-original-title="Name"] > .input-with-feedback') + cy.findByPlaceholderText('Name') .should('have.value', '%test%'); }); it('navigates to new form', () => { - cy.get('#navbar-search') + cy.findByPlaceholderText('Search or type a command (Ctrl + G)') .type('new blog post{downarrow}{enter}', { delay: 200 }); cy.get('.title-text:visible').should('have.text', 'New Blog Post'); }); it('calculates math expressions', () => { - cy.get('#navbar-search') + cy.findByPlaceholderText('Search or type a command (Ctrl + G)') .type('55 + 32{downarrow}{enter}', { delay: 200 }); cy.get('.modal-title').should('contain', 'Result'); diff --git a/cypress/integration/control_barcode.js b/cypress/integration/control_barcode.js index 1df5e64f0e..51984fdbd2 100644 --- a/cypress/integration/control_barcode.js +++ b/cypress/integration/control_barcode.js @@ -20,7 +20,7 @@ context('Control Barcode', () => { it('should generate barcode on setting a value', () => { get_dialog_with_barcode().as('dialog'); - cy.get('.frappe-control[data-fieldname=barcode] input') + cy.findByRole('textbox') .focus() .type('123456789') .blur(); @@ -37,11 +37,11 @@ context('Control Barcode', () => { it('should reset when input is cleared', () => { get_dialog_with_barcode().as('dialog'); - cy.get('.frappe-control[data-fieldname=barcode] input') + cy.findByRole('textbox') .focus() .type('123456789') .blur(); - cy.get('.frappe-control[data-fieldname=barcode] input') + cy.findByRole('textbox') .clear() .blur(); cy.get('.frappe-control[data-fieldname=barcode] svg[data-barcode-value="123456789"]') diff --git a/cypress/integration/control_icon.js b/cypress/integration/control_icon.js index f92927f267..73927a15c6 100644 --- a/cypress/integration/control_icon.js +++ b/cypress/integration/control_icon.js @@ -17,17 +17,17 @@ context('Control Icon', () => { it('should set icon', () => { get_dialog_with_icon().as('dialog'); - cy.get('.frappe-control[data-fieldname=icon] input').first().click(); + cy.findByRole('textbox').first().click(); cy.get('.icon-picker .icon-wrapper[id=active]').first().click(); - cy.get('.frappe-control[data-fieldname=icon] input').first().should('have.value', 'active'); + cy.findByRole('textbox').first().should('have.value', 'active'); cy.get('@dialog').then(dialog => { let value = dialog.get_value('icon'); expect(value).to.equal('active'); }); cy.get('.icon-picker .icon-wrapper[id=resting]').first().click(); - cy.get('.frappe-control[data-fieldname=icon] input').first().should('have.value', 'resting'); + cy.findByRole('textbox').first().should('have.value', 'resting'); cy.get('@dialog').then(dialog => { let value = dialog.get_value('icon'); expect(value).to.equal('resting'); @@ -36,14 +36,14 @@ context('Control Icon', () => { it('search for icon and clear search input', () => { let search_text = 'ed'; - cy.get('.icon-picker input[type=search]').first().click().type(search_text); + cy.findByRole('searchbox').first().click().type(search_text); cy.get('.icon-section .icon-wrapper:not(.hidden)').then(i => { cy.get(`.icon-section .icon-wrapper[id*='${search_text}']`).then(icons => { expect(i.length).to.equal(icons.length); }); }); - cy.get('.icon-picker input[type=search]').clear().blur(); + cy.findByRole('searchbox').clear().blur(); cy.get('.icon-section .icon-wrapper').should('not.have.class', 'hidden'); }); diff --git a/cypress/integration/control_link.js b/cypress/integration/control_link.js index 8f9257e9c4..d2aa467af1 100644 --- a/cypress/integration/control_link.js +++ b/cypress/integration/control_link.js @@ -35,7 +35,7 @@ context('Control Link', () => { cy.wait('@search_link'); cy.get('@input').type('todo for link', { delay: 200 }); cy.wait('@search_link'); - cy.get('.frappe-control[data-fieldname=link] ul').should('be.visible'); + cy.findByRole('listbox').should('be.visible'); cy.get('.frappe-control[data-fieldname=link] input').type('{enter}', { delay: 100 }); cy.get('.frappe-control[data-fieldname=link] input').blur(); cy.get('@dialog').then(dialog => { @@ -71,7 +71,7 @@ context('Control Link', () => { cy.get('@input').type(todos[0]).blur(); cy.wait('@validate_link'); cy.get('@input').focus(); - cy.get('.frappe-control[data-fieldname=link] .link-btn') + cy.findByTitle('Open Link') .should('be.visible') .click(); cy.location('pathname').should('eq', `/app/todo/${todos[0]}`); diff --git a/cypress/integration/control_select.js b/cypress/integration/control_select.js index 0bc719b4a7..8e18d21260 100644 --- a/cypress/integration/control_select.js +++ b/cypress/integration/control_select.js @@ -24,8 +24,10 @@ context('Control Select', () => { cy.get('@control').get('.select-icon').should('exist'); cy.get('@control').get('.placeholder').should('have.css', 'display', 'block'); cy.get('@select').select('Option 1'); + cy.findByDisplayValue('Option 1').should('exist'); cy.get('@control').get('.placeholder').should('have.css', 'display', 'none'); cy.get('@select').invoke('val', ''); + cy.findByDisplayValue('Option 1').should('not.exist'); cy.get('@control').get('.placeholder').should('have.css', 'display', 'block'); diff --git a/cypress/integration/depends_on.js b/cypress/integration/depends_on.js index d33babb134..9aa6b5d89d 100644 --- a/cypress/integration/depends_on.js +++ b/cypress/integration/depends_on.js @@ -62,11 +62,11 @@ context('Depends On', () => { it('should set the field as mandatory depending on other fields value', () => { cy.new_form('Test Depends On'); cy.fill_field('test_field', 'Some Value'); - cy.get('button.primary-action').contains('Save').click(); + cy.findByRole('button', {name: 'Save'}).click(); cy.get('.msgprint-dialog .modal-title').contains('Missing Fields').should('be.visible'); cy.hide_dialog(); cy.fill_field('test_field', 'Random value'); - cy.get('button.primary-action').contains('Save').click(); + cy.findByRole('button', {name: 'Save'}).click(); cy.get('.msgprint-dialog .modal-title').contains('Missing Fields').should('not.be.visible'); }); it('should set the field as read only depending on other fields value', () => { @@ -84,7 +84,7 @@ context('Depends On', () => { cy.fill_field('dependant_field', 'Some Value'); //cy.fill_field('test_field', 'Some Other Value'); cy.get('.frappe-control[data-fieldname="child_test_depends_on_field"]').as('table'); - cy.get('@table').find('button.grid-add-row').click(); + cy.get('@table').findByRole('button', {name: 'Add Row'}).click(); cy.get('@table').find('[data-idx="1"]').as('row1'); cy.get('@row1').find('.btn-open-row').click(); cy.get('@row1').find('.form-in-grid').as('row1-form_in_grid'); diff --git a/cypress/integration/file_uploader.js b/cypress/integration/file_uploader.js index e1e232c058..3d4f92df3c 100644 --- a/cypress/integration/file_uploader.js +++ b/cypress/integration/file_uploader.js @@ -25,7 +25,7 @@ context('FileUploader', () => { cy.get_open_dialog().find('.file-name').should('contain', 'example.json'); cy.intercept('POST', '/api/method/upload_file').as('upload_file'); - cy.get_open_dialog().find('.btn-modal-primary').click(); + cy.get_open_dialog().findByRole('button', {name: 'Upload'}).click(); cy.wait('@upload_file').its('response.statusCode').should('eq', 200); cy.get('.modal:visible').should('not.exist'); }); @@ -33,11 +33,11 @@ context('FileUploader', () => { it('should accept uploaded files', () => { open_upload_dialog(); - cy.get_open_dialog().find('.btn-file-upload div:contains("Library")').click(); - cy.get('.file-filter').type('example.json'); - cy.get_open_dialog().find('.tree-label:contains("example.json")').first().click(); + cy.get_open_dialog().findByRole('button', {name: 'Library'}).click(); + cy.findByPlaceholderText('Search by filename or extension').type('example.json'); + cy.get_open_dialog().findAllByText('example.json').first().click(); cy.intercept('POST', '/api/method/upload_file').as('upload_file'); - cy.get_open_dialog().find('.btn-primary').click(); + cy.get_open_dialog().findByRole('button', {name: 'Upload'}).click(); cy.wait('@upload_file').its('response.body.message') .should('have.property', 'file_name', 'example.json'); cy.get('.modal:visible').should('not.exist'); @@ -46,10 +46,12 @@ context('FileUploader', () => { it('should accept web links', () => { open_upload_dialog(); - cy.get_open_dialog().find('.btn-file-upload div:contains("Link")').click(); - cy.get_open_dialog().find('.file-web-link input').type('https://github.com', { delay: 100, force: true }); + cy.get_open_dialog().findByRole('button', {name: 'Link'}).click(); + cy.get_open_dialog() + .findByPlaceholderText('Attach a web link') + .type('https://github.com', { delay: 100, force: true }); cy.intercept('POST', '/api/method/upload_file').as('upload_file'); - cy.get_open_dialog().find('.btn-primary').click(); + cy.get_open_dialog().findByRole('button', {name: 'Upload'}).click(); cy.wait('@upload_file').its('response.body.message') .should('have.property', 'file_url', 'https://github.com'); cy.get('.modal:visible').should('not.exist'); @@ -62,15 +64,14 @@ context('FileUploader', () => { subjectType: 'drag-n-drop', }); - cy.get_open_dialog().find('.file-name').should('contain', 'sample_image.jpg'); + cy.get_open_dialog().findAllByText('sample_image.jpg').should('exist'); cy.get_open_dialog().find('.btn-crop').first().click(); - cy.get_open_dialog().find('.image-cropper-actions > .btn-primary').should('contain', 'Crop'); - cy.get_open_dialog().find('.image-cropper-actions > .btn-primary').click(); - cy.get_open_dialog().find('.optimize-checkbox').first().should('contain', 'Optimize'); - cy.get_open_dialog().find('.optimize-checkbox').first().click(); + cy.get_open_dialog().findByRole('button', {name: 'Crop'}).click(); + cy.get_open_dialog().findAllByRole('checkbox', {name: 'Optimize'}).should('exist'); + cy.get_open_dialog().findAllByLabelText('Optimize').first().click(); cy.intercept('POST', '/api/method/upload_file').as('upload_file'); - cy.get_open_dialog().find('.btn-modal-primary').click(); + cy.get_open_dialog().findByRole('button', {name: 'Upload'}).click(); cy.wait('@upload_file').its('response.statusCode').should('eq', 200); cy.get('.modal:visible').should('not.exist'); }); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index a81ba60fb0..c941652487 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -1,4 +1,5 @@ import 'cypress-file-upload'; +import '@testing-library/cypress/add-commands'; // *********************************************** // This example commands.js shows you how to // create various custom commands and overwrite diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index f2395ae490..ac574841fe 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -597,16 +597,18 @@ def run_ui_tests(context, app, headless=False, parallel=True, ci_build_id=None): node_bin = subprocess.getoutput("npm bin") cypress_path = "{0}/cypress".format(node_bin) plugin_path = "{0}/../cypress-file-upload".format(node_bin) + testing_library_path = f"{node_bin}/../@testing-library" # check if cypress in path...if not, install it. if not ( os.path.exists(cypress_path) and os.path.exists(plugin_path) + and os.path.exists(testing_library_path) and cint(subprocess.getoutput("npm view cypress version")[:1]) >= 6 ): # install cypress click.secho("Installing Cypress...", fg="yellow") - frappe.commands.popen("yarn add cypress@^6 cypress-file-upload@^5 --no-lockfile") + frappe.commands.popen("yarn add cypress@^6 cypress-file-upload@^5 @testing-library/cypress@^8 --no-lockfile") # run for headless mode run_or_open = 'run --browser firefox --record' if headless else 'open' From 05166f919c070fc36b34e7b79f48061fc81850dc Mon Sep 17 00:00:00 2001 From: leela Date: Tue, 17 Aug 2021 22:48:52 +0530 Subject: [PATCH 064/244] fix: rename cancelled docs patch query use backquotes in queries where column names are dynamic(To avoid query issues incase reserved keywords used as a table columns.) --- frappe/patches/v14_0/rename_cancelled_documents.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frappe/patches/v14_0/rename_cancelled_documents.py b/frappe/patches/v14_0/rename_cancelled_documents.py index fbe49c2351..4b565d4f76 100644 --- a/frappe/patches/v14_0/rename_cancelled_documents.py +++ b/frappe/patches/v14_0/rename_cancelled_documents.py @@ -129,9 +129,9 @@ def update_linked_doctypes(doctype, cancelled_doc_names): update `tab{linked_dt}` set - {column}=CONCAT({column}, '-CANC') + `{column}`=CONCAT(`{column}`, '-CANC') where - {column} in %(cancelled_doc_names)s; + `{column}` in %(cancelled_doc_names)s; """.format(linked_dt=linked_dt, column=field), {'cancelled_doc_names': cancelled_doc_names}) else: @@ -151,9 +151,9 @@ def update_dynamic_linked_doctypes(doctype, cancelled_doc_names): update `tab{linked_dt}` set - {column}=CONCAT({column}, '-CANC') + `{column}`=CONCAT(`{column}`, '-CANC') where - {column} in %(cancelled_doc_names)s and {doctype_fieldname}=%(dt)s; + `{column}` in %(cancelled_doc_names)s and {doctype_fieldname}=%(dt)s; """.format(linked_dt=linked_dt, column=fieldname, doctype_fieldname=doctype_fieldname), {'cancelled_doc_names': cancelled_doc_names, 'dt': doctype}) else: From 604263929f91ae03a69b28b82e4d750b5e4061a9 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Wed, 18 Aug 2021 08:20:22 +0530 Subject: [PATCH 065/244] test: Add more assersions to test cases - for better coverage --- frappe/core/doctype/user/test_user.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py index d901bfa608..6a6ec8d2f4 100644 --- a/frappe/core/doctype/user/test_user.py +++ b/frappe/core/doctype/user/test_user.py @@ -144,7 +144,7 @@ class TestUser(unittest.TestCase): self.assertFalse(frappe.db.exists('User', new_user.name)) def test_password_strength(self): - # Test Password without Password Strenth Policy + # Test Password without Password Strength Policy frappe.db.set_value("System Settings", "System Settings", "enable_password_policy", 0) # password policy is disabled, test_password_strength should be ignored @@ -163,6 +163,17 @@ class TestUser(unittest.TestCase): result = test_password_strength("Eastern_43A1W") self.assertEqual(result['feedback']['password_policy_validation_passed'], True) + + # test password strength while saving user with new password + user = frappe.get_doc("User", "test@example.com") + frappe.flags.in_test = False + user.new_password = "password" + self.assertRaisesRegex(frappe.exceptions.ValidationError, "Invalid Password", user.save) + user.reload() + user.new_password = "Eastern_43A1W" + user.save() + frappe.flags.in_test = True + def test_comment_mentions(self): comment = ''' @@ -215,7 +226,10 @@ class TestUser(unittest.TestCase): Testing comment for @Team - + and + + @Unknown Team + please check
''' From d1c63a4b54356505262be88cca5dda990df3a920 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Wed, 18 Aug 2021 08:22:48 +0530 Subject: [PATCH 066/244] refactor: Move code to appropriate location - update_roles had no use in user.py so moved it to role_profile.py where it is actually used --- frappe/core/doctype/role_profile/role_profile.py | 9 +++++++-- frappe/core/doctype/user/user.py | 9 --------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/frappe/core/doctype/role_profile/role_profile.py b/frappe/core/doctype/role_profile/role_profile.py index 0f58da5b5e..986cf834eb 100644 --- a/frappe/core/doctype/role_profile/role_profile.py +++ b/frappe/core/doctype/role_profile/role_profile.py @@ -3,6 +3,7 @@ # For license information, please see license.txt from frappe.model.document import Document +import frappe class RoleProfile(Document): def autoname(self): @@ -11,5 +12,9 @@ class RoleProfile(Document): def on_update(self): """ Changes in role_profile reflected across all its user """ - from frappe.core.doctype.user.user import update_roles - update_roles(self.name) + users = frappe.get_all('User', filters={'role_profile_name': self.name}) + roles = [role.role for role in self.roles] + for d in users: + user = frappe.get_doc('User', d) + user.set('roles', []) + user.add_roles(*roles) diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 3f91ebe54c..1336f6eab7 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -966,15 +966,6 @@ def get_module_profile(module_profile): module_profile = frappe.get_doc('Module Profile', {'module_profile_name': module_profile}) return module_profile.get('block_modules') -def update_roles(role_profile): - users = frappe.get_all('User', filters={'role_profile_name': role_profile}) - role_profile = frappe.get_doc('Role Profile', role_profile) - roles = [role.role for role in role_profile.roles] - for d in users: - user = frappe.get_doc('User', d) - user.set('roles', []) - user.add_roles(*roles) - def create_contact(user, ignore_links=False, ignore_mandatory=False): from frappe.contacts.doctype.contact.contact import get_contact_name if user.name in ["Administrator", "Guest"]: return From 7217277394cbc5d677463a9564af12bf3a1cc073 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Wed, 18 Aug 2021 09:03:03 +0530 Subject: [PATCH 067/244] test: Fix flaky test --- frappe/core/doctype/user/test_user.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py index 6a6ec8d2f4..1ce22e758c 100644 --- a/frappe/core/doctype/user/test_user.py +++ b/frappe/core/doctype/user/test_user.py @@ -317,8 +317,9 @@ class TestUser(unittest.TestCase): frappe.set_user("test@example.com") test_user = frappe.get_doc("User", "test@example.com") test_user.reset_password() - frappe.cache().hset('redirect_after_login', test_user.email, "/some_portal_page") - self.assertEqual(update_password(new_password, key=test_user.reset_password_key), "/app") + pwd_key = test_user.reset_password_key + test_user.reload() + self.assertEqual(update_password(new_password, key=pwd_key), "/app") self.assertEqual(update_password(new_password, key="wrong_key"), "The Link specified has either been used before or Invalid") # password verification should fail with old password @@ -347,6 +348,7 @@ class TestUser(unittest.TestCase): self.assertEqual(update_password(new_password, old_password=old_password), "/") # reset password update_password(old_password, old_password=new_password) + frappe.set_user("Administrator") def test_password_verification(self): From 093c72e9635b8f0060645812871a3d5b98e4c384 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Wed, 18 Aug 2021 09:04:01 +0530 Subject: [PATCH 068/244] test: Update role profile test case with more assertions --- .../doctype/role_profile/test_role_profile.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/frappe/core/doctype/role_profile/test_role_profile.py b/frappe/core/doctype/role_profile/test_role_profile.py index 53e0a1b043..57b77d868b 100644 --- a/frappe/core/doctype/role_profile/test_role_profile.py +++ b/frappe/core/doctype/role_profile/test_role_profile.py @@ -8,6 +8,7 @@ test_dependencies = ['Role'] class TestRoleProfile(unittest.TestCase): def test_make_new_role_profile(self): + frappe.delete_doc_if_exists('Role Profile', 'Test 1', force=1) new_role_profile = frappe.get_doc(dict(doctype='Role Profile', role_profile='Test 1')).insert() self.assertEqual(new_role_profile.role_profile, 'Test 1') @@ -19,7 +20,25 @@ class TestRoleProfile(unittest.TestCase): new_role_profile.save() self.assertEqual(new_role_profile.roles[0].role, '_Test Role 2') + # user with a role profile + random_user = frappe.mock("email") + random_user_name = frappe.mock("name") + + random_user = frappe.get_doc({ + "doctype": "User", + "email": random_user, + "enabled": 1, + "first_name": random_user_name, + "new_password": "Eastern_43A1W", + "role_profile_name": 'Test 1' + }).insert(ignore_permissions=True, ignore_if_duplicate=True) + self.assertListEqual([role.role for role in random_user.roles], [role.role for role in new_role_profile.roles]) + # clear roles new_role_profile.roles = [] new_role_profile.save() self.assertEqual(new_role_profile.roles, []) + + # user roles with the role profile should also be updated + random_user.reload() + self.assertListEqual(random_user.roles, []) \ No newline at end of file From fe15028df3fa527dc0029678a1697232000cbb36 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Wed, 18 Aug 2021 10:20:08 +0530 Subject: [PATCH 069/244] test: Fix reset password test --- frappe/core/doctype/user/test_records.json | 16 +++++++++++++++- frappe/core/doctype/user/test_user.py | 8 +++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/frappe/core/doctype/user/test_records.json b/frappe/core/doctype/user/test_records.json index f9033d4660..21fe3ff69d 100644 --- a/frappe/core/doctype/user/test_records.json +++ b/frappe/core/doctype/user/test_records.json @@ -70,5 +70,19 @@ "role": "System Manager" } ] - } + }, + { + "doctype": "User", + "email": "testpassword@example.com", + "enabled": 1, + "first_name": "_Test", + "new_password": "Eastern_43A1W", + "roles": [ + { + "doctype": "Has Role", + "parentfield": "roles", + "role": "System Manager" + } + ] + } ] diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py index 1ce22e758c..6534e70235 100644 --- a/frappe/core/doctype/user/test_user.py +++ b/frappe/core/doctype/user/test_user.py @@ -314,12 +314,10 @@ class TestUser(unittest.TestCase): frappe.local.cookie_manager = CookieManager() frappe.local.login_manager = LoginManager() - frappe.set_user("test@example.com") - test_user = frappe.get_doc("User", "test@example.com") + frappe.set_user("testpassword@example.com") + test_user = frappe.get_doc("User", "testpassword@example.com") test_user.reset_password() - pwd_key = test_user.reset_password_key - test_user.reload() - self.assertEqual(update_password(new_password, key=pwd_key), "/app") + self.assertEqual(update_password(new_password, key=test_user.reset_password_key), "/app") self.assertEqual(update_password(new_password, key="wrong_key"), "The Link specified has either been used before or Invalid") # password verification should fail with old password From 3fd60b70eeec03b29d426407a3202d111bef7658 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Wed, 18 Aug 2021 11:42:44 +0530 Subject: [PATCH 070/244] fix: Length change for docfield not updated in Database --- frappe/custom/doctype/customize_form/customize_form.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py index 8de194fb00..94f25a41aa 100644 --- a/frappe/custom/doctype/customize_form/customize_form.py +++ b/frappe/custom/doctype/customize_form/customize_form.py @@ -193,6 +193,16 @@ class CustomizeForm(Document): if prop == "fieldtype": self.validate_fieldtype_change(df, meta_df[0].get(prop), df.get(prop)) + elif prop == "length": + old_value_length = cint(meta_df[0].get(prop)) + new_value_length = cint(df.get(prop)) + + if new_value_length and (old_value_length > new_value_length): + self.check_length_for_fieldtypes.append({'df': df, 'old_value': meta_df[0].get(prop)}) + self.validate_fieldtype_length() + else: + self.flags.update_db = True + elif prop == "allow_on_submit" and df.get(prop): if not frappe.db.get_value("DocField", {"parent": self.doc_type, "fieldname": df.fieldname}, "allow_on_submit"): From cf67f9d28332ca139d1607108688f52c86941226 Mon Sep 17 00:00:00 2001 From: Rushabh Mehta Date: Wed, 18 Aug 2021 12:01:44 +0530 Subject: [PATCH 071/244] fix(minor): remove url from metatags --- frappe/website/website_components/metatags.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frappe/website/website_components/metatags.py b/frappe/website/website_components/metatags.py index 045bef8fe1..e26098b773 100644 --- a/frappe/website/website_components/metatags.py +++ b/frappe/website/website_components/metatags.py @@ -1,5 +1,7 @@ import frappe +METATAGS = ('title', 'description', 'image', 'author', 'published_on') + class MetaTags(): def __init__(self, path, context): self.path = path @@ -12,7 +14,7 @@ class MetaTags(): self.set_metatags_from_website_route_meta() def init_metatags_from_context(self): - for key in ('title', 'description', 'image', 'author', 'url', 'published_on'): + for key in METATAGS: if key not in self.tags and self.context.get(key): self.tags[key] = self.context[key] @@ -28,12 +30,12 @@ class MetaTags(): if "og:type" not in self.tags: self.tags["og:type"] = "article" - for key in ('title', 'description', 'image', 'author', 'url'): + for key in METATAGS: if self.tags.get(key): self.tags['og:' + key] = self.tags.get(key) def set_twitter_tags(self): - for key in ('title', 'description', 'image', 'author', 'url'): + for key in METATAGS: if self.tags.get(key): self.tags['twitter:' + key] = self.tags.get(key) From 68b9ae808bf9e1eeed29b7f5eba8f074a6e53014 Mon Sep 17 00:00:00 2001 From: hasnain2808 Date: Wed, 18 Aug 2021 12:35:05 +0530 Subject: [PATCH 072/244] feat: maxLines in ace editor --- frappe/public/js/frappe/form/controls/code.js | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/frappe/public/js/frappe/form/controls/code.js b/frappe/public/js/frappe/form/controls/code.js index 5af7cf2863..29749a8a27 100644 --- a/frappe/public/js/frappe/form/controls/code.js +++ b/frappe/public/js/frappe/form/controls/code.js @@ -9,12 +9,7 @@ frappe.ui.form.ControlCode = class ControlCode extends frappe.ui.form.ControlTex this.ace_editor_target = $('
') .appendTo(this.input_area); - this.expanded = false; - this.$expand_button = $(``).click(() => { - this.expanded = !this.expanded; - this.refresh_height(); - this.toggle_label(); - }).appendTo(this.$input_wrapper); + // styling this.ace_editor_target.addClass('border rounded'); this.ace_editor_target.css('height', 300); @@ -22,6 +17,18 @@ frappe.ui.form.ControlCode = class ControlCode extends frappe.ui.form.ControlTex // initialize const ace = window.ace; this.editor = ace.edit(this.ace_editor_target.get(0)); + + if (this.df.maxLines) + this.editor.setOption("maxLines", this.df.maxLines); + else{ + this.expanded = false; + this.$expand_button = $(``).click(() => { + this.expanded = !this.expanded; + this.refresh_height(); + this.toggle_label(); + }).appendTo(this.$input_wrapper); + } + this.editor.setTheme('ace/theme/tomorrow'); this.editor.setOption("showPrintMargin", false); this.editor.setOption("wrap", this.df.wrap); From 3baa33d5da5b797587ba249e4ed723eba2242a3f Mon Sep 17 00:00:00 2001 From: Shariq Ansari <30859809+shariquerik@users.noreply.github.com> Date: Wed, 18 Aug 2021 13:21:10 +0530 Subject: [PATCH 073/244] fix: Use IP Address instead of Email field (#13553) --- frappe/core/doctype/feedback/feedback.json | 17 +++++++------- frappe/core/doctype/feedback/test_feedback.py | 4 ++-- .../templates/includes/feedback/feedback.html | 16 +++++--------- .../templates/includes/feedback/feedback.py | 22 ++++++++++++++----- frappe/website/doctype/blog_post/blog_post.py | 7 ++++-- 5 files changed, 38 insertions(+), 28 deletions(-) diff --git a/frappe/core/doctype/feedback/feedback.json b/frappe/core/doctype/feedback/feedback.json index cf8a180e27..b77e7a6677 100644 --- a/frappe/core/doctype/feedback/feedback.json +++ b/frappe/core/doctype/feedback/feedback.json @@ -8,8 +8,8 @@ "reference_doctype", "reference_name", "column_break_3", - "email", "rating", + "ip_address", "section_break_6", "feedback" ], @@ -18,12 +18,6 @@ "fieldname": "column_break_3", "fieldtype": "Column Break" }, - { - "fieldname": "email", - "fieldtype": "Data", - "label": "Email", - "reqd": 1 - }, { "fieldname": "rating", "fieldtype": "Float", @@ -56,11 +50,18 @@ "label": "Reference Name", "options": "reference_doctype", "reqd": 1 + }, + { + "fieldname": "ip_address", + "fieldtype": "Data", + "hidden": 1, + "label": "IP Address", + "read_only": 1 } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2021-06-14 15:11:26.005805", + "modified": "2021-06-23 12:45:42.045696", "modified_by": "Administrator", "module": "Core", "name": "Feedback", diff --git a/frappe/core/doctype/feedback/test_feedback.py b/frappe/core/doctype/feedback/test_feedback.py index 702f9d8ac1..2a96d86874 100644 --- a/frappe/core/doctype/feedback/test_feedback.py +++ b/frappe/core/doctype/feedback/test_feedback.py @@ -12,12 +12,12 @@ class TestFeedback(unittest.TestCase): frappe.db.sql("delete from `tabFeedback` where reference_doctype = 'Blog Post'") from frappe.templates.includes.feedback.feedback import add_feedback, update_feedback - feedback = add_feedback('Blog Post', test_blog.name, 5, 'New feedback','test@test.com') + feedback = add_feedback('Blog Post', test_blog.name, 5, 'New feedback') self.assertEqual(feedback.feedback, 'New feedback') self.assertEqual(feedback.rating, 5) - updated_feedback = update_feedback('Blog Post', test_blog.name, 6, 'Updated feedback', 'test@test.com') + updated_feedback = update_feedback('Blog Post', test_blog.name, 6, 'Updated feedback') self.assertEqual(updated_feedback.feedback, 'Updated feedback') self.assertEqual(updated_feedback.rating, 6) diff --git a/frappe/templates/includes/feedback/feedback.html b/frappe/templates/includes/feedback/feedback.html index 55d44e95fb..f180fa5e42 100644 --- a/frappe/templates/includes/feedback/feedback.html +++ b/frappe/templates/includes/feedback/feedback.html @@ -7,9 +7,6 @@
-
- -
{% for rating in [1, 2, 3, 4, 5 ,6, 7, 8, 9, 10] %} @@ -41,7 +38,6 @@ feedback && $("#submit-feedback").html(__("Update")); if (frappe.is_user_logged_in()) { - $(".feedback_email").parent().toggleClass("hidden"); if (feedback) { $("[name='feedback']").val(feedback); toggle_feedback(); @@ -83,12 +79,12 @@ $('#submit-feedback').click((ev) => { let update = ev.target.innerText !== __("Submit"); + let rating = $('.rating').find('.rating-click').length; let args = { reference_doctype: "{{ reference_doctype or doctype }}", reference_name: "{{ reference_name or name }}", rating: rating, - feedback: $("[name='feedback']").val(), - feedback_email: $("[name='feedback_email']").val() || frappe.user_id + feedback: $("[name='feedback']").val() } if (args.rating == 0) { @@ -101,16 +97,14 @@ return false; } - if (args.feedback_email!=='Administrator' && !validate_email(args.feedback_email)) { - frappe.msgprint("{{ _("Please enter a valid email address.") }}"); - return false; - } - if (!update) { frappe.call({ method: "frappe.templates.includes.feedback.feedback.add_feedback", args: args, callback: function(r) { + if (!r.message) { + return + } toggle_feedback(); if (!frappe.is_user_logged_in()) { $("[name='feedback']").val(''); diff --git a/frappe/templates/includes/feedback/feedback.py b/frappe/templates/includes/feedback/feedback.py index 1830a3e09e..b15d9567d7 100644 --- a/frappe/templates/includes/feedback/feedback.py +++ b/frappe/templates/includes/feedback/feedback.py @@ -3,21 +3,33 @@ from __future__ import unicode_literals import frappe +from frappe.utils import add_to_date, now from frappe import _ @frappe.whitelist(allow_guest=True) -def add_feedback(reference_doctype, reference_name, rating, feedback, feedback_email): +def add_feedback(reference_doctype, reference_name, rating, feedback): doc = frappe.get_doc(reference_doctype, reference_name) if doc.disable_feedback == 1: return + feedback_count = frappe.db.count("Feedback", { + "reference_doctype": reference_doctype, + "reference_name": reference_name, + "ip_address": frappe.local.request_ip, + "creation": (">", add_to_date(now(), hours=-1)) + }) + + if feedback_count > 20: + frappe.msgprint(_('Hourly feedback limit reached')) + return + doc = frappe.new_doc('Feedback') doc.reference_doctype = reference_doctype doc.reference_name = reference_name doc.rating = rating doc.feedback = feedback - doc.email = feedback_email + doc.ip_address = frappe.local.request_ip doc.save(ignore_permissions=True) subject = _('New Feedback on {0}: {1}').format(reference_doctype, reference_name) @@ -25,13 +37,13 @@ def add_feedback(reference_doctype, reference_name, rating, feedback, feedback_e return doc @frappe.whitelist() -def update_feedback(reference_doctype, reference_name, rating, feedback, feedback_email): +def update_feedback(reference_doctype, reference_name, rating, feedback): doc = frappe.get_doc(reference_doctype, reference_name) if doc.disable_feedback == 1: return filters = { - "email": feedback_email, + "owner": frappe.session.user, "reference_doctype": reference_doctype, "reference_name": reference_name } @@ -49,7 +61,7 @@ def send_mail(feedback, subject): doc = frappe.get_doc(feedback.reference_doctype, feedback.reference_name) message = ("

{0} ({1})

".format(feedback.feedback, feedback.rating) - + "

{2}

".format(frappe.utils.get_request_site_address(), + + "

{2}

".format(frappe.utils.get_request_site_address(), feedback.name, _("View Feedback"))) diff --git a/frappe/website/doctype/blog_post/blog_post.py b/frappe/website/doctype/blog_post/blog_post.py index 965fc8e3e0..cb887a2ffc 100644 --- a/frappe/website/doctype/blog_post/blog_post.py +++ b/frappe/website/doctype/blog_post/blog_post.py @@ -147,12 +147,15 @@ class BlogPost(WebsiteGenerator): context.comment_text = _('{0} comments').format(len(context.comment_list)) def load_feedback(self, context): + user = frappe.session.user + if user == 'Guest': + user = '' feedback = frappe.get_all('Feedback', - fields=['email', 'feedback', 'rating'], + fields=['feedback', 'rating'], filters=dict( reference_doctype=self.doctype, reference_name=self.name, - email=frappe.session.user + owner=user ) ) context.user_feedback = feedback[0] if feedback else '' From f3e0a30ec78bd7a3cfbe2690e137597a61c3f6ec Mon Sep 17 00:00:00 2001 From: Mohammad Hasnain Mohsin Rajan Date: Wed, 18 Aug 2021 13:55:33 +0530 Subject: [PATCH 074/244] style: add space after else keyword --- frappe/public/js/frappe/form/controls/code.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/form/controls/code.js b/frappe/public/js/frappe/form/controls/code.js index 29749a8a27..b5653a68a7 100644 --- a/frappe/public/js/frappe/form/controls/code.js +++ b/frappe/public/js/frappe/form/controls/code.js @@ -20,7 +20,7 @@ frappe.ui.form.ControlCode = class ControlCode extends frappe.ui.form.ControlTex if (this.df.maxLines) this.editor.setOption("maxLines", this.df.maxLines); - else{ + else { this.expanded = false; this.$expand_button = $(``).click(() => { this.expanded = !this.expanded; From 03c975a1f1ea59eeb38818427954c90240d8b886 Mon Sep 17 00:00:00 2001 From: Mohammad Hasnain Mohsin Rajan Date: Wed, 18 Aug 2021 14:03:22 +0530 Subject: [PATCH 075/244] fix: use snake case Co-authored-by: Suraj Shetty <13928957+surajshetty3416@users.noreply.github.com> --- frappe/public/js/frappe/form/controls/code.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/form/controls/code.js b/frappe/public/js/frappe/form/controls/code.js index b5653a68a7..0f0565cf3e 100644 --- a/frappe/public/js/frappe/form/controls/code.js +++ b/frappe/public/js/frappe/form/controls/code.js @@ -19,7 +19,7 @@ frappe.ui.form.ControlCode = class ControlCode extends frappe.ui.form.ControlTex this.editor = ace.edit(this.ace_editor_target.get(0)); if (this.df.maxLines) - this.editor.setOption("maxLines", this.df.maxLines); + this.editor.setOption("maxLines", this.df.max_lines); else { this.expanded = false; this.$expand_button = $(``).click(() => { From 6da11e7a2640f1b42bb540530a5be05c872085cb Mon Sep 17 00:00:00 2001 From: hasnain2808 Date: Wed, 18 Aug 2021 14:06:11 +0530 Subject: [PATCH 076/244] fix: use snake case --- frappe/public/js/frappe/form/controls/code.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/form/controls/code.js b/frappe/public/js/frappe/form/controls/code.js index 0f0565cf3e..210bf57839 100644 --- a/frappe/public/js/frappe/form/controls/code.js +++ b/frappe/public/js/frappe/form/controls/code.js @@ -18,7 +18,7 @@ frappe.ui.form.ControlCode = class ControlCode extends frappe.ui.form.ControlTex const ace = window.ace; this.editor = ace.edit(this.ace_editor_target.get(0)); - if (this.df.maxLines) + if (this.df.max_lines) this.editor.setOption("maxLines", this.df.max_lines); else { this.expanded = false; From 93198b7123efde04c0251e75c34c250e3bca4ee4 Mon Sep 17 00:00:00 2001 From: MitulDavid Date: Wed, 18 Aug 2021 19:11:01 +0530 Subject: [PATCH 077/244] test: Replace existing queries with Testing-Library queries --- cypress/integration/awesome_bar.js | 2 +- cypress/integration/control_barcode.js | 6 ++-- cypress/integration/control_icon.js | 10 +++--- cypress/integration/control_link.js | 2 +- cypress/integration/form.js | 2 +- cypress/integration/form_tour.js | 16 ++++----- cypress/integration/grid_pagination.js | 4 +-- cypress/integration/list_view_settings.js | 12 +++---- cypress/integration/login.js | 12 +++---- cypress/integration/recorder.js | 16 ++++----- cypress/integration/report_view.js | 2 +- cypress/integration/timeline.js | 40 +++++++++++------------ 12 files changed, 62 insertions(+), 62 deletions(-) diff --git a/cypress/integration/awesome_bar.js b/cypress/integration/awesome_bar.js index 08a2a71843..fb09b384a8 100644 --- a/cypress/integration/awesome_bar.js +++ b/cypress/integration/awesome_bar.js @@ -11,7 +11,7 @@ context('Awesome Bar', () => { it('navigates to doctype list', () => { cy.findByPlaceholderText('Search or type a command (Ctrl + G)').type('todo', { delay: 200 }); - cy.findByRole('listbox').should('be.visible'); + cy.get('.awesomplete').findByRole('listbox').should('be.visible'); cy.findByPlaceholderText('Search or type a command (Ctrl + G)').type('{downarrow}{enter}', { delay: 100 }); cy.get('.title-text').should('contain', 'To Do'); diff --git a/cypress/integration/control_barcode.js b/cypress/integration/control_barcode.js index 51984fdbd2..5f1ab86d41 100644 --- a/cypress/integration/control_barcode.js +++ b/cypress/integration/control_barcode.js @@ -20,7 +20,7 @@ context('Control Barcode', () => { it('should generate barcode on setting a value', () => { get_dialog_with_barcode().as('dialog'); - cy.findByRole('textbox') + cy.get('.frappe-control[data-fieldname=barcode]').findByRole('textbox') .focus() .type('123456789') .blur(); @@ -37,11 +37,11 @@ context('Control Barcode', () => { it('should reset when input is cleared', () => { get_dialog_with_barcode().as('dialog'); - cy.findByRole('textbox') + cy.get('.frappe-control[data-fieldname=barcode]').findByRole('textbox') .focus() .type('123456789') .blur(); - cy.findByRole('textbox') + cy.get('.frappe-control[data-fieldname=barcode]').findByRole('textbox') .clear() .blur(); cy.get('.frappe-control[data-fieldname=barcode] svg[data-barcode-value="123456789"]') diff --git a/cypress/integration/control_icon.js b/cypress/integration/control_icon.js index 73927a15c6..5c531a0823 100644 --- a/cypress/integration/control_icon.js +++ b/cypress/integration/control_icon.js @@ -17,17 +17,17 @@ context('Control Icon', () => { it('should set icon', () => { get_dialog_with_icon().as('dialog'); - cy.findByRole('textbox').first().click(); + cy.get('.frappe-control[data-fieldname=icon]').findByRole('textbox').click(); cy.get('.icon-picker .icon-wrapper[id=active]').first().click(); - cy.findByRole('textbox').first().should('have.value', 'active'); + cy.get('.frappe-control[data-fieldname=icon]').findByRole('textbox').should('have.value', 'active'); cy.get('@dialog').then(dialog => { let value = dialog.get_value('icon'); expect(value).to.equal('active'); }); cy.get('.icon-picker .icon-wrapper[id=resting]').first().click(); - cy.findByRole('textbox').first().should('have.value', 'resting'); + cy.get('.frappe-control[data-fieldname=icon]').findByRole('textbox').should('have.value', 'resting'); cy.get('@dialog').then(dialog => { let value = dialog.get_value('icon'); expect(value).to.equal('resting'); @@ -36,14 +36,14 @@ context('Control Icon', () => { it('search for icon and clear search input', () => { let search_text = 'ed'; - cy.findByRole('searchbox').first().click().type(search_text); + cy.get('.icon-picker').findByRole('searchbox').click().type(search_text); cy.get('.icon-section .icon-wrapper:not(.hidden)').then(i => { cy.get(`.icon-section .icon-wrapper[id*='${search_text}']`).then(icons => { expect(i.length).to.equal(icons.length); }); }); - cy.findByRole('searchbox').clear().blur(); + cy.get('.icon-picker').findByRole('searchbox').clear().blur(); cy.get('.icon-section .icon-wrapper').should('not.have.class', 'hidden'); }); diff --git a/cypress/integration/control_link.js b/cypress/integration/control_link.js index d2aa467af1..7d44a71d06 100644 --- a/cypress/integration/control_link.js +++ b/cypress/integration/control_link.js @@ -35,7 +35,7 @@ context('Control Link', () => { cy.wait('@search_link'); cy.get('@input').type('todo for link', { delay: 200 }); cy.wait('@search_link'); - cy.findByRole('listbox').should('be.visible'); + cy.get('.frappe-control[data-fieldname=link]').findByRole('listbox').should('be.visible'); cy.get('.frappe-control[data-fieldname=link] input').type('{enter}', { delay: 100 }); cy.get('.frappe-control[data-fieldname=link] input').blur(); cy.get('@dialog').then(dialog => { diff --git a/cypress/integration/form.js b/cypress/integration/form.js index 909955c1df..d20750b1d5 100644 --- a/cypress/integration/form.js +++ b/cypress/integration/form.js @@ -26,7 +26,7 @@ context('Form', () => { cy.visit('/app/contact'); cy.add_filter(); cy.get('.filter-field .input-with-feedback.form-control').type('123', { force: true }); - cy.get('.filter-popover .apply-filters').click({ force: true }); + cy.findByRole('button', {name: 'Apply Filters'}).click({ force: true }); cy.visit('/app/contact/Test Form Contact 3'); cy.get('.prev-doc').should('be.visible').click(); cy.get('.msgprint-dialog .modal-body').contains('No further records').should('be.visible'); diff --git a/cypress/integration/form_tour.js b/cypress/integration/form_tour.js index d12be63f3b..d2d39679a8 100644 --- a/cypress/integration/form_tour.js +++ b/cypress/integration/form_tour.js @@ -9,7 +9,7 @@ context('Form Tour', () => { const open_test_form_tour = () => { cy.visit('/app/form-tour/Test Form Tour'); - cy.get('button[data-label="Show%20Tour"]').should('be.visible').and('contain', 'Show Tour').as('show_tour'); + cy.findByRole('button', {name: 'Show Tour'}).should('be.visible').as('show_tour'); cy.get('@show_tour').click(); cy.wait(500); cy.url().should('include', '/app/contact'); @@ -23,7 +23,7 @@ context('Form Tour', () => { cy.get('#driver-popover-item').should('be.visible'); cy.get('.frappe-control[data-fieldname="first_name"]').as('first_name'); cy.get('@first_name').should('have.class', 'driver-highlighted-element'); - cy.get('.driver-next-btn').as('next_btn'); + cy.get('#driver-popover-item').findByRole('button', {name: 'Next'}).as('next_btn'); // next btn shouldn't move to next step, if first name is not entered cy.get('@next_btn').click(); @@ -39,7 +39,7 @@ context('Form Tour', () => { // assert field is highlighted cy.get('.frappe-control[data-fieldname="last_name"]').as('last_name'); cy.get('@last_name').should('have.class', 'driver-highlighted-element'); - + // after filling the field, next step should be highlighted cy.fill_field('last_name', 'Test Last Name', 'Data'); cy.wait(500); @@ -49,12 +49,12 @@ context('Form Tour', () => { // assert field is highlighted cy.get('.frappe-control[data-fieldname="phone_nos"]').as('phone_nos'); cy.get('@phone_nos').should('have.class', 'driver-highlighted-element'); - + // move to next step cy.wait(500); cy.get('@next_btn').click(); cy.wait(500); - + // assert add row btn is highlighted cy.get('@phone_nos').find('.grid-add-row').as('add_row'); cy.get('@add_row').should('have.class', 'driver-highlighted-element'); @@ -78,11 +78,11 @@ context('Form Tour', () => { // collapse row cy.get('.grid-row-open .grid-collapse-row').click(); cy.wait(500); - + // assert save btn is highlighted cy.get('.primary-action').should('have.class', 'driver-highlighted-element'); - cy.get('@next_btn').should('contain', 'Save'); + cy.wait(500); + cy.get('#driver-popover-item').findByRole('button', {name: 'Save'}).should('be.visible'); }); }); - \ No newline at end of file diff --git a/cypress/integration/grid_pagination.js b/cypress/integration/grid_pagination.js index 8f6b79c1f4..c07230d2b8 100644 --- a/cypress/integration/grid_pagination.js +++ b/cypress/integration/grid_pagination.js @@ -30,12 +30,12 @@ context('Grid Pagination', () => { it('adds and deletes rows and changes page', () => { cy.visit('/app/contact/Test Contact'); cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); - cy.get('@table').find('button.grid-add-row').click(); + cy.get('@table').findByRole('button', {name: 'Add Row'}).click(); cy.get('@table').find('.grid-body .row-index').should('contain', 1001); cy.get('@table').find('.current-page-number').should('contain', '21'); cy.get('@table').find('.total-page-number').should('contain', '21'); cy.get('@table').find('.grid-body .grid-row .grid-row-check').click({ force: true }); - cy.get('@table').find('button.grid-remove-rows').click(); + cy.get('@table').findByRole('button', {name: 'Delete'}).click(); cy.get('@table').find('.grid-body .row-index').last().should('contain', 1000); cy.get('@table').find('.current-page-number').should('contain', '20'); cy.get('@table').find('.total-page-number').should('contain', '20'); diff --git a/cypress/integration/list_view_settings.js b/cypress/integration/list_view_settings.js index 52512b911e..61d4b8aae5 100644 --- a/cypress/integration/list_view_settings.js +++ b/cypress/integration/list_view_settings.js @@ -17,9 +17,9 @@ context('List View Settings', () => { cy.get('.dropdown-menu li').filter(':visible').contains('List Settings').click(); cy.get('.modal-dialog').should('contain', 'DocType Settings'); - cy.get('input[data-fieldname="disable_count"]').check({ force: true }); - cy.get('input[data-fieldname="disable_sidebar_stats"]').check({ force: true }); - cy.get('button').filter(':visible').contains('Save').click(); + cy.findByLabelText('Disable Count').check({ force: true }); + cy.findByLabelText('Disable Sidebar Stats').check({ force: true }); + cy.findByRole('button', {name: 'Save'}).click(); cy.reload({ force: true }); @@ -29,8 +29,8 @@ context('List View Settings', () => { cy.get('.menu-btn-group button').click({ force: true }); cy.get('.dropdown-menu li').filter(':visible').contains('List Settings').click(); cy.get('.modal-dialog').should('contain', 'DocType Settings'); - cy.get('input[data-fieldname="disable_count"]').uncheck({ force: true }); - cy.get('input[data-fieldname="disable_sidebar_stats"]').uncheck({ force: true }); - cy.get('button').filter(':visible').contains('Save').click(); + cy.findByLabelText('Disable Count').uncheck({ force: true }); + cy.findByLabelText('Disable Sidebar Stats').uncheck({ force: true }); + cy.findByRole('button', {name: 'Save'}).click(); }); }); diff --git a/cypress/integration/login.js b/cypress/integration/login.js index 6b109dd18d..98739bb4c9 100644 --- a/cypress/integration/login.js +++ b/cypress/integration/login.js @@ -11,13 +11,13 @@ context('Login', () => { it('validates password', () => { cy.get('#login_email').type('Administrator'); - cy.get('.btn-login:visible').click(); + cy.findByRole('button', {name: 'Login'}).click(); cy.location('pathname').should('eq', '/login'); }); it('validates email', () => { cy.get('#login_password').type('qwe'); - cy.get('.btn-login:visible').click(); + cy.findByRole('button', {name: 'Login'}).click(); cy.location('pathname').should('eq', '/login'); }); @@ -25,8 +25,8 @@ context('Login', () => { cy.get('#login_email').type('Administrator'); cy.get('#login_password').type('qwer'); - cy.get('.btn-login:visible').click(); - cy.get('.btn-login:visible').contains('Invalid Login. Try again.'); + cy.findByRole('button', {name: 'Login'}).click(); + cy.findByRole('button', {name: 'Invalid Login. Try again.'}).should('exist'); cy.location('pathname').should('eq', '/login'); }); @@ -34,7 +34,7 @@ context('Login', () => { cy.get('#login_email').type('Administrator'); cy.get('#login_password').type(Cypress.config('adminPassword')); - cy.get('.btn-login:visible').click(); + cy.findByRole('button', {name: 'Login'}).click(); cy.location('pathname').should('eq', '/app'); cy.window().its('frappe.session.user').should('eq', 'Administrator'); }); @@ -60,7 +60,7 @@ context('Login', () => { cy.get('#login_email').type('Administrator'); cy.get('#login_password').type(Cypress.config('adminPassword')); - cy.get('.btn-login:visible').click(); + cy.findByRole('button', {name: 'Login'}).click(); // verify redirected location and url params after login cy.url().should('include', '/me?' + payload.toString().replace('+', '%20')); diff --git a/cypress/integration/recorder.js b/cypress/integration/recorder.js index 5b7692d8ff..7a62b2e6d9 100644 --- a/cypress/integration/recorder.js +++ b/cypress/integration/recorder.js @@ -16,24 +16,24 @@ context('Recorder', () => { it('Navigate to Recorder', () => { cy.visit('/app'); cy.awesomebar('recorder'); - cy.get('h3').should('contain', 'Recorder'); + cy.findByTitle('Recorder').should('exist'); cy.url().should('include', '/recorder/detail'); }); it('Recorder Empty State', () => { - cy.get('.title-text').should('contain', 'Recorder'); + cy.findByTitle('Recorder').should('exist'); cy.get('.indicator-pill').should('contain', 'Inactive').should('have.class', 'red'); - cy.get('.primary-action').should('contain', 'Start'); - cy.get('.btn-secondary').should('contain', 'Clear'); + cy.findByRole('button', {name: 'Start'}).should('exist'); + cy.findByRole('button', {name: 'Clear'}).should('exist'); cy.get('.msg-box').should('contain', 'Inactive'); - cy.get('.msg-box .btn-primary').should('contain', 'Start Recording'); + cy.findByRole('button', {name: 'Start Recording'}).should('exist'); }); it('Recorder Start', () => { - cy.get('.primary-action').should('contain', 'Start').click(); + cy.findByRole('button', {name: 'Start'}).click(); cy.get('.indicator-pill').should('contain', 'Active').should('have.class', 'green'); cy.get('.msg-box').should('contain', 'No Requests'); @@ -46,12 +46,12 @@ context('Recorder', () => { cy.get('.list-count').should('contain', '20 of '); cy.visit('/app/recorder'); - cy.get('.title-text').should('contain', 'Recorder'); + cy.findByTitle('Recorder').should('exist'); cy.get('.result-list').should('contain', '/api/method/frappe.desk.reportview.get'); }); it('Recorder View Request', () => { - cy.get('.primary-action').should('contain', 'Start').click(); + cy.findByRole('button', {name: 'Start'}).click(); cy.visit('/app/List/DocType/List'); cy.intercept('POST', '/api/method/frappe.desk.reportview.get').as('list_refresh'); diff --git a/cypress/integration/report_view.js b/cypress/integration/report_view.js index ea76246ae2..e762eebea1 100644 --- a/cypress/integration/report_view.js +++ b/cypress/integration/report_view.js @@ -23,7 +23,7 @@ context('Report View', () => { let cell = cy.get('.dt-row-0 > .dt-cell--col-4'); // select the cell cell.dblclick(); - cell.find('input[data-fieldname="enabled"]').check({ force: true }); + cell.findByRole('checkbox').check({ force: true }); cy.get('.dt-row-0 > .dt-cell--col-5').click(); cy.wait('@value-update'); cy.get('@doc').then(doc => { diff --git a/cypress/integration/timeline.js b/cypress/integration/timeline.js index c7bbe29e5a..7a8f3a159b 100644 --- a/cypress/integration/timeline.js +++ b/cypress/integration/timeline.js @@ -10,26 +10,26 @@ context('Timeline', () => { 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.findByRole('button', {name: 'Edit in full page'}).click(); + cy.get('[data-fieldname="description"] .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.findByRole('button', {name: 'Save'}).click(); cy.wait(700); cy.visit('/app/todo'); - cy.get('.list-row > .level-left > .list-subject > .level-item.ellipsis > .ellipsis').eq(0).click(); + cy.get('.level-item.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'); + cy.get('[data-fieldname="comment"] .ql-editor').should('contain', '').type('Testing Timeline'); //Adding new comment - cy.get('.comment-input-wrapper > .btn').contains('Comment').click(); + cy.findByRole('button', {name: '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.get('.timeline-content [data-fieldname="comment"] .ql-editor').first().type(' 123'); cy.click_timeline_action_btn(0); //To check if the edited comment text is visible in timeline content @@ -37,20 +37,20 @@ context('Timeline', () => { //Discarding comment cy.click_timeline_action_btn(0); - cy.get('.actions > .btn').eq(1).first().click(); + cy.findByRole('button', {name: 'Dismiss'}).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.findByRole('button', {name: '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}); + cy.get('.menu-btn-group button').eq(1).click(); + cy.get('.menu-btn-group [data-label="Delete"]').click(); + cy.findByRole('button', {name: 'Yes'}).click(); }); it('Timeline should have submit and cancel activity information', () => { @@ -64,31 +64,31 @@ context('Timeline', () => { //Adding a new entry for the created custom doctype cy.fill_field('title', 'Test'); - cy.get('.modal-footer > .standard-actions > .btn-primary').contains('Save').click(); - cy.get('.modal-footer > .standard-actions > .btn-primary').contains('Submit').click(); + cy.findByRole('button', {name: 'Save'}).click(); + cy.findByRole('button', {name: 'Submit'}).click(); cy.visit('/app/custom-submittable-doctype'); cy.get('.list-subject > .bold > .ellipsis').eq(0).click(); //To check if the submission of the documemt is visible in the timeline content cy.get('.timeline-content').should('contain', 'Administrator submitted this document'); - cy.get('.page-actions > .standard-actions > .btn-secondary').contains('Cancel').click({delay: 900}); - cy.get('.modal-footer > .standard-actions > .btn-primary').contains('Yes').click(); - + cy.findByRole('button', {name: 'Cancel'}).click({delay: 900}); + cy.findByRole('button', {name: 'Yes'}).click(); + //To check if the cancellation of the documemt is visible in the timeline content cy.get('.timeline-content').should('contain', 'Administrator cancelled this document'); //Deleting the document cy.visit('/app/custom-submittable-doctype'); cy.get('.list-subject > .select-like > .list-row-checkbox').eq(0).click(); - cy.get('.page-actions > .standard-actions > .actions-btn-group > .btn').contains('Actions').click(); + cy.findByRole('button', {name: 'Actions'}).click(); cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(7).click(); cy.click_modal_primary_button('Yes', {force: true, delay: 700}); //Deleting the custom doctype cy.visit('/app/doctype'); cy.get('.list-subject > .select-like > .list-row-checkbox').eq(0).click(); - cy.get('.page-actions > .standard-actions > .actions-btn-group > .btn').contains('Actions').click(); - cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(5).click(); + cy.findByRole('button', {name: 'Actions'}).click(); + cy.get('.actions-btn-group [data-label="Delete"]').click(); cy.click_modal_primary_button('Yes'); }); }); \ No newline at end of file From 29dfffb9c5d1877170ef19e14740f335e079df0a Mon Sep 17 00:00:00 2001 From: MitulDavid Date: Wed, 18 Aug 2021 19:32:16 +0530 Subject: [PATCH 078/244] refactor: Replace .format() with f-strings --- frappe/commands/utils.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index ac574841fe..be8304e45d 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -589,14 +589,14 @@ def run_ui_tests(context, app, headless=False, parallel=True, ci_build_id=None): admin_password = frappe.get_conf(site).admin_password # override baseUrl using env variable - site_env = 'CYPRESS_baseUrl={}'.format(site_url) - password_env = 'CYPRESS_adminPassword={}'.format(admin_password) if admin_password else '' + site_env = f'CYPRESS_baseUrl={site_url}' + password_env = f'CYPRESS_adminPassword={admin_password}' if admin_password else '' os.chdir(app_base_path) node_bin = subprocess.getoutput("npm bin") - cypress_path = "{0}/cypress".format(node_bin) - plugin_path = "{0}/../cypress-file-upload".format(node_bin) + cypress_path = f"{node_bin}/cypress" + plugin_path = f"{node_bin}/../cypress-file-upload" testing_library_path = f"{node_bin}/../@testing-library" # check if cypress in path...if not, install it. @@ -619,7 +619,7 @@ def run_ui_tests(context, app, headless=False, parallel=True, ci_build_id=None): formatted_command += ' --parallel' if ci_build_id: - formatted_command += ' --ci-build-id {}'.format(ci_build_id) + formatted_command += f' --ci-build-id {ci_build_id}' click.secho("Running Cypress...", fg="yellow") frappe.commands.popen(formatted_command, cwd=app_base_path, raise_err=True) From 5640366757860da10cf7b3af94307c89bfb0c5ab Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Wed, 18 Aug 2021 19:59:56 +0530 Subject: [PATCH 079/244] fix: Handle null values rows for XLSX format --- frappe/email/doctype/auto_email_report/auto_email_report.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frappe/email/doctype/auto_email_report/auto_email_report.py b/frappe/email/doctype/auto_email_report/auto_email_report.py index f30279e308..90b869997e 100644 --- a/frappe/email/doctype/auto_email_report/auto_email_report.py +++ b/frappe/email/doctype/auto_email_report/auto_email_report.py @@ -133,8 +133,10 @@ class AutoEmailReport(Document): new_row = [] out.append(new_row) for df in columns: - if df.fieldname not in row: continue - new_row.append(frappe.format(row[df.fieldname], df, row)) + if row.get(df.fieldname): + new_row.append(frappe.format(row[df.fieldname], df, row)) + else: + new_row.append('') return out From eb37566ac0fb6434c752f7dabc93cf2113a8195e Mon Sep 17 00:00:00 2001 From: hasnain2808 Date: Wed, 18 Aug 2021 20:34:58 +0530 Subject: [PATCH 080/244] feat: min lines for ace editor --- frappe/public/js/frappe/form/controls/code.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frappe/public/js/frappe/form/controls/code.js b/frappe/public/js/frappe/form/controls/code.js index 210bf57839..421af1cbb3 100644 --- a/frappe/public/js/frappe/form/controls/code.js +++ b/frappe/public/js/frappe/form/controls/code.js @@ -18,8 +18,12 @@ frappe.ui.form.ControlCode = class ControlCode extends frappe.ui.form.ControlTex const ace = window.ace; this.editor = ace.edit(this.ace_editor_target.get(0)); - if (this.df.max_lines) - this.editor.setOption("maxLines", this.df.max_lines); + if (this.df.max_lines || this.df.min_lines) { + if (this.df.max_lines) + this.editor.setOption("maxLines", this.df.max_lines); + if (this.df.min_lines) + this.editor.setOption("minLines", this.df.min_lines); + } else { this.expanded = false; this.$expand_button = $(``).click(() => { From 12c106719d6b24fc058eb4a83983edb14563542e Mon Sep 17 00:00:00 2001 From: Mohammad Hasnain Mohsin Rajan Date: Wed, 18 Aug 2021 20:44:36 +0530 Subject: [PATCH 081/244] style: close curly brace on the same line --- frappe/public/js/frappe/form/controls/code.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frappe/public/js/frappe/form/controls/code.js b/frappe/public/js/frappe/form/controls/code.js index 421af1cbb3..27888fd49b 100644 --- a/frappe/public/js/frappe/form/controls/code.js +++ b/frappe/public/js/frappe/form/controls/code.js @@ -23,8 +23,7 @@ frappe.ui.form.ControlCode = class ControlCode extends frappe.ui.form.ControlTex this.editor.setOption("maxLines", this.df.max_lines); if (this.df.min_lines) this.editor.setOption("minLines", this.df.min_lines); - } - else { + } else { this.expanded = false; this.$expand_button = $(``).click(() => { this.expanded = !this.expanded; From 2fcd3b556da4427cf3286e438a650cf6d87c6cab Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Wed, 18 Aug 2021 22:53:35 +0530 Subject: [PATCH 082/244] fix: Use query report build_xlsx_data function for XLSX and CSV format --- frappe/desk/query_report.py | 4 +-- .../auto_email_report/auto_email_report.py | 31 ++++++++----------- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/frappe/desk/query_report.py b/frappe/desk/query_report.py index b42c9c89a0..610eaf466a 100644 --- a/frappe/desk/query_report.py +++ b/frappe/desk/query_report.py @@ -391,7 +391,7 @@ def handle_duration_fieldtype_values(result, columns): return result -def build_xlsx_data(columns, data, visible_idx, include_indentation): +def build_xlsx_data(columns, data, visible_idx, include_indentation, ignore_visible_idx=False): result = [[]] column_widths = [] @@ -407,7 +407,7 @@ def build_xlsx_data(columns, data, visible_idx, include_indentation): # build table from result for row_idx, row in enumerate(data.result): # only pick up rows that are visible in the report - if row_idx in visible_idx: + if ignore_visible_idx or row_idx in visible_idx: row_data = [] if isinstance(row, dict): for col_idx, column in enumerate(data.columns): diff --git a/frappe/email/doctype/auto_email_report/auto_email_report.py b/frappe/email/doctype/auto_email_report/auto_email_report.py index 90b869997e..ccfff594b7 100644 --- a/frappe/email/doctype/auto_email_report/auto_email_report.py +++ b/frappe/email/doctype/auto_email_report/auto_email_report.py @@ -13,6 +13,7 @@ from frappe.utils import (format_time, get_link_to_form, get_url_to_report, from frappe.model.naming import append_number_if_name_exists from frappe.utils.csvutils import to_csv from frappe.utils.xlsxutils import make_xlsx +from frappe.desk.query_report import build_xlsx_data max_reports_per_user = frappe.local.conf.max_reports_per_user or 3 @@ -99,13 +100,21 @@ class AutoEmailReport(Document): return self.get_html_table(columns, data) elif self.format == 'XLSX': - spreadsheet_data = self.get_spreadsheet_data(columns, data) - xlsx_file = make_xlsx(spreadsheet_data, "Auto Email Report") + report_data = frappe._dict() + report_data['columns'] = columns + report_data['result'] = data + + xlsx_data, column_widths = build_xlsx_data(columns, report_data, [], 1, ignore_visible_idx=True) + xlsx_file = make_xlsx(xlsx_data, "Auto Email Report", column_widths=column_widths) return xlsx_file.getvalue() elif self.format == 'CSV': - spreadsheet_data = self.get_spreadsheet_data(columns, data) - return to_csv(spreadsheet_data) + report_data = frappe._dict() + report_data['columns'] = columns + report_data['result'] = data + + xlsx_data, column_widths = build_xlsx_data(columns, report_data, [], 1, ignore_visible_idx=True) + return to_csv(xlsx_data) else: frappe.throw(_('Invalid Output Format')) @@ -126,20 +135,6 @@ class AutoEmailReport(Document): 'edit_report_settings': get_link_to_form('Auto Email Report', self.name) }) - @staticmethod - def get_spreadsheet_data(columns, data): - out = [[_(df.label) for df in columns], ] - for row in data: - new_row = [] - out.append(new_row) - for df in columns: - if row.get(df.fieldname): - new_row.append(frappe.format(row[df.fieldname], df, row)) - else: - new_row.append('') - - return out - def get_file_name(self): return "{0}.{1}".format(self.report.replace(" ", "-").replace("/", "-"), self.format.lower()) From 68263b8663ba2e79622441209757db844c876be4 Mon Sep 17 00:00:00 2001 From: shariquerik Date: Thu, 19 Aug 2021 12:27:44 +0530 Subject: [PATCH 083/244] fix: App without home page gives error when navigate to home --- frappe/public/js/frappe/router.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/public/js/frappe/router.js b/frappe/public/js/frappe/router.js index 4360f3e887..c27e443c06 100644 --- a/frappe/public/js/frappe/router.js +++ b/frappe/public/js/frappe/router.js @@ -354,8 +354,8 @@ frappe.router = { return a; } }).join('/'); - - return '/app/' + (path_string || 'home'); + let default_page = frappe.workspaces['home'] ? 'home' : Object.keys(frappe.workspaces)[0]; + return '/app/' + (path_string || default_page); }, push_state(url) { From b96b4c90f76b5500ec6899265e2ea216e1c8f0eb Mon Sep 17 00:00:00 2001 From: shariquerik Date: Thu, 19 Aug 2021 12:30:59 +0530 Subject: [PATCH 084/244] fix: Can't navigate to webpage with name same as private workspace --- frappe/public/js/frappe/router.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/router.js b/frappe/public/js/frappe/router.js index c27e443c06..484f1ac911 100644 --- a/frappe/public/js/frappe/router.js +++ b/frappe/public/js/frappe/router.js @@ -129,7 +129,7 @@ frappe.router = { if (frappe.workspaces[route[0]]) { // public workspace route = ['Workspaces', frappe.workspaces[route[0]].title]; - } else if (frappe.workspaces[route[1]]) { + } else if (route[0] == 'private' && frappe.workspaces[route[1]]) { // private workspace route = ['Workspaces', 'private', frappe.workspaces[route[1]].title]; } else if (this.routes[route[0]]) { From 461dfd6c6e14a08fdd5f5e0e7bb06a1e8769192b Mon Sep 17 00:00:00 2001 From: shariquerik Date: Thu, 19 Aug 2021 12:42:43 +0530 Subject: [PATCH 085/244] fix: Make Content hidden and added validation --- frappe/desk/doctype/workspace/workspace.json | 15 ++++++++------- frappe/desk/doctype/workspace/workspace.py | 3 +++ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/frappe/desk/doctype/workspace/workspace.json b/frappe/desk/doctype/workspace/workspace.json index e2ae38faf1..e991ee3f8f 100644 --- a/frappe/desk/doctype/workspace/workspace.json +++ b/frappe/desk/doctype/workspace/workspace.json @@ -28,7 +28,6 @@ "pin_to_bottom", "hide_custom", "public", - "content_section", "content", "section_break_2", "charts_label", @@ -39,6 +38,7 @@ "section_break_18", "cards_label", "links", + "roles_section", "roles" ], "fields": [ @@ -239,14 +239,10 @@ "fieldtype": "Data", "label": "Parent Page" }, - { - "fieldname": "content_section", - "fieldtype": "Section Break", - "label": "Content" - }, { "fieldname": "content", "fieldtype": "Long Text", + "hidden": 1, "label": "Content" }, { @@ -259,10 +255,15 @@ "fieldtype": "Table", "label": "Roles", "options": "Has Role" + }, + { + "fieldname": "roles_section", + "fieldtype": "Section Break", + "label": "Roles" } ], "links": [], - "modified": "2021-08-05 11:49:09.028243", + "modified": "2021-08-19 12:36:29.825169", "modified_by": "Administrator", "module": "Desk", "name": "Workspace", diff --git a/frappe/desk/doctype/workspace/workspace.py b/frappe/desk/doctype/workspace/workspace.py index 0821ae03c4..693c34bcd1 100644 --- a/frappe/desk/doctype/workspace/workspace.py +++ b/frappe/desk/doctype/workspace/workspace.py @@ -17,6 +17,9 @@ class Workspace(Document): frappe.throw(_("You need to be in developer mode to edit this document")) validate_route_conflict(self.doctype, self.name) + if isinstance(self.content, list): + frappe.throw(_("Content data shoud be a list")) + duplicate_exists = frappe.db.exists("Workspace", { "name": ["!=", self.name], 'is_default': 1, 'extends': self.extends }) From 1032d3a7d1101b9e7d68032e7c9d7c244ca21cc3 Mon Sep 17 00:00:00 2001 From: shariquerik Date: Thu, 19 Aug 2021 12:49:45 +0530 Subject: [PATCH 086/244] fix: Removed default collapsing sidebar section --- frappe/public/js/frappe/views/workspace/workspace.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/frappe/public/js/frappe/views/workspace/workspace.js b/frappe/public/js/frappe/views/workspace/workspace.js index b46c220d9d..8f11e711ca 100644 --- a/frappe/public/js/frappe/views/workspace/workspace.js +++ b/frappe/public/js/frappe/views/workspace/workspace.js @@ -174,10 +174,6 @@ frappe.views.Workspace = class Workspace { $(e.target).parent().find('.sidebar-item-container').toggleClass('hidden'); }); - if (!this.current_page.name) { - $title.trigger("click"); - } - if (Object.keys(root_pages).length === 0) { sidebar_section.addClass('hidden'); } From bb55ca4c29c584275ed6a14660552250da4744b9 Mon Sep 17 00:00:00 2001 From: shariquerik Date: Thu, 19 Aug 2021 12:51:40 +0530 Subject: [PATCH 087/244] fix: Make title and label field mandatory --- frappe/desk/doctype/workspace/workspace.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frappe/desk/doctype/workspace/workspace.json b/frappe/desk/doctype/workspace/workspace.json index e991ee3f8f..020f3153df 100644 --- a/frappe/desk/doctype/workspace/workspace.json +++ b/frappe/desk/doctype/workspace/workspace.json @@ -46,6 +46,7 @@ "fieldname": "label", "fieldtype": "Data", "label": "Name", + "reqd": 1, "unique": 1 }, { @@ -232,7 +233,8 @@ { "fieldname": "title", "fieldtype": "Data", - "label": "Title" + "label": "Title", + "reqd": 1 }, { "fieldname": "parent_page", @@ -263,7 +265,7 @@ } ], "links": [], - "modified": "2021-08-19 12:36:29.825169", + "modified": "2021-08-19 12:51:00.233017", "modified_by": "Administrator", "module": "Desk", "name": "Workspace", From 6a599f77ebf29a6e466ad44c75c915586ff8971a Mon Sep 17 00:00:00 2001 From: shariquerik Date: Thu, 19 Aug 2021 14:07:52 +0530 Subject: [PATCH 088/244] fix: Content validation --- frappe/desk/doctype/workspace/workspace.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frappe/desk/doctype/workspace/workspace.py b/frappe/desk/doctype/workspace/workspace.py index 693c34bcd1..31ae5754c1 100644 --- a/frappe/desk/doctype/workspace/workspace.py +++ b/frappe/desk/doctype/workspace/workspace.py @@ -17,7 +17,10 @@ class Workspace(Document): frappe.throw(_("You need to be in developer mode to edit this document")) validate_route_conflict(self.doctype, self.name) - if isinstance(self.content, list): + try: + if not isinstance(loads(self.content), list): + raise + except: frappe.throw(_("Content data shoud be a list")) duplicate_exists = frappe.db.exists("Workspace", { From ca4d220f145d0678f2621e3631a8993c2c3d02b3 Mon Sep 17 00:00:00 2001 From: shariquerik Date: Thu, 19 Aug 2021 14:20:18 +0530 Subject: [PATCH 089/244] fix: sider fix --- frappe/desk/doctype/workspace/workspace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/desk/doctype/workspace/workspace.py b/frappe/desk/doctype/workspace/workspace.py index 31ae5754c1..7795d02616 100644 --- a/frappe/desk/doctype/workspace/workspace.py +++ b/frappe/desk/doctype/workspace/workspace.py @@ -20,7 +20,7 @@ class Workspace(Document): try: if not isinstance(loads(self.content), list): raise - except: + except Exception: frappe.throw(_("Content data shoud be a list")) duplicate_exists = frappe.db.exists("Workspace", { From c681b1af27fb3c975496e8b8679c4fff997586fd Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Thu, 19 Aug 2021 14:40:20 +0530 Subject: [PATCH 090/244] fix: Make thumbnail function --- frappe/core/doctype/file/file.py | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index e79b2bd761..f09f5bcaa2 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -21,7 +21,7 @@ import zipfile import requests import requests.exceptions from PIL import Image, ImageFile, ImageOps -from io import StringIO +from io import BytesIO from urllib.parse import quote, unquote import frappe @@ -270,16 +270,12 @@ class File(Document): def make_thumbnail(self, set_as_thumbnail=True, width=300, height=300, suffix="small", crop=False): if self.file_url: - if self.file_url.startswith("/files"): - try: + try: + if self.file_url.startswith(("/files", "/private/files")): image, filename, extn = get_local_image(self.file_url) - except IOError: - return - - else: - try: + else: image, filename, extn = get_web_image(self.file_url) - except (requests.exceptions.HTTPError, requests.exceptions.SSLError, IOError, TypeError): + except (requests.exceptions.HTTPError, requests.exceptions.SSLError, IOError, TypeError): return size = width, height @@ -289,16 +285,13 @@ class File(Document): image.thumbnail(size, Image.ANTIALIAS) thumbnail_url = filename + "_" + suffix + "." + extn - path = os.path.abspath(frappe.get_site_path("public", thumbnail_url.lstrip("/"))) try: image.save(path) - if set_as_thumbnail: self.db_set("thumbnail_url", thumbnail_url) - self.db_set("thumbnail_url", thumbnail_url) except IOError: frappe.msgprint(_("Unable to write file format for {0}").format(path)) return @@ -704,7 +697,7 @@ def get_web_image(file_url): raise try: - image = Image.open(StringIO(frappe.safe_decode(r.content))) + image = Image.open(BytesIO(r.content)) except Exception as e: frappe.msgprint(_("Image link '{0}' is not valid").format(file_url), raise_exception=e) @@ -886,7 +879,7 @@ def extract_images_from_html(doc, content): if b"," in content: content = content.split(b",")[1] content = base64.b64decode(content) - + content = optimize_image(content, mtype) if "filename=" in headers: From 1427712c8817a01fa3631535eb15267fc5120465 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Thu, 19 Aug 2021 14:41:25 +0530 Subject: [PATCH 091/244] test: Add test cases for file - file_url validations - make_thumbnail --- frappe/core/doctype/file/test_file.py | 52 +++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index 649010c468..5b63da30ef 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -365,6 +365,58 @@ class TestFile(unittest.TestCase): file1.file_url = '/private/files/parent_dir2.txt' file1.save() + def test_file_url_validation(self): + test_file = frappe.get_doc({ + "doctype": "File", + "file_name": 'logo', + "file_url": 'https://frappe.io/files/frappe.png' + }) + + self.assertIsNone(test_file.validate()) + + # bad path + test_file.file_url = "/usr/bin/man" + self.assertRaisesRegex(frappe.exceptions.ValidationError, "URL must start with http:// or https://", test_file.validate) + + test_file.file_url = None + test_file.file_name = "/usr/bin/man" + self.assertRaisesRegex(frappe.exceptions.ValidationError, "There is some problem with the file url", test_file.validate) + + test_file.file_url = None + test_file.file_name = "_file" + self.assertRaisesRegex(IOError, "does not exist", test_file.validate) + + test_file.file_url = None + test_file.file_name = "/private/files/_file" + self.assertRaisesRegex(IOError, "does not exist", test_file.validate) + + def test_make_thumbnail(self): + # test web image + test_file = frappe.get_doc({ + "doctype": "File", + "file_name": 'logo', + "file_url": frappe.utils.get_url('/_test/assets/image.jpg'), + "docstatus": 0 + }).insert() + + test_file.make_thumbnail() + self.assertEquals(test_file.thumbnail_url, '/files/image_small.jpg') + + # test local image + test_file.db_set('thumbnail_url', None) + test_file.reload() + test_file.file_url = "/files/image_small.jpg" + test_file.make_thumbnail(suffix="xs") + self.assertEquals(test_file.thumbnail_url, '/files/image_small_xs.jpg') + + frappe.clear_messages() + test_file.db_set('thumbnail_url', None) + test_file.reload() + test_file.file_url = frappe.utils.get_url('unknown.jpg') + test_file.make_thumbnail(suffix="xs") + self.assertEqual(frappe.message_log[0], '{"message": "File \'http://test-site:8000/unknown.jpg\' not found"}') + self.assertEquals(test_file.thumbnail_url, None) + class TestAttachment(unittest.TestCase): test_doctype = 'Test For Attachment' From a7fb1816aa873e4afc8963f6dddc4b79640bfc9d Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Thu, 19 Aug 2021 15:27:25 +0530 Subject: [PATCH 092/244] refactor: Remove unused and redundant code --- frappe/core/doctype/file/file.py | 75 ----------------- .../prepared_report/prepared_report.py | 3 +- frappe/desk/form/utils.py | 2 +- frappe/model/delete_doc.py | 2 +- frappe/utils/file_manager.py | 80 +++---------------- .../personal_data_download_request.py | 2 +- 6 files changed, 14 insertions(+), 150 deletions(-) diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index f09f5bcaa2..b44a06876e 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -352,10 +352,6 @@ class File(Document): return files - def get_file_url(self): - data = frappe.db.get_value("File", self.file_data_name, ["file_name", "file_url"], as_dict=True) - return data.file_url or data.file_name - def exists_on_disk(self): exists = os.path.exists(self.get_full_path()) return exists @@ -424,33 +420,6 @@ class File(Document): return get_files_path(self.file_name, is_private=self.is_private) - def get_file_doc(self): - '''returns File object (Document) from given parameters or form_dict''' - r = frappe.form_dict - - if self.file_url is None: self.file_url = r.file_url - if self.file_name is None: self.file_name = r.file_name - if self.attached_to_doctype is None: self.attached_to_doctype = r.doctype - if self.attached_to_name is None: self.attached_to_name = r.docname - if self.attached_to_field is None: self.attached_to_field = r.docfield - if self.folder is None: self.folder = r.folder - if self.is_private is None: self.is_private = r.is_private - - if r.filedata: - file_doc = self.save_uploaded() - - elif r.file_url: - file_doc = self.save() - - return file_doc - - - def save_uploaded(self): - self.content = self.get_uploaded_content() - if self.content: - return self.save() - else: - raise Exception def get_uploaded_content(self): # should not be unicode when reading a file, hence using frappe.form @@ -532,14 +501,6 @@ class File(Document): 'file_url': self.file_url } - def get_file_data_from_hash(self): - for name in frappe.db.sql_list("select name from `tabFile` where content_hash=%s and is_private=%s", - (self.content_hash, self.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 check_max_file_size(self): max_file_size = get_max_file_size() file_size = len(self.content) @@ -733,48 +694,12 @@ def delete_file(path): os.remove(path) -def remove_file(fid=None, attached_to_doctype=None, attached_to_name=None, from_delete=False, delete_permanently=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, delete_permanently=delete_permanently) - - return comment def get_max_file_size(): return cint(conf.get('max_file_size')) or 10485760 -def remove_all(dt, dn, from_delete=False, delete_permanently=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)): - if from_delete: - # If deleting a doc, directly delete files - frappe.delete_doc("File", fid, ignore_permissions=True, delete_permanently=delete_permanently) - else: - # Removes file and adds a comment in the document it is attached to - remove_file(fid=fid, attached_to_doctype=dt, attached_to_name=dn, - from_delete=from_delete, delete_permanently=delete_permanently) - except Exception as e: - if e.args[0]!=1054: raise # (temp till for patched) - def has_permission(doc, ptype=None, user=None): has_access = False diff --git a/frappe/core/doctype/prepared_report/prepared_report.py b/frappe/core/doctype/prepared_report/prepared_report.py index c68bb6a4f1..409aa6f217 100644 --- a/frappe/core/doctype/prepared_report/prepared_report.py +++ b/frappe/core/doctype/prepared_report/prepared_report.py @@ -11,8 +11,7 @@ from frappe.desk.query_report import generate_report_result from frappe.model.document import Document from frappe.utils import gzip_compress, gzip_decompress from frappe.utils.background_jobs import enqueue -from frappe.core.doctype.file.file import remove_all - +from frappe.utils.file_manager import remove_all class PreparedReport(Document): def before_insert(self): diff --git a/frappe/desk/form/utils.py b/frappe/desk/form/utils.py index bfceee6ea2..d7ac940d21 100644 --- a/frappe/desk/form/utils.py +++ b/frappe/desk/form/utils.py @@ -5,7 +5,7 @@ import frappe, json import frappe.desk.form.meta import frappe.desk.form.load from frappe.desk.form.document_follow import follow_document -from frappe.utils.file_manager import extract_images_from_html +from frappe.core.doctype.file.file import extract_images_from_html from frappe import _ diff --git a/frappe/model/delete_doc.py b/frappe/model/delete_doc.py index fbbf1a4852..c2f750b744 100644 --- a/frappe/model/delete_doc.py +++ b/frappe/model/delete_doc.py @@ -10,7 +10,7 @@ import frappe.model.meta from frappe import _ from frappe import get_module_path from frappe.model.dynamic_links import get_dynamic_link_map -from frappe.core.doctype.file.file import remove_all +from frappe.utils.file_manager import remove_all from frappe.utils.password import delete_all_passwords_for from frappe.model.naming import revert_series_if_last from frappe.utils.global_search import delete_for_document diff --git a/frappe/utils/file_manager.py b/frappe/utils/file_manager.py index 7efdff299b..eb4556c47c 100644 --- a/frappe/utils/file_manager.py +++ b/frappe/utils/file_manager.py @@ -213,28 +213,22 @@ def write_file(content, fname, is_private=0): return get_files_path(fname, is_private=is_private) -def remove_all(dt, dn, from_delete=False): +def remove_all(dt, dn, from_delete=False, delete_permanently=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) + if from_delete: + # If deleting a doc, directly delete files + frappe.delete_doc("File", fid, ignore_permissions=True, delete_permanently=delete_permanently) + else: + # Removes file and adds a comment in the document it is attached to + remove_file(fid=fid, attached_to_doctype=dt, attached_to_name=dn, + from_delete=from_delete, delete_permanently=delete_permanently) 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): +def remove_file(fid=None, attached_to_doctype=None, attached_to_name=None, from_delete=False, delete_permanently=False): """Remove file and File entry""" file_name = None if not (attached_to_doctype and attached_to_name): @@ -252,8 +246,7 @@ def remove_file(fid, attached_to_doctype=None, attached_to_name=None, from_delet 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) + frappe.delete_doc("File", fid, ignore_permissions=ignore_permissions, delete_permanently=delete_permanently) return comment @@ -372,59 +365,6 @@ def download_file(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(",") - mtype = headers.split(";")[0] - - if isinstance(content, str): - content = content.encode("utf-8") - if b"," in content: - content = content.split(b",")[1] - content = base64.b64decode(content) - - content = optimize_image(content, mtype) - - if "filename=" in headers: - filename = headers.split("filename=")[-1] - - # decode filename - if not isinstance(filename, str): - filename = str(filename, 'utf-8') - else: - filename = get_random_filename(content_type=mtype) - - doctype = doc.parenttype if doc.parent else doc.doctype - name = doc.parent or doc.name - - if doc.doctype == "Comment": - doctype = doc.reference_doctype - name = doc.reference_name - - # TODO fix this - file_url = save_file(filename, content, doctype, name, decode=False).get("file_url") - if not frappe.flags.has_dataurl: - frappe.flags.has_dataurl = True - - return ']*src\s*=\s*["\'](?=data:)(.*?)["\']', _save_file, content) - - return content - - def get_random_filename(extn=None, content_type=None): if extn: if not extn.startswith("."): diff --git a/frappe/website/doctype/personal_data_download_request/personal_data_download_request.py b/frappe/website/doctype/personal_data_download_request/personal_data_download_request.py index 9255adb8c2..e74e8190c4 100644 --- a/frappe/website/doctype/personal_data_download_request/personal_data_download_request.py +++ b/frappe/website/doctype/personal_data_download_request/personal_data_download_request.py @@ -28,7 +28,7 @@ class PersonalDataDownloadRequest(Document): }) f.save(ignore_permissions=True) - file_link = frappe.utils.get_url("/api/method/frappe.core.doctype.file.file.download_file") +\ + file_link = frappe.utils.get_url("/api/method/frappe.utils.file_manager.download_file") +\ "?" + get_signed_params({"file_url": f.file_url}) host_name = frappe.local.site frappe.sendmail( From 6b1955b34afafa930c0c8882ef76f05a23a1e2eb Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Thu, 19 Aug 2021 15:27:48 +0530 Subject: [PATCH 093/244] test: Add test image file --- frappe/core/doctype/file/test_file.py | 2 +- frappe/www/_test/assets/image.jpg | Bin 0 -> 161713 bytes 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 frappe/www/_test/assets/image.jpg diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index 5b63da30ef..1920148fca 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -397,7 +397,7 @@ class TestFile(unittest.TestCase): "file_name": 'logo', "file_url": frappe.utils.get_url('/_test/assets/image.jpg'), "docstatus": 0 - }).insert() + }).insert(ignore_permissions=True) test_file.make_thumbnail() self.assertEquals(test_file.thumbnail_url, '/files/image_small.jpg') diff --git a/frappe/www/_test/assets/image.jpg b/frappe/www/_test/assets/image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4a2c1552b991add9a356a460415626ad700f401b GIT binary patch literal 161713 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 literal 0 HcmV?d00001 From 21e664b23b07797765b79d42fc222985277e8ff7 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Thu, 19 Aug 2021 16:02:20 +0530 Subject: [PATCH 094/244] test: Fix file_name assertion --- frappe/core/doctype/file/test_file.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index 1920148fca..ce971221c5 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -2,6 +2,7 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt import base64 +import json import frappe import os import unittest @@ -414,7 +415,7 @@ class TestFile(unittest.TestCase): test_file.reload() test_file.file_url = frappe.utils.get_url('unknown.jpg') test_file.make_thumbnail(suffix="xs") - self.assertEqual(frappe.message_log[0], '{"message": "File \'http://test-site:8000/unknown.jpg\' not found"}') + self.assertEqual(json.loads(frappe.message_log[0]), {"message": f"File '{frappe.utils.get_url('unknown.jpg')}' not found"}) self.assertEquals(test_file.thumbnail_url, None) class TestAttachment(unittest.TestCase): From 44c82276e830f7b720703e1aeffdfd724013fc0c Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Thu, 19 Aug 2021 17:37:01 +0530 Subject: [PATCH 095/244] refactor: Remove unused methods --- frappe/utils/file_manager.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/frappe/utils/file_manager.py b/frappe/utils/file_manager.py index eb4556c47c..b1e088d641 100644 --- a/frappe/utils/file_manager.py +++ b/frappe/utils/file_manager.py @@ -365,23 +365,6 @@ def download_file(file_url): frappe.local.response.filecontent = filedata frappe.local.response.type = "download" -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''' From bc234e9052d8de5f9c8a59fa51379d750f7c537c Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Thu, 19 Aug 2021 17:38:09 +0530 Subject: [PATCH 096/244] test: Add test case for extract_image_from_doc --- frappe/core/doctype/file/test_file.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index ce971221c5..72fe3491d1 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -522,3 +522,22 @@ class TestAttachmentsAccess(unittest.TestCase): frappe.set_user('Administrator') frappe.db.rollback() + + +class TestFileUtils(unittest.TestCase): + def test_extract_images_from_doc(self): + # with filename in data URI + todo = frappe.get_doc({ + "doctype": "ToDo", + "description": 'Test ' + }).insert() + self.assertTrue(frappe.db.exists("File", {"attached_to_name": todo.name})) + self.assertIn('', todo.description) + + # withot filename in data URI + todo = frappe.get_doc({ + "doctype": "ToDo", + "description": 'Test ' + }).insert() + filename = frappe.db.exists("File", {"attached_to_name": todo.name}) + self.assertIn(f' Date: Thu, 19 Aug 2021 17:39:45 +0530 Subject: [PATCH 097/244] fix: File name from data URI headers --- frappe/core/doctype/file/file.py | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index b44a06876e..85e9a5f27b 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -25,7 +25,7 @@ from io import BytesIO from urllib.parse import quote, unquote import frappe -from frappe import _, conf +from frappe import _, conf, safe_decode from frappe.model.document import Document from frappe.utils import call_hook_method, cint, cstr, encode, get_files_path, get_hook_method, random_string, strip from frappe.utils.image import strip_exif_data, optimize_image @@ -809,10 +809,8 @@ def extract_images_from_html(doc, content): if "filename=" in headers: filename = headers.split("filename=")[-1] + filename = safe_decode(filename).split(";")[0] - # decode filename - if not isinstance(filename, str): - filename = str(filename, 'utf-8') else: filename = get_random_filename(content_type=mtype) @@ -840,12 +838,9 @@ def extract_images_from_html(doc, content): return content -def get_random_filename(extn=None, content_type=None): - if extn: - if not extn.startswith("."): - extn = "." + extn - - elif content_type: +def get_random_filename(content_type=None): + extn = None + if content_type: extn = mimetypes.guess_extension(content_type) return random_string(7) + (extn or "") @@ -897,13 +892,6 @@ def get_attached_images(doctype, names): return out -@frappe.whitelist() -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 get_files_in_folder(folder, start=0, page_length=20): start = cint(start) From 1cf1bc1e068e38a9932cd4823a3a58c080dec621 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Thu, 19 Aug 2021 17:48:40 +0530 Subject: [PATCH 098/244] refactor: Remove unused functions --- frappe/core/doctype/file/file.py | 14 -------------- frappe/utils/csvutils.py | 11 +---------- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index 85e9a5f27b..8d0eee8c69 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -420,20 +420,6 @@ class File(Document): return get_files_path(self.file_name, is_private=self.is_private) - - def get_uploaded_content(self): - # 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) - return frappe.uploaded_content - elif self.content: - return self.content - frappe.msgprint(_('No file attached')) - return None - - def save_file(self, content=None, decode=False, ignore_existing_file_check=False): file_exists = False self.content = content diff --git a/frappe/utils/csvutils.py b/frappe/utils/csvutils.py index 734d68fe8a..69b7f6f2d3 100644 --- a/frappe/utils/csvutils.py +++ b/frappe/utils/csvutils.py @@ -6,16 +6,7 @@ import json import csv import requests from io import StringIO -from frappe.utils import encode, cstr, cint, flt, comma_or - -def read_csv_content_from_uploaded_file(ignore_encoding=False): - if getattr(frappe, "uploaded_file", None): - with open(frappe.uploaded_file, "r") as upfile: - fcontent = upfile.read() - else: - _file = frappe.new_doc("File") - fcontent = _file.get_uploaded_content() - return read_csv_content(fcontent, ignore_encoding) +from frappe.utils import cstr, cint, flt, comma_or def read_csv_content_from_attached_file(doc): fileid = frappe.get_all("File", fields = ["name"], filters = {"attached_to_doctype": doc.doctype, From 133be72dfdbb9a108bd10e49f8b33f0ed4c4b331 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Thu, 19 Aug 2021 18:21:40 +0530 Subject: [PATCH 099/244] fix: Do frappe.db.commit() only for GET request --- frappe/core/doctype/access_log/access_log.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frappe/core/doctype/access_log/access_log.py b/frappe/core/doctype/access_log/access_log.py index 2ea014f981..82db450b4a 100644 --- a/frappe/core/doctype/access_log/access_log.py +++ b/frappe/core/doctype/access_log/access_log.py @@ -29,4 +29,5 @@ def make_access_log(doctype=None, document=None, method=None, file_type=None, doc.insert(ignore_permissions=True) # `frappe.db.commit` added because insert doesnt `commit` when called in GET requests like `printview` - frappe.db.commit() + if frappe.request and frappe.request.method == 'GET': + frappe.db.commit() From 6b789ec53ad789416638bd0ee34fcd739bf010e4 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 19 Aug 2021 18:44:36 +0530 Subject: [PATCH 100/244] fix: Revert auto_commit after frappe.sendmail --- frappe/email/doctype/newsletter/newsletter.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frappe/email/doctype/newsletter/newsletter.py b/frappe/email/doctype/newsletter/newsletter.py index 667d0fb34c..a118240488 100755 --- a/frappe/email/doctype/newsletter/newsletter.py +++ b/frappe/email/doctype/newsletter/newsletter.py @@ -165,6 +165,7 @@ class Newsletter(WebsiteGenerator): sender = self.send_from or frappe.utils.get_formatted_email(self.owner) args = {"message": message, "name": self.name} + is_auto_commit_set = bool(frappe.db.auto_commit_on_many_writes) frappe.db.auto_commit_on_many_writes = not frappe.flags.in_test frappe.sendmail( @@ -184,7 +185,7 @@ class Newsletter(WebsiteGenerator): args=args, ) - frappe.db.auto_commit_on_many_writes = not frappe.flags.in_test + frappe.db.auto_commit_on_many_writes = is_auto_commit_set def get_message(self) -> str: if self.content_type == "HTML": From 07906594e3fb8cb24417932eb327132e282296d4 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Thu, 19 Aug 2021 19:33:37 +0530 Subject: [PATCH 101/244] test: Fix failing test --- frappe/core/doctype/file/test_file.py | 2 +- frappe/core/doctype/user/test_user.py | 2 +- frappe/desk/doctype/notification_log/test_notification_log.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index 72fe3491d1..df0ba8c2ec 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -534,7 +534,7 @@ class TestFileUtils(unittest.TestCase): self.assertTrue(frappe.db.exists("File", {"attached_to_name": todo.name})) self.assertIn('', todo.description) - # withot filename in data URI + # without filename in data URI todo = frappe.get_doc({ "doctype": "ToDo", "description": 'Test ' diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py index 6534e70235..5fea8ed9d8 100644 --- a/frappe/core/doctype/user/test_user.py +++ b/frappe/core/doctype/user/test_user.py @@ -346,7 +346,7 @@ class TestUser(unittest.TestCase): self.assertEqual(update_password(new_password, old_password=old_password), "/") # reset password update_password(old_password, old_password=new_password) - frappe.set_user("Administrator") + def test_password_verification(self): diff --git a/frappe/desk/doctype/notification_log/test_notification_log.py b/frappe/desk/doctype/notification_log/test_notification_log.py index af4dee8df3..2d232cf942 100644 --- a/frappe/desk/doctype/notification_log/test_notification_log.py +++ b/frappe/desk/doctype/notification_log/test_notification_log.py @@ -55,6 +55,6 @@ def get_todo(): def get_user(): users = frappe.db.get_all('User', - filters={'name': ('not in', ['Administrator', 'Guest'])}, + filters={'name': ('not in', ['Administrator', 'Guest']), 'enabled': 1}, fields='name', limit=1) return users[0].name From c00a5003f482dd37326495ceda2cb418f5317839 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 19 Aug 2021 19:46:50 +0530 Subject: [PATCH 102/244] refactor(tests): frappe.db.delete > frappe.db.sql Use frappe.db.delete wherever possible. Get rid of all the frappe.db.sql ;) This commit focuses on the frappe.tests module --- frappe/tests/test_assign.py | 2 +- frappe/tests/test_db_query.py | 2 +- frappe/tests/test_domainification.py | 6 +++--- frappe/tests/test_dynamic_links.py | 2 +- frappe/tests/test_email.py | 8 ++++---- frappe/tests/test_frappe_client.py | 24 ++++++++++++------------ frappe/tests/test_global_search.py | 10 +++++----- frappe/tests/test_goal.py | 2 +- frappe/tests/test_naming.py | 16 ++++++++-------- frappe/tests/test_permissions.py | 8 ++++---- frappe/tests/test_scheduler.py | 2 +- 11 files changed, 41 insertions(+), 41 deletions(-) diff --git a/frappe/tests/test_assign.py b/frappe/tests/test_assign.py index e9c1ccec6d..e553fc3f5d 100644 --- a/frappe/tests/test_assign.py +++ b/frappe/tests/test_assign.py @@ -22,7 +22,7 @@ class TestAssign(unittest.TestCase): self.assertEqual(len(assignments), 0) def test_assignment_count(self): - frappe.db.sql('delete from tabToDo') + frappe.db.delete("ToDo") if not frappe.db.exists("User", "test_assign1@example.com"): frappe.get_doc({"doctype":"User", "email":"test_assign1@example.com", "first_name":"Test", "roles": [{"role": "System Manager"}]}).insert() diff --git a/frappe/tests/test_db_query.py b/frappe/tests/test_db_query.py index 89975b46d6..3724b46d9d 100644 --- a/frappe/tests/test_db_query.py +++ b/frappe/tests/test_db_query.py @@ -105,7 +105,7 @@ class TestReportview(unittest.TestCase): def test_between_filters(self): """ test case to check between filter for date fields """ - frappe.db.sql("delete from tabEvent") + frappe.db.delete("Event") # create events to test the between operator filter todays_event = create_event() diff --git a/frappe/tests/test_domainification.py b/frappe/tests/test_domainification.py index c9acd9ec45..bc972efe1f 100644 --- a/frappe/tests/test_domainification.py +++ b/frappe/tests/test_domainification.py @@ -17,9 +17,9 @@ class TestDomainification(unittest.TestCase): self.add_active_domain("_Test Domain 1") def tearDown(self): - frappe.db.sql("delete from tabRole where name='_Test Role'") - frappe.db.sql("delete from `tabHas Role` where role='_Test Role'") - frappe.db.sql("delete from tabDomain where name in ('_Test Domain 1', '_Test Domain 2')") + frappe.db.delete("Role", {"name": "_Test Role"}) + frappe.db.delete("Has Role", {"role": "_Test Role"}) + frappe.db.delete("Domain", {"name": ("in", ("_Test Domain 1", "_Test Domain 2"))}) frappe.delete_doc('DocType', 'Test Domainification') self.remove_from_active_domains(remove_all=True) diff --git a/frappe/tests/test_dynamic_links.py b/frappe/tests/test_dynamic_links.py index 04ccc91ff2..5ca12ddf13 100644 --- a/frappe/tests/test_dynamic_links.py +++ b/frappe/tests/test_dynamic_links.py @@ -4,7 +4,7 @@ import frappe, unittest class TestDynamicLinks(unittest.TestCase): def setUp(self): - frappe.db.sql('delete from `tabEmail Unsubscribe`') + frappe.db.delete("Email Unsubscribe") def test_delete_normal(self): event = frappe.get_doc({ diff --git a/frappe/tests/test_email.py b/frappe/tests/test_email.py index a837a2c572..68ab80cb8b 100644 --- a/frappe/tests/test_email.py +++ b/frappe/tests/test_email.py @@ -7,9 +7,9 @@ test_dependencies = ['Email Account'] class TestEmail(unittest.TestCase): def setUp(self): - frappe.db.sql("""delete from `tabEmail Unsubscribe`""") - frappe.db.sql("""delete from `tabEmail Queue`""") - frappe.db.sql("""delete from `tabEmail Queue Recipient`""") + frappe.db.delete("Email Unsubscribe") + frappe.db.delete("Email Queue") + frappe.db.delete("Email Queue Recipient") def test_email_queue(self, send_after=None): frappe.sendmail(recipients=['test@example.com', 'test1@example.com'], @@ -170,7 +170,7 @@ class TestEmail(unittest.TestCase): import re email_account = frappe.get_doc('Email Account', '_Test Email Account 1') - frappe.db.sql('''delete from `tabCommunication` where sender = 'sukh@yyy.com' ''') + frappe.db.delete("Communication", {"sender": "sukh@yyy.com"}) with open(frappe.get_app_path('frappe', 'tests', 'data', 'email_with_image.txt'), 'r') as raw: mails = email_account.get_inbound_mails(test_mails=[raw.read()]) diff --git a/frappe/tests/test_frappe_client.py b/frappe/tests/test_frappe_client.py index e1cdbb6ccd..556828f843 100644 --- a/frappe/tests/test_frappe_client.py +++ b/frappe/tests/test_frappe_client.py @@ -12,7 +12,7 @@ import base64 class TestFrappeClient(unittest.TestCase): def test_insert_many(self): server = FrappeClient(get_url(), "Administrator", "admin", verify=False) - frappe.db.sql("delete from `tabNote` where title in ('Sing','a','song','of','sixpence')") + frappe.db.delete("Note", {"title": ("in", ('Sing','a','song','of','sixpence'))}) frappe.db.commit() server.insert_many([ @@ -31,7 +31,7 @@ class TestFrappeClient(unittest.TestCase): def test_create_doc(self): server = FrappeClient(get_url(), "Administrator", "admin", verify=False) - frappe.db.sql("delete from `tabNote` where title = 'test_create'") + frappe.db.delete("Note", {"title": "test_create"}) frappe.db.commit() server.insert({"doctype": "Note", "public": True, "title": "test_create"}) @@ -46,7 +46,7 @@ class TestFrappeClient(unittest.TestCase): def test_get_doc(self): server = FrappeClient(get_url(), "Administrator", "admin", verify=False) - frappe.db.sql("delete from `tabNote` where title = 'get_this'") + frappe.db.delete("Note", {"title": "get_this"}) frappe.db.commit() server.insert_many([ @@ -57,7 +57,7 @@ class TestFrappeClient(unittest.TestCase): def test_get_value(self): server = FrappeClient(get_url(), "Administrator", "admin", verify=False) - frappe.db.sql("delete from `tabNote` where title = 'get_value'") + frappe.db.delete("Note", {"title": "get_value"}) frappe.db.commit() test_content = "test get value" @@ -82,7 +82,7 @@ class TestFrappeClient(unittest.TestCase): def test_update_doc(self): server = FrappeClient(get_url(), "Administrator", "admin", verify=False) - frappe.db.sql("delete from `tabNote` where title in ('Sing','sing')") + frappe.db.delete("Note", {"title": ("in" ("Sing", "sing"))}) frappe.db.commit() server.insert({"doctype":"Note", "public": True, "title": "Sing"}) @@ -94,12 +94,12 @@ class TestFrappeClient(unittest.TestCase): def test_update_child_doc(self): server = FrappeClient(get_url(), "Administrator", "admin", verify=False) - frappe.db.sql("delete from `tabContact` where first_name = 'George' and last_name = 'Steevens'") - frappe.db.sql("delete from `tabContact` where first_name = 'William' and last_name = 'Shakespeare'") - frappe.db.sql("delete from `tabCommunication` where reference_doctype = 'Event'") - frappe.db.sql("delete from `tabCommunication Link` where link_doctype = 'Contact'") - frappe.db.sql("delete from `tabEvent` where subject = 'Sing a song of sixpence'") - frappe.db.sql("delete from `tabEvent Participants` where reference_doctype = 'Contact'") + frappe.db.delete("Contact", {"first_name": "George", "last_name": "Steevens"}) + frappe.db.delete("Contact", {"first_name": "William", "last_name": "Shakespeare"}) + frappe.db.delete("Communication", {"reference_doctype": "Event"}) + frappe.db.delete("Communication Link", {"link_doctype": "Contact"}) + frappe.db.delete("Event", {"subject": "Sing a song of sixpence"}) + frappe.db.delete("Event Participants", {"reference_doctype": "Contact"}) frappe.db.commit() # create multiple contacts @@ -131,7 +131,7 @@ class TestFrappeClient(unittest.TestCase): def test_delete_doc(self): server = FrappeClient(get_url(), "Administrator", "admin", verify=False) - frappe.db.sql("delete from `tabNote` where title = 'delete'") + frappe.db.delete("Note", {"title": "delete"}) frappe.db.commit() server.insert_many([ diff --git a/frappe/tests/test_global_search.py b/frappe/tests/test_global_search.py index 3921af6738..c227abfb99 100644 --- a/frappe/tests/test_global_search.py +++ b/frappe/tests/test_global_search.py @@ -24,15 +24,15 @@ class TestGlobalSearch(unittest.TestCase): make_property_setter(doctype, "repeat_on", "in_global_search", 0, "Int") def tearDown(self): - frappe.db.sql("DELETE FROM `tabProperty Setter` WHERE `doc_type`='Event'") + frappe.db.delete("Property Setter", {"doc_type": "Event"}) frappe.clear_cache(doctype='Event') - frappe.db.sql('DELETE FROM `tabEvent`') - frappe.db.sql('DELETE FROM `__global_search`') + frappe.db.delete("Event") + frappe.db.delete("__global_search") make_test_objects('Event') frappe.db.commit() def insert_test_events(self): - frappe.db.sql('DELETE FROM `tabEvent`') + frappe.db.delete("Event") phrases = ['"The Sixth Extinction II: Amor Fati" is the second episode of the seventh season of the American science fiction.', 'After Mulder awakens from his coma, he realizes his duty to prevent alien colonization. ', 'Carter explored themes of extraterrestrial involvement in ancient mass extinctions in this episode, the third in a trilogy.'] @@ -97,7 +97,7 @@ class TestGlobalSearch(unittest.TestCase): self.assertEqual(len(results), 0) def test_insert_child_table(self): - frappe.db.sql('delete from tabEvent') + frappe.db.delete("Event") phrases = ['Hydrus is a small constellation in the deep southern sky. ', 'It was first depicted on a celestial atlas by Johann Bayer in his 1603 Uranometria. ', 'The French explorer and astronomer Nicolas Louis de Lacaille charted the brighter stars and gave their Bayer designations in 1756. ', diff --git a/frappe/tests/test_goal.py b/frappe/tests/test_goal.py index 5a83baa1af..55b1b9aad8 100644 --- a/frappe/tests/test_goal.py +++ b/frappe/tests/test_goal.py @@ -13,7 +13,7 @@ class TestGoal(unittest.TestCase): make_test_objects('Event', reset=True) def tearDown(self): - frappe.db.sql('delete from `tabEvent`') + frappe.db.delete("Event") # make_test_objects('Event', reset=True) frappe.db.commit() diff --git a/frappe/tests/test_naming.py b/frappe/tests/test_naming.py index 830c39442e..3a8e2445d7 100644 --- a/frappe/tests/test_naming.py +++ b/frappe/tests/test_naming.py @@ -72,7 +72,7 @@ class TestNaming(unittest.TestCase): current_index = frappe.db.sql("""SELECT current from `tabSeries` where name = %s""", series, as_dict=True)[0] self.assertEqual(current_index.get('current'), 0) - frappe.db.sql("""delete from `tabSeries` where name = %s""", series) + frappe.db.delete("Series", {"name": series}) series = 'TEST-{}-'.format(year) key = 'TEST-.YYYY.-.#####' @@ -82,40 +82,40 @@ class TestNaming(unittest.TestCase): current_index = frappe.db.sql("""SELECT current from `tabSeries` where name = %s""", series, as_dict=True)[0] self.assertEqual(current_index.get('current'), 1) - frappe.db.sql("""delete from `tabSeries` where name = %s""", series) + frappe.db.delete("Series", {"name": series}) series = 'TEST-' key = 'TEST-' name = 'TEST-00003' - frappe.db.sql("DELETE FROM `tabSeries` WHERE `name`=%s", series) + frappe.db.delete("Series", {"name": series}) frappe.db.sql("""INSERT INTO `tabSeries` (name, current) values (%s, 3)""", (series,)) revert_series_if_last(key, name) current_index = frappe.db.sql("""SELECT current from `tabSeries` where name = %s""", series, as_dict=True)[0] self.assertEqual(current_index.get('current'), 2) - frappe.db.sql("""delete from `tabSeries` where name = %s""", series) + frappe.db.delete("Series", {"name": series}) series = 'TEST1-' key = 'TEST1-.#####.-2021-22' name = 'TEST1-00003-2021-22' - frappe.db.sql("DELETE FROM `tabSeries` WHERE `name`=%s", series) + frappe.db.delete("Series", {"name": series}) frappe.db.sql("""INSERT INTO `tabSeries` (name, current) values (%s, 3)""", (series,)) revert_series_if_last(key, name) current_index = frappe.db.sql("""SELECT current from `tabSeries` where name = %s""", series, as_dict=True)[0] self.assertEqual(current_index.get('current'), 2) - frappe.db.sql("""delete from `tabSeries` where name = %s""", series) + frappe.db.delete("Series", {"name": series}) series = '' key = '.#####.-2021-22' name = '00003-2021-22' - frappe.db.sql("DELETE FROM `tabSeries` WHERE `name`=%s", series) + frappe.db.delete("Series", {"name": series}) frappe.db.sql("""INSERT INTO `tabSeries` (name, current) values (%s, 3)""", (series,)) revert_series_if_last(key, name) current_index = frappe.db.sql("""SELECT current from `tabSeries` where name = %s""", series, as_dict=True)[0] self.assertEqual(current_index.get('current'), 2) - frappe.db.sql("""delete from `tabSeries` where name = %s""", series) + frappe.db.delete("Series", {"name": series}) def test_naming_for_cancelled_and_amended_doc(self): submittable_doctype = frappe.get_doc({ diff --git a/frappe/tests/test_permissions.py b/frappe/tests/test_permissions.py index ada64156de..9586660535 100644 --- a/frappe/tests/test_permissions.py +++ b/frappe/tests/test_permissions.py @@ -38,7 +38,7 @@ class TestPermissions(unittest.TestCase): reset('Blogger') reset('Blog Post') - frappe.db.sql('delete from `tabUser Permission`') + frappe.db.delete("User Permission") frappe.set_user("test1@example.com") @@ -334,9 +334,9 @@ class TestPermissions(unittest.TestCase): doctype""" frappe.set_user('Administrator') - frappe.db.sql('DELETE FROM `tabContact`') - frappe.db.sql('DELETE FROM `tabContact Email`') - frappe.db.sql('DELETE FROM `tabContact Phone`') + frappe.db.delete("Contact") + frappe.db.delete("Contact Email") + frappe.db.delete("Contact Phone") reset('Salutation') reset('Contact') diff --git a/frappe/tests/test_scheduler.py b/frappe/tests/test_scheduler.py index ec43c08ab7..f13bcbe06f 100644 --- a/frappe/tests/test_scheduler.py +++ b/frappe/tests/test_scheduler.py @@ -45,7 +45,7 @@ class TestScheduler(TestCase): # 1st job is in the queue (or running), don't enqueue it again self.assertFalse(job.enqueue()) - frappe.db.sql('DELETE FROM `tabScheduled Job Log` WHERE `scheduled_job_type`=%s', job.name) + frappe.db.delete("Scheduled Job Log", {"scheduled_job_type": job.name}) def test_is_dormant(self): self.assertTrue(is_dormant(check_time= get_datetime('2100-01-01 00:00:00'))) From ebc220db393e7549cb7d948e57dbb6a92897b35b Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 19 Aug 2021 19:50:48 +0530 Subject: [PATCH 103/244] refactor(tests): frappe.db.delete > frappe.db.sql Use frappe.db.delete wherever possible. Get rid of all the frappe.db.sql ;) This commit focuses on the tests written under the doctype specific tests --- .../assignment_rule/test_assignment_rule.py | 8 ++++---- .../milestone_tracker/test_milestone_tracker.py | 4 ++-- frappe/core/doctype/comment/test_comment.py | 4 ++-- frappe/core/doctype/feedback/test_feedback.py | 4 ++-- .../core/doctype/translation/test_translation.py | 2 +- frappe/core/doctype/user/test_user.py | 6 +++--- frappe/desk/doctype/event/test_event.py | 2 +- frappe/desk/doctype/note/test_note.py | 6 +++--- frappe/desk/doctype/tag/test_tag.py | 2 +- frappe/desk/doctype/todo/test_todo.py | 6 +++--- .../doctype/email_account/test_email_account.py | 12 ++++++------ .../doctype/notification/test_notification.py | 16 ++++++++-------- .../integrations/doctype/webhook/test_webhook.py | 8 ++++---- .../energy_point_log/test_energy_point_log.py | 4 ++-- .../test_personal_data_download_request.py | 4 ++-- frappe/website/doctype/web_page/test_web_page.py | 2 +- .../workflow/doctype/workflow/test_workflow.py | 2 +- 17 files changed, 46 insertions(+), 46 deletions(-) diff --git a/frappe/automation/doctype/assignment_rule/test_assignment_rule.py b/frappe/automation/doctype/assignment_rule/test_assignment_rule.py index e287b83965..dfefd091fb 100644 --- a/frappe/automation/doctype/assignment_rule/test_assignment_rule.py +++ b/frappe/automation/doctype/assignment_rule/test_assignment_rule.py @@ -76,7 +76,7 @@ class TestAutoAssign(unittest.TestCase): # clear 5 assignments for first user # can't do a limit in "delete" since postgres does not support it for d in frappe.get_all('ToDo', dict(reference_type = 'Note', owner = 'test@example.com'), limit=5): - frappe.db.sql("delete from tabToDo where name = %s", d.name) + frappe.db.delete("ToDo", {"name": d.name}) # add 5 more assignments for i in range(5): @@ -177,7 +177,7 @@ class TestAutoAssign(unittest.TestCase): ), 'owner'), 'test@example.com') def check_assignment_rule_scheduling(self): - frappe.db.sql("DELETE FROM `tabAssignment Rule`") + frappe.db.delete("Assignment Rule") days_1 = [dict(day = 'Sunday'), dict(day = 'Monday'), dict(day = 'Tuesday')] @@ -204,7 +204,7 @@ class TestAutoAssign(unittest.TestCase): ), 'owner'), ['test3@example.com']) def test_assignment_rule_condition(self): - frappe.db.sql("DELETE FROM `tabAssignment Rule`") + frappe.db.delete("Assignment Rule") # Add expiry_date custom field from frappe.custom.doctype.custom_field.custom_field import create_custom_field @@ -253,7 +253,7 @@ class TestAutoAssign(unittest.TestCase): assignment_rule.delete() def clear_assignments(): - frappe.db.sql("delete from tabToDo where reference_type = 'Note'") + frappe.db.delete("ToDo", {"reference_type": "Note"}) def get_assignment_rule(days, assign=None): frappe.delete_doc_if_exists('Assignment Rule', 'For Note 1') diff --git a/frappe/automation/doctype/milestone_tracker/test_milestone_tracker.py b/frappe/automation/doctype/milestone_tracker/test_milestone_tracker.py index 21b2779018..1683e94827 100644 --- a/frappe/automation/doctype/milestone_tracker/test_milestone_tracker.py +++ b/frappe/automation/doctype/milestone_tracker/test_milestone_tracker.py @@ -7,7 +7,7 @@ import unittest class TestMilestoneTracker(unittest.TestCase): def test_milestone(self): - frappe.db.sql('delete from `tabMilestone Tracker`') + frappe.db.delete("Milestone Tracker") frappe.cache().delete_key('milestone_tracker_map') @@ -44,5 +44,5 @@ class TestMilestoneTracker(unittest.TestCase): self.assertEqual(milestones[0].value, 'Closed') # cleanup - frappe.db.sql('delete from tabMilestone') + frappe.db.delete("Milestone") milestone_tracker.delete() \ No newline at end of file diff --git a/frappe/core/doctype/comment/test_comment.py b/frappe/core/doctype/comment/test_comment.py index 13db92e7a8..12fe027fba 100644 --- a/frappe/core/doctype/comment/test_comment.py +++ b/frappe/core/doctype/comment/test_comment.py @@ -30,7 +30,7 @@ class TestComment(unittest.TestCase): from frappe.website.doctype.blog_post.test_blog_post import make_test_blog test_blog = make_test_blog() - frappe.db.sql("delete from `tabComment` where reference_doctype = 'Blog Post'") + frappe.db.delete("Comment", {"reference_doctype": "Blog Post"}) from frappe.templates.includes.comments.comments import add_comment add_comment('Good comment with 10 chars', 'test@test.com', 'Good Tester', @@ -41,7 +41,7 @@ class TestComment(unittest.TestCase): reference_name = test_blog.name ))[0].published, 1) - frappe.db.sql("delete from `tabComment` where reference_doctype = 'Blog Post'") + frappe.db.delete("Comment", {"reference_doctype": "Blog Post"}) add_comment('pleez vizits my site http://mysite.com', 'test@test.com', 'bad commentor', 'Blog Post', test_blog.name, test_blog.route) diff --git a/frappe/core/doctype/feedback/test_feedback.py b/frappe/core/doctype/feedback/test_feedback.py index 2a96d86874..c7551420c3 100644 --- a/frappe/core/doctype/feedback/test_feedback.py +++ b/frappe/core/doctype/feedback/test_feedback.py @@ -9,7 +9,7 @@ class TestFeedback(unittest.TestCase): from frappe.website.doctype.blog_post.test_blog_post import make_test_blog test_blog = make_test_blog() - frappe.db.sql("delete from `tabFeedback` where reference_doctype = 'Blog Post'") + frappe.db.delete("Feedback", {"reference_doctype": "Blog Post"}) from frappe.templates.includes.feedback.feedback import add_feedback, update_feedback feedback = add_feedback('Blog Post', test_blog.name, 5, 'New feedback') @@ -22,6 +22,6 @@ class TestFeedback(unittest.TestCase): self.assertEqual(updated_feedback.feedback, 'Updated feedback') self.assertEqual(updated_feedback.rating, 6) - frappe.db.sql("delete from `tabFeedback` where reference_doctype = 'Blog Post'") + frappe.db.delete("Feedback", {"reference_doctype": "Blog Post"}) test_blog.delete() \ No newline at end of file diff --git a/frappe/core/doctype/translation/test_translation.py b/frappe/core/doctype/translation/test_translation.py index ae1293b38f..e5b5d2d393 100644 --- a/frappe/core/doctype/translation/test_translation.py +++ b/frappe/core/doctype/translation/test_translation.py @@ -8,7 +8,7 @@ from frappe import _ class TestTranslation(unittest.TestCase): def setUp(self): - frappe.db.sql('delete from tabTranslation') + frappe.db.delete("Translation") def tearDown(self): frappe.local.lang = 'en' diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py index 392128834d..fbe1830844 100644 --- a/frappe/core/doctype/user/test_user.py +++ b/frappe/core/doctype/user/test_user.py @@ -52,7 +52,7 @@ class TestUser(unittest.TestCase): def test_delete(self): frappe.get_doc("User", "test@example.com").add_roles("_Test Role 2") self.assertRaises(frappe.LinkExistsError, delete_doc, "Role", "_Test Role 2") - frappe.db.sql("""delete from `tabHas Role` where role='_Test Role 2'""") + frappe.db.delete("Has Role", {"role": "_Test Role 2"}) delete_doc("Role","_Test Role 2") if frappe.db.exists("User", "_test@example.com"): @@ -294,5 +294,5 @@ class TestUser(unittest.TestCase): # self.assertIsNone(frappe.db.get("User", {"email": email})) def delete_contact(user): - frappe.db.sql("DELETE FROM `tabContact` WHERE `email_id`= %s", user) - frappe.db.sql("DELETE FROM `tabContact Email` WHERE `email_id`= %s", user) + frappe.db.delete("Contact", {"email_id": user}) + frappe.db.delete("Contact Email", {"email_id": user}) diff --git a/frappe/desk/doctype/event/test_event.py b/frappe/desk/doctype/event/test_event.py index 77211946a9..8f56d11da3 100644 --- a/frappe/desk/doctype/event/test_event.py +++ b/frappe/desk/doctype/event/test_event.py @@ -14,7 +14,7 @@ test_records = frappe.get_test_records('Event') class TestEvent(unittest.TestCase): def setUp(self): - frappe.db.sql('delete from tabEvent') + frappe.db.delete("Event") make_test_objects('Event', reset=True) self.test_records = frappe.get_test_records('Event') diff --git a/frappe/desk/doctype/note/test_note.py b/frappe/desk/doctype/note/test_note.py index 1bb1730357..3207fa9b8d 100644 --- a/frappe/desk/doctype/note/test_note.py +++ b/frappe/desk/doctype/note/test_note.py @@ -8,9 +8,9 @@ test_records = frappe.get_test_records('Note') class TestNote(unittest.TestCase): def insert_note(self): - frappe.db.sql('delete from tabVersion') - frappe.db.sql('delete from tabNote') - frappe.db.sql('delete from `tabNote Seen By`') + frappe.db.delete("Version") + frappe.db.delete("Note") + frappe.db.delete("Note Seen By") return frappe.get_doc(dict(doctype='Note', title='test note', content='test note content')).insert() diff --git a/frappe/desk/doctype/tag/test_tag.py b/frappe/desk/doctype/tag/test_tag.py index 6eb7219c26..b9c6e0b744 100644 --- a/frappe/desk/doctype/tag/test_tag.py +++ b/frappe/desk/doctype/tag/test_tag.py @@ -6,7 +6,7 @@ from frappe.desk.doctype.tag.tag import add_tag class TestTag(unittest.TestCase): def setUp(self) -> None: - frappe.db.sql("DELETE from `tabTag`") + frappe.db.delete("Tag") frappe.db.sql("UPDATE `tabDocType` set _user_tags=''") def test_tag_count_query(self): diff --git a/frappe/desk/doctype/todo/test_todo.py b/frappe/desk/doctype/todo/test_todo.py index b38e4a059a..4afe99597d 100644 --- a/frappe/desk/doctype/todo/test_todo.py +++ b/frappe/desk/doctype/todo/test_todo.py @@ -14,7 +14,7 @@ class TestToDo(unittest.TestCase): todo = frappe.get_doc(dict(doctype='ToDo', description='test todo', assigned_by='Administrator')).insert() - frappe.db.sql('delete from `tabDeleted Document`') + frappe.db.delete("Deleted Document") todo.delete() deleted = frappe.get_doc('Deleted Document', dict(deleted_doctype=todo.doctype, deleted_name=todo.name)) @@ -27,7 +27,7 @@ class TestToDo(unittest.TestCase): frappe.db.get_value('User', todo.assigned_by, 'full_name')) def test_fetch_setup(self): - frappe.db.sql('delete from tabToDo') + frappe.db.delete("ToDo") todo_meta = frappe.get_doc('DocType', 'ToDo') todo_meta.get('fields', dict(fieldname='assigned_by_full_name'))[0].fetch_from = '' @@ -105,7 +105,7 @@ class TestToDo(unittest.TestCase): frappe.db.rollback() def test_fetch_if_empty(self): - frappe.db.sql('delete from tabToDo') + frappe.db.delete("ToDo") # Allow user changes todo_meta = frappe.get_doc('DocType', 'ToDo') diff --git a/frappe/email/doctype/email_account/test_email_account.py b/frappe/email/doctype/email_account/test_email_account.py index 35cacac45a..da03a5959e 100644 --- a/frappe/email/doctype/email_account/test_email_account.py +++ b/frappe/email/doctype/email_account/test_email_account.py @@ -34,8 +34,8 @@ class TestEmailAccount(unittest.TestCase): def setUp(self): frappe.flags.mute_emails = False frappe.flags.sent_mail = None - frappe.db.sql('delete from `tabEmail Queue`') - frappe.db.sql('delete from `tabUnhandled Email`') + frappe.db.delete("Email Queue") + frappe.db.delete("Unhandled Email") def get_test_mail(self, fname): with open(os.path.join(os.path.dirname(__file__), "test_mails", fname), "r") as f: @@ -60,7 +60,7 @@ class TestEmailAccount(unittest.TestCase): comm = frappe.get_doc("Communication", {"sender": "test_sender@example.com"}) comm.db_set("creation", datetime.now() - timedelta(seconds = 30 * 60)) - frappe.db.sql("DELETE FROM `tabEmail Queue`") + frappe.db.delete("Email Queue") notify_unreplied() self.assertTrue(frappe.db.get_value("Email Queue", {"reference_doctype": comm.reference_doctype, "reference_name": comm.reference_name, "status":"Not Sent"})) @@ -183,7 +183,7 @@ class TestEmailAccount(unittest.TestCase): def test_threading_by_message_id(self): cleanup() - frappe.db.sql("""delete from `tabEmail Queue`""") + frappe.db.delete("Email Queue") # reference document for testing event = frappe.get_doc(dict(doctype='Event', subject='test-message')).insert() @@ -242,8 +242,8 @@ class TestInboundMail(unittest.TestCase): def setUp(self): cleanup() - frappe.db.sql('delete from `tabEmail Queue`') - frappe.db.sql('delete from `tabToDo`') + frappe.db.delete("Email Queue") + frappe.db.delete("ToDo") def get_test_mail(self, fname): with open(os.path.join(os.path.dirname(__file__), "test_mails", fname), "r") as f: diff --git a/frappe/email/doctype/notification/test_notification.py b/frappe/email/doctype/notification/test_notification.py index d6358ccbbe..2629050c1b 100644 --- a/frappe/email/doctype/notification/test_notification.py +++ b/frappe/email/doctype/notification/test_notification.py @@ -9,7 +9,7 @@ test_dependencies = ["User", "Notification"] class TestNotification(unittest.TestCase): def setUp(self): - frappe.db.sql("""delete from `tabEmail Queue`""") + frappe.db.delete("Email Queue") frappe.set_user("test@example.com") if not frappe.db.exists('Notification', {'name': 'ToDo Status Update'}, 'name'): @@ -50,7 +50,7 @@ class TestNotification(unittest.TestCase): self.assertTrue(frappe.db.get_value("Email Queue", {"reference_doctype": "Communication", "reference_name": communication.name, "status":"Not Sent"})) - frappe.db.sql("""delete from `tabEmail Queue`""") + frappe.db.delete("Email Queue") communication.reload() communication.content = "test 2" @@ -189,9 +189,9 @@ class TestNotification(unittest.TestCase): def test_cc_jinja(self): - frappe.db.sql("""delete from `tabUser` where email='test_jinja@example.com'""") - frappe.db.sql("""delete from `tabEmail Queue`""") - frappe.db.sql("""delete from `tabEmail Queue Recipient`""") + frappe.db.delete("User", {"email": "test_jinja@example.com"}) + frappe.db.delete("Email Queue") + frappe.db.delete("Email Queue Recipient") test_user = frappe.new_doc("User") test_user.name = 'test_jinja' @@ -205,9 +205,9 @@ class TestNotification(unittest.TestCase): self.assertTrue(frappe.db.get_value("Email Queue Recipient", {"recipient": "test_jinja@example.com"})) - frappe.db.sql("""delete from `tabUser` where email='test_jinja@example.com'""") - frappe.db.sql("""delete from `tabEmail Queue`""") - frappe.db.sql("""delete from `tabEmail Queue Recipient`""") + frappe.db.delete("User", {"email": "test_jinja@example.com"}) + frappe.db.delete("Email Queue") + frappe.db.delete("Email Queue Recipient") def test_notification_to_assignee(self): todo = frappe.new_doc('ToDo') diff --git a/frappe/integrations/doctype/webhook/test_webhook.py b/frappe/integrations/doctype/webhook/test_webhook.py index b77e311f7e..1470f666a1 100644 --- a/frappe/integrations/doctype/webhook/test_webhook.py +++ b/frappe/integrations/doctype/webhook/test_webhook.py @@ -11,9 +11,9 @@ class TestWebhook(unittest.TestCase): @classmethod def setUpClass(cls): # delete any existing webhooks - frappe.db.sql("DELETE FROM tabWebhook") + frappe.db.delete("Webhook") # Delete existing logs if any - frappe.db.sql("DELETE FROM `tabWebhook Request Log`") + frappe.db.delete("Webhook Request Log") # create test webhooks cls.create_sample_webhooks() @@ -46,7 +46,7 @@ class TestWebhook(unittest.TestCase): @classmethod def tearDownClass(cls): # delete any existing webhooks - frappe.db.sql("DELETE FROM tabWebhook") + frappe.db.delete("Webhook") def setUp(self): # retrieve or create a User webhook for `after_insert` @@ -168,7 +168,7 @@ class TestWebhook(unittest.TestCase): def test_webhook_req_log_creation(self): if not frappe.db.get_value('User', 'user2@integration.webhooks.test.com'): user = frappe.get_doc({ - 'doctype': 'User', + 'doctype': 'User', 'email': 'user2@integration.webhooks.test.com', 'first_name': 'user2' }).insert() diff --git a/frappe/social/doctype/energy_point_log/test_energy_point_log.py b/frappe/social/doctype/energy_point_log/test_energy_point_log.py index 4a6e86463e..eb03058760 100644 --- a/frappe/social/doctype/energy_point_log/test_energy_point_log.py +++ b/frappe/social/doctype/energy_point_log/test_energy_point_log.py @@ -13,8 +13,8 @@ class TestEnergyPointLog(unittest.TestCase): def tearDown(self): frappe.set_user('Administrator') - frappe.db.sql('DELETE FROM `tabEnergy Point Log`') - frappe.db.sql('DELETE FROM `tabEnergy Point Rule`') + frappe.db.delete("Energy Point Log") + frappe.db.delete("Energy Point Rule") frappe.cache().delete_value('energy_point_rule_map') def test_user_energy_point(self): diff --git a/frappe/website/doctype/personal_data_download_request/test_personal_data_download_request.py b/frappe/website/doctype/personal_data_download_request/test_personal_data_download_request.py index 563b6b3843..2efac5af12 100644 --- a/frappe/website/doctype/personal_data_download_request/test_personal_data_download_request.py +++ b/frappe/website/doctype/personal_data_download_request/test_personal_data_download_request.py @@ -13,7 +13,7 @@ class TestRequestPersonalData(unittest.TestCase): create_user_if_not_exists(email='test_privacy@example.com') def tearDown(self): - frappe.db.sql("""DELETE FROM `tabPersonal Data Download Request`""") + frappe.db.delete("Personal Data Download Request") def test_user_data_creation(self): user_data = json.loads(get_user_data('test_privacy@example.com')) @@ -45,7 +45,7 @@ class TestRequestPersonalData(unittest.TestCase): limit=1) self.assertTrue("Subject: Download Your Data" in email_queue[0].message) - frappe.db.sql("delete from `tabEmail Queue`") + frappe.db.delete("Email Queue") def create_user_if_not_exists(email, first_name = None): frappe.delete_doc_if_exists("User", email) diff --git a/frappe/website/doctype/web_page/test_web_page.py b/frappe/website/doctype/web_page/test_web_page.py index 0d36d0f870..aebc6a38c1 100644 --- a/frappe/website/doctype/web_page/test_web_page.py +++ b/frappe/website/doctype/web_page/test_web_page.py @@ -8,7 +8,7 @@ test_records = frappe.get_test_records('Web Page') class TestWebPage(unittest.TestCase): def setUp(self): - frappe.db.sql("delete from `tabWeb Page`") + frappe.db.delete("Web Page") for t in test_records: frappe.get_doc(t).insert() diff --git a/frappe/workflow/doctype/workflow/test_workflow.py b/frappe/workflow/doctype/workflow/test_workflow.py index cd36fd2d0b..fb19352c67 100644 --- a/frappe/workflow/doctype/workflow/test_workflow.py +++ b/frappe/workflow/doctype/workflow/test_workflow.py @@ -76,7 +76,7 @@ class TestWorkflow(unittest.TestCase): self.assertListEqual(actions, ['Review']) def test_if_workflow_actions_were_processed(self): - frappe.db.sql('delete from `tabWorkflow Action`') + frappe.db.delete("Workflow Action") user = frappe.get_doc('User', 'test2@example.com') user.add_roles('Test Approver', 'System Manager') frappe.set_user('test2@example.com') From 32c6cf1c443c1c522848736da25cc0b22aea1425 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 19 Aug 2021 19:53:16 +0530 Subject: [PATCH 104/244] refactor(misc): frappe.db.delete > frappe.db.sql Use frappe.db.delete wherever possible. Get rid of all the frappe.db.sql ;) This commit focuses on the pending modules that had relatively easier DELETE statements. --- frappe/model/delete_doc.py | 9 +++++---- frappe/model/document.py | 3 +-- frappe/modules/utils.py | 3 +-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/frappe/model/delete_doc.py b/frappe/model/delete_doc.py index fbbf1a4852..16fc960260 100644 --- a/frappe/model/delete_doc.py +++ b/frappe/model/delete_doc.py @@ -190,7 +190,7 @@ def delete_from_table(doctype, name, ignore_doctypes, doc): # delete from child tables for t in list(set(tables)): if t not in ignore_doctypes: - frappe.db.sql("delete from `tab%s` where parenttype=%s and parent = %s" % (t, '%s', '%s'), (doctype, name)) + frappe.db.delete(t, {"parenttype": doctype, "parent": name}) def update_flags(doc, flags=None, ignore_permissions=False): if ignore_permissions: @@ -323,9 +323,10 @@ def delete_dynamic_links(doctype, name): def delete_references(doctype, reference_doctype, reference_name, reference_doctype_field = 'reference_doctype', reference_name_field = 'reference_name'): - frappe.db.sql('''delete from `tab{0}` - where {1}=%s and {2}=%s'''.format(doctype, reference_doctype_field, reference_name_field), # nosec - (reference_doctype, reference_name)) + frappe.db.delete(doctype, { + reference_doctype_field: reference_doctype, + reference_name_field: reference_name + }) def clear_references(doctype, reference_doctype, reference_name, reference_doctype_field = 'reference_doctype', reference_name_field = 'reference_name'): diff --git a/frappe/model/document.py b/frappe/model/document.py index ee12fd89e0..37549e2001 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -385,8 +385,7 @@ class Document(BaseDocument): [self.name, self.doctype, fieldname] + rows) if len(deleted_rows) > 0: # delete rows that do not match the ones in the document - frappe.db.sql("""delete from `tab{0}` where name in ({1})""".format(df.options, - ','.join(['%s'] * len(deleted_rows))), tuple(row[0] for row in deleted_rows)) + frappe.db.delete(df.options, {"name": ("in", tuple(row[0] for row in deleted_rows))}) else: # no rows found, delete all rows diff --git a/frappe/modules/utils.py b/frappe/modules/utils.py index 0f3e57a5a0..ed2a839dc1 100644 --- a/frappe/modules/utils.py +++ b/frappe/modules/utils.py @@ -114,8 +114,7 @@ def sync_customizations_for_doctype(data, folder): doc.db_insert() if custom_doctype != 'Custom Field': - frappe.db.sql('delete from `tab{0}` where `{1}` =%s'.format( - custom_doctype, doctype_fieldname), doc_type) + frappe.db.delete(custom_doctype, {doctype_fieldname: doc_type}) for d in data[key]: _insert(d) From 4df118906f66dd7d06b6b85063f80dc157704862 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 19 Aug 2021 20:10:11 +0530 Subject: [PATCH 105/244] fix: Run test skipped earlier due to missing indent --- frappe/desk/doctype/todo/test_todo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/desk/doctype/todo/test_todo.py b/frappe/desk/doctype/todo/test_todo.py index 4afe99597d..7e0fa115c9 100644 --- a/frappe/desk/doctype/todo/test_todo.py +++ b/frappe/desk/doctype/todo/test_todo.py @@ -104,7 +104,7 @@ class TestToDo(unittest.TestCase): clear_permissions_cache('ToDo') frappe.db.rollback() -def test_fetch_if_empty(self): + def test_fetch_if_empty(self): frappe.db.delete("ToDo") # Allow user changes From b7e42fdc910592e3b0a55500914420eb6668c4e8 Mon Sep 17 00:00:00 2001 From: Komal-Saraf0609 Date: Tue, 10 Aug 2021 18:53:20 +0530 Subject: [PATCH 106/244] test: Added test cases for folder navigation and checking if the nested folder contains the added file --- cypress/integration/folder_navigation.js | 79 ++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 cypress/integration/folder_navigation.js diff --git a/cypress/integration/folder_navigation.js b/cypress/integration/folder_navigation.js new file mode 100644 index 0000000000..4a389101b5 --- /dev/null +++ b/cypress/integration/folder_navigation.js @@ -0,0 +1,79 @@ +context('Folder Navigation', () => { + before(() => { + cy.visit('/login'); + cy.login(); + cy.visit('/app/file'); + }); + + it('Adding Folders', () => { + //Adding filter to go into the home folder + cy.get('.filter-selector > .btn').click(); + cy.get('.filter-action-buttons > div > .btn-secondary').contains('Clear Filters').click(); + cy.get('.filter-action-buttons > .text-muted').click(); + cy.get('.fieldname-select-area > .awesomplete > .form-control').type('Fol{enter}'); + cy.get('.filter-field > .form-group > .link-field > .awesomplete > .input-with-feedback').type('Home{enter}'); + cy.get('.filter-action-buttons > div > .btn-primary').click(); + + //Adding folder (Test Folder) + cy.get('.menu-btn-group > .btn').click(); + cy.get('.menu-btn-group > .dropdown-menu > .user-action > .grey-link').eq(2).click(); + cy.get('form > .frappe-control > .form-group > .control-input-wrapper > .control-input > .input-with-feedback').type('Test Folder'); + cy.get('.modal-footer > .standard-actions > .btn-primary').click(); + }); + + it('Navigating the nested folders, checking if the URL formed is correct, checking if the added content in the child folder is correct', () => { + //Navigating inside the Attachments folder + cy.get('[title="Attachments"] > span').click(); + + //To check if the URL formed after visiting the attachments folder is correct + cy.location('pathname').should('eq', '/app/file/view/home/Attachments'); + cy.visit('/app/file/view/home/Attachments'); + + //Adding folder inside the attachments folder + cy.get('.menu-btn-group > .btn').click(); + cy.get('.menu-btn-group > .dropdown-menu > .user-action > .grey-link').eq(2).click(); + cy.get('form > .frappe-control > .form-group > .control-input-wrapper > .control-input > .input-with-feedback').type('Test Folder'); + cy.get('.modal-footer > .standard-actions > .btn-primary').click(); + + //Navigating inside the added folder in the Attachments folder + cy.get('[title="Test Folder"] > span').click(); + + //To check if the URL is correct after visiting the Test Folder + cy.location('pathname').should('eq', '/app/file/view/home/Attachments/Test%20Folder'); + cy.visit('/app/file/view/home/Attachments/Test%20Folder'); + + //Adding a file inside the Test Folder + cy.get('.primary-action').contains('Add File').eq(0).click({force : true}); + 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(); + + //To check if the added file is present in the Test Folder + cy.get('span.level-item > span').should('contain','Test Folder'); + cy.get('.list-row-container').eq(0).should('contain.text','72402.jpg'); + cy.get('.list-row-checkbox').eq(0).click(); + + //Deleting the added file from the Test folder + cy.get('.actions-btn-group > .btn').click(); + cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(5).click(); + cy.wait(700); + cy.click_modal_primary_button('Yes', {force : true, delay: 700}); + cy.wait(700); + + //Deleting the Test Folder + cy.visit('/app/file/view/home/Attachments'); + cy.get('.list-row-checkbox').eq(0).click(); + cy.get('.actions-btn-group > .btn').click(); + cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(5).click(); + cy.click_modal_primary_button('Yes'); + }); + + it('Deleting Test Folder from the home', () => { + //Deleting the Test Folder added in the home directory + cy.visit('/app/file/view/home'); + cy.get('.list-row-container > .list-row > .level-left > .list-subject > .list-row-checkbox').eq(0).click({force : true, delay : 500}); + cy.get('.actions-btn-group > .btn').click(); + cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(5).click(); + cy.click_modal_primary_button('Yes', {force : true}); + }); +}); \ No newline at end of file From a881e85cb36e634bb3af0301736d87273089eb2c Mon Sep 17 00:00:00 2001 From: Komal-Saraf0609 Date: Tue, 10 Aug 2021 19:10:27 +0530 Subject: [PATCH 107/244] test: Fixed sider issues --- cypress/integration/folder_navigation.js | 122 +++++++++++------------ 1 file changed, 61 insertions(+), 61 deletions(-) diff --git a/cypress/integration/folder_navigation.js b/cypress/integration/folder_navigation.js index 4a389101b5..d4fe569828 100644 --- a/cypress/integration/folder_navigation.js +++ b/cypress/integration/folder_navigation.js @@ -5,75 +5,75 @@ context('Folder Navigation', () => { cy.visit('/app/file'); }); - it('Adding Folders', () => { - //Adding filter to go into the home folder - cy.get('.filter-selector > .btn').click(); - cy.get('.filter-action-buttons > div > .btn-secondary').contains('Clear Filters').click(); - cy.get('.filter-action-buttons > .text-muted').click(); - cy.get('.fieldname-select-area > .awesomplete > .form-control').type('Fol{enter}'); - cy.get('.filter-field > .form-group > .link-field > .awesomplete > .input-with-feedback').type('Home{enter}'); - cy.get('.filter-action-buttons > div > .btn-primary').click(); - - //Adding folder (Test Folder) - cy.get('.menu-btn-group > .btn').click(); - cy.get('.menu-btn-group > .dropdown-menu > .user-action > .grey-link').eq(2).click(); - cy.get('form > .frappe-control > .form-group > .control-input-wrapper > .control-input > .input-with-feedback').type('Test Folder'); - cy.get('.modal-footer > .standard-actions > .btn-primary').click(); - }); + it('Adding Folders', () => { + //Adding filter to go into the home folder + cy.get('.filter-selector > .btn').click(); + cy.get('.filter-action-buttons > div > .btn-secondary').contains('Clear Filters').click(); + cy.get('.filter-action-buttons > .text-muted').click(); + cy.get('.fieldname-select-area > .awesomplete > .form-control').type('Fol{enter}'); + cy.get('.filter-field > .form-group > .link-field > .awesomplete > .input-with-feedback').type('Home{enter}'); + cy.get('.filter-action-buttons > div > .btn-primary').click(); - it('Navigating the nested folders, checking if the URL formed is correct, checking if the added content in the child folder is correct', () => { - //Navigating inside the Attachments folder - cy.get('[title="Attachments"] > span').click(); + //Adding folder (Test Folder) + cy.get('.menu-btn-group > .btn').click(); + cy.get('.menu-btn-group > .dropdown-menu > .user-action > .grey-link').eq(2).click(); + cy.get('form > .frappe-control > .form-group > .control-input-wrapper > .control-input > .input-with-feedback').type('Test Folder'); + cy.get('.modal-footer > .standard-actions > .btn-primary').click(); + }); - //To check if the URL formed after visiting the attachments folder is correct - cy.location('pathname').should('eq', '/app/file/view/home/Attachments'); - cy.visit('/app/file/view/home/Attachments'); + it('Navigating the nested folders, checking if the URL formed is correct, checking if the added content in the child folder is correct', () => { + //Navigating inside the Attachments folder + cy.get('[title="Attachments"] > span').click(); - //Adding folder inside the attachments folder - cy.get('.menu-btn-group > .btn').click(); - cy.get('.menu-btn-group > .dropdown-menu > .user-action > .grey-link').eq(2).click(); - cy.get('form > .frappe-control > .form-group > .control-input-wrapper > .control-input > .input-with-feedback').type('Test Folder'); - cy.get('.modal-footer > .standard-actions > .btn-primary').click(); + //To check if the URL formed after visiting the attachments folder is correct + cy.location('pathname').should('eq', '/app/file/view/home/Attachments'); + cy.visit('/app/file/view/home/Attachments'); - //Navigating inside the added folder in the Attachments folder - cy.get('[title="Test Folder"] > span').click(); + //Adding folder inside the attachments folder + cy.get('.menu-btn-group > .btn').click(); + cy.get('.menu-btn-group > .dropdown-menu > .user-action > .grey-link').eq(2).click(); + cy.get('form > .frappe-control > .form-group > .control-input-wrapper > .control-input > .input-with-feedback').type('Test Folder'); + cy.get('.modal-footer > .standard-actions > .btn-primary').click(); - //To check if the URL is correct after visiting the Test Folder - cy.location('pathname').should('eq', '/app/file/view/home/Attachments/Test%20Folder'); - cy.visit('/app/file/view/home/Attachments/Test%20Folder'); + //Navigating inside the added folder in the Attachments folder + cy.get('[title="Test Folder"] > span').click(); - //Adding a file inside the Test Folder - cy.get('.primary-action').contains('Add File').eq(0).click({force : true}); - 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(); + //To check if the URL is correct after visiting the Test Folder + cy.location('pathname').should('eq', '/app/file/view/home/Attachments/Test%20Folder'); + cy.visit('/app/file/view/home/Attachments/Test%20Folder'); - //To check if the added file is present in the Test Folder - cy.get('span.level-item > span').should('contain','Test Folder'); - cy.get('.list-row-container').eq(0).should('contain.text','72402.jpg'); - cy.get('.list-row-checkbox').eq(0).click(); + //Adding a file inside the Test Folder + cy.get('.primary-action').contains('Add File').eq(0).click({force: true}); + 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(); - //Deleting the added file from the Test folder - cy.get('.actions-btn-group > .btn').click(); - cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(5).click(); - cy.wait(700); - cy.click_modal_primary_button('Yes', {force : true, delay: 700}); - cy.wait(700); + //To check if the added file is present in the Test Folder + cy.get('span.level-item > span').should('contain', 'Test Folder'); + cy.get('.list-row-container').eq(0).should('contain.text', '72402.jpg'); + cy.get('.list-row-checkbox').eq(0).click(); - //Deleting the Test Folder - cy.visit('/app/file/view/home/Attachments'); - cy.get('.list-row-checkbox').eq(0).click(); - cy.get('.actions-btn-group > .btn').click(); - cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(5).click(); - cy.click_modal_primary_button('Yes'); - }); + //Deleting the added file from the Test folder + cy.get('.actions-btn-group > .btn').click(); + cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(5).click(); + cy.wait(700); + cy.click_modal_primary_button('Yes', {force: true, delay: 700}); + cy.wait(700); - it('Deleting Test Folder from the home', () => { - //Deleting the Test Folder added in the home directory - cy.visit('/app/file/view/home'); - cy.get('.list-row-container > .list-row > .level-left > .list-subject > .list-row-checkbox').eq(0).click({force : true, delay : 500}); - cy.get('.actions-btn-group > .btn').click(); - cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(5).click(); - cy.click_modal_primary_button('Yes', {force : true}); - }); + //Deleting the Test Folder + cy.visit('/app/file/view/home/Attachments'); + cy.get('.list-row-checkbox').eq(0).click(); + cy.get('.actions-btn-group > .btn').click(); + cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(5).click(); + cy.click_modal_primary_button('Yes'); + }); + + it('Deleting Test Folder from the home', () => { + //Deleting the Test Folder added in the home directory + cy.visit('/app/file/view/home'); + cy.get('.list-row-container > .list-row > .level-left > .list-subject > .list-row-checkbox').eq(0).click({force: true, delay: 500}); + cy.get('.actions-btn-group > .btn').click(); + cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(5).click(); + cy.click_modal_primary_button('Yes', {force: true}); + }); }); \ No newline at end of file From 9fce3c60943ea003f719ca5a809b399cb13efb96 Mon Sep 17 00:00:00 2001 From: Komal-Saraf0609 Date: Thu, 8 Jul 2021 18:57:34 +0530 Subject: [PATCH 108/244] test: Added test cases for Email testing --- cypress/integration/email_testing.js | 44 ++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 cypress/integration/email_testing.js diff --git a/cypress/integration/email_testing.js b/cypress/integration/email_testing.js new file mode 100644 index 0000000000..15cc895600 --- /dev/null +++ b/cypress/integration/email_testing.js @@ -0,0 +1,44 @@ +context('Email Feature Test', () => { + before(() => { + cy.visit('/login'); + cy.login(); + cy.visit('/app/todo'); + }); + + it('Saving and Deleting Email Template', () => { + cy.get('.level-item.ellipsis > .ellipsis').click(); + 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'); + + 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(); + cy.get('.modal-footer > .standard-actions > .btn-primary').contains('Send').click({delay:500}); + cy.get('[data-doctype="Communication"] > .timeline-content').should('contain','Test Mail'); + cy.get('.timeline-content').should('contain','Added 72402.jpg'); + cy.get('[title="Open Communication"] > .icon').click(); + 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 > :nth-child(11) > .grey-link').click(); + cy.get('.modal.show > .modal-dialog > .modal-content > .modal-footer > .standard-actions > .btn-primary').click(); + cy.visit('/app/todo'); + cy.get('.level-item.ellipsis > .ellipsis').click(); + cy.get('.attachment-row > .data-pill > .remove-btn > .icon').click(); + cy.get('.modal-footer > .standard-actions > .btn-primary').contains('Yes').click(); + cy.get('.timeline-content').should('contain','Removed 72402.jpg'); + }); + + it('Discarding Email Template', () => { + cy.visit('/app/todo'); + cy.get('.level-item.ellipsis > .ellipsis').click(); + 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.get('.timeline-actions > .btn').click(); + cy.get_field('recipients','MultiSelect').should('have.value',''); + cy.get('.modal.show > .modal-dialog > .modal-content > .modal-header > .modal-actions > .btn-modal-close > .icon').click(); + }); + + +}); From f047a5517daea95a1408061fe9d47786aec94d74 Mon Sep 17 00:00:00 2001 From: Komal-Saraf0609 Date: Thu, 19 Aug 2021 20:24:03 +0530 Subject: [PATCH 109/244] test: corrected the selectors and shorten them (using the newly added API's) --- cypress/integration/folder_navigation.js | 134 +++++++++++------------ 1 file changed, 67 insertions(+), 67 deletions(-) diff --git a/cypress/integration/folder_navigation.js b/cypress/integration/folder_navigation.js index d4fe569828..1ff38005ad 100644 --- a/cypress/integration/folder_navigation.js +++ b/cypress/integration/folder_navigation.js @@ -1,79 +1,79 @@ context('Folder Navigation', () => { - before(() => { - cy.visit('/login'); - cy.login(); - cy.visit('/app/file'); - }); - - it('Adding Folders', () => { - //Adding filter to go into the home folder - cy.get('.filter-selector > .btn').click(); - cy.get('.filter-action-buttons > div > .btn-secondary').contains('Clear Filters').click(); - cy.get('.filter-action-buttons > .text-muted').click(); - cy.get('.fieldname-select-area > .awesomplete > .form-control').type('Fol{enter}'); - cy.get('.filter-field > .form-group > .link-field > .awesomplete > .input-with-feedback').type('Home{enter}'); - cy.get('.filter-action-buttons > div > .btn-primary').click(); + before(() => { + cy.visit('/login'); + cy.login(); + cy.visit('/app/file'); + }); - //Adding folder (Test Folder) - cy.get('.menu-btn-group > .btn').click(); - cy.get('.menu-btn-group > .dropdown-menu > .user-action > .grey-link').eq(2).click(); - cy.get('form > .frappe-control > .form-group > .control-input-wrapper > .control-input > .input-with-feedback').type('Test Folder'); - cy.get('.modal-footer > .standard-actions > .btn-primary').click(); - }); + it('Adding Folders', () => { + //Adding filter to go into the home folder + cy.get('.filter-selector > .btn').click(); + cy.findByRole('button', {name: 'Clear Filters'}).click(); + cy.get('.filter-action-buttons > .text-muted').click(); + cy.get('.fieldname-select-area > .awesomplete > .form-control').type('Fol{enter}'); + cy.get('.filter-field > .form-group > .link-field > .awesomplete > .input-with-feedback').type('Home{enter}'); + cy.get('.filter-action-buttons > div > .btn-primary').click(); - it('Navigating the nested folders, checking if the URL formed is correct, checking if the added content in the child folder is correct', () => { - //Navigating inside the Attachments folder - cy.get('[title="Attachments"] > span').click(); + //Adding folder (Test Folder) + cy.get('.menu-btn-group > .btn').click(); + cy.get('.menu-btn-group [data-label="New Folder"]').click(); + cy.get('form > [data-fieldname="value"]').type('Test Folder'); + cy.findByRole('button', {name: 'Create'}).click(); + }); - //To check if the URL formed after visiting the attachments folder is correct - cy.location('pathname').should('eq', '/app/file/view/home/Attachments'); - cy.visit('/app/file/view/home/Attachments'); + it('Navigating the nested folders, checking if the URL formed is correct, checking if the added content in the child folder is correct', () => { + //Navigating inside the Attachments folder + cy.get('[title="Attachments"] > span').click(); - //Adding folder inside the attachments folder - cy.get('.menu-btn-group > .btn').click(); - cy.get('.menu-btn-group > .dropdown-menu > .user-action > .grey-link').eq(2).click(); - cy.get('form > .frappe-control > .form-group > .control-input-wrapper > .control-input > .input-with-feedback').type('Test Folder'); - cy.get('.modal-footer > .standard-actions > .btn-primary').click(); + //To check if the URL formed after visiting the attachments folder is correct + cy.location('pathname').should('eq', '/app/file/view/home/Attachments'); + cy.visit('/app/file/view/home/Attachments'); - //Navigating inside the added folder in the Attachments folder - cy.get('[title="Test Folder"] > span').click(); + //Adding folder inside the attachments folder + cy.get('.menu-btn-group > .btn').click(); + cy.get('.menu-btn-group [data-label="New Folder"]').click(); + cy.get('form > [data-fieldname="value"]').type('Test Folder'); + cy.findByRole('button', {name: 'Create'}).click(); - //To check if the URL is correct after visiting the Test Folder - cy.location('pathname').should('eq', '/app/file/view/home/Attachments/Test%20Folder'); - cy.visit('/app/file/view/home/Attachments/Test%20Folder'); + //Navigating inside the added folder in the Attachments folder + cy.get('[title="Test Folder"] > span').click(); - //Adding a file inside the Test Folder - cy.get('.primary-action').contains('Add File').eq(0).click({force: true}); - 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(); + //To check if the URL is correct after visiting the Test Folder + cy.location('pathname').should('eq', '/app/file/view/home/Attachments/Test%20Folder'); + cy.visit('/app/file/view/home/Attachments/Test%20Folder'); - //To check if the added file is present in the Test Folder - cy.get('span.level-item > span').should('contain', 'Test Folder'); - cy.get('.list-row-container').eq(0).should('contain.text', '72402.jpg'); - cy.get('.list-row-checkbox').eq(0).click(); + //Adding a file inside the Test Folder + cy.findByRole('button', {name: 'Add File'}).eq(0).click({force: true}); + 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.findByRole('button', {name: 'Upload'}).click(); - //Deleting the added file from the Test folder - cy.get('.actions-btn-group > .btn').click(); - cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(5).click(); - cy.wait(700); - cy.click_modal_primary_button('Yes', {force: true, delay: 700}); - cy.wait(700); + //To check if the added file is present in the Test Folder + cy.get('span.level-item > span').should('contain', 'Test Folder'); + cy.get('.list-row-container').eq(0).should('contain.text', '72402.jpg'); + cy.get('.list-row-checkbox').eq(0).click(); - //Deleting the Test Folder - cy.visit('/app/file/view/home/Attachments'); - cy.get('.list-row-checkbox').eq(0).click(); - cy.get('.actions-btn-group > .btn').click(); - cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(5).click(); - cy.click_modal_primary_button('Yes'); - }); + //Deleting the added file from the Test folder + cy.findByRole('button', {name: 'Actions'}).click(); + cy.get('.actions-btn-group [data-label="Delete"]').click(); + cy.wait(700); + cy.findByRole('button', {name: 'Yes'}).click(); + cy.wait(700); - it('Deleting Test Folder from the home', () => { - //Deleting the Test Folder added in the home directory - cy.visit('/app/file/view/home'); - cy.get('.list-row-container > .list-row > .level-left > .list-subject > .list-row-checkbox').eq(0).click({force: true, delay: 500}); - cy.get('.actions-btn-group > .btn').click(); - cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(5).click(); - cy.click_modal_primary_button('Yes', {force: true}); - }); -}); \ No newline at end of file + //Deleting the Test Folder + cy.visit('/app/file/view/home/Attachments'); + cy.get('.list-row-checkbox').eq(0).click(); + cy.findByRole('button', {name: 'Actions'}).click(); + cy.get('.actions-btn-group [data-label="Delete"]').click(); + cy.findByRole('button', {name: 'Yes'}).click(); + }); + + it('Deleting Test Folder from the home', () => { + //Deleting the Test Folder added in the home directory + cy.visit('/app/file/view/home'); + cy.get('.level-left > .list-subject > .list-row-checkbox').eq(0).click({force: true, delay: 500}); + cy.findByRole('button', {name: 'Actions'}).click(); + cy.get('.actions-btn-group [data-label="Delete"]').click(); + cy.findByRole('button', {name: 'Yes'}).click(); + }); +}); From 380e9e7c080a616a34090992e910632a4daa0ff6 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 19 Aug 2021 20:30:13 +0530 Subject: [PATCH 110/244] fix(test): Add missing comma --- frappe/tests/test_frappe_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/tests/test_frappe_client.py b/frappe/tests/test_frappe_client.py index 556828f843..d192952e92 100644 --- a/frappe/tests/test_frappe_client.py +++ b/frappe/tests/test_frappe_client.py @@ -82,7 +82,7 @@ class TestFrappeClient(unittest.TestCase): def test_update_doc(self): server = FrappeClient(get_url(), "Administrator", "admin", verify=False) - frappe.db.delete("Note", {"title": ("in" ("Sing", "sing"))}) + frappe.db.delete("Note", {"title": ("in", ("Sing", "sing"))}) frappe.db.commit() server.insert({"doctype":"Note", "public": True, "title": "Sing"}) From 8a6b8a5348d48444d8acc278bcfe83c844dbf246 Mon Sep 17 00:00:00 2001 From: Komal-Saraf0609 Date: Thu, 19 Aug 2021 20:41:31 +0530 Subject: [PATCH 111/244] test: corrected selectors and shorten them (using the newly added API's) --- cypress/integration/folder_navigation.js | 81 ------------------------ 1 file changed, 81 deletions(-) diff --git a/cypress/integration/folder_navigation.js b/cypress/integration/folder_navigation.js index 987bddce7c..1ff38005ad 100644 --- a/cypress/integration/folder_navigation.js +++ b/cypress/integration/folder_navigation.js @@ -1,5 +1,4 @@ context('Folder Navigation', () => { -<<<<<<< HEAD before(() => { cy.visit('/login'); cy.login(); @@ -78,83 +77,3 @@ context('Folder Navigation', () => { cy.findByRole('button', {name: 'Yes'}).click(); }); }); -======= - before(() => { - cy.visit('/login'); - cy.login(); - cy.visit('/app/file'); - }); - - it('Adding Folders', () => { - //Adding filter to go into the home folder - cy.get('.filter-selector > .btn').click(); - cy.get('.filter-action-buttons > div > .btn-secondary').contains('Clear Filters').click(); - cy.get('.filter-action-buttons > .text-muted').click(); - cy.get('.fieldname-select-area > .awesomplete > .form-control').type('Fol{enter}'); - cy.get('.filter-field > .form-group > .link-field > .awesomplete > .input-with-feedback').type('Home{enter}'); - cy.get('.filter-action-buttons > div > .btn-primary').click(); - - //Adding folder (Test Folder) - cy.get('.menu-btn-group > .btn').click(); - cy.get('.menu-btn-group > .dropdown-menu > .user-action > .grey-link').eq(2).click(); - cy.get('form > .frappe-control > .form-group > .control-input-wrapper > .control-input > .input-with-feedback').type('Test Folder'); - cy.get('.modal-footer > .standard-actions > .btn-primary').click(); - }); - - it('Navigating the nested folders, checking if the URL formed is correct, checking if the added content in the child folder is correct', () => { - //Navigating inside the Attachments folder - cy.get('[title="Attachments"] > span').click(); - - //To check if the URL formed after visiting the attachments folder is correct - cy.location('pathname').should('eq', '/app/file/view/home/Attachments'); - cy.visit('/app/file/view/home/Attachments'); - - //Adding folder inside the attachments folder - cy.get('.menu-btn-group > .btn').click(); - cy.get('.menu-btn-group > .dropdown-menu > .user-action > .grey-link').eq(2).click(); - cy.get('form > .frappe-control > .form-group > .control-input-wrapper > .control-input > .input-with-feedback').type('Test Folder'); - cy.get('.modal-footer > .standard-actions > .btn-primary').click(); - - //Navigating inside the added folder in the Attachments folder - cy.get('[title="Test Folder"] > span').click(); - - //To check if the URL is correct after visiting the Test Folder - cy.location('pathname').should('eq', '/app/file/view/home/Attachments/Test%20Folder'); - cy.visit('/app/file/view/home/Attachments/Test%20Folder'); - - //Adding a file inside the Test Folder - cy.get('.primary-action').contains('Add File').eq(0).click({force: true}); - 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(); - - //To check if the added file is present in the Test Folder - cy.get('span.level-item > span').should('contain', 'Test Folder'); - cy.get('.list-row-container').eq(0).should('contain.text', '72402.jpg'); - cy.get('.list-row-checkbox').eq(0).click(); - - //Deleting the added file from the Test folder - cy.get('.actions-btn-group > .btn').click(); - cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(5).click(); - cy.wait(700); - cy.click_modal_primary_button('Yes', {force: true, delay: 700}); - cy.wait(700); - - //Deleting the Test Folder - cy.visit('/app/file/view/home/Attachments'); - cy.get('.list-row-checkbox').eq(0).click(); - cy.get('.actions-btn-group > .btn').click(); - cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(5).click(); - cy.click_modal_primary_button('Yes'); - }); - - it('Deleting Test Folder from the home', () => { - //Deleting the Test Folder added in the home directory - cy.visit('/app/file/view/home'); - cy.get('.list-row-container > .list-row > .level-left > .list-subject > .list-row-checkbox').eq(0).click({force: true, delay: 500}); - cy.get('.actions-btn-group > .btn').click(); - cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(5).click(); - cy.click_modal_primary_button('Yes', {force: true}); - }); -}); ->>>>>>> 166574b1d065ac0c0461a671d7fe837a9d35b7ce From b8f28fd28b94bc97011b1761b96059978728a53f Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 19 Aug 2021 20:47:27 +0530 Subject: [PATCH 112/244] test(todo): Use doc.meta instead of doctype form --- frappe/desk/doctype/todo/test_todo.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frappe/desk/doctype/todo/test_todo.py b/frappe/desk/doctype/todo/test_todo.py index 7e0fa115c9..f6371c5921 100644 --- a/frappe/desk/doctype/todo/test_todo.py +++ b/frappe/desk/doctype/todo/test_todo.py @@ -122,9 +122,8 @@ class TestToDo(unittest.TestCase): 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.meta.get('fields', dict(fieldname='assigned_by_full_name'))[0].fetch_if_empty = 0 + todo.meta.save() todo.reload() todo.save() From 533b0147b28b069bd3d4ed4c41e4d20528b6a572 Mon Sep 17 00:00:00 2001 From: Komal-Saraf0609 Date: Thu, 19 Aug 2021 20:58:12 +0530 Subject: [PATCH 113/244] test: Fixed sider issues --- cypress/integration/folder_navigation.js | 130 +++++++++++------------ 1 file changed, 65 insertions(+), 65 deletions(-) diff --git a/cypress/integration/folder_navigation.js b/cypress/integration/folder_navigation.js index 1ff38005ad..09df8d2d20 100644 --- a/cypress/integration/folder_navigation.js +++ b/cypress/integration/folder_navigation.js @@ -1,79 +1,79 @@ context('Folder Navigation', () => { - before(() => { - cy.visit('/login'); - cy.login(); - cy.visit('/app/file'); - }); + before(() => { + cy.visit('/login'); + cy.login(); + cy.visit('/app/file'); + }); - it('Adding Folders', () => { - //Adding filter to go into the home folder - cy.get('.filter-selector > .btn').click(); - cy.findByRole('button', {name: 'Clear Filters'}).click(); - cy.get('.filter-action-buttons > .text-muted').click(); - cy.get('.fieldname-select-area > .awesomplete > .form-control').type('Fol{enter}'); - cy.get('.filter-field > .form-group > .link-field > .awesomplete > .input-with-feedback').type('Home{enter}'); - cy.get('.filter-action-buttons > div > .btn-primary').click(); + it('Adding Folders', () => { + //Adding filter to go into the home folder + cy.get('.filter-selector > .btn').click(); + cy.findByRole('button', {name: 'Clear Filters'}).click(); + cy.get('.filter-action-buttons > .text-muted').click(); + cy.get('.fieldname-select-area > .awesomplete > .form-control').type('Fol{enter}'); + cy.get('.filter-field > .form-group > .link-field > .awesomplete > .input-with-feedback').type('Home{enter}'); + cy.get('.filter-action-buttons > div > .btn-primary').click(); - //Adding folder (Test Folder) - cy.get('.menu-btn-group > .btn').click(); - cy.get('.menu-btn-group [data-label="New Folder"]').click(); - cy.get('form > [data-fieldname="value"]').type('Test Folder'); - cy.findByRole('button', {name: 'Create'}).click(); - }); + //Adding folder (Test Folder) + cy.get('.menu-btn-group > .btn').click(); + cy.get('.menu-btn-group [data-label="New Folder"]').click(); + cy.get('form > [data-fieldname="value"]').type('Test Folder'); + cy.findByRole('button', {name: 'Create'}).click(); + }); - it('Navigating the nested folders, checking if the URL formed is correct, checking if the added content in the child folder is correct', () => { - //Navigating inside the Attachments folder - cy.get('[title="Attachments"] > span').click(); + it('Navigating the nested folders, checking if the URL formed is correct, checking if the added content in the child folder is correct', () => { + //Navigating inside the Attachments folder + cy.get('[title="Attachments"] > span').click(); - //To check if the URL formed after visiting the attachments folder is correct - cy.location('pathname').should('eq', '/app/file/view/home/Attachments'); - cy.visit('/app/file/view/home/Attachments'); + //To check if the URL formed after visiting the attachments folder is correct + cy.location('pathname').should('eq', '/app/file/view/home/Attachments'); + cy.visit('/app/file/view/home/Attachments'); - //Adding folder inside the attachments folder - cy.get('.menu-btn-group > .btn').click(); - cy.get('.menu-btn-group [data-label="New Folder"]').click(); - cy.get('form > [data-fieldname="value"]').type('Test Folder'); - cy.findByRole('button', {name: 'Create'}).click(); + //Adding folder inside the attachments folder + cy.get('.menu-btn-group > .btn').click(); + cy.get('.menu-btn-group [data-label="New Folder"]').click(); + cy.get('form > [data-fieldname="value"]').type('Test Folder'); + cy.findByRole('button', {name: 'Create'}).click(); - //Navigating inside the added folder in the Attachments folder - cy.get('[title="Test Folder"] > span').click(); + //Navigating inside the added folder in the Attachments folder + cy.get('[title="Test Folder"] > span').click(); - //To check if the URL is correct after visiting the Test Folder - cy.location('pathname').should('eq', '/app/file/view/home/Attachments/Test%20Folder'); - cy.visit('/app/file/view/home/Attachments/Test%20Folder'); + //To check if the URL is correct after visiting the Test Folder + cy.location('pathname').should('eq', '/app/file/view/home/Attachments/Test%20Folder'); + cy.visit('/app/file/view/home/Attachments/Test%20Folder'); - //Adding a file inside the Test Folder - cy.findByRole('button', {name: 'Add File'}).eq(0).click({force: true}); - 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.findByRole('button', {name: 'Upload'}).click(); + //Adding a file inside the Test Folder + cy.findByRole('button', {name: 'Add File'}).eq(0).click({force: true}); + 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.findByRole('button', {name: 'Upload'}).click(); - //To check if the added file is present in the Test Folder - cy.get('span.level-item > span').should('contain', 'Test Folder'); - cy.get('.list-row-container').eq(0).should('contain.text', '72402.jpg'); - cy.get('.list-row-checkbox').eq(0).click(); + //To check if the added file is present in the Test Folder + cy.get('span.level-item > span').should('contain', 'Test Folder'); + cy.get('.list-row-container').eq(0).should('contain.text', '72402.jpg'); + cy.get('.list-row-checkbox').eq(0).click(); - //Deleting the added file from the Test folder - cy.findByRole('button', {name: 'Actions'}).click(); - cy.get('.actions-btn-group [data-label="Delete"]').click(); - cy.wait(700); - cy.findByRole('button', {name: 'Yes'}).click(); - cy.wait(700); + //Deleting the added file from the Test folder + cy.findByRole('button', {name: 'Actions'}).click(); + cy.get('.actions-btn-group [data-label="Delete"]').click(); + cy.wait(700); + cy.findByRole('button', {name: 'Yes'}).click(); + cy.wait(700); - //Deleting the Test Folder - cy.visit('/app/file/view/home/Attachments'); - cy.get('.list-row-checkbox').eq(0).click(); - cy.findByRole('button', {name: 'Actions'}).click(); - cy.get('.actions-btn-group [data-label="Delete"]').click(); - cy.findByRole('button', {name: 'Yes'}).click(); - }); + //Deleting the Test Folder + cy.visit('/app/file/view/home/Attachments'); + cy.get('.list-row-checkbox').eq(0).click(); + cy.findByRole('button', {name: 'Actions'}).click(); + cy.get('.actions-btn-group [data-label="Delete"]').click(); + cy.findByRole('button', {name: 'Yes'}).click(); + }); - it('Deleting Test Folder from the home', () => { - //Deleting the Test Folder added in the home directory - cy.visit('/app/file/view/home'); - cy.get('.level-left > .list-subject > .list-row-checkbox').eq(0).click({force: true, delay: 500}); - cy.findByRole('button', {name: 'Actions'}).click(); - cy.get('.actions-btn-group [data-label="Delete"]').click(); - cy.findByRole('button', {name: 'Yes'}).click(); - }); + it('Deleting Test Folder from the home', () => { + //Deleting the Test Folder added in the home directory + cy.visit('/app/file/view/home'); + cy.get('.level-left > .list-subject > .list-row-checkbox').eq(0).click({force: true, delay: 500}); + cy.findByRole('button', {name: 'Actions'}).click(); + cy.get('.actions-btn-group [data-label="Delete"]').click(); + cy.findByRole('button', {name: 'Yes'}).click(); + }); }); From a8846d52df900ae71539cc0b06f83ec0aad96833 Mon Sep 17 00:00:00 2001 From: Komal-Saraf0609 Date: Thu, 19 Aug 2021 21:04:02 +0530 Subject: [PATCH 114/244] test: Fixed sider issues --- cypress/integration/email_testing.js | 66 ++++++++++++++-------------- 1 file changed, 32 insertions(+), 34 deletions(-) diff --git a/cypress/integration/email_testing.js b/cypress/integration/email_testing.js index 15cc895600..cdc7a90af5 100644 --- a/cypress/integration/email_testing.js +++ b/cypress/integration/email_testing.js @@ -5,40 +5,38 @@ context('Email Feature Test', () => { cy.visit('/app/todo'); }); - it('Saving and Deleting Email Template', () => { - cy.get('.level-item.ellipsis > .ellipsis').click(); - 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'); - - 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(); - cy.get('.modal-footer > .standard-actions > .btn-primary').contains('Send').click({delay:500}); - cy.get('[data-doctype="Communication"] > .timeline-content').should('contain','Test Mail'); - cy.get('.timeline-content').should('contain','Added 72402.jpg'); - cy.get('[title="Open Communication"] > .icon').click(); - 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 > :nth-child(11) > .grey-link').click(); - cy.get('.modal.show > .modal-dialog > .modal-content > .modal-footer > .standard-actions > .btn-primary').click(); - cy.visit('/app/todo'); - cy.get('.level-item.ellipsis > .ellipsis').click(); - cy.get('.attachment-row > .data-pill > .remove-btn > .icon').click(); - cy.get('.modal-footer > .standard-actions > .btn-primary').contains('Yes').click(); - cy.get('.timeline-content').should('contain','Removed 72402.jpg'); - }); + it('Saving and Deleting Email Template', () => { + cy.get('.level-item.ellipsis > .ellipsis').click(); + 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'); - it('Discarding Email Template', () => { - cy.visit('/app/todo'); - cy.get('.level-item.ellipsis > .ellipsis').click(); - 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.get('.timeline-actions > .btn').click(); - cy.get_field('recipients','MultiSelect').should('have.value',''); - cy.get('.modal.show > .modal-dialog > .modal-content > .modal-header > .modal-actions > .btn-modal-close > .icon').click(); - }); + 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(); + cy.get('.modal-footer > .standard-actions > .btn-primary').contains('Send').click({delay:500}); + cy.get('[data-doctype="Communication"] > .timeline-content').should('contain','Test Mail'); + cy.get('.timeline-content').should('contain','Added 72402.jpg'); + cy.get('[title="Open Communication"] > .icon').click(); + 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 > :nth-child(11) > .grey-link').click(); + cy.get('.modal.show > .modal-dialog > .modal-content > .modal-footer > .standard-actions > .btn-primary').click(); + cy.visit('/app/todo'); + cy.get('.level-item.ellipsis > .ellipsis').click(); + cy.get('.attachment-row > .data-pill > .remove-btn > .icon').click(); + cy.get('.modal-footer > .standard-actions > .btn-primary').contains('Yes').click(); + cy.get('.timeline-content').should('contain','Removed 72402.jpg'); + }); - + it('Discarding Email Template', () => { + cy.visit('/app/todo'); + cy.get('.level-item.ellipsis > .ellipsis').click(); + 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.get('.timeline-actions > .btn').click(); + cy.get_field('recipients','MultiSelect').should('have.value',''); + cy.get('.modal.show > .modal-dialog > .modal-content > .modal-header > .modal-actions > .btn-modal-close > .icon').click(); + }); }); From f53622d21ecd69a1565354fd6b540f5d4b2db84c Mon Sep 17 00:00:00 2001 From: Komal-Saraf0609 Date: Thu, 19 Aug 2021 21:10:08 +0530 Subject: [PATCH 115/244] test: Deleted the file email_testing.js --- cypress/integration/email_testing.js | 42 ---------------------------- 1 file changed, 42 deletions(-) delete mode 100644 cypress/integration/email_testing.js diff --git a/cypress/integration/email_testing.js b/cypress/integration/email_testing.js deleted file mode 100644 index cdc7a90af5..0000000000 --- a/cypress/integration/email_testing.js +++ /dev/null @@ -1,42 +0,0 @@ -context('Email Feature Test', () => { - before(() => { - cy.visit('/login'); - cy.login(); - cy.visit('/app/todo'); - }); - - it('Saving and Deleting Email Template', () => { - cy.get('.level-item.ellipsis > .ellipsis').click(); - 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'); - - 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(); - cy.get('.modal-footer > .standard-actions > .btn-primary').contains('Send').click({delay:500}); - cy.get('[data-doctype="Communication"] > .timeline-content').should('contain','Test Mail'); - cy.get('.timeline-content').should('contain','Added 72402.jpg'); - cy.get('[title="Open Communication"] > .icon').click(); - 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 > :nth-child(11) > .grey-link').click(); - cy.get('.modal.show > .modal-dialog > .modal-content > .modal-footer > .standard-actions > .btn-primary').click(); - cy.visit('/app/todo'); - cy.get('.level-item.ellipsis > .ellipsis').click(); - cy.get('.attachment-row > .data-pill > .remove-btn > .icon').click(); - cy.get('.modal-footer > .standard-actions > .btn-primary').contains('Yes').click(); - cy.get('.timeline-content').should('contain','Removed 72402.jpg'); - }); - - it('Discarding Email Template', () => { - cy.visit('/app/todo'); - cy.get('.level-item.ellipsis > .ellipsis').click(); - 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.get('.timeline-actions > .btn').click(); - cy.get_field('recipients','MultiSelect').should('have.value',''); - cy.get('.modal.show > .modal-dialog > .modal-content > .modal-header > .modal-actions > .btn-modal-close > .icon').click(); - }); -}); From 36f698f0f8d7040966d31f12f4b9ca4f4c7976a7 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Thu, 19 Aug 2021 21:38:57 +0530 Subject: [PATCH 116/244] fix: Unzip functionality --- frappe/core/doctype/file/file.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index 8d0eee8c69..a31119961d 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -319,12 +319,10 @@ class File(Document): def unzip(self): '''Unzip current file and replace it by its children''' - if not ".zip" in self.file_name: - frappe.msgprint(_("Not a zip file")) - return + if not ".zip" in self.file_url: + frappe.throw(_("{0} is not a zip file").format(self.file_name)) - zip_path = frappe.get_site_path(self.file_url.strip('/')) - base_url = os.path.dirname(self.file_url) + zip_path = self.get_full_path() files = [] with zipfile.ZipFile(zip_path) as z: @@ -837,7 +835,7 @@ def unzip_file(name): '''Unzip the given file and make file records for each of the extracted files''' file_obj = frappe.get_doc('File', name) files = file_obj.unzip() - return len(files) + return files @frappe.whitelist() def optimize_saved_image(doc_name): From 3acab8a7c7da04184ad5828a042250356cdfe8e3 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Thu, 19 Aug 2021 21:40:12 +0530 Subject: [PATCH 117/244] test: Add test cases for unzip functionality --- frappe/core/doctype/file/test_file.py | 23 +++++++++++++++++++++++ frappe/www/_test/assets/file.zip | Bin 0 -> 156164 bytes 2 files changed, 23 insertions(+) create mode 100644 frappe/www/_test/assets/file.zip diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index df0ba8c2ec..f17c52150a 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -418,6 +418,29 @@ class TestFile(unittest.TestCase): self.assertEqual(json.loads(frappe.message_log[0]), {"message": f"File '{frappe.utils.get_url('unknown.jpg')}' not found"}) self.assertEquals(test_file.thumbnail_url, None) + def test_file_unzip(self): + file_path = frappe.get_app_path('frappe', 'www/_test/assets/file.zip') + public_file_path = frappe.get_site_path('public', 'files') + try: + import shutil + shutil.copy(file_path, public_file_path) + except Exception as e: + print(e) + + test_file = frappe.get_doc({ + "doctype": "File", + "file_url": '/files/file.zip', + }).insert(ignore_permissions=True) + + self.assertListEqual([file.file_name for file in unzip_file(test_file.name)], + ['css_asset.css', 'image.jpg', 'js_asset.min.js']) + + test_file = frappe.get_doc({ + "doctype": "File", + "file_url": frappe.utils.get_url('/_test/assets/image.jpg'), + }).insert(ignore_permissions=True) + self.assertRaisesRegex(frappe.exceptions.ValidationError, 'not a zip file', test_file.unzip) + class TestAttachment(unittest.TestCase): test_doctype = 'Test For Attachment' diff --git a/frappe/www/_test/assets/file.zip b/frappe/www/_test/assets/file.zip new file mode 100644 index 0000000000000000000000000000000000000000..06d70919d4b19cc415ed795b8a897a7fb9fba08b GIT binary patch literal 156164 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 W80cK0`U`N0baj%n60`ricK-)q-D;2k literal 0 HcmV?d00001 From ed41602526db31d7e12b33d6a26fdd3161fd7ba6 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Thu, 19 Aug 2021 21:43:43 +0530 Subject: [PATCH 118/244] test: Add more assersions and minor refactor --- frappe/core/doctype/file/file.py | 7 ++++--- frappe/core/doctype/file/test_file.py | 13 +++++++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index a31119961d..06222872a6 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -257,7 +257,7 @@ class File(Document): with open(get_files_path(file_name, is_private=self.is_private), "rb") as f: self.content_hash = get_content_hash(f.read()) except IOError: - frappe.msgprint(_("File {0} does not exist").format(self.file_url)) + frappe.throw(_("File {0} does not exist").format(self.file_url)) raise def on_trash(self): @@ -559,7 +559,8 @@ def create_new_folder(file_name, folder): file.file_name = file_name file.is_folder = 1 file.folder = folder - file.insert() + file.insert(ignore_if_duplicate=True) + return file @frappe.whitelist() def move_file(file_list, new_parent, old_parent): @@ -610,7 +611,7 @@ def get_local_image(file_url): try: image = Image.open(file_path) except IOError: - frappe.msgprint(_("Unable to read file format for {0}").format(file_url), raise_exception=True) + frappe.throw(_("Unable to read file format for {0}").format(file_url)) content = None diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index f17c52150a..e054aed950 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -7,7 +7,7 @@ import frappe import os import unittest from frappe import _ -from frappe.core.doctype.file.file import move_file, get_files_in_folder +from frappe.core.doctype.file.file import get_attached_images, move_file, get_files_in_folder, unzip_file from frappe.utils import get_files_path # test_records = frappe.get_test_records('File') @@ -397,7 +397,6 @@ class TestFile(unittest.TestCase): "doctype": "File", "file_name": 'logo', "file_url": frappe.utils.get_url('/_test/assets/image.jpg'), - "docstatus": 0 }).insert(ignore_permissions=True) test_file.make_thumbnail() @@ -407,7 +406,7 @@ class TestFile(unittest.TestCase): test_file.db_set('thumbnail_url', None) test_file.reload() test_file.file_url = "/files/image_small.jpg" - test_file.make_thumbnail(suffix="xs") + test_file.make_thumbnail(suffix="xs", crop=True) self.assertEquals(test_file.thumbnail_url, '/files/image_small_xs.jpg') frappe.clear_messages() @@ -556,6 +555,7 @@ class TestFileUtils(unittest.TestCase): }).insert() self.assertTrue(frappe.db.exists("File", {"attached_to_name": todo.name})) self.assertIn('', todo.description) + self.assertListEqual(get_attached_images('ToDo', [todo.name])[todo.name], ['/files/pix.png']) # without filename in data URI todo = frappe.get_doc({ @@ -563,4 +563,9 @@ class TestFileUtils(unittest.TestCase): "description": 'Test ' }).insert() filename = frappe.db.exists("File", {"attached_to_name": todo.name}) - self.assertIn(f' Date: Thu, 19 Aug 2021 22:04:23 +0530 Subject: [PATCH 119/244] test: Fix typo --- frappe/core/doctype/file/test_file.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index e054aed950..f9d06429ff 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -423,8 +423,8 @@ class TestFile(unittest.TestCase): try: import shutil shutil.copy(file_path, public_file_path) - except Exception as e: - print(e) + except Exception: + pass test_file = frappe.get_doc({ "doctype": "File", @@ -567,5 +567,11 @@ class TestFileUtils(unittest.TestCase): def test_create_new_folder(self): from frappe.core.doctype.file.file import create_new_folder - folder = create_new_folder('test_folder', 'home') + folder = create_new_folder('test_folder', 'Home') self.assertTrue(folder.is_folder) + +# def get_files_by_search_text(text): +# def optimize_saved_image(doc_name): +# def download_file(file_url): +# def remove_file_by_url(file_url, doctype=None, name=None): +# def on_doctype_update(): \ No newline at end of file From 228253289f8ee6e670c4868d97ce1bc25da818e6 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Fri, 20 Aug 2021 09:55:26 +0530 Subject: [PATCH 120/244] fix: Only select active numeric field after layout refresh --- frappe/public/js/frappe/form/layout.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/frappe/public/js/frappe/form/layout.js b/frappe/public/js/frappe/form/layout.js index 88e0463fa5..f171c4f32e 100644 --- a/frappe/public/js/frappe/form/layout.js +++ b/frappe/public/js/frappe/form/layout.js @@ -252,14 +252,18 @@ frappe.ui.form.Layout = class Layout { } if (document.activeElement) { - document.activeElement.focus(); - - if (document.activeElement.tagName == 'INPUT') { + if (document.activeElement.tagName == 'INPUT' && this.is_numeric_field_active()) { document.activeElement.select(); } } } + is_numeric_field_active() { + const control = $(document.activeElement).closest(".frappe-control") + const fieldtype = control.data().fieldtype; + return frappe.model.numeric_fieldtypes.includes(fieldtype) + } + refresh_sections() { // hide invisible sections this.wrapper.find(".form-section:not(.hide-control)").each(function() { From 32148cadc51d24771fe98871a7816d8f5e50b4de Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Fri, 20 Aug 2021 09:58:23 +0530 Subject: [PATCH 121/244] fix: Avoid input events for controls in grid --- frappe/public/js/frappe/form/controls/data.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/form/controls/data.js b/frappe/public/js/frappe/form/controls/data.js index e4a7dd6d59..864a0562ef 100644 --- a/frappe/public/js/frappe/form/controls/data.js +++ b/frappe/public/js/frappe/form/controls/data.js @@ -163,7 +163,7 @@ frappe.ui.form.ControlData = class ControlData extends frappe.ui.form.ControlInp } }; this.$input.on("change", change_handler); - if (this.constructor.trigger_change_on_input_event) { + if (this.constructor.trigger_change_on_input_event && !this.in_grid()) { // debounce to avoid repeated validations on value change this.$input.on("input", frappe.utils.debounce(change_handler, 500)); } @@ -267,4 +267,7 @@ frappe.ui.form.ControlData = class ControlData extends frappe.ui.form.ControlInp let el = this.$input.parents(el_class)[0]; if (el) $(el).toggleClass(scroll_class, add); } + in_grid() { + return this.grid || this.layout && this.layout.grid; + } }; From ff552d5d1d61fc83568bd213cc537c9bc5a623c2 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Fri, 20 Aug 2021 10:02:44 +0530 Subject: [PATCH 122/244] perf: Reduce debounce delay --- frappe/public/js/frappe/form/grid.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/form/grid.js b/frappe/public/js/frappe/form/grid.js index 8de1600c05..05c70b214d 100644 --- a/frappe/public/js/frappe/form/grid.js +++ b/frappe/public/js/frappe/form/grid.js @@ -38,7 +38,7 @@ export default class Grid { this.is_grid = true; this.debounced_refresh = this.refresh.bind(this); - this.debounced_refresh = frappe.utils.debounce(this.debounced_refresh, 500); + this.debounced_refresh = frappe.utils.debounce(this.debounced_refresh, 100); } allow_on_grid_editing() { From ebcb3618cf95e7c9a65c3d7dea40b8cd84b32f34 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Fri, 20 Aug 2021 10:23:49 +0530 Subject: [PATCH 123/244] style: Add missing semicolons --- frappe/public/js/frappe/form/layout.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/public/js/frappe/form/layout.js b/frappe/public/js/frappe/form/layout.js index f171c4f32e..21d7a451ac 100644 --- a/frappe/public/js/frappe/form/layout.js +++ b/frappe/public/js/frappe/form/layout.js @@ -259,9 +259,9 @@ frappe.ui.form.Layout = class Layout { } is_numeric_field_active() { - const control = $(document.activeElement).closest(".frappe-control") + const control = $(document.activeElement).closest(".frappe-control"); const fieldtype = control.data().fieldtype; - return frappe.model.numeric_fieldtypes.includes(fieldtype) + return frappe.model.numeric_fieldtypes.includes(fieldtype); } refresh_sections() { From 623998c787314dfd0eb70b41d2bb26a418de3624 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 20 Aug 2021 11:03:06 +0530 Subject: [PATCH 124/244] fix(db): Cast single dt field only if value is truthy --- frappe/database/database.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frappe/database/database.py b/frappe/database/database.py index d6ecf0795d..d4fc6c2219 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -557,7 +557,10 @@ class Database(object): if not df: frappe.throw(_('Invalid field name: {0}').format(frappe.bold(fieldname)), self.InvalidColumnName) - val = cast_fieldtype(df.fieldtype, val) + # cast only if value is "set" or is truthy? + # cast_fieldtype returns currnt TS value for Datetime, Date fields + if val: + val = cast_fieldtype(df.fieldtype, val) self.value_cache[doctype][fieldname] = val From 09868c26a9687f5b35339772be1db42d58951b06 Mon Sep 17 00:00:00 2001 From: Pruthvi Patel Date: Fri, 20 Aug 2021 11:41:41 +0530 Subject: [PATCH 125/244] feat: add `--autoreload` flag --- frappe/commands/utils.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index 765028dfed..35e566794f 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -486,8 +486,13 @@ frappe.db.connect() @click.command('console') +@click.option( + '--autoreload', + is_flag=True, + help="Reload changes to code automatically" +) @pass_context -def console(context): +def console(context, autoreload=False): "Start ipython console for a site" site = get_site(context) frappe.init(site=site) @@ -497,7 +502,7 @@ def console(context): from IPython.terminal.embed import InteractiveShellEmbed terminal = InteractiveShellEmbed() - if frappe.conf.developer_mode: + if autoreload: terminal.extension_manager.load_extension("autoreload") terminal.run_line_magic("autoreload", "2") From cb034e4c52e2213cdbe5f5a053e8a1bec169ebd9 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 20 Aug 2021 12:08:39 +0530 Subject: [PATCH 126/244] fix: Consistent return types in cast_fieldtype Note: BREAKING CHANGE --- frappe/database/database.py | 5 +---- frappe/utils/data.py | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/frappe/database/database.py b/frappe/database/database.py index d4fc6c2219..d6ecf0795d 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -557,10 +557,7 @@ class Database(object): if not df: frappe.throw(_('Invalid field name: {0}').format(frappe.bold(fieldname)), self.InvalidColumnName) - # cast only if value is "set" or is truthy? - # cast_fieldtype returns currnt TS value for Datetime, Date fields - if val: - val = cast_fieldtype(df.fieldtype, val) + val = cast_fieldtype(df.fieldtype, val) self.value_cache[doctype][fieldname] = val diff --git a/frappe/utils/data.py b/frappe/utils/data.py index f2c553211d..d89cda1519 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -505,7 +505,17 @@ def has_common(l1, l2): """Returns truthy value if there are common elements in lists l1 and l2""" return set(l1) & set(l2) -def cast_fieldtype(fieldtype, value): +def cast_fieldtype(fieldtype, value=None): + """Cast the value to the Python native object of the Frappe fieldtype provided. + If value is None, the first/lowest value of the `fieldtype` will be returned. + + Mapping of Python types => Frappe types: + * float => ("Currency", "Float", "Percent") + * int => ("Int", "Check") + * datetime.datetime => ("Datetime",) + * datetime.date => ("Date",) + * datetime.time => ("Time",) + """ if fieldtype in ("Currency", "Float", "Percent"): value = flt(value) @@ -517,12 +527,18 @@ def cast_fieldtype(fieldtype, value): value = cstr(value) elif fieldtype == "Date": + if value is None: + value = datetime.datetime(1, 1, 1).date() value = getdate(value) elif fieldtype == "Datetime": + if value is None: + value = datetime.datetime(1, 1, 1) value = get_datetime(value) elif fieldtype == "Time": + if value is None: + value = "0:0:0" value = to_timedelta(value) return value From 70f9f77df8caae76b80790954451407413336c64 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Fri, 20 Aug 2021 12:16:55 +0530 Subject: [PATCH 127/244] fix: Handle exception --- frappe/public/js/frappe/form/layout.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/form/layout.js b/frappe/public/js/frappe/form/layout.js index 21d7a451ac..8d52c8d592 100644 --- a/frappe/public/js/frappe/form/layout.js +++ b/frappe/public/js/frappe/form/layout.js @@ -260,7 +260,7 @@ frappe.ui.form.Layout = class Layout { is_numeric_field_active() { const control = $(document.activeElement).closest(".frappe-control"); - const fieldtype = control.data().fieldtype; + const fieldtype = (control.data() || {}).fieldtype; return frappe.model.numeric_fieldtypes.includes(fieldtype); } From a2cb9be7a4cd89e563888006e91ba03f353c251c Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 20 Aug 2021 12:35:24 +0530 Subject: [PATCH 128/244] feat: frappe.utils.data.cast Cast the value to the Python native object of the Frappe fieldtype provided. If value is None, the first/lowest value of the `fieldtype` will be returned. Mapping of Python types => Frappe types: * float => ("Currency", "Float", "Percent") * int => ("Int", "Check") * datetime.datetime => ("Datetime",) * datetime.date => ("Date",) * datetime.time => ("Time",) Deprecate frappe.utils.data.cast_fieldtype in favour of new util cast which handles types "better" --- frappe/utils/data.py | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/frappe/utils/data.py b/frappe/utils/data.py index d89cda1519..4a25ad997a 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -8,6 +8,7 @@ import re, datetime, math, time from code import compile_command from urllib.parse import quote, urljoin from frappe.desk.utils import slug +from click import secho DATE_FORMAT = "%Y-%m-%d" TIME_FORMAT = "%H:%M:%S.%f" @@ -505,7 +506,36 @@ def has_common(l1, l2): """Returns truthy value if there are common elements in lists l1 and l2""" return set(l1) & set(l2) -def cast_fieldtype(fieldtype, value=None): +def cast_fieldtype(fieldtype, value): + # TODO: Add DeprecationWarning for this util + message = ( + "Function `frappe.utils.data.cast` has been deprecated in favour" + " of `frappe.utils.data.cast`. Use the newer util for safer type casting. " + ) + secho(message, fg="yellow") + + if fieldtype in ("Currency", "Float", "Percent"): + value = flt(value) + + elif fieldtype in ("Int", "Check"): + value = cint(value) + + elif fieldtype in ("Data", "Text", "Small Text", "Long Text", + "Text Editor", "Select", "Link", "Dynamic Link"): + value = cstr(value) + + elif fieldtype == "Date": + value = getdate(value) + + elif fieldtype == "Datetime": + value = get_datetime(value) + + elif fieldtype == "Time": + value = to_timedelta(value) + + return value + +def cast(fieldtype, value=None): """Cast the value to the Python native object of the Frappe fieldtype provided. If value is None, the first/lowest value of the `fieldtype` will be returned. @@ -1218,7 +1248,7 @@ def evaluate_filters(doc, filters): def compare(val1, condition, val2, fieldtype=None): ret = False if fieldtype: - val2 = cast_fieldtype(fieldtype, val2) + val2 = cast(fieldtype, val2) if condition in operator_map: ret = operator_map[condition](val1, val2) From ed6533f73705a483853d5b5ae8bf8cc0fca9156a Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 20 Aug 2021 12:37:15 +0530 Subject: [PATCH 129/244] fix: Use cast in favour of cast_fieldtype Use newly introduced casting util for Python-Frappe types mapping --- frappe/database/database.py | 21 ++------------------- frappe/model/base_document.py | 4 ++-- frappe/model/meta.py | 12 ++++++------ 3 files changed, 10 insertions(+), 27 deletions(-) diff --git a/frappe/database/database.py b/frappe/database/database.py index d6ecf0795d..9fab8e116f 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -14,7 +14,7 @@ import frappe.model.meta from frappe import _ from time import time -from frappe.utils import now, getdate, cast_fieldtype, get_datetime, get_table_name +from frappe.utils import now, getdate, cast, get_datetime, get_table_name from frappe.model.utils.link_count import flush_local_link_count @@ -516,7 +516,6 @@ class Database(object): FROM `tabSingles` WHERE doctype = %s """, doctype) - # result = _cast_result(doctype, result) dict_ = frappe._dict(result) @@ -557,7 +556,7 @@ class Database(object): if not df: frappe.throw(_('Invalid field name: {0}').format(frappe.bold(fieldname)), self.InvalidColumnName) - val = cast_fieldtype(df.fieldtype, val) + val = cast(df.fieldtype, val) self.value_cache[doctype][fieldname] = val @@ -1052,19 +1051,3 @@ def enqueue_jobs_after_commit(): q.enqueue_call(execute_job, timeout=job.get("timeout"), kwargs=job.get("queue_args")) frappe.flags.enqueue_after_commit = [] - -# Helpers -def _cast_result(doctype, result): - batch = [ ] - - try: - for field, value in result: - df = frappe.get_meta(doctype).get_field(field) - if df: - value = cast_fieldtype(df.fieldtype, value) - - batch.append(tuple([field, value])) - except frappe.exceptions.DoesNotExistError: - return result - - return tuple(batch) diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 752543f46a..815dd27002 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -9,7 +9,7 @@ from frappe.model.utils.link_count import notify_link_count from frappe.modules import load_doctype_module from frappe.model import display_fieldtypes from frappe.utils import (cint, flt, now, cstr, strip_html, - sanitize_html, sanitize_email, cast_fieldtype) + sanitize_html, sanitize_email, cast) from frappe.utils.html_utils import unescape_html max_positive_value = { @@ -969,7 +969,7 @@ class BaseDocument(object): return self.cast(val, df) def cast(self, value, df): - return cast_fieldtype(df.fieldtype, value) + return cast(df.fieldtype, value) def _extract_images_from_text_editor(self): from frappe.core.doctype.file.file import extract_images_from_doc diff --git a/frappe/model/meta.py b/frappe/model/meta.py index de794ba77f..f89163e092 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -16,7 +16,7 @@ Example: ''' from datetime import datetime import frappe, json, os -from frappe.utils import cstr, cint, cast_fieldtype +from frappe.utils import cstr, cint, cast from frappe.model import default_fields, no_value_fields, optional_fields, data_fieldtypes, table_fields from frappe.model.document import Document from frappe.model.base_document import BaseDocument @@ -322,24 +322,24 @@ class Meta(Document): for ps in property_setters: if ps.doctype_or_field=='DocType': - self.set(ps.property, cast_fieldtype(ps.property_type, ps.value)) + self.set(ps.property, cast(ps.property_type, ps.value)) elif ps.doctype_or_field=='DocField': for d in self.fields: if d.fieldname == ps.field_name: - d.set(ps.property, cast_fieldtype(ps.property_type, ps.value)) + d.set(ps.property, cast(ps.property_type, ps.value)) break elif ps.doctype_or_field=='DocType Link': for d in self.links: if d.name == ps.row_name: - d.set(ps.property, cast_fieldtype(ps.property_type, ps.value)) + d.set(ps.property, cast(ps.property_type, ps.value)) break elif ps.doctype_or_field=='DocType Action': for d in self.actions: if d.name == ps.row_name: - d.set(ps.property, cast_fieldtype(ps.property_type, ps.value)) + d.set(ps.property, cast(ps.property_type, ps.value)) break def add_custom_links_and_actions(self): @@ -532,7 +532,7 @@ class Meta(Document): label = link.group, items = [link.parent_doctype or link.link_doctype] )) - + if not link.is_child_table: if link.link_fieldname != data.fieldname: if data.fieldname: From de3050cd81478de35a9c78e660195480f95c7523 Mon Sep 17 00:00:00 2001 From: Komal-Saraf0609 Date: Fri, 20 Aug 2021 12:47:51 +0530 Subject: [PATCH 130/244] test: Corrected selectors and shorten them (Using the added testing-library) --- cypress/integration/dashboard_links.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/cypress/integration/dashboard_links.js b/cypress/integration/dashboard_links.js index 6bf2d22dad..e6c9a7e08c 100644 --- a/cypress/integration/dashboard_links.js +++ b/cypress/integration/dashboard_links.js @@ -13,8 +13,8 @@ context('Dashboard links', () => { //Adding a new contact cy.get('.btn[data-doctype="Contact"]').click(); - cy.get('.has-error > .form-group > .control-input-wrapper > .control-input > .input-with-feedback').type('Admin'); - cy.get('#page-Contact > .page-head > .container > .row > .col > .standard-actions > .primary-action').click(); + cy.get('[data-doctype="Contact"][data-fieldname="first_name"]').type('Admin'); + cy.findByRole('button', {name: 'Save'}).click(); cy.visit('/app/user'); cy.get('.list-row-col > .level-item > .ellipsis').eq(0).click(); @@ -25,15 +25,15 @@ context('Dashboard links', () => { //Deleting the newly created contact cy.visit('/app/contact'); cy.get('.list-subject > .select-like > .list-row-checkbox').eq(0).click(); - cy.get('.actions-btn-group > .btn').contains('Actions').click(); - cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(5).click(); - cy.get('.modal-footer > .standard-actions > .btn-primary').contains('Yes').click({delay: 700}); + cy.findByRole('button', {name: 'Actions'}).click(); + cy.get('.actions-btn-group [data-label="Delete"]').click(); + cy.findByRole('button', {name: 'Yes'}).click({delay: 700}); //To check if the counter from the "Contact" doc link is removed - cy.visit('/app/user'); cy.wait(700); + cy.visit('/app/user'); cy.get('.list-row-col > .level-item > .ellipsis').eq(0).click(); cy.get('[data-doctype="Contact"]').should('contain', 'Contact'); }); -}); \ No newline at end of file +}); From 9781fb758f2223294dd818680cc85c0ee03b6cf7 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Fri, 20 Aug 2021 22:06:50 +0530 Subject: [PATCH 131/244] fix: Rate limiter to allow kwargs --- frappe/rate_limiter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/rate_limiter.py b/frappe/rate_limiter.py index 023cdb9cb0..528e5d6b56 100644 --- a/frappe/rate_limiter.py +++ b/frappe/rate_limiter.py @@ -103,7 +103,7 @@ def rate_limit(key: str, limit: Union[int, Callable] = 5, seconds: int= 24*60*60 def wrapper(*args, **kwargs): # Do not apply rate limits if method is not opted to check if methods != 'ALL' and frappe.request.method.upper() not in methods: - return frappe.call(fun, **frappe.form_dict) + return frappe.call(fun, **frappe.form_dict or kwargs) _limit = limit() if callable(limit) else limit @@ -118,6 +118,6 @@ def rate_limit(key: str, limit: Union[int, Callable] = 5, seconds: int= 24*60*60 if value > _limit: frappe.throw(_("You hit the rate limit because of too many requests. Please try after sometime.")) - return frappe.call(fun, **frappe.form_dict) + return frappe.call(fun, **frappe.form_dict or kwargs) return wrapper return ratelimit_decorator From 6f72e79f1acce546c8ada707afa57c2353bf2b4b Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Fri, 20 Aug 2021 22:35:01 +0530 Subject: [PATCH 132/244] test: Add more assertions for reset password --- frappe/core/doctype/user/test_user.py | 27 ++++++++++++++++++- .../notification_log/test_notification_log.py | 6 ++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py index 5fea8ed9d8..1c4c483fa8 100644 --- a/frappe/core/doctype/user/test_user.py +++ b/frappe/core/doctype/user/test_user.py @@ -1,11 +1,12 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # MIT License. See license.txt import frappe, unittest +import json from frappe.model.delete_doc import delete_doc from frappe.utils import get_url from frappe.core.doctype.user.user import (test_password_strength, - extract_mentions, sign_up, update_password, verify_password) + extract_mentions, sign_up, update_password, verify_password, reset_password) from frappe.frappeclient import FrappeClient from unittest.mock import patch @@ -347,6 +348,30 @@ class TestUser(unittest.TestCase): # reset password update_password(old_password, old_password=new_password) + # test API endpoint + with patch.object(user_module.frappe, 'sendmail') as sendmail: + frappe.clear_messages() + test_user = frappe.get_doc("User", "test2@example.com") + self.assertEqual(reset_password(user="test2@example.com"), None) + test_user.reload() + self.assertEqual(update_password(new_password, key=test_user.reset_password_key), "/") + update_password(old_password, old_password=new_password) + self.assertEqual(json.loads(frappe.message_log[0]), {"message": "Password reset instructions have been sent to your email"}) + sendmail.assert_called_once() + self.assertEqual(sendmail.call_args[1]["recipients"], "test2@example.com") + + self.assertEqual(reset_password(user="test2@example.com"), None) + self.assertEqual(reset_password(user="Administrator"), "not allowed") + self.assertEqual(reset_password(user="random"), "not found") + + def test_user_onload_modules(self): + from frappe.desk.form.load import getdoc + from frappe.config import get_modules_from_all_apps + frappe.response.docs = [] + getdoc("User", "Administrator") + doc = frappe.response.docs[0] + self.assertListEqual(doc.get("__onload").get('all_modules', []), + [m.get("module_name") for m in get_modules_from_all_apps()]) def test_password_verification(self): diff --git a/frappe/desk/doctype/notification_log/test_notification_log.py b/frappe/desk/doctype/notification_log/test_notification_log.py index 2d232cf942..bedb10b495 100644 --- a/frappe/desk/doctype/notification_log/test_notification_log.py +++ b/frappe/desk/doctype/notification_log/test_notification_log.py @@ -2,6 +2,7 @@ # Copyright (c) 2019, Frappe Technologies and Contributors # See license.txt import frappe +from frappe.core.doctype.user.user import get_system_users from frappe.desk.form.assign_to import add as assign_task import unittest @@ -54,7 +55,4 @@ def get_todo(): return frappe.get_cached_doc('ToDo', res[0].name) def get_user(): - users = frappe.db.get_all('User', - filters={'name': ('not in', ['Administrator', 'Guest']), 'enabled': 1}, - fields='name', limit=1) - return users[0].name + return get_system_users(limit=1)[0] From 9e4c8cc28ec93dc23be354ec2a8a33933c1a1719 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Fri, 20 Aug 2021 23:35:55 +0530 Subject: [PATCH 133/244] chore: Remove commented text --- frappe/core/doctype/file/test_file.py | 6 ------ frappe/core/doctype/user/test_user.py | 3 --- 2 files changed, 9 deletions(-) diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index f9d06429ff..5478d7ab85 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -569,9 +569,3 @@ class TestFileUtils(unittest.TestCase): from frappe.core.doctype.file.file import create_new_folder folder = create_new_folder('test_folder', 'Home') self.assertTrue(folder.is_folder) - -# def get_files_by_search_text(text): -# def optimize_saved_image(doc_name): -# def download_file(file_url): -# def remove_file_by_url(file_url, doctype=None, name=None): -# def on_doctype_update(): \ No newline at end of file diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py index 1c4c483fa8..6ea744d553 100644 --- a/frappe/core/doctype/user/test_user.py +++ b/frappe/core/doctype/user/test_user.py @@ -374,9 +374,6 @@ class TestUser(unittest.TestCase): [m.get("module_name") for m in get_modules_from_all_apps()]) - def test_password_verification(self): - pass - def delete_contact(user): frappe.db.sql("DELETE FROM `tabContact` WHERE `email_id`= %s", user) frappe.db.sql("DELETE FROM `tabContact Email` WHERE `email_id`= %s", user) From 5587ab5a91ebb5c000c5ad9e9b249deffa7127ff Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Fri, 20 Aug 2021 23:54:42 +0530 Subject: [PATCH 134/244] style: Fix liinter warnings --- frappe/core/doctype/file/file.py | 2 +- .../prepared_report/prepared_report.py | 1 - frappe/core/doctype/user/test_user.py | 19 +++++++++---------- .../doctype/email_account/email_account.py | 13 +++++++------ 4 files changed, 17 insertions(+), 18 deletions(-) diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index 06222872a6..fcc8a860f3 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -319,7 +319,7 @@ class File(Document): def unzip(self): '''Unzip current file and replace it by its children''' - if not ".zip" in self.file_url: + if not self.file_url.endswith(".zip"): frappe.throw(_("{0} is not a zip file").format(self.file_name)) zip_path = self.get_full_path() diff --git a/frappe/core/doctype/prepared_report/prepared_report.py b/frappe/core/doctype/prepared_report/prepared_report.py index 409aa6f217..c9b5fd9d2e 100644 --- a/frappe/core/doctype/prepared_report/prepared_report.py +++ b/frappe/core/doctype/prepared_report/prepared_report.py @@ -11,7 +11,6 @@ from frappe.desk.query_report import generate_report_result from frappe.model.document import Document from frappe.utils import gzip_compress, gzip_decompress from frappe.utils.background_jobs import enqueue -from frappe.utils.file_manager import remove_all class PreparedReport(Document): def before_insert(self): diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py index 6ea744d553..bcc273a784 100644 --- a/frappe/core/doctype/user/test_user.py +++ b/frappe/core/doctype/user/test_user.py @@ -1,16 +1,16 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # MIT License. See license.txt -import frappe, unittest import json +import unittest +from unittest.mock import patch +import frappe +import frappe.exceptions +from frappe.core.doctype.user.user import (extract_mentions, reset_password, + sign_up, test_password_strength, update_password, verify_password) +from frappe.frappeclient import FrappeClient from frappe.model.delete_doc import delete_doc from frappe.utils import get_url -from frappe.core.doctype.user.user import (test_password_strength, - extract_mentions, sign_up, update_password, verify_password, reset_password) -from frappe.frappeclient import FrappeClient - -from unittest.mock import patch -import frappe.exceptions user_module = frappe.core.doctype.user.user test_records = frappe.get_test_records('User') @@ -305,9 +305,8 @@ class TestUser(unittest.TestCase): def test_reset_password(self): + from frappe.auth import CookieManager, LoginManager from frappe.utils import set_request - from frappe.auth import CookieManager - from frappe.auth import LoginManager old_password = "Eastern_43A1W" new_password = "easy_password" @@ -365,8 +364,8 @@ class TestUser(unittest.TestCase): self.assertEqual(reset_password(user="random"), "not found") def test_user_onload_modules(self): - from frappe.desk.form.load import getdoc from frappe.config import get_modules_from_all_apps + from frappe.desk.form.load import getdoc frappe.response.docs = [] getdoc("User", "Administrator") doc = frappe.response.docs[0] diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index a1ddbf7a68..fb7349adba 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -725,6 +725,7 @@ def get_max_email_uid(email_account): def setup_user_email_inbox(email_account, awaiting_password, email_id, enable_outgoing): """ setup email inbox for user """ from frappe.core.doctype.user.user import ask_pass_update + def add_user_email(user): user = frappe.get_doc("User", user) row = user.append("user_emails", {}) @@ -736,11 +737,11 @@ def setup_user_email_inbox(email_account, awaiting_password, email_id, enable_ou user.save(ignore_permissions=True) - udpate_user_email_settings = False + update_user_email_settings = False if not all([email_account, email_id]): return - user_names = frappe.db.get_values("User", { "email": email_id }, as_dict=True) + user_names = frappe.db.get_values("User", {"email": email_id}, as_dict=True) if not user_names: return @@ -757,9 +758,9 @@ def setup_user_email_inbox(email_account, awaiting_password, email_id, enable_ou add_user_email(user_name) else: # update awaiting password for email account - udpate_user_email_settings = True + update_user_email_settings = True - if udpate_user_email_settings: + if update_user_email_settings: frappe.db.sql("""UPDATE `tabUser Email` SET awaiting_password = %(awaiting_password)s, enable_outgoing = %(enable_outgoing)s WHERE email_account = %(email_account)s""", { "email_account": email_account, @@ -782,8 +783,8 @@ def remove_user_email_inbox(email_account): for user in users: doc = frappe.get_doc("User", user.get("name")) - to_remove = [ row for row in doc.user_emails if row.email_account == email_account ] - [ doc.remove(row) for row in to_remove ] + to_remove = [row for row in doc.user_emails if row.email_account == email_account] + [doc.remove(row) for row in to_remove] doc.save(ignore_permissions=True) From 399629da45ab664df8ae3c6c21e806962cc0a7d0 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Sat, 21 Aug 2021 13:47:42 +0530 Subject: [PATCH 135/244] fix(ux): Disable button while executing script - page.set_action will now pass the btn in the handler callback --- frappe/desk/doctype/system_console/system_console.js | 9 ++++++--- frappe/public/js/frappe/form/form.js | 4 ++-- frappe/public/js/frappe/ui/page.js | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/frappe/desk/doctype/system_console/system_console.js b/frappe/desk/doctype/system_console/system_console.js index c7eac39490..48dd2ba108 100644 --- a/frappe/desk/doctype/system_console/system_console.js +++ b/frappe/desk/doctype/system_console/system_console.js @@ -5,7 +5,7 @@ frappe.ui.form.on('System Console', { onload: function(frm) { frappe.ui.keys.add_shortcut({ shortcut: 'shift+enter', - action: () => frm.execute_action('Execute'), + action: () => frm.page.btn_primary.trigger('click'), page: frm.page, description: __('Execute Console script'), ignore_inputs: true, @@ -14,8 +14,11 @@ frappe.ui.form.on('System Console', { refresh: function(frm) { frm.disable_save(); - frm.page.set_primary_action(__("Execute"), () => { - frm.execute_action('Execute'); + frm.page.set_primary_action(__("Execute"), $btn => { + $btn.text(__('Executing...')); + return frm.execute_action("Execute").then(() => { + $btn.text(__('Execute')); + }); }); } }); diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index fd49df027c..3588923527 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -339,7 +339,7 @@ frappe.ui.form.Form = class FrappeForm { } } if (action.action_type==='Server Action') { - frappe.xcall(action.action, {'doc': this.doc}).then((doc) => { + return frappe.xcall(action.action, {'doc': this.doc}).then((doc) => { if (doc.doctype) { // document is returned by the method, // apply the changes locally and refresh @@ -354,7 +354,7 @@ frappe.ui.form.Form = class FrappeForm { }); }); } else if (action.action_type==='Route') { - frappe.set_route(action.action); + return frappe.set_route(action.action); } } diff --git a/frappe/public/js/frappe/ui/page.js b/frappe/public/js/frappe/ui/page.js index 22fdf476b8..320227b258 100644 --- a/frappe/public/js/frappe/ui/page.js +++ b/frappe/public/js/frappe/ui/page.js @@ -251,7 +251,7 @@ frappe.ui.Page = class Page { .prop("disabled", false) .html(opts.label) .on("click", function() { - let response = opts.click.apply(this); + let response = opts.click.apply(this, [btn]); me.btn_disable_enable(btn, response); }); From cd9b07b3bbdb80c27370301f9f786c537a9ff646 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Sat, 21 Aug 2021 19:07:44 +0530 Subject: [PATCH 136/244] test: Add test case for docfield length property update --- .../doctype/customize_form/test_customize_form.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/frappe/custom/doctype/customize_form/test_customize_form.py b/frappe/custom/doctype/customize_form/test_customize_form.py index aef95cd676..6783e52d68 100644 --- a/frappe/custom/doctype/customize_form/test_customize_form.py +++ b/frappe/custom/doctype/customize_form/test_customize_form.py @@ -188,6 +188,19 @@ class TestCustomizeForm(unittest.TestCase): def test_core_doctype_customization(self): self.assertRaises(frappe.ValidationError, self.get_customize_form, 'User') + def test_save_customization_length_field_property(self): + # Using Notification Log doctype as it doesn't have any other custom fields + d = self.get_customize_form("Notification Log") + + document_name = d.get("fields", {"fieldname": "document_name"})[0] + document_name.length = 255 + d.run_method("save_customization") + + self.assertEqual(frappe.db.get_value("Property Setter", + {"doc_type": "Notification Log", "property": "length", "field_name": "document_name"}, "value"), '255') + + self.assertTrue(d.flags.update_db) + def test_custom_link(self): try: # create a dummy doctype linked to Event From cc5a23411b6eefdd303f76d79554e93391c531f1 Mon Sep 17 00:00:00 2001 From: MitulDavid Date: Mon, 23 Aug 2021 11:15:02 +0530 Subject: [PATCH 137/244] refactor: Image cropping and optimization --- frappe/core/doctype/file/file.py | 41 ++++++++++++------- frappe/public/icons/timeless/symbol-defs.svg | 2 +- .../public/js/frappe/file_uploader/index.js | 4 +- frappe/utils/file_manager.py | 2 +- 4 files changed, 32 insertions(+), 17 deletions(-) diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index e79b2bd761..f279a04a44 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -594,6 +594,31 @@ class File(Document): if self.file_url: self.is_private = cint(self.file_url.startswith('/private')) + @frappe.whitelist() + def optimize_file(self): + if self.is_folder: + raise TypeError('Folders cannot be optimized') + + content_type = mimetypes.guess_type(self.file_name)[0] + is_local_image = content_type.startswith('image/') and self.file_size > 0 + is_svg = content_type == 'image/svg+xml' + if is_local_image and not is_svg: + content = self.get_content() + optimized_content = optimize_image(content, content_type) + + file_path = get_files_path(is_private=self.is_private) + file_path = os.path.join(file_path.encode('utf-8'), self.file_name.encode('utf-8')) + with open(file_path, 'wb+') as f: + f.write(optimized_content) + + self.file_size = len(optimized_content) + self.content_hash = get_content_hash(optimized_content) + self.save() + elif is_svg: + raise TypeError('Optimization of SVG images is not supported') + else: + raise NotImplementedError('Only local image files can be optimized') + def on_doctype_update(): frappe.db.add_index("File", ["attached_to_doctype", "attached_to_name"]) @@ -886,7 +911,7 @@ def extract_images_from_html(doc, content): if b"," in content: content = content.split(b",")[1] content = base64.b64decode(content) - + content = optimize_image(content, mtype) if "filename=" in headers: @@ -943,19 +968,7 @@ def unzip_file(name): @frappe.whitelist() def optimize_saved_image(doc_name): file_doc = frappe.get_doc('File', doc_name) - content = file_doc.get_content() - content_type = mimetypes.guess_type(file_doc.file_name)[0] - - optimized_content = optimize_image(content, content_type) - - file_path = get_files_path(is_private=file_doc.is_private) - file_path = os.path.join(file_path.encode('utf-8'), file_doc.file_name.encode('utf-8')) - with open(file_path, 'wb+') as f: - f.write(optimized_content) - - file_doc.file_size = len(optimized_content) - file_doc.content_hash = get_content_hash(optimized_content) - file_doc.save() + file_doc.optimize_file() @frappe.whitelist() def get_attached_images(doctype, names): diff --git a/frappe/public/icons/timeless/symbol-defs.svg b/frappe/public/icons/timeless/symbol-defs.svg index f216374526..b2f1428967 100644 --- a/frappe/public/icons/timeless/symbol-defs.svg +++ b/frappe/public/icons/timeless/symbol-defs.svg @@ -567,7 +567,7 @@ - diff --git a/frappe/public/js/frappe/file_uploader/index.js b/frappe/public/js/frappe/file_uploader/index.js index daad9857ee..87bc1c8ec8 100644 --- a/frappe/public/js/frappe/file_uploader/index.js +++ b/frappe/public/js/frappe/file_uploader/index.js @@ -28,7 +28,7 @@ export default class FileUploader { } if (attach_doc_image) { - restrictions.allowed_file_types = ['.jpg', '.jpeg', '.png']; + restrictions.allowed_file_types = ['image/jpeg', 'image/png']; } this.$fileuploader = new Vue({ @@ -70,8 +70,10 @@ export default class FileUploader { this.uploader.$watch('hide_dialog_footer', (hide_dialog_footer) => { if (hide_dialog_footer) { this.dialog && this.dialog.footer.addClass('hide'); + this.dialog.$wrapper.data('bs.modal')._config.backdrop = 'static'; } else { this.dialog && this.dialog.footer.removeClass('hide'); + this.dialog.$wrapper.data('bs.modal')._config.backdrop = true; } }); diff --git a/frappe/utils/file_manager.py b/frappe/utils/file_manager.py index 7efdff299b..79a5423d8b 100644 --- a/frappe/utils/file_manager.py +++ b/frappe/utils/file_manager.py @@ -393,7 +393,7 @@ def extract_images_from_html(doc, content): if b"," in content: content = content.split(b",")[1] content = base64.b64decode(content) - + content = optimize_image(content, mtype) if "filename=" in headers: From d01335ab91376dc3751a32568440340d688410df Mon Sep 17 00:00:00 2001 From: shariquerik Date: Mon, 23 Aug 2021 11:26:43 +0530 Subject: [PATCH 138/244] fix: Faulty Export All rows visibility condition --- frappe/public/js/frappe/list/list_view.js | 3 ++- frappe/public/js/frappe/views/reports/report_view.js | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js index 8a0e43c8f3..d4133049e6 100644 --- a/frappe/public/js/frappe/list/list_view.js +++ b/frappe/public/js/frappe/list/list_view.js @@ -867,8 +867,9 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { filters: this.get_filters_for_args() }).then(total_count => { this.total_count = total_count || current_count; + this.count_without_children = count_without_children !== current_count ? count_without_children : undefined; let str = __('{0} of {1}', [current_count, this.total_count]); - if (count_without_children !== current_count) { + if (this.count_without_children) { str = __('{0} of {1} ({2} rows with children)', [count_without_children, this.total_count, current_count]); } return str; diff --git a/frappe/public/js/frappe/views/reports/report_view.js b/frappe/public/js/frappe/views/reports/report_view.js index 2547dd6407..b46e6fb374 100644 --- a/frappe/public/js/frappe/views/reports/report_view.js +++ b/frappe/public/js/frappe/views/reports/report_view.js @@ -1401,7 +1401,7 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView { } ]; - if (this.total_count > args.page_length) { + if (this.total_count > this.count_without_children || args.page_length) { fields.push({ fieldtype: 'Check', fieldname: 'export_all_rows', From 7c178ae18c41d21ac53d8b77aae0b179adeb8349 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 23 Aug 2021 11:28:33 +0530 Subject: [PATCH 139/244] fix(ux): better message for removal of assignment (#14008) (cherry picked from commit 40352d9df6418e0c435e0ae9f9dd8f96095c70db) Co-authored-by: Ankush Menat --- frappe/desk/doctype/todo/todo.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frappe/desk/doctype/todo/todo.py b/frappe/desk/doctype/todo/todo.py index 09297b4e5e..754b94cdcb 100644 --- a/frappe/desk/doctype/todo/todo.py +++ b/frappe/desk/doctype/todo/todo.py @@ -29,8 +29,15 @@ class ToDo(Document): else: # NOTE the previous value is only available in validate method if self.get_db_value("status") != self.status: + if self.owner == frappe.session.user: + removal_message = frappe._("{0} removed their assignment.").format( + get_fullname(frappe.session.user)) + else: + removal_message = frappe._("Assignment of {0} removed by {1}").format( + get_fullname(self.owner), get_fullname(frappe.session.user)) + self._assignment = { - "text": frappe._("Assignment closed by {0}").format(get_fullname(frappe.session.user)), + "text": removal_message, "comment_type": "Assignment Completed" } From 30f9572122a4ef8c357799e50f763e49c21cac4c Mon Sep 17 00:00:00 2001 From: Suraj Shetty <13928957+surajshetty3416@users.noreply.github.com> Date: Mon, 23 Aug 2021 12:32:14 +0530 Subject: [PATCH 140/244] chore: Let maintainer raise a PR on stable branch --- .mergify.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.mergify.yml b/.mergify.yml index 1a81a28594..8c7a7dc95d 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -1,9 +1,11 @@ pull_request_rules: - name: Auto-close PRs on stable branch conditions: - - or: - - base=version-13 - - base=version-12 + - and: + - author!=surajshetty3416 + - or: + - base=version-13 + - base=version-12 actions: close: comment: From 7e9b78bf7deb5dd03c01653e81ef810e7f2dd7bc Mon Sep 17 00:00:00 2001 From: shariquerik Date: Mon, 23 Aug 2021 13:45:14 +0530 Subject: [PATCH 141/244] test: Type on focused block fix --- cypress/integration/workspace.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cypress/integration/workspace.js b/cypress/integration/workspace.js index 9701e54c5e..28930b5734 100644 --- a/cypress/integration/workspace.js +++ b/cypress/integration/workspace.js @@ -36,12 +36,12 @@ context('Workspace 2.0', () => { cy.get('.codex-editor__redactor .ce-block'); cy.get('.custom-actions .inner-group-button[data-label="Add%20Block"]').click(); cy.get('.custom-actions .inner-group-button .dropdown-menu .block-menu-item-label').contains('Heading').click(); - cy.get(".ce-block:last").find('h2').click({force: true}).type('Header'); + cy.get(":focus").type('Header'); cy.get(".ce-block:last").find('.ce-header').should('exist'); cy.get('.custom-actions .inner-group-button[data-label="Add%20Block"]').click(); cy.get('.custom-actions .inner-group-button .dropdown-menu .block-menu-item-label').contains('Text').click(); - cy.get(".ce-block:last").find('.ce-paragraph').click({force: true}).type('Paragraph text'); + cy.get(":focus").type('Paragraph text'); cy.get(".ce-block:last").find('.ce-paragraph').should('exist'); }); From 70899a7a959be3d7ba26bd6aa6783a7123434c24 Mon Sep 17 00:00:00 2001 From: Rushabh Mehta Date: Mon, 23 Aug 2021 21:43:29 +0530 Subject: [PATCH 142/244] feat(minor): Added app_name and app_logo in Website Settings --- .../website_settings/website_settings.json | 23 +++++++++++++++---- frappe/www/login.py | 8 ++++--- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/frappe/website/doctype/website_settings/website_settings.json b/frappe/website/doctype/website_settings/website_settings.json index f4eee7231e..e7ae2a01e1 100644 --- a/frappe/website/doctype/website_settings/website_settings.json +++ b/frappe/website/doctype/website_settings/website_settings.json @@ -56,9 +56,11 @@ "google_analytics_id", "google_analytics_anonymize_ip", "misc_section", - "subdomain", + "app_name", + "app_logo", "disable_signup", "section_break_38", + "subdomain", "head_html", "robots_txt", "route_redirects", @@ -224,7 +226,7 @@ "collapsible": 1, "fieldname": "misc_section", "fieldtype": "Section Break", - "label": "Disable Signup" + "label": "Login Page" }, { "description": "An icon file with .ico extension. Should be 16 x 16 px. Generated using a favicon generator. [favicon-generator.org]", @@ -235,7 +237,7 @@ { "description": "Sub-domain provided by erpnext.com", "fieldname": "subdomain", - "fieldtype": "Text", + "fieldtype": "Small Text", "label": "Subdomain", "read_only": 1 }, @@ -425,6 +427,17 @@ "fieldname": "navbar_template_section", "fieldtype": "Section Break", "label": "Navbar Template" + }, + { + "default": "Frappe", + "fieldname": "app_name", + "fieldtype": "Data", + "label": "App Name" + }, + { + "fieldname": "app_logo", + "fieldtype": "Attach Image", + "label": "App Logo" } ], "icon": "fa fa-cog", @@ -433,7 +446,7 @@ "issingle": 1, "links": [], "max_attachments": 10, - "modified": "2021-07-15 17:39:56.609771", + "modified": "2021-08-23 21:39:51.702248", "modified_by": "Administrator", "module": "Website", "name": "Website Settings", @@ -457,4 +470,4 @@ "sort_field": "modified", "sort_order": "ASC", "track_changes": 1 -} +} \ No newline at end of file diff --git a/frappe/www/login.py b/frappe/www/login.py index 6542b29d42..f816b2f91e 100644 --- a/frappe/www/login.py +++ b/frappe/www/login.py @@ -34,9 +34,11 @@ def get_context(context): context.for_test = 'login.html' context["title"] = "Login" context["provider_logins"] = [] - context["disable_signup"] = frappe.utils.cint(frappe.db.get_value("Website Settings", "Website Settings", "disable_signup")) - context["logo"] = frappe.get_hooks("app_logo_url")[-1] - context["app_name"] = frappe.get_system_settings("app_name") or _("Frappe") + context["disable_signup"] = frappe.utils.cint(frappe.db.get_single_value("Website Settings", "disable_signup")) + context["logo"] = (frappe.db.get_single_value('Website Settings', 'app_logo') or + frappe.get_hooks("app_logo_url")[-1]) + context["app_name"] = (frappe.db.get_single_value('Website Settings', 'app_name') or + frappe.get_system_settings("app_name") or _("Frappe")) providers = [i.name for i in frappe.get_all("Social Login Key", filters={"enable_social_login":1}, order_by="name")] for provider in providers: client_id, base_url = frappe.get_value("Social Login Key", provider, ["client_id", "base_url"]) From 7caae74004b30cfcd6718c1a3b3f1d151efe9e1b Mon Sep 17 00:00:00 2001 From: Shariq Ansari <30859809+shariquerik@users.noreply.github.com> Date: Tue, 24 Aug 2021 10:15:37 +0530 Subject: [PATCH 143/244] build: Update datatable to 1.15.4 (#14016) Co-authored-by: Suraj Shetty <13928957+surajshetty3416@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 1ddbec178e..2283a44533 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "express": "^4.17.1", "fast-deep-equal": "^2.0.1", "frappe-charts": "^2.0.0-rc13", - "frappe-datatable": "^1.15.3", + "frappe-datatable": "^1.15.4", "frappe-gantt": "^0.5.0", "fuse.js": "^3.4.6", "highlight.js": "^10.4.1", diff --git a/yarn.lock b/yarn.lock index e8f527b7f1..ee530d747b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2721,10 +2721,10 @@ frappe-charts@^2.0.0-rc13: resolved "https://registry.yarnpkg.com/frappe-charts/-/frappe-charts-2.0.0-rc13.tgz#fdb251d7ae311c41e38f90a3ae108070ec6b9072" integrity sha512-Bv7IfllIrjRbKWHn5b769dOSenqdBixAr6m5kurf8ZUOJSLOgK4HOXItJ7BA8n9PvviH9/k5DaloisjLM2Bm1w== -frappe-datatable@^1.15.3: - version "1.15.3" - resolved "https://registry.yarnpkg.com/frappe-datatable/-/frappe-datatable-1.15.3.tgz#1737e9aebfd363ffadffced71a3534c40e350223" - integrity sha512-tUE3pNbxCMX0HPKvwurLBPRAOAdS0gNo1+MpoyFSqXI7b7sp6/TCBRht6qu1Luw+VyIzBtXkJdnnqU+Uoy8iow== +frappe-datatable@^1.15.4: + version "1.15.4" + resolved "https://registry.yarnpkg.com/frappe-datatable/-/frappe-datatable-1.15.4.tgz#dc2e5e5d8a0a7cb8ee658f2d39966af1d4405401" + integrity sha512-eW3upPvverm1GNBL4+IcPDvjm5xbJc5ZXW8TYEUZt/QQ2W75K/T6736pSzi9D6mX9sn3BtZ7Ige7MS45SGrgzQ== dependencies: hyperlist "^1.0.0-beta" lodash "^4.17.5" From 14783a1ba19ed381c132c0cd9c13cce1cb4b5174 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Tue, 24 Aug 2021 10:25:33 +0530 Subject: [PATCH 144/244] fix: Remove unnecessary raise after throw --- frappe/core/doctype/file/file.py | 1 - 1 file changed, 1 deletion(-) diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index fcc8a860f3..5b88418675 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -258,7 +258,6 @@ class File(Document): self.content_hash = get_content_hash(f.read()) except IOError: frappe.throw(_("File {0} does not exist").format(self.file_url)) - raise def on_trash(self): if self.is_home_folder or self.is_attachments_folder: From 850939ed1374b9d145a2258b1be3155c955006bb Mon Sep 17 00:00:00 2001 From: gavin Date: Tue, 24 Aug 2021 13:26:03 +0530 Subject: [PATCH 145/244] Revert "ci: ignore js only changes while running unittests (#13932)" This reverts commit ead26527b7c0df4b4b011bd0962e751f15aa01b1. --- .github/workflows/patch-mariadb-tests.yml | 7 +------ .github/workflows/server-mariadb-tests.yml | 6 ------ .github/workflows/server-postgres-tests.yml | 3 --- .github/workflows/ui-tests.yml | 2 -- 4 files changed, 1 insertion(+), 17 deletions(-) diff --git a/.github/workflows/patch-mariadb-tests.yml b/.github/workflows/patch-mariadb-tests.yml index 82be4d06b5..e8627a01fb 100644 --- a/.github/workflows/patch-mariadb-tests.yml +++ b/.github/workflows/patch-mariadb-tests.yml @@ -1,11 +1,6 @@ name: Patch -on: - pull_request: - paths-ignore: - - '**.js' - - '**.md' - workflow_dispatch: +on: [pull_request, workflow_dispatch] jobs: test: diff --git a/.github/workflows/server-mariadb-tests.yml b/.github/workflows/server-mariadb-tests.yml index 8d5bd690a1..2476102e3d 100644 --- a/.github/workflows/server-mariadb-tests.yml +++ b/.github/workflows/server-mariadb-tests.yml @@ -2,15 +2,9 @@ name: Server on: pull_request: - paths-ignore: - - '**.js' - - '**.md' workflow_dispatch: push: branches: [ develop ] - paths-ignore: - - '**.js' - - '**.md' jobs: test: diff --git a/.github/workflows/server-postgres-tests.yml b/.github/workflows/server-postgres-tests.yml index 8c97c7f84b..4325eebaad 100644 --- a/.github/workflows/server-postgres-tests.yml +++ b/.github/workflows/server-postgres-tests.yml @@ -2,9 +2,6 @@ name: Server on: pull_request: - paths-ignore: - - '**.js' - - '**.md' workflow_dispatch: jobs: diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index d76e5e77ea..f342c0709e 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -2,8 +2,6 @@ name: UI on: pull_request: - paths-ignore: - - '**.md' workflow_dispatch: push: branches: [ develop ] From af93d35471535522eeda9f72e95fd884b5a1ddfe Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Tue, 24 Aug 2021 14:33:53 +0530 Subject: [PATCH 146/244] fix: Use remove_all in file_manager.py --- frappe/core/doctype/file/file.py | 1 + 1 file changed, 1 insertion(+) diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index 5b88418675..b8ea134db5 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -729,6 +729,7 @@ def remove_file_by_url(file_url, doctype=None, name=None): fid = frappe.db.get_value("File", {"file_url": file_url}) if fid: + from frappe.utils.file_manager import remove_file return remove_file(fid=fid) From 597d237f63fd0391cd55616ee47feb8a00fc18d2 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 24 Aug 2021 14:14:42 +0530 Subject: [PATCH 147/244] ci: Roulette for GHA --- .github/helper/install_dependencies.sh | 5 -- .github/helper/roulette.py | 65 ++++++++++----------- .github/workflows/patch-mariadb-tests.yml | 18 ++++++ .github/workflows/server-mariadb-tests.yml | 22 +++++++ .github/workflows/server-postgres-tests.yml | 19 ++++++ .github/workflows/ui-tests.yml | 21 +++++++ 6 files changed, 112 insertions(+), 38 deletions(-) diff --git a/.github/helper/install_dependencies.sh b/.github/helper/install_dependencies.sh index 9be8519d85..d16f5b62ad 100644 --- a/.github/helper/install_dependencies.sh +++ b/.github/helper/install_dependencies.sh @@ -2,11 +2,6 @@ set -e -# python "${GITHUB_WORKSPACE}/.github/helper/roulette.py" -# if [[ $? != 2 ]];then -# exit; -# fi - # install wkhtmltopdf wget -O /tmp/wkhtmltox.tar.xz https://github.com/frappe/wkhtmltopdf/raw/master/wkhtmltox-0.12.3_linux-generic-amd64.tar.xz tar -xf /tmp/wkhtmltox.tar.xz -C /tmp diff --git a/.github/helper/roulette.py b/.github/helper/roulette.py index ea4f07b9f7..ce4c8ed633 100644 --- a/.github/helper/roulette.py +++ b/.github/helper/roulette.py @@ -1,4 +1,3 @@ -# if the script ends with exit code 0, then no tests are run further, else all tests are run import os import re import shlex @@ -7,50 +6,50 @@ import sys def get_output(command, shell=True): - print(command) - command = shlex.split(command) - return subprocess.check_output(command, shell=shell, encoding="utf8").strip() + print(command) + command = shlex.split(command) + return subprocess.check_output(command, shell=shell, encoding="utf8").strip() def is_py(file): - return file.endswith("py") + return file.endswith("py") + +def is_ci(file): + return file.endswith("yml") or ".github" in file def is_js(file): - return file.endswith("js") + return file.endswith("js") def is_docs(file): - regex = re.compile(r'\.(md|png|jpg|jpeg)$|^.github|LICENSE') - return bool(regex.search(file)) + regex = re.compile(r'\.(md|png|jpg|jpeg)$|^.github|LICENSE') + return bool(regex.search(file)) if __name__ == "__main__": - build_type = os.environ.get("TYPE") - before = os.environ.get("BEFORE") - after = os.environ.get("AFTER") - commit_range = before + '...' + after - print("Build Type: {}".format(build_type)) - print("Commit Range: {}".format(commit_range)) + files_list = sys.argv[1:] + build_type = os.environ.get("TYPE") - try: - files_changed = get_output("git diff --name-only {}".format(commit_range), shell=False) - except Exception: - sys.exit(2) + if not files_list: + print("No files' changes detected. Build is shutting") + sys.exit(0) - if "fatal" not in files_changed: - files_list = files_changed.split() - only_docs_changed = len(list(filter(is_docs, files_list))) == len(files_list) - only_js_changed = len(list(filter(is_js, files_list))) == len(files_list) - only_py_changed = len(list(filter(is_py, files_list))) == len(files_list) + ci_files_changed = any(f for f in files_list if is_ci(f)) + only_docs_changed = len(list(filter(is_docs, files_list))) == len(files_list) + only_js_changed = len(list(filter(is_js, files_list))) == len(files_list) + only_py_changed = len(list(filter(is_py, files_list))) == len(files_list) - if only_docs_changed: - print("Only docs were updated, stopping build process.") - sys.exit(0) + if ci_files_changed: + print("CI related files were updated, running all build processes.") - if only_js_changed and build_type == "server": - print("Only JavaScript code was updated; Stopping Python build process.") - sys.exit(0) + if only_docs_changed: + print("Only docs were updated, stopping build process.") + sys.exit(0) - if only_py_changed and build_type == "ui": - print("Only Python code was updated, stopping Cypress build process.") - sys.exit(0) + if only_js_changed and build_type == "server": + print("Only JavaScript code was updated; Stopping Python build process.") + sys.exit(0) - sys.exit(2) + if only_py_changed and build_type == "ui": + print("Only Python code was updated, stopping Cypress build process.") + sys.exit(0) + + os.system('echo "::set-output name=build::strawberry"') diff --git a/.github/workflows/patch-mariadb-tests.yml b/.github/workflows/patch-mariadb-tests.yml index e8627a01fb..27776fb2f1 100644 --- a/.github/workflows/patch-mariadb-tests.yml +++ b/.github/workflows/patch-mariadb-tests.yml @@ -26,10 +26,22 @@ jobs: with: python-version: 3.7 + - uses: jitterbit/get-changed-files@v1 + id: files + + - name: Check if build should be run + id: check-build + run: | + python "${GITHUB_WORKSPACE}/.github/helper/roulette.py" ${{ steps.files.outputs.all }} + env: + TYPE: "server" + - name: Add to Hosts + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts - name: Cache pip + if: ${{ steps.check-build.outputs.build == 'strawberry' }} uses: actions/cache@v2 with: path: ~/.cache/pip @@ -39,6 +51,7 @@ jobs: ${{ runner.os }}- - name: Cache node modules + if: ${{ steps.check-build.outputs.build == 'strawberry' }} uses: actions/cache@v2 env: cache-name: cache-node-modules @@ -51,10 +64,12 @@ jobs: ${{ runner.os }}- - name: Get yarn cache directory path + if: ${{ steps.check-build.outputs.build == 'strawberry' }} id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn cache dir)" - uses: actions/cache@v2 + if: ${{ steps.check-build.outputs.build == 'strawberry' }} id: yarn-cache with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} @@ -63,6 +78,7 @@ jobs: ${{ runner.os }}-yarn- - name: Install Dependencies + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: bash ${GITHUB_WORKSPACE}/.github/helper/install_dependencies.sh env: BEFORE: ${{ env.GITHUB_EVENT_PATH.before }} @@ -70,12 +86,14 @@ jobs: TYPE: server - name: Install + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh env: DB: mariadb TYPE: server - name: Run Patch Tests + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: | cd ~/frappe-bench/ wget https://frappeframework.com/files/v10-frappe.sql.gz diff --git a/.github/workflows/server-mariadb-tests.yml b/.github/workflows/server-mariadb-tests.yml index 2476102e3d..d6d5097a09 100644 --- a/.github/workflows/server-mariadb-tests.yml +++ b/.github/workflows/server-mariadb-tests.yml @@ -35,17 +35,30 @@ jobs: with: python-version: 3.7 + - uses: jitterbit/get-changed-files@v1 + id: files + + - name: Check if build should be run + id: check-build + run: | + python "${GITHUB_WORKSPACE}/.github/helper/roulette.py" ${{ steps.files.outputs.all }} + env: + TYPE: "server" + - uses: actions/setup-node@v2 + if: ${{ steps.check-build.outputs.build == 'strawberry' }} with: node-version: 14 check-latest: true - name: Add to Hosts + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: | echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts echo "127.0.0.1 test_site_producer" | sudo tee -a /etc/hosts - name: Cache pip + if: ${{ steps.check-build.outputs.build == 'strawberry' }} uses: actions/cache@v2 with: path: ~/.cache/pip @@ -55,6 +68,7 @@ jobs: ${{ runner.os }}- - name: Cache node modules + if: ${{ steps.check-build.outputs.build == 'strawberry' }} uses: actions/cache@v2 env: cache-name: cache-node-modules @@ -67,10 +81,12 @@ jobs: ${{ runner.os }}- - name: Get yarn cache directory path + if: ${{ steps.check-build.outputs.build == 'strawberry' }} id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn cache dir)" - uses: actions/cache@v2 + if: ${{ steps.check-build.outputs.build == 'strawberry' }} id: yarn-cache with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} @@ -79,6 +95,7 @@ jobs: ${{ runner.os }}-yarn- - name: Install Dependencies + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: bash ${GITHUB_WORKSPACE}/.github/helper/install_dependencies.sh env: BEFORE: ${{ env.GITHUB_EVENT_PATH.before }} @@ -86,18 +103,22 @@ jobs: TYPE: server - name: Install + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh env: DB: mariadb TYPE: server - name: Run Tests + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --use-orchestrator --with-coverage env: CI_BUILD_ID: ${{ github.run_id }} ORCHESTRATOR_URL: http://test-orchestrator.frappe.io - name: Upload Coverage Data + if: ${{ steps.check-build.outputs.build == 'strawberry' }} + id: upload-coverage-data run: | cp ~/frappe-bench/sites/.coverage ${GITHUB_WORKSPACE} cd ${GITHUB_WORKSPACE} @@ -114,6 +135,7 @@ jobs: coveralls: name: Coverage Wrap Up needs: test + if: ${{ needs.test.steps.check-build.build == 'strawberry' }} container: python:3-slim runs-on: ubuntu-18.04 steps: diff --git a/.github/workflows/server-postgres-tests.yml b/.github/workflows/server-postgres-tests.yml index 4325eebaad..b2a283340c 100644 --- a/.github/workflows/server-postgres-tests.yml +++ b/.github/workflows/server-postgres-tests.yml @@ -37,17 +37,30 @@ jobs: with: python-version: 3.7 + - uses: jitterbit/get-changed-files@v1 + id: files + + - name: Check if build should be run + id: check-build + run: | + python "${GITHUB_WORKSPACE}/.github/helper/roulette.py" ${{ steps.files.outputs.all }} + env: + TYPE: "server" + - uses: actions/setup-node@v2 + if: ${{ steps.check-build.outputs.build == 'strawberry' }} with: node-version: '14' check-latest: true - name: Add to Hosts + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: | echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts echo "127.0.0.1 test_site_producer" | sudo tee -a /etc/hosts - name: Cache pip + if: ${{ steps.check-build.outputs.build == 'strawberry' }} uses: actions/cache@v2 with: path: ~/.cache/pip @@ -57,6 +70,7 @@ jobs: ${{ runner.os }}- - name: Cache node modules + if: ${{ steps.check-build.outputs.build == 'strawberry' }} uses: actions/cache@v2 env: cache-name: cache-node-modules @@ -69,10 +83,12 @@ jobs: ${{ runner.os }}- - name: Get yarn cache directory path + if: ${{ steps.check-build.outputs.build == 'strawberry' }} id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn cache dir)" - uses: actions/cache@v2 + if: ${{ steps.check-build.outputs.build == 'strawberry' }} id: yarn-cache with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} @@ -81,6 +97,7 @@ jobs: ${{ runner.os }}-yarn- - name: Install Dependencies + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: bash ${GITHUB_WORKSPACE}/.github/helper/install_dependencies.sh env: BEFORE: ${{ env.GITHUB_EVENT_PATH.before }} @@ -88,12 +105,14 @@ jobs: TYPE: server - name: Install + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh env: DB: postgres TYPE: server - name: Run Tests + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --use-orchestrator env: CI_BUILD_ID: ${{ github.run_id }} diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index f342c0709e..af598d6c40 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -35,17 +35,30 @@ jobs: with: python-version: 3.7 + - uses: jitterbit/get-changed-files@v1 + id: files + + - name: Check if build should be run + id: check-build + run: | + python "${GITHUB_WORKSPACE}/.github/helper/roulette.py" ${{ steps.files.outputs.all }} + env: + TYPE: "ui" + - uses: actions/setup-node@v2 + if: ${{ steps.check-build.outputs.build == 'strawberry' }} with: node-version: 14 check-latest: true - name: Add to Hosts + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: | echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts echo "127.0.0.1 test_site_producer" | sudo tee -a /etc/hosts - name: Cache pip + if: ${{ steps.check-build.outputs.build == 'strawberry' }} uses: actions/cache@v2 with: path: ~/.cache/pip @@ -55,6 +68,7 @@ jobs: ${{ runner.os }}- - name: Cache node modules + if: ${{ steps.check-build.outputs.build == 'strawberry' }} uses: actions/cache@v2 env: cache-name: cache-node-modules @@ -67,10 +81,12 @@ jobs: ${{ runner.os }}- - name: Get yarn cache directory path + if: ${{ steps.check-build.outputs.build == 'strawberry' }} id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn cache dir)" - uses: actions/cache@v2 + if: ${{ steps.check-build.outputs.build == 'strawberry' }} id: yarn-cache with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} @@ -79,6 +95,7 @@ jobs: ${{ runner.os }}-yarn- - name: Cache cypress binary + if: ${{ steps.check-build.outputs.build == 'strawberry' }} uses: actions/cache@v2 with: path: ~/.cache @@ -88,6 +105,7 @@ jobs: ${{ runner.os }}- - name: Install Dependencies + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: bash ${GITHUB_WORKSPACE}/.github/helper/install_dependencies.sh env: BEFORE: ${{ env.GITHUB_EVENT_PATH.before }} @@ -95,15 +113,18 @@ jobs: TYPE: ui - name: Install + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh env: DB: mariadb TYPE: ui - name: Site Setup + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: cd ~/frappe-bench/ && bench --site test_site execute frappe.utils.install.complete_setup_wizard - name: UI Tests + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: cd ~/frappe-bench/ && bench --site test_site run-ui-tests frappe --headless --parallel --ci-build-id $GITHUB_RUN_ID env: CYPRESS_RECORD_KEY: 4a48f41c-11b3-425b-aa88-c58048fa69eb From 32759cfc909628ccfd246cebdd7930a0c4359335 Mon Sep 17 00:00:00 2001 From: ChillarAnand Date: Wed, 25 Aug 2021 08:02:02 +0530 Subject: [PATCH 148/244] fix: Passed failfast flag to unit test runner in all scenarios --- frappe/commands/utils.py | 2 +- frappe/test_runner.py | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index f2395ae490..2092071198 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -524,7 +524,7 @@ def console(context): @click.option('--skip-test-records', is_flag=True, default=False, help="Don't create test records") @click.option('--skip-before-tests', is_flag=True, default=False, help="Don't run before tests hook") @click.option('--junit-xml-output', help="Destination file path for junit xml report") -@click.option('--failfast', is_flag=True, default=False) +@click.option('--failfast', is_flag=True, default=False, help="Stop the test run on the first error or failure") @pass_context def run_tests(context, app=None, module=None, doctype=None, test=(), profile=False, coverage=False, junit_xml_output=False, ui_tests = False, doctype_list_path=None, diff --git a/frappe/test_runner.py b/frappe/test_runner.py index 8112362f34..ed46c9c071 100644 --- a/frappe/test_runner.py +++ b/frappe/test_runner.py @@ -50,7 +50,7 @@ def main(app=None, module=None, doctype=None, verbose=False, tests=(), frappe.connect() # if not frappe.conf.get("db_name").startswith("test_"): - # raise Exception, 'db_name must start with "test_"' + # raise Exception, 'db_name must start with "test_"' # workaround! since there is no separate test db frappe.clear_cache() @@ -65,9 +65,9 @@ def main(app=None, module=None, doctype=None, verbose=False, tests=(), frappe.get_attr(fn)() if doctype: - ret = run_tests_for_doctype(doctype, verbose, tests, force, profile, junit_xml_output=junit_xml_output) + ret = run_tests_for_doctype(doctype, verbose, tests, force, profile, failfast=failfast, junit_xml_output=junit_xml_output) elif module: - ret = run_tests_for_module(module, verbose, tests, profile, junit_xml_output=junit_xml_output) + ret = run_tests_for_module(module, verbose, tests, profile, failfast=failfast, junit_xml_output=junit_xml_output) else: ret = run_all_tests(app, verbose, profile, ui_tests, failfast=failfast, junit_xml_output=junit_xml_output) @@ -150,7 +150,7 @@ def run_all_tests(app=None, verbose=False, profile=False, ui_tests=False, failfa return out -def run_tests_for_doctype(doctypes, verbose=False, tests=(), force=False, profile=False, junit_xml_output=False): +def run_tests_for_doctype(doctypes, verbose=False, tests=(), force=False, profile=False, failfast=False, junit_xml_output=False): modules = [] if not isinstance(doctypes, (list, tuple)): doctypes = [doctypes] @@ -168,18 +168,18 @@ def run_tests_for_doctype(doctypes, verbose=False, tests=(), force=False, profil make_test_records(doctype, verbose=verbose, force=force) modules.append(importlib.import_module(test_module)) - return _run_unittest(modules, verbose=verbose, tests=tests, profile=profile, junit_xml_output=junit_xml_output) + return _run_unittest(modules, verbose=verbose, tests=tests, profile=profile, failfast=failfast, junit_xml_output=junit_xml_output) -def run_tests_for_module(module, verbose=False, tests=(), profile=False, junit_xml_output=False): +def run_tests_for_module(module, verbose=False, tests=(), profile=False, failfast=False, junit_xml_output=False): module = importlib.import_module(module) if hasattr(module, "test_dependencies"): for doctype in module.test_dependencies: make_test_records(doctype, verbose=verbose) frappe.db.commit() - return _run_unittest(module, verbose=verbose, tests=tests, profile=profile, junit_xml_output=junit_xml_output) + return _run_unittest(module, verbose=verbose, tests=tests, profile=profile, failfast=failfast, junit_xml_output=junit_xml_output) -def _run_unittest(modules, verbose=False, tests=(), profile=False, junit_xml_output=False): +def _run_unittest(modules, verbose=False, tests=(), profile=False, failfast=False, junit_xml_output=False): frappe.db.begin() test_suite = unittest.TestSuite() @@ -198,9 +198,9 @@ def _run_unittest(modules, verbose=False, tests=(), profile=False, junit_xml_out test_suite.addTest(module_test_cases) if junit_xml_output: - runner = unittest_runner(verbosity=1+(verbose and 1 or 0)) + runner = unittest_runner(verbosity=1+(verbose and 1 or 0), failfast=failfast) else: - runner = unittest_runner(resultclass=TimeLoggingTestResult, verbosity=1+(verbose and 1 or 0)) + runner = unittest_runner(resultclass=TimeLoggingTestResult, verbosity=1+(verbose and 1 or 0), failfast=failfast) if profile: pr = cProfile.Profile() From c07298648ac6ea6b1613309e68f3c7a5d6bde419 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Wed, 25 Aug 2021 10:49:37 +0530 Subject: [PATCH 149/244] test: Blur the phone_nos field to enable validation for the field --- cypress/integration/form_tour.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cypress/integration/form_tour.js b/cypress/integration/form_tour.js index d2d39679a8..ab7ada9034 100644 --- a/cypress/integration/form_tour.js +++ b/cypress/integration/form_tour.js @@ -20,10 +20,10 @@ context('Form Tour', () => { it('navigates a form tour', () => { open_test_form_tour(); - cy.get('#driver-popover-item').should('be.visible'); + cy.get('.frappe-driver').should('be.visible'); cy.get('.frappe-control[data-fieldname="first_name"]').as('first_name'); cy.get('@first_name').should('have.class', 'driver-highlighted-element'); - cy.get('#driver-popover-item').findByRole('button', {name: 'Next'}).as('next_btn'); + cy.get('.frappe-driver').findByRole('button', {name: 'Next'}).as('next_btn'); // next btn shouldn't move to next step, if first name is not entered cy.get('@next_btn').click(); @@ -68,13 +68,13 @@ context('Form Tour', () => { cy.get('.grid-row-open .frappe-control[data-fieldname="phone"]').as('phone'); cy.get('@phone').should('have.class', 'driver-highlighted-element'); // enter value in a table field - cy.fill_table_field('phone_nos', '1', 'phone', '1234567890'); + let field = cy.fill_table_field('phone_nos', '1', 'phone', '1234567890'); + field.blur(); // move to collapse row step cy.wait(500); - cy.get('@next_btn').click(); + cy.get('.driver-popover-title').contains('Test Title 4').siblings().get('@next_btn').click(); cy.wait(500); - // collapse row cy.get('.grid-row-open .grid-collapse-row').click(); cy.wait(500); @@ -82,7 +82,7 @@ context('Form Tour', () => { // assert save btn is highlighted cy.get('.primary-action').should('have.class', 'driver-highlighted-element'); cy.wait(500); - cy.get('#driver-popover-item').findByRole('button', {name: 'Save'}).should('be.visible'); + cy.get('.frappe-driver').findByRole('button', {name: 'Save'}).should('be.visible'); }); }); From 0611c1698fd6bcc5444236df35dc05afb181f9ac Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 25 Aug 2021 11:30:51 +0530 Subject: [PATCH 150/244] refactor: File.optimize_file Simplified method logic --- frappe/core/doctype/file/file.py | 33 ++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index f279a04a44..18aae13799 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -602,23 +602,25 @@ class File(Document): content_type = mimetypes.guess_type(self.file_name)[0] is_local_image = content_type.startswith('image/') and self.file_size > 0 is_svg = content_type == 'image/svg+xml' - if is_local_image and not is_svg: - content = self.get_content() - optimized_content = optimize_image(content, content_type) - file_path = get_files_path(is_private=self.is_private) - file_path = os.path.join(file_path.encode('utf-8'), self.file_name.encode('utf-8')) - with open(file_path, 'wb+') as f: - f.write(optimized_content) - - self.file_size = len(optimized_content) - self.content_hash = get_content_hash(optimized_content) - self.save() - elif is_svg: - raise TypeError('Optimization of SVG images is not supported') - else: + if not is_local_image: raise NotImplementedError('Only local image files can be optimized') + if is_svg: + raise TypeError('Optimization of SVG images is not supported') + + content = self.get_content() + optimized_content = optimize_image(content, content_type) + file_path = get_files_path(self.file_name, is_private=self.is_private) + + with open(file_path, 'wb+') as f: + f.write(optimized_content) + + self.file_size = len(optimized_content) + self.content_hash = get_content_hash(optimized_content) + self.save() + + def on_doctype_update(): frappe.db.add_index("File", ["attached_to_doctype", "attached_to_name"]) @@ -967,8 +969,7 @@ def unzip_file(name): @frappe.whitelist() def optimize_saved_image(doc_name): - file_doc = frappe.get_doc('File', doc_name) - file_doc.optimize_file() + frappe.get_doc('File', doc_name).optimize_file() @frappe.whitelist() def get_attached_images(doctype, names): From bc596e968159024ec956d30df6be92c5b31c7b19 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 25 Aug 2021 11:45:00 +0530 Subject: [PATCH 151/244] fix: Revert file on disk if rolledback --- frappe/core/doctype/file/file.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index 18aae13799..0c2832cdbd 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -321,8 +321,16 @@ class File(Document): self.delete_file_data_content(only_thumbnail=True) def on_rollback(self): - self.flags.on_rollback = True - self.on_trash() + # if original_content flag is set, this rollback should revert the file to its original state + if self.flags.original_content: + file_path = self.get_full_path() + with open(file_path, "wb+") as f: + f.write(self.flags.original_content) + + # following condition is only executed when an insert has been rolledback + else: + self.flags.on_rollback = True + self.on_trash() def unzip(self): '''Unzip current file and replace it by its children''' @@ -610,8 +618,8 @@ class File(Document): raise TypeError('Optimization of SVG images is not supported') content = self.get_content() + file_path = self.get_full_path() optimized_content = optimize_image(content, content_type) - file_path = get_files_path(self.file_name, is_private=self.is_private) with open(file_path, 'wb+') as f: f.write(optimized_content) @@ -620,6 +628,10 @@ class File(Document): self.content_hash = get_content_hash(optimized_content) self.save() + # if rolledback, revert back to original + self.flags.original_content = content + frappe.local.rollback_observers.append(self) + def on_doctype_update(): frappe.db.add_index("File", ["attached_to_doctype", "attached_to_name"]) From 75ff78e6bd1beefc9e9df8d2772f9ce1a7cfdb53 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 25 Aug 2021 12:03:33 +0530 Subject: [PATCH 152/244] fix(File): Remove extra API endpoint Got rid of frappe.core.doctype.file.file.optimize_saved_image in favour of whitelisted Document Action File.optimize_file --- frappe/core/doctype/file/file.js | 12 +++--------- frappe/core/doctype/file/file.py | 3 --- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/frappe/core/doctype/file/file.js b/frappe/core/doctype/file/file.js index bc0cc17553..202903f1ab 100644 --- a/frappe/core/doctype/file/file.js +++ b/frappe/core/doctype/file/file.js @@ -29,15 +29,9 @@ frappe.ui.form.on("File", "refresh", function(frm) { if (is_optimizable) { frm.add_custom_button(__("Optimize"), function() { frappe.show_alert(__("Optimizing image...")); - frappe.call({ - method: "frappe.core.doctype.file.file.optimize_saved_image", - args: { - doc_name: frm.doc.name, - }, - callback: function() { - frappe.show_alert(__("Image optimized")); - frappe.set_route("List", "File"); - } + frm.call("optimize_file").then(() => { + frappe.show_alert(__("Image optimized")); + frappe.set_route("List", "File"); }); }); } diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index 0c2832cdbd..369179eece 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -979,9 +979,6 @@ def unzip_file(name): files = file_obj.unzip() return len(files) -@frappe.whitelist() -def optimize_saved_image(doc_name): - frappe.get_doc('File', doc_name).optimize_file() @frappe.whitelist() def get_attached_images(doctype, names): From 004891237e77fb95b64a7f8b535774b30b3b1b02 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Wed, 25 Aug 2021 12:34:48 +0530 Subject: [PATCH 153/244] test: Fix flaky timeline_email tests --- cypress/integration/timeline_email.js | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/cypress/integration/timeline_email.js b/cypress/integration/timeline_email.js index e5b3ebeb7c..82af24e822 100644 --- a/cypress/integration/timeline_email.js +++ b/cypress/integration/timeline_email.js @@ -8,14 +8,13 @@ context('Timeline Email', () => { 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.get('.custom-actions:visible > .btn').contains("Edit in full page").click({delay: 500}); + cy.fill_field("description", "Test ToDo", "Text Editor"); 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(); + cy.get('.list-row > .level-left > .list-subject').eq(0).click(); //Creating a new email cy.get('.timeline-actions > .btn').click(); @@ -47,7 +46,7 @@ context('Timeline Email', () => { //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(); + cy.get('.modal-footer:visible > .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'); @@ -55,17 +54,17 @@ context('Timeline Email', () => { //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.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(); + cy.get('.modal-header:visible > .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(); + cy.get('.menu-btn-group:visible > .btn').click(); + cy.get('.menu-btn-group:visible > .dropdown-menu > li > .dropdown-item').contains('Delete').click(); + cy.get('.modal-footer:visible > .standard-actions > .btn-primary').click(); }); }); From e01ef7ff52eeda80c267258ae51271a5f412b086 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 25 Aug 2021 12:40:44 +0530 Subject: [PATCH 154/244] ci(roulette): Use GitHub REST to figure out changed files --- .github/helper/roulette.py | 20 +++++++++++++------- .github/workflows/patch-mariadb-tests.yml | 6 ++---- .github/workflows/server-mariadb-tests.yml | 6 ++---- .github/workflows/server-postgres-tests.yml | 6 ++---- .github/workflows/ui-tests.yml | 6 ++---- 5 files changed, 21 insertions(+), 23 deletions(-) diff --git a/.github/helper/roulette.py b/.github/helper/roulette.py index ce4c8ed633..311f15c9db 100644 --- a/.github/helper/roulette.py +++ b/.github/helper/roulette.py @@ -1,5 +1,6 @@ import os import re +import requests import shlex import subprocess import sys @@ -14,19 +15,24 @@ def is_py(file): return file.endswith("py") def is_ci(file): - return file.endswith("yml") or ".github" in file + return ".github" in file -def is_js(file): - return file.endswith("js") +def is_frontend_code(file): + return file.endswith((".css", ".scss", ".less", ".sass", ".styl", ".js", ".ts")) def is_docs(file): - regex = re.compile(r'\.(md|png|jpg|jpeg)$|^.github|LICENSE') + regex = re.compile(r'\.(md|png|jpg|jpeg|csv)$|^.github|LICENSE') return bool(regex.search(file)) if __name__ == "__main__": files_list = sys.argv[1:] build_type = os.environ.get("TYPE") + pr_number = os.environ.get("PR_NUMBER") + + if not files_list and pr_number: + res = requests.get(f"https://api.github.com/repos/frappe/frappe/pulls/{pr_number}/files") + files_list = [f["filename"] for f in res.json()] if not files_list: print("No files' changes detected. Build is shutting") @@ -34,7 +40,7 @@ if __name__ == "__main__": ci_files_changed = any(f for f in files_list if is_ci(f)) only_docs_changed = len(list(filter(is_docs, files_list))) == len(files_list) - only_js_changed = len(list(filter(is_js, files_list))) == len(files_list) + only_frontend_code_changed = len(list(filter(is_frontend_code, files_list))) == len(files_list) only_py_changed = len(list(filter(is_py, files_list))) == len(files_list) if ci_files_changed: @@ -44,8 +50,8 @@ if __name__ == "__main__": print("Only docs were updated, stopping build process.") sys.exit(0) - if only_js_changed and build_type == "server": - print("Only JavaScript code was updated; Stopping Python build process.") + if only_frontend_code_changed and build_type == "server": + print("Only Frontend code was updated; Stopping Python build process.") sys.exit(0) if only_py_changed and build_type == "ui": diff --git a/.github/workflows/patch-mariadb-tests.yml b/.github/workflows/patch-mariadb-tests.yml index 27776fb2f1..d0de566e9d 100644 --- a/.github/workflows/patch-mariadb-tests.yml +++ b/.github/workflows/patch-mariadb-tests.yml @@ -26,15 +26,13 @@ jobs: with: python-version: 3.7 - - uses: jitterbit/get-changed-files@v1 - id: files - - name: Check if build should be run id: check-build run: | - python "${GITHUB_WORKSPACE}/.github/helper/roulette.py" ${{ steps.files.outputs.all }} + python "${GITHUB_WORKSPACE}/.github/helper/roulette.py" env: TYPE: "server" + PR_NUMBER: ${{ github.event.number }} - name: Add to Hosts if: ${{ steps.check-build.outputs.build == 'strawberry' }} diff --git a/.github/workflows/server-mariadb-tests.yml b/.github/workflows/server-mariadb-tests.yml index d6d5097a09..da9724c6bd 100644 --- a/.github/workflows/server-mariadb-tests.yml +++ b/.github/workflows/server-mariadb-tests.yml @@ -35,15 +35,13 @@ jobs: with: python-version: 3.7 - - uses: jitterbit/get-changed-files@v1 - id: files - - name: Check if build should be run id: check-build run: | - python "${GITHUB_WORKSPACE}/.github/helper/roulette.py" ${{ steps.files.outputs.all }} + python "${GITHUB_WORKSPACE}/.github/helper/roulette.py" env: TYPE: "server" + PR_NUMBER: ${{ github.event.number }} - uses: actions/setup-node@v2 if: ${{ steps.check-build.outputs.build == 'strawberry' }} diff --git a/.github/workflows/server-postgres-tests.yml b/.github/workflows/server-postgres-tests.yml index b2a283340c..d7658bc1ba 100644 --- a/.github/workflows/server-postgres-tests.yml +++ b/.github/workflows/server-postgres-tests.yml @@ -37,15 +37,13 @@ jobs: with: python-version: 3.7 - - uses: jitterbit/get-changed-files@v1 - id: files - - name: Check if build should be run id: check-build run: | - python "${GITHUB_WORKSPACE}/.github/helper/roulette.py" ${{ steps.files.outputs.all }} + python "${GITHUB_WORKSPACE}/.github/helper/roulette.py" env: TYPE: "server" + PR_NUMBER: ${{ github.event.number }} - uses: actions/setup-node@v2 if: ${{ steps.check-build.outputs.build == 'strawberry' }} diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index af598d6c40..90c72e7018 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -35,15 +35,13 @@ jobs: with: python-version: 3.7 - - uses: jitterbit/get-changed-files@v1 - id: files - - name: Check if build should be run id: check-build run: | - python "${GITHUB_WORKSPACE}/.github/helper/roulette.py" ${{ steps.files.outputs.all }} + python "${GITHUB_WORKSPACE}/.github/helper/roulette.py" env: TYPE: "ui" + PR_NUMBER: ${{ github.event.number }} - uses: actions/setup-node@v2 if: ${{ steps.check-build.outputs.build == 'strawberry' }} From 2865b35673a3bc3158ceed3c784f19e7cae2b9f8 Mon Sep 17 00:00:00 2001 From: shariquerik Date: Wed, 25 Aug 2021 12:43:03 +0530 Subject: [PATCH 155/244] patch: set title as label if extends is empty --- frappe/patches/v14_0/update_workspace2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/patches/v14_0/update_workspace2.py b/frappe/patches/v14_0/update_workspace2.py index 2d7eb4cc76..1a56310a6c 100644 --- a/frappe/patches/v14_0/update_workspace2.py +++ b/frappe/patches/v14_0/update_workspace2.py @@ -54,7 +54,7 @@ def update_wspace(doc, seq, content): doc.sequence_id = seq + 1 doc.content = json.dumps(content) doc.public = 0 - doc.title = doc.extends + doc.title = doc.extends or doc.label doc.extends = '' doc.category = '' doc.onboarding = '' From 4a468c14d3eacba8274842a8cfa99a7c316a479b Mon Sep 17 00:00:00 2001 From: shariquerik Date: Wed, 25 Aug 2021 13:02:27 +0530 Subject: [PATCH 156/244] fix: updated pah --- frappe/patches.txt | 2 +- frappe/patches/v14_0/update_workspace2.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/patches.txt b/frappe/patches.txt index 87919b0247..41ca1a1724 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -182,4 +182,4 @@ frappe.patches.v13_0.jinja_hook frappe.patches.v13_0.update_notification_channel_if_empty frappe.patches.v14_0.drop_data_import_legacy frappe.patches.v14_0.rename_cancelled_documents -frappe.patches.v14_0.update_workspace2 +frappe.patches.v14_0.update_workspace2 # 25.08.2021 diff --git a/frappe/patches/v14_0/update_workspace2.py b/frappe/patches/v14_0/update_workspace2.py index 1a56310a6c..4974919049 100644 --- a/frappe/patches/v14_0/update_workspace2.py +++ b/frappe/patches/v14_0/update_workspace2.py @@ -50,7 +50,7 @@ def create_content(doc): return content def update_wspace(doc, seq, content): - if not doc.is_standard and not doc.public: + if not doc.title and not content and not doc.is_standard and not doc.public: doc.sequence_id = seq + 1 doc.content = json.dumps(content) doc.public = 0 From 82c354e9187140eaf81b722086edb00cea946ec4 Mon Sep 17 00:00:00 2001 From: shariquerik Date: Wed, 25 Aug 2021 13:03:40 +0530 Subject: [PATCH 157/244] fix: updated patch --- frappe/patches/v14_0/update_workspace2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/patches/v14_0/update_workspace2.py b/frappe/patches/v14_0/update_workspace2.py index 4974919049..c212faee76 100644 --- a/frappe/patches/v14_0/update_workspace2.py +++ b/frappe/patches/v14_0/update_workspace2.py @@ -50,7 +50,7 @@ def create_content(doc): return content def update_wspace(doc, seq, content): - if not doc.title and not content and not doc.is_standard and not doc.public: + if not doc.title and not doc.content and not doc.is_standard and not doc.public: doc.sequence_id = seq + 1 doc.content = json.dumps(content) doc.public = 0 From fe060bda0ee83843fa7af65958fe099337ffeec0 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 25 Aug 2021 13:25:40 +0530 Subject: [PATCH 158/244] fix: Use urllib instead of requests Simply because "too much effort" to add another library pfft --- .github/helper/roulette.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/helper/roulette.py b/.github/helper/roulette.py index 311f15c9db..002f1e1017 100644 --- a/.github/helper/roulette.py +++ b/.github/helper/roulette.py @@ -1,11 +1,18 @@ +import json import os import re -import requests import shlex import subprocess import sys +import urllib.request +def get_files_list(pr_number): + req = urllib.request.Request(f"https://api.github.com/repos/frappe/frappe/pulls/{pr_number}/files") + res = urllib.request.urlopen(req) + dump = json.loads(res.read().decode('utf8')) + return [change["filename"] for change in dump] + def get_output(command, shell=True): print(command) command = shlex.split(command) @@ -31,8 +38,7 @@ if __name__ == "__main__": pr_number = os.environ.get("PR_NUMBER") if not files_list and pr_number: - res = requests.get(f"https://api.github.com/repos/frappe/frappe/pulls/{pr_number}/files") - files_list = [f["filename"] for f in res.json()] + files_list = get_files_list(pr_number=pr_number) if not files_list: print("No files' changes detected. Build is shutting") From e2585cbc7e5aa1921816a3cc54e16dac791d77e2 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 25 Aug 2021 13:46:09 +0530 Subject: [PATCH 159/244] ci(roulette): Add support for running on forks --- .github/helper/roulette.py | 7 ++++--- .github/workflows/patch-mariadb-tests.yml | 1 + .github/workflows/server-mariadb-tests.yml | 1 + .github/workflows/server-postgres-tests.yml | 1 + .github/workflows/ui-tests.yml | 1 + 5 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/helper/roulette.py b/.github/helper/roulette.py index 002f1e1017..5c934bc697 100644 --- a/.github/helper/roulette.py +++ b/.github/helper/roulette.py @@ -7,8 +7,8 @@ import sys import urllib.request -def get_files_list(pr_number): - req = urllib.request.Request(f"https://api.github.com/repos/frappe/frappe/pulls/{pr_number}/files") +def get_files_list(pr_number, repo="frappe/frappe"): + req = urllib.request.Request(f"https://api.github.com/repos/{repo}/pulls/{pr_number}/files") res = urllib.request.urlopen(req) dump = json.loads(res.read().decode('utf8')) return [change["filename"] for change in dump] @@ -36,9 +36,10 @@ if __name__ == "__main__": files_list = sys.argv[1:] build_type = os.environ.get("TYPE") pr_number = os.environ.get("PR_NUMBER") + repo = os.environ.get("REPO_NAME") if not files_list and pr_number: - files_list = get_files_list(pr_number=pr_number) + files_list = get_files_list(pr_number=pr_number, repo=repo) if not files_list: print("No files' changes detected. Build is shutting") diff --git a/.github/workflows/patch-mariadb-tests.yml b/.github/workflows/patch-mariadb-tests.yml index d0de566e9d..6ccc059afb 100644 --- a/.github/workflows/patch-mariadb-tests.yml +++ b/.github/workflows/patch-mariadb-tests.yml @@ -33,6 +33,7 @@ jobs: env: TYPE: "server" PR_NUMBER: ${{ github.event.number }} + REPO_NAME: ${{ github.repository }} - name: Add to Hosts if: ${{ steps.check-build.outputs.build == 'strawberry' }} diff --git a/.github/workflows/server-mariadb-tests.yml b/.github/workflows/server-mariadb-tests.yml index da9724c6bd..65b6666678 100644 --- a/.github/workflows/server-mariadb-tests.yml +++ b/.github/workflows/server-mariadb-tests.yml @@ -42,6 +42,7 @@ jobs: env: TYPE: "server" PR_NUMBER: ${{ github.event.number }} + REPO_NAME: ${{ github.repository }} - uses: actions/setup-node@v2 if: ${{ steps.check-build.outputs.build == 'strawberry' }} diff --git a/.github/workflows/server-postgres-tests.yml b/.github/workflows/server-postgres-tests.yml index d7658bc1ba..17a0f6f94f 100644 --- a/.github/workflows/server-postgres-tests.yml +++ b/.github/workflows/server-postgres-tests.yml @@ -44,6 +44,7 @@ jobs: env: TYPE: "server" PR_NUMBER: ${{ github.event.number }} + REPO_NAME: ${{ github.repository }} - uses: actions/setup-node@v2 if: ${{ steps.check-build.outputs.build == 'strawberry' }} diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 90c72e7018..d56433c216 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -42,6 +42,7 @@ jobs: env: TYPE: "ui" PR_NUMBER: ${{ github.event.number }} + REPO_NAME: ${{ github.repository }} - uses: actions/setup-node@v2 if: ${{ steps.check-build.outputs.build == 'strawberry' }} From 525923d21383f3e03ee645c07fe6652415730ac2 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 25 Aug 2021 15:23:00 +0530 Subject: [PATCH 160/244] fix(roulette): Run only one conditional block --- .github/helper/roulette.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/helper/roulette.py b/.github/helper/roulette.py index 5c934bc697..d00c47d8d7 100644 --- a/.github/helper/roulette.py +++ b/.github/helper/roulette.py @@ -53,15 +53,15 @@ if __name__ == "__main__": if ci_files_changed: print("CI related files were updated, running all build processes.") - if only_docs_changed: + elif only_docs_changed: print("Only docs were updated, stopping build process.") sys.exit(0) - if only_frontend_code_changed and build_type == "server": + elif only_frontend_code_changed and build_type == "server": print("Only Frontend code was updated; Stopping Python build process.") sys.exit(0) - if only_py_changed and build_type == "ui": + elif only_py_changed and build_type == "ui": print("Only Python code was updated, stopping Cypress build process.") sys.exit(0) From 586835a833b5cbc01ff4ef01986d8c985bab5e1c Mon Sep 17 00:00:00 2001 From: Steve Date: Wed, 25 Aug 2021 06:09:29 -0400 Subject: [PATCH 161/244] fix: Duplicate name columns in list_view when no title field defined (#14006) * fix duplicate name columns in list_view when no title field defined * refactor: Simplify code Co-authored-by: Suraj Shetty <13928957+surajshetty3416@users.noreply.github.com> --- frappe/public/js/frappe/list/list_view.js | 1 + 1 file changed, 1 insertion(+) diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js index 8a0e43c8f3..3cdecd8ddb 100644 --- a/frappe/public/js/frappe/list/list_view.js +++ b/frappe/public/js/frappe/list/list_view.js @@ -367,6 +367,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { if ( !this.settings.hide_name_column && + this.meta.title_field && this.meta.title_field !== 'name' ) { this.columns.push({ From 3e1d9dcbe392b962905e0c18e7d11e7abb434e66 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 25 Aug 2021 15:40:30 +0530 Subject: [PATCH 162/244] chore(workspace): Comment out flaky test --- .../desk/doctype/workspace/test_workspace.py | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/frappe/desk/doctype/workspace/test_workspace.py b/frappe/desk/doctype/workspace/test_workspace.py index 8aa3d57adf..f13a136c20 100644 --- a/frappe/desk/doctype/workspace/test_workspace.py +++ b/frappe/desk/doctype/workspace/test_workspace.py @@ -12,19 +12,20 @@ class TestWorkspace(unittest.TestCase): frappe.db.delete("DocType", {"module": "Test Module"}) frappe.delete_doc("Module Def", "Test Module") - def test_workspace_with_cards_specific_to_a_country(self): - workspace = create_workspace() - insert_card(workspace, "Card Label 1", "DocType 1", "DocType 2", "France") - insert_card(workspace, "Card Label 2", "DocType A", "DocType B") + # TODO: FIX ME - flaky test!!! + # def test_workspace_with_cards_specific_to_a_country(self): + # workspace = create_workspace() + # insert_card(workspace, "Card Label 1", "DocType 1", "DocType 2", "France") + # insert_card(workspace, "Card Label 2", "DocType A", "DocType B") - workspace.insert(ignore_if_duplicate = True) + # workspace.insert(ignore_if_duplicate = True) - cards = workspace.get_link_groups() + # cards = workspace.get_link_groups() - if frappe.get_system_settings('country') == "France": - self.assertEqual(len(cards), 2) - else: - self.assertEqual(len(cards), 1) + # if frappe.get_system_settings('country') == "France": + # self.assertEqual(len(cards), 2) + # else: + # self.assertEqual(len(cards), 1) def create_module(module_name): module = frappe.get_doc({ @@ -91,4 +92,4 @@ def create_doctype(doctype_name, module): 'permissions': [ {'role': 'System Manager'} ] - }).insert(ignore_if_duplicate = True) \ No newline at end of file + }).insert(ignore_if_duplicate = True) From 383c657b9f79c5ee390d37ae3e01539b01a1ac4e Mon Sep 17 00:00:00 2001 From: shariquerik Date: Wed, 25 Aug 2021 16:21:59 +0530 Subject: [PATCH 163/244] fix: Total Row is hidden in Query Report & Script Report --- frappe/public/js/frappe/views/reports/query_report.js | 4 ++++ frappe/public/scss/desk/report.scss | 5 +++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/frappe/public/js/frappe/views/reports/query_report.js b/frappe/public/js/frappe/views/reports/query_report.js index f13f683d79..ea0d904d0f 100644 --- a/frappe/public/js/frappe/views/reports/query_report.js +++ b/frappe/public/js/frappe/views/reports/query_report.js @@ -854,6 +854,10 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { } }; + if (this.raw_data.add_total_row) { + this.page.body[0].style.setProperty('--report-total-height', '310px'); + } + if (this.report_settings.get_datatable_options) { datatable_options = this.report_settings.get_datatable_options(datatable_options); } diff --git a/frappe/public/scss/desk/report.scss b/frappe/public/scss/desk/report.scss index e2aae431aa..2389a4f8f6 100644 --- a/frappe/public/scss/desk/report.scss +++ b/frappe/public/scss/desk/report.scss @@ -84,8 +84,9 @@ margin-bottom: 10px; } -.layout-main-section .frappe-card { +.layout-main-section { --report-filter-height: 0px; + --report-total-height: 275px; } .report-wrapper { @@ -95,7 +96,7 @@ height: calc(100vh - var(--report-filter-height) - 205px); .dt-scrollable { - height: calc(100vh - var(--report-filter-height) - 275px); + height: calc(100vh - var(--report-filter-height) - var(--report-total-height)); } } } From 0b4d084ceb6b3fa5c26e3622674cf24352b350bf Mon Sep 17 00:00:00 2001 From: MitulDavid Date: Wed, 25 Aug 2021 17:14:32 +0530 Subject: [PATCH 164/244] fix: Flags not set on error during .save() --- frappe/core/doctype/file/file.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index e747ca2b91..36ff67ce7c 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -563,12 +563,10 @@ class File(Document): self.file_size = len(optimized_content) self.content_hash = get_content_hash(optimized_content) - self.save() - # if rolledback, revert back to original self.flags.original_content = content frappe.local.rollback_observers.append(self) - + self.save() def on_doctype_update(): frappe.db.add_index("File", ["attached_to_doctype", "attached_to_name"]) From 15ba0fbe3e22f85a61bfae00481ec5ad18022e78 Mon Sep 17 00:00:00 2001 From: MitulDavid Date: Wed, 25 Aug 2021 17:15:29 +0530 Subject: [PATCH 165/244] refactor: Remove set_route after optimization --- frappe/core/doctype/file/file.js | 1 - 1 file changed, 1 deletion(-) diff --git a/frappe/core/doctype/file/file.js b/frappe/core/doctype/file/file.js index 202903f1ab..d40328d3cd 100644 --- a/frappe/core/doctype/file/file.js +++ b/frappe/core/doctype/file/file.js @@ -31,7 +31,6 @@ frappe.ui.form.on("File", "refresh", function(frm) { frappe.show_alert(__("Optimizing image...")); frm.call("optimize_file").then(() => { frappe.show_alert(__("Image optimized")); - frappe.set_route("List", "File"); }); }); } From 23c34183f344292886859b334fddba5892904541 Mon Sep 17 00:00:00 2001 From: MitulDavid Date: Wed, 25 Aug 2021 17:17:39 +0530 Subject: [PATCH 166/244] test: Add tests for optimize_file --- frappe/core/doctype/file/test_file.py | 63 +++++++++++++++++++++++++++ frappe/tests/data/sample_svg.svg | 12 +++++ 2 files changed, 75 insertions(+) create mode 100644 frappe/tests/data/sample_svg.svg diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index 5478d7ab85..745ff88e71 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -440,6 +440,69 @@ class TestFile(unittest.TestCase): }).insert(ignore_permissions=True) self.assertRaisesRegex(frappe.exceptions.ValidationError, 'not a zip file', test_file.unzip) + def test_optimize_file(self): + file_path = frappe.get_app_path("frappe", "tests/data/sample_image_for_optimization.jpg") + with open(file_path, "rb") as f: + file_content = f.read() + test_file = frappe.get_doc({ + "doctype": "File", + "file_name": "sample_image_for_optimization.jpg", + "content": file_content + }).insert() + original_size = test_file.file_size + original_content_hash = test_file.content_hash + + test_file.optimize_file() + optimized_size = test_file.file_size + updated_content_hash = test_file.content_hash + + self.assertLess(optimized_size, original_size) + self.assertNotEqual(original_content_hash, updated_content_hash) + test_file.delete() + + def test_optimize_svg(self): + file_path = frappe.get_app_path("frappe", "tests/data/sample_svg.svg") + with open(file_path, "rb") as f: + file_content = f.read() + test_file = frappe.get_doc({ + "doctype": "File", + "file_name": "sample_svg.svg", + "content": file_content + }).insert() + self.assertRaises(TypeError, test_file.optimize_file) + test_file.delete() + + def test_optimize_textfile(self): + test_file = frappe.get_doc({ + "doctype": "File", + "file_name": "sample_text.txt", + "content": "Text files cannot be optimized" + }).insert() + self.assertRaises(NotImplementedError, test_file.optimize_file) + test_file.delete() + + def test_optimize_folder(self): + test_folder = frappe.get_doc("File", "Home/Attachments") + self.assertRaises(TypeError, test_folder.optimize_file) + + def test_revert_optimized_file_on_rollback(self): + file_path = frappe.get_app_path("frappe", "tests/data/sample_image_for_optimization.jpg") + with open(file_path, "rb") as f: + file_content = f.read() + test_file = frappe.get_doc({ + "doctype": "File", + "file_name": "sample_image_for_optimization.jpg", + "content": file_content + }).insert() + image_path = test_file.get_full_path() + size_before_optimization = os.stat(image_path).st_size + + test_file.optimize_file() + frappe.db.rollback() + size_after_rollback = os.stat(image_path).st_size + self.assertEqual(size_before_optimization, size_after_rollback) + test_file.delete() + class TestAttachment(unittest.TestCase): test_doctype = 'Test For Attachment' diff --git a/frappe/tests/data/sample_svg.svg b/frappe/tests/data/sample_svg.svg new file mode 100644 index 0000000000..9fcb3c8242 --- /dev/null +++ b/frappe/tests/data/sample_svg.svg @@ -0,0 +1,12 @@ + + + + Artboard + Created with Sketch. + + + + + + + \ No newline at end of file From 678474c6529fb564084cda210f17554edb5fddc1 Mon Sep 17 00:00:00 2001 From: shariquerik Date: Wed, 25 Aug 2021 17:23:44 +0530 Subject: [PATCH 167/244] chore: Using jquery to set css --- frappe/public/js/frappe/views/reports/query_report.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/views/reports/query_report.js b/frappe/public/js/frappe/views/reports/query_report.js index ea0d904d0f..1053f9b7c5 100644 --- a/frappe/public/js/frappe/views/reports/query_report.js +++ b/frappe/public/js/frappe/views/reports/query_report.js @@ -855,7 +855,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { }; if (this.raw_data.add_total_row) { - this.page.body[0].style.setProperty('--report-total-height', '310px'); + this.$page.find('.layout-main-section').css('--report-total-height', '310px'); } if (this.report_settings.get_datatable_options) { From 55d22e185badcc5fd46ad3726c9466798c55fd49 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 25 Aug 2021 19:44:52 +0530 Subject: [PATCH 168/244] chore: extend editorconfig to vue components --- .editorconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.editorconfig b/.editorconfig index 24f122a8d4..d76f67cd7f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,6 +9,6 @@ trim_trailing_whitespace = true charset = utf-8 # python, js indentation settings -[{*.py,*.js}] +[{*.py,*.js,*.vue}] indent_style = tab indent_size = 4 From 1bebe288768325d66414b51c9fef38b5ca1927d4 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 25 Aug 2021 19:43:18 +0530 Subject: [PATCH 169/244] feat: import/export full recorder captures --- .../js/frappe/recorder/RecorderDetail.vue | 38 ++++++++++++++++--- .../js/frappe/recorder/RequestDetail.vue | 25 +++++++----- frappe/recorder.py | 7 ++++ 3 files changed, 56 insertions(+), 14 deletions(-) diff --git a/frappe/public/js/frappe/recorder/RecorderDetail.vue b/frappe/public/js/frappe/recorder/RecorderDetail.vue index d17a8f0ec4..8b95612201 100644 --- a/frappe/public/js/frappe/recorder/RecorderDetail.vue +++ b/frappe/public/js/frappe/recorder/RecorderDetail.vue @@ -1,5 +1,5 @@