diff --git a/docs/source/endpoints/index.md b/docs/source/endpoints/index.md index 82d2b0c357..975d6fda2f 100644 --- a/docs/source/endpoints/index.md +++ b/docs/source/endpoints/index.md @@ -41,6 +41,7 @@ principals querystring querystringsearch registry +relations roles searching system diff --git a/docs/source/endpoints/relations.md b/docs/source/endpoints/relations.md new file mode 100644 index 0000000000..2e60bf84ee --- /dev/null +++ b/docs/source/endpoints/relations.md @@ -0,0 +1,314 @@ +--- +myst: + html_meta: + "description": "Create, query, and delete relations between content items with the relations endpoint." + "property=og:description": "Create, query, and delete relations between content items with the relations endpoint." + "property=og:title": "Relations" + "keywords": "Plone, plone.restapi, REST, API, relations, service, endpoint" +--- + +(restapi-relations-label)= + +# Relations + +Plone's relations represent binary relationships between content objects. + +A single relation is defined by source, target, and relation name. + +You can define relations either with content type schema fields `RelationChoice` or `RelationList`, or with types `isReferencing` or `iterate-working-copy`. + +- Relations based on fields of a content type schema are editable by users. +- The relations `isReferencing` (block text links to a Plone content object) and `iterate-working-copy` (working copy is enabled and the content object is a working copy) are not editable. + They are created and deleted with links in text, respectively creating and deleting working copies. + +You can create, query, and delete relations by interacting through the `@relations` endpoint on the site root. +Querying relations with the `@relations` endpoint requires the `zope2.View` permission on both the source and target objects. +Therefore results include relations if and only if both the source and target are accessible by the querying user. +Creating and deleting relations requires `zope2.View` permission on the target object and `cmf.ModifyPortalContent` permission on the source object. + +(restapi-relations-getting-statistics-for-all-relations-label)= + +## Getting statistics for all relations + +The call without any parameters returns statistics on all existing relations to which the user has access. + +```{eval-rst} +.. http:example:: curl httpie python-requests + :request: ../../../src/plone/restapi/tests/http-examples/relations_catalog_get_stats.req +``` + +```{literalinclude} ../../../src/plone/restapi/tests/http-examples/relations_catalog_get_stats.resp +:language: http +``` + +(restapi-relations-querying-relations-label)= + +## Querying relations + +You can query relations by a single source, target, or relation type. +Combinations are allowed. +The source and target must be either a UID or path. + +Queried relations require the `View` permission on the source and target. +If the user lacks permission to access these relations, then they are omitted from the query results. + +The relations are grouped by relation name, source, and target, and are provided in a summarized format. + + +Query relations of a **relation type**: + +```{eval-rst} +.. http:example:: curl httpie python-requests + :request: ../../../src/plone/restapi/tests/http-examples/relations_get_relationname.req +``` + +```{literalinclude} ../../../src/plone/restapi/tests/http-examples/relations_get_relationname.resp +:language: http +``` + +Query relations of a **source** object by path: + +```{eval-rst} +.. http:example:: curl httpie python-requests + :request: ../../../src/plone/restapi/tests/http-examples/relations_get_source_by_path.req +``` + +```{literalinclude} ../../../src/plone/restapi/tests/http-examples/relations_get_source_by_path.resp +:language: http +``` + +Query relations of a **source** object by UID: + +```{eval-rst} +.. http:example:: curl httpie python-requests + :request: ../../../src/plone/restapi/tests/http-examples/relations_get_source_by_uid.req +``` + +```{literalinclude} ../../../src/plone/restapi/tests/http-examples/relations_get_source_by_uid.resp +:language: http +``` + +Query relations by **relation name and source**: + +```{eval-rst} +.. http:example:: curl httpie python-requests + :request: ../../../src/plone/restapi/tests/http-examples/relations_get_source_and_relation.req +``` + +```{literalinclude} ../../../src/plone/restapi/tests/http-examples/relations_get_source_and_relation.resp +:language: http +``` + +Query relations to a **target**: + +```{eval-rst} +.. http:example:: curl httpie python-requests + :request: ../../../src/plone/restapi/tests/http-examples/relations_get_target.req +``` + +```{literalinclude} ../../../src/plone/restapi/tests/http-examples/relations_get_target.resp +:language: http +``` + +### Refining + +Querying can be further refined by applying the `query_target` search parameter to restrict the source or target to either contain a search string or be located under a path. + +Search string example: + +``` +/@relations?relation=comprisesComponentPart&query_target=wheel +``` + +Path example: + +``` +/@relations?relation=comprisesComponentPart&query_target=/inside/garden +``` + +### Limit the results + +Limit the number of results by `max` to, for example, at most 100 results: + +``` +/@relations?relation=comprisesComponentPart&source=/documents/doc-1&max=100 +``` + +### Only broken relations + +Retrieve items with broken relations by querying with `onlyBroken`: + +``` +/@relations?onlyBroken=true +``` + +This returns a JSON object, for example: + +```json +{ + "@id": "http://localhost:55001/plone/@relations?onlyBroken=true", + "relations": { + "relatedItems": { + "items": [ + "http://localhost:55001/plone/document-2", + ], + "items_total": 1 + } + } +} +``` + + +(restapi-relations-creating-relations-label)= + +## Creating relations + +You can create relations by providing a list of the source, target, and name of the relation. +The source and target must be either a UID or path. + +If the relation is based on a `RelationChoice` or `RelationList` field of the source object, the value of the field is updated accordingly. + +Create a relation by **path**: + +```{eval-rst} +.. http:example:: curl httpie python-requests + :request: ../../../src/plone/restapi/tests/http-examples/relations_post.req +``` + +```{literalinclude} ../../../src/plone/restapi/tests/http-examples/relations_post.resp +:language: http +``` + +Create a relation by **UID**: + +```{eval-rst} +.. http:example:: curl httpie python-requests + :request: ../../../src/plone/restapi/tests/http-examples/relations_post_with_uid.req +``` + +```{literalinclude} ../../../src/plone/restapi/tests/http-examples/relations_post_with_uid.resp +:language: http +``` + +If either the source or target do not exist, then an attempt to create a relation will fail, and will return a `422 Unprocessable Entity` response. + +```{eval-rst} +.. http:example:: curl httpie python-requests + :request: ../../../src/plone/restapi/tests/http-examples/relations_post_failure.req +``` + +```{literalinclude} ../../../src/plone/restapi/tests/http-examples/relations_post_failure.resp +:language: http +``` + +(restapi-relations-deleting-relations-label)= + +## Deleting relations + +You can delete relations by relation name, source object, target object, or a combination of these. +You can also delete relations by providing a list of relations. + +If a deleted relation is based on a `RelationChoice` or `RelationList` field, the value of the field is removed or updated accordingly on the source object. + +### Delete a list of relations + +You can delete relations by either UID or path. + +```{eval-rst} +.. http:example:: curl httpie python-requests + :request: ../../../src/plone/restapi/tests/http-examples/relations_del_path_uid.req +``` + +```{literalinclude} ../../../src/plone/restapi/tests/http-examples/relations_del_path_uid.resp +:language: http +``` + +If either the source or target do not exist, then an attempt to delete a relation will fail, and will return a `422 Unprocessable Entity` response. + +```{eval-rst} +.. http:example:: curl httpie python-requests + :request: ../../../src/plone/restapi/tests/http-examples/relations_del_failure.req +``` + +```{literalinclude} ../../../src/plone/restapi/tests/http-examples/relations_del_failure.resp +:language: http +``` + +### Delete relations by relation name + +```{eval-rst} +.. http:example:: curl httpie python-requests + :request: ../../../src/plone/restapi/tests/http-examples/relations_del_relationname.req +``` + +```{literalinclude} ../../../src/plone/restapi/tests/http-examples/relations_del_relationname.resp +:language: http +``` + +### Delete relations by source + +You can delete relations by either source UID or path. + +The following example shows how to delete a relation by source path. + +```{eval-rst} +.. http:example:: curl httpie python-requests + :request: ../../../src/plone/restapi/tests/http-examples/relations_del_source.req +``` + +```{literalinclude} ../../../src/plone/restapi/tests/http-examples/relations_del_source.resp +:language: http +``` + +### Delete relations by target + +You can delete relations by either target UID or path. + +The following example shows how to delete a relation by target path. + +```{eval-rst} +.. http:example:: curl httpie python-requests + :request: ../../../src/plone/restapi/tests/http-examples/relations_del_target.req +``` + +```{literalinclude} ../../../src/plone/restapi/tests/http-examples/relations_del_target.resp +:language: http +``` + +### Delete relations by a combination of source, target, and relation name + +You can delete relations by a combination of either any two of their relation name, source, and target, or a combination of all three. +In the following example, you would delete a relation by its relation name and target. + +```{eval-rst} +.. http:example:: curl httpie python-requests + :request: ../../../src/plone/restapi/tests/http-examples/relations_del_combi.req +``` + +```{literalinclude} ../../../src/plone/restapi/tests/http-examples/relations_del_combi.resp +:language: http +``` + + +## Fix relations + +Broken relations can be fixed by releasing and re-indexing them. +A successfully fixed relation will return a `204 No Content` response. + +```{eval-rst} +.. http:example:: curl httpie python-requests + :request: ../../../src/plone/restapi/tests/http-examples/relations_rebuild.req +``` + +In rare cases, you may need to flush the `intIds`. +You can rebuild relations by flushing the `intIds` with the following HTTP POST request. + +```{warning} +If your code relies on `intIds`, you should take caution and think carefully before you flush them. +``` + + +```{eval-rst} +.. http:example:: curl httpie python-requests + :request: ../../../src/plone/restapi/tests/http-examples/relations_rebuild_with_flush.req +``` diff --git a/news/1432.feature b/news/1432.feature new file mode 100644 index 0000000000..abcc204934 --- /dev/null +++ b/news/1432.feature @@ -0,0 +1 @@ +Create relations service. Query, add, delete. @ksuess \ No newline at end of file diff --git a/src/plone/restapi/services/configure.zcml b/src/plone/restapi/services/configure.zcml index 0af1402ec3..1fd3c0568f 100644 --- a/src/plone/restapi/services/configure.zcml +++ b/src/plone/restapi/services/configure.zcml @@ -30,6 +30,7 @@ + diff --git a/src/plone/restapi/services/relations/__init__.py b/src/plone/restapi/services/relations/__init__.py new file mode 100644 index 0000000000..e5c9bfc3f0 --- /dev/null +++ b/src/plone/restapi/services/relations/__init__.py @@ -0,0 +1,36 @@ +try: + from plone.api.relation import create as api_relation_create + from plone.api.relation import delete as api_relation_delete +except ImportError: + api_relation_create = None + api_relation_delete = None + +from plone.app.uuid.utils import uuidToObject +from Products.CMFCore.DynamicType import DynamicType +from zope.component.hooks import getSite + + +def plone_api_content_get(path=None, UID=None): + """Get an object. + + copy pasted from plone.api + """ + if path: + site = getSite() + site_absolute_path = "/".join(site.getPhysicalPath()) + if not path.startswith("{path}".format(path=site_absolute_path)): + path = "{site_path}{relative_path}".format( + site_path=site_absolute_path, + relative_path=path, + ) + try: + content = site.restrictedTraverse(path) + except (KeyError, AttributeError): + return None # When no object is found don't raise an error + else: + # Only return a content if it implements DynamicType, + # which is true for Dexterity content and Comment (plone.app.discussion) + return content if isinstance(content, DynamicType) else None + + elif UID: + return uuidToObject(UID) diff --git a/src/plone/restapi/services/relations/add.py b/src/plone/restapi/services/relations/add.py new file mode 100644 index 0000000000..a86eac0732 --- /dev/null +++ b/src/plone/restapi/services/relations/add.py @@ -0,0 +1,129 @@ +from AccessControl.SecurityManagement import getSecurityManager +from plone import api +from plone.restapi.deserializer import json_body +from plone.restapi.services import Service +from plone.restapi.services.relations import plone_api_content_get +from plone.restapi.services.relations import api_relation_create +from Products.CMFCore.permissions import ManagePortal +from zope.interface import alsoProvides +from zope.interface import implementer +from zope.publisher.interfaces import IPublishTraverse +import plone.protect.interfaces +import logging + + +log = logging.getLogger(__name__) + +try: + from Products.CMFPlone.relationhelper import rebuild_relations +except ImportError: + try: + from collective.relationhelpers.api import rebuild_relations + except ImportError: + rebuild_relations = None + + +@implementer(IPublishTraverse) +class PostRelations(Service): + """Create new relations.""" + + def __init__(self, context, request): + super().__init__(context, request) + self.params = [] + self.sm = getSecurityManager() + + def publishTraverse(self, request, name): + # Treat any path segments after /@relations as parameters + self.params.append(name) + return self + + def reply(self): + # Disable CSRF protection + if "IDisableCSRFProtection" in dir(plone.protect.interfaces): + alsoProvides(self.request, plone.protect.interfaces.IDisableCSRFProtection) + + if not api_relation_create: + raise NotImplementedError() + + data = json_body(self.request) + + # Rebuild relations with or without regenerating intIds + if "rebuild" in self.params: + if api.user.has_permission(ManagePortal): + if rebuild_relations: + flush = True if data.get("flush", False) else False + try: + rebuild_relations(flush_and_rebuild_intids=flush) + print("*** Relations rebuild. flush:", flush) + return self.reply_no_content() + except Exception as e: + self.request.response.setStatus(500) + return dict( + error=dict( + # type="ImportError", + message=str(e), + ) + ) + else: + self.request.response.setStatus(501) + return dict( + error=dict( + type="ImportError", + message="Relationhelpers not available. Install collective.relationhelpers or upgrade to Plone 6!", + ) + ) + else: + self.request.response.setStatus(403) + return dict( + error=dict( + type="Forbidden", + ) + ) + + failed_relations = [] + for relationdata in data["items"]: + source_obj = plone_api_content_get(UID=relationdata["source"]) + if not source_obj: + source_obj = plone_api_content_get(path=relationdata["source"]) + target_obj = plone_api_content_get(UID=relationdata["target"]) + if not target_obj: + target_obj = plone_api_content_get(path=relationdata["target"]) + + if not source_obj or not target_obj: + msg = ( + "Source and target not found." + if not source_obj and not target_obj + else "Source not found." + if not source_obj + else "Target not found." + ) + msg = f"Failed on creating a relation. {msg}" + log.error(f"{msg} {relationdata}") + failed_relations.append((relationdata, msg)) + continue + + try: + api_relation_create( + source=source_obj, + target=target_obj, + relationship=relationdata["relation"], + ) + except Exception as e: + msg = f"{type(e).__name__}: {str(e)}. Failed on creating a relation. source:{source_obj}, target: {target_obj}" + log.error(f"{msg} {relationdata}") + failed_relations.append((relationdata, msg)) + continue + + if len(failed_relations) > 0: + return self._error( + 422, + "Unprocessable Content", + "Failed on creating relations", + failed_relations, + ) + + return self.reply_no_content() + + def _error(self, status, type, message, failed=[]): + self.request.response.setStatus(status) + return {"error": {"type": type, "message": message, "failed": failed}} diff --git a/src/plone/restapi/services/relations/configure.zcml b/src/plone/restapi/services/relations/configure.zcml new file mode 100644 index 0000000000..f21cd0ac15 --- /dev/null +++ b/src/plone/restapi/services/relations/configure.zcml @@ -0,0 +1,31 @@ + + + + + + + + + diff --git a/src/plone/restapi/services/relations/delete.py b/src/plone/restapi/services/relations/delete.py new file mode 100644 index 0000000000..5d8f691191 --- /dev/null +++ b/src/plone/restapi/services/relations/delete.py @@ -0,0 +1,113 @@ +from plone.restapi.deserializer import json_body +from plone.restapi.services import Service +from plone.restapi.services.relations import plone_api_content_get +from plone.restapi.services.relations import api_relation_delete +from zope.interface import alsoProvides +import plone.protect.interfaces +import logging + + +log = logging.getLogger(__name__) + + +class DeleteRelations(Service): + """Delete relations.""" + + def reply(self): + # Disable CSRF protection + if "IDisableCSRFProtection" in dir(plone.protect.interfaces): + alsoProvides(self.request, plone.protect.interfaces.IDisableCSRFProtection) + + if not api_relation_delete: + raise NotImplementedError() + + data = json_body(self.request) + + failed_relations = [] + # List of single relations + if data.get("items", None): + for relationdata in data["items"]: + # UIDs provided? + source_obj = plone_api_content_get(UID=relationdata["source"]) + target_obj = plone_api_content_get(UID=relationdata["target"]) + # Or maybe path provided? + if not source_obj: + source_obj = plone_api_content_get(path=relationdata["source"]) + if not target_obj: + target_obj = plone_api_content_get(path=relationdata["target"]) + # Source or target not found by UID or path + if not source_obj or not target_obj: + msg = ( + "Source and target not found." + if not source_obj and not target_obj + else "Source not found." + if not source_obj + else "Target not found." + ) + msg = f"Failed on deleting a relation. {msg}" + log.error(f"{msg} {relationdata}") + failed_relations.append((relationdata, msg)) + continue + + try: + api_relation_delete( + source=source_obj, + target=target_obj, + relationship=relationdata["relation"], + ) + except Exception as e: + msg = f"{type(e).__name__}: {str(e)}. Failed on deleting a relation. source:{source_obj}, target: {target_obj}" + log.error(f"{msg} {relationdata}") + failed_relations.append((relationdata, msg)) + continue + + if len(failed_relations) > 0: + return self._error( + 422, + "Unprocessable Content", + "Failed on deleting relations", + failed_relations, + ) + + # Bunch of relations defined by source, target, relation name, or a combination of them + else: + relation = data.get("relation", None) + source = data.get("source", None) + target = data.get("target", None) + + source_obj = None + if source: + source_obj = plone_api_content_get(UID=source) + if not source_obj: + source_obj = plone_api_content_get(path=source) + if not source_obj: + msg = f"Failed on deleting relations. Source not found: {source}" + log.error(msg) + return self._error(422, "Unprocessable Content", msg) + + target_obj = None + if target: + target_obj = plone_api_content_get(UID=target) + if not target_obj: + target_obj = plone_api_content_get(path=target) + if not target_obj: + msg = f"Failed on deleting relations. Target not found: {target}" + log.error(msg) + return self._error(422, "Unprocessable Content", msg) + + try: + api_relation_delete( + source=source_obj, + target=target_obj, + relationship=relation, + ) + except Exception as e: + msg = f"{type(e).__name__}: {str(e)}. Failed on deleting a relation. source:{source}, target: {target}, relation: {relation}" + log.error(f"{msg} {data}") + return self._error(422, type(e).__name__, msg) + + return self.reply_no_content() + + def _error(self, status, type, message, failed=[]): + self.request.response.setStatus(status) + return {"error": {"type": type, "message": message, "failed": failed}} diff --git a/src/plone/restapi/services/relations/get.py b/src/plone/restapi/services/relations/get.py new file mode 100644 index 0000000000..ea79c96b5d --- /dev/null +++ b/src/plone/restapi/services/relations/get.py @@ -0,0 +1,305 @@ +from AccessControl.SecurityManagement import getSecurityManager +from collections import defaultdict +from plone.restapi.interfaces import ISerializeToJsonSummary +from plone.restapi.serializer.converters import json_compatible +from plone.restapi.services import Service +from plone.restapi.services.relations import api_relation_create +from plone.restapi.services.relations import plone_api_content_get +from Products.CMFCore.utils import getToolByName +from zc.relation import catalog as zcr_catalog +from zc.relation.interfaces import ICatalog +from zExceptions import Unauthorized +from zope.component import getMultiAdapter +from zope.component import getUtility +from zope.component import queryUtility +from zope.component.hooks import getSite +from zope.globalrequest import getRequest +from zope.intid.interfaces import IIntIds +from zope.intid.interfaces import IntIdMissingError +from zope.interface import alsoProvides +from zope.schema.interfaces import IVocabularyFactory + +import plone.protect.interfaces + +MAX = 2500 + +try: + from Products.CMFPlone.relationhelper import get_relations_stats +except ImportError: + try: + from collective.relationhelpers.api import get_relations_stats + except ImportError: + get_relations_stats = None + +try: + from Products.CMFPlone.relationhelper import rebuild_relations +except ImportError: + try: + from collective.relationhelpers.api import rebuild_relations + except ImportError: + rebuild_relations = None + + +def make_summary(obj, request): + """Add UID to metadata_fields.""" + metadata_fields = request.form.get("metadata_fields", []) or [] + if not isinstance(metadata_fields, list): + metadata_fields = [metadata_fields] + metadata_fields.append("UID") + request.form["metadata_fields"] = list(set(metadata_fields)) + summary = getMultiAdapter((obj, request), ISerializeToJsonSummary)() + summary = json_compatible(summary) + return summary + + +def get_relations( + sources=None, + targets=None, + relationship=None, + request=None, + unrestricted=False, + onlyBroken=False, + max=None, +): + """Get valid relations.""" + results = defaultdict(list) + if request is None: + request = getRequest() + intids = getUtility(IIntIds) + relation_catalog = queryUtility(ICatalog) + if relation_catalog is None: + return results + + query = {} + if sources is not None: + iids = [] + for el in sources: + try: + iids.append(intids.getId(el)) + except IntIdMissingError: + continue + query["from_id"] = zcr_catalog.any(*iids) + + if targets is not None: + iids = [] + for el in targets: + try: + iids.append(intids.getId(el)) + except IntIdMissingError: + continue + query["to_id"] = zcr_catalog.any(*iids) + if relationship is not None: + query["from_attribute"] = relationship + + if not unrestricted: + checkPermission = getSecurityManager().checkPermission + + if max: + try: + max = int(max) + except TypeError as e: + raise ValueError(str(e)) + count = 0 + relations = relation_catalog.findRelations(query) + for relation in relations: + if relation.isBroken(): + if not onlyBroken: + continue + else: + if onlyBroken: + continue + count += 1 + if max and count > max: + break + + source_obj = relation.from_object + target_obj = relation.to_object + + if not unrestricted: + can_view = (not source_obj or checkPermission("View", source_obj)) and ( + not target_obj or checkPermission("View", target_obj) + ) + if not can_view: + continue + + if onlyBroken: + results[relation.from_attribute].append( + [ + source_obj and source_obj.absolute_url() or "", + target_obj and target_obj.absolute_url() or "", + ] + ) + else: + results[relation.from_attribute].append( + { + "source": make_summary(source_obj, request), + "target": make_summary(target_obj, request), + } + ) + return results + + +def relation_stats(): + if get_relations_stats is not None: + rels, broken = get_relations_stats() + results = {"stats": rels, "broken": broken} + return json_compatible(results) + else: + raise NotImplementedError("Not implemented in this version of Plone") + + +def getBrokenRelationNames(): + relation_catalog = queryUtility(ICatalog) + if relation_catalog is None: + return [] + + relations = relation_stats() + return relations["broken"] and relations["broken"].keys() or [] + + +def getStaticCatalogVocabularyQuery(vocabularyFactoryName): + factory = queryUtility(IVocabularyFactory, vocabularyFactoryName) + if factory: + return factory().query + return + + +class GetRelations(Service): + """Get relations or stats + + source: UID of content item + target: UID of content item + relation: name of a relation + max: integer: maximum of results + onlyBroken: boolean: dictionary with broken relations per relation type + query_source: Restrict relations by path or SearchableText + query_target: Restrict relations by path or SearchableText + rebuild: Rebuild relations + flush: If rebuild, then this also flushes the intIds + + Returns: + stats if no parameter, else relations + """ + + def __init__(self, context, request): + super().__init__(context, request) + + def reply(self): + # Disable CSRF protection + if "IDisableCSRFProtection" in dir(plone.protect.interfaces): + alsoProvides(self.request, plone.protect.interfaces.IDisableCSRFProtection) + + source = self.request.get("source", None) + target = self.request.get("target", None) + relationship = self.request.get("relation", None) + max = self.request.get("max", False) + onlyBroken = self.request.get("onlyBroken", False) + query_source = self.request.get("query_source", None) + query_target = self.request.get("query_target", None) + + targets = None + sources = None + + catalog = getToolByName(self.context, "portal_catalog") + portal = getSite() + + # Get broken relations for all relation types + if onlyBroken: + relationNames = getBrokenRelationNames() + if len(relationNames) == 0: + return self.reply_no_content(status=204) + result = { + "@id": f'{self.request["SERVER_URL"]}{self.request.environ["REQUEST_URI"]}', + "relations": {}, + } + for relationName in relationNames: + rels = get_relations(relationship=relationName, onlyBroken=True) + result["relations"][relationName] = { + "items": rels[relationName], + "items_total": len(rels), + } + return result + + # Stats + if not source and not target and not relationship: + try: + stats = relation_stats() + stats[ + "@id" + ] = f'{self.request["SERVER_URL"]}{self.request.environ["REQUEST_URI"]}' + return stats + except ImportError: + self.request.response.setStatus(501) + return dict( + error=dict( + type="ImportError", + message="Relationhelpers not available. Install collective.relationhelpers or upgrade to Plone 6!", + ) + ) + except Unauthorized: + return self.reply_no_content(status=401) + + # Query relations + if source: + if source[0:1] == "/": + source = plone_api_content_get(path=source) + else: + source = plone_api_content_get(UID=source) + if not source: + return self.reply_no_content(status=404) + else: + sources = [source] + + if target: + if target[0:1] == "/": + target = plone_api_content_get(path=target) + else: + target = plone_api_content_get(UID=target) + if not target: + return self.reply_no_content(status=404) + else: + targets = [target] + + if query_source: + query_objects = {} + if query_source[0] == "/": + query_objects["path"] = {"query": f"{portal.id}/{query_source}"} + else: + query_objects["SearchableText"] = query_source + results = catalog.searchResults(**query_objects) + sources = [el.getObject() for el in results] + + if query_target: + query_objects = {} + if query_target[0] == "/": + query_objects["path"] = {"query": query_target} + else: + query_objects["SearchableText"] = query_target + results = catalog.searchResults(**query_objects) + targets = [el.getObject() for el in results] + + data = get_relations( + sources=sources, + targets=targets, + relationship=relationship, + max=max, + request=self.request, + ) + + result = { + "@id": f'{self.request["SERVER_URL"]}{self.request.environ["REQUEST_URI"]}', + "relations": {}, + } + if relationship and not data: + result["relations"][relationship] = {"items": [], "items_total": 0} + for key in data: + result["relations"][key] = { + "items": data[key], + "items_total": len(data[key]), + } + if relationship: + scvq = getStaticCatalogVocabularyQuery(relationship) + result["relations"][relationship]["staticCatalogVocabularyQuery"] = scvq + result["relations"][relationship]["readonly"] = not api_relation_create + + return result diff --git a/src/plone/restapi/tests/http-examples/relations_catalog_get_stats.req b/src/plone/restapi/tests/http-examples/relations_catalog_get_stats.req new file mode 100644 index 0000000000..6a930d610d --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_catalog_get_stats.req @@ -0,0 +1,3 @@ +GET /plone/@relations HTTP/1.1 +Accept: application/json +Authorization: Basic YWRtaW46c2VjcmV0 diff --git a/src/plone/restapi/tests/http-examples/relations_catalog_get_stats.resp b/src/plone/restapi/tests/http-examples/relations_catalog_get_stats.resp new file mode 100644 index 0000000000..3b60709a66 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_catalog_get_stats.resp @@ -0,0 +1,11 @@ +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "@id": "http://localhost:55001/plone/@relations", + "broken": {}, + "stats": { + "comprisesComponentPart": 2, + "relatedItems": 2 + } +} diff --git a/src/plone/restapi/tests/http-examples/relations_del.req b/src/plone/restapi/tests/http-examples/relations_del.req new file mode 100644 index 0000000000..f7b0ab6376 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_del.req @@ -0,0 +1,14 @@ +DELETE /plone/@relations HTTP/1.1 +Accept: application/json +Authorization: Basic YWRtaW46c2VjcmV0 +Content-Type: application/json + +{ + "items": [ + { + "relation": "comprisesComponentPart", + "source": "SomeUUID000000000000000000000001", + "target": "SomeUUID000000000000000000000002" + } + ] +} diff --git a/src/plone/restapi/tests/http-examples/relations_del.resp b/src/plone/restapi/tests/http-examples/relations_del.resp new file mode 100644 index 0000000000..0074ded3bc --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_del.resp @@ -0,0 +1,2 @@ +HTTP/1.1 204 No Content + diff --git a/src/plone/restapi/tests/http-examples/relations_del_anonymous.req b/src/plone/restapi/tests/http-examples/relations_del_anonymous.req new file mode 100644 index 0000000000..f03468b129 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_del_anonymous.req @@ -0,0 +1,13 @@ +DELETE /plone/@relations HTTP/1.1 +Accept: application/json +Content-Type: application/json + +{ + "items": [ + { + "relation": "comprisesComponentPart", + "source": "/document", + "target": "/document-2" + } + ] +} diff --git a/src/plone/restapi/tests/http-examples/relations_del_anonymous.resp b/src/plone/restapi/tests/http-examples/relations_del_anonymous.resp new file mode 100644 index 0000000000..325f535a72 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_del_anonymous.resp @@ -0,0 +1,7 @@ +HTTP/1.1 401 Unauthorized +Content-Type: application/json + +{ + "message": "You are not authorized to access this resource.", + "type": "Unauthorized" +} diff --git a/src/plone/restapi/tests/http-examples/relations_del_combi.req b/src/plone/restapi/tests/http-examples/relations_del_combi.req new file mode 100644 index 0000000000..109d86fe1c --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_del_combi.req @@ -0,0 +1,9 @@ +DELETE /plone/@relations HTTP/1.1 +Accept: application/json +Authorization: Basic YWRtaW46c2VjcmV0 +Content-Type: application/json + +{ + "relation": "relatedItems", + "target": "/document" +} diff --git a/src/plone/restapi/tests/http-examples/relations_del_combi.resp b/src/plone/restapi/tests/http-examples/relations_del_combi.resp new file mode 100644 index 0000000000..0074ded3bc --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_del_combi.resp @@ -0,0 +1,2 @@ +HTTP/1.1 204 No Content + diff --git a/src/plone/restapi/tests/http-examples/relations_del_failure.req b/src/plone/restapi/tests/http-examples/relations_del_failure.req new file mode 100644 index 0000000000..03080d1ece --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_del_failure.req @@ -0,0 +1,19 @@ +DELETE /plone/@relations HTTP/1.1 +Accept: application/json +Authorization: Basic YWRtaW46c2VjcmV0 +Content-Type: application/json + +{ + "items": [ + { + "relation": "comprisesComponentPart", + "source": "/document", + "target": "/dont-know-this-doc" + }, + { + "relation": "comprisesComponentPart", + "source": "/doc-does-not-exist", + "target": "/document" + } + ] +} diff --git a/src/plone/restapi/tests/http-examples/relations_del_failure.resp b/src/plone/restapi/tests/http-examples/relations_del_failure.resp new file mode 100644 index 0000000000..8d686c5e15 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_del_failure.resp @@ -0,0 +1,27 @@ +HTTP/1.1 422 Unprocessable Entity +Content-Type: application/json + +{ + "error": { + "failed": [ + [ + { + "relation": "comprisesComponentPart", + "source": "/document", + "target": "/dont-know-this-doc" + }, + "Failed on deleting a relation. Target not found." + ], + [ + { + "relation": "comprisesComponentPart", + "source": "/doc-does-not-exist", + "target": "/document" + }, + "Failed on deleting a relation. Source not found." + ] + ], + "message": "Failed on deleting relations", + "type": "Unprocessable Content" + } +} diff --git a/src/plone/restapi/tests/http-examples/relations_del_path_uid.req b/src/plone/restapi/tests/http-examples/relations_del_path_uid.req new file mode 100644 index 0000000000..121f428968 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_del_path_uid.req @@ -0,0 +1,19 @@ +DELETE /plone/@relations HTTP/1.1 +Accept: application/json +Authorization: Basic YWRtaW46c2VjcmV0 +Content-Type: application/json + +{ + "items": [ + { + "relation": "comprisesComponentPart", + "source": "/document", + "target": "/document-2" + }, + { + "relation": "comprisesComponentPart", + "source": "SomeUUID000000000000000000000001", + "target": "SomeUUID000000000000000000000003" + } + ] +} diff --git a/src/plone/restapi/tests/http-examples/relations_del_path_uid.resp b/src/plone/restapi/tests/http-examples/relations_del_path_uid.resp new file mode 100644 index 0000000000..0074ded3bc --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_del_path_uid.resp @@ -0,0 +1,2 @@ +HTTP/1.1 204 No Content + diff --git a/src/plone/restapi/tests/http-examples/relations_del_relationname.req b/src/plone/restapi/tests/http-examples/relations_del_relationname.req new file mode 100644 index 0000000000..70a218a437 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_del_relationname.req @@ -0,0 +1,8 @@ +DELETE /plone/@relations HTTP/1.1 +Accept: application/json +Authorization: Basic YWRtaW46c2VjcmV0 +Content-Type: application/json + +{ + "relation": "relatedItems" +} diff --git a/src/plone/restapi/tests/http-examples/relations_del_relationname.resp b/src/plone/restapi/tests/http-examples/relations_del_relationname.resp new file mode 100644 index 0000000000..0074ded3bc --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_del_relationname.resp @@ -0,0 +1,2 @@ +HTTP/1.1 204 No Content + diff --git a/src/plone/restapi/tests/http-examples/relations_del_source.req b/src/plone/restapi/tests/http-examples/relations_del_source.req new file mode 100644 index 0000000000..af04c18165 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_del_source.req @@ -0,0 +1,8 @@ +DELETE /plone/@relations HTTP/1.1 +Accept: application/json +Authorization: Basic YWRtaW46c2VjcmV0 +Content-Type: application/json + +{ + "source": "/document" +} diff --git a/src/plone/restapi/tests/http-examples/relations_del_source.resp b/src/plone/restapi/tests/http-examples/relations_del_source.resp new file mode 100644 index 0000000000..0074ded3bc --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_del_source.resp @@ -0,0 +1,2 @@ +HTTP/1.1 204 No Content + diff --git a/src/plone/restapi/tests/http-examples/relations_del_target.req b/src/plone/restapi/tests/http-examples/relations_del_target.req new file mode 100644 index 0000000000..05632f7333 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_del_target.req @@ -0,0 +1,8 @@ +DELETE /plone/@relations HTTP/1.1 +Accept: application/json +Authorization: Basic YWRtaW46c2VjcmV0 +Content-Type: application/json + +{ + "target": "/document" +} diff --git a/src/plone/restapi/tests/http-examples/relations_del_target.resp b/src/plone/restapi/tests/http-examples/relations_del_target.resp new file mode 100644 index 0000000000..0074ded3bc --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_del_target.resp @@ -0,0 +1,2 @@ +HTTP/1.1 204 No Content + diff --git a/src/plone/restapi/tests/http-examples/relations_get_relationname.req b/src/plone/restapi/tests/http-examples/relations_get_relationname.req new file mode 100644 index 0000000000..ce27a66f66 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_get_relationname.req @@ -0,0 +1,3 @@ +GET /plone/@relations?relation=comprisesComponentPart HTTP/1.1 +Accept: application/json +Authorization: Basic YWRtaW46c2VjcmV0 diff --git a/src/plone/restapi/tests/http-examples/relations_get_relationname.resp b/src/plone/restapi/tests/http-examples/relations_get_relationname.resp new file mode 100644 index 0000000000..2e4ba27cf3 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_get_relationname.resp @@ -0,0 +1,55 @@ +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "@id": "http://localhost:55001/plone/@relations?relation=comprisesComponentPart", + "relations": { + "comprisesComponentPart": { + "items": [ + { + "source": { + "@id": "http://localhost:55001/plone/document", + "@type": "Document", + "UID": "SomeUUID000000000000000000000001", + "description": "", + "review_state": "published", + "title": "Test document 1", + "type_title": "Page" + }, + "target": { + "@id": "http://localhost:55001/plone/document-2", + "@type": "Document", + "UID": "SomeUUID000000000000000000000002", + "description": "", + "review_state": "published", + "title": "Test document 2", + "type_title": "Page" + } + }, + { + "source": { + "@id": "http://localhost:55001/plone/document", + "@type": "Document", + "UID": "SomeUUID000000000000000000000001", + "description": "", + "review_state": "published", + "title": "Test document 1", + "type_title": "Page" + }, + "target": { + "@id": "http://localhost:55001/plone/document-3", + "@type": "Document", + "UID": "SomeUUID000000000000000000000003", + "description": "", + "review_state": "private", + "title": "Test document 3", + "type_title": "Page" + } + } + ], + "items_total": 2, + "readonly": false, + "staticCatalogVocabularyQuery": null + } + } +} diff --git a/src/plone/restapi/tests/http-examples/relations_get_relationname_anonymous.req b/src/plone/restapi/tests/http-examples/relations_get_relationname_anonymous.req new file mode 100644 index 0000000000..12ddbaa693 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_get_relationname_anonymous.req @@ -0,0 +1,2 @@ +GET /plone/@relations?relation=comprisesComponentPart HTTP/1.1 +Accept: application/json diff --git a/src/plone/restapi/tests/http-examples/relations_get_relationname_anonymous.resp b/src/plone/restapi/tests/http-examples/relations_get_relationname_anonymous.resp new file mode 100644 index 0000000000..ebf6c8554f --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_get_relationname_anonymous.resp @@ -0,0 +1,35 @@ +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "@id": "http://localhost:55001/plone/@relations?relation=comprisesComponentPart", + "relations": { + "comprisesComponentPart": { + "items": [ + { + "source": { + "@id": "http://localhost:55001/plone/document", + "@type": "Document", + "UID": "SomeUUID000000000000000000000001", + "description": "", + "review_state": "published", + "title": "Test document 1", + "type_title": "Page" + }, + "target": { + "@id": "http://localhost:55001/plone/document-2", + "@type": "Document", + "UID": "SomeUUID000000000000000000000002", + "description": "", + "review_state": "published", + "title": "Test document 2", + "type_title": "Page" + } + } + ], + "items_total": 1, + "readonly": false, + "staticCatalogVocabularyQuery": null + } + } +} diff --git a/src/plone/restapi/tests/http-examples/relations_get_source_and_relation.req b/src/plone/restapi/tests/http-examples/relations_get_source_and_relation.req new file mode 100644 index 0000000000..98c6aa53bc --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_get_source_and_relation.req @@ -0,0 +1,3 @@ +GET /plone/@relations?source=/document&relation=comprisesComponentPart HTTP/1.1 +Accept: application/json +Authorization: Basic YWRtaW46c2VjcmV0 diff --git a/src/plone/restapi/tests/http-examples/relations_get_source_and_relation.resp b/src/plone/restapi/tests/http-examples/relations_get_source_and_relation.resp new file mode 100644 index 0000000000..fad9ba9423 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_get_source_and_relation.resp @@ -0,0 +1,55 @@ +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "@id": "http://localhost:55001/plone/@relations?source=/document&relation=comprisesComponentPart", + "relations": { + "comprisesComponentPart": { + "items": [ + { + "source": { + "@id": "http://localhost:55001/plone/document", + "@type": "Document", + "UID": "SomeUUID000000000000000000000001", + "description": "", + "review_state": "published", + "title": "Test document 1", + "type_title": "Page" + }, + "target": { + "@id": "http://localhost:55001/plone/document-2", + "@type": "Document", + "UID": "SomeUUID000000000000000000000002", + "description": "", + "review_state": "published", + "title": "Test document 2", + "type_title": "Page" + } + }, + { + "source": { + "@id": "http://localhost:55001/plone/document", + "@type": "Document", + "UID": "SomeUUID000000000000000000000001", + "description": "", + "review_state": "published", + "title": "Test document 1", + "type_title": "Page" + }, + "target": { + "@id": "http://localhost:55001/plone/document-3", + "@type": "Document", + "UID": "SomeUUID000000000000000000000003", + "description": "", + "review_state": "private", + "title": "Test document 3", + "type_title": "Page" + } + } + ], + "items_total": 2, + "readonly": false, + "staticCatalogVocabularyQuery": null + } + } +} diff --git a/src/plone/restapi/tests/http-examples/relations_get_source_anonymous.req b/src/plone/restapi/tests/http-examples/relations_get_source_anonymous.req new file mode 100644 index 0000000000..73075407cb --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_get_source_anonymous.req @@ -0,0 +1,2 @@ +GET /plone/@relations?source=/document HTTP/1.1 +Accept: application/json diff --git a/src/plone/restapi/tests/http-examples/relations_get_source_anonymous.resp b/src/plone/restapi/tests/http-examples/relations_get_source_anonymous.resp new file mode 100644 index 0000000000..c7e0ae18f4 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_get_source_anonymous.resp @@ -0,0 +1,33 @@ +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "@id": "http://localhost:55001/plone/@relations?source=/document", + "relations": { + "comprisesComponentPart": { + "items": [ + { + "source": { + "@id": "http://localhost:55001/plone/document", + "@type": "Document", + "UID": "SomeUUID000000000000000000000001", + "description": "", + "review_state": "published", + "title": "Test document 1", + "type_title": "Page" + }, + "target": { + "@id": "http://localhost:55001/plone/document-2", + "@type": "Document", + "UID": "SomeUUID000000000000000000000002", + "description": "", + "review_state": "published", + "title": "Test document 2", + "type_title": "Page" + } + } + ], + "items_total": 1 + } + } +} diff --git a/src/plone/restapi/tests/http-examples/relations_get_source_by_path.req b/src/plone/restapi/tests/http-examples/relations_get_source_by_path.req new file mode 100644 index 0000000000..055de329e8 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_get_source_by_path.req @@ -0,0 +1,3 @@ +GET /plone/@relations?source=/document HTTP/1.1 +Accept: application/json +Authorization: Basic YWRtaW46c2VjcmV0 diff --git a/src/plone/restapi/tests/http-examples/relations_get_source_by_path.resp b/src/plone/restapi/tests/http-examples/relations_get_source_by_path.resp new file mode 100644 index 0000000000..9d8937e66c --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_get_source_by_path.resp @@ -0,0 +1,78 @@ +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "@id": "http://localhost:55001/plone/@relations?source=/document", + "relations": { + "comprisesComponentPart": { + "items": [ + { + "source": { + "@id": "http://localhost:55001/plone/document", + "@type": "Document", + "UID": "SomeUUID000000000000000000000001", + "description": "", + "review_state": "published", + "title": "Test document 1", + "type_title": "Page" + }, + "target": { + "@id": "http://localhost:55001/plone/document-2", + "@type": "Document", + "UID": "SomeUUID000000000000000000000002", + "description": "", + "review_state": "published", + "title": "Test document 2", + "type_title": "Page" + } + }, + { + "source": { + "@id": "http://localhost:55001/plone/document", + "@type": "Document", + "UID": "SomeUUID000000000000000000000001", + "description": "", + "review_state": "published", + "title": "Test document 1", + "type_title": "Page" + }, + "target": { + "@id": "http://localhost:55001/plone/document-3", + "@type": "Document", + "UID": "SomeUUID000000000000000000000003", + "description": "", + "review_state": "private", + "title": "Test document 3", + "type_title": "Page" + } + } + ], + "items_total": 2 + }, + "relatedItems": { + "items": [ + { + "source": { + "@id": "http://localhost:55001/plone/document", + "@type": "Document", + "UID": "SomeUUID000000000000000000000001", + "description": "", + "review_state": "published", + "title": "Test document 1", + "type_title": "Page" + }, + "target": { + "@id": "http://localhost:55001/plone/document-3", + "@type": "Document", + "UID": "SomeUUID000000000000000000000003", + "description": "", + "review_state": "private", + "title": "Test document 3", + "type_title": "Page" + } + } + ], + "items_total": 1 + } + } +} diff --git a/src/plone/restapi/tests/http-examples/relations_get_source_by_uid.req b/src/plone/restapi/tests/http-examples/relations_get_source_by_uid.req new file mode 100644 index 0000000000..73921c0fd4 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_get_source_by_uid.req @@ -0,0 +1,3 @@ +GET /plone/@relations?source=SomeUUID000000000000000000000001 HTTP/1.1 +Accept: application/json +Authorization: Basic YWRtaW46c2VjcmV0 diff --git a/src/plone/restapi/tests/http-examples/relations_get_source_by_uid.resp b/src/plone/restapi/tests/http-examples/relations_get_source_by_uid.resp new file mode 100644 index 0000000000..366bd4d066 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_get_source_by_uid.resp @@ -0,0 +1,78 @@ +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "@id": "http://localhost:55001/plone/@relations?source=SomeUUID000000000000000000000001", + "relations": { + "comprisesComponentPart": { + "items": [ + { + "source": { + "@id": "http://localhost:55001/plone/document", + "@type": "Document", + "UID": "SomeUUID000000000000000000000001", + "description": "", + "review_state": "published", + "title": "Test document 1", + "type_title": "Page" + }, + "target": { + "@id": "http://localhost:55001/plone/document-2", + "@type": "Document", + "UID": "SomeUUID000000000000000000000002", + "description": "", + "review_state": "published", + "title": "Test document 2", + "type_title": "Page" + } + }, + { + "source": { + "@id": "http://localhost:55001/plone/document", + "@type": "Document", + "UID": "SomeUUID000000000000000000000001", + "description": "", + "review_state": "published", + "title": "Test document 1", + "type_title": "Page" + }, + "target": { + "@id": "http://localhost:55001/plone/document-3", + "@type": "Document", + "UID": "SomeUUID000000000000000000000003", + "description": "", + "review_state": "private", + "title": "Test document 3", + "type_title": "Page" + } + } + ], + "items_total": 2 + }, + "relatedItems": { + "items": [ + { + "source": { + "@id": "http://localhost:55001/plone/document", + "@type": "Document", + "UID": "SomeUUID000000000000000000000001", + "description": "", + "review_state": "published", + "title": "Test document 1", + "type_title": "Page" + }, + "target": { + "@id": "http://localhost:55001/plone/document-3", + "@type": "Document", + "UID": "SomeUUID000000000000000000000003", + "description": "", + "review_state": "private", + "title": "Test document 3", + "type_title": "Page" + } + } + ], + "items_total": 1 + } + } +} diff --git a/src/plone/restapi/tests/http-examples/relations_get_target.req b/src/plone/restapi/tests/http-examples/relations_get_target.req new file mode 100644 index 0000000000..7ad5065c63 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_get_target.req @@ -0,0 +1,3 @@ +GET /plone/@relations?target=/document HTTP/1.1 +Accept: application/json +Authorization: Basic YWRtaW46c2VjcmV0 diff --git a/src/plone/restapi/tests/http-examples/relations_get_target.resp b/src/plone/restapi/tests/http-examples/relations_get_target.resp new file mode 100644 index 0000000000..f6fdd7f86a --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_get_target.resp @@ -0,0 +1,33 @@ +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "@id": "http://localhost:55001/plone/@relations?target=/document", + "relations": { + "relatedItems": { + "items": [ + { + "source": { + "@id": "http://localhost:55001/plone/document-2", + "@type": "Document", + "UID": "SomeUUID000000000000000000000002", + "description": "", + "review_state": "published", + "title": "Test document 2", + "type_title": "Page" + }, + "target": { + "@id": "http://localhost:55001/plone/document", + "@type": "Document", + "UID": "SomeUUID000000000000000000000001", + "description": "", + "review_state": "published", + "title": "Test document 1", + "type_title": "Page" + } + } + ], + "items_total": 1 + } + } +} diff --git a/src/plone/restapi/tests/http-examples/relations_post.req b/src/plone/restapi/tests/http-examples/relations_post.req new file mode 100644 index 0000000000..5d796890a6 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_post.req @@ -0,0 +1,19 @@ +POST /plone/@relations HTTP/1.1 +Accept: application/json +Authorization: Basic YWRtaW46c2VjcmV0 +Content-Type: application/json + +{ + "items": [ + { + "relation": "relatedItems", + "source": "/document-3", + "target": "/document" + }, + { + "relation": "relatedItems", + "source": "/document-3", + "target": "/document-2" + } + ] +} diff --git a/src/plone/restapi/tests/http-examples/relations_post.resp b/src/plone/restapi/tests/http-examples/relations_post.resp new file mode 100644 index 0000000000..0074ded3bc --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_post.resp @@ -0,0 +1,2 @@ +HTTP/1.1 204 No Content + diff --git a/src/plone/restapi/tests/http-examples/relations_post_anonyous.req b/src/plone/restapi/tests/http-examples/relations_post_anonyous.req new file mode 100644 index 0000000000..3ea1ef91a5 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_post_anonyous.req @@ -0,0 +1,13 @@ +POST /plone/@relations HTTP/1.1 +Accept: application/json +Content-Type: application/json + +{ + "items": [ + { + "relation": "comprisesComponentPart", + "source": "/document", + "target": "/document-2" + } + ] +} diff --git a/src/plone/restapi/tests/http-examples/relations_post_anonyous.resp b/src/plone/restapi/tests/http-examples/relations_post_anonyous.resp new file mode 100644 index 0000000000..325f535a72 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_post_anonyous.resp @@ -0,0 +1,7 @@ +HTTP/1.1 401 Unauthorized +Content-Type: application/json + +{ + "message": "You are not authorized to access this resource.", + "type": "Unauthorized" +} diff --git a/src/plone/restapi/tests/http-examples/relations_post_failure.req b/src/plone/restapi/tests/http-examples/relations_post_failure.req new file mode 100644 index 0000000000..1dce765542 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_post_failure.req @@ -0,0 +1,14 @@ +POST /plone/@relations HTTP/1.1 +Accept: application/json +Authorization: Basic YWRtaW46c2VjcmV0 +Content-Type: application/json + +{ + "items": [ + { + "relation": "comprisesComponentPart", + "source": "/document", + "target": "/document-does-not-exist" + } + ] +} diff --git a/src/plone/restapi/tests/http-examples/relations_post_failure.resp b/src/plone/restapi/tests/http-examples/relations_post_failure.resp new file mode 100644 index 0000000000..276f67cd98 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_post_failure.resp @@ -0,0 +1,19 @@ +HTTP/1.1 422 Unprocessable Entity +Content-Type: application/json + +{ + "error": { + "failed": [ + [ + { + "relation": "comprisesComponentPart", + "source": "/document", + "target": "/document-does-not-exist" + }, + "Failed on creating a relation. Target not found." + ] + ], + "message": "Failed on creating relations", + "type": "Unprocessable Content" + } +} diff --git a/src/plone/restapi/tests/http-examples/relations_post_with_uid.req b/src/plone/restapi/tests/http-examples/relations_post_with_uid.req new file mode 100644 index 0000000000..75459fdeae --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_post_with_uid.req @@ -0,0 +1,19 @@ +POST /plone/@relations HTTP/1.1 +Accept: application/json +Authorization: Basic YWRtaW46c2VjcmV0 +Content-Type: application/json + +{ + "items": [ + { + "relation": "comprisesComponentPart", + "source": "SomeUUID000000000000000000000001", + "target": "SomeUUID000000000000000000000002" + }, + { + "relation": "comprisesComponentPart", + "source": "SomeUUID000000000000000000000003", + "target": "SomeUUID000000000000000000000002" + } + ] +} diff --git a/src/plone/restapi/tests/http-examples/relations_post_with_uid.resp b/src/plone/restapi/tests/http-examples/relations_post_with_uid.resp new file mode 100644 index 0000000000..0074ded3bc --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_post_with_uid.resp @@ -0,0 +1,2 @@ +HTTP/1.1 204 No Content + diff --git a/src/plone/restapi/tests/http-examples/relations_rebuild.req b/src/plone/restapi/tests/http-examples/relations_rebuild.req new file mode 100644 index 0000000000..f2b6c88b0d --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_rebuild.req @@ -0,0 +1,3 @@ +POST /plone/@relations/rebuild HTTP/1.1 +Accept: application/json +Authorization: Basic YWRtaW46c2VjcmV0 diff --git a/src/plone/restapi/tests/http-examples/relations_rebuild.resp b/src/plone/restapi/tests/http-examples/relations_rebuild.resp new file mode 100644 index 0000000000..0074ded3bc --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_rebuild.resp @@ -0,0 +1,2 @@ +HTTP/1.1 204 No Content + diff --git a/src/plone/restapi/tests/http-examples/relations_rebuild_with_flush.req b/src/plone/restapi/tests/http-examples/relations_rebuild_with_flush.req new file mode 100644 index 0000000000..9d55e71498 --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_rebuild_with_flush.req @@ -0,0 +1,8 @@ +POST /plone/@relations/rebuild HTTP/1.1 +Accept: application/json +Authorization: Basic YWRtaW46c2VjcmV0 +Content-Type: application/json + +{ + "flush": 1 +} diff --git a/src/plone/restapi/tests/http-examples/relations_rebuild_with_flush.resp b/src/plone/restapi/tests/http-examples/relations_rebuild_with_flush.resp new file mode 100644 index 0000000000..0074ded3bc --- /dev/null +++ b/src/plone/restapi/tests/http-examples/relations_rebuild_with_flush.resp @@ -0,0 +1,2 @@ +HTTP/1.1 204 No Content + diff --git a/src/plone/restapi/tests/test_documentation_relations.py b/src/plone/restapi/tests/test_documentation_relations.py new file mode 100644 index 0000000000..88ee2d07ec --- /dev/null +++ b/src/plone/restapi/tests/test_documentation_relations.py @@ -0,0 +1,552 @@ +from plone import api +from plone.app.testing import SITE_OWNER_NAME +from plone.app.testing import SITE_OWNER_PASSWORD +from plone.app.vocabularies.catalog import StaticCatalogVocabulary +from plone.restapi.services.relations import api_relation_create +from plone.restapi.services.relations.get import getStaticCatalogVocabularyQuery +from plone.restapi.testing import PLONE_RESTAPI_DX_FUNCTIONAL_TESTING +from plone.restapi.tests.test_documentation import TestDocumentationBase +from plone.restapi.tests.test_documentation import save_request_and_response_for_docs +from zope.component import provideUtility +from zope.schema.interfaces import IVocabularyFactory + +import transaction + +try: + from Products.CMFPlone.relationhelper import rebuild_relations +except ImportError: + try: + from collective.relationhelpers.api import rebuild_relations + except ImportError: + rebuild_relations = None + + +def ExamplesVocabularyFactory(context=None): + return StaticCatalogVocabulary( + { + "portal_type": ["example"], + "review_state": "published", + "sort_on": "sortable_title", + } + ) + + +class TestRelationsDocumentation(TestDocumentationBase): + layer = PLONE_RESTAPI_DX_FUNCTIONAL_TESTING + + def setUp(self): + super().setUp() + + if api_relation_create: + self.doc1 = api.content.create( + container=self.portal, + type="Document", + id="document", + title="Test document 1", + ) + api.content.transition(self.doc1, "publish") + + self.doc2 = api.content.create( + container=self.portal, + type="Document", + id="document-2", + title="Test document 2", + ) + api.content.transition(self.doc2, "publish") + + self.doc3 = api.content.create( + container=self.portal, + type="Document", + id="document-3", + title="Test document 3", + ) + + transaction.commit() + + api_relation_create( + source=self.doc1, + target=self.doc2, + relationship="comprisesComponentPart", + ) + api_relation_create( + source=self.doc1, + target=self.doc3, + relationship="comprisesComponentPart", + ) + api_relation_create( + source=self.doc1, + target=self.doc3, + relationship="relatedItems", + ) + api_relation_create( + source=self.doc2, + target=self.doc1, + relationship="relatedItems", + ) + transaction.commit() + + def tearDown(self): + super().tearDown() + + def test_documentation_GET_relations(self): + if api_relation_create: + self.assertEqual( + set( + [ + relationvalue.to_object + for relationvalue in api.relation.get( + source=self.doc1, relationship="comprisesComponentPart" + ) + ] + ), + {self.doc2, self.doc3}, + ) + self.assertEqual( + set( + [ + relationvalue.to_object + for relationvalue in api.relation.get( + source=self.doc1, relationship="relatedItems" + ) + ] + ), + {self.doc3}, + ) + + """ + Stats of relations + """ + response = self.api_session.get( + "/@relations", + ) + save_request_and_response_for_docs("relations_catalog_get_stats", response) + + self.assertEqual(response.status_code, 200) + resp = response.json() + self.assertIn("stats", resp) + self.assertIn("broken", resp) + self.assertEqual(resp["stats"]["comprisesComponentPart"], 2) + self.assertEqual(resp["broken"], {}) + + """ + Query relations + """ + # relation name + response = self.api_session.get( + "/@relations?relation=comprisesComponentPart", + ) + save_request_and_response_for_docs("relations_get_relationname", response) + resp = response.json() + self.assertEqual( + resp["relations"]["comprisesComponentPart"]["items_total"], 2 + ) + self.assertIn( + "UID", resp["relations"]["comprisesComponentPart"]["items"][0]["source"] + ) + + # relation name (sub set of relations for Anonymous) + self.api_session.auth = None + response = self.api_session.get( + "/@relations?relation=comprisesComponentPart", + ) + save_request_and_response_for_docs( + "relations_get_relationname_anonymous", response + ) + resp = response.json() + self.assertEqual( + resp["relations"]["comprisesComponentPart"]["items_total"], 1 + ) # not 2 as for admin + self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) + + # source by path + response = self.api_session.get( + "/@relations?source=/document", + ) + save_request_and_response_for_docs("relations_get_source_by_path", response) + resp = response.json() + self.assertEqual( + resp["relations"]["comprisesComponentPart"]["items_total"], 2 + ) + self.assertEqual(resp["relations"]["relatedItems"]["items_total"], 1) + + # source by uid + response = self.api_session.get( + f"/@relations?source={self.doc1.UID()}", + ) + save_request_and_response_for_docs("relations_get_source_by_uid", response) + + # source by path (sub set of relations for Anonymous) + self.api_session.auth = None + response = self.api_session.get( + "/@relations?source=/document", + ) + save_request_and_response_for_docs( + "relations_get_source_anonymous", response + ) + resp = response.json() + self.assertEqual( + resp["relations"]["comprisesComponentPart"]["items_total"], 1 + ) # subset of results for manager + self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) + + # source and relation + response = self.api_session.get( + "/@relations?source=/document&relation=comprisesComponentPart", + ) + save_request_and_response_for_docs( + "relations_get_source_and_relation", response + ) + + # target + response = self.api_session.get( + "/@relations?target=/document", + ) + save_request_and_response_for_docs("relations_get_target", response) + resp = response.json() + self.assertEqual(resp["relations"]["relatedItems"]["items_total"], 1) + + def test_documentation_GET_relations_vocabulary(self): + if api_relation_create: + # Register named staticCatalogVocabulary + factory = ExamplesVocabularyFactory # () + provideUtility( + factory, provides=IVocabularyFactory, name="comprisesComponentPart" + ) + + # Query relations + response = self.api_session.get( + "/@relations?relation=comprisesComponentPart", + ) + + resp = response.json() + # Is the vocabulary registered? + self.assertEqual( + getStaticCatalogVocabularyQuery("comprisesComponentPart"), + { + "portal_type": ["example"], + "review_state": "published", + "sort_on": "sortable_title", + }, + ) + # Is the vocabulary included in response? + self.assertEqual( + resp["relations"]["comprisesComponentPart"][ + "staticCatalogVocabularyQuery" + ], + { + "portal_type": ["example"], + "review_state": "published", + "sort_on": "sortable_title", + }, + ) + + def test_documentation_POST_relations(self): + """ + Add relations + """ + self.maxDiff = None + + if api_relation_create: + response = self.api_session.get( + "/@relations?relation=relatedItems", + ) + resp = response.json() + self.assertEqual(resp["relations"]["relatedItems"]["items_total"], 2) + + response = self.api_session.post( + "/@relations", + json={ + "items": [ + { + "source": "/document-3", + "target": "/document", + "relation": "relatedItems", + }, + { + "source": "/document-3", + "target": "/document-2", + "relation": "relatedItems", + }, + ] + }, + ) + save_request_and_response_for_docs("relations_post", response) + + response = self.api_session.get( + "/@relations?relation=relatedItems", + ) + resp = response.json() + self.assertEqual(resp["relations"]["relatedItems"]["items_total"], 4) + + # Failing addition + response = self.api_session.post( + "/@relations", + json={ + "items": [ + { + "source": "/document", + "target": "/document-does-not-exist", + "relation": "comprisesComponentPart", + } + ] + }, + ) + save_request_and_response_for_docs("relations_post_failure", response) + resp = response.json() + self.assertIn("failed", resp["error"]) + + # Add by UID + response = self.api_session.post( + "/@relations", + json={ + "items": [ + { + "source": self.doc1.UID(), + "target": self.doc2.UID(), + "relation": "comprisesComponentPart", + }, + { + "source": self.doc3.UID(), + "target": self.doc2.UID(), + "relation": "comprisesComponentPart", + }, + ] + }, + ) + save_request_and_response_for_docs("relations_post_with_uid", response) + + def test_documentation_POST_relations_anonymous(self): + """ + Post relations + """ + + if api_relation_create: + self.api_session.auth = None + response = self.api_session.post( + "/@relations", + json={ + "items": [ + { + "source": "/document", + "target": "/document-2", + "relation": "comprisesComponentPart", + } + ] + }, + ) + self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) + + save_request_and_response_for_docs("relations_post_anonyous", response) + + # Get relations and test that no relation is removed. + response = self.api_session.get( + "/@relations?relation=comprisesComponentPart", + ) + resp = response.json() + self.assertEqual( + resp["relations"]["comprisesComponentPart"]["items_total"], 2 + ) + + def test_documentation_DEL_relations_list(self): + """ + Delete relations + """ + self.maxDiff = None + + if api_relation_create: + # Delete list by path and UID + response = self.api_session.delete( + "/@relations", + json={ + "items": [ + { + "source": "/document", + "target": "/document-2", + "relation": "comprisesComponentPart", + }, + { + "source": self.doc1.UID(), + "target": self.doc3.UID(), + "relation": "comprisesComponentPart", + }, + ] + }, + ) + save_request_and_response_for_docs("relations_del_path_uid", response) + + # Get relations and test that the deleted relations are removed. + response = self.api_session.get( + "/@relations?relation=comprisesComponentPart", + ) + resp = response.json() + + self.assertEqual( + resp["relations"]["comprisesComponentPart"]["items_total"], 0 + ) # instead of 2 before deletion + + # Failing deletion + response = self.api_session.delete( + "/@relations", + json={ + "items": [ + { + "source": "/document", + "target": "/dont-know-this-doc", + "relation": "comprisesComponentPart", + }, + { + "source": "/doc-does-not-exist", + "target": "/document", + "relation": "comprisesComponentPart", + }, + ] + }, + ) + save_request_and_response_for_docs("relations_del_failure", response) + resp = response.json() + self.assertIn("error", resp) + + def test_documentation_DEL_relations_list_anonymous(self): + """ + Delete relations + """ + + if api_relation_create: + self.api_session.auth = None + response = self.api_session.delete( + "/@relations", + json={ + "items": [ + { + "source": "/document", + "target": "/document-2", + "relation": "comprisesComponentPart", + } + ] + }, + ) + self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) + + save_request_and_response_for_docs("relations_del_anonymous", response) + + # Get relations and test that no relation is removed. + response = self.api_session.get( + "/@relations?relation=comprisesComponentPart", + ) + resp = response.json() + self.assertEqual( + resp["relations"]["comprisesComponentPart"]["items_total"], 2 + ) + + def test_documentation_DEL_relations_by_relationship(self): + """ + Delete relations + """ + self.maxDiff = None + + if api_relation_create: + + response = self.api_session.get( + "/@relations?relation=relatedItems", + ) + resp = response.json() + self.assertEqual(resp["relations"]["relatedItems"]["items_total"], 2) + + # Delete by relation name + response = self.api_session.delete( + "/@relations", + json={"relation": "relatedItems"}, + ) + save_request_and_response_for_docs("relations_del_relationname", response) + + response = self.api_session.get( + "/@relations?relation=relatedItems", + ) + resp = response.json() + self.assertEqual(resp["relations"]["relatedItems"]["items_total"], 0) + + def test_documentation_DEL_relations_by_source_or_target(self): + """ + Delete relations + """ + self.maxDiff = None + + if api_relation_create: + # Delete by source + response = self.api_session.delete( + "/@relations", + json={"source": "/document"}, + ) + save_request_and_response_for_docs("relations_del_source", response) + + response = self.api_session.get( + "/@relations?relation=relatedItems", + ) + resp = response.json() + self.assertEqual(resp["relations"]["relatedItems"]["items_total"], 1) + + # Delete by target + response = self.api_session.delete( + "/@relations", + json={"target": "/document"}, + ) + save_request_and_response_for_docs("relations_del_target", response) + + response = self.api_session.get( + "/@relations?relation=relatedItems", + ) + resp = response.json() + self.assertEqual(resp["relations"]["relatedItems"]["items_total"], 0) + + def test_documentation_DEL_relations_bunch_combi(self): + """ + Delete relations + """ + self.maxDiff = None + + if api_relation_create: + response = self.api_session.get( + "/@relations", + ) + resp = response.json() + self.assertEqual(resp["stats"]["comprisesComponentPart"], 2) + self.assertEqual(resp["stats"]["relatedItems"], 2) + + # Delete by combination of source and relation name + response = self.api_session.delete( + "/@relations", + json={"source": "/document", "relation": "relatedItems"}, + ) + save_request_and_response_for_docs("relations_del_combi", response) + + response = self.api_session.get( + "/@relations", + ) + resp = response.json() + self.assertEqual(resp["stats"]["comprisesComponentPart"], 2) + self.assertEqual(resp["stats"]["relatedItems"], 1) + + # Delete by combination of target and relation name + response = self.api_session.delete( + "/@relations", + json={"target": "/document", "relation": "relatedItems"}, + ) + save_request_and_response_for_docs("relations_del_combi", response) + + response = self.api_session.get( + "/@relations", + ) + resp = response.json() + self.assertEqual(resp["stats"]["comprisesComponentPart"], 2) + self.assertNotIn("relatedItems", resp["stats"]) + + def test_documentation_POST_rebuild(self): + if rebuild_relations: + response = self.api_session.post("/@relations/rebuild") + save_request_and_response_for_docs("relations_rebuild", response) + + response = self.api_session.post( + "/@relations/rebuild", + json={"flush": 1}, + ) + save_request_and_response_for_docs("relations_rebuild_with_flush", response)