diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py index 41e86656e9..0f400d07a5 100644 --- a/frappe/custom/doctype/custom_field/custom_field.py +++ b/frappe/custom/doctype/custom_field/custom_field.py @@ -443,3 +443,53 @@ def _update_fieldname_references(field: CustomField, old_fieldname: str, new_fie "insert_after", new_fieldname, ) + + +def delete_custom_fields(custom_fields: dict, bypass_hooks: bool = False): + """ + Delete custom fields from doctypes. + + :param custom_fields: Dict mapping doctype to field names. + :param bypass_hooks: If `True`, fast raw delete (skips hooks (doc events like on_trash)). + + Example: + + ``` + delete_custom_fields({"Address": ["custom_a", "custom_b"]}) + + delete_custom_fields({"ToDo": [{"fieldname": "cf_1"}]}, bypass_hooks=True) + ```` + """ + for doctype, fields in custom_fields.items(): + fieldnames = [] + + if isinstance(fields, (list, tuple, set)): + for field in fields: + if isinstance(field, str): + fieldnames.append(field) + elif isinstance(field, dict) and field.get("fieldname"): + fieldnames.append(field["fieldname"]) + + if not fieldnames: + continue + + fieldnames = tuple(set(fieldnames)) + + if bypass_hooks: + frappe.db.delete( + "Custom Field", + { + "fieldname": ("in", fieldnames), + "dt": doctype, + }, + ) + frappe.clear_cache(doctype=doctype) + else: + custom_field_names = frappe.get_all( + "Custom Field", + filters={"fieldname": ("in", fieldnames), "dt": doctype}, + pluck="name", + ) + + for custom_field_name in custom_field_names: + frappe.get_doc("Custom Field", custom_field_name).delete(ignore_permissions=True, force=True) diff --git a/frappe/custom/doctype/custom_field/test_custom_field.py b/frappe/custom/doctype/custom_field/test_custom_field.py index 3f0aee90bf..e3eafe6bd2 100644 --- a/frappe/custom/doctype/custom_field/test_custom_field.py +++ b/frappe/custom/doctype/custom_field/test_custom_field.py @@ -5,8 +5,10 @@ import frappe from frappe.custom.doctype.custom_field.custom_field import ( create_custom_field, create_custom_fields, + delete_custom_fields, rename_fieldname, ) +from frappe.custom.doctype.property_setter.property_setter import make_property_setter from frappe.tests import IntegrationTestCase @@ -183,3 +185,50 @@ class TestCustomField(IntegrationTestCase): self.assertFalse(doc.get(old)) field.delete() + + def test_delete_custom_fields(self): + doctype = "ToDo" + fields = [ + { + "fieldname": f"test_delete_{frappe.generate_hash(length=5)}", + "fieldtype": "Data", + "insert_after": "status", + } + for _ in range(4) + ] + fieldnames = [f["fieldname"] for f in fields] + + create_custom_fields({doctype: fields}) + + # create property setters for fields deleted via safe path (hooks should clean these up) + for fieldname in fieldnames[:2]: + make_property_setter(doctype, fieldname, "hidden", "1", "Check") + + def field_exists(fieldname): + return frappe.db.exists("Custom Field", {"fieldname": fieldname, "dt": doctype}) + + def property_setter_exists(fieldname): + return frappe.db.exists("Property Setter", {"doc_type": doctype, "field_name": fieldname}) + + for fieldname in fieldnames: + self.assertTrue(field_exists(fieldname)) + for fieldname in fieldnames[:2]: + self.assertTrue(property_setter_exists(fieldname)) + + # 1 + delete_custom_fields({doctype: [fieldnames[0], fieldnames[0]]}) + self.assertFalse(field_exists(fieldnames[0])) + self.assertFalse(property_setter_exists(fieldnames[0])) + + # 2 + delete_custom_fields({doctype: [{"fieldname": fieldnames[1]}]}) + self.assertFalse(field_exists(fieldnames[1])) + self.assertFalse(property_setter_exists(fieldnames[1])) + + # 3 + delete_custom_fields({doctype: [fieldnames[2], fieldnames[2]]}, bypass_hooks=True) + self.assertFalse(field_exists(fieldnames[2])) + + # 4 + delete_custom_fields({doctype: [{"fieldname": fieldnames[3]}]}, bypass_hooks=True) + self.assertFalse(field_exists(fieldnames[3]))