From c34ae36f2bd7eaac95e41596f391a4611e776c5c Mon Sep 17 00:00:00 2001 From: Roman Kysil Date: Tue, 8 Oct 2024 15:26:22 +0200 Subject: [PATCH 1/3] Feedbacks list update --- CHANGES.rst | 4 +- README.rst | 21 +++ .../feedback/restapi/services/configure.zcml | 8 ++ .../feedback/restapi/services/list_update.py | 64 +++++++++ .../test_restapi_services_list_upgdate.py | 122 ++++++++++++++++++ test_plone60.cfg | 4 + 6 files changed, 221 insertions(+), 2 deletions(-) create mode 100644 src/collective/feedback/restapi/services/list_update.py create mode 100644 src/collective/feedback/tests/test_restapi_services_list_upgdate.py diff --git a/CHANGES.rst b/CHANGES.rst index 3ea3e4f..6789ec3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,8 +5,8 @@ Changelog 1.1.5 (unreleased) ------------------ -- Nothing changed yet. - +- Feedbacks list update endpoint @@feedback-list. + [folix-01] 1.1.4 (2024-08-21) ------------------ diff --git a/README.rst b/README.rst index 6743bce..2be6206 100644 --- a/README.rst +++ b/README.rst @@ -87,6 +87,27 @@ Search reviews Right now data is not indexed so search filters does not work. You only need to call search method to get all data. +List update +----------- + +PATCH +~~~~~ + +This endpoint allows update feedbacks by list. +By now you can only change "read" property + + +Example:: + + curl http://localhost:8080/Plone/@feedback-list \ + -X PATCH \ + -H 'Accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "101010101": {"read": true}, + }' + + Installation ------------ diff --git a/src/collective/feedback/restapi/services/configure.zcml b/src/collective/feedback/restapi/services/configure.zcml index 402ba97..b3fd6af 100644 --- a/src/collective/feedback/restapi/services/configure.zcml +++ b/src/collective/feedback/restapi/services/configure.zcml @@ -45,5 +45,13 @@ layer="collective.feedback.interfaces.ICollectiveFeedbackLayer" name="@feedback" /> + diff --git a/src/collective/feedback/restapi/services/list_update.py b/src/collective/feedback/restapi/services/list_update.py new file mode 100644 index 0000000..c8b0afc --- /dev/null +++ b/src/collective/feedback/restapi/services/list_update.py @@ -0,0 +1,64 @@ +from plone.protect.interfaces import IDisableCSRFProtection +from plone.restapi.deserializer import json_body +from plone.restapi.services import Service +from zExceptions import BadRequest, NotFound +from zope.component import getUtility +from zope.interface import alsoProvides, implementer + +from collective.feedback.interfaces import ICollectiveFeedbackStore + + +class FeedbacListkUpdate(Service): + """ + Service for update feedback to object, you can only update `read` field + """ + + def __init__(self, context, request): + super().__init__(context, request) + + def reply(self): + alsoProvides(self.request, IDisableCSRFProtection) + + tool = getUtility(ICollectiveFeedbackStore) + + form_data = self.extract_data(json_body(self.request)) + + for id, value in form_data.items(): + comment = tool.get(id) + + if comment.get("error", "") == "NotFound": + raise NotFound() + + try: + tool.update(id, value) + except ValueError as e: + self.request.response.setStatus(500) + return dict( + error=dict( + type="InternalServerError", + message=getattr(e, "message", e.__str__()), + ) + ) + + return form_data + + def extract_data(self, form_data): + data = {} + + for id, value in form_data.items(): + try: + self.validate_data(value) + data[int(id)] = {"read": value.get("read")} + except ValueError: + raise BadRequest(f"Bad id={id} format provided") + + return data + + def validate_data(self, data): + """ + check all required fields and parameters + """ + for field in ["read"]: + value = data.get(field, None) + if value is None: + raise BadRequest("Campo obbligatorio mancante: {}".format(field)) diff --git a/src/collective/feedback/tests/test_restapi_services_list_upgdate.py b/src/collective/feedback/tests/test_restapi_services_list_upgdate.py new file mode 100644 index 0000000..a865a01 --- /dev/null +++ b/src/collective/feedback/tests/test_restapi_services_list_upgdate.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +import unittest + +import transaction +from plone import api +from plone.app.testing import ( + SITE_OWNER_NAME, + SITE_OWNER_PASSWORD, + TEST_USER_ID, + setRoles, +) +from plone.restapi.testing import RelativeSession +from zope.component import getUtility + +from collective.feedback.interfaces import ICollectiveFeedbackStore +from collective.feedback.testing import RESTAPI_TESTING + + +class TestAdd(unittest.TestCase): + layer = RESTAPI_TESTING + + def setUp(self): + self.app = self.layer["app"] + self.portal = self.layer["portal"] + self.portal_url = self.portal.absolute_url() + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + api.user.create( + email="memberuser@example.com", + username="memberuser", + password="secret!!", + ) + + self.document = api.content.create( + title="Document", container=self.portal, type="Document" + ) + api.content.transition(obj=self.document, transition="publish") + + self.private_document = api.content.create( + title="restricted document", container=self.portal, type="Document" + ) + transaction.commit() + + self.api_session = RelativeSession(self.portal_url) + self.api_session.headers.update({"Accept": "application/json"}) + self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) + self.anon_api_session = RelativeSession(self.portal_url) + self.anon_api_session.headers.update({"Accept": "application/json"}) + + self.url = "{}/@feedback-add".format(self.document.absolute_url()) + self.url_private_document = "{}/@feedback-add".format( + self.private_document.absolute_url() + ) + + def tearDown(self): + self.api_session.close() + self.anon_api_session.close() + + def test_correctly_update_data(self): + self.anon_api_session.post( + self.url, + json={"vote": 3, "comment": "i disagree", "honey": ""}, + ) + self.anon_api_session.post( + self.url, + json={"vote": 2, "comment": "i disagree", "honey": ""}, + ) + transaction.commit() + tool = getUtility(ICollectiveFeedbackStore) + feedbacks = tool.search() + + self.assertEqual(len(feedbacks), 2) + + self.api_session.patch( + api.portal.get().absolute_url() + "/@feedback-list", + json={str(feedbacks[0].intid): {"read": True}}, + ) + transaction.commit() + + self.assertTrue(tool.get(feedbacks[0].intid).attrs.get("read")) + + def test_unknown_id(self): + self.anon_api_session.post( + self.url, + json={"vote": 3, "comment": "i disagree", "honey": ""}, + ) + transaction.commit() + + tool = getUtility(ICollectiveFeedbackStore) + feedbacks = tool.search() + + self.assertEqual(len(feedbacks), 1) + + resp = self.api_session.patch( + api.portal.get().absolute_url() + "/@feedback-list", + json={"1111111111": {"read": True}}, + ) + + transaction.commit() + + self.assertEqual(resp.status_code, 404) + + def test_bad_id(self): + self.anon_api_session.post( + self.url, + json={"vote": 3, "comment": "i disagree", "honey": ""}, + ) + transaction.commit() + + tool = getUtility(ICollectiveFeedbackStore) + feedbacks = tool.search() + + self.assertEqual(len(feedbacks), 1) + + resp = self.api_session.patch( + api.portal.get().absolute_url() + "/@feedback-list", + json={"fffffffff": {"read": True}}, + ) + + transaction.commit() + + self.assertEqual(resp.status_code, 400) diff --git a/test_plone60.cfg b/test_plone60.cfg index 12e1d6f..aece9ad 100644 --- a/test_plone60.cfg +++ b/test_plone60.cfg @@ -92,3 +92,7 @@ collective.honeypot = 2.1 # Added by buildout at 2024-02-08 21:49:12.973900 zpretty = 3.1.0 + +# Added by buildout at 2024-10-08 14:48:52.237066 +pluggy = 1.5.0 +tomli = 2.0.2 From 63a3562f343fad72cf3ae88814c6a69adcaa8a3d Mon Sep 17 00:00:00 2001 From: Roman Kysil Date: Wed, 30 Oct 2024 12:12:02 +0100 Subject: [PATCH 2/3] Add sort and filter options --- .../feedback/restapi/services/get.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/collective/feedback/restapi/services/get.py b/src/collective/feedback/restapi/services/get.py index 64454e5..fbd61b2 100644 --- a/src/collective/feedback/restapi/services/get.py +++ b/src/collective/feedback/restapi/services/get.py @@ -15,6 +15,8 @@ from collective.feedback.interfaces import ICollectiveFeedbackStore +DEFAULT_SORT_KEY = "date" + @implementer(IPublishTraverse) class FeedbackGet(Service): @@ -35,6 +37,9 @@ def reply(self): results = self.get_single_object_feedbacks(self.params[0]) else: results = self.get_data() + + results = self.filter_unread(self.sort_results(results)) + batch = HypermediaBatch(self.request, results) data = { "@id": batch.canonical_url, @@ -48,6 +53,24 @@ def reply(self): data["actions"] = {"can_delete_feedbacks": self.can_delete_feedbacks()} return data + def sort_results(self, results): + sort_on = self.request.get("sort_on") + sort_order = self.request.get("sort_order", "") + + return sorted( + results, + key=lambda item: item.get(sort_on, DEFAULT_SORT_KEY), + reverse=sort_order == "descending", + ) + + def filter_unread(self, results): + unread = self.request.get("unread") + + if unread: + return list(filter(lambda item: not item.get("read"), results)) + + return results + def can_delete_feedbacks(self): return api.user.has_permission("collective.feedback: Delete Feedbacks") From 01106ff582a93fe9188cc629b74730ec66f036be Mon Sep 17 00:00:00 2001 From: Roman Kysil Date: Thu, 7 Nov 2024 14:31:04 +0100 Subject: [PATCH 3/3] Unused import --- src/collective/feedback/restapi/services/list_update.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/collective/feedback/restapi/services/list_update.py b/src/collective/feedback/restapi/services/list_update.py index c8b0afc..eb0837d 100644 --- a/src/collective/feedback/restapi/services/list_update.py +++ b/src/collective/feedback/restapi/services/list_update.py @@ -3,7 +3,7 @@ from plone.restapi.services import Service from zExceptions import BadRequest, NotFound from zope.component import getUtility -from zope.interface import alsoProvides, implementer +from zope.interface import alsoProvides from collective.feedback.interfaces import ICollectiveFeedbackStore