diff --git a/frappe/core/doctype/doctype/test_doctype.py b/frappe/core/doctype/doctype/test_doctype.py index 8d8731e012..969a71ab7d 100644 --- a/frappe/core/doctype/doctype/test_doctype.py +++ b/frappe/core/doctype/doctype/test_doctype.py @@ -26,7 +26,7 @@ class TestDocType(unittest.TestCase): }], "permissions": [{ "role": "System Manager", - "read": 1 + "read": 1, }], "name": name }) @@ -295,3 +295,58 @@ class TestDocType(unittest.TestCase): field_1.search_index = 1 self.assertRaises(CannotIndexedError, doc.insert) + + def test_cancel_link_doctype(self): + import json + from frappe.desk.form.linked_with import get_submitted_linked_docs, cancel_all_linked_docs + + #create doctype + link_doc = self.new_doctype('Test Linked Doctype') + link_doc.is_submittable = 1 + for data in link_doc.get('permissions'): + data.submit = 1 + data.cancel = 1 + link_doc.insert() + + doc = self.new_doctype('Test Doctype') + doc.is_submittable = 1 + field_2 = doc.append('fields', {}) + field_2.label = 'Test Linked Doctype' + field_2.fieldname = 'test_linked_doctype' + field_2.fieldtype = 'Link' + field_2.options = 'Test Linked Doctype' + for data in link_doc.get('permissions'): + data.submit = 1 + data.cancel = 1 + doc.insert() + + # create doctype data + data_link_doc = frappe.new_doc('Test Linked Doctype') + data_link_doc.some_fieldname = 'Data1' + data_link_doc.insert() + data_link_doc.save() + data_link_doc.submit() + + data_doc = frappe.new_doc('Test Doctype') + data_doc.some_fieldname = 'Data1' + data_doc.test_linked_doctype = data_link_doc.name + data_doc.insert() + data_doc.save() + data_doc.submit() + + docs = get_submitted_linked_docs(link_doc.name, data_link_doc.name) + dump_docs = json.dumps(docs.get('docs')) + cancel_all_linked_docs(dump_docs) + data_link_doc.cancel() + data_doc.load_from_db() + self.assertEqual(data_link_doc.docstatus, 2) + self.assertEqual(data_doc.docstatus, 2) + + # delete doctype record + data_doc.delete() + data_link_doc.delete() + + # delete doctype + link_doc.delete() + doc.delete() + frappe.db.commit() \ No newline at end of file diff --git a/frappe/desk/form/linked_with.py b/frappe/desk/form/linked_with.py index 734b99a003..6c679bf312 100644 --- a/frappe/desk/form/linked_with.py +++ b/frappe/desk/form/linked_with.py @@ -1,14 +1,119 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # MIT License. See license.txt from __future__ import unicode_literals - -import frappe, json +import json +from collections import defaultdict +from six import string_types +import frappe +import frappe.desk.form.load +import frappe.desk.form.meta +from frappe import _ from frappe.model.meta import is_single from frappe.modules import load_doctype_module -import frappe.desk.form.meta -import frappe.desk.form.load -from six import string_types -from collections import defaultdict + + +@frappe.whitelist() +def get_submitted_linked_docs(doctype, name, docs=None): + """ + Get all nested submitted linked doctype linkinfo + + Arguments: + doctype (str) - The doctype for which get all linked doctypes + name (str) - The docname for which get all linked doctypes + + Keyword Arguments: + docs (list of dict) - (Optional) Get list of dictionary for linked doctype. + + Returns: + dict - Return list of documents and link count + """ + + if not docs: + docs = [] + + linkinfo = get_linked_doctypes(doctype) + linked_docs = get_linked_docs(doctype, name, linkinfo) + + link_count = 0 + for link_doctype, link_names in linked_docs.items(): + for link in link_names: + docinfo = link.update({"doctype": link_doctype}) + validated_doc = validate_linked_doc(docinfo) + + if not validated_doc: + continue + + link_count += 1 + if link.name in [doc.get("name") for doc in docs]: + continue + + links = get_submitted_linked_docs(link_doctype, link.name, docs) + docs.append({ + "doctype": link_doctype, + "name": link.name, + "docstatus": link.docstatus, + "link_count": links.get("count") + }) + + # sort linked documents by ascending number of links + docs.sort(key=lambda doc: doc.get("link_count")) + return { + "docs": docs, + "count": link_count + } + + +@frappe.whitelist() +def cancel_all_linked_docs(docs): + """ + Cancel all linked doctype + + Arguments: + docs (str) - It contains all list of dictionaries of a linked documents. + """ + + docs = json.loads(docs) + for i, doc in enumerate(docs, 1): + if validate_linked_doc(doc) is True: + frappe.publish_progress(percent=i * 100 / len(docs), title=_("Cancelling documents")) + linked_doc = frappe.get_doc(doc.get("doctype"), doc.get("name")) + linked_doc.cancel() + + +def validate_linked_doc(docinfo): + """ + Validate a document to be submitted and non-exempted from auto-cancel. + + Args: + docs (dict): The document to check for submitted and non-exempt from auto-cancel + + Returns: + bool: True if linked document passes all validations, else False + """ + + # skip non-submittable doctypes since they don't need to be cancelled + if not frappe.get_meta(docinfo.get('doctype')).is_submittable: + return False + + # skip draft or cancelled documents + if docinfo.get('docstatus') != 1: + return False + + # skip other doctypes since they don't need to be cancelled + auto_cancel_exempt_doctypes = get_exempted_doctypes() + if docinfo.get('doctype') in auto_cancel_exempt_doctypes: + return False + + return True + + +def get_exempted_doctypes(): + """ Get list of doctypes exempted from being auto-cancelled """ + + auto_cancel_exempt_doctypes = [] + for doctypes in frappe.get_hooks('auto_cancel_exempted_doctypes'): + auto_cancel_exempt_doctypes.append(doctypes) + return auto_cancel_exempt_doctypes @frappe.whitelist() @@ -184,8 +289,8 @@ def get_dynamic_linked_fields(doctype, without_ignore_user_permissions_enabled=F if is_single(df.doctype): continue # optimized to get both link exists and parenttype - possible_link = frappe.db.sql("""select distinct `{doctype_fieldname}`, parenttype - from `tab{doctype}` where `{doctype_fieldname}`=%s""".format(**df), doctype, as_dict=True) + possible_link = frappe.get_all(df.doctype, filters={df.doctype_fieldname: doctype}, + fields=['parenttype'], distinct=True) if not possible_link: continue @@ -203,4 +308,4 @@ def get_dynamic_linked_fields(doctype, without_ignore_user_permissions_enabled=F "doctype_fieldname": df.doctype_fieldname } - return ret \ No newline at end of file + return ret diff --git a/frappe/integrations/oauth2_logins.py b/frappe/integrations/oauth2_logins.py index a3ee98ad4e..14a6bcc417 100644 --- a/frappe/integrations/oauth2_logins.py +++ b/frappe/integrations/oauth2_logins.py @@ -31,6 +31,10 @@ def login_via_office365(code, state): def login_via_salesforce(code, state): login_via_oauth2("salesforce", code, state, decoder=decoder_compat) +@frappe.whitelist(allow_guest=True) +def login_via_fairlogin(code, state): + login_via_oauth2("fairlogin", code, state, decoder=decoder_compat) + @frappe.whitelist(allow_guest=True) def custom(code, state): """ diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index 02aa8b78dc..b55c822ba6 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -648,18 +648,96 @@ frappe.ui.form.Form = class FrappeForm { } savecancel(btn, callback, on_error) { - var me = this; - + const me = this; this.validate_form_action('Cancel'); - frappe.confirm(__("Permanently Cancel {0}?", [this.docname]), function() { + + frappe.call({ + method: "frappe.desk.form.linked_with.get_submitted_linked_docs", + args: { + doctype: me.doc.doctype, + name: me.doc.name + }, + freeze: true, + callback: (r) => { + if (!r.exc && r.message.count > 0) { + me._cancel_all(r, btn, callback, on_error); + } else { + me._cancel(btn, callback, on_error, false); + } + } + }); + } + + _cancel_all(r, btn, callback, on_error) { + const me = this; + + // add confirmation message for cancelling all linked docs + let links_text = ""; + let links = r.message.docs; + const doctypes = Array.from(new Set(links.map(link => link.doctype))); + + for (let doctype of doctypes) { + let docnames = links + .filter((link) => link.doctype == doctype) + .map((link) => frappe.utils.get_form_link(link.doctype, link.name, true)) + .join(", "); + links_text += `
  • ${doctype}: ${docnames}
  • `; + } + links_text = ``; + + let confirm_message = __('{0} {1} is linked with the following submitted documents: {2}', + [(me.doc.doctype).bold(), me.doc.name, links_text]); + + let can_cancel = links.every((link) => frappe.model.can_cancel(link.doctype)); + if (can_cancel) { + confirm_message += __('Do you want to cancel all linked documents?'); + } else { + confirm_message += __('You do not have permissions to cancel all linked documents.'); + } + + // generate dialog box to cancel all linked docs + let d = new frappe.ui.Dialog({ + title: __("Cancel All Documents"), + fields: [{ + fieldtype: "HTML", + options: `

    ${confirm_message}

    ` + }] + }, () => me.handle_save_fail(btn, on_error)); + + // if user can cancel all linked docs, add action to the dialog + if (can_cancel) { + d.set_primary_action("Cancel All", () => { + d.hide(); + frappe.call({ + method: "frappe.desk.form.linked_with.cancel_all_linked_docs", + args: { + docs: links + }, + freeze: true, + callback: (resp) => { + if (!resp.exc) { + me.reload_doc(); + me._cancel(btn, callback, on_error, true); + } + } + }); + }); + } + + d.show(); + }; + + _cancel(btn, callback, on_error, skip_confirm) { + const me = this; + const cancel_doc = () => { frappe.validated = true; - me.script_manager.trigger("before_cancel").then(function() { - if(!frappe.validated) { + me.script_manager.trigger("before_cancel").then(() => { + if (!frappe.validated) { return me.handle_save_fail(btn, on_error); } var after_cancel = function(r) { - if(r.exc) { + if (r.exc) { me.handle_save_fail(btn, on_error); } else { frappe.utils.play_sound("cancel"); @@ -670,8 +748,14 @@ frappe.ui.form.Form = class FrappeForm { }; frappe.ui.form.save(me, "cancel", after_cancel, btn); }); - }, () => me.handle_save_fail(btn, on_error)); - } + } + + if (skip_confirm) { + cancel_doc(); + } else { + frappe.confirm(__("Permanently Cancel {0}?", [this.docname]), cancel_doc, me.handle_save_fail(btn, on_error)); + } + }; savetrash() { this.validate_form_action("Delete"); diff --git a/frappe/test_runner.py b/frappe/test_runner.py index 0be19c6110..b66a96595d 100644 --- a/frappe/test_runner.py +++ b/frappe/test_runner.py @@ -41,7 +41,7 @@ def main(app=None, module=None, doctype=None, verbose=False, tests=(), xmloutput_fh = None if junit_xml_output: - xmloutput_fh = open(junit_xml_output, 'w') + xmloutput_fh = open(junit_xml_output, 'wb') unittest_runner = xmlrunner_wrapper(xmloutput_fh) else: unittest_runner = unittest.TextTestRunner @@ -68,11 +68,11 @@ 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) + ret = run_tests_for_doctype(doctype, verbose, tests, force, profile, junit_xml_output=junit_xml_output) elif module: - ret = run_tests_for_module(module, verbose, tests, profile) + ret = run_tests_for_module(module, verbose, tests, profile, junit_xml_output=junit_xml_output) else: - ret = run_all_tests(app, verbose, profile, ui_tests, failfast=failfast) + ret = run_all_tests(app, verbose, profile, ui_tests, failfast=failfast, junit_xml_output=junit_xml_output) if frappe.db: frappe.db.commit() @@ -109,7 +109,7 @@ class TimeLoggingTestResult(unittest.TextTestResult): super(TimeLoggingTestResult, self).addSuccess(test) -def run_all_tests(app=None, verbose=False, profile=False, ui_tests=False, failfast=False): +def run_all_tests(app=None, verbose=False, profile=False, ui_tests=False, failfast=False, junit_xml_output=False): import os apps = [app] if app else frappe.get_installed_apps() @@ -130,11 +130,16 @@ def run_all_tests(app=None, verbose=False, profile=False, ui_tests=False, failfa _add_test(app, path, filename, verbose, test_suite, ui_tests) + if junit_xml_output: + 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), failfast=failfast) + if profile: pr = cProfile.Profile() pr.enable() - out = unittest_runner(resultclass=TimeLoggingTestResult, verbosity=1+(verbose and 1 or 0), failfast=failfast).run(test_suite) + out = runner.run(test_suite) if profile: pr.disable() @@ -145,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): +def run_tests_for_doctype(doctypes, verbose=False, tests=(), force=False, profile=False, junit_xml_output=False): modules = [] if not isinstance(doctypes, (list, tuple)): doctypes = [doctypes] @@ -163,17 +168,17 @@ 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) + return _run_unittest(modules, verbose=verbose, tests=tests, profile=profile, junit_xml_output=junit_xml_output) -def run_tests_for_module(module, verbose=False, tests=(), profile=False): +def run_tests_for_module(module, verbose=False, tests=(), profile=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) - return _run_unittest(module, verbose=verbose, tests=tests, profile=profile) + return _run_unittest(module, verbose=verbose, tests=tests, profile=profile, junit_xml_output=junit_xml_output) -def _run_unittest(modules, verbose=False, tests=(), profile=False): +def _run_unittest(modules, verbose=False, tests=(), profile=False, junit_xml_output=False): test_suite = unittest.TestSuite() if not isinstance(modules, (list, tuple)): @@ -189,13 +194,18 @@ def _run_unittest(modules, verbose=False, tests=(), profile=False): else: test_suite.addTest(module_test_cases) + if junit_xml_output: + runner = unittest_runner(verbosity=1+(verbose and 1 or 0)) + else: + runner = unittest_runner(resultclass=TimeLoggingTestResult, verbosity=1+(verbose and 1 or 0)) + if profile: pr = cProfile.Profile() pr.enable() frappe.flags.tests_verbose = verbose - out = unittest_runner(verbosity=1+(verbose and 1 or 0)).run(test_suite) + out = runner.run(test_suite) if profile: diff --git a/frappe/utils/boilerplate.py b/frappe/utils/boilerplate.py index 4a7b93751a..b81d802a07 100755 --- a/frappe/utils/boilerplate.py +++ b/frappe/utils/boilerplate.py @@ -256,6 +256,10 @@ app_license = "{app_license}" # "Task": "{app_name}.task.get_dashboard_data" # }} +# exempt linked doctypes from being automatically cancelled +# +# auto_cancel_exempted_doctypes = ["Auto Repeat"] + """ desktop_template = """# -*- coding: utf-8 -*-