From 7a3ed609fd6f9488147fc4a2547ff0247fc66044 Mon Sep 17 00:00:00 2001 From: ozer550 Date: Wed, 27 Mar 2024 21:17:45 +0530 Subject: [PATCH 1/4] create feedback models and add tests --- .../contentcuration/constants/feedback.py | 8 ++ ...nsevent_recommendationsinteractionevent.py | 67 ++++++++++++++ contentcuration/contentcuration/models.py | 66 ++++++++++++++ .../contentcuration/tests/test_models.py | 90 +++++++++++++++++++ 4 files changed, 231 insertions(+) create mode 100644 contentcuration/contentcuration/constants/feedback.py create mode 100644 contentcuration/contentcuration/migrations/0148_flagfeedbackevent_recommendationsevent_recommendationsinteractionevent.py diff --git a/contentcuration/contentcuration/constants/feedback.py b/contentcuration/contentcuration/constants/feedback.py new file mode 100644 index 0000000000..178c4a99ab --- /dev/null +++ b/contentcuration/contentcuration/constants/feedback.py @@ -0,0 +1,8 @@ +FEEDBACK_TYPE_CHOICES = ( + ('IMPORTED', 'Imported'), + ('REJECTED', 'Rejected'), + ('PREVIEWED', 'Previewed'), + ('SHOWMORE', 'Show More'), + ('IGNORED', 'Ignored'), + ('FLAGGED', 'Flagged'), +) diff --git a/contentcuration/contentcuration/migrations/0148_flagfeedbackevent_recommendationsevent_recommendationsinteractionevent.py b/contentcuration/contentcuration/migrations/0148_flagfeedbackevent_recommendationsevent_recommendationsinteractionevent.py new file mode 100644 index 0000000000..6b8ab6de2c --- /dev/null +++ b/contentcuration/contentcuration/migrations/0148_flagfeedbackevent_recommendationsevent_recommendationsinteractionevent.py @@ -0,0 +1,67 @@ +# Generated by Django 3.2.24 on 2024-03-27 15:46 +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contentcuration', '0147_alter_formatpreset_id'), + ] + + operations = [ + migrations.CreateModel( + name='RecommendationsInteractionEvent', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('context', models.JSONField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('contentnode_id', models.UUIDField()), + ('content_id', models.UUIDField()), + ('feedback_type', models.CharField(choices=[('IMPORTED', 'Imported'), ('REJECTED', 'Rejected'), ('PREVIEWED', 'Previewed'), ('SHOWMORE', 'Show More'), ('IGNORED', 'Ignored'), ('FLAGGED', 'Flagged')], max_length=50)), + ('feedback_reason', models.TextField()), + ('recommendation_event_id', models.UUIDField()), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='RecommendationsEvent', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('context', models.JSONField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('contentnode_id', models.UUIDField()), + ('content_id', models.UUIDField()), + ('target_channel_id', models.UUIDField()), + ('time_hidden', models.DateTimeField()), + ('content', models.JSONField(default=list)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='FlagFeedbackEvent', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('context', models.JSONField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('contentnode_id', models.UUIDField()), + ('content_id', models.UUIDField()), + ('target_channel_id', models.UUIDField()), + ('feedback_type', models.CharField(choices=[('IMPORTED', 'Imported'), ('REJECTED', 'Rejected'), ('PREVIEWED', 'Previewed'), ('SHOWMORE', 'Show More'), ('IGNORED', 'Ignored'), ('FLAGGED', 'Flagged')], max_length=50)), + ('feedback_reason', models.TextField()), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 0d73096bfa..e7aa2b7654 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -65,6 +65,7 @@ from contentcuration.constants import channel_history from contentcuration.constants import completion_criteria +from contentcuration.constants import feedback from contentcuration.constants import user_history from contentcuration.constants.contentnode import kind_activity_map from contentcuration.db.models.expressions import Array @@ -2590,3 +2591,68 @@ class Meta: name='task_result_signature', ), ] + + +class BaseFeedback(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + context = models.JSONField() + + # for RecommendationInteractionEvent class contentnode_id represents: + # the date/time this interaction happened + # + # for RecommendationEvent class contentnode_id represents: + # time_shown: timestamp of when the recommendations are first shown + created_at = models.DateTimeField(auto_now_add=True) + + # for RecommendationsEvent class conntentnode_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) + # + # for RecommendationsFlaggedEvent class contentnode_id represents: + # contentnode_id of the content that is being flagged. + contentnode_id = models.UUIDField() + + # These are corresponding values of content_id to given contentNode_id for a ContentNode. + content_id = models.UUIDField() + + class Meta: + abstract = True + + +class BaseFeedbackEvent(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + # The ID of the channel being worked on (where the content is being imported into) + # or the Channel Id where the flagged content exists. + target_channel_id = models.UUIDField() + + class Meta: + abstract = True + + +class BaseFeedbackInteractionEvent(models.Model): + feedback_type = models.CharField(max_length=50, choices=feedback.FEEDBACK_TYPE_CHOICES) + feedback_reason = models.TextField() + + class Meta: + abstract = True + + +class FlagFeedbackEvent(BaseFeedback, BaseFeedbackEvent, BaseFeedbackInteractionEvent): + pass + + +class RecommendationsInteractionEvent(BaseFeedback, BaseFeedbackInteractionEvent): + recommendation_event_id = models.UUIDField() + pass + + +class RecommendationsEvent(BaseFeedback, BaseFeedbackEvent): + # timestamp of when the user navigated away from the recommendation list + time_hidden = models.DateTimeField() + # A list of JSON blobs, representing the content items in the list of recommendations. + content = models.JSONField(default=list) + pass diff --git a/contentcuration/contentcuration/tests/test_models.py b/contentcuration/contentcuration/tests/test_models.py index d53be0176b..89fc9bcec9 100644 --- a/contentcuration/contentcuration/tests/test_models.py +++ b/contentcuration/contentcuration/tests/test_models.py @@ -1,12 +1,15 @@ import uuid +from uuid import uuid4 import mock import pytest from django.conf import settings from django.core.cache import cache from django.core.exceptions import ValidationError +from django.core.management import call_command from django.db.models import Q from django.db.utils import IntegrityError +from django.test import TestCase from django.utils import timezone from le_utils.constants import content_kinds from le_utils.constants import format_presets @@ -21,9 +24,12 @@ from contentcuration.models import CONTENTNODE_TREE_ID_CACHE_KEY from contentcuration.models import File from contentcuration.models import FILE_DURATION_CONSTRAINT +from contentcuration.models import FlagFeedbackEvent from contentcuration.models import generate_object_storage_name from contentcuration.models import Invitation from contentcuration.models import object_storage_name +from contentcuration.models import RecommendationsEvent +from contentcuration.models import RecommendationsInteractionEvent from contentcuration.models import User from contentcuration.models import UserHistory from contentcuration.tests import testdata @@ -981,3 +987,87 @@ def test_prune(self): ChannelHistory.prune() self.assertEqual(2, ChannelHistory.objects.count()) self.assertEqual(2, ChannelHistory.objects.filter(id__in=last_history_ids).count()) + + +class FeedbackModelTests(TestCase): + def setUp(self): + call_command("loadconstants") + self.user = testdata.user() + + def _create_base_feedback_data(self, context, contentnode_id, content_id): + base_feedback_data = { + 'context': context, + 'contentnode_id': contentnode_id, + 'content_id': content_id, + } + return base_feedback_data + + def _create_recommendation_event(self): + channel = testdata.channel() + node_where_import_was_initiated = testdata.node({"kind_id": content_kinds.TOPIC, "title": "recomendations provided here"}) + base_feedback_data = self._create_base_feedback_data( + {'model_version': 1, 'breadcrums': "#Title#->Random"}, + node_where_import_was_initiated.id, + node_where_import_was_initiated.content_id + ) + recommendations_event = RecommendationsEvent.objects.create( + user=self.user, + target_channel_id=channel.id, + time_hidden=timezone.now(), + content=[{'content_id': str(uuid4()), 'node_id': str(uuid4()), 'channel_id': str(uuid4()), 'score': 4}], + **base_feedback_data + ) + + return recommendations_event + + def test_create_flag_feedback_event(self): + channel = testdata.channel("testchannel") + flagged_node = testdata.node({"kind_id": content_kinds.TOPIC, "title": "SuS ContentNode"}) + base_feedback_data = self._create_base_feedback_data( + {'spam': 'Spam or misleading'}, + flagged_node.id, + flagged_node.content_id + ) + flag_feedback_event = FlagFeedbackEvent.objects.create( + user=self.user, + target_channel_id=channel.id, + **base_feedback_data + ) + self.assertEqual(flag_feedback_event.user, self.user) + self.assertEqual(flag_feedback_event.context['spam'], 'Spam or misleading') + + def test_create_recommendations_interaction_event(self): + # This represents a node that was recommended by the model and was interacted by user! + recommended_node = testdata.node({"kind_id": content_kinds.TOPIC, "title": "This node was recommended by the model"}) + base_feedback_data = self._create_base_feedback_data( + {"comment": "explicit reason given by user why he rejected this node!"}, + recommended_node.id, + recommended_node.content_id + ) + fk = self._create_recommendation_event().id + rec_interaction_event = RecommendationsInteractionEvent.objects.create( + feedback_type='rejected', + feedback_reason='some predefined reasons like (not related)', + recommendation_event_id=fk, + **base_feedback_data + ) + self.assertEqual(rec_interaction_event.feedback_type, 'rejected') + self.assertEqual(rec_interaction_event.feedback_reason, 'some predefined reasons like (not related)') + + def test_create_recommendations_event(self): + channel = testdata.channel() + node_where_import_was_initiated = testdata.node({"kind_id": content_kinds.TOPIC, "title": "recomendations provided here"}) + base_feedback_data = self._create_base_feedback_data( + {'model_version': 1, 'breadcrums': "#Title#->Random"}, + node_where_import_was_initiated.id, + node_where_import_was_initiated.content_id + ) + recommendations_event = RecommendationsEvent.objects.create( + user=self.user, + target_channel_id=channel.id, + time_hidden=timezone.now(), + content=[{'content_id': str(uuid4()), 'node_id': str(uuid4()), 'channel_id': str(uuid4()), 'score': 4}], + **base_feedback_data + ) + self.assertEqual(len(recommendations_event.content), 1) + self.assertEqual(recommendations_event.content[0]['score'], 4) From 269b1855d68b0d4260e59ed3bafab93b44368b62 Mon Sep 17 00:00:00 2001 From: ozer550 Date: Mon, 15 Apr 2024 17:08:41 +0530 Subject: [PATCH 2/4] optimize tests --- contentcuration/contentcuration/models.py | 2 -- contentcuration/contentcuration/tests/test_models.py | 7 ++++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index e7aa2b7654..eafa4909b3 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2647,7 +2647,6 @@ class FlagFeedbackEvent(BaseFeedback, BaseFeedbackEvent, BaseFeedbackInteraction class RecommendationsInteractionEvent(BaseFeedback, BaseFeedbackInteractionEvent): recommendation_event_id = models.UUIDField() - pass class RecommendationsEvent(BaseFeedback, BaseFeedbackEvent): @@ -2655,4 +2654,3 @@ class RecommendationsEvent(BaseFeedback, BaseFeedbackEvent): time_hidden = models.DateTimeField() # A list of JSON blobs, representing the content items in the list of recommendations. content = models.JSONField(default=list) - pass diff --git a/contentcuration/contentcuration/tests/test_models.py b/contentcuration/contentcuration/tests/test_models.py index 89fc9bcec9..f08931e8fd 100644 --- a/contentcuration/contentcuration/tests/test_models.py +++ b/contentcuration/contentcuration/tests/test_models.py @@ -990,8 +990,13 @@ def test_prune(self): class FeedbackModelTests(TestCase): - def setUp(self): + + @classmethod + def setUpClass(cls): + super(TestCase, cls).setUpClass() call_command("loadconstants") + + def setUp(self): self.user = testdata.user() def _create_base_feedback_data(self, context, contentnode_id, content_id): From f530c633235f722c8579be7e142097b5845ffe48 Mon Sep 17 00:00:00 2001 From: ozer550 Date: Mon, 15 Apr 2024 21:32:32 +0530 Subject: [PATCH 3/4] fix tests --- contentcuration/contentcuration/tests/test_models.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/contentcuration/contentcuration/tests/test_models.py b/contentcuration/contentcuration/tests/test_models.py index f08931e8fd..262fb64752 100644 --- a/contentcuration/contentcuration/tests/test_models.py +++ b/contentcuration/contentcuration/tests/test_models.py @@ -6,10 +6,8 @@ from django.conf import settings from django.core.cache import cache from django.core.exceptions import ValidationError -from django.core.management import call_command from django.db.models import Q from django.db.utils import IntegrityError -from django.test import TestCase from django.utils import timezone from le_utils.constants import content_kinds from le_utils.constants import format_presets @@ -989,14 +987,14 @@ def test_prune(self): self.assertEqual(2, ChannelHistory.objects.filter(id__in=last_history_ids).count()) -class FeedbackModelTests(TestCase): +class FeedbackModelTests(StudioTestCase): @classmethod def setUpClass(cls): - super(TestCase, cls).setUpClass() - call_command("loadconstants") + super(FeedbackModelTests, cls).setUpClass() def setUp(self): + super(FeedbackModelTests, self).setUp() self.user = testdata.user() def _create_base_feedback_data(self, context, contentnode_id, content_id): From 4cc66c8c9caba7e93b127181522e22915bde7c3d Mon Sep 17 00:00:00 2001 From: ozer550 Date: Mon, 15 Apr 2024 23:31:28 +0530 Subject: [PATCH 4/4] limit feedback_reason length --- ..._recommendationsevent_recommendationsinteractionevent.py | 6 +++--- contentcuration/contentcuration/models.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/contentcuration/contentcuration/migrations/0148_flagfeedbackevent_recommendationsevent_recommendationsinteractionevent.py b/contentcuration/contentcuration/migrations/0148_flagfeedbackevent_recommendationsevent_recommendationsinteractionevent.py index 6b8ab6de2c..ea3b80c86d 100644 --- a/contentcuration/contentcuration/migrations/0148_flagfeedbackevent_recommendationsevent_recommendationsinteractionevent.py +++ b/contentcuration/contentcuration/migrations/0148_flagfeedbackevent_recommendationsevent_recommendationsinteractionevent.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.24 on 2024-03-27 15:46 +# Generated by Django 3.2.24 on 2024-04-15 17:59 import uuid import django.db.models.deletion @@ -23,7 +23,7 @@ class Migration(migrations.Migration): ('contentnode_id', models.UUIDField()), ('content_id', models.UUIDField()), ('feedback_type', models.CharField(choices=[('IMPORTED', 'Imported'), ('REJECTED', 'Rejected'), ('PREVIEWED', 'Previewed'), ('SHOWMORE', 'Show More'), ('IGNORED', 'Ignored'), ('FLAGGED', 'Flagged')], max_length=50)), - ('feedback_reason', models.TextField()), + ('feedback_reason', models.TextField(max_length=1500)), ('recommendation_event_id', models.UUIDField()), ], options={ @@ -57,7 +57,7 @@ class Migration(migrations.Migration): ('content_id', models.UUIDField()), ('target_channel_id', models.UUIDField()), ('feedback_type', models.CharField(choices=[('IMPORTED', 'Imported'), ('REJECTED', 'Rejected'), ('PREVIEWED', 'Previewed'), ('SHOWMORE', 'Show More'), ('IGNORED', 'Ignored'), ('FLAGGED', 'Flagged')], max_length=50)), - ('feedback_reason', models.TextField()), + ('feedback_reason', models.TextField(max_length=1500)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], options={ diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index eafa4909b3..05c8acf350 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2635,7 +2635,7 @@ class Meta: class BaseFeedbackInteractionEvent(models.Model): feedback_type = models.CharField(max_length=50, choices=feedback.FEEDBACK_TYPE_CHOICES) - feedback_reason = models.TextField() + feedback_reason = models.TextField(max_length=1500) class Meta: abstract = True