From 308d4f24b02440b75bbcb88b7300ee4ea8494644 Mon Sep 17 00:00:00 2001 From: ozer550 Date: Thu, 15 May 2025 10:18:52 +0530 Subject: [PATCH 1/4] add remaining recommendation classes in the frontend utilities --- .../frontend/shared/feedbackApiUtils.js | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/contentcuration/contentcuration/frontend/shared/feedbackApiUtils.js b/contentcuration/contentcuration/frontend/shared/feedbackApiUtils.js index dc74e2a4e1..d2cd6e5514 100644 --- a/contentcuration/contentcuration/frontend/shared/feedbackApiUtils.js +++ b/contentcuration/contentcuration/frontend/shared/feedbackApiUtils.js @@ -16,6 +16,8 @@ export const FeedbackTypeOptions = { // This is mock currently, fixed value of URL still to be decided // referencing the url by name export const FLAG_FEEDBACK_EVENT_URL = urls[`${'flagged'}_${'list'}`]; +const RECCOMMENDATION_EVENT_URL = 'TBD'; +const RECCOMMENDATION_INTERACTION_EVENT_URL = 'TBD'; /** * @typedef {Object} BaseFeedbackParams @@ -133,6 +135,35 @@ export class FlagFeedbackEvent extends BaseFlagFeedback { } } +/** + * Initializes a new RecommendationsEvent object. + * + * @param {Object} params - Parameters for initializing the recommendations event. + * @param {Object[]} params.content - An array of JSON objects, + * each representing a recommended content item. + */ +export class RecommendationsEvent extends BaseFeedbackEvent { + constructor({ content, ...basefeedbackEventParams }) { + super(basefeedbackEventParams); + this.content = content; + this.URL = RECCOMMENDATION_EVENT_URL; + } +} + +/** + * Initializes a new RecommendationsInteractionEvent object. + * + * @param {Object} params - Parameters for initializing the recommendations interaction event. + * @param {BaseFeedbackParams} feedbackInteractionEventParams - Parameters inherited from the + * base feedback interaction event class. + */ +export class RecommendationsInteractionEvent extends BaseFeedbackInteractionEvent { + constructor(feedbackInteractionEventParams) { + super(feedbackInteractionEventParams); + this.URL = RECCOMMENDATION_INTERACTION_EVENT_URL; + } +} + /** * Sends a request using the provided feedback object. * From 73783350454ca24f993b0e1c59076e0f63bec515 Mon Sep 17 00:00:00 2001 From: ozer550 Date: Thu, 15 May 2025 16:18:30 +0530 Subject: [PATCH 2/4] add remaining recommendation serializers, serializers and tests --- contentcuration/contentcuration/models.py | 2 +- .../contentcuration/tests/test_serializers.py | 114 ++++++++++++++++++ contentcuration/contentcuration/urls.py | 4 + .../contentcuration/viewsets/feedback.py | 72 +++++++++++ 4 files changed, 191 insertions(+), 1 deletion(-) diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index d10f3ac2b0..b3e76703cc 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2708,7 +2708,7 @@ class BaseFeedback(models.Model): # time_shown: timestamp of when the recommendations are first shown created_at = models.DateTimeField(auto_now_add=True) - # for RecommendationsEvent class conntentnode_id represents: + # for RecommendationsEvent class contentnode_id represents: # target_topic_id that the ID of the topic the user # initiated the import from (where the imported content will go) # diff --git a/contentcuration/contentcuration/tests/test_serializers.py b/contentcuration/contentcuration/tests/test_serializers.py index 3d1a9e4f02..64ec90072a 100644 --- a/contentcuration/contentcuration/tests/test_serializers.py +++ b/contentcuration/contentcuration/tests/test_serializers.py @@ -1,4 +1,7 @@ +import uuid + from django.db.models.query import QuerySet +from django.utils import timezone from le_utils.constants import content_kinds from mock import Mock from rest_framework import serializers @@ -7,11 +10,14 @@ from contentcuration.models import Channel from contentcuration.models import ContentNode from contentcuration.models import DEFAULT_CONTENT_DEFAULTS +from contentcuration.models import RecommendationsEvent from contentcuration.tests import testdata from contentcuration.viewsets.channel import ChannelSerializer as BaseChannelSerializer from contentcuration.viewsets.common import ContentDefaultsSerializer from contentcuration.viewsets.contentnode import ContentNodeSerializer from contentcuration.viewsets.feedback import FlagFeedbackEventSerializer +from contentcuration.viewsets.feedback import RecommendationsEventSerializer +from contentcuration.viewsets.feedback import RecommendationsInteractionEventSerializer def ensure_no_querysets_in_serializer(object): @@ -225,3 +231,111 @@ def test_invalid_data(self): data = {'context': 'invalid'} serializer = FlagFeedbackEventSerializer(data=data) self.assertFalse(serializer.is_valid()) + + +class RecommendationsInteractionEventSerializerTestCase(BaseAPITestCase): + def setUp(self): + super(RecommendationsInteractionEventSerializerTestCase, self).setUp() + self.channel = testdata.channel("testchannel") + self.interaction_node = testdata.node( + { + "kind_id": content_kinds.VIDEO, + "title": "Recommended Video content", + }, + ) + self.node_where_import_is_initiated = testdata.node( + { + "kind_id": content_kinds.TOPIC, + "title": "Node where content is imported", + }, + ) + self.recommendation_event = RecommendationsEvent.objects.create( + user=self.user, + target_channel_id=self.channel.id, + content_id=self.node_where_import_is_initiated.content_id, + contentnode_id=self.node_where_import_is_initiated.id, + context={'model_version': 1, 'breadcrumbs': "#Title#->Random"}, + time_hidden=timezone.now(), + content=[{'content_id': str(uuid.uuid4()), 'node_id': str(uuid.uuid4()), 'channel_id': str(uuid.uuid4()), 'score': 4}] + ) + + def test_deserialization_and_validation(self): + data = { + 'context': {'test_key': 'test_value'}, + 'contentnode_id': str(self.interaction_node.id), + 'content_id': str(self.interaction_node.content_id), + 'feedback_type': 'IGNORED', + 'feedback_reason': '----', + 'recommendation_event_id': str(self.recommendation_event.id) + } + serializer = RecommendationsInteractionEventSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + instance = serializer.save() + self.assertEqual(instance.context, data['context']) + self.assertEqual(instance.feedback_type, data['feedback_type']) + self.assertEqual(str(instance.recommendation_event_id), data['recommendation_event_id']) + + def test_invalid_data(self): + data = {'context': 'invalid'} + serializer = RecommendationsInteractionEventSerializer(data=data) + self.assertFalse(serializer.is_valid()) + + data = { + 'context': {'test_key': 'test_value'}, + 'contentnode_id': str(self.interaction_node.id), + 'content_id': str(self.interaction_node.content_id), + 'feedback_type': 'INVALID_TYPE', + 'feedback_reason': '-----', + 'recommendation_event_id': 'invalid-uuid' + } + serializer = RecommendationsInteractionEventSerializer(data=data) + self.assertFalse(serializer.is_valid()) + + +class RecommendationsEventSerializerTestCase(BaseAPITestCase): + def setUp(self): + super(RecommendationsEventSerializerTestCase, self).setUp() + self.channel = testdata.channel("testchannel") + self.node_where_import_is_initiated = testdata.node( + { + "kind_id": content_kinds.TOPIC, + "title": "Title of the topic", + }, + ) + + def test_deserialization_and_validation(self): + data = { + 'user': self.user.id, + 'target_channel_id': str(self.channel.id), + 'context': {'model_version': 1, 'breadcrumbs': "#Title#->Random"}, + 'contentnode_id': str(self.node_where_import_is_initiated.id), + 'content_id': str(self.node_where_import_is_initiated.content_id), + 'time_hidden': timezone.now().isoformat(), + 'content': [{'content_id': str(uuid.uuid4()), 'node_id': str(uuid.uuid4()), 'channel_id': str(uuid.uuid4()), 'score': 4}] + } + serializer = RecommendationsEventSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + instance = serializer.save() + self.assertEqual(instance.context, data['context']) + self.assertEqual(instance.user.id, data['user']) + self.assertEqual(str(instance.contentnode_id).replace('-', ''), data['contentnode_id'].replace('-', '')) + self.assertEqual(instance.content, data['content']) + + def test_invalid_data(self): + # Test with missing required fields + data = {'context': 'invalid'} + serializer = RecommendationsEventSerializer(data=data) + self.assertFalse(serializer.is_valid()) + + # Test with invalid contentnode_id + data = { + 'user': self.user.id, + 'target_channel_id': str(self.channel.id), + 'context': {'model_version': 1, 'breadcrumbs': "#Title#->Random"}, + 'contentnode_id': 'invalid-uuid', + 'content_id': str(self.node_where_import_is_initiated.content_id), + 'time_hidden': timezone.now().isoformat(), + 'content': [{'content_id': str(uuid.uuid4()), 'node_id': str(uuid.uuid4()), 'channel_id': str(uuid.uuid4()), 'score': 4}] + } + serializer = RecommendationsEventSerializer(data=data) + self.assertFalse(serializer.is_valid()) diff --git a/contentcuration/contentcuration/urls.py b/contentcuration/contentcuration/urls.py index b4764e23ed..aa8fae777b 100644 --- a/contentcuration/contentcuration/urls.py +++ b/contentcuration/contentcuration/urls.py @@ -40,6 +40,8 @@ from contentcuration.viewsets.clipboard import ClipboardViewSet from contentcuration.viewsets.contentnode import ContentNodeViewSet from contentcuration.viewsets.feedback import FlagFeedbackEventViewSet +from contentcuration.viewsets.feedback import RecommendationsEventViewSet +from contentcuration.viewsets.feedback import RecommendationsInteractionEventViewSet from contentcuration.viewsets.file import FileViewSet from contentcuration.viewsets.invitation import InvitationViewSet from contentcuration.viewsets.recommendation import RecommendationView @@ -70,6 +72,8 @@ def get_redirect_url(self, *args, **kwargs): router.register(r'admin-users', AdminUserViewSet, basename='admin-users') router.register(r'clipboard', ClipboardViewSet, basename='clipboard') router.register(r'flagged', FlagFeedbackEventViewSet, basename='flagged') +router.register(r'recommendations', RecommendationsEventViewSet, basename='recommendations') +router.register(r'recommendationsinteraction', RecommendationsInteractionEventViewSet, basename='recommendations-interaction') urlpatterns = [ re_path(r'^api/', include(router.urls)), diff --git a/contentcuration/contentcuration/viewsets/feedback.py b/contentcuration/contentcuration/viewsets/feedback.py index 7044da4bd8..e467340ee1 100644 --- a/contentcuration/contentcuration/viewsets/feedback.py +++ b/contentcuration/contentcuration/viewsets/feedback.py @@ -4,6 +4,8 @@ from rest_framework.permissions import IsAuthenticated from contentcuration.models import FlagFeedbackEvent +from contentcuration.models import RecommendationsEvent +from contentcuration.models import RecommendationsInteractionEvent class IsAdminForListAndDestroy(permissions.BasePermission): @@ -48,6 +50,76 @@ class Meta: fields = BaseFeedbackSerializer.Meta.fields + BaseFeedbackEventSerializer.Meta.fields + BaseFeedbackInteractionEventSerializer.Meta.fields +class RecommendationsInteractionEventSerializer(BaseFeedbackSerializer, BaseFeedbackInteractionEventSerializer): + recommendation_event_id = serializers.UUIDField() + + class Meta: + model = RecommendationsInteractionEvent + fields = BaseFeedbackSerializer.Meta.fields + BaseFeedbackInteractionEventSerializer.Meta.fields + ['recommendation_event_id'] + + def create(self, validated_data): + return RecommendationsInteractionEvent(**validated_data) + + def update(self, instance, validated_data): + instance.save() + + +class RecommendationsEventSerializer(BaseFeedbackSerializer, BaseFeedbackEventSerializer): + content = serializers.JSONField(default=list) + + class Meta: + model = RecommendationsEvent + fields = BaseFeedbackSerializer.Meta.fields + BaseFeedbackEventSerializer.Meta.fields + ['content', 'time_hidden'] + + def create(self, validated_data): + return RecommendationsEvent(**validated_data) + + def update(self, instance, validated_data): + instance.save() + + +class RecommendationsInteractionEventViewSet( + viewsets.ViewSet, +): + queryset = RecommendationsInteractionEvent.objects.all() + serializer_class = RecommendationsInteractionEventSerializer + + # TODO: decide export mechansim to make use of gathered data + + def create(self, request): + pass + + def update(self, request, pk=None): + pass + + def partial_update(self, request, pk=None): + pass + + def destroy(self, request, pk=None): + pass + + +class RecommendationsEventViewSet( + viewsets.ViewSet +): + queryset = RecommendationsEvent.objects.all() + serializer_class = RecommendationsEventSerializer + + # TODO: decide export mechansim to make use of gathered data + + def create(self, request): + pass + + def update(self, request, pk=None): + pass + + def partial_update(self, request, pk=None): + pass + + def destroy(self, request, pk=None): + pass + + class FlagFeedbackEventViewSet(viewsets.ModelViewSet): queryset = FlagFeedbackEvent.objects.all() serializer_class = FlagFeedbackEventSerializer From 41238334a88f918f7b528e637d2641ca487ab092 Mon Sep 17 00:00:00 2001 From: ozer550 Date: Fri, 16 May 2025 01:19:41 +0530 Subject: [PATCH 3/4] add tests for backend --- .../tests/viewsets/test_recommendations.py | 248 ++++++++++++++++++ .../contentcuration/viewsets/feedback.py | 50 +--- 2 files changed, 262 insertions(+), 36 deletions(-) diff --git a/contentcuration/contentcuration/tests/viewsets/test_recommendations.py b/contentcuration/contentcuration/tests/viewsets/test_recommendations.py index 7e92129143..1dbd692984 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_recommendations.py +++ b/contentcuration/contentcuration/tests/viewsets/test_recommendations.py @@ -1,7 +1,11 @@ from automation.utils.appnexus import errors from django.urls import reverse +from le_utils.constants import content_kinds from mock import patch +from contentcuration.models import RecommendationsEvent +from contentcuration.models import RecommendationsInteractionEvent +from contentcuration.tests import testdata from contentcuration.tests.base import StudioAPITestCase @@ -125,3 +129,247 @@ def test_recommendation_generic_error(self, mock_load_recommendations): self.assertEqual(response.status_code, 500) self.assertEqual(response.content.decode(), error_message) mock_load_recommendations.assert_called_once() + + +class RecommendationsEventViewSetTestCase(StudioAPITestCase): + @property + def recommendations_event_object(self): + return { + 'context': {'model_version': 1, 'breadcrumbs': "#Title#->Random"}, + 'contentnode_id': self.contentNode.id, + 'content_id': self.contentNode.content_id, + 'target_channel_id': self.channel.id, + 'user': self.user.id, + 'time_hidden': '2024-03-20T10:00:00Z', + 'content': [{'content_id': str(self.contentNode.content_id), 'node_id': str(self.contentNode.id), 'channel_id': str(self.channel.id), 'score': 4}] + } + + def setUp(self): + super(RecommendationsEventViewSetTestCase, self).setUp() + self.contentNode = testdata.node( + { + "kind_id": content_kinds.VIDEO, + "title": "Recommended Video content", + }, + ) + self.channel = testdata.channel() + self.user = testdata.user() + self.client.force_authenticate(user=self.user) + + def test_create_recommendations_event(self): + recommendations_event = self.recommendations_event_object + response = self.client.post( + reverse("recommendations-list"), recommendations_event, format="json", + ) + self.assertEqual(response.status_code, 201, response.content) + + def test_list_fails(self): + response = self.client.get(reverse("recommendations-list"), format="json") + self.assertEqual(response.status_code, 405, response.content) + + def test_retrieve_fails(self): + recommendations_event = RecommendationsEvent.objects.create( + **{ + 'context': {'model_version': 1, 'breadcrumbs': "#Title#->Random"}, + 'contentnode_id': self.contentNode.id, + 'content_id': self.contentNode.content_id, + 'target_channel_id': self.channel.id, + 'time_hidden': '2024-03-20T10:00:00Z', + 'content': [{'content_id': str(self.contentNode.content_id), + 'node_id': str(self.contentNode.id), + 'channel_id': str(self.channel.id), 'score': 4}] + }, + user=self.user, + ) + response = self.client.get(reverse("recommendations-detail", kwargs={"pk": recommendations_event.id}), format="json") + self.assertEqual(response.status_code, 405, response.content) + + def test_update_recommendations_event(self): + recommendations_event = RecommendationsEvent.objects.create( + **{ + 'context': {'model_version': 1, 'breadcrumbs': "#Title#->Random"}, + 'contentnode_id': self.contentNode.id, + 'content_id': self.contentNode.content_id, + 'target_channel_id': self.channel.id, + 'time_hidden': '2024-03-20T10:00:00Z', + 'content': [{'content_id': str(self.contentNode.content_id), + 'node_id': str(self.contentNode.id), + 'channel_id': str(self.channel.id), + 'score': 4}] + }, + user=self.user, + ) + updated_data = self.recommendations_event_object + updated_data['context'] = {'model_version': 2, 'breadcrumbs': "#Title#->Updated"} + response = self.client.put( + reverse("recommendations-detail", kwargs={"pk": recommendations_event.id}), + updated_data, + format="json" + ) + self.assertEqual(response.status_code, 200, response.content) + + def test_partial_update_recommendations_event(self): + recommendations_event = RecommendationsEvent.objects.create( + **{ + 'context': {'model_version': 1, 'breadcrumbs': "#Title#->Random"}, + 'contentnode_id': self.contentNode.id, + 'content_id': self.contentNode.content_id, + 'target_channel_id': self.channel.id, + 'time_hidden': '2024-03-20T10:00:00Z', + 'content': [{'content_id': str(self.contentNode.content_id), + 'node_id': str(self.contentNode.id), + 'channel_id': str(self.channel.id), + 'score': 4}] + }, + user=self.user, + ) + response = self.client.patch( + reverse("recommendations-detail", kwargs={"pk": recommendations_event.id}), + {'context': {'model_version': 2}}, + format="json" + ) + self.assertEqual(response.status_code, 200, response.content) + + def test_destroy_recommendations_event(self): + recommendations_event = RecommendationsEvent.objects.create( + **{ + 'context': {'model_version': 1, 'breadcrumbs': "#Title#->Random"}, + 'contentnode_id': self.contentNode.id, + 'content_id': self.contentNode.content_id, + 'target_channel_id': self.channel.id, + 'time_hidden': '2024-03-20T10:00:00Z', + 'content': [{'content_id': str(self.contentNode.content_id), + 'node_id': str(self.contentNode.id), + 'channel_id': str(self.channel.id), + 'score': 4}] + }, + user=self.user, + ) + response = self.client.delete( + reverse("recommendations-detail", kwargs={"pk": recommendations_event.id}), + format="json" + ) + self.assertEqual(response.status_code, 204, response.content) + + +class RecommendationsInteractionEventViewSetTestCase(StudioAPITestCase): + @property + def recommendations_interaction_object(self): + return { + 'context': {'test_key': 'test_value'}, + 'contentnode_id': self.interaction_node.id, + 'content_id': self.interaction_node.content_id, + 'feedback_type': 'IGNORED', + 'feedback_reason': '----', + 'recommendation_event_id': str(self.recommendation_event.id) + } + + def setUp(self): + super(RecommendationsInteractionEventViewSetTestCase, self).setUp() + self.channel = testdata.channel() + self.user = testdata.user() + self.client.force_authenticate(user=self.user) + self.interaction_node = testdata.node( + { + "kind_id": content_kinds.VIDEO, + "title": "Recommended Video content", + }, + ) + self.node_where_import_is_initiated = testdata.node( + { + "kind_id": content_kinds.TOPIC, + "title": "Node where content is imported", + }, + ) + self.recommendation_event = RecommendationsEvent.objects.create( + user=self.user, + target_channel_id=self.channel.id, + content_id=self.node_where_import_is_initiated.content_id, + contentnode_id=self.node_where_import_is_initiated.id, + context={'model_version': 1, 'breadcrumbs': "#Title#->Random"}, + time_hidden='2024-03-20T10:00:00Z', + content=[{'content_id': str(self.interaction_node.content_id), + 'node_id': str(self.interaction_node.id), + 'channel_id': str(self.channel.id), + 'score': 4}] + ) + + def test_create_recommendations_interaction(self): + recommendations_interaction = self.recommendations_interaction_object + response = self.client.post( + reverse("recommendations-interaction-list"), recommendations_interaction, format="json", + ) + self.assertEqual(response.status_code, 201, response.content) + + def test_list_fails(self): + response = self.client.get(reverse("recommendations-interaction-list"), format="json") + self.assertEqual(response.status_code, 405, response.content) + + def test_retrieve_fails(self): + recommendations_interaction = RecommendationsInteractionEvent.objects.create( + **{ + 'context': {'test_key': 'test_value'}, + 'contentnode_id': self.interaction_node.id, + 'content_id': self.interaction_node.content_id, + 'feedback_type': 'IGNORED', + 'feedback_reason': '----', + 'recommendation_event_id': self.recommendation_event.id + } + ) + response = self.client.get(reverse("recommendations-interaction-detail", kwargs={"pk": recommendations_interaction.id}), format="json") + self.assertEqual(response.status_code, 405, response.content) + + def test_update_recommendations_interaction(self): + recommendations_interaction = RecommendationsInteractionEvent.objects.create( + **{ + 'context': {'test_key': 'test_value'}, + 'contentnode_id': self.interaction_node.id, + 'content_id': self.interaction_node.content_id, + 'feedback_type': 'IGNORED', + 'feedback_reason': '----', + 'recommendation_event_id': self.recommendation_event.id + } + ) + updated_data = self.recommendations_interaction_object + updated_data['feedback_type'] = 'PREVIEWED' + response = self.client.put( + reverse("recommendations-interaction-detail", kwargs={"pk": recommendations_interaction.id}), + updated_data, + format="json" + ) + self.assertEqual(response.status_code, 200, response.content) + + def test_partial_update_recommendations_interaction(self): + recommendations_interaction = RecommendationsInteractionEvent.objects.create( + **{ + 'context': {'test_key': 'test_value'}, + 'contentnode_id': self.interaction_node.id, + 'content_id': self.interaction_node.content_id, + 'feedback_type': 'IGNORED', + 'feedback_reason': '----', + 'recommendation_event_id': self.recommendation_event.id + } + ) + response = self.client.patch( + reverse("recommendations-interaction-detail", kwargs={"pk": recommendations_interaction.id}), + {'feedback_type': 'IMPORTED'}, + format="json" + ) + self.assertEqual(response.status_code, 200, response.content) + + def test_destroy_recommendations_interaction(self): + recommendations_interaction = RecommendationsInteractionEvent.objects.create( + **{ + 'context': {'test_key': 'test_value'}, + 'contentnode_id': self.interaction_node.id, + 'content_id': self.interaction_node.content_id, + 'feedback_type': 'IGNORED', + 'feedback_reason': '----', + 'recommendation_event_id': self.recommendation_event.id + } + ) + response = self.client.delete( + reverse("recommendations-interaction-detail", kwargs={"pk": recommendations_interaction.id}), + format="json" + ) + self.assertEqual(response.status_code, 204, response.content) diff --git a/contentcuration/contentcuration/viewsets/feedback.py b/contentcuration/contentcuration/viewsets/feedback.py index e467340ee1..0cd07b1dc0 100644 --- a/contentcuration/contentcuration/viewsets/feedback.py +++ b/contentcuration/contentcuration/viewsets/feedback.py @@ -58,10 +58,13 @@ class Meta: fields = BaseFeedbackSerializer.Meta.fields + BaseFeedbackInteractionEventSerializer.Meta.fields + ['recommendation_event_id'] def create(self, validated_data): - return RecommendationsInteractionEvent(**validated_data) + return RecommendationsInteractionEvent.objects.create(**validated_data) def update(self, instance, validated_data): + for attr, value in validated_data.items(): + setattr(instance, attr, value) instance.save() + return instance class RecommendationsEventSerializer(BaseFeedbackSerializer, BaseFeedbackEventSerializer): @@ -72,52 +75,27 @@ class Meta: fields = BaseFeedbackSerializer.Meta.fields + BaseFeedbackEventSerializer.Meta.fields + ['content', 'time_hidden'] def create(self, validated_data): - return RecommendationsEvent(**validated_data) + return RecommendationsEvent.objects.create(**validated_data) def update(self, instance, validated_data): + for attr, value in validated_data.items(): + setattr(instance, attr, value) instance.save() + return instance -class RecommendationsInteractionEventViewSet( - viewsets.ViewSet, -): +class RecommendationsInteractionEventViewSet(viewsets.ModelViewSet): + # TODO: decide export procedure queryset = RecommendationsInteractionEvent.objects.all() serializer_class = RecommendationsInteractionEventSerializer + http_method_names = ['post', 'put', 'patch', 'delete'] - # TODO: decide export mechansim to make use of gathered data - def create(self, request): - pass - - def update(self, request, pk=None): - pass - - def partial_update(self, request, pk=None): - pass - - def destroy(self, request, pk=None): - pass - - -class RecommendationsEventViewSet( - viewsets.ViewSet -): +class RecommendationsEventViewSet(viewsets.ModelViewSet): + # TODO: decide export procedure queryset = RecommendationsEvent.objects.all() serializer_class = RecommendationsEventSerializer - - # TODO: decide export mechansim to make use of gathered data - - def create(self, request): - pass - - def update(self, request, pk=None): - pass - - def partial_update(self, request, pk=None): - pass - - def destroy(self, request, pk=None): - pass + http_method_names = ['post', 'put', 'patch', 'delete'] class FlagFeedbackEventViewSet(viewsets.ModelViewSet): From 98697966b295b99fede98c339ece8d35d0d2cb59 Mon Sep 17 00:00:00 2001 From: ozer550 Date: Fri, 16 May 2025 17:51:52 +0530 Subject: [PATCH 4/4] add frontend tests and register review --- .../shared/__tests__/feedbackUtils.spec.js | 325 ++++++++++++++++-- .../frontend/shared/feedbackApiUtils.js | 30 +- ..._alter_recommendationsevent_time_hidden.py | 18 + contentcuration/contentcuration/models.py | 4 +- .../tests/viewsets/test_recommendations.py | 159 ++++----- .../contentcuration/viewsets/feedback.py | 25 +- 6 files changed, 419 insertions(+), 142 deletions(-) create mode 100644 contentcuration/contentcuration/migrations/0153_alter_recommendationsevent_time_hidden.py diff --git a/contentcuration/contentcuration/frontend/shared/__tests__/feedbackUtils.spec.js b/contentcuration/contentcuration/frontend/shared/__tests__/feedbackUtils.spec.js index c82a4a2561..ae9dd8b23e 100644 --- a/contentcuration/contentcuration/frontend/shared/__tests__/feedbackUtils.spec.js +++ b/contentcuration/contentcuration/frontend/shared/__tests__/feedbackUtils.spec.js @@ -2,8 +2,12 @@ import { v4 as uuidv4 } from 'uuid'; import { sendRequest, FlagFeedbackEvent, + RecommendationsEvent, + RecommendationsInteractionEvent, FeedbackTypeOptions, FLAG_FEEDBACK_EVENT_URL, + RECCOMMENDATION_EVENT_URL, + RECCOMMENDATION_INTERACTION_EVENT_URL, } from '../feedbackApiUtils'; import client from '../client'; @@ -12,6 +16,8 @@ jest.mock('../client'); describe('FeedBackUtility Tests', () => { let flagFeedbackEvent; + let recommendationsEvent; + let recommendationsInteractionEvent; afterEach(() => { jest.clearAllMocks(); @@ -26,54 +32,301 @@ describe('FeedBackUtility Tests', () => { feedback_type: FeedbackTypeOptions.flagged, feedback_reason: 'Inappropriate Language', }); + + recommendationsEvent = new RecommendationsEvent({ + context: { model_version: 1, breadcrumbs: '#Title#->Random' }, + contentnode_id: uuidv4(), + content_id: uuidv4(), + target_channel_id: uuidv4(), + user_id: uuidv4(), + content: [ + { + content_id: uuidv4(), + node_id: uuidv4(), + channel_id: uuidv4(), + score: 4, + }, + ], + }); + + recommendationsInteractionEvent = new RecommendationsInteractionEvent({ + context: { test_key: 'test_value' }, + contentnode_id: uuidv4(), + content_id: uuidv4(), + feedback_type: FeedbackTypeOptions.ignored, + feedback_reason: '----', + recommendation_event_id: uuidv4(), //currently this is random to test but should have the actual + // recommendation event id of the recommendation event + }); + + // Reset all client method mocks client.post.mockRestore(); + client.put.mockRestore(); + client.patch.mockRestore(); + client.delete.mockRestore(); + client.get.mockRestore(); }); - it('should generate data object without functions', () => { - const dataObject = flagFeedbackEvent.getDataObject(); - expect(dataObject.id).toEqual('mocked-uuid'); - expect(dataObject.context).toEqual({ key: 'value' }); - expect(dataObject.contentnode_id).toEqual('mocked-uuid'); - expect(dataObject.content_id).toEqual('mocked-uuid'); + describe('FlagFeedbackEvent Tests', () => { + it('should generate data object without functions', () => { + const dataObject = flagFeedbackEvent.getDataObject(); + expect(dataObject.id).toEqual('mocked-uuid'); + expect(dataObject.context).toEqual({ key: 'value' }); + expect(dataObject.contentnode_id).toEqual('mocked-uuid'); + expect(dataObject.content_id).toEqual('mocked-uuid'); + expect(dataObject.getDataObject).toBeUndefined(); + expect(dataObject.target_topic_id).toEqual('mocked-uuid'); + expect(dataObject.feedback_type).toEqual(FeedbackTypeOptions.flagged); + expect(dataObject.feedback_reason).toEqual('Inappropriate Language'); + expect(dataObject.URL).toBeUndefined(); + }); + + it('should throw an error when URL is not defined', () => { + flagFeedbackEvent.URL = undefined; + expect(() => flagFeedbackEvent.getUrl()).toThrowError( + 'URL is not defined for the FeedBack Object.', + ); + }); - expect(dataObject.getDataObject).toBeUndefined(); - expect(dataObject.target_topic_id).toEqual('mocked-uuid'); - expect(dataObject.feedback_type).toEqual(FeedbackTypeOptions.flagged); - expect(dataObject.feedback_reason).toEqual('Inappropriate Language'); + it('should return the correct URL when URL is defined', () => { + const result = flagFeedbackEvent.getUrl(); + expect(result).toEqual(FLAG_FEEDBACK_EVENT_URL); + }); - expect(dataObject.URL).toBeUndefined(); - }); + it('should send a request using sendRequest function', async () => { + client.post.mockResolvedValue(Promise.resolve({ data: 'Mocked API Response' })); - it('should throw an error when URL is not defined', () => { - flagFeedbackEvent.URL = undefined; - expect(() => flagFeedbackEvent.getUrl()).toThrowError( - 'URL is not defined for the FeedBack Object.', - ); - }); + const result = await sendRequest(flagFeedbackEvent); + + expect(result).toEqual('Mocked API Response'); + expect(client.post).toHaveBeenCalledWith( + FLAG_FEEDBACK_EVENT_URL, + flagFeedbackEvent.getDataObject(), + ); + }); - it('should return the correct URL when URL is defined', () => { - const result = flagFeedbackEvent.getUrl(); - expect(result).toEqual(FLAG_FEEDBACK_EVENT_URL); + it.skip('should handle errors when sending a request using sendRequest function', async () => { + client.post.mockRejectedValue(new Error('Mocked API Error')); + await expect(sendRequest(flagFeedbackEvent)).rejects.toThrowError('Mocked API Error'); + expect(client.post).toHaveBeenCalledWith( + FLAG_FEEDBACK_EVENT_URL, + flagFeedbackEvent.getDataObject(), + ); + }); }); - it('should send a request using sendRequest function', async () => { - client.post.mockResolvedValue(Promise.resolve({ data: 'Mocked API Response' })); + describe('RecommendationsEvent Tests', () => { + it('should generate data object without functions', () => { + const dataObject = recommendationsEvent.getDataObject(); + expect(dataObject.id).toEqual('mocked-uuid'); + expect(dataObject.context).toEqual({ model_version: 1, breadcrumbs: '#Title#->Random' }); + expect(dataObject.contentnode_id).toEqual('mocked-uuid'); + expect(dataObject.content_id).toEqual('mocked-uuid'); + expect(dataObject.target_channel_id).toEqual('mocked-uuid'); + expect(dataObject.user_id).toEqual('mocked-uuid'); + expect(dataObject.content).toEqual([ + { + content_id: 'mocked-uuid', + node_id: 'mocked-uuid', + channel_id: 'mocked-uuid', + score: 4, + }, + ]); + expect(dataObject.getDataObject).toBeUndefined(); + expect(dataObject.URL).toBeUndefined(); + }); + + it('should throw an error when URL is not defined', () => { + recommendationsEvent.URL = undefined; + expect(() => recommendationsEvent.getUrl()).toThrowError( + 'URL is not defined for the FeedBack Object.', + ); + }); + + it('should return the correct URL when URL is defined', () => { + const result = recommendationsEvent.getUrl(); + expect(result).toEqual(RECCOMMENDATION_EVENT_URL); + }); + + describe('HTTP Methods', () => { + it('should send POST request successfully', async () => { + client.post.mockResolvedValue(Promise.resolve({ data: 'Mocked API Response' })); + const result = await sendRequest(recommendationsEvent, 'post'); + expect(result).toEqual('Mocked API Response'); + expect(client.post).toHaveBeenCalledWith( + RECCOMMENDATION_EVENT_URL, + recommendationsEvent.getDataObject(), + ); + }); + + it('should send PUT request successfully', async () => { + client.put.mockResolvedValue(Promise.resolve({ data: 'Mocked API Response' })); + const result = await sendRequest(recommendationsEvent, 'put'); + expect(result).toEqual('Mocked API Response'); + expect(client.put).toHaveBeenCalledWith( + RECCOMMENDATION_EVENT_URL, + recommendationsEvent.getDataObject(), + ); + }); + + it('should send PATCH request successfully', async () => { + client.patch.mockResolvedValue(Promise.resolve({ data: 'Mocked API Response' })); + const result = await sendRequest(recommendationsEvent, 'patch'); + expect(result).toEqual('Mocked API Response'); + expect(client.patch).toHaveBeenCalledWith( + RECCOMMENDATION_EVENT_URL, + recommendationsEvent.getDataObject(), + ); + }); + + it('should handle errors for POST request', async () => { + client.post.mockRejectedValue(new Error('Mocked API Error')); + await expect(sendRequest(recommendationsEvent, 'post')).rejects.toThrowError( + 'Mocked API Error', + ); + expect(client.post).toHaveBeenCalledWith( + RECCOMMENDATION_EVENT_URL, + recommendationsEvent.getDataObject(), + ); + }); + + it('should handle errors for PUT request', async () => { + client.put.mockRejectedValue(new Error('Mocked API Error')); + await expect(sendRequest(recommendationsEvent, 'put')).rejects.toThrowError( + 'Mocked API Error', + ); + expect(client.put).toHaveBeenCalledWith( + RECCOMMENDATION_EVENT_URL, + recommendationsEvent.getDataObject(), + ); + }); - const result = await sendRequest(flagFeedbackEvent); + it('should handle errors for PATCH request', async () => { + client.patch.mockRejectedValue(new Error('Mocked API Error')); + await expect(sendRequest(recommendationsEvent, 'patch')).rejects.toThrowError( + 'Mocked API Error', + ); + expect(client.patch).toHaveBeenCalledWith( + RECCOMMENDATION_EVENT_URL, + recommendationsEvent.getDataObject(), + ); + }); - expect(result).toEqual('Mocked API Response'); - expect(client.post).toHaveBeenCalledWith( - FLAG_FEEDBACK_EVENT_URL, - flagFeedbackEvent.getDataObject(), - ); + it('should throw error for unsupported DELETE method', async () => { + await expect(sendRequest(recommendationsEvent, 'delete')).rejects.toThrowError( + 'Unsupported HTTP method: delete', + ); + }); + + it('should throw error for unsupported GET method', async () => { + await expect(sendRequest(recommendationsEvent, 'get')).rejects.toThrowError( + 'Unsupported HTTP method: get', + ); + }); + }); }); - it.skip('should handle errors when sending a request using sendRequest function', async () => { - client.post.mockRejectedValue(new Error('Mocked API Error')); - await expect(sendRequest(flagFeedbackEvent)).rejects.toThrowError('Mocked API Error'); - expect(client.post).toHaveBeenCalledWith( - FLAG_FEEDBACK_EVENT_URL, - flagFeedbackEvent.getDataObject(), - ); + describe('RecommendationsInteractionEvent Tests', () => { + it('should generate data object without functions', () => { + const dataObject = recommendationsInteractionEvent.getDataObject(); + expect(dataObject.id).toEqual('mocked-uuid'); + expect(dataObject.context).toEqual({ test_key: 'test_value' }); + expect(dataObject.contentnode_id).toEqual('mocked-uuid'); + expect(dataObject.content_id).toEqual('mocked-uuid'); + expect(dataObject.feedback_type).toEqual(FeedbackTypeOptions.ignored); + expect(dataObject.feedback_reason).toEqual('----'); + expect(dataObject.recommendation_event_id).toEqual('mocked-uuid'); + expect(dataObject.getDataObject).toBeUndefined(); + expect(dataObject.URL).toBeUndefined(); + }); + + it('should throw an error when URL is not defined', () => { + recommendationsInteractionEvent.URL = undefined; + expect(() => recommendationsInteractionEvent.getUrl()).toThrowError( + 'URL is not defined for the FeedBack Object.', + ); + }); + + it('should return the correct URL when URL is defined', () => { + const result = recommendationsInteractionEvent.getUrl(); + expect(result).toEqual(RECCOMMENDATION_INTERACTION_EVENT_URL); + }); + + describe('HTTP Methods', () => { + it('should send POST request successfully', async () => { + client.post.mockResolvedValue(Promise.resolve({ data: 'Mocked API Response' })); + const result = await sendRequest(recommendationsInteractionEvent, 'post'); + expect(result).toEqual('Mocked API Response'); + expect(client.post).toHaveBeenCalledWith( + RECCOMMENDATION_INTERACTION_EVENT_URL, + recommendationsInteractionEvent.getDataObject(), + ); + }); + + it('should send PUT request successfully', async () => { + client.put.mockResolvedValue(Promise.resolve({ data: 'Mocked API Response' })); + const result = await sendRequest(recommendationsInteractionEvent, 'put'); + expect(result).toEqual('Mocked API Response'); + expect(client.put).toHaveBeenCalledWith( + RECCOMMENDATION_INTERACTION_EVENT_URL, + recommendationsInteractionEvent.getDataObject(), + ); + }); + + it('should send PATCH request successfully', async () => { + client.patch.mockResolvedValue(Promise.resolve({ data: 'Mocked API Response' })); + const result = await sendRequest(recommendationsInteractionEvent, 'patch'); + expect(result).toEqual('Mocked API Response'); + expect(client.patch).toHaveBeenCalledWith( + RECCOMMENDATION_INTERACTION_EVENT_URL, + recommendationsInteractionEvent.getDataObject(), + ); + }); + + it('should handle errors for POST request', async () => { + client.post.mockRejectedValue(new Error('Mocked API Error')); + await expect(sendRequest(recommendationsInteractionEvent, 'post')).rejects.toThrowError( + 'Mocked API Error', + ); + expect(client.post).toHaveBeenCalledWith( + RECCOMMENDATION_INTERACTION_EVENT_URL, + recommendationsInteractionEvent.getDataObject(), + ); + }); + + it('should handle errors for PUT request', async () => { + client.put.mockRejectedValue(new Error('Mocked API Error')); + await expect(sendRequest(recommendationsInteractionEvent, 'put')).rejects.toThrowError( + 'Mocked API Error', + ); + expect(client.put).toHaveBeenCalledWith( + RECCOMMENDATION_INTERACTION_EVENT_URL, + recommendationsInteractionEvent.getDataObject(), + ); + }); + + it('should handle errors for PATCH request', async () => { + client.patch.mockRejectedValue(new Error('Mocked API Error')); + await expect(sendRequest(recommendationsInteractionEvent, 'patch')).rejects.toThrowError( + 'Mocked API Error', + ); + expect(client.patch).toHaveBeenCalledWith( + RECCOMMENDATION_INTERACTION_EVENT_URL, + recommendationsInteractionEvent.getDataObject(), + ); + }); + + it('should throw error for unsupported DELETE method', async () => { + await expect(sendRequest(recommendationsInteractionEvent, 'delete')).rejects.toThrowError( + 'Unsupported HTTP method: delete', + ); + }); + + it('should throw error for unsupported GET method', async () => { + await expect(sendRequest(recommendationsInteractionEvent, 'get')).rejects.toThrowError( + 'Unsupported HTTP method: get', + ); + }); + }); }); }); diff --git a/contentcuration/contentcuration/frontend/shared/feedbackApiUtils.js b/contentcuration/contentcuration/frontend/shared/feedbackApiUtils.js index d2cd6e5514..692c1e1d8d 100644 --- a/contentcuration/contentcuration/frontend/shared/feedbackApiUtils.js +++ b/contentcuration/contentcuration/frontend/shared/feedbackApiUtils.js @@ -16,8 +16,8 @@ export const FeedbackTypeOptions = { // This is mock currently, fixed value of URL still to be decided // referencing the url by name export const FLAG_FEEDBACK_EVENT_URL = urls[`${'flagged'}_${'list'}`]; -const RECCOMMENDATION_EVENT_URL = 'TBD'; -const RECCOMMENDATION_INTERACTION_EVENT_URL = 'TBD'; +export const RECCOMMENDATION_EVENT_URL = urls['recommendations']; +export const RECCOMMENDATION_INTERACTION_EVENT_URL = urls['recommendations-interaction']; /** * @typedef {Object} BaseFeedbackParams @@ -154,12 +154,15 @@ export class RecommendationsEvent extends BaseFeedbackEvent { * Initializes a new RecommendationsInteractionEvent object. * * @param {Object} params - Parameters for initializing the recommendations interaction event. + * @param {string} params.recommendation_event_id - The ID of the recommendation event this + * interaction is for. * @param {BaseFeedbackParams} feedbackInteractionEventParams - Parameters inherited from the * base feedback interaction event class. */ export class RecommendationsInteractionEvent extends BaseFeedbackInteractionEvent { - constructor(feedbackInteractionEventParams) { + constructor({ recommendation_event_id, ...feedbackInteractionEventParams }) { super(feedbackInteractionEventParams); + this.recommendation_event_id = recommendation_event_id; this.URL = RECCOMMENDATION_INTERACTION_EVENT_URL; } } @@ -170,16 +173,33 @@ export class RecommendationsInteractionEvent extends BaseFeedbackInteractionEven * @function * * @param {BaseFeedback} feedbackObject - The feedback object to use for the request. + * @param {string} [method='post'] - The HTTP method to use (post, put, patch). * @throws {Error} Throws an error if the URL is not defined for the feedback object. * @returns {Promise} A promise that resolves to the response data from the API. */ -export async function sendRequest(feedbackObject) { +export async function sendRequest(feedbackObject, method = 'post') { try { const url = feedbackObject.getUrl(); - const response = await client.post(url, feedbackObject.getDataObject()); + const data = feedbackObject.getDataObject(); + + let response; + switch (method.toLowerCase()) { + case 'post': + response = await client.post(url, data); + break; + case 'put': + response = await client.put(url, data); + break; + case 'patch': + response = await client.patch(url, data); + break; + default: + throw new Error(`Unsupported HTTP method: ${method}`); + } return response.data; } catch (error) { // eslint-disable-next-line no-console console.error('Error sending feedback request:', error); + throw error; } } diff --git a/contentcuration/contentcuration/migrations/0153_alter_recommendationsevent_time_hidden.py b/contentcuration/contentcuration/migrations/0153_alter_recommendationsevent_time_hidden.py new file mode 100644 index 0000000000..451cbeadb0 --- /dev/null +++ b/contentcuration/contentcuration/migrations/0153_alter_recommendationsevent_time_hidden.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.24 on 2025-05-16 07:02 +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contentcuration', '0152_alter_assessmentitem_type'), + ] + + operations = [ + migrations.AlterField( + model_name='recommendationsevent', + name='time_hidden', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index b3e76703cc..5fcf0a63bc 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2714,7 +2714,7 @@ class BaseFeedback(models.Model): # # for ReccomendationsInteractionEvent class contentnode_id represents: # contentNode_id of one of the item being interacted with - # (this must correspond to one of the items in the “content” array on the RecommendationEvent) + # (this must correspond to one of the items in the "content" array on the RecommendationEvent) # # for RecommendationsFlaggedEvent class contentnode_id represents: # contentnode_id of the content that is being flagged. @@ -2755,6 +2755,6 @@ class RecommendationsInteractionEvent(BaseFeedback, BaseFeedbackInteractionEvent class RecommendationsEvent(BaseFeedback, BaseFeedbackEvent): # timestamp of when the user navigated away from the recommendation list - time_hidden = models.DateTimeField() + time_hidden = models.DateTimeField(null=True, blank=True) # A list of JSON blobs, representing the content items in the list of recommendations. content = models.JSONField(default=list) diff --git a/contentcuration/contentcuration/tests/viewsets/test_recommendations.py b/contentcuration/contentcuration/tests/viewsets/test_recommendations.py index 1dbd692984..d45e39ed04 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_recommendations.py +++ b/contentcuration/contentcuration/tests/viewsets/test_recommendations.py @@ -63,8 +63,7 @@ def test_recommend_success(self, mock_load_recommendations): self.client.force_authenticate(user=self.admin_user) mock_load_recommendations.return_value = self.recommendations_list - response = self.client.post(reverse("recommendations"), data=self.topics, - format="json") + response = self.client.post(reverse("recommendations"), data=self.topics, format="json") self.assertEqual(response.status_code, 200, response.content) self.assertEqual(response.json(), self.recommendations_list) @@ -169,16 +168,17 @@ def test_list_fails(self): def test_retrieve_fails(self): recommendations_event = RecommendationsEvent.objects.create( - **{ - 'context': {'model_version': 1, 'breadcrumbs': "#Title#->Random"}, - 'contentnode_id': self.contentNode.id, - 'content_id': self.contentNode.content_id, - 'target_channel_id': self.channel.id, - 'time_hidden': '2024-03-20T10:00:00Z', - 'content': [{'content_id': str(self.contentNode.content_id), - 'node_id': str(self.contentNode.id), - 'channel_id': str(self.channel.id), 'score': 4}] - }, + context={'model_version': 1, 'breadcrumbs': "#Title#->Random"}, + contentnode_id=self.contentNode.id, + content_id=self.contentNode.content_id, + target_channel_id=self.channel.id, + time_hidden='2024-03-20T10:00:00Z', + content=[{ + 'content_id': str(self.contentNode.content_id), + 'node_id': str(self.contentNode.id), + 'channel_id': str(self.channel.id), + 'score': 4 + }], user=self.user, ) response = self.client.get(reverse("recommendations-detail", kwargs={"pk": recommendations_event.id}), format="json") @@ -186,17 +186,17 @@ def test_retrieve_fails(self): def test_update_recommendations_event(self): recommendations_event = RecommendationsEvent.objects.create( - **{ - 'context': {'model_version': 1, 'breadcrumbs': "#Title#->Random"}, - 'contentnode_id': self.contentNode.id, - 'content_id': self.contentNode.content_id, - 'target_channel_id': self.channel.id, - 'time_hidden': '2024-03-20T10:00:00Z', - 'content': [{'content_id': str(self.contentNode.content_id), - 'node_id': str(self.contentNode.id), - 'channel_id': str(self.channel.id), - 'score': 4}] - }, + context={'model_version': 1, 'breadcrumbs': "#Title#->Random"}, + contentnode_id=self.contentNode.id, + content_id=self.contentNode.content_id, + target_channel_id=self.channel.id, + time_hidden='2024-03-20T10:00:00Z', + content=[{ + 'content_id': str(self.contentNode.content_id), + 'node_id': str(self.contentNode.id), + 'channel_id': str(self.channel.id), + 'score': 4 + }], user=self.user, ) updated_data = self.recommendations_event_object @@ -210,17 +210,17 @@ def test_update_recommendations_event(self): def test_partial_update_recommendations_event(self): recommendations_event = RecommendationsEvent.objects.create( - **{ - 'context': {'model_version': 1, 'breadcrumbs': "#Title#->Random"}, - 'contentnode_id': self.contentNode.id, - 'content_id': self.contentNode.content_id, - 'target_channel_id': self.channel.id, - 'time_hidden': '2024-03-20T10:00:00Z', - 'content': [{'content_id': str(self.contentNode.content_id), - 'node_id': str(self.contentNode.id), - 'channel_id': str(self.channel.id), - 'score': 4}] - }, + context={'model_version': 1, 'breadcrumbs': "#Title#->Random"}, + contentnode_id=self.contentNode.id, + content_id=self.contentNode.content_id, + target_channel_id=self.channel.id, + time_hidden='2024-03-20T10:00:00Z', + content=[{ + 'content_id': str(self.contentNode.content_id), + 'node_id': str(self.contentNode.id), + 'channel_id': str(self.channel.id), + 'score': 4 + }], user=self.user, ) response = self.client.patch( @@ -232,24 +232,23 @@ def test_partial_update_recommendations_event(self): def test_destroy_recommendations_event(self): recommendations_event = RecommendationsEvent.objects.create( - **{ - 'context': {'model_version': 1, 'breadcrumbs': "#Title#->Random"}, - 'contentnode_id': self.contentNode.id, - 'content_id': self.contentNode.content_id, - 'target_channel_id': self.channel.id, - 'time_hidden': '2024-03-20T10:00:00Z', - 'content': [{'content_id': str(self.contentNode.content_id), - 'node_id': str(self.contentNode.id), - 'channel_id': str(self.channel.id), - 'score': 4}] - }, + context={'model_version': 1, 'breadcrumbs': "#Title#->Random"}, + contentnode_id=self.contentNode.id, + content_id=self.contentNode.content_id, + target_channel_id=self.channel.id, + time_hidden='2024-03-20T10:00:00Z', + content=[{ + 'content_id': str(self.contentNode.content_id), + 'node_id': str(self.contentNode.id), + 'channel_id': str(self.channel.id), 'score': 4 + }], user=self.user, ) response = self.client.delete( reverse("recommendations-detail", kwargs={"pk": recommendations_event.id}), format="json" ) - self.assertEqual(response.status_code, 204, response.content) + self.assertEqual(response.status_code, 405, response.content) class RecommendationsInteractionEventViewSetTestCase(StudioAPITestCase): @@ -288,10 +287,12 @@ def setUp(self): contentnode_id=self.node_where_import_is_initiated.id, context={'model_version': 1, 'breadcrumbs': "#Title#->Random"}, time_hidden='2024-03-20T10:00:00Z', - content=[{'content_id': str(self.interaction_node.content_id), - 'node_id': str(self.interaction_node.id), - 'channel_id': str(self.channel.id), - 'score': 4}] + content=[{ + 'content_id': str(self.interaction_node.content_id), + 'node_id': str(self.interaction_node.id), + 'channel_id': str(self.channel.id), + 'score': 4 + }] ) def test_create_recommendations_interaction(self): @@ -307,28 +308,24 @@ def test_list_fails(self): def test_retrieve_fails(self): recommendations_interaction = RecommendationsInteractionEvent.objects.create( - **{ - 'context': {'test_key': 'test_value'}, - 'contentnode_id': self.interaction_node.id, - 'content_id': self.interaction_node.content_id, - 'feedback_type': 'IGNORED', - 'feedback_reason': '----', - 'recommendation_event_id': self.recommendation_event.id - } + context={'test_key': 'test_value'}, + contentnode_id=self.interaction_node.id, + content_id=self.interaction_node.content_id, + feedback_type='IGNORED', + feedback_reason='----', + recommendation_event_id=self.recommendation_event.id ) response = self.client.get(reverse("recommendations-interaction-detail", kwargs={"pk": recommendations_interaction.id}), format="json") self.assertEqual(response.status_code, 405, response.content) def test_update_recommendations_interaction(self): recommendations_interaction = RecommendationsInteractionEvent.objects.create( - **{ - 'context': {'test_key': 'test_value'}, - 'contentnode_id': self.interaction_node.id, - 'content_id': self.interaction_node.content_id, - 'feedback_type': 'IGNORED', - 'feedback_reason': '----', - 'recommendation_event_id': self.recommendation_event.id - } + context={'test_key': 'test_value'}, + contentnode_id=self.interaction_node.id, + content_id=self.interaction_node.content_id, + feedback_type='IGNORED', + feedback_reason='----', + recommendation_event_id=self.recommendation_event.id ) updated_data = self.recommendations_interaction_object updated_data['feedback_type'] = 'PREVIEWED' @@ -341,14 +338,12 @@ def test_update_recommendations_interaction(self): def test_partial_update_recommendations_interaction(self): recommendations_interaction = RecommendationsInteractionEvent.objects.create( - **{ - 'context': {'test_key': 'test_value'}, - 'contentnode_id': self.interaction_node.id, - 'content_id': self.interaction_node.content_id, - 'feedback_type': 'IGNORED', - 'feedback_reason': '----', - 'recommendation_event_id': self.recommendation_event.id - } + context={'test_key': 'test_value'}, + contentnode_id=self.interaction_node.id, + content_id=self.interaction_node.content_id, + feedback_type='IGNORED', + feedback_reason='----', + recommendation_event_id=self.recommendation_event.id ) response = self.client.patch( reverse("recommendations-interaction-detail", kwargs={"pk": recommendations_interaction.id}), @@ -359,17 +354,15 @@ def test_partial_update_recommendations_interaction(self): def test_destroy_recommendations_interaction(self): recommendations_interaction = RecommendationsInteractionEvent.objects.create( - **{ - 'context': {'test_key': 'test_value'}, - 'contentnode_id': self.interaction_node.id, - 'content_id': self.interaction_node.content_id, - 'feedback_type': 'IGNORED', - 'feedback_reason': '----', - 'recommendation_event_id': self.recommendation_event.id - } + context={'test_key': 'test_value'}, + contentnode_id=self.interaction_node.id, + content_id=self.interaction_node.content_id, + feedback_type='IGNORED', + feedback_reason='----', + recommendation_event_id=self.recommendation_event.id ) response = self.client.delete( reverse("recommendations-interaction-detail", kwargs={"pk": recommendations_interaction.id}), format="json" ) - self.assertEqual(response.status_code, 204, response.content) + self.assertEqual(response.status_code, 405, response.content) diff --git a/contentcuration/contentcuration/viewsets/feedback.py b/contentcuration/contentcuration/viewsets/feedback.py index 0cd07b1dc0..3ee1db6e94 100644 --- a/contentcuration/contentcuration/viewsets/feedback.py +++ b/contentcuration/contentcuration/viewsets/feedback.py @@ -1,3 +1,4 @@ +from django.utils import timezone from rest_framework import permissions from rest_framework import serializers from rest_framework import viewsets @@ -57,45 +58,37 @@ class Meta: model = RecommendationsInteractionEvent fields = BaseFeedbackSerializer.Meta.fields + BaseFeedbackInteractionEventSerializer.Meta.fields + ['recommendation_event_id'] - def create(self, validated_data): - return RecommendationsInteractionEvent.objects.create(**validated_data) - - def update(self, instance, validated_data): - for attr, value in validated_data.items(): - setattr(instance, attr, value) - instance.save() - return instance - class RecommendationsEventSerializer(BaseFeedbackSerializer, BaseFeedbackEventSerializer): content = serializers.JSONField(default=list) + time_hidden = serializers.DateTimeField(required=False, read_only=True) class Meta: model = RecommendationsEvent fields = BaseFeedbackSerializer.Meta.fields + BaseFeedbackEventSerializer.Meta.fields + ['content', 'time_hidden'] def create(self, validated_data): - return RecommendationsEvent.objects.create(**validated_data) + validated_data.pop('time_hidden', None) + return super().create(validated_data) def update(self, instance, validated_data): - for attr, value in validated_data.items(): - setattr(instance, attr, value) - instance.save() - return instance + if 'time_hidden' in validated_data: + validated_data['time_hidden'] = timezone.now() + return super().update(instance, validated_data) class RecommendationsInteractionEventViewSet(viewsets.ModelViewSet): # TODO: decide export procedure queryset = RecommendationsInteractionEvent.objects.all() serializer_class = RecommendationsInteractionEventSerializer - http_method_names = ['post', 'put', 'patch', 'delete'] + http_method_names = ['post', 'put', 'patch'] class RecommendationsEventViewSet(viewsets.ModelViewSet): # TODO: decide export procedure queryset = RecommendationsEvent.objects.all() serializer_class = RecommendationsEventSerializer - http_method_names = ['post', 'put', 'patch', 'delete'] + http_method_names = ['post', 'put', 'patch'] class FlagFeedbackEventViewSet(viewsets.ModelViewSet):