diff --git a/docs/changes.rst b/docs/changes.rst
index 82817a2a..0f228dfa 100644
--- a/docs/changes.rst
+++ b/docs/changes.rst
@@ -4,6 +4,12 @@ Changelog
11.0.0 (unreleased)
-------------------
+- Add ``@navtree`` REST API endpoint.
+ [maurits]
+
+- Add ``@tool-versions`` REST API endpoint.
+ [maurits]
+
- Prevent the choice widget to throw the error:
``TypeError: object of type 'CatalogSource' has no len()``
[ale-rt]
diff --git a/src/osha/oira/configure.zcml b/src/osha/oira/configure.zcml
index 3fc15937..2af2dcbd 100644
--- a/src/osha/oira/configure.zcml
+++ b/src/osha/oira/configure.zcml
@@ -35,6 +35,7 @@
+
+
+
+
+
+
+
diff --git a/src/osha/oira/services/navigation.py b/src/osha/oira/services/navigation.py
new file mode 100644
index 00000000..58656b5f
--- /dev/null
+++ b/src/osha/oira/services/navigation.py
@@ -0,0 +1,67 @@
+from plone import api
+from plone.restapi.services import Service
+
+
+class NavigationService(Service):
+ """This JSON service reuses the navigation tile to serve through the
+ REST API the navigation tree of this context.
+
+ The nodes of the tree should be prepared to have a successful
+ JSON serialization.
+
+ This method removes the keys:
+
+ - brain (which is not serializable)
+ - parent (which will create a circular reference)
+
+ and renames some keys to match the plone.restapi standards:
+
+ - portal_type -> @type
+ - url -> @id
+
+ It also fixes the portal_type which is returned normalized
+ with a dash instead of a dot.
+ """
+
+ def fix_node(self, node):
+ """Prepare a node for serialization"""
+ banned_keys = [
+ "brain", # not serializable
+ "parent", # creates a circular reference
+ ]
+ for key in banned_keys:
+ if key in node:
+ del node[key]
+
+ # Rename keys to match plone.restapi standards
+ mapping = {
+ "portal_type": "@type",
+ "url": "@id",
+ "children": "items",
+ }
+ for old_key, new_key in mapping.items():
+ if old_key in node:
+ node[new_key] = node.pop(old_key)
+
+ # Fix the portal_type
+ if "@type" in node:
+ node["@type"] = node["@type"].replace("-", ".")
+
+ # Recurse into children
+ if "items" in node:
+ for child in node["items"]:
+ self.fix_node(child)
+
+ return node
+
+ def reply(self):
+ """We use the navtree tile to get the navigation tree,
+ but we have to fiddle with the nodes to have a proper serialization.
+ """
+ navtree_tile = api.content.get_view("navtree", self.context, self.request)
+ navtree_tile.update()
+ tree = [self.fix_node(node) for node in navtree_tile.tree]
+ return {
+ "@id": self.request.getURL(),
+ "items": tree,
+ }
diff --git a/src/osha/oira/services/surveys.py b/src/osha/oira/services/surveys.py
new file mode 100644
index 00000000..f8b53b73
--- /dev/null
+++ b/src/osha/oira/services/surveys.py
@@ -0,0 +1,85 @@
+from Acquisition import aq_base
+from Acquisition import aq_inner
+from plone import api
+from plone.base.utils import base_hasattr
+from plone.restapi.serializer.converters import json_compatible
+from plone.restapi.services import Service
+
+
+class ToolVersionsGet(Service):
+ """Get info from the oira tool (survey group) and its versions (surveys)."""
+
+ @property
+ def default_image_url(self):
+ portal_url = api.portal.get().absolute_url()
+ return f"{portal_url}/++resource++osha.oira.content/clipboard.svg"
+
+ def get_survey_info(self, survey):
+ # Is this survey the tool version that is published on the client?
+ published_on_client = self.published_tool_version_id == survey.id
+ # Note that if 'published_on_client' is true, the review_state
+ # should be 'published', otherwise 'draft', but this is not always
+ # in sync. So instead of 'api.content.get_state(obj=survey)',
+ # let's report what the state is meant to be.
+ review_state = "published" if published_on_client else "draft"
+ # The 'published' attribute should be the date of publication of this
+ # survey, but we could inherit this attribute from the surveygroup,
+ # where it would contain the id of the client-published tool version.
+ # So do not inherit this.
+ published_date = getattr(aq_base(survey), "published", None)
+ return {
+ "@id": survey.absolute_url(),
+ "id": survey.id,
+ "title": survey.Title(),
+ "created": json_compatible(survey.created()),
+ "modified": json_compatible(survey.modified()),
+ "published": json_compatible(published_date),
+ "review_state": review_state,
+ }
+
+ def reply(self):
+ surveygroup = aq_inner(self.context)
+ # The 'published' attribute of the surveygroup has the id of the tool
+ # version that is currently published on the OiRA client side.
+ self.published_tool_version_id = getattr(surveygroup, "published", None)
+
+ # First gather info about the survey group.
+ result = {
+ "@id": f"{surveygroup.absolute_url()}/@tool-versions",
+ "id": surveygroup.id,
+ "title": surveygroup.Title(),
+ "published_tool_version_id": self.published_tool_version_id,
+ "@type": surveygroup.portal_type,
+ # We will try to get the next ones from one of the surveys.
+ "image_url": "",
+ "summary": "",
+ "introduction": "",
+ }
+
+ # Now add info for each tool version.
+ items = []
+ surveys = surveygroup.contentValues()
+ for survey in surveys:
+ items.append(self.get_survey_info(survey))
+ result["versions"] = items
+
+ # Get some more info from the published survey, or from the first one.
+ survey = (
+ surveygroup.get(self.published_tool_version_id)
+ if self.published_tool_version_id
+ else None
+ )
+ if survey is None and surveys:
+ survey = surveys[0]
+ if survey is not None:
+ if base_hasattr(survey, "image") and survey.image:
+ # The survey has a not inherited image attribute and it is not empty.
+ result["image_url"] = f"{survey.absolute_url()}/@@images/image"
+ if base_hasattr(survey, "introduction"):
+ result["introduction"] = survey.introduction
+ # The description field always exists.
+ result["summary"] = survey.Description()
+ if not result["image_url"]:
+ result["image_url"] = self.default_image_url
+
+ return result