diff --git a/base.cfg b/base.cfg
index 33f786f8ec..27278b4bc8 100644
--- a/base.cfg
+++ b/base.cfg
@@ -18,6 +18,7 @@ parts =
develop = .
sources-dir = extras
auto-checkout =
+ plone.volto
# plone.rest
allow-hosts =
@@ -45,6 +46,7 @@ eggs =
Pillow
plone.app.debugtoolbar
plone.restapi [test]
+ plone.volto
environment-vars =
zope_i18n_compile_mo_files true
@@ -190,6 +192,7 @@ output = ${buildout:directory}/bin/zpretty-run
mode = 755
[sources]
+plone.volto = git git@github.com:plone/plone.volto.git pushurl=git@github.com:plone/plone.volto.git branch=preview-image-link
plone.rest = git git://github.com/plone/plone.rest.git pushurl=git@github.com:plone/plone.rest.git branch=master
plone.schema = git git://github.com/plone/plone.schema.git pushurl=git@github.com:plone/plone.schema.git branch=master
Products.ZCatalog = git git://github.com/zopefoundation/Products.ZCatalog.git pushurl=git@github.com:zopefoundation/Products.ZCatalog.git
\ No newline at end of file
diff --git a/news/xxxx.feature b/news/xxxx.feature
new file mode 100644
index 0000000000..637f61bba1
--- /dev/null
+++ b/news/xxxx.feature
@@ -0,0 +1 @@
+- Enhance relation field serialization for image content types [@reebalazs]
\ No newline at end of file
diff --git a/src/plone/restapi/interfaces.py b/src/plone/restapi/interfaces.py
index 9137914b80..e20c2d9cbd 100644
--- a/src/plone/restapi/interfaces.py
+++ b/src/plone/restapi/interfaces.py
@@ -224,3 +224,15 @@ def non_metadata_attributes():
def blocklisted_attributes():
"""Returns a set with attributes blocked during serialization."""
+
+
+class IRelationObjectSerializer(Interface):
+ """The relation object serializer multi adapter serializes the relation object into
+ JSON compatible python data.
+ """
+
+ def __init__(rel_obj, field, context, request):
+ """Adapts relation object, field, context and request."""
+
+ def __call__():
+ """Returns JSON compatible python data."""
diff --git a/src/plone/restapi/serializer/configure.zcml b/src/plone/restapi/serializer/configure.zcml
index f827730c19..ceb1915e91 100644
--- a/src/plone/restapi/serializer/configure.zcml
+++ b/src/plone/restapi/serializer/configure.zcml
@@ -113,4 +113,9 @@
name="plone.restapi.summary_serializer_metadata"
/>
+
+
+
+
+
diff --git a/src/plone/restapi/serializer/relationfield.py b/src/plone/restapi/serializer/relationfield.py
index 931453fbea..12e253a5fd 100644
--- a/src/plone/restapi/serializer/relationfield.py
+++ b/src/plone/restapi/serializer/relationfield.py
@@ -27,7 +27,25 @@ def relationvalue_converter(value):
@adapter(IRelationChoice, IDexterityContent, Interface)
@implementer(IFieldSerializer)
class RelationChoiceFieldSerializer(DefaultFieldSerializer):
- pass
+ def __call__(self):
+ result = json_compatible(self.get_value())
+ # Enhance information based on the content type in relation
+ if result is None:
+ return None
+ portal = getMultiAdapter(
+ (self.context, self.request), name="plone_portal_state"
+ ).portal()
+ portal_url = portal.absolute_url()
+ rel_url = result["@id"]
+ if not rel_url.startswith(portal_url):
+ raise RuntimeError(
+ f"Url must start with portal url. [{portal_url} <> {rel_url}]"
+ )
+ rel_path = rel_url[len(portal_url) + 1 :]
+ rel_obj = portal.unrestrictedTraverse(rel_path, None)
+ serializer = getMultiAdapter((rel_obj, self.field, self.context, self.request))
+ result = serializer()
+ return result
@adapter(IRelationList, IDexterityContent, Interface)
diff --git a/src/plone/restapi/serializer/relationobject.py b/src/plone/restapi/serializer/relationobject.py
new file mode 100644
index 0000000000..8237277247
--- /dev/null
+++ b/src/plone/restapi/serializer/relationobject.py
@@ -0,0 +1,85 @@
+from plone.dexterity.interfaces import IDexterityContent
+from plone.restapi.interfaces import IRelationObjectSerializer
+from plone.restapi.serializer.converters import json_compatible
+from zope.component import adapter
+from zope.component import getMultiAdapter
+from zope.interface import implementer
+from zope.interface import Interface
+from zope.interface import alsoProvides
+from plone.app.contenttypes.interfaces import IImage
+from plone.namedfile.interfaces import INamedImageField
+from z3c.relationfield.interfaces import IRelationChoice
+
+import logging
+
+
+log = logging.getLogger(__name__)
+
+
+@adapter(IDexterityContent, IRelationChoice, IDexterityContent, Interface)
+@implementer(IRelationObjectSerializer)
+class DefaultRelationObjectSerializer:
+ def __init__(self, rel_obj, field, context, request):
+ self.context = context
+ self.request = request
+ self.field = field
+ self.rel_obj = rel_obj
+
+ def __call__(self):
+ obj = self.rel_obj
+ # Start from the values of the default field serializer
+ result = json_compatible(self.get_value())
+ if result is None:
+ return None
+ # Add some more values from the object in relation
+ additional = {
+ "id": obj.id,
+ "created": obj.created(),
+ "modified": obj.modified(),
+ "UID": obj.UID(),
+ }
+ result.update(additional)
+ return json_compatible(result)
+
+ def get_value(self, default=None):
+ return getattr(self.field.interface(self.context), self.field.__name__, default)
+
+
+class FieldSim:
+ def __init__(self, name, provides):
+ self.__name__ = name
+ alsoProvides(self, provides)
+
+ def get(self, context):
+ return getattr(context, self.__name__)
+
+
+class FieldRelationObjectSerializer(DefaultRelationObjectSerializer):
+ """The relationship object is treatable like a field
+
+ So we can reuse the serialization for that specific field.
+ """
+
+ field_name = None
+ field_interface = None
+
+ def __call__(self):
+ field = FieldSim(self.field_name, self.field_interface)
+ result = super().__call__()
+ if result is None:
+ return None
+ # Reuse a serializer from dxfields
+ serializer = getMultiAdapter((field, self.rel_obj, self.request))
+ # Extend the default values with the content specific ones
+ additional = serializer()
+ if additional is not None:
+ result.update(additional)
+ return result
+
+
+@adapter(IImage, IRelationChoice, IDexterityContent, Interface)
+class ImageRelationObjectSerializer(FieldRelationObjectSerializer):
+ # The name of the attribute that contains the data object within the relation object
+ field_name = "image"
+ # The field adapter that we will emulate to get the serialized data for this content type
+ field_interface = INamedImageField
diff --git a/src/plone/restapi/tests/test_dxfield_serializer.py b/src/plone/restapi/tests/test_dxfield_serializer.py
index 3fcfcd125f..b9a83f732c 100644
--- a/src/plone/restapi/tests/test_dxfield_serializer.py
+++ b/src/plone/restapi/tests/test_dxfield_serializer.py
@@ -1,3 +1,4 @@
+from DateTime import DateTime
from datetime import date
from datetime import datetime
from datetime import time
@@ -268,6 +269,9 @@ def test_relationchoice_field_serialization_returns_summary_dict(self):
description="Description 2",
)
]
+ doc2.creation_date = DateTime("2016-01-21T01:14:48+00:00")
+ doc2.modification_date = DateTime("2017-01-21T01:14:48+00:00")
+ doc2_uid = doc2.UID()
value = self.serialize("test_relationchoice_field", doc2)
self.assertEqual(
{
@@ -276,6 +280,42 @@ def test_relationchoice_field_serialization_returns_summary_dict(self):
"title": "Referenceable Document",
"description": "Description 2",
"review_state": "private",
+ # Additional fields are added by the relationship serializer
+ # for default content.
+ "UID": doc2_uid,
+ "created": "2016-01-21T01:14:48+00:00",
+ "id": "doc2",
+ "modified": "2017-01-21T01:14:48+00:00",
+ },
+ value,
+ )
+
+ def test_relationchoice_field_serialization_depends_on_content_type(self):
+ image1 = self.portal[
+ self.portal.invokeFactory(
+ "Image",
+ id="image1",
+ title="Test Image",
+ description="Test Image Description",
+ )
+ ]
+ image1.creation_date = DateTime("2016-01-21T01:14:48+00:00")
+ image1.modification_date = DateTime("2017-01-21T01:14:48+00:00")
+ image1_uid = image1.UID()
+ value = self.serialize("test_relationchoice_field", image1)
+ self.assertEqual(
+ {
+ "@id": "http://nohost/plone/image1",
+ "@type": "Image",
+ "title": "Test Image",
+ "description": "Test Image Description",
+ "review_state": None,
+ # Additional fields are added by the relationship serializer
+ # for default content.
+ "UID": image1_uid,
+ "created": "2016-01-21T01:14:48+00:00",
+ "id": "image1",
+ "modified": "2017-01-21T01:14:48+00:00",
},
value,
)
@@ -634,5 +674,5 @@ def test_namedblobimage_field_serialization_doesnt_choke_on_corrupt_image(self):
class TestDexterityFieldSerializers(TestCase):
- def default_field_serializer(self):
+ def test_default_field_serializer(self):
verifyClass(IFieldSerializer, DefaultFieldSerializer)
diff --git a/src/plone/restapi/tests/test_relationobject_serializer.py b/src/plone/restapi/tests/test_relationobject_serializer.py
new file mode 100644
index 0000000000..16086bb172
--- /dev/null
+++ b/src/plone/restapi/tests/test_relationobject_serializer.py
@@ -0,0 +1,295 @@
+from DateTime import DateTime
+from importlib import import_module
+from plone.dexterity.utils import iterSchemata
+from plone.namedfile.file import NamedImage
+from plone.restapi.interfaces import IRelationObjectSerializer
+from plone.restapi.serializer.relationobject import DefaultRelationObjectSerializer
+from plone.restapi.testing import PLONE_RESTAPI_DX_INTEGRATION_TESTING
+from plone.restapi.tests.helpers import patch_scale_uuid
+from unittest import TestCase
+from z3c.form.interfaces import IDataManager
+from zope.component import getMultiAdapter
+from zope.interface.verify import verifyClass
+
+import os
+
+
+HAS_PLONE_6 = getattr(
+ import_module("Products.CMFPlone.factory"), "PLONE60MARKER", False
+)
+
+
+class TestRelationObjectSerializing(TestCase):
+ layer = PLONE_RESTAPI_DX_INTEGRATION_TESTING
+ maxDiff = None
+
+ def setUp(self):
+ self.portal = self.layer["portal"]
+ self.request = self.layer["request"]
+ self.doc1 = self.portal[
+ self.portal.invokeFactory(
+ "DXTestDocument", id="doc1", title="Test Document"
+ )
+ ]
+ self.doc2 = self.portal[
+ self.portal.invokeFactory(
+ "DXTestDocument",
+ id="doc2",
+ title="Test Document 2",
+ description="Test Document 2 Description",
+ )
+ ]
+ self.doc2.creation_date = DateTime("2016-01-21T01:14:48+00:00")
+ self.doc2.modification_date = DateTime("2017-01-21T01:14:48+00:00")
+
+ def serialize(self, fieldname, value, rel_obj):
+ for schema in iterSchemata(self.doc1):
+ if fieldname in schema:
+ field = schema.get(fieldname)
+ break
+ dm = getMultiAdapter((self.doc1, field), IDataManager)
+ dm.set(value)
+ serializer = getMultiAdapter((rel_obj, field, self.doc1, self.request))
+ return serializer()
+
+ def test_empty_relationship(self):
+ """Edge case: empty relationship
+
+ Should not be used like this but since it's possible to call it with
+ an empty relationship but a valid rel_obj, we include testing for this.
+ """
+ fn = "test_relationchoice_field"
+ value = self.serialize(
+ fn,
+ None,
+ self.doc2,
+ )
+ self.assertTrue(value is None)
+
+ def test_default_relationship(self):
+ fn = "test_relationchoice_field"
+ value = self.serialize(
+ fn,
+ self.doc2,
+ self.doc2,
+ )
+ self.assertTrue(isinstance(value, dict), "Not a ")
+ obj_url = self.doc2.absolute_url()
+ obj_uid = self.doc2.UID()
+ self.assertEqual(
+ {
+ "@id": obj_url,
+ "@type": "DXTestDocument",
+ "UID": obj_uid,
+ "title": "Test Document 2",
+ "description": "Test Document 2 Description",
+ "id": "doc2",
+ "created": "2016-01-21T01:14:48+00:00",
+ "modified": "2017-01-21T01:14:48+00:00",
+ "review_state": "private",
+ },
+ value,
+ )
+
+
+class TestRelationObjectImageSerializingOriginalAndPNGScales(TestCase):
+ layer = PLONE_RESTAPI_DX_INTEGRATION_TESTING
+ maxDiff = None
+
+ def setUp(self):
+ self.portal = self.layer["portal"]
+ self.request = self.layer["request"]
+
+ self.doc1 = self.portal[
+ self.portal.invokeFactory(
+ "DXTestDocument", id="doc1", title="Test Document"
+ )
+ ]
+ self.image1 = self.portal[
+ self.portal.invokeFactory(
+ "Image",
+ id="image1",
+ title="Test Image",
+ description="Test Image Description",
+ )
+ ]
+ self.image1.creation_date = DateTime("2016-01-21T01:14:48+00:00")
+ self.image1.modification_date = DateTime("2017-01-21T01:14:48+00:00")
+
+ def serialize(self, fieldname, value, image):
+ for schema in iterSchemata(self.doc1):
+ if fieldname in schema:
+ field = schema.get(fieldname)
+ break
+ dm = getMultiAdapter((self.doc1, field), IDataManager)
+ dm.set(value)
+ setattr(self.image1, "image", image)
+ serializer = getMultiAdapter((self.image1, field, self.doc1, self.request))
+ return serializer()
+
+ def test_empty_relationship(self):
+ """Edge case: empty relationship"""
+ image_file = os.path.join(os.path.dirname(__file__), "1024x768.gif")
+ with open(image_file, "rb") as f:
+ image_data = f.read()
+ fn = "test_relationchoice_field"
+ value = self.serialize(
+ fn,
+ None,
+ NamedImage(
+ data=image_data, contentType="image/gif", filename="1024x768.gif"
+ ),
+ )
+ self.assertTrue(value is None)
+
+ def test_image_serialization_returns_dict_with_original_scale(self):
+ """In Plone >= 5.2 the image returned when requesting an image
+ scale with the same width and height of the original image is
+ the actual original image in its original format"""
+ image_file = os.path.join(os.path.dirname(__file__), "1024x768.gif")
+ with open(image_file, "rb") as f:
+ image_data = f.read()
+ fn = "test_relationchoice_field"
+ scale_url_uuid = "uuid_1"
+ with patch_scale_uuid(scale_url_uuid):
+ value = self.serialize(
+ fn,
+ self.image1,
+ NamedImage(
+ data=image_data, contentType="image/gif", filename="1024x768.gif"
+ ),
+ )
+ self.assertTrue(isinstance(value, dict), "Not a ")
+ obj_url = self.image1.absolute_url()
+ obj_uid = self.image1.UID()
+ # Original image is still a "scale"
+ # scaled images are converted to PNG in Plone = 5.2
+ original_download_url = "{}/@@images/{}.{}".format(
+ obj_url, scale_url_uuid, "gif"
+ )
+ scale_download_url = "{}/@@images/{}.{}".format(
+ obj_url, scale_url_uuid, "png"
+ )
+ scales = {
+ "listing": {
+ "download": scale_download_url,
+ "width": 16,
+ "height": 12,
+ },
+ "icon": {"download": scale_download_url, "width": 32, "height": 24},
+ "tile": {"download": scale_download_url, "width": 64, "height": 48},
+ "thumb": {
+ "download": scale_download_url,
+ "width": 128,
+ "height": 96,
+ },
+ "mini": {
+ "download": scale_download_url,
+ "width": 200,
+ "height": 150,
+ },
+ "preview": {
+ "download": scale_download_url,
+ "width": 400,
+ "height": 300,
+ },
+ "large": {
+ "download": scale_download_url,
+ "width": 768,
+ "height": 576,
+ },
+ }
+ if HAS_PLONE_6:
+ # PLIP #3279 amended the image scales
+ # https://github.com/plone/Products.CMFPlone/pull/3450
+ scales["great"] = {
+ "download": scale_download_url,
+ "height": 768,
+ "width": 1024,
+ }
+ scales["huge"] = {
+ "download": scale_download_url,
+ "height": 768,
+ "width": 1024,
+ }
+ scales["larger"] = {
+ "download": scale_download_url,
+ "height": 750,
+ "width": 1000,
+ }
+ scales["large"] = {
+ "download": scale_download_url,
+ "height": 600,
+ "width": 800,
+ }
+ scales["teaser"] = {
+ "download": scale_download_url,
+ "height": 450,
+ "width": 600,
+ }
+ self.assertEqual(
+ {
+ "filename": "1024x768.gif",
+ "content-type": "image/gif",
+ "size": 1514,
+ "download": original_download_url,
+ "width": 1024,
+ "height": 768,
+ "@id": obj_url,
+ "@type": "Image",
+ "UID": obj_uid,
+ "title": "Test Image",
+ "description": "Test Image Description",
+ "id": "image1",
+ "created": "2016-01-21T01:14:48+00:00",
+ "modified": "2017-01-21T01:14:48+00:00",
+ "review_state": None,
+ "scales": scales,
+ },
+ value,
+ )
+
+ def test_image_serialization_doesnt_choke_on_corrupt_image(self):
+ """In Plone >= 5.2 the original image file is not a "scale", so its url
+ is returned as is and we need to check it, but the scales should be empty"""
+ image_data = b"INVALID IMAGE DATA"
+ fn = "test_relationchoice_field"
+ scale_url_uuid = "uuid_1"
+ with patch_scale_uuid(scale_url_uuid):
+ value = self.serialize(
+ fn,
+ self.image1,
+ NamedImage(
+ data=image_data, contentType="image/gif", filename="1024x768.gif"
+ ),
+ )
+ obj_url = self.image1.absolute_url()
+ obj_uid = self.image1.UID()
+ self.assertEqual(
+ {
+ "content-type": "image/gif",
+ "download": "{}/@@images/{}.{}".format(
+ obj_url, scale_url_uuid, "gif"
+ ),
+ "filename": "1024x768.gif",
+ "height": -1,
+ "scales": {},
+ "size": 18,
+ "width": -1,
+ "@id": obj_url,
+ "@type": "Image",
+ "UID": obj_uid,
+ "title": "Test Image",
+ "description": "Test Image Description",
+ "id": "image1",
+ "created": "2016-01-21T01:14:48+00:00",
+ "modified": "2017-01-21T01:14:48+00:00",
+ "review_state": None,
+ },
+ value,
+ )
+
+
+class TestRelationObjectSerializers(TestCase):
+ def test_default_relationobject_serializer(self):
+ verifyClass(IRelationObjectSerializer, DefaultRelationObjectSerializer)