diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py index 41e86656e991..0f400d07a5c3 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 3f0aee90bf4a..e3eafe6bd2a1 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 @@ 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 @@ def gen_fieldname(): 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])) diff --git a/frappe/templates/includes/comments/comments.py b/frappe/templates/includes/comments/comments.py index cf0cf331f9c4..826057201d9d 100644 --- a/frappe/templates/includes/comments/comments.py +++ b/frappe/templates/includes/comments/comments.py @@ -24,7 +24,7 @@ def get_limit(): @frappe.whitelist(allow_guest=True) -# @rate_limit(key="reference_name", limit=get_limit, seconds=60 * 60) +@rate_limit(limit=get_limit, seconds=60 * 60) def add_comment( comment: str, comment_email: str, comment_by: str, reference_doctype: str, reference_name: str, route: str ): diff --git a/package.json b/package.json index 3f2136990c32..f153ad9dd6c3 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "fast-deep-equal": "^2.0.1", "fast-glob": "^3.2.5", "frappe-charts": "2.0.0-rc27", - "frappe-datatable": "1.20.1", + "frappe-datatable": "1.20.2", "frappe-gantt": "^1.2.1", "frappe-quill-image-resize": "^3.0.9", "gemoji": "^8.1.0", diff --git a/yarn.lock b/yarn.lock index 72cccb875807..8cab88db923b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1433,10 +1433,10 @@ frappe-charts@2.0.0-rc27: resolved "https://registry.yarnpkg.com/frappe-charts/-/frappe-charts-2.0.0-rc27.tgz#a04737d36bcce5381b25ad48896c43b02eb62852" integrity sha512-J4WCrHYB6oR4Dfu28aaCxlUu64C/V+qJlNE1E0xpya2/yCeqDZ8LA6pS63SBMOdV2CTP8cJ6Isk5m+rZi9gElA== -frappe-datatable@1.20.1: - version "1.20.1" - resolved "https://registry.yarnpkg.com/frappe-datatable/-/frappe-datatable-1.20.1.tgz#4009ea7b23fb2482729afd21383aa1acb749f35f" - integrity sha512-I1VMI8x1wGQEs6POylo1kuRlG4ZRB58cVD3AP6qVNlgzethh1ELBXCRPredo1cWXDm65IbuVuVsh8eSiuPiUCw== +frappe-datatable@1.20.2: + version "1.20.2" + resolved "https://registry.yarnpkg.com/frappe-datatable/-/frappe-datatable-1.20.2.tgz#5cea64425bf35855ec5f14d916ff4ec2b4ca2bbf" + integrity sha512-XaN96/woV/VyRsWQgnbRmi1XV3lrnZkUWRP9ANNRkJSmniYddJVQKt7K9w6Ilq+vclNvJX96iQc7B6m77w1xXg== dependencies: hyperlist "^1.0.0-beta" lodash "^4.17.5"