diff --git a/frappe/api/v1.py b/frappe/api/v1.py index 0006e5e5d5..744ad89cdb 100644 --- a/frappe/api/v1.py +++ b/frappe/api/v1.py @@ -4,6 +4,7 @@ from werkzeug.routing import Rule import frappe from frappe import _ +from frappe.utils import attach_expanded_links from frappe.utils.data import sbool @@ -11,6 +12,9 @@ def document_list(doctype: str): if frappe.form_dict.get("fields"): frappe.form_dict["fields"] = json.loads(frappe.form_dict["fields"]) + if frappe.form_dict.get("expand"): + frappe.form_dict["expand"] = json.loads(frappe.form_dict["expand"]) + # set limit of records for frappe.get_list frappe.form_dict.setdefault( "limit_page_length", @@ -74,7 +78,35 @@ def read_doc(doctype: str, name: str): doc = frappe.get_doc(doctype, name) doc.check_permission("read") doc.apply_fieldlevel_read_permissions() - return doc + doc_dict = doc.as_dict() + if sbool(frappe.form_dict.get("expand_links")): + get_values_for_link_and_dynamic_link_fields(doc_dict) + get_values_for_table_and_multiselect_fields(doc_dict) + + return doc_dict + + +def get_values_for_link_and_dynamic_link_fields(doc_dict): + meta = frappe.get_meta(doc_dict.doctype) + link_fields = meta.get_link_fields() + meta.get_dynamic_link_fields() + + for field in link_fields: + if not (doc_fieldvalue := getattr(doc_dict, field.fieldname, None)): + continue + + doctype = field.options if field.fieldtype == "Link" else doc_dict.get(field.options) + + link_doc = frappe.get_doc(doctype, doc_fieldvalue) + doc_dict.update({field.fieldname: link_doc}) + + +def get_values_for_table_and_multiselect_fields(doc_dict): + meta = frappe.get_meta(doc_dict.doctype) + table_fields = meta.get_table_fields() + + for field in table_fields: + table_link_fieldnames = [f.fieldname for f in frappe.get_meta(field.options).get_link_fields()] + attach_expanded_links(field.options, doc_dict.get(field.fieldname), table_link_fieldnames) def execute_doc_method(doctype: str, name: str, method: str | None = None): diff --git a/frappe/client.py b/frappe/client.py index 8c893b5ce7..13755531cc 100644 --- a/frappe/client.py +++ b/frappe/client.py @@ -11,7 +11,7 @@ from frappe import _ from frappe.desk.reportview import validate_args from frappe.model.db_query import check_parent_permission from frappe.model.utils import is_virtual_doctype -from frappe.utils import get_safe_filters +from frappe.utils import attach_expanded_links, get_safe_filters from frappe.utils.caching import http_cache if TYPE_CHECKING: @@ -37,6 +37,7 @@ def get_list( debug: bool = False, as_dict: bool = True, or_filters=None, + expand=None, ): """Return a list of records by filters, fields, ordering and limit. @@ -64,7 +65,17 @@ def get_list( ) validate_args(args) - return frappe.get_list(**args) + _list = frappe.get_list(**args) + + if not expand: + return _list + + if fields and not fields[0] == "*": + expand = [f for f in expand if f in fields] + + attach_expanded_links(doctype, _list, expand) + + return _list @frappe.whitelist() diff --git a/frappe/tests/test_api.py b/frappe/tests/test_api.py index 789fb1e15f..b959cf0f7a 100644 --- a/frappe/tests/test_api.py +++ b/frappe/tests/test_api.py @@ -138,13 +138,24 @@ class FrappeAPITestCase(IntegrationTestCase): class TestResourceAPI(FrappeAPITestCase): DOCTYPE = "ToDo" GENERATED_DOCUMENTS: typing.ClassVar[list] = [] + TEST_USER = "test@restapi.com" @classmethod def setUpClass(cls): super().setUpClass() + cls.GENERATED_DOCUMENTS = [] + user = frappe.get_doc( + {"doctype": "User", "email": cls.TEST_USER, "first_name": "Test User", "send_welcome_email": 0} + ).insert(ignore_permissions=True) + for _ in range(20): - doc = frappe.get_doc({"doctype": "ToDo", "description": frappe.mock("paragraph")}).insert() - cls.GENERATED_DOCUMENTS = [] + doc = frappe.get_doc( + { + "doctype": "ToDo", + "description": frappe.mock("paragraph"), + "allocated_to": user.name, + } + ).insert() cls.GENERATED_DOCUMENTS.append(doc.name) frappe.db.commit() @@ -153,6 +164,7 @@ class TestResourceAPI(FrappeAPITestCase): frappe.db.commit() for name in cls.GENERATED_DOCUMENTS: frappe.delete_doc_if_exists(cls.DOCTYPE, name) + frappe.delete_doc_if_exists("User", cls.TEST_USER) frappe.db.commit() def test_unauthorized_call(self): @@ -167,6 +179,35 @@ class TestResourceAPI(FrappeAPITestCase): self.assertIsInstance(response.json, dict) self.assertIn("data", response.json) + def test_get_list_expand(self): + response = self.get( + self.resource(self.DOCTYPE), + { + "sid": self.sid, + "filters": json.dumps([["name", "=", self.GENERATED_DOCUMENTS[0]]]), + "fields": json.dumps(["allocated_to", "name"]), + "expand": json.dumps(["allocated_to"]), + }, + ) + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response.json, dict) + self.assertIn("data", response.json) + self.assertIn("allocated_to", response.json["data"][0]) + self.assertIsInstance(response.json["data"][0]["allocated_to"], dict) + self.assertIn("name", response.json["data"][0]["allocated_to"]) + + def test_get_doc_expand(self): + response = self.get( + self.resource(self.DOCTYPE, self.GENERATED_DOCUMENTS[0]), + { + "expand_links": json.dumps(True), + }, + ) + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response.json, dict) + self.assertIn("data", response.json) + self.assertIsInstance(response.json["data"]["allocated_to"], dict) + def test_get_list_limit(self): # test 3: fetch data with limit response = self.get(self.resource(self.DOCTYPE), {"sid": self.sid, "limit": 2}) diff --git a/frappe/utils/data.py b/frappe/utils/data.py index ad96ca0589..65c260bc26 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -2836,3 +2836,78 @@ def map_trackers(url_trackers: dict, create: bool = False): frappe_trackers["utm_content"] = url_content return frappe_trackers + + +def attach_expanded_links(doctype: str, docs: list, fields_to_expand: list): + """ + Expands specified link or dynamic link fields in a list of documents by replacing + their linked values (names) with full document records. + + This function takes a list of documents and a list of link fieldnames that should be + expanded. For each specified field, it retrieves all referenced linked records from + the corresponding doctypes and replaces the link value in each document with the + full linked record (as a dict). + + Args: + doctype (str): The parent doctype of the provided documents. + docs (list[dict]): A list of document dictionaries whose link fields are to be expanded. + fields_to_expand (list[str]): A list of fieldnames corresponding to link or dynamic + link fields that should be expanded. + + Returns: + None: The function modifies the `docs` list in place. + + Example: + >>> docs = [{"customer": "CUST-001"}, {"customer": "CUST-002"}] + >>> attach_expanded_links("Sales Invoice", docs, ["customer"]) + >>> docs[0]["customer"] + { + "name": "CUST-001", + "customer_name": "John Doe", + "customer_group": "Retail", + ... + } + + """ + + if not fields_to_expand: + return + + meta = frappe.get_meta(doctype) + + link_fields = {f.fieldname: f for f in meta.get_link_fields() + meta.get_dynamic_link_fields()} + + doctype_values = defaultdict(set) + field_to_doctype = {} + + for fieldname in fields_to_expand: + if fieldname not in link_fields: + continue + e = link_fields[fieldname] + link_doctype = e.options + field_to_doctype[fieldname] = link_doctype + + for li in docs: + val = li.get(fieldname) + if val: + doctype_values[link_doctype].add(val) + + doctype_title_maps = {} + + for link_doctype, values in doctype_values.items(): + records = frappe.get_list( + link_doctype, + filters={"name": ["in", list(values)]}, + fields=["*"], + ) + doctype_title_maps[link_doctype] = {r["name"]: r for r in records} + + for li in docs: + for fieldname in fields_to_expand: + if fieldname not in field_to_doctype: + continue + link_doctype = field_to_doctype[fieldname] + val = li.get(fieldname) + val_title = doctype_title_maps.get(link_doctype, {}).get(val) + if val and val_title: + li[fieldname] = val_title