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)