From e7ffb2b3c8ad905cedffa42229be10867be0c2c6 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Mon, 5 Jan 2026 17:05:43 +0530 Subject: [PATCH 1/8] feat: add bulk_update and bulk_delete endpoints to /api/v2 --- frappe/api/v2.py | 204 +++++++++++++++++++++++++++++ frappe/tests/test_api_v2.py | 253 ++++++++++++++++++++++++++++++++++++ 2 files changed, 457 insertions(+) diff --git a/frappe/api/v2.py b/frappe/api/v2.py index 88bfe86527..e0d944fc87 100644 --- a/frappe/api/v2.py +++ b/frappe/api/v2.py @@ -235,6 +235,206 @@ def execute_doc_method(doctype: str, name: str, method: str | None = None): return result +def bulk_delete_docs(doctype: str): + """Bulk delete multiple documents of the same doctype. + + Request body should contain: + names: List of document names to delete + + Returns: + deleted: List of successfully deleted document names + failed: List of failed deletions with error messages + total: Total number of documents attempted + success_count: Number of successful deletions + failure_count: Number of failed deletions + """ + data = frappe.form_dict + names = frappe.parse_json(data.get("names", "[]")) + + if not isinstance(names, list): + frappe.throw(_("'names' must be an array")) + + deleted = [] + failed = [] + + for name in names: + try: + frappe.delete_doc(doctype, name, ignore_missing=False) + deleted.append(name) + except Exception as e: + failed.append({"name": name, "error": str(e)}) + + return { + "deleted": deleted, + "failed": failed, + "total": len(names), + "success_count": len(deleted), + "failure_count": len(failed), + } + + +def bulk_delete(): + """Bulk delete documents across multiple doctypes. + + Request body should contain: + documents: List of {"doctype": str, "name": str} objects + + Returns: + deleted: List of successfully deleted documents + failed: List of failed deletions with error messages + total: Total number of documents attempted + success_count: Number of successful deletions + failure_count: Number of failed deletions + """ + data = frappe.form_dict + documents = frappe.parse_json(data.get("documents", "[]")) + + if not isinstance(documents, list): + frappe.throw(_("Request body must contain 'documents' as an array")) + + deleted = [] + failed = [] + + for item in documents: + doctype = None + name = None + try: + if not isinstance(item, dict): + raise ValueError(_("Each document must be a dictionary with 'doctype' and 'name' keys")) + + doctype = item.get("doctype") + name = item.get("name") + + if not doctype or not name: + raise ValueError(_("Both 'doctype' and 'name' are required")) + + frappe.delete_doc(doctype, name, ignore_missing=False) + deleted.append({"doctype": doctype, "name": name}) + except Exception as e: + failed.append({"doctype": doctype, "name": name, "error": str(e)}) + + return { + "deleted": deleted, + "failed": failed, + "total": len(documents), + "success_count": len(deleted), + "failure_count": len(failed), + } + + +def bulk_update_docs(doctype: str): + """Bulk update multiple documents of the same doctype. + + Request body should contain: + updates: List of {"name": str, ...fields} objects where each object contains + the document name and the fields to update + + Returns: + updated: List of successfully updated document names + failed: List of failed updates with error messages + total: Total number of documents attempted + success_count: Number of successful updates + failure_count: Number of failed updates + """ + data = frappe.form_dict + updates = frappe.parse_json(data.get("updates", "[]")) + + if not isinstance(updates, list): + frappe.throw(_("'updates' must be an array")) + + updated = [] + failed = [] + + for item in updates: + name = None + try: + if not isinstance(item, dict): + raise ValueError(_("Each update must be a dictionary with 'name' and field values")) + + name = item.get("name") + if not name: + raise ValueError(_("'name' is required")) + + doc = frappe.get_doc(doctype, name, for_update=True) + item_copy = item.copy() + item_copy.pop("name") + item_copy.pop("flags", None) + + doc.update(item_copy) + doc.save() + + updated.append(name) + except Exception as e: + failed.append({"name": name, "error": str(e)}) + + return { + "updated": updated, + "failed": failed, + "total": len(updates), + "success_count": len(updated), + "failure_count": len(failed), + } + + +def bulk_update(): + """Bulk update documents across multiple doctypes. + + Request body should contain: + documents: List of {"doctype": str, "name": str, ...fields} objects + + Returns: + updated: List of successfully updated documents + failed: List of failed updates with error messages + total: Total number of documents attempted + success_count: Number of successful updates + failure_count: Number of failed updates + """ + data = frappe.form_dict + documents = frappe.parse_json(data.get("documents", "[]")) + + if not isinstance(documents, list): + frappe.throw(_("Request body must contain 'documents' as an array")) + + updated = [] + failed = [] + + for item in documents: + doctype = None + name = None + try: + if not isinstance(item, dict): + raise ValueError( + _("Each document must be a dictionary with 'doctype', 'name', and field values") + ) + + doctype = item.get("doctype") + name = item.get("name") + + if not doctype or not name: + raise ValueError(_("Both 'doctype' and 'name' are required")) + + doc = frappe.get_doc(doctype, name, for_update=True) + item_copy = item.copy() + item_copy.pop("doctype") + item_copy.pop("name") + item_copy.pop("flags", None) + + doc.update(item_copy) + doc.save() + + updated.append({"doctype": doctype, "name": name}) + except Exception as e: + failed.append({"doctype": doctype, "name": name, "error": str(e)}) + + return { + "updated": updated, + "failed": failed, + "total": len(documents), + "success_count": len(updated), + "failure_count": len(failed), + } + + def run_doc_method(method: str, document: dict[str, Any] | str, kwargs=None): """run a whitelisted controller method on in-memory document. @@ -272,6 +472,8 @@ url_rules = [ Rule("/method/logout", endpoint=logout, methods=["POST"]), Rule("/method/ping", endpoint=frappe.ping), Rule("/method/upload_file", endpoint=upload_file, methods=["POST"]), + Rule("/method/bulk_delete", endpoint=bulk_delete, methods=["POST"]), + Rule("/method/bulk_update", endpoint=bulk_update, methods=["POST"]), Rule("/method/", endpoint=handle_rpc_call), Rule( "/method/run_doc_method", @@ -282,6 +484,8 @@ url_rules = [ # Document level APIs Rule("/document/", methods=["GET"], endpoint=document_list), Rule("/document/", methods=["POST"], endpoint=create_doc), + Rule("/document//bulk_delete", methods=["POST"], endpoint=bulk_delete_docs), + Rule("/document//bulk_update", methods=["POST"], endpoint=bulk_update_docs), Rule("/document///", methods=["GET"], endpoint=read_doc), Rule("/document///copy", methods=["GET"], endpoint=copy_doc), Rule("/document///", methods=["PATCH", "PUT"], endpoint=update_doc), diff --git a/frappe/tests/test_api_v2.py b/frappe/tests/test_api_v2.py index d763dd0566..5d82955e4d 100644 --- a/frappe/tests/test_api_v2.py +++ b/frappe/tests/test_api_v2.py @@ -265,6 +265,259 @@ class TestMethodAPIV2(FrappeAPITestCase): self.assertEqual(response["data"]["content"], comment_txt) +class TestBulkOperationsV2(FrappeAPITestCase): + """Test bulk delete and bulk update endpoints""" + + version = "v2" + DOCTYPE = "ToDo" + GENERATED_DOCUMENTS: typing.ClassVar[list] = [] + + @classmethod + def setUpClass(cls): + super().setUpClass() + # Create test documents + for i in range(10): + doc = frappe.get_doc({"doctype": "ToDo", "description": f"Test bulk operations {i}"}).insert() + cls.GENERATED_DOCUMENTS.append(doc.name) + frappe.db.commit() + + @classmethod + def tearDownClass(cls): + frappe.db.commit() + for name in cls.GENERATED_DOCUMENTS: + frappe.delete_doc_if_exists(cls.DOCTYPE, name) + frappe.db.commit() + + def setUp(self) -> None: + self.post(self.method("login"), {"sid": self.sid}) + return super().setUp() + + def test_bulk_delete_docs_single_doctype(self): + # Create docs to delete + doc1 = frappe.get_doc({"doctype": self.DOCTYPE, "description": "To delete 1"}).insert() + doc2 = frappe.get_doc({"doctype": self.DOCTYPE, "description": "To delete 2"}).insert() + frappe.db.commit() + + # Bulk delete + response = self.post( + self.resource(self.DOCTYPE, "bulk_delete"), + {"names": frappe.as_json([doc1.name, doc2.name]), "sid": self.sid}, + ) + + self.assertEqual(response.status_code, 200) + data = response.json["data"] + self.assertEqual(data["total"], 2) + self.assertEqual(data["success_count"], 2) + self.assertEqual(data["failure_count"], 0) + self.assertIn(doc1.name, data["deleted"]) + self.assertIn(doc2.name, data["deleted"]) + + # Verify deletion + self.assertFalse(frappe.db.exists(self.DOCTYPE, doc1.name)) + self.assertFalse(frappe.db.exists(self.DOCTYPE, doc2.name)) + + def test_bulk_delete_docs_partial_failure(self): + # Create one valid doc + doc = frappe.get_doc({"doctype": self.DOCTYPE, "description": "To delete"}).insert() + frappe.db.commit() + + # Try to delete valid and non-existent doc + non_existent = "non-existent-todo" + response = self.post( + self.resource(self.DOCTYPE, "bulk_delete"), + {"names": frappe.as_json([doc.name, non_existent]), "sid": self.sid}, + ) + + self.assertEqual(response.status_code, 200) + data = response.json["data"] + self.assertEqual(data["total"], 2) + self.assertEqual(data["success_count"], 1) + self.assertEqual(data["failure_count"], 1) + self.assertIn(doc.name, data["deleted"]) + self.assertEqual(len(data["failed"]), 1) + self.assertEqual(data["failed"][0]["name"], non_existent) + + def test_bulk_delete_cross_doctype(self): + # Create docs of different types + todo = frappe.get_doc({"doctype": "ToDo", "description": "Test"}).insert() + note = frappe.get_doc({"doctype": "Note", "title": "Test Note", "content": "Test"}).insert() + frappe.db.commit() + + # Bulk delete across doctypes + response = self.post( + self.method("bulk_delete"), + { + "documents": frappe.as_json( + [ + {"doctype": "ToDo", "name": todo.name}, + {"doctype": "Note", "name": note.name}, + ] + ), + "sid": self.sid, + }, + ) + + self.assertEqual(response.status_code, 200) + data = response.json["data"] + self.assertEqual(data["total"], 2) + self.assertEqual(data["success_count"], 2) + self.assertEqual(data["failure_count"], 0) + + # Verify deletion + self.assertFalse(frappe.db.exists("ToDo", todo.name)) + self.assertFalse(frappe.db.exists("Note", note.name)) + + def test_bulk_delete_invalid_format(self): + # Test with invalid format (not a list) + response = self.post( + self.method("bulk_delete"), + {"documents": frappe.as_json({"doctype": "ToDo", "name": "test"}), "sid": self.sid}, + ) + self.assertEqual(response.status_code, 417) + + # Test with invalid document format (not dict) + response = self.post( + self.method("bulk_delete"), + {"documents": frappe.as_json(["invalid-item"]), "sid": self.sid}, + ) + self.assertEqual(response.status_code, 200) + data = response.json["data"] + self.assertEqual(data["failure_count"], 1) + + def test_bulk_update_docs_single_doctype(self): + # Create fresh docs for this test + doc1 = frappe.get_doc({"doctype": self.DOCTYPE, "description": "Original 1"}).insert() + doc2 = frappe.get_doc({"doctype": self.DOCTYPE, "description": "Original 2"}).insert() + frappe.db.commit() + + try: + # Bulk update + response = self.post( + self.resource(self.DOCTYPE, "bulk_update"), + { + "updates": frappe.as_json( + [ + {"name": doc1.name, "description": "Updated description 1", "priority": "High"}, + {"name": doc2.name, "description": "Updated description 2", "priority": "Low"}, + ] + ), + "sid": self.sid, + }, + ) + + self.assertEqual(response.status_code, 200) + data = response.json["data"] + self.assertEqual(data["total"], 2) + self.assertEqual(data["success_count"], 2) + self.assertEqual(data["failure_count"], 0) + self.assertIn(doc1.name, data["updated"]) + self.assertIn(doc2.name, data["updated"]) + + # Verify updates + updated_doc1 = frappe.get_doc(self.DOCTYPE, doc1.name) + updated_doc2 = frappe.get_doc(self.DOCTYPE, doc2.name) + self.assertEqual(updated_doc1.description, "Updated description 1") + self.assertEqual(updated_doc1.priority, "High") + self.assertEqual(updated_doc2.description, "Updated description 2") + self.assertEqual(updated_doc2.priority, "Low") + finally: + frappe.delete_doc_if_exists(self.DOCTYPE, doc1.name) + frappe.delete_doc_if_exists(self.DOCTYPE, doc2.name) + frappe.db.commit() + + def test_bulk_update_cross_doctype(self): + # Create test documents + todo = frappe.get_doc({"doctype": "ToDo", "description": "Test"}).insert() + note = frappe.get_doc({"doctype": "Note", "title": "Test", "content": "Test"}).insert() + frappe.db.commit() + + try: + # Bulk update across doctypes + response = self.post( + self.method("bulk_update"), + { + "documents": frappe.as_json( + [ + {"doctype": "ToDo", "name": todo.name, "description": "Updated ToDo"}, + {"doctype": "Note", "name": note.name, "title": "Updated Note"}, + ] + ), + "sid": self.sid, + }, + ) + + self.assertEqual(response.status_code, 200) + data = response.json["data"] + self.assertEqual(data["total"], 2) + self.assertEqual(data["success_count"], 2) + self.assertEqual(data["failure_count"], 0) + + # Verify updates + updated_todo = frappe.get_doc("ToDo", todo.name) + updated_note = frappe.get_doc("Note", note.name) + self.assertEqual(updated_todo.description, "Updated ToDo") + self.assertEqual(updated_note.title, "Updated Note") + finally: + frappe.delete_doc_if_exists("ToDo", todo.name) + frappe.delete_doc_if_exists("Note", note.name) + frappe.db.commit() + + def test_bulk_update_partial_failure(self): + # Create a fresh doc for this test + doc = frappe.get_doc({"doctype": self.DOCTYPE, "description": "Original"}).insert() + frappe.db.commit() + valid_doc = doc.name + non_existent = "non-existent-todo" + + try: + # Try to update valid and non-existent doc + response = self.post( + self.resource(self.DOCTYPE, "bulk_update"), + { + "updates": frappe.as_json( + [ + {"name": valid_doc, "description": "Updated"}, + {"name": non_existent, "description": "Should fail"}, + ] + ), + "sid": self.sid, + }, + ) + + self.assertEqual(response.status_code, 200) + data = response.json["data"] + self.assertEqual(data["total"], 2) + self.assertEqual(data["success_count"], 1) + self.assertEqual(data["failure_count"], 1) + self.assertIn(valid_doc, data["updated"]) + self.assertEqual(len(data["failed"]), 1) + self.assertEqual(data["failed"][0]["name"], non_existent) + + # Verify successful update + updated_doc = frappe.get_doc(self.DOCTYPE, valid_doc) + self.assertEqual(updated_doc.description, "Updated") + finally: + frappe.delete_doc_if_exists(self.DOCTYPE, valid_doc) + frappe.db.commit() + + def test_bulk_update_invalid_format(self): + # Test with invalid format (not a list) + response = self.post( + self.resource(self.DOCTYPE, "bulk_update"), + {"updates": frappe.as_json({"name": "test", "description": "test"}), "sid": self.sid}, + ) + self.assertEqual(response.status_code, 417) + + # Test with missing name field + response = self.post( + self.resource(self.DOCTYPE, "bulk_update"), + {"updates": frappe.as_json([{"description": "test"}]), "sid": self.sid}, + ) + self.assertEqual(response.status_code, 200) + data = response.json["data"] + self.assertEqual(data["failure_count"], 1) + + class TestDocTypeAPIV2(FrappeAPITestCase): version = "v2" From 3ebccb670bc19f7e4e87b38dfa5771828d35fd43 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Thu, 8 Jan 2026 01:12:25 +0530 Subject: [PATCH 2/8] fix: unused code and ignore semgrep warning db.commit is required so that records are inserted into the db and request calls are able to fetch those inserted documents --- frappe/tests/test_api_v2.py | 35 +++++++++-------------------------- 1 file changed, 9 insertions(+), 26 deletions(-) diff --git a/frappe/tests/test_api_v2.py b/frappe/tests/test_api_v2.py index 5d82955e4d..db379c8d80 100644 --- a/frappe/tests/test_api_v2.py +++ b/frappe/tests/test_api_v2.py @@ -270,23 +270,6 @@ class TestBulkOperationsV2(FrappeAPITestCase): version = "v2" DOCTYPE = "ToDo" - GENERATED_DOCUMENTS: typing.ClassVar[list] = [] - - @classmethod - def setUpClass(cls): - super().setUpClass() - # Create test documents - for i in range(10): - doc = frappe.get_doc({"doctype": "ToDo", "description": f"Test bulk operations {i}"}).insert() - cls.GENERATED_DOCUMENTS.append(doc.name) - frappe.db.commit() - - @classmethod - def tearDownClass(cls): - frappe.db.commit() - for name in cls.GENERATED_DOCUMENTS: - frappe.delete_doc_if_exists(cls.DOCTYPE, name) - frappe.db.commit() def setUp(self) -> None: self.post(self.method("login"), {"sid": self.sid}) @@ -296,7 +279,7 @@ class TestBulkOperationsV2(FrappeAPITestCase): # Create docs to delete doc1 = frappe.get_doc({"doctype": self.DOCTYPE, "description": "To delete 1"}).insert() doc2 = frappe.get_doc({"doctype": self.DOCTYPE, "description": "To delete 2"}).insert() - frappe.db.commit() + frappe.db.commit() # nosemgrep # Bulk delete response = self.post( @@ -319,7 +302,7 @@ class TestBulkOperationsV2(FrappeAPITestCase): def test_bulk_delete_docs_partial_failure(self): # Create one valid doc doc = frappe.get_doc({"doctype": self.DOCTYPE, "description": "To delete"}).insert() - frappe.db.commit() + frappe.db.commit() # nosemgrep # Try to delete valid and non-existent doc non_existent = "non-existent-todo" @@ -341,7 +324,7 @@ class TestBulkOperationsV2(FrappeAPITestCase): # Create docs of different types todo = frappe.get_doc({"doctype": "ToDo", "description": "Test"}).insert() note = frappe.get_doc({"doctype": "Note", "title": "Test Note", "content": "Test"}).insert() - frappe.db.commit() + frappe.db.commit() # nosemgrep # Bulk delete across doctypes response = self.post( @@ -388,7 +371,7 @@ class TestBulkOperationsV2(FrappeAPITestCase): # Create fresh docs for this test doc1 = frappe.get_doc({"doctype": self.DOCTYPE, "description": "Original 1"}).insert() doc2 = frappe.get_doc({"doctype": self.DOCTYPE, "description": "Original 2"}).insert() - frappe.db.commit() + frappe.db.commit() # nosemgrep try: # Bulk update @@ -423,13 +406,13 @@ class TestBulkOperationsV2(FrappeAPITestCase): finally: frappe.delete_doc_if_exists(self.DOCTYPE, doc1.name) frappe.delete_doc_if_exists(self.DOCTYPE, doc2.name) - frappe.db.commit() + frappe.db.commit() # nosemgrep def test_bulk_update_cross_doctype(self): # Create test documents todo = frappe.get_doc({"doctype": "ToDo", "description": "Test"}).insert() note = frappe.get_doc({"doctype": "Note", "title": "Test", "content": "Test"}).insert() - frappe.db.commit() + frappe.db.commit() # nosemgrep try: # Bulk update across doctypes @@ -460,12 +443,12 @@ class TestBulkOperationsV2(FrappeAPITestCase): finally: frappe.delete_doc_if_exists("ToDo", todo.name) frappe.delete_doc_if_exists("Note", note.name) - frappe.db.commit() + frappe.db.commit() # nosemgrep def test_bulk_update_partial_failure(self): # Create a fresh doc for this test doc = frappe.get_doc({"doctype": self.DOCTYPE, "description": "Original"}).insert() - frappe.db.commit() + frappe.db.commit() # nosemgrep valid_doc = doc.name non_existent = "non-existent-todo" @@ -498,7 +481,7 @@ class TestBulkOperationsV2(FrappeAPITestCase): self.assertEqual(updated_doc.description, "Updated") finally: frappe.delete_doc_if_exists(self.DOCTYPE, valid_doc) - frappe.db.commit() + frappe.db.commit() # nosemgrep def test_bulk_update_invalid_format(self): # Test with invalid format (not a list) From 9d323178f8f6cb30126f461c8868cfee3dc0ded6 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Thu, 8 Jan 2026 01:29:44 +0530 Subject: [PATCH 3/8] fix: return updated doc in response.docs --- frappe/api/v2.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frappe/api/v2.py b/frappe/api/v2.py index e0d944fc87..f09df4b210 100644 --- a/frappe/api/v2.py +++ b/frappe/api/v2.py @@ -362,8 +362,10 @@ def bulk_update_docs(doctype: str): doc.update(item_copy) doc.save() + doc.apply_fieldlevel_read_permissions() updated.append(name) + frappe.response.docs.append(doc.as_dict()) except Exception as e: failed.append({"name": name, "error": str(e)}) @@ -421,8 +423,10 @@ def bulk_update(): doc.update(item_copy) doc.save() + doc.apply_fieldlevel_read_permissions() updated.append({"doctype": doctype, "name": name}) + frappe.response.docs.append(doc.as_dict()) except Exception as e: failed.append({"doctype": doctype, "name": name, "error": str(e)}) From c2541994755a8b2a0b7e667d9fd7de43c867d219 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Thu, 8 Jan 2026 01:43:31 +0530 Subject: [PATCH 4/8] fix: simplify naming documents -> docs updates -> docs --- frappe/api/v2.py | 38 ++++++++++++++++++------------------- frappe/tests/test_api_v2.py | 16 ++++++++-------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/frappe/api/v2.py b/frappe/api/v2.py index f09df4b210..a16ebaded5 100644 --- a/frappe/api/v2.py +++ b/frappe/api/v2.py @@ -277,7 +277,7 @@ def bulk_delete(): """Bulk delete documents across multiple doctypes. Request body should contain: - documents: List of {"doctype": str, "name": str} objects + docs: List of {"doctype": str, "name": str} objects Returns: deleted: List of successfully deleted documents @@ -287,15 +287,15 @@ def bulk_delete(): failure_count: Number of failed deletions """ data = frappe.form_dict - documents = frappe.parse_json(data.get("documents", "[]")) + docs = frappe.parse_json(data.get("docs", "[]")) - if not isinstance(documents, list): - frappe.throw(_("Request body must contain 'documents' as an array")) + if not isinstance(docs, list): + frappe.throw(_("Request body must contain 'docs' as an array")) deleted = [] failed = [] - for item in documents: + for item in docs: doctype = None name = None try: @@ -316,7 +316,7 @@ def bulk_delete(): return { "deleted": deleted, "failed": failed, - "total": len(documents), + "total": len(docs), "success_count": len(deleted), "failure_count": len(failed), } @@ -326,8 +326,8 @@ def bulk_update_docs(doctype: str): """Bulk update multiple documents of the same doctype. Request body should contain: - updates: List of {"name": str, ...fields} objects where each object contains - the document name and the fields to update + docs: List of {"name": str, ...fields} objects where each object contains + the document name and the fields to update Returns: updated: List of successfully updated document names @@ -337,15 +337,15 @@ def bulk_update_docs(doctype: str): failure_count: Number of failed updates """ data = frappe.form_dict - updates = frappe.parse_json(data.get("updates", "[]")) + docs = frappe.parse_json(data.get("docs", "[]")) - if not isinstance(updates, list): - frappe.throw(_("'updates' must be an array")) + if not isinstance(docs, list): + frappe.throw(_("'docs' must be an array")) updated = [] failed = [] - for item in updates: + for item in docs: name = None try: if not isinstance(item, dict): @@ -372,7 +372,7 @@ def bulk_update_docs(doctype: str): return { "updated": updated, "failed": failed, - "total": len(updates), + "total": len(docs), "success_count": len(updated), "failure_count": len(failed), } @@ -382,7 +382,7 @@ def bulk_update(): """Bulk update documents across multiple doctypes. Request body should contain: - documents: List of {"doctype": str, "name": str, ...fields} objects + docs: List of {"doctype": str, "name": str, ...fields} objects Returns: updated: List of successfully updated documents @@ -392,15 +392,15 @@ def bulk_update(): failure_count: Number of failed updates """ data = frappe.form_dict - documents = frappe.parse_json(data.get("documents", "[]")) + docs = frappe.parse_json(data.get("docs", "[]")) - if not isinstance(documents, list): - frappe.throw(_("Request body must contain 'documents' as an array")) + if not isinstance(docs, list): + frappe.throw(_("Request body must contain 'docs' as an array")) updated = [] failed = [] - for item in documents: + for item in docs: doctype = None name = None try: @@ -433,7 +433,7 @@ def bulk_update(): return { "updated": updated, "failed": failed, - "total": len(documents), + "total": len(docs), "success_count": len(updated), "failure_count": len(failed), } diff --git a/frappe/tests/test_api_v2.py b/frappe/tests/test_api_v2.py index db379c8d80..4c4d490c01 100644 --- a/frappe/tests/test_api_v2.py +++ b/frappe/tests/test_api_v2.py @@ -330,7 +330,7 @@ class TestBulkOperationsV2(FrappeAPITestCase): response = self.post( self.method("bulk_delete"), { - "documents": frappe.as_json( + "docs": frappe.as_json( [ {"doctype": "ToDo", "name": todo.name}, {"doctype": "Note", "name": note.name}, @@ -354,14 +354,14 @@ class TestBulkOperationsV2(FrappeAPITestCase): # Test with invalid format (not a list) response = self.post( self.method("bulk_delete"), - {"documents": frappe.as_json({"doctype": "ToDo", "name": "test"}), "sid": self.sid}, + {"docs": frappe.as_json({"doctype": "ToDo", "name": "test"}), "sid": self.sid}, ) self.assertEqual(response.status_code, 417) # Test with invalid document format (not dict) response = self.post( self.method("bulk_delete"), - {"documents": frappe.as_json(["invalid-item"]), "sid": self.sid}, + {"docs": frappe.as_json(["invalid-item"]), "sid": self.sid}, ) self.assertEqual(response.status_code, 200) data = response.json["data"] @@ -378,7 +378,7 @@ class TestBulkOperationsV2(FrappeAPITestCase): response = self.post( self.resource(self.DOCTYPE, "bulk_update"), { - "updates": frappe.as_json( + "docs": frappe.as_json( [ {"name": doc1.name, "description": "Updated description 1", "priority": "High"}, {"name": doc2.name, "description": "Updated description 2", "priority": "Low"}, @@ -419,7 +419,7 @@ class TestBulkOperationsV2(FrappeAPITestCase): response = self.post( self.method("bulk_update"), { - "documents": frappe.as_json( + "docs": frappe.as_json( [ {"doctype": "ToDo", "name": todo.name, "description": "Updated ToDo"}, {"doctype": "Note", "name": note.name, "title": "Updated Note"}, @@ -457,7 +457,7 @@ class TestBulkOperationsV2(FrappeAPITestCase): response = self.post( self.resource(self.DOCTYPE, "bulk_update"), { - "updates": frappe.as_json( + "docs": frappe.as_json( [ {"name": valid_doc, "description": "Updated"}, {"name": non_existent, "description": "Should fail"}, @@ -487,14 +487,14 @@ class TestBulkOperationsV2(FrappeAPITestCase): # Test with invalid format (not a list) response = self.post( self.resource(self.DOCTYPE, "bulk_update"), - {"updates": frappe.as_json({"name": "test", "description": "test"}), "sid": self.sid}, + {"docs": frappe.as_json({"name": "test", "description": "test"}), "sid": self.sid}, ) self.assertEqual(response.status_code, 417) # Test with missing name field response = self.post( self.resource(self.DOCTYPE, "bulk_update"), - {"updates": frappe.as_json([{"description": "test"}]), "sid": self.sid}, + {"docs": frappe.as_json([{"description": "test"}]), "sid": self.sid}, ) self.assertEqual(response.status_code, 200) data = response.json["data"] From b5fbc058cf591c8668d0703fb967350520441a3d Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Thu, 8 Jan 2026 01:47:51 +0530 Subject: [PATCH 5/8] chore: ignore semgrep warning --- frappe/tests/test_api_v2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/tests/test_api_v2.py b/frappe/tests/test_api_v2.py index 4c4d490c01..67d4d7953d 100644 --- a/frappe/tests/test_api_v2.py +++ b/frappe/tests/test_api_v2.py @@ -550,7 +550,7 @@ def generate_admin_keys(): from frappe.core.doctype.user.user import generate_keys generate_keys("Administrator") - frappe.db.commit() + frappe.db.commit() # nosemgrep @whitelist_for_tests() From e67cbaa1436a4f65372371d8325166060eb0dea9 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Fri, 9 Jan 2026 17:46:42 +0530 Subject: [PATCH 6/8] fix: stricter type checking and savepoints - standard error message format - add savepoint for atomic transactions per document - update tests --- frappe/api/v2.py | 70 ++++++++++++++++++++++++++++--------- frappe/tests/test_api_v2.py | 56 ++++++++++++++--------------- 2 files changed, 80 insertions(+), 46 deletions(-) diff --git a/frappe/api/v2.py b/frappe/api/v2.py index a16ebaded5..975b262e09 100644 --- a/frappe/api/v2.py +++ b/frappe/api/v2.py @@ -121,8 +121,17 @@ def document_list(doctype: str) -> list[dict[str, Any]]: start: int = cint(args.get("start", 0)) limit: int = cint(args.get("limit", 20)) group_by: str | None = args.get("group_by", None) - debug: bool = args.get("debug", False) - as_dict: bool = args.get("as_dict", True) + debug: bool = bool(args.get("debug", False)) + as_dict: bool = bool(args.get("as_dict", True)) + + if fields and not isinstance(fields, list): + frappe.throw(_("'fields' must be a list")) + if filters and not isinstance(filters, (list, dict)): + frappe.throw(_("'filters' must be a list or dictionary")) + if order_by and not isinstance(order_by, str): + frappe.throw(_("'order_by' must be a string")) + if group_by and not isinstance(group_by, str): + frappe.throw(_("'group_by' must be a string")) query = frappe.qb.get_query( table=doctype, @@ -249,19 +258,27 @@ def bulk_delete_docs(doctype: str): failure_count: Number of failed deletions """ data = frappe.form_dict - names = frappe.parse_json(data.get("names", "[]")) + names = data.get("names", []) if not isinstance(names, list): - frappe.throw(_("'names' must be an array")) + frappe.throw(_("'names' must be a list")) deleted = [] failed = [] for name in names: + if not isinstance(name, str | int): + failed.append({"name": name, "error": _("'name' must be a string or integer")}) + continue + + savepoint = "bulk_delete_docs" + frappe.db.savepoint(savepoint) + try: frappe.delete_doc(doctype, name, ignore_missing=False) deleted.append(name) except Exception as e: + frappe.db.rollback(save_point=savepoint) failed.append({"name": name, "error": str(e)}) return { @@ -287,10 +304,10 @@ def bulk_delete(): failure_count: Number of failed deletions """ data = frappe.form_dict - docs = frappe.parse_json(data.get("docs", "[]")) + docs = data.get("docs") if not isinstance(docs, list): - frappe.throw(_("Request body must contain 'docs' as an array")) + frappe.throw(_("'docs' must be a list")) deleted = [] failed = [] @@ -298,6 +315,9 @@ def bulk_delete(): for item in docs: doctype = None name = None + savepoint = "bulk_delete" + frappe.db.savepoint(savepoint) + try: if not isinstance(item, dict): raise ValueError(_("Each document must be a dictionary with 'doctype' and 'name' keys")) @@ -305,12 +325,16 @@ def bulk_delete(): doctype = item.get("doctype") name = item.get("name") - if not doctype or not name: - raise ValueError(_("Both 'doctype' and 'name' are required")) + if not isinstance(doctype, str): + raise ValueError(_("'doctype' must be a string")) + + if not isinstance(name, str | int): + raise ValueError(_("'name' must be a string or integer")) frappe.delete_doc(doctype, name, ignore_missing=False) deleted.append({"doctype": doctype, "name": name}) except Exception as e: + frappe.db.rollback(save_point=savepoint) failed.append({"doctype": doctype, "name": name, "error": str(e)}) return { @@ -337,23 +361,26 @@ def bulk_update_docs(doctype: str): failure_count: Number of failed updates """ data = frappe.form_dict - docs = frappe.parse_json(data.get("docs", "[]")) + docs = data.get("docs") if not isinstance(docs, list): - frappe.throw(_("'docs' must be an array")) + frappe.throw(_("'docs' must be a list")) updated = [] failed = [] for item in docs: name = None + savepoint = "bulk_update_docs" + frappe.db.savepoint(savepoint) + try: if not isinstance(item, dict): raise ValueError(_("Each update must be a dictionary with 'name' and field values")) name = item.get("name") - if not name: - raise ValueError(_("'name' is required")) + if not isinstance(name, str | int): + raise ValueError(_("'name' must be a string or integer")) doc = frappe.get_doc(doctype, name, for_update=True) item_copy = item.copy() @@ -367,6 +394,7 @@ def bulk_update_docs(doctype: str): updated.append(name) frappe.response.docs.append(doc.as_dict()) except Exception as e: + frappe.db.rollback(save_point=savepoint) failed.append({"name": name, "error": str(e)}) return { @@ -392,10 +420,10 @@ def bulk_update(): failure_count: Number of failed updates """ data = frappe.form_dict - docs = frappe.parse_json(data.get("docs", "[]")) + docs = data.get("docs") if not isinstance(docs, list): - frappe.throw(_("Request body must contain 'docs' as an array")) + frappe.throw(_("'docs' must be a list")) updated = [] failed = [] @@ -403,6 +431,9 @@ def bulk_update(): for item in docs: doctype = None name = None + savepoint = "bulk_update" + frappe.db.savepoint(savepoint) + try: if not isinstance(item, dict): raise ValueError( @@ -412,8 +443,11 @@ def bulk_update(): doctype = item.get("doctype") name = item.get("name") - if not doctype or not name: - raise ValueError(_("Both 'doctype' and 'name' are required")) + if not isinstance(doctype, str): + raise ValueError(_("'doctype' must be a string")) + + if not isinstance(name, str | int): + raise ValueError(_("'name' must be a string or integer")) doc = frappe.get_doc(doctype, name, for_update=True) item_copy = item.copy() @@ -428,6 +462,7 @@ def bulk_update(): updated.append({"doctype": doctype, "name": name}) frappe.response.docs.append(doc.as_dict()) except Exception as e: + frappe.db.rollback(save_point=savepoint) failed.append({"doctype": doctype, "name": name, "error": str(e)}) return { @@ -452,6 +487,9 @@ def run_doc_method(method: str, document: dict[str, Any] | str, kwargs=None): if isinstance(document, str): document = frappe.parse_json(document) + if not isinstance(document, dict): + frappe.throw(_("'document' must be a dictionary")) + if kwargs is None: kwargs = {} diff --git a/frappe/tests/test_api_v2.py b/frappe/tests/test_api_v2.py index 67d4d7953d..2137a948c7 100644 --- a/frappe/tests/test_api_v2.py +++ b/frappe/tests/test_api_v2.py @@ -284,7 +284,7 @@ class TestBulkOperationsV2(FrappeAPITestCase): # Bulk delete response = self.post( self.resource(self.DOCTYPE, "bulk_delete"), - {"names": frappe.as_json([doc1.name, doc2.name]), "sid": self.sid}, + {"names": [doc1.name, doc2.name], "sid": self.sid}, ) self.assertEqual(response.status_code, 200) @@ -308,7 +308,7 @@ class TestBulkOperationsV2(FrappeAPITestCase): non_existent = "non-existent-todo" response = self.post( self.resource(self.DOCTYPE, "bulk_delete"), - {"names": frappe.as_json([doc.name, non_existent]), "sid": self.sid}, + {"names": [doc.name, non_existent], "sid": self.sid}, ) self.assertEqual(response.status_code, 200) @@ -330,12 +330,10 @@ class TestBulkOperationsV2(FrappeAPITestCase): response = self.post( self.method("bulk_delete"), { - "docs": frappe.as_json( - [ - {"doctype": "ToDo", "name": todo.name}, - {"doctype": "Note", "name": note.name}, - ] - ), + "docs": [ + {"doctype": "ToDo", "name": todo.name}, + {"doctype": "Note", "name": note.name}, + ], "sid": self.sid, }, ) @@ -354,18 +352,20 @@ class TestBulkOperationsV2(FrappeAPITestCase): # Test with invalid format (not a list) response = self.post( self.method("bulk_delete"), - {"docs": frappe.as_json({"doctype": "ToDo", "name": "test"}), "sid": self.sid}, + {"docs": {"doctype": "ToDo", "name": "test"}, "sid": self.sid}, ) self.assertEqual(response.status_code, 417) + self.assertIn("'docs' must be a list", response.json["errors"][0]["message"]) # Test with invalid document format (not dict) response = self.post( self.method("bulk_delete"), - {"docs": frappe.as_json(["invalid-item"]), "sid": self.sid}, + {"docs": ["invalid-item"], "sid": self.sid}, ) self.assertEqual(response.status_code, 200) data = response.json["data"] self.assertEqual(data["failure_count"], 1) + self.assertIn("must be a dictionary", data["failed"][0]["error"]) def test_bulk_update_docs_single_doctype(self): # Create fresh docs for this test @@ -378,12 +378,10 @@ class TestBulkOperationsV2(FrappeAPITestCase): response = self.post( self.resource(self.DOCTYPE, "bulk_update"), { - "docs": frappe.as_json( - [ - {"name": doc1.name, "description": "Updated description 1", "priority": "High"}, - {"name": doc2.name, "description": "Updated description 2", "priority": "Low"}, - ] - ), + "docs": [ + {"name": doc1.name, "description": "Updated description 1", "priority": "High"}, + {"name": doc2.name, "description": "Updated description 2", "priority": "Low"}, + ], "sid": self.sid, }, ) @@ -419,12 +417,10 @@ class TestBulkOperationsV2(FrappeAPITestCase): response = self.post( self.method("bulk_update"), { - "docs": frappe.as_json( - [ - {"doctype": "ToDo", "name": todo.name, "description": "Updated ToDo"}, - {"doctype": "Note", "name": note.name, "title": "Updated Note"}, - ] - ), + "docs": [ + {"doctype": "ToDo", "name": todo.name, "description": "Updated ToDo"}, + {"doctype": "Note", "name": note.name, "title": "Updated Note"}, + ], "sid": self.sid, }, ) @@ -457,12 +453,10 @@ class TestBulkOperationsV2(FrappeAPITestCase): response = self.post( self.resource(self.DOCTYPE, "bulk_update"), { - "docs": frappe.as_json( - [ - {"name": valid_doc, "description": "Updated"}, - {"name": non_existent, "description": "Should fail"}, - ] - ), + "docs": [ + {"name": valid_doc, "description": "Updated"}, + {"name": non_existent, "description": "Should fail"}, + ], "sid": self.sid, }, ) @@ -487,18 +481,20 @@ class TestBulkOperationsV2(FrappeAPITestCase): # Test with invalid format (not a list) response = self.post( self.resource(self.DOCTYPE, "bulk_update"), - {"docs": frappe.as_json({"name": "test", "description": "test"}), "sid": self.sid}, + {"docs": {"name": "test", "description": "test"}, "sid": self.sid}, ) self.assertEqual(response.status_code, 417) + self.assertIn("'docs' must be a list", response.json["errors"][0]["message"]) # Test with missing name field response = self.post( self.resource(self.DOCTYPE, "bulk_update"), - {"docs": frappe.as_json([{"description": "test"}]), "sid": self.sid}, + {"docs": [{"description": "test"}], "sid": self.sid}, ) self.assertEqual(response.status_code, 200) data = response.json["data"] self.assertEqual(data["failure_count"], 1) + self.assertIn("'name' must be a string or integer", data["failed"][0]["error"]) class TestDocTypeAPIV2(FrappeAPITestCase): From 759752d84707bd42a690927adf5f3666e372567d Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Fri, 9 Jan 2026 19:24:38 +0530 Subject: [PATCH 7/8] fix: add enqueue functionality for more than 20 docs - add tests for enqueue functionality - raise FrappeValueError instead of frappe.throw for better static typing --- frappe/api/v2.py | 116 +++++++++++++++++++++++++++--------- frappe/tests/test_api_v2.py | 52 +++++++++++++++- 2 files changed, 139 insertions(+), 29 deletions(-) diff --git a/frappe/api/v2.py b/frappe/api/v2.py index 975b262e09..767b84561a 100644 --- a/frappe/api/v2.py +++ b/frappe/api/v2.py @@ -25,6 +25,10 @@ PERMISSION_MAP = { } +class FrappeValueError(ValueError): + http_status_code = 417 + + def handle_rpc_call(method: str, doctype: str | None = None): from frappe.modules.utils import load_doctype_module @@ -125,13 +129,13 @@ def document_list(doctype: str) -> list[dict[str, Any]]: as_dict: bool = bool(args.get("as_dict", True)) if fields and not isinstance(fields, list): - frappe.throw(_("'fields' must be a list")) + raise FrappeValueError("'fields' must be a list") if filters and not isinstance(filters, (list, dict)): - frappe.throw(_("'filters' must be a list or dictionary")) + raise FrappeValueError("'filters' must be a list or dictionary") if order_by and not isinstance(order_by, str): - frappe.throw(_("'order_by' must be a string")) + raise FrappeValueError("'order_by' must be a string") if group_by and not isinstance(group_by, str): - frappe.throw(_("'group_by' must be a string")) + raise FrappeValueError("'group_by' must be a string") query = frappe.qb.get_query( table=doctype, @@ -257,20 +261,35 @@ def bulk_delete_docs(doctype: str): success_count: Number of successful deletions failure_count: Number of failed deletions """ - data = frappe.form_dict - names = data.get("names", []) + names = frappe.form_dict.get("names") if not isinstance(names, list): - frappe.throw(_("'names' must be a list")) + raise FrappeValueError("'names' must be a list") + if len(names) > 20: + job = frappe.enqueue( + "frappe.api.v2.execute_bulk_delete_docs", + doctype=doctype, + names=names, + ) + frappe.response.http_status_code = 202 + return {"job_id": job.id} + + return execute_bulk_delete_docs(doctype, names) + + +def execute_bulk_delete_docs(doctype: str, names: list[str | int]): deleted = [] failed = [] for name in names: if not isinstance(name, str | int): - failed.append({"name": name, "error": _("'name' must be a string or integer")}) + failed.append({"name": name, "error": "'name' must be a string or integer"}) continue + if isinstance(name, int): + name = str(name) + savepoint = "bulk_delete_docs" frappe.db.savepoint(savepoint) @@ -303,12 +322,23 @@ def bulk_delete(): success_count: Number of successful deletions failure_count: Number of failed deletions """ - data = frappe.form_dict - docs = data.get("docs") + docs = frappe.form_dict.get("docs", []) if not isinstance(docs, list): - frappe.throw(_("'docs' must be a list")) + raise FrappeValueError("'docs' must be a list") + if len(docs) > 20: + job = frappe.enqueue( + "frappe.api.v2.execute_bulk_delete", + docs=docs, + ) + frappe.response.http_status_code = 202 + return {"job_id": job.id} + + return execute_bulk_delete(docs) + + +def execute_bulk_delete(docs: list): deleted = [] failed = [] @@ -320,16 +350,19 @@ def bulk_delete(): try: if not isinstance(item, dict): - raise ValueError(_("Each document must be a dictionary with 'doctype' and 'name' keys")) + raise FrappeValueError("Each document must be a dictionary with 'doctype' and 'name' keys") doctype = item.get("doctype") name = item.get("name") if not isinstance(doctype, str): - raise ValueError(_("'doctype' must be a string")) + raise FrappeValueError("'doctype' must be a string") if not isinstance(name, str | int): - raise ValueError(_("'name' must be a string or integer")) + raise FrappeValueError("'name' must be a string or integer") + + if isinstance(name, int): + name = str(name) frappe.delete_doc(doctype, name, ignore_missing=False) deleted.append({"doctype": doctype, "name": name}) @@ -360,12 +393,24 @@ def bulk_update_docs(doctype: str): success_count: Number of successful updates failure_count: Number of failed updates """ - data = frappe.form_dict - docs = data.get("docs") + docs = frappe.form_dict.get("docs") if not isinstance(docs, list): - frappe.throw(_("'docs' must be a list")) + raise FrappeValueError("'docs' must be a list") + if len(docs) > 20: + job = frappe.enqueue( + "frappe.api.v2.execute_bulk_update_docs", + doctype=doctype, + docs=docs, + ) + frappe.response.http_status_code = 202 + return {"job_id": job.id} + + return execute_bulk_update_docs(doctype, docs) + + +def execute_bulk_update_docs(doctype: str, docs: list): updated = [] failed = [] @@ -376,11 +421,14 @@ def bulk_update_docs(doctype: str): try: if not isinstance(item, dict): - raise ValueError(_("Each update must be a dictionary with 'name' and field values")) + raise FrappeValueError("Each update must be a dictionary with 'name' and field values") name = item.get("name") if not isinstance(name, str | int): - raise ValueError(_("'name' must be a string or integer")) + raise FrappeValueError("'name' must be a string or integer") + + if isinstance(name, int): + name = str(name) doc = frappe.get_doc(doctype, name, for_update=True) item_copy = item.copy() @@ -419,12 +467,23 @@ def bulk_update(): success_count: Number of successful updates failure_count: Number of failed updates """ - data = frappe.form_dict - docs = data.get("docs") + docs = frappe.form_dict.get("docs") if not isinstance(docs, list): - frappe.throw(_("'docs' must be a list")) + raise FrappeValueError("'docs' must be a list") + if len(docs) > 20: + job = frappe.enqueue( + "frappe.api.v2.execute_bulk_update", + docs=docs, + ) + frappe.response.http_status_code = 202 + return {"job_id": job.id} + + return execute_bulk_update(docs) + + +def execute_bulk_update(docs: list): updated = [] failed = [] @@ -436,18 +495,21 @@ def bulk_update(): try: if not isinstance(item, dict): - raise ValueError( - _("Each document must be a dictionary with 'doctype', 'name', and field values") + raise FrappeValueError( + "Each document must be a dictionary with 'doctype', 'name', and field values" ) doctype = item.get("doctype") name = item.get("name") if not isinstance(doctype, str): - raise ValueError(_("'doctype' must be a string")) + raise FrappeValueError("'doctype' must be a string") if not isinstance(name, str | int): - raise ValueError(_("'name' must be a string or integer")) + raise FrappeValueError("'name' must be a string or integer") + + if isinstance(name, int): + name = str(name) doc = frappe.get_doc(doctype, name, for_update=True) item_copy = item.copy() @@ -488,7 +550,7 @@ def run_doc_method(method: str, document: dict[str, Any] | str, kwargs=None): document = frappe.parse_json(document) if not isinstance(document, dict): - frappe.throw(_("'document' must be a dictionary")) + raise FrappeValueError("'document' must be a dictionary") if kwargs is None: kwargs = {} diff --git a/frappe/tests/test_api_v2.py b/frappe/tests/test_api_v2.py index 2137a948c7..cebc47b27b 100644 --- a/frappe/tests/test_api_v2.py +++ b/frappe/tests/test_api_v2.py @@ -355,7 +355,7 @@ class TestBulkOperationsV2(FrappeAPITestCase): {"docs": {"doctype": "ToDo", "name": "test"}, "sid": self.sid}, ) self.assertEqual(response.status_code, 417) - self.assertIn("'docs' must be a list", response.json["errors"][0]["message"]) + self.assertIn("'docs' must be a list", response.json["errors"][0]["exception"]) # Test with invalid document format (not dict) response = self.post( @@ -484,7 +484,7 @@ class TestBulkOperationsV2(FrappeAPITestCase): {"docs": {"name": "test", "description": "test"}, "sid": self.sid}, ) self.assertEqual(response.status_code, 417) - self.assertIn("'docs' must be a list", response.json["errors"][0]["message"]) + self.assertIn("'docs' must be a list", response.json["errors"][0]["exception"]) # Test with missing name field response = self.post( @@ -496,6 +496,54 @@ class TestBulkOperationsV2(FrappeAPITestCase): self.assertEqual(data["failure_count"], 1) self.assertIn("'name' must be a string or integer", data["failed"][0]["error"]) + def test_bulk_enqueue(self): + # Create 25 docs + docs = [ + frappe.get_doc({"doctype": self.DOCTYPE, "description": f"To delete {i}"}).insert() + for i in range(25) + ] + frappe.db.commit() # nosemgrep + + try: + # Bulk delete > 20 docs + names = [doc.name for doc in docs] + response = self.post( + self.resource(self.DOCTYPE, "bulk_delete"), + {"names": names, "sid": self.sid}, + ) + + self.assertEqual(response.status_code, 202) + self.assertIn("job_id", response.json["data"]) + finally: + # Clean up + for doc in docs: + frappe.delete_doc_if_exists(self.DOCTYPE, doc.name) + frappe.db.commit() # nosemgrep + + def test_bulk_update_enqueue(self): + # Create 25 docs + docs = [ + frappe.get_doc({"doctype": self.DOCTYPE, "description": f"To update {i}"}).insert() + for i in range(25) + ] + frappe.db.commit() # nosemgrep + + try: + # Bulk update > 20 docs + updates = [{"name": doc.name, "description": "Updated"} for doc in docs] + response = self.post( + self.resource(self.DOCTYPE, "bulk_update"), + {"docs": updates, "sid": self.sid}, + ) + + self.assertEqual(response.status_code, 202) + self.assertIn("job_id", response.json["data"]) + finally: + # Clean up + for doc in docs: + frappe.delete_doc_if_exists(self.DOCTYPE, doc.name) + frappe.db.commit() # nosemgrep + class TestDocTypeAPIV2(FrappeAPITestCase): version = "v2" From 20901ad9e20d70c9251a96359076bc17e49196f6 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Tue, 13 Jan 2026 16:02:17 +0530 Subject: [PATCH 8/8] fix: configurable async threshold per doctype --- frappe/api/v2.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/frappe/api/v2.py b/frappe/api/v2.py index 767b84561a..be3231fb7d 100644 --- a/frappe/api/v2.py +++ b/frappe/api/v2.py @@ -25,6 +25,17 @@ PERMISSION_MAP = { } +def get_bulk_operation_async_threshold(doctype: str | None = None) -> int: + conf = frappe.conf.get("bulk_operation_async_threshold", 20) + + if isinstance(conf, dict): + value = conf.get(doctype, 20) if doctype else conf.get("*", 20) + else: + value = conf + + return cint(value) + + class FrappeValueError(ValueError): http_status_code = 417 @@ -266,7 +277,7 @@ def bulk_delete_docs(doctype: str): if not isinstance(names, list): raise FrappeValueError("'names' must be a list") - if len(names) > 20: + if len(names) > get_bulk_operation_async_threshold(doctype): job = frappe.enqueue( "frappe.api.v2.execute_bulk_delete_docs", doctype=doctype, @@ -327,7 +338,7 @@ def bulk_delete(): if not isinstance(docs, list): raise FrappeValueError("'docs' must be a list") - if len(docs) > 20: + if len(docs) > get_bulk_operation_async_threshold(): job = frappe.enqueue( "frappe.api.v2.execute_bulk_delete", docs=docs, @@ -398,7 +409,7 @@ def bulk_update_docs(doctype: str): if not isinstance(docs, list): raise FrappeValueError("'docs' must be a list") - if len(docs) > 20: + if len(docs) > get_bulk_operation_async_threshold(doctype): job = frappe.enqueue( "frappe.api.v2.execute_bulk_update_docs", doctype=doctype, @@ -472,7 +483,7 @@ def bulk_update(): if not isinstance(docs, list): raise FrappeValueError("'docs' must be a list") - if len(docs) > 20: + if len(docs) > get_bulk_operation_async_threshold(): job = frappe.enqueue( "frappe.api.v2.execute_bulk_update", docs=docs,