From eb8e683c267f826808fab6645acff73833daa4d9 Mon Sep 17 00:00:00 2001 From: Abdeali Chharchhoda Date: Tue, 21 Apr 2026 18:20:16 +0530 Subject: [PATCH 1/8] feat: add delete_custom_fields function to remove custom fields from doctypes --- .../doctype/custom_field/custom_field.py | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py index 41e86656e991..d72d26603f20 100644 --- a/frappe/custom/doctype/custom_field/custom_field.py +++ b/frappe/custom/doctype/custom_field/custom_field.py @@ -443,3 +443,64 @@ def _update_fieldname_references(field: CustomField, old_fieldname: str, new_fie "insert_after", new_fieldname, ) + + +def delete_custom_fields(custom_fields: dict): + """ + Delete custom fields from the given doctypes. + + :param custom_fields: Dictionary of doctypes with fields to be deleted. + + --- + Structure of the `custom_fields` dictionary: + + ```py + # first structure + { + "DocType1": ["field1", "field2", ...], + "DocType2": ["field1", "field2", ...], + ... + } + + # second structure + { + "DocType1": [ + {"fieldname": "field1", ...}, + {"fieldname": "field2", ...}, + ... + ], + "DocType2": [ + {"fieldname": "field1", ...}, + {"fieldname": "field2", ...}, + ... + ], + ... + } + ``` + + """ + 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"]) + + # avoid redundant values in SQL IN clause + fieldnames = list(set(fieldnames)) + + if not fieldnames: + continue + + frappe.db.delete( + "Custom Field", + { + "fieldname": ("in", fieldnames), + "dt": doctype, + }, + ) + + frappe.clear_cache(doctype=doctype) From 0ed3651767b41f36abb327f0ce270dfd67a650f8 Mon Sep 17 00:00:00 2001 From: Abdeali Chharchhoda Date: Tue, 21 Apr 2026 18:21:00 +0530 Subject: [PATCH 2/8] test: add test for delete_custom_fields function --- .../doctype/custom_field/test_custom_field.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/frappe/custom/doctype/custom_field/test_custom_field.py b/frappe/custom/doctype/custom_field/test_custom_field.py index 3f0aee90bf4a..daa95f11b0ab 100644 --- a/frappe/custom/doctype/custom_field/test_custom_field.py +++ b/frappe/custom/doctype/custom_field/test_custom_field.py @@ -5,6 +5,7 @@ from frappe.custom.doctype.custom_field.custom_field import ( create_custom_field, create_custom_fields, + delete_custom_fields, rename_fieldname, ) from frappe.tests import IntegrationTestCase @@ -183,3 +184,36 @@ def gen_fieldname(): self.assertFalse(doc.get(old)) field.delete() + + def test_delete_custom_fields(self): + doctype = "ToDo" + + field_1 = f"test_delete_cf_{frappe.generate_hash(length=6)}" + field_2 = f"test_delete_cf_{frappe.generate_hash(length=6)}" + field_3 = f"test_delete_cf_{frappe.generate_hash(length=6)}" + + create_custom_fields( + { + doctype: [ + {"fieldname": field_1, "fieldtype": "Data", "insert_after": "status"}, + {"fieldname": field_2, "fieldtype": "Data", "insert_after": "priority"}, + {"fieldname": field_3, "fieldtype": "Data", "insert_after": "color"}, + ] + } + ) + + def field_exists(fieldname): + return frappe.db.exists("Custom Field", {"fieldname": fieldname, "dt": doctype}) + + self.assertTrue(field_exists(field_1)) + self.assertTrue(field_exists(field_2)) + self.assertTrue(field_exists(field_3)) + + # delete using first supported structure (list of fieldname strings) + delete_custom_fields({doctype: [field_1, field_1]}) + self.assertFalse(field_exists(field_1)) + + # delete using second supported structure (list of field dicts) + delete_custom_fields({doctype: [{"fieldname": field_2}, {"fieldname": field_3}]}) + self.assertFalse(field_exists(field_2)) + self.assertFalse(field_exists(field_3)) From a0240a3d18a76a15a0c6d3ee1407c4d807629c49 Mon Sep 17 00:00:00 2001 From: Abdeali Chharchhoda Date: Tue, 21 Apr 2026 18:33:46 +0530 Subject: [PATCH 3/8] chore: minor fix --- frappe/custom/doctype/custom_field/custom_field.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py index d72d26603f20..7b05a7ea3b28 100644 --- a/frappe/custom/doctype/custom_field/custom_field.py +++ b/frappe/custom/doctype/custom_field/custom_field.py @@ -489,16 +489,13 @@ def delete_custom_fields(custom_fields: dict): elif isinstance(field, dict) and field.get("fieldname"): fieldnames.append(field["fieldname"]) - # avoid redundant values in SQL IN clause - fieldnames = list(set(fieldnames)) - if not fieldnames: continue frappe.db.delete( "Custom Field", { - "fieldname": ("in", fieldnames), + "fieldname": ("in", set(fieldnames)), "dt": doctype, }, ) From 7fd4451e968f26be9e5967dcc83af855d89cefc4 Mon Sep 17 00:00:00 2001 From: Abdeali Chharchhoda Date: Thu, 23 Apr 2026 10:38:54 +0530 Subject: [PATCH 4/8] refactor: enhance delete_custom_fields function to support bypassing hooks --- .../doctype/custom_field/custom_field.py | 67 +++++++++---------- 1 file changed, 30 insertions(+), 37 deletions(-) diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py index 7b05a7ea3b28..ccc30fc3d266 100644 --- a/frappe/custom/doctype/custom_field/custom_field.py +++ b/frappe/custom/doctype/custom_field/custom_field.py @@ -445,39 +445,20 @@ def _update_fieldname_references(field: CustomField, old_fieldname: str, new_fie ) -def delete_custom_fields(custom_fields: dict): +def delete_custom_fields(custom_fields: dict, bypass_hooks: bool = False): """ - Delete custom fields from the given doctypes. - - :param custom_fields: Dictionary of doctypes with fields to be deleted. - - --- - Structure of the `custom_fields` dictionary: - - ```py - # first structure - { - "DocType1": ["field1", "field2", ...], - "DocType2": ["field1", "field2", ...], - ... - } - - # second structure - { - "DocType1": [ - {"fieldname": "field1", ...}, - {"fieldname": "field2", ...}, - ... - ], - "DocType2": [ - {"fieldname": "field1", ...}, - {"fieldname": "field2", ...}, - ... - ], - ... - } + 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 = [] @@ -492,12 +473,24 @@ def delete_custom_fields(custom_fields: dict): if not fieldnames: continue - frappe.db.delete( - "Custom Field", - { - "fieldname": ("in", set(fieldnames)), - "dt": doctype, - }, - ) + fieldnames = tuple(set(fieldnames)) + + if bypass_hooks: + frappe.db.delete( + "Custom Field", + { + "fieldname": ("in", fieldnames), + "dt": 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) frappe.clear_cache(doctype=doctype) From 9a75ff6fd3f88fec51d67cab7f8bcd04b0868642 Mon Sep 17 00:00:00 2001 From: Abdeali Chharchhoda Date: Thu, 23 Apr 2026 11:03:38 +0530 Subject: [PATCH 5/8] test: enhance delete_custom_fields test to cover multiple deletion methods --- .../doctype/custom_field/test_custom_field.py | 59 ++++++++++++------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/frappe/custom/doctype/custom_field/test_custom_field.py b/frappe/custom/doctype/custom_field/test_custom_field.py index daa95f11b0ab..e3eafe6bd2a1 100644 --- a/frappe/custom/doctype/custom_field/test_custom_field.py +++ b/frappe/custom/doctype/custom_field/test_custom_field.py @@ -8,6 +8,7 @@ delete_custom_fields, rename_fieldname, ) +from frappe.custom.doctype.property_setter.property_setter import make_property_setter from frappe.tests import IntegrationTestCase @@ -187,33 +188,47 @@ def gen_fieldname(): def test_delete_custom_fields(self): doctype = "ToDo" - - field_1 = f"test_delete_cf_{frappe.generate_hash(length=6)}" - field_2 = f"test_delete_cf_{frappe.generate_hash(length=6)}" - field_3 = f"test_delete_cf_{frappe.generate_hash(length=6)}" - - create_custom_fields( + fields = [ { - doctype: [ - {"fieldname": field_1, "fieldtype": "Data", "insert_after": "status"}, - {"fieldname": field_2, "fieldtype": "Data", "insert_after": "priority"}, - {"fieldname": field_3, "fieldtype": "Data", "insert_after": "color"}, - ] + "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}) - self.assertTrue(field_exists(field_1)) - self.assertTrue(field_exists(field_2)) - self.assertTrue(field_exists(field_3)) + 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])) - # delete using first supported structure (list of fieldname strings) - delete_custom_fields({doctype: [field_1, field_1]}) - self.assertFalse(field_exists(field_1)) + # 3 + delete_custom_fields({doctype: [fieldnames[2], fieldnames[2]]}, bypass_hooks=True) + self.assertFalse(field_exists(fieldnames[2])) - # delete using second supported structure (list of field dicts) - delete_custom_fields({doctype: [{"fieldname": field_2}, {"fieldname": field_3}]}) - self.assertFalse(field_exists(field_2)) - self.assertFalse(field_exists(field_3)) + # 4 + delete_custom_fields({doctype: [{"fieldname": fieldnames[3]}]}, bypass_hooks=True) + self.assertFalse(field_exists(fieldnames[3])) From 1f9015a9c287f7557fb3f2bf1db8c4e3eea884ab Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 24 Apr 2026 10:39:14 +0530 Subject: [PATCH 6/8] fix: Re-add rate limit on blog comments (#38862) --- frappe/templates/includes/comments/comments.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ): From 43d749758812078ab1b4e4b913a55cbc72126ef4 Mon Sep 17 00:00:00 2001 From: Ejaaz Khan Date: Fri, 24 Apr 2026 11:15:36 +0530 Subject: [PATCH 7/8] chore: update datatble to 1.20.2 --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) 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" From 1d571827cf396f849a9ef02d72bcb7129b4cd8f1 Mon Sep 17 00:00:00 2001 From: Abdeali Chharchhoda Date: Fri, 24 Apr 2026 12:01:34 +0530 Subject: [PATCH 8/8] chore: clear cache after deleting custom fields --- frappe/custom/doctype/custom_field/custom_field.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py index ccc30fc3d266..0f400d07a5c3 100644 --- a/frappe/custom/doctype/custom_field/custom_field.py +++ b/frappe/custom/doctype/custom_field/custom_field.py @@ -483,6 +483,7 @@ def delete_custom_fields(custom_fields: dict, bypass_hooks: bool = False): "dt": doctype, }, ) + frappe.clear_cache(doctype=doctype) else: custom_field_names = frappe.get_all( "Custom Field", @@ -492,5 +493,3 @@ def delete_custom_fields(custom_fields: dict, bypass_hooks: bool = False): for custom_field_name in custom_field_names: frappe.get_doc("Custom Field", custom_field_name).delete(ignore_permissions=True, force=True) - - frappe.clear_cache(doctype=doctype)