diff --git a/.gitignore b/.gitignore index 3988bfb..481d0ef 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ report.html reports/ venv/ # excludes +.python-version diff --git a/CHANGES.rst b/CHANGES.rst index 701e29b..1d3dcc6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,7 +5,11 @@ Changelog 1.1.4 (unreleased) ------------------ -- Nothing changed yet. +- Add feedback update endpoint. + [folix-01] + +- Add read field to the comment. + [folix-01] 1.1.3 (2024-04-29) diff --git a/src/collective/feedback/__init__.py b/src/collective/feedback/__init__.py index b661503..43b0762 100644 --- a/src/collective/feedback/__init__.py +++ b/src/collective/feedback/__init__.py @@ -2,5 +2,4 @@ """Init and utils.""" from zope.i18nmessageid import MessageFactory - _ = MessageFactory("collective.feedback") diff --git a/src/collective/feedback/locales/update.py b/src/collective/feedback/locales/update.py index ce89f54..9e6a067 100644 --- a/src/collective/feedback/locales/update.py +++ b/src/collective/feedback/locales/update.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- import os -import pkg_resources import subprocess +import pkg_resources domain = "collective.feedback" os.chdir(pkg_resources.resource_filename(domain, "")) diff --git a/src/collective/feedback/permissions.zcml b/src/collective/feedback/permissions.zcml index ee69ca2..1368e60 100644 --- a/src/collective/feedback/permissions.zcml +++ b/src/collective/feedback/permissions.zcml @@ -21,6 +21,10 @@ id="collective.feedback.ShowDeletedFeedbacks" title="collective.feedback: Show Deleted Feedbacks" /> + diff --git a/src/collective/feedback/profiles/default/metadata.xml b/src/collective/feedback/profiles/default/metadata.xml index 498df83..6e1e374 100644 --- a/src/collective/feedback/profiles/default/metadata.xml +++ b/src/collective/feedback/profiles/default/metadata.xml @@ -1,6 +1,6 @@ - 1201 + 1202 profile-plone.restapi:default profile-souper.plone:default diff --git a/src/collective/feedback/profiles/default/rolemap.xml b/src/collective/feedback/profiles/default/rolemap.xml index cfdb62b..62639c6 100644 --- a/src/collective/feedback/profiles/default/rolemap.xml +++ b/src/collective/feedback/profiles/default/rolemap.xml @@ -25,6 +25,11 @@ - + + + + + + diff --git a/src/collective/feedback/restapi/services/add.py b/src/collective/feedback/restapi/services/add.py index 77e418c..2b8151e 100644 --- a/src/collective/feedback/restapi/services/add.py +++ b/src/collective/feedback/restapi/services/add.py @@ -1,4 +1,3 @@ -from collective.feedback.interfaces import ICollectiveFeedbackStore from plone import api from plone.protect.interfaces import IDisableCSRFProtection from plone.restapi.deserializer import json_body @@ -7,6 +6,8 @@ from zope.component import getUtility from zope.interface import alsoProvides +from collective.feedback.interfaces import ICollectiveFeedbackStore + class FeedbackAdd(Service): """ diff --git a/src/collective/feedback/restapi/services/configure.zcml b/src/collective/feedback/restapi/services/configure.zcml index c80e9b3..402ba97 100644 --- a/src/collective/feedback/restapi/services/configure.zcml +++ b/src/collective/feedback/restapi/services/configure.zcml @@ -37,5 +37,13 @@ layer="collective.feedback.interfaces.ICollectiveFeedbackLayer" name="@feedback-delete" /> + diff --git a/src/collective/feedback/restapi/services/delete.py b/src/collective/feedback/restapi/services/delete.py index 36fd8ff..9afed5f 100644 --- a/src/collective/feedback/restapi/services/delete.py +++ b/src/collective/feedback/restapi/services/delete.py @@ -1,12 +1,12 @@ -from collective.feedback.interfaces import ICollectiveFeedbackStore from plone.protect.interfaces import IDisableCSRFProtection from plone.restapi.services import Service from zExceptions import BadRequest from zope.component import getUtility -from zope.interface import alsoProvides -from zope.interface import implementer +from zope.interface import alsoProvides, implementer from zope.publisher.interfaces import IPublishTraverse +from collective.feedback.interfaces import ICollectiveFeedbackStore + @implementer(IPublishTraverse) class FeedbackDelete(Service): diff --git a/src/collective/feedback/restapi/services/get.py b/src/collective/feedback/restapi/services/get.py index 41d7ccc..64454e5 100644 --- a/src/collective/feedback/restapi/services/get.py +++ b/src/collective/feedback/restapi/services/get.py @@ -1,7 +1,8 @@ -from AccessControl import Unauthorized -from collective.feedback.interfaces import ICollectiveFeedbackStore +import csv from copy import deepcopy from datetime import datetime + +from AccessControl import Unauthorized from plone import api from plone.restapi.batching import HypermediaBatch from plone.restapi.search.utils import unflatten_dotted_dict @@ -12,7 +13,7 @@ from zope.interface import implementer from zope.publisher.interfaces import IPublishTraverse -import csv +from collective.feedback.interfaces import ICollectiveFeedbackStore @implementer(IPublishTraverse) @@ -115,6 +116,8 @@ def get_single_object_feedbacks(self, uid): "answer": record._attrs.get("answer", ""), "comment": record._attrs.get("comment", ""), "title": commented_object.title, + "id": record.intid, + "read": record._attrs.get("read", ""), } ) @@ -135,6 +138,7 @@ def get_data(self): uid = feedback._attrs.get("uid", "") date = feedback._attrs.get("date", "") vote = feedback._attrs.get("vote", "") + if uid not in feedbacks: obj = self.get_commented_obj(uid=uid) if not obj and not api.user.has_permission( @@ -161,6 +165,11 @@ def get_data(self): data["vote_num"] += 1 data["vote_sum"] += vote + # Sign if page has unread comments + data["has_unread"] = data.get( + "has_unread", False + ) or not feedback._attrs.get("read", False) + # number of comment comment = feedback._attrs.get("comment", "") answer = feedback._attrs.get("answer", "") @@ -174,10 +183,27 @@ def get_data(self): if data["last_vote"] < date: data["last_vote"] = date - # avg calculation + pages_to_remove = [] + + has_undread = query.get("has_unread", None) + + if has_undread in ("true", "false"): + has_undread = not (has_undread == "false") and has_undread == "true" + else: + has_undread = None + for uid, feedback in feedbacks.items(): + # avg calculation feedback["vote"] = feedback.pop("vote_sum") / feedback.pop("vote_num") + # Use has_unread filter + if has_undread is not None: + if feedback["has_unread"] != has_undread: + pages_to_remove.append(uid) + + for uid in pages_to_remove: + del feedbacks[uid] + result = list(feedbacks.values()) # sort diff --git a/src/collective/feedback/restapi/services/update.py b/src/collective/feedback/restapi/services/update.py new file mode 100644 index 0000000..ce09740 --- /dev/null +++ b/src/collective/feedback/restapi/services/update.py @@ -0,0 +1,82 @@ +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 zope.publisher.interfaces import IPublishTraverse + +from collective.feedback.interfaces import ICollectiveFeedbackStore + + +@implementer(IPublishTraverse) +class FeedbackUpdate(Service): + """ + Service for update feedback to object, you can only update `read` field + """ + + def __init__(self, context, request): + super().__init__(context, request) + self.params = [] + + def publishTraverse(self, request, id): + # Consume any path segments after /@users as parameters + self.params.append(id) + return self + + def reply(self): + alsoProvides(self.request, IDisableCSRFProtection) + + tool = getUtility(ICollectiveFeedbackStore) + + if self.params: + try: + id = int(self.params[0]) + except ValueError: + raise BadRequest(f"Bad id={self.params[0]} format provided") + + comment = tool.get(id) + + if comment.get("error", "") == "NotFound": + raise NotFound() + + form_data = json_body(self.request) + + self.validate_form(form_data=form_data) + + form_data = self.extract_data(form_data) + + try: + res = tool.update(id, form_data) + except ValueError as e: + self.request.response.setStatus(500) + return dict( + error=dict( + type="InternalServerError", + message=getattr(e, "message", e.__str__()), + ) + ) + + if res is None: + return self.reply_no_content() + + self.request.response.setStatus(500) + + return dict( + error=dict( + type="InternalServerError", + message="Unable to add. Contact site manager.", + ) + ) + + def extract_data(sefl, form_data): + return {"read": form_data.get("read")} + + def validate_form(self, form_data): + """ + check all required fields and parameters + """ + for field in ["read"]: + value = form_data.get(field, None) + if value is None: + raise BadRequest("Campo obbligatorio mancante: {}".format(field)) diff --git a/src/collective/feedback/storage/catalog.py b/src/collective/feedback/storage/catalog.py index e2f6b9a..4266051 100644 --- a/src/collective/feedback/storage/catalog.py +++ b/src/collective/feedback/storage/catalog.py @@ -3,8 +3,7 @@ from repoze.catalog.indexes.field import CatalogFieldIndex from repoze.catalog.indexes.text import CatalogTextIndex from souper.interfaces import ICatalogFactory -from souper.soup import NodeAttributeIndexer -from souper.soup import NodeTextIndexer +from souper.soup import NodeAttributeIndexer, NodeTextIndexer from zope.interface import implementer diff --git a/src/collective/feedback/storage/store.py b/src/collective/feedback/storage/store.py index dd0805b..b795ea4 100644 --- a/src/collective/feedback/storage/store.py +++ b/src/collective/feedback/storage/store.py @@ -1,18 +1,14 @@ # -*- coding: utf-8 -*- -from collective.feedback.interfaces import ICollectiveFeedbackStore +import logging from datetime import datetime -from plone import api -from repoze.catalog.query import And -from repoze.catalog.query import Any -from repoze.catalog.query import Contains -from repoze.catalog.query import Eq -from souper.soup import get_soup -from souper.soup import Record -from zope.interface import implementer -import logging import six +from plone import api +from repoze.catalog.query import And, Any, Contains, Eq +from souper.soup import Record, get_soup +from zope.interface import implementer +from collective.feedback.interfaces import ICollectiveFeedbackStore logger = logging.getLogger(__name__) @@ -21,7 +17,7 @@ class CollectiveFeedbackStore(object): """Store class for collective.feedback catalog soup""" - fields = ["uid", "url", "title", "comment", "vote", "answer", "date"] + fields = ["uid", "url", "title", "comment", "vote", "answer", "date", "read"] text_index = "title" indexes = ["title", "vote", "uid"] keyword_indexes = [] @@ -83,10 +79,10 @@ def parse_query_params(self, index, value): # return "{} in any('{}')".format(index, value) else: return Eq(index, value) - if isinstance(value, int): - return "{} == {}".format(index, value) - else: - return "{} == '{}'".format(index, value) + # if isinstance(value, int): + # return "{} == {}".format(index, value) + # else: + # return "{} == '{}'".format(index, value) def get_record(self, id): if isinstance(id, six.text_type) or isinstance(id, str): @@ -113,6 +109,7 @@ def update(self, id, data): else: record.attrs[k] = v + self.soup.reindex(records=[record]) def delete(self, id): @@ -123,5 +120,12 @@ def delete(self, id): return {"error": "NotFound"} del self.soup[record] + def get(self, id): + try: + return self.soup.get(id) + except KeyError: + logger.error('[GET] Subscription with id "{}" not found.'.format(id)) + return {"error": "NotFound"} + def clear(self): self.soup.clear() diff --git a/src/collective/feedback/testing.py b/src/collective/feedback/testing.py index a04d653..df70938 100644 --- a/src/collective/feedback/testing.py +++ b/src/collective/feedback/testing.py @@ -1,16 +1,17 @@ -from plone.app.contenttypes.testing import PLONE_APP_CONTENTTYPES_FIXTURE -from plone.app.testing import applyProfile -from plone.app.testing import FunctionalTesting -from plone.app.testing import IntegrationTesting -from plone.app.testing import PloneSandboxLayer -from plone.testing.zope import WSGI_SERVER_FIXTURE - -import collective.feedback import collective.honeypot import collective.honeypot.config import plone.restapi import souper.plone +from plone.app.contenttypes.testing import PLONE_APP_CONTENTTYPES_FIXTURE +from plone.app.testing import ( + FunctionalTesting, + IntegrationTesting, + PloneSandboxLayer, + applyProfile, +) +from plone.testing.zope import WSGI_SERVER_FIXTURE +import collective.feedback collective.honeypot.config.EXTRA_PROTECTED_ACTIONS = set(["feedback-add"]) collective.honeypot.config.HONEYPOT_FIELD = "honey" diff --git a/src/collective/feedback/tests/test_delete_content.py b/src/collective/feedback/tests/test_delete_content.py index a17a00c..d41169e 100644 --- a/src/collective/feedback/tests/test_delete_content.py +++ b/src/collective/feedback/tests/test_delete_content.py @@ -1,19 +1,21 @@ # -*- coding: utf-8 -*- -from collective.feedback.interfaces import ICollectiveFeedbackStore -from collective.feedback.testing import RESTAPI_TESTING +import unittest from datetime import datetime + +import transaction from plone import api -from plone.app.testing import setRoles -from plone.app.testing import SITE_OWNER_NAME -from plone.app.testing import SITE_OWNER_PASSWORD -from plone.app.testing import TEST_USER_ID +from plone.app.testing import ( + SITE_OWNER_NAME, + SITE_OWNER_PASSWORD, + TEST_USER_ID, + setRoles, +) from plone.restapi.testing import RelativeSession -from souper.soup import get_soup -from souper.soup import Record +from souper.soup import Record, get_soup from zope.component import getUtility -import transaction -import unittest +from collective.feedback.interfaces import ICollectiveFeedbackStore +from collective.feedback.testing import RESTAPI_TESTING class TestCustomerSatisfactionGet(unittest.TestCase): diff --git a/src/collective/feedback/tests/test_feedbacks_add.py b/src/collective/feedback/tests/test_feedbacks_add.py index 5d20231..08f9b3c 100644 --- a/src/collective/feedback/tests/test_feedbacks_add.py +++ b/src/collective/feedback/tests/test_feedbacks_add.py @@ -1,16 +1,19 @@ # -*- coding: utf-8 -*- -from collective.feedback.interfaces import ICollectiveFeedbackStore -from collective.feedback.testing import RESTAPI_TESTING +import unittest + +import transaction from plone import api -from plone.app.testing import setRoles -from plone.app.testing import SITE_OWNER_NAME -from plone.app.testing import SITE_OWNER_PASSWORD -from plone.app.testing import TEST_USER_ID +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 -import transaction -import unittest +from collective.feedback.interfaces import ICollectiveFeedbackStore +from collective.feedback.testing import RESTAPI_TESTING class TestAdd(unittest.TestCase): diff --git a/src/collective/feedback/tests/test_feedbacks_get.py b/src/collective/feedback/tests/test_feedbacks_get.py index 6f413e0..224c43a 100644 --- a/src/collective/feedback/tests/test_feedbacks_get.py +++ b/src/collective/feedback/tests/test_feedbacks_get.py @@ -1,30 +1,35 @@ # -*- coding: utf-8 -*- -from collective.feedback.interfaces import ICollectiveFeedbackStore -from collective.feedback.testing import RESTAPI_TESTING +import unittest from datetime import datetime + +import transaction from plone import api -from plone.app.testing import setRoles -from plone.app.testing import SITE_OWNER_NAME -from plone.app.testing import SITE_OWNER_PASSWORD -from plone.app.testing import TEST_USER_ID +from plone.app.testing import ( + SITE_OWNER_NAME, + SITE_OWNER_PASSWORD, + TEST_USER_ID, + setRoles, +) from plone.restapi.serializer.converters import json_compatible from plone.restapi.testing import RelativeSession -from souper.soup import get_soup -from souper.soup import Record +from souper.soup import Record, get_soup from zope.component import getUtility -import transaction -import unittest +from collective.feedback.interfaces import ICollectiveFeedbackStore +from collective.feedback.testing import RESTAPI_TESTING class TestGet(unittest.TestCase): layer = RESTAPI_TESTING - def add_record(self, date=None, vote="", uid="", comment="", title=""): + def add_record(self, date=None, vote="", uid="", comment="", title="", read=False): if not date: date = datetime.now() + soup = get_soup("feedback_soup", self.portal) + transaction.commit() + record = Record() record.attrs["vote"] = vote record.attrs["date"] = date @@ -35,6 +40,9 @@ def add_record(self, date=None, vote="", uid="", comment="", title=""): record.attrs["uid"] = uid if title: record.attrs["title"] = title + if read: + record.attrs["read"] = read + soup.add(record) transaction.commit() @@ -124,6 +132,7 @@ def test_endpoint_returns_data(self): [ { "comments": 1, + "has_unread": True, "last_vote": json_compatible(now), "title": "", "uid": "", @@ -231,3 +240,59 @@ def test_users_without_permission_dont_have_can_delete_feedbacks_action(self): self.assertIn("can_delete_feedbacks", res["actions"]) self.assertFalse(res["actions"]["can_delete_feedbacks"]) + + def test_has_unread_filter(self): + response = self.api_session.get(self.url) + res = response.json() + + self.assertEqual(res["items_total"], 0) + + now = datetime.now() + + self.add_record(vote=1, comment="is ok", date=now, read=True) + + response = self.api_session.get(self.url) + + res = response.json() + + self.assertEqual(res["items_total"], 1) + + self.assertEqual( + res["items"], + [ + { + "comments": 1, + "last_vote": json_compatible(now), + "title": "", + "uid": "", + "vote": 1.0, + "has_unread": False, + } + ], + ) + + self.add_record(vote=1, comment="is ok", date=now, read=False) + + # has_unread=true case + response = self.api_session.get(self.url + "?has_unread=true") + + self.assertEqual( + response.json()["items"], + [ + { + "comments": 2, + "last_vote": json_compatible(now), + "title": "", + "uid": "", + "vote": 1.0, + "has_unread": True, + } + ], + ) + + self.add_record(vote=1, comment="is ok", date=now) + + # has_unread=false case + response = self.api_session.get(self.url + "?has_unread=false") + + self.assertEqual(response.json()["items"], []) diff --git a/src/collective/feedback/tests/test_store.py b/src/collective/feedback/tests/test_store.py index 1ffaffa..3a09aa1 100644 --- a/src/collective/feedback/tests/test_store.py +++ b/src/collective/feedback/tests/test_store.py @@ -1,12 +1,12 @@ # -*- coding: utf-8 -*- -from collective.feedback.interfaces import ICollectiveFeedbackStore -from collective.feedback.testing import FUNCTIONAL_TESTING -from plone.app.testing import setRoles -from plone.app.testing import TEST_USER_ID -from zope.component import getUtility +import unittest import transaction -import unittest +from plone.app.testing import TEST_USER_ID, setRoles +from zope.component import getUtility + +from collective.feedback.interfaces import ICollectiveFeedbackStore +from collective.feedback.testing import FUNCTIONAL_TESTING class TestTool(unittest.TestCase): diff --git a/src/collective/feedback/upgrades.py b/src/collective/feedback/upgrades.py index 0f42610..f06ae53 100644 --- a/src/collective/feedback/upgrades.py +++ b/src/collective/feedback/upgrades.py @@ -1,9 +1,8 @@ # -*- coding: utf-8 -*- -from plone import api -from plone.app.upgrade.utils import installOrReinstallProduct - import logging +from plone import api +from plone.app.upgrade.utils import installOrReinstallProduct logger = logging.getLogger(__name__) diff --git a/src/collective/feedback/upgrades.zcml b/src/collective/feedback/upgrades.zcml index bb953d3..4b23689 100644 --- a/src/collective/feedback/upgrades.zcml +++ b/src/collective/feedback/upgrades.zcml @@ -37,4 +37,14 @@ handler=".upgrades.update_actions" /> + + +