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"