diff --git a/docs/changelog.rst b/docs/changelog.rst index 21cb034..6405d9a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,8 @@ Changelog 2.7.0 (unreleased) ------------------ +- #86 Inject formatted result +- #85 Allow field projection - #81 Support second-level precision on searches against DateIndex - #83 Fetch multiple items by UID - #80 Precise timestamp filtering and sorting for created/modified fields diff --git a/src/senaite/jsonapi/api.py b/src/senaite/jsonapi/api.py index c911618..cf9d6bd 100644 --- a/src/senaite/jsonapi/api.py +++ b/src/senaite/jsonapi/api.py @@ -298,10 +298,16 @@ def make_items_for(brains_or_objects, endpoint=None, complete=False): # check if the user wants to include children include_children = req.get_children(False) + # optional field projection: ?fields=uid,id,title,... + # empty set means no projection (full object returned) + fields = req.get_fields() + def extract_data(brain_or_object): info = get_info(brain_or_object, endpoint=endpoint, complete=complete) if include_children and is_folderish(brain_or_object): info.update(get_children_info(brain_or_object, complete=complete)) + if fields: + info = {k: info[k] for k in fields if k in info} return info return map(extract_data, brains_or_objects) @@ -364,6 +370,14 @@ def get_info(brain_or_object, endpoint=None, complete=False): # add the snapshot version of this content info["version"] = snapshot.get_version(obj) + # Inject formatted result for analysis objects. + # getFormattedResult() handles detection limits, result options, + # scientific notation, etc. html=False gives plain text output + # (e.g. "< 0.1" instead of "< 0.1"). + get_formatted = getattr(obj, "getFormattedResult", None) + if callable(get_formatted): + info["getFormattedResult"] = get_formatted(html=False) + # update the data set with the workflow information # -> only possible if `?complete=yes&workflow=yes` if req.get_workflow(False): diff --git a/src/senaite/jsonapi/request.py b/src/senaite/jsonapi/request.py index a73acef..78551bd 100644 --- a/src/senaite/jsonapi/request.py +++ b/src/senaite/jsonapi/request.py @@ -228,6 +228,20 @@ def get_uids(): return [v.strip() for v in value.split(",") if v.strip()] +def get_fields(): + """Returns the set of field names requested via the 'fields' parameter. + + Accepts a comma-separated string, e.g. ``?fields=uid,id,title``. + Returns an empty set when the parameter is absent or blank. + An empty set means no projection is applied and the full object is + returned (unchanged behaviour). + """ + value = get("fields", "") + if not value: + return set() + return set(v.strip() for v in value.split(",") if v.strip()) + + def get_request_data(): """ extract and convert the json data from the request diff --git a/src/senaite/jsonapi/tests/doctests/field_projection.rst b/src/senaite/jsonapi/tests/doctests/field_projection.rst new file mode 100644 index 0000000..32a83f5 --- /dev/null +++ b/src/senaite/jsonapi/tests/doctests/field_projection.rst @@ -0,0 +1,188 @@ +FIELD PROJECTION +---------------- + +Running this test from the buildout directory: + + bin/test test_doctests -t field_projection + + +Test Setup +~~~~~~~~~~ + +Needed Imports: + + >>> import json + >>> import transaction + >>> from plone.app.testing import setRoles + >>> from plone.app.testing import TEST_USER_ID + >>> from bika.lims import api + +Functional Helpers: + + >>> def get(url): + ... browser.open("{}/{}".format(api_url, url)) + ... return browser.contents + + >>> def get_count(response): + ... data = json.loads(response) + ... return data.get("count") + + >>> def get_item_keys(response, index=0): + ... data = json.loads(response) + ... items = data.get("items", []) + ... if not items: + ... return [] + ... return sorted(items[index].keys()) + +Variables: + + >>> portal = self.portal + >>> portal_url = portal.absolute_url() + >>> api_url = "{}/@@API/senaite/v1".format(portal_url) + >>> browser = self.getBrowser() + >>> setRoles(portal, TEST_USER_ID, ["LabManager", "Manager"]) + +Create two Client objects and commit so they are indexed: + + >>> c1 = api.create(portal.clients, "Client", title="Alpha Lab", ClientID="AL") + >>> c2 = api.create(portal.clients, "Client", title="Beta Lab", ClientID="BL") + >>> uid1 = api.get_uid(c1) + >>> uid2 = api.get_uid(c2) + >>> transaction.commit() + + +Basic projection — only requested fields are returned +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Requesting ``uid,id,title`` returns exactly those three keys in each item: + + >>> response = get("client?fields=uid,id,title") + >>> keys = get_item_keys(response) + >>> "uid" in keys + True + >>> "id" in keys + True + >>> "title" in keys + True + +Fields that were not requested are absent: + + >>> "portal_type" in keys + False + >>> "url" in keys + False + >>> "path" in keys + False + + +Projection with complete=1 +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Field projection applies after object wake-up, so ``complete=1`` fields +are also subject to it: + + >>> response = get("client?complete=1&fields=uid,title,ClientID") + >>> keys = get_item_keys(response) + >>> "uid" in keys + True + >>> "title" in keys + True + >>> "ClientID" in keys + True + +Fields outside the requested set are still excluded: + + >>> "id" in keys + False + >>> "portal_type" in keys + False + + +Requested content is correct +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The projected values match the objects that were created: + + >>> response = get("client?fields=uid,title") + >>> data = json.loads(response) + >>> titles = sorted(item["title"] for item in data["items"]) + >>> titles + [u'Alpha Lab', u'Beta Lab'] + + +Projection with a single requested field +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Requesting only ``title`` returns items with exactly one key: + + >>> response = get("client?fields=title") + >>> keys = get_item_keys(response) + >>> keys + [u'title'] + + +Unknown fields are silently omitted +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A field name that does not exist on the object is ignored rather than +raising an error: + + >>> response = get("client?fields=uid,nonexistent_field") + >>> keys = get_item_keys(response) + >>> "uid" in keys + True + >>> "nonexistent_field" in keys + False + + +No projection when fields parameter is absent +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Without a ``fields`` parameter the full object is returned, including +the standard set of metadata fields: + + >>> response = get("client?uids={}".format(uid1)) + >>> keys = get_item_keys(response) + >>> "uid" in keys + True + >>> "url" in keys + True + >>> "portal_type" in keys + True + + +Projection combined with batch UID fetch +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``?fields=`` and ``?uids=`` can be combined in a single request: + + >>> response = get("client?uids={},{}&fields=uid,title".format(uid1, uid2)) + >>> get_count(response) + 2 + >>> data = json.loads(response) + >>> keys = sorted(data["items"][0].keys()) + >>> keys + [u'title', u'uid'] + >>> "Alpha Lab" in response + True + >>> "Beta Lab" in response + True + + +Projection on the generic /search route +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``fields`` parameter works on the ``/search`` endpoint as well: + + >>> response = get( + ... "search?portal_type=Client&fields=uid,id,title" + ... ) + >>> keys = get_item_keys(response) + >>> "uid" in keys + True + >>> "id" in keys + True + >>> "title" in keys + True + >>> "portal_type" in keys + False