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 dc74e2a4e1..692c1e1d8d 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'}`]; +export const RECCOMMENDATION_EVENT_URL = urls['recommendations']; +export const RECCOMMENDATION_INTERACTION_EVENT_URL = urls['recommendations-interaction']; /** * @typedef {Object} BaseFeedbackParams @@ -133,22 +135,71 @@ 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 {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({ recommendation_event_id, ...feedbackInteractionEventParams }) { + super(feedbackInteractionEventParams); + this.recommendation_event_id = recommendation_event_id; + this.URL = RECCOMMENDATION_INTERACTION_EVENT_URL; + } +} + /** * Sends a request using the provided feedback object. * * @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 d10f3ac2b0..5fcf0a63bc 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2708,13 +2708,13 @@ 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) # # 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/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/tests/viewsets/test_recommendations.py b/contentcuration/contentcuration/tests/viewsets/test_recommendations.py index 7e92129143..d45e39ed04 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 @@ -59,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) @@ -125,3 +128,241 @@ 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, 405, 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, 405, response.content) 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..3ee1db6e94 100644 --- a/contentcuration/contentcuration/viewsets/feedback.py +++ b/contentcuration/contentcuration/viewsets/feedback.py @@ -1,9 +1,12 @@ +from django.utils import timezone from rest_framework import permissions from rest_framework import serializers from rest_framework import viewsets 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 +51,46 @@ 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'] + + +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): + validated_data.pop('time_hidden', None) + return super().create(validated_data) + + def update(self, instance, validated_data): + 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'] + + +class RecommendationsEventViewSet(viewsets.ModelViewSet): + # TODO: decide export procedure + queryset = RecommendationsEvent.objects.all() + serializer_class = RecommendationsEventSerializer + http_method_names = ['post', 'put', 'patch'] + + class FlagFeedbackEventViewSet(viewsets.ModelViewSet): queryset = FlagFeedbackEvent.objects.all() serializer_class = FlagFeedbackEventSerializer