diff --git a/erpnext/accounts/doctype/transaction_deletion_record_details/__init__.py b/erpnext/accounts/doctype/transaction_deletion_record_details/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/doctype/transaction_deletion_record_details/transaction_deletion_record_details.json b/erpnext/accounts/doctype/transaction_deletion_record_details/transaction_deletion_record_details.json new file mode 100644 index 00000000000..fe4b0852ac1 --- /dev/null +++ b/erpnext/accounts/doctype/transaction_deletion_record_details/transaction_deletion_record_details.json @@ -0,0 +1,58 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2024-02-04 10:53:32.307930", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "doctype_name", + "docfield_name", + "no_of_docs", + "done" + ], + "fields": [ + { + "fieldname": "doctype_name", + "fieldtype": "Link", + "in_list_view": 1, + "label": "DocType", + "options": "DocType", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "docfield_name", + "fieldtype": "Data", + "label": "DocField", + "read_only": 1 + }, + { + "fieldname": "no_of_docs", + "fieldtype": "Int", + "in_list_view": 1, + "label": "No of Docs", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "done", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Done", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2024-02-05 17:35:09.556054", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Transaction Deletion Record Details", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/transaction_deletion_record_details/transaction_deletion_record_details.py b/erpnext/accounts/doctype/transaction_deletion_record_details/transaction_deletion_record_details.py new file mode 100644 index 00000000000..bc5b5c41fdd --- /dev/null +++ b/erpnext/accounts/doctype/transaction_deletion_record_details/transaction_deletion_record_details.py @@ -0,0 +1,26 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class TransactionDeletionRecordDetails(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + docfield_name: DF.Data | None + doctype_name: DF.Link + done: DF.Check + no_of_docs: DF.Int + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + # end: auto-generated types + + pass diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 5a4e8539bf6..e328c686f5d 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -299,7 +299,10 @@ period_closing_doctypes = [ doc_events = { "*": { - "validate": "erpnext.support.doctype.service_level_agreement.service_level_agreement.apply", + "validate": [ + "erpnext.support.doctype.service_level_agreement.service_level_agreement.apply", + "erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record.check_for_running_deletion_job", + ], }, tuple(period_closing_doctypes): { "validate": "erpnext.accounts.doctype.accounting_period.accounting_period.validate_accounting_period_on_doc_save", diff --git a/erpnext/setup/demo.py b/erpnext/setup/demo.py new file mode 100644 index 00000000000..f48402e175b --- /dev/null +++ b/erpnext/setup/demo.py @@ -0,0 +1,228 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import json +import os +from random import randint + +import frappe +from frappe import _ +from frappe.utils import add_days, getdate + +from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry +from erpnext.accounts.utils import get_fiscal_year +from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice +from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice +from erpnext.setup.setup_wizard.operations.install_fixtures import create_bank_account + + +def setup_demo_data(): + from frappe.utils.telemetry import capture + + capture("demo_data_creation_started", "erpnext") + try: + company = create_demo_company() + process_masters() + make_transactions(company) + frappe.cache.delete_keys("bootinfo") + frappe.publish_realtime("demo_data_complete") + except Exception: + frappe.log_error("Failed to create demo data") + capture("demo_data_creation_failed", "erpnext", properties={"exception": frappe.get_traceback()}) + raise + capture("demo_data_creation_completed", "erpnext") + + +@frappe.whitelist() +def clear_demo_data(): + from frappe.utils.telemetry import capture + + frappe.only_for("System Manager") + + capture("demo_data_erased", "erpnext") + try: + company = frappe.db.get_single_value("Global Defaults", "demo_company") + create_transaction_deletion_record(company) + clear_masters() + delete_company(company) + default_company = frappe.db.get_single_value("Global Defaults", "default_company") + frappe.db.set_default("company", default_company) + except Exception: + frappe.db.rollback() + frappe.log_error("Failed to erase demo data") + frappe.throw( + _("Failed to erase demo data, please delete the demo company manually."), + title=_("Could Not Delete Demo Data"), + ) + + +def create_demo_company(): + company = frappe.db.get_all("Company")[0].name + company_doc = frappe.get_doc("Company", company) + + # Make a dummy company + new_company = frappe.new_doc("Company") + new_company.company_name = company_doc.company_name + " (Demo)" + new_company.abbr = company_doc.abbr + "D" + new_company.enable_perpetual_inventory = 1 + new_company.default_currency = company_doc.default_currency + new_company.country = company_doc.country + new_company.chart_of_accounts_based_on = "Standard Template" + new_company.chart_of_accounts = company_doc.chart_of_accounts + new_company.insert() + + # Set Demo Company as default to + frappe.db.set_single_value("Global Defaults", "demo_company", new_company.name) + frappe.db.set_default("company", new_company.name) + + bank_account = create_bank_account({"company_name": new_company.name}) + frappe.db.set_value("Company", new_company.name, "default_bank_account", bank_account.name) + + return new_company.name + + +def process_masters(): + for doctype in frappe.get_hooks("demo_master_doctypes"): + data = read_data_file_using_hooks(doctype) + if data: + for item in json.loads(data): + create_demo_record(item) + + +def create_demo_record(doctype): + frappe.get_doc(doctype).insert(ignore_permissions=True) + + +def make_transactions(company): + frappe.db.set_single_value("Stock Settings", "allow_negative_stock", 1) + from erpnext.accounts.utils import FiscalYearError + + try: + start_date = get_fiscal_year(date=getdate())[1] + except FiscalYearError: + # User might have setup fiscal year for previous or upcoming years + active_fiscal_years = frappe.db.get_all("Fiscal Year", filters={"disabled": 0}, as_list=1) + if active_fiscal_years: + start_date = frappe.db.get_value("Fiscal Year", active_fiscal_years[0][0], "year_start_date") + else: + frappe.throw(_("There are no active Fiscal Years for which Demo Data can be generated.")) + + for doctype in frappe.get_hooks("demo_transaction_doctypes"): + data = read_data_file_using_hooks(doctype) + if data: + for item in json.loads(data): + create_transaction(item, company, start_date) + + convert_order_to_invoices() + frappe.db.set_single_value("Stock Settings", "allow_negative_stock", 0) + + +def create_transaction(doctype, company, start_date): + document_type = doctype.get("doctype") + warehouse = get_warehouse(company) + + if document_type == "Purchase Order": + posting_date = get_random_date(start_date, 1, 25) + else: + posting_date = get_random_date(start_date, 31, 350) + + doctype.update( + { + "company": company, + "set_posting_time": 1, + "transaction_date": posting_date, + "schedule_date": posting_date, + "delivery_date": posting_date, + "set_warehouse": warehouse, + } + ) + + doc = frappe.get_doc(doctype) + doc.save(ignore_permissions=True) + doc.submit() + + +def convert_order_to_invoices(): + for document in ["Purchase Order", "Sales Order"]: + # Keep some orders intentionally unbilled/unpaid + for i, order in enumerate( + frappe.db.get_all( + document, filters={"docstatus": 1}, fields=["name", "transaction_date"], limit=6 + ) + ): + + if document == "Purchase Order": + invoice = make_purchase_invoice(order.name) + elif document == "Sales Order": + invoice = make_sales_invoice(order.name) + + invoice.set_posting_time = 1 + invoice.posting_date = order.transaction_date + invoice.due_date = order.transaction_date + invoice.bill_date = order.transaction_date + + if invoice.get("payment_schedule"): + invoice.payment_schedule[0].due_date = order.transaction_date + + invoice.update_stock = 1 + invoice.submit() + + if i % 2 != 0: + payment = get_payment_entry(invoice.doctype, invoice.name) + payment.posting_date = order.transaction_date + payment.reference_no = invoice.name + payment.submit() + + +def get_random_date(start_date, start_range, end_range): + return add_days(start_date, randint(start_range, end_range)) + + +def create_transaction_deletion_record(company): + transaction_deletion_record = frappe.new_doc("Transaction Deletion Record") + transaction_deletion_record.company = company + transaction_deletion_record.process_in_single_transaction = True + transaction_deletion_record.save(ignore_permissions=True) + transaction_deletion_record.submit() + transaction_deletion_record.start_deletion_tasks() + + +def clear_masters(): + for doctype in frappe.get_hooks("demo_master_doctypes")[::-1]: + data = read_data_file_using_hooks(doctype) + if data: + for item in json.loads(data): + clear_demo_record(item) + + +def clear_demo_record(document): + document_type = document.get("doctype") + del document["doctype"] + + valid_columns = frappe.get_meta(document_type).get_valid_columns() + + filters = document + for key in list(filters): + if key not in valid_columns: + filters.pop(key, None) + + doc = frappe.get_doc(document_type, filters) + doc.delete(ignore_permissions=True) + + +def delete_company(company): + frappe.db.set_single_value("Global Defaults", "demo_company", "") + frappe.delete_doc("Company", company, ignore_permissions=True) + + +def read_data_file_using_hooks(doctype): + path = os.path.join(os.path.dirname(__file__), "demo_data") + with open(os.path.join(path, doctype + ".json"), "r") as f: + data = f.read() + + return data + + +def get_warehouse(company): + warehouses = frappe.db.get_all("Warehouse", {"company": company, "is_group": 0}) + return warehouses[randint(0, 3)].name diff --git a/erpnext/setup/doctype/company/company.js b/erpnext/setup/doctype/company/company.js index 23a55487adc..6c237d787bb 100644 --- a/erpnext/setup/doctype/company/company.js +++ b/erpnext/setup/doctype/company/company.js @@ -169,43 +169,49 @@ frappe.ui.form.on("Company", { }, delete_company_transactions: function (frm) { - frappe.verify_password(function () { - var d = frappe.prompt( - { - fieldtype: "Data", - fieldname: "company_name", - label: __("Please enter the company name to confirm"), - reqd: 1, - description: __( - "Please make sure you really want to delete all the transactions for this company. Your master data will remain as it is. This action cannot be undone." - ), - }, - function (data) { - if (data.company_name !== frm.doc.name) { - frappe.msgprint(__("Company name not same")); - return; - } - frappe.call({ - method: "erpnext.setup.doctype.company.company.create_transaction_deletion_request", - args: { - company: data.company_name, - }, - freeze: true, - callback: function (r, rt) { - if (!r.exc) - frappe.msgprint( - __("Successfully deleted all transactions related to this company!") - ); - }, - onerror: function () { - frappe.msgprint(__("Wrong Password")); - }, + frappe.call({ + method: "erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record.is_deletion_doc_running", + args: { + company: frm.doc.name, + }, + freeze: true, + callback: function (r) { + if (!r.exc) { + frappe.verify_password(function () { + var d = frappe.prompt( + { + fieldtype: "Data", + fieldname: "company_name", + label: __("Please enter the company name to confirm"), + reqd: 1, + description: __( + "Please make sure you really want to delete all the transactions for this company. Your master data will remain as it is. This action cannot be undone." + ), + }, + function (data) { + if (data.company_name !== frm.doc.name) { + frappe.msgprint(__("Company name not same")); + return; + } + frappe.call({ + method: "erpnext.setup.doctype.company.company.create_transaction_deletion_request", + args: { + company: data.company_name, + }, + freeze: true, + callback: function (r, rt) {}, + onerror: function () { + frappe.msgprint(__("Wrong Password")); + }, + }); + }, + __("Delete all the Transactions for this Company"), + __("Delete") + ); + d.get_primary_btn().addClass("btn-danger"); }); - }, - __("Delete all the Transactions for this Company"), - __("Delete") - ); - d.get_primary_btn().addClass("btn-danger"); + } + }, }); }, }); diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index bea76f19412..5ee7dbb4b47 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -11,7 +11,7 @@ from frappe.cache_manager import clear_defaults_cache from frappe.contacts.address_and_contact import load_address_and_contact from frappe.custom.doctype.property_setter.property_setter import make_property_setter from frappe.desk.page.setup_wizard.setup_wizard import make_records -from frappe.utils import cint, formatdate, get_timestamp, today +from frappe.utils import cint, formatdate, get_link_to_form, get_timestamp, today from frappe.utils.nestedset import NestedSet, rebuild_tree from erpnext.accounts.doctype.account.account import get_account_currency @@ -812,6 +812,19 @@ def get_default_company_address(name, sort_key="is_primary_address", existing_ad @frappe.whitelist() def create_transaction_deletion_request(company): + from erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record import ( + is_deletion_doc_running, + ) + + is_deletion_doc_running(company) + tdr = frappe.get_doc({"doctype": "Transaction Deletion Record", "company": company}) - tdr.insert() tdr.submit() + tdr.start_deletion_tasks() + + frappe.msgprint( + _("A Transaction Deletion Document: {0} is triggered for {0}").format( + get_link_to_form("Transaction Deletion Record", tdr.name) + ), + frappe.bold(company), + ) diff --git a/erpnext/setup/doctype/transaction_deletion_record/test_transaction_deletion_record.py b/erpnext/setup/doctype/transaction_deletion_record/test_transaction_deletion_record.py index 319d435ca69..24a12bac9fe 100644 --- a/erpnext/setup/doctype/transaction_deletion_record/test_transaction_deletion_record.py +++ b/erpnext/setup/doctype/transaction_deletion_record/test_transaction_deletion_record.py @@ -28,6 +28,7 @@ class TestTransactionDeletionRecord(unittest.TestCase): for i in range(5): create_task("Dunder Mifflin Paper Co") tdr = create_transaction_deletion_request("Dunder Mifflin Paper Co") + tdr.reload() for doctype in tdr.doctypes: if doctype.doctype_name == "Task": self.assertEqual(doctype.no_of_docs, 5) @@ -49,7 +50,9 @@ def create_company(company_name): def create_transaction_deletion_request(company): tdr = frappe.get_doc({"doctype": "Transaction Deletion Record", "company": company}) tdr.insert() + tdr.process_in_single_transaction = True tdr.submit() + tdr.start_deletion_tasks() return tdr diff --git a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.js b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.js index 527c753d6a9..9aa02784165 100644 --- a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.js +++ b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.js @@ -10,20 +10,24 @@ frappe.ui.form.on("Transaction Deletion Record", { callback: function (r) { doctypes_to_be_ignored_array = r.message; populate_doctypes_to_be_ignored(doctypes_to_be_ignored_array, frm); - frm.fields_dict["doctypes_to_be_ignored"].grid.set_column_disp("no_of_docs", false); frm.refresh_field("doctypes_to_be_ignored"); }, }); } - - frm.get_field("doctypes_to_be_ignored").grid.cannot_add_rows = true; - frm.fields_dict["doctypes_to_be_ignored"].grid.set_column_disp("no_of_docs", false); - frm.refresh_field("doctypes_to_be_ignored"); }, refresh: function (frm) { - frm.fields_dict["doctypes_to_be_ignored"].grid.set_column_disp("no_of_docs", false); - frm.refresh_field("doctypes_to_be_ignored"); + if (frm.doc.docstatus == 1 && ["Queued", "Failed"].find((x) => x == frm.doc.status)) { + let execute_btn = frm.doc.status == "Queued" ? __("Start Deletion") : __("Retry"); + + frm.add_custom_button(execute_btn, () => { + // Entry point for chain of events + frm.call({ + method: "start_deletion_tasks", + doc: frm.doc, + }); + }); + } }, }); diff --git a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.json b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.json index 23e59472a6d..e03e1695e0e 100644 --- a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.json +++ b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.json @@ -7,10 +7,21 @@ "engine": "InnoDB", "field_order": [ "company", + "section_break_qpwb", + "status", + "error_log", + "tasks_section", + "delete_bin_data", + "delete_leads_and_addresses", + "reset_company_default_values", + "clear_notifications", + "initialize_doctypes_table", + "delete_transactions", + "section_break_tbej", "doctypes", "doctypes_to_be_ignored", "amended_from", - "status" + "process_in_single_transaction" ], "fields": [ { @@ -25,14 +36,16 @@ "fieldname": "doctypes", "fieldtype": "Table", "label": "Summary", - "options": "Transaction Deletion Record Item", + "no_copy": 1, + "options": "Transaction Deletion Record Details", "read_only": 1 }, { "fieldname": "doctypes_to_be_ignored", "fieldtype": "Table", "label": "Excluded DocTypes", - "options": "Transaction Deletion Record Item" + "options": "Transaction Deletion Record Item", + "read_only": 1 }, { "fieldname": "amended_from", @@ -46,18 +59,93 @@ { "fieldname": "status", "fieldtype": "Select", - "hidden": 1, "label": "Status", - "options": "Draft\nCompleted" + "no_copy": 1, + "options": "Queued\nRunning\nFailed\nCompleted\nCancelled", + "read_only": 1 + }, + { + "fieldname": "section_break_tbej", + "fieldtype": "Section Break" + }, + { + "fieldname": "tasks_section", + "fieldtype": "Section Break", + "label": "Tasks" + }, + { + "default": "0", + "fieldname": "delete_bin_data", + "fieldtype": "Check", + "label": "Delete Bins", + "no_copy": 1, + "read_only": 1 + }, + { + "default": "0", + "fieldname": "delete_leads_and_addresses", + "fieldtype": "Check", + "label": "Delete Leads and Addresses", + "no_copy": 1, + "read_only": 1 + }, + { + "default": "0", + "fieldname": "clear_notifications", + "fieldtype": "Check", + "label": "Clear Notifications", + "no_copy": 1, + "read_only": 1 + }, + { + "default": "0", + "fieldname": "reset_company_default_values", + "fieldtype": "Check", + "label": "Reset Company Default Values", + "no_copy": 1, + "read_only": 1 + }, + { + "default": "0", + "fieldname": "delete_transactions", + "fieldtype": "Check", + "label": "Delete Transactions", + "no_copy": 1, + "read_only": 1 + }, + { + "default": "0", + "fieldname": "initialize_doctypes_table", + "fieldtype": "Check", + "label": "Initialize Summary Table", + "no_copy": 1, + "read_only": 1 + }, + { + "depends_on": "eval: doc.error_log", + "fieldname": "error_log", + "fieldtype": "Long Text", + "label": "Error Log" + }, + { + "fieldname": "section_break_qpwb", + "fieldtype": "Section Break" + }, + { + "default": "0", + "fieldname": "process_in_single_transaction", + "fieldtype": "Check", + "label": "Process in Single Transaction" } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-08-04 20:15:59.071493", + "modified": "2024-03-20 14:58:15.086360", "modified_by": "Administrator", "module": "Setup", "name": "Transaction Deletion Record", + "naming_rule": "Expression (old style)", "owner": "Administrator", "permissions": [ { @@ -76,5 +164,6 @@ ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py index 649b43b5e91..db5024bbc19 100644 --- a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py +++ b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py @@ -1,18 +1,31 @@ # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt +from collections import OrderedDict import frappe from frappe import _, qb from frappe.desk.notifications import clear_notifications from frappe.model.document import Document -from frappe.utils import cint, create_batch +from frappe.utils import cint, comma_and, create_batch, get_link_to_form +from frappe.utils.background_jobs import create_job_id, is_job_enqueued class TransactionDeletionRecord(Document): def __init__(self, *args, **kwargs): super(TransactionDeletionRecord, self).__init__(*args, **kwargs) self.batch_size = 5000 + # Tasks are listed by their execution order + self.task_to_internal_method_map = OrderedDict( + { + "Delete Bins": "delete_bins", + "Delete Leads and Addresses": "delete_lead_addresses", + "Reset Company Values": "reset_company_values", + "Clear Notifications": "delete_notifications", + "Initialize Summary Table": "initialize_doctypes_to_be_deleted_table", + "Delete Transactions": "delete_company_transactions", + } + ) def validate(self): frappe.only_for("System Manager") @@ -29,104 +42,266 @@ class TransactionDeletionRecord(Document): title=_("Not Allowed"), ) + def generate_job_name_for_task(self, task=None): + method = self.task_to_internal_method_map[task] + return f"{self.name}_{method}" + + def generate_job_name_for_next_tasks(self, task=None): + job_names = [] + current_task_idx = list(self.task_to_internal_method_map).index(task) + for idx, task in enumerate(self.task_to_internal_method_map.keys(), 0): + # generate job_name for next tasks + if idx > current_task_idx: + job_names.append(self.generate_job_name_for_task(task)) + return job_names + + def generate_job_name_for_all_tasks(self): + job_names = [] + for task in self.task_to_internal_method_map.keys(): + job_names.append(self.generate_job_name_for_task(task)) + return job_names + def before_submit(self): + if queued_docs := frappe.db.get_all( + "Transaction Deletion Record", + filters={"company": self.company, "status": ("in", ["Running", "Queued"]), "docstatus": 1}, + pluck="name", + ): + frappe.throw( + _( + "Cannot enqueue multi docs for one company. {0} is already queued/running for company: {1}" + ).format( + comma_and([get_link_to_form("Transaction Deletion Record", x) for x in queued_docs]), + frappe.bold(self.company), + ) + ) + if not self.doctypes_to_be_ignored: self.populate_doctypes_to_be_ignored_table() - self.delete_bins() - self.delete_lead_addresses() - self.reset_company_values() - clear_notifications() - self.delete_company_transactions() + def reset_task_flags(self): + self.clear_notifications = 0 + self.delete_bin_data = 0 + self.delete_leads_and_addresses = 0 + self.delete_transactions = 0 + self.initialize_doctypes_table = 0 + self.reset_company_default_values = 0 + + def before_save(self): + self.status = "" + self.doctypes.clear() + self.reset_task_flags() + + def on_submit(self): + self.db_set("status", "Queued") + + def on_cancel(self): + self.db_set("status", "Cancelled") + + def enqueue_task(self, task: str | None = None): + if task and task in self.task_to_internal_method_map: + # make sure that none of next tasks are already running + job_names = self.generate_job_name_for_next_tasks(task=task) + self.validate_running_task_for_doc(job_names=job_names) + + # Generate Job Id to uniquely identify each task for this document + job_id = self.generate_job_name_for_task(task) + + if self.process_in_single_transaction: + self.execute_task(task_to_execute=task) + else: + frappe.enqueue( + "frappe.utils.background_jobs.run_doc_method", + doctype=self.doctype, + name=self.name, + doc_method="execute_task", + job_id=job_id, + queue="long", + enqueue_after_commit=True, + task_to_execute=task, + ) + + def execute_task(self, task_to_execute: str | None = None): + if task_to_execute: + method = self.task_to_internal_method_map[task_to_execute] + if task := getattr(self, method, None): + try: + task() + except Exception as err: + frappe.db.rollback() + traceback = frappe.get_traceback(with_context=True) + if traceback: + message = "Traceback:
" + traceback + frappe.db.set_value(self.doctype, self.name, "error_log", message) + frappe.db.set_value(self.doctype, self.name, "status", "Failed") + + def delete_notifications(self): + self.validate_doc_status() + if not self.clear_notifications: + clear_notifications() + self.db_set("clear_notifications", 1) + self.enqueue_task(task="Initialize Summary Table") def populate_doctypes_to_be_ignored_table(self): doctypes_to_be_ignored_list = get_doctypes_to_be_ignored() for doctype in doctypes_to_be_ignored_list: self.append("doctypes_to_be_ignored", {"doctype_name": doctype}) - def delete_bins(self): - frappe.db.sql( - """delete from `tabBin` where warehouse in - (select name from tabWarehouse where company=%s)""", - self.company, - ) + def validate_running_task_for_doc(self, job_names: list = None): + # at most only one task should be runnning + running_tasks = [] + for x in job_names: + if is_job_enqueued(x): + running_tasks.append(create_job_id(x)) - def delete_lead_addresses(self): - """Delete addresses to which leads are linked""" - leads = frappe.get_all("Lead", filters={"company": self.company}) - leads = ["'%s'" % row.get("name") for row in leads] - addresses = [] - if leads: - addresses = frappe.db.sql_list( - """select parent from `tabDynamic Link` where link_name - in ({leads})""".format( - leads=",".join(leads) + if running_tasks: + frappe.throw( + _("{0} is already running for {1}").format( + comma_and([get_link_to_form("RQ Job", x) for x in running_tasks]), self.name ) ) - if addresses: - addresses = ["%s" % frappe.db.escape(addr) for addr in addresses] - - frappe.db.sql( - """delete from `tabAddress` where name in ({addresses}) and - name not in (select distinct dl1.parent from `tabDynamic Link` dl1 - inner join `tabDynamic Link` dl2 on dl1.parent=dl2.parent - and dl1.link_doctype<>dl2.link_doctype)""".format( - addresses=",".join(addresses) - ) + def validate_doc_status(self): + if self.status != "Running": + frappe.throw( + _("{0} is not running. Cannot trigger events for this Document").format( + get_link_to_form("Transaction Deletion Record", self.name) ) + ) - frappe.db.sql( - """delete from `tabDynamic Link` where link_doctype='Lead' - and parenttype='Address' and link_name in ({leads})""".format( + @frappe.whitelist() + def start_deletion_tasks(self): + # This method is the entry point for the chain of events that follow + self.db_set("status", "Running") + self.enqueue_task(task="Delete Bins") + + def delete_bins(self): + self.validate_doc_status() + if not self.delete_bin_data: + frappe.db.sql( + """delete from `tabBin` where warehouse in + (select name from tabWarehouse where company=%s)""", + self.company, + ) + self.db_set("delete_bin_data", 1) + self.enqueue_task(task="Delete Leads and Addresses") + + def delete_lead_addresses(self): + """Delete addresses to which leads are linked""" + self.validate_doc_status() + if not self.delete_leads_and_addresses: + leads = frappe.get_all("Lead", filters={"company": self.company}) + leads = ["'%s'" % row.get("name") for row in leads] + addresses = [] + if leads: + addresses = frappe.db.sql_list( + """select parent from `tabDynamic Link` where link_name + in ({leads})""".format( leads=",".join(leads) ) ) - frappe.db.sql( - """update `tabCustomer` set lead_name=NULL where lead_name in ({leads})""".format( - leads=",".join(leads) + if addresses: + addresses = ["%s" % frappe.db.escape(addr) for addr in addresses] + + frappe.db.sql( + """delete from `tabAddress` where name in ({addresses}) and + name not in (select distinct dl1.parent from `tabDynamic Link` dl1 + inner join `tabDynamic Link` dl2 on dl1.parent=dl2.parent + and dl1.link_doctype<>dl2.link_doctype)""".format( + addresses=",".join(addresses) + ) + ) + + frappe.db.sql( + """delete from `tabDynamic Link` where link_doctype='Lead' + and parenttype='Address' and link_name in ({leads})""".format( + leads=",".join(leads) + ) + ) + + frappe.db.sql( + """update `tabCustomer` set lead_name=NULL where lead_name in ({leads})""".format( + leads=",".join(leads) + ) ) - ) + self.db_set("delete_leads_and_addresses", 1) + self.enqueue_task(task="Reset Company Values") def reset_company_values(self): - company_obj = frappe.get_doc("Company", self.company) - company_obj.total_monthly_sales = 0 - company_obj.sales_monthly_history = None - company_obj.save() + self.validate_doc_status() + if not self.reset_company_default_values: + company_obj = frappe.get_doc("Company", self.company) + company_obj.total_monthly_sales = 0 + company_obj.sales_monthly_history = None + company_obj.save() + self.db_set("reset_company_default_values", 1) + self.enqueue_task(task="Clear Notifications") + + def initialize_doctypes_to_be_deleted_table(self): + self.validate_doc_status() + if not self.initialize_doctypes_table: + doctypes_to_be_ignored_list = self.get_doctypes_to_be_ignored_list() + docfields = self.get_doctypes_with_company_field(doctypes_to_be_ignored_list) + tables = self.get_all_child_doctypes() + for docfield in docfields: + if docfield["parent"] != self.doctype: + no_of_docs = self.get_number_of_docs_linked_with_specified_company( + docfield["parent"], docfield["fieldname"] + ) + if no_of_docs > 0: + # Initialize + self.populate_doctypes_table(tables, docfield["parent"], docfield["fieldname"], 0) + self.db_set("initialize_doctypes_table", 1) + self.enqueue_task(task="Delete Transactions") def delete_company_transactions(self): - doctypes_to_be_ignored_list = self.get_doctypes_to_be_ignored_list() - docfields = self.get_doctypes_with_company_field(doctypes_to_be_ignored_list) + self.validate_doc_status() + if not self.delete_transactions: + doctypes_to_be_ignored_list = self.get_doctypes_to_be_ignored_list() + docfields = self.get_doctypes_with_company_field(doctypes_to_be_ignored_list) - tables = self.get_all_child_doctypes() - for docfield in docfields: - if docfield["parent"] != self.doctype: - no_of_docs = self.get_number_of_docs_linked_with_specified_company( - docfield["parent"], docfield["fieldname"] - ) - - if no_of_docs > 0: - self.delete_version_log(docfield["parent"], docfield["fieldname"]) - - reference_docs = frappe.get_all( - docfield["parent"], filters={docfield["fieldname"]: self.company} + tables = self.get_all_child_doctypes() + for docfield in self.doctypes: + if docfield.doctype_name != self.doctype and not docfield.done: + no_of_docs = self.get_number_of_docs_linked_with_specified_company( + docfield.doctype_name, docfield.docfield_name ) - reference_doc_names = [r.name for r in reference_docs] + if no_of_docs > 0: + reference_docs = frappe.get_all( + docfield.doctype_name, filters={docfield.docfield_name: self.company}, limit=self.batch_size + ) + reference_doc_names = [r.name for r in reference_docs] - self.delete_communications(docfield["parent"], reference_doc_names) - self.delete_comments(docfield["parent"], reference_doc_names) - self.unlink_attachments(docfield["parent"], reference_doc_names) + self.delete_version_log(docfield.doctype_name, reference_doc_names) + self.delete_communications(docfield.doctype_name, reference_doc_names) + self.delete_comments(docfield.doctype_name, reference_doc_names) + self.unlink_attachments(docfield.doctype_name, reference_doc_names) + self.delete_child_tables(docfield.doctype_name, reference_doc_names) + self.delete_docs_linked_with_specified_company(docfield.doctype_name, reference_doc_names) + processed = int(docfield.no_of_docs) + len(reference_doc_names) + frappe.db.set_value(docfield.doctype, docfield.name, "no_of_docs", processed) + else: + # reset naming series + naming_series = frappe.db.get_value("DocType", docfield.doctype_name, "autoname") + if naming_series: + if "#" in naming_series: + self.update_naming_series(naming_series, docfield.doctype_name) + frappe.db.set_value(docfield.doctype, docfield.name, "done", 1) - self.populate_doctypes_table(tables, docfield["parent"], no_of_docs) - - self.delete_child_tables(docfield["parent"], docfield["fieldname"]) - self.delete_docs_linked_with_specified_company(docfield["parent"], docfield["fieldname"]) - - naming_series = frappe.db.get_value("DocType", docfield["parent"], "autoname") - if naming_series: - if "#" in naming_series: - self.update_naming_series(naming_series, docfield["parent"]) + pending_doctypes = frappe.db.get_all( + "Transaction Deletion Record Details", + filters={"parent": self.name, "done": 0}, + pluck="doctype_name", + ) + if pending_doctypes: + # as method is enqueued after commit, calling itself will not make validate_doc_status to throw + # recursively call this task to delete all transactions + self.enqueue_task(task="Delete Transactions") + else: + self.db_set("status", "Completed") + self.db_set("delete_transactions", 1) + self.db_set("error_log", None) def get_doctypes_to_be_ignored_list(self): singles = frappe.get_all("DocType", filters={"issingle": 1}, pluck="name") @@ -155,25 +330,24 @@ class TransactionDeletionRecord(Document): def get_number_of_docs_linked_with_specified_company(self, doctype, company_fieldname): return frappe.db.count(doctype, {company_fieldname: self.company}) - def populate_doctypes_table(self, tables, doctype, no_of_docs): + def populate_doctypes_table(self, tables, doctype, fieldname, no_of_docs): + self.flags.ignore_validate_update_after_submit = True if doctype not in tables: - self.append("doctypes", {"doctype_name": doctype, "no_of_docs": no_of_docs}) - - def delete_child_tables(self, doctype, company_fieldname): - parent_docs_to_be_deleted = frappe.get_all( - doctype, {company_fieldname: self.company}, pluck="name" - ) + self.append( + "doctypes", {"doctype_name": doctype, "docfield_name": fieldname, "no_of_docs": no_of_docs} + ) + self.save(ignore_permissions=True) + def delete_child_tables(self, doctype, reference_doc_names): child_tables = frappe.get_all( "DocField", filters={"fieldtype": "Table", "parent": doctype}, pluck="options" ) - for batch in create_batch(parent_docs_to_be_deleted, self.batch_size): - for table in child_tables: - frappe.db.delete(table, {"parent": ["in", batch]}) + for table in child_tables: + frappe.db.delete(table, {"parent": ["in", reference_doc_names]}) - def delete_docs_linked_with_specified_company(self, doctype, company_fieldname): - frappe.db.delete(doctype, {company_fieldname: self.company}) + def delete_docs_linked_with_specified_company(self, doctype, reference_doc_names): + frappe.db.delete(doctype, {"name": ("in", reference_doc_names)}) def update_naming_series(self, naming_series, doctype_name): if "." in naming_series: @@ -194,17 +368,11 @@ class TransactionDeletionRecord(Document): frappe.db.sql("""update `tabSeries` set current = %s where name=%s""", (last, prefix)) - def delete_version_log(self, doctype, company_fieldname): - dt = qb.DocType(doctype) - names = qb.from_(dt).select(dt.name).where(dt[company_fieldname] == self.company).run(as_list=1) - names = [x[0] for x in names] - - if names: - versions = qb.DocType("Version") - for batch in create_batch(names, self.batch_size): - qb.from_(versions).delete().where( - (versions.ref_doctype == doctype) & (versions.docname.isin(batch)) - ).run() + def delete_version_log(self, doctype, docnames): + versions = qb.DocType("Version") + qb.from_(versions).delete().where( + (versions.ref_doctype == doctype) & (versions.docname.isin(docnames)) + ).run() def delete_communications(self, doctype, reference_doc_names): communications = frappe.get_all( @@ -276,3 +444,34 @@ def get_doctypes_to_be_ignored(): doctypes_to_be_ignored.extend(frappe.get_hooks("company_data_to_be_ignored") or []) return doctypes_to_be_ignored + + +@frappe.whitelist() +def is_deletion_doc_running(company: str | None = None, err_msg: str | None = None): + if company: + if running_deletion_jobs := frappe.db.get_all( + "Transaction Deletion Record", + filters={"docstatus": 1, "company": company, "status": "Running"}, + ): + if not err_msg: + err_msg = "" + frappe.throw( + title=_("Deletion in Progress!"), + msg=_("Transaction Deletion Document: {0} is running for this Company. {1}").format( + get_link_to_form("Transaction Deletion Record", running_deletion_jobs[0].name), err_msg + ), + ) + + +def check_for_running_deletion_job(doc, method=None): + # Check if DocType has 'company' field + df = qb.DocType("DocField") + if ( + not_allowed := qb.from_(df) + .select(df.parent) + .where((df.fieldname == "company") & (df.parent == doc.doctype)) + .run() + ): + is_deletion_doc_running( + doc.company, _("Cannot make any transactions until the deletion job is completed") + ) diff --git a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record_list.js b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record_list.js index 08a35df2c17..285cb6dd221 100644 --- a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record_list.js +++ b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record_list.js @@ -2,11 +2,15 @@ // License: GNU General Public License v3. See license.txt frappe.listview_settings["Transaction Deletion Record"] = { + add_fields: ["status"], get_indicator: function (doc) { - if (doc.docstatus == 0) { - return [__("Draft"), "red"]; - } else { - return [__("Completed"), "green"]; - } + let colors = { + Queued: "orange", + Completed: "green", + Running: "blue", + Failed: "red", + }; + let status = doc.status; + return [__(status), colors[status], "status,=," + status]; }, }; diff --git a/erpnext/setup/doctype/transaction_deletion_record_item/transaction_deletion_record_item.json b/erpnext/setup/doctype/transaction_deletion_record_item/transaction_deletion_record_item.json index be0be945c4e..89db63694c2 100644 --- a/erpnext/setup/doctype/transaction_deletion_record_item/transaction_deletion_record_item.json +++ b/erpnext/setup/doctype/transaction_deletion_record_item/transaction_deletion_record_item.json @@ -5,8 +5,7 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ - "doctype_name", - "no_of_docs" + "doctype_name" ], "fields": [ { @@ -16,18 +15,12 @@ "label": "DocType", "options": "DocType", "reqd": 1 - }, - { - "fieldname": "no_of_docs", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Number of Docs" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-05-08 23:10:46.166744", + "modified": "2024-02-04 10:56:27.413691", "modified_by": "Administrator", "module": "Setup", "name": "Transaction Deletion Record Item", @@ -35,5 +28,6 @@ "permissions": [], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file