From a29e9c10ad0b89a64963ab90f5af19a1936a0f74 Mon Sep 17 00:00:00 2001 From: Nil Bacardit Date: Tue, 10 Oct 2023 15:32:17 +0200 Subject: [PATCH] Adding OrderedDict field --- CHANGELOG.rst | 7 +- contrib-requirements.txt | 2 +- guillotina/api/service.py | 3 +- guillotina/contrib/image/api.py | 13 ++++ guillotina/contrib/image/behaviors.py | 21 ++++++ guillotina/json/serialize_value.py | 12 ++++ guillotina/schema/__init__.py | 3 +- guillotina/schema/_field.py | 21 ++++++ guillotina/schema/interfaces.py | 4 ++ guillotina/tests/image/test_field.py | 94 +++++++++++++++++++++++++++ setup.py | 2 +- 11 files changed, 177 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 44f837629..8f3742c76 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -13,8 +13,13 @@ CHANGELOG - Fix path__starts. Add a slash when parsing the path of the query if the context of the search is not the container, to avoid getting the results of contexts that starts with the same path. - [nbacardit26] + [nilbacardit26] - Adding render_options when registering a user. + [nilbacardit26] +- Adding MultiImageOrderedAttachmentBehavior, and exposing + @orderImages to the images' api to order the keys of a field whose + behavior is MultiImageOrderedAttachment + [nilbacardit26] 6.4.2 (2022-08-25) diff --git a/contrib-requirements.txt b/contrib-requirements.txt index 9548b8b6a..f3a1aa68d 100644 --- a/contrib-requirements.txt +++ b/contrib-requirements.txt @@ -3,7 +3,7 @@ html2text==2019.8.11 aiosmtplib==1.1.4 pre-commit==1.18.2 flake8==5.0.4 -codecov==2.0.15 +codecov==2.1.13 mypy-zope==0.3.2 black==22.3.0 isort==4.3.21 diff --git a/guillotina/api/service.py b/guillotina/api/service.py index b23c7281b..b42f062a8 100644 --- a/guillotina/api/service.py +++ b/guillotina/api/service.py @@ -248,11 +248,12 @@ async def prepare(self): if self.behavior is not None and IAsyncBehavior.implementedBy(self.behavior.__class__): # providedBy not working here? await self.behavior.load() - if IDict.providedBy(field) and ICloudFileField.providedBy(field.value_type): key = self.request.matchdict.get("file_key") if key is not None: self.field = CloudFileField(__name__=name).bind(DictFieldProxy(key, ctx, name)) + else: + self.field = field.bind(ctx) elif ICloudFileField.providedBy(field): self.field = field.bind(ctx) diff --git a/guillotina/contrib/image/api.py b/guillotina/contrib/image/api.py index 6670c042a..e71b1df58 100644 --- a/guillotina/contrib/image/api.py +++ b/guillotina/contrib/image/api.py @@ -138,3 +138,16 @@ async def __call__(self): return await adapter.download() else: return HTTPNoContent() + + +@configure.service( + context=IResource, + method="PATCH", + permission="guillotina.ViewContent", + name="@orderImages/{field_name}", + **_traversed_file_doc("Order the keys of a field"), +) +class OrderMultiImage(TraversableFieldService): + async def __call__(self): + data = await self.request.json() + self.field.reorder_images(data) diff --git a/guillotina/contrib/image/behaviors.py b/guillotina/contrib/image/behaviors.py index bac1c7347..57efcaa67 100644 --- a/guillotina/contrib/image/behaviors.py +++ b/guillotina/contrib/image/behaviors.py @@ -1,6 +1,8 @@ +from collections import OrderedDict as NativeOrderedDict from guillotina import configure from guillotina.contrib.image.image import CloudImageFileField from guillotina.schema import Dict +from guillotina.schema import OrderedDict from guillotina.schema import TextLine from zope.interface import Interface @@ -13,6 +15,10 @@ class IMultiImageAttachmentMarker(Interface): """Marker interface for content with several image attachments.""" +class IMultiImageOrderedAttachmentMarker(Interface): + """Marker interface for content with several ordered image attachments.""" + + @configure.behavior( title="ImageAttachment behavior", marker=IImageAttachmentMarker, for_="guillotina.interfaces.IResource" ) @@ -29,3 +35,18 @@ class IMultiImageAttachment(Interface): images = Dict( key_type=TextLine(), value_type=CloudImageFileField(), default={}, missing_value={}, max_length=1000 ) + + +@configure.behavior( + title="MultiImageAttachment behavior", + marker=IMultiImageAttachmentMarker, + for_="guillotina.interfaces.IResource", +) +class IMultiImageOrderedAttachment(Interface): + images = OrderedDict( + key_type=TextLine(), + value_type=CloudImageFileField(), + default=NativeOrderedDict(), + missing_value=NativeOrderedDict(), + max_length=1000, + ) diff --git a/guillotina/json/serialize_value.py b/guillotina/json/serialize_value.py index a8d4cb7ba..ea5105dd9 100644 --- a/guillotina/json/serialize_value.py +++ b/guillotina/json/serialize_value.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from collections import OrderedDict from datetime import date from datetime import datetime from datetime import time @@ -77,6 +78,17 @@ def dict_converter(value): return dict(zip(keys, values)) +@configure.value_serializer(OrderedDict) +def ordered_dict_converter(value): + if value == {}: + return {} + + keys, values = zip(*value.items()) + keys = map(json_compatible, keys) + values = map(json_compatible, values) + return dict(zip(keys, values)) + + @configure.value_serializer(datetime) def python_datetime_converter(value): try: diff --git a/guillotina/schema/__init__.py b/guillotina/schema/__init__.py index a4292a431..17c3e0e74 100644 --- a/guillotina/schema/__init__.py +++ b/guillotina/schema/__init__.py @@ -42,6 +42,7 @@ from guillotina.schema._field import NativeStringLine from guillotina.schema._field import Object from guillotina.schema._field import Orderable +from guillotina.schema._field import OrderedDict from guillotina.schema._field import Password from guillotina.schema._field import Set from guillotina.schema._field import SourceText @@ -71,7 +72,7 @@ Iterable, List, MaskTextLine, MinMaxLen, NativeString, NativeStringLine, Object, Orderable Password, Set, SourceText, Text, TextLine, Time, Timedelta, Tuple, URI, UnionField get_fields, get_fields_in_order, getFieldNames, getFieldNamesInOrder, -getValidationErrors, getSchemaValidationErrors, JSONField +getValidationErrors, getSchemaValidationErrors, JSONField, OrderedDict accessors ValidationError NO_VALUE diff --git a/guillotina/schema/_field.py b/guillotina/schema/_field.py index d4f4229a5..0f10fb5cc 100644 --- a/guillotina/schema/_field.py +++ b/guillotina/schema/_field.py @@ -12,6 +12,7 @@ # ############################################################################## from collections import namedtuple +from collections import OrderedDict as NativeOrderedDict from datetime import date from datetime import datetime from datetime import time @@ -65,6 +66,7 @@ from guillotina.schema.interfaces import IMinMaxLen from guillotina.schema.interfaces import IObject from guillotina.schema.interfaces import IObjectJSONField +from guillotina.schema.interfaces import IOrderedDict from guillotina.schema.interfaces import IPassword from guillotina.schema.interfaces import ISet from guillotina.schema.interfaces import ISource @@ -665,6 +667,25 @@ def bind(self, object): return clone +@implementer(IOrderedDict) +class OrderedDict(Dict): + """A field representing an OrderedDict.""" + + _type = NativeOrderedDict + + def reorder_images(self, payload): + data_field = self.get(self.context) + # payload is an ordered list of the keys + if isinstance(payload, list): + if len(payload) != len(data_field): + raise ValueError("Length of the payload must be equal to the field") + for key in payload: + if key not in data_field: + raise ValueError("Key not found") + data_field.move_to_end(key) + self.set(self.context, data_field) + + DEFAULT_JSON_SCHMEA = json.dumps({"type": "object", "properties": {}}) diff --git a/guillotina/schema/interfaces.py b/guillotina/schema/interfaces.py index 8ba169fad..164f9a7f3 100644 --- a/guillotina/schema/interfaces.py +++ b/guillotina/schema/interfaces.py @@ -484,6 +484,10 @@ class IDict(IMinMaxLen, IIterable, IContainer): ) +class IOrderedDict(IDict): + """Object representing an ordered dict""" + + class ITerm(Interface): """Object representing a single value in a vocabulary.""" diff --git a/guillotina/tests/image/test_field.py b/guillotina/tests/image/test_field.py index 2cddcb6e2..1fbc849ec 100644 --- a/guillotina/tests/image/test_field.py +++ b/guillotina/tests/image/test_field.py @@ -1,5 +1,6 @@ from guillotina.contrib.image.behaviors import IImageAttachment from guillotina.contrib.image.behaviors import IMultiImageAttachment +from guillotina.contrib.image.behaviors import IMultiImageOrderedAttachment from guillotina.tests.image import TEST_DATA_LOCATION import json @@ -135,3 +136,96 @@ async def test_multiimage_field_with_behavior(redis_container, container_request response, status = await requester("GET", "/db/guillotina/foobar/@images/images/key1/thumb") assert status == 200 + + +@pytest.mark.app_settings( + {"applications": ["guillotina", "guillotina.contrib.image"], "cloud_datamanager": "db"} +) +async def test_multiimage_ordered_field_with_behavior(redis_container, container_requester): + async with container_requester as requester: + _, status = await requester("POST", "/db/guillotina/@addons", data=json.dumps({"id": "image"})) + assert status == 200 + + response, status = await requester( + "POST", + "/db/guillotina/", + data=json.dumps( + {"@type": "Item", "@behaviors": [IMultiImageOrderedAttachment.__identifier__], "id": "foobar"} + ), + ) + assert status == 201 + + with open(os.path.join(TEST_DATA_LOCATION, "profile.jpg"), "rb") as image: + data = image.read() + size = len(data) + + response, status = await requester( + "GET", + "/db/guillotina/foobar", + ) + assert status == 200 + + response, status = await requester( + "PATCH", + "/db/guillotina/foobar/@upload/images/key2", + data=data, + headers={"x-upload-size": f"{size}"}, + ) + assert status == 200 + + response, status = await requester( + "PATCH", + "/db/guillotina/foobar/@upload/images/key1", + data=data, + headers={"x-upload-size": f"{size}"}, + ) + assert status == 200 + + response, status = await requester( + "PATCH", + "/db/guillotina/foobar/@upload/images/key0", + data=data, + headers={"x-upload-size": f"{size}"}, + ) + assert status == 200 + + response, status = await requester( + "PATCH", + "/db/guillotina/foobar/@upload/images/key3", + data=data, + headers={"x-upload-size": f"{size}"}, + ) + assert status == 200 + + response, status = await requester("GET", "/db/guillotina/foobar") + behavior = response["guillotina.contrib.image.behaviors.IMultiImageOrderedAttachment"] + # First in first + keys_ordered = {"key2": 0, "key1": 1, "key0": 2, "key3": 3} + count = 0 + for image in behavior["images"].keys(): + assert count == keys_ordered[image] + count += 1 + + response, status = await requester("DELETE", "/db/guillotina/foobar/@delete/images/key1") + assert status == 200 + + response, status = await requester("GET", "/db/guillotina/foobar") + behavior = response["guillotina.contrib.image.behaviors.IMultiImageOrderedAttachment"] + keys_ordered = {"key2": 0, "key0": 1, "key3": 2} + count = 0 + for image in behavior["images"].keys(): + assert count == keys_ordered[image] + count += 1 + + response, status = await requester( + "PATCH", "/db/guillotina/foobar/@orderImages/images", data=json.dumps(["key3", "key2", "key0"]) + ) + assert status == 200 + + response, status = await requester("GET", "/db/guillotina/foobar") + behavior = response["guillotina.contrib.image.behaviors.IMultiImageOrderedAttachment"] + keys_ordered = {"key3": 0, "key2": 1, "key0": 2} + count = 0 + for image in behavior["images"].keys(): + assert count == keys_ordered[image] + count += 1 diff --git a/setup.py b/setup.py index f9518e44a..e8c68094b 100644 --- a/setup.py +++ b/setup.py @@ -78,7 +78,7 @@ extras_require={ "test": [ "pytest>=3.8.0,<6.3.0", - "docker>=5.0.0,<6.0.0", + "docker>=6.0.0,<6.1.1", # https://github.com/docker/docker-py/pull/3116 "backoff", "psycopg2-binary", "pytest-asyncio<=0.13.0",