Merge pull request #34027 from sumitbhanushali/expand-link

feat: add support of expand in rest api v1/document_list
This commit is contained in:
Akhil Narang 2025-10-28 17:02:14 +05:30 committed by GitHub
commit 6dfcc2bf4c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 164 additions and 5 deletions

View file

@ -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):

View file

@ -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()

View file

@ -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})

View file

@ -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