diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 9e0911b619..5f1a5acea6 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -292,6 +292,10 @@ def check_space(self, size, checksum): if space < size: raise PermissionDenied(_("Not enough space. Check your storage under Settings page.")) + def check_feature_flag(self, flag_name): + feature_flags = self.feature_flags or {} + return feature_flags.get(flag_name, False) + def check_channel_space(self, channel): active_files = self.get_user_active_files() staging_tree_id = channel.staging_tree.tree_id diff --git a/contentcuration/contentcuration/tests/test_serializers.py b/contentcuration/contentcuration/tests/test_serializers.py index 1eed06db9e..a30232527e 100644 --- a/contentcuration/contentcuration/tests/test_serializers.py +++ b/contentcuration/contentcuration/tests/test_serializers.py @@ -8,9 +8,11 @@ from contentcuration.models import Channel from contentcuration.models import ContentNode from contentcuration.models import DEFAULT_CONTENT_DEFAULTS +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 def ensure_no_querysets_in_serializer(object): @@ -178,3 +180,46 @@ def test_save__update(self): self.assertEqual( dict(author="Buster", license="Special Permissions"), c.content_defaults ) + + +class FlagFeedbackSerializerTestCase(BaseAPITestCase): + def setUp(self): + super(FlagFeedbackSerializerTestCase, self).setUp() + self.channel = testdata.channel("testchannel") + self.flagged_node = testdata.node( + { + "kind_id": content_kinds.VIDEO, + "title": "Suspicious Video content", + }, + ) + + 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 test_deserialization_and_validation(self): + data = { + 'user': self.user.id, + 'target_channel_id': str(self.channel.id), + 'context': {'test_key': 'test_value'}, + 'contentnode_id': str(self.flagged_node.id), + 'content_id': str(self.flagged_node.content_id), + 'feedback_type': 'FLAGGED', + 'feedback_reason': 'Reason1.....' + } + serializer = FlagFeedbackEventSerializer(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(instance.feedback_type, data['feedback_type']) + self.assertEqual(instance.feedback_reason, data['feedback_reason']) + + def test_invalid_data(self): + data = {'context': 'invalid'} + serializer = FlagFeedbackEventSerializer(data=data) + self.assertFalse(serializer.is_valid()) diff --git a/contentcuration/contentcuration/tests/testdata.py b/contentcuration/contentcuration/tests/testdata.py index 3592c9df17..5904e2789f 100644 --- a/contentcuration/contentcuration/tests/testdata.py +++ b/contentcuration/contentcuration/tests/testdata.py @@ -229,11 +229,13 @@ def random_string(chars=10): return ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(chars)) -def user(email='user@test.com'): +def user(email='user@test.com', feature_flags=None): user, is_new = cc.User.objects.get_or_create(email=email) if is_new: user.set_password('password') user.is_active = True + if feature_flags is not None: + user.feature_flags = feature_flags user.save() return user diff --git a/contentcuration/contentcuration/tests/viewsets/test_flagged.py b/contentcuration/contentcuration/tests/viewsets/test_flagged.py new file mode 100644 index 0000000000..a507c5e4e9 --- /dev/null +++ b/contentcuration/contentcuration/tests/viewsets/test_flagged.py @@ -0,0 +1,134 @@ +from django.urls import reverse +from le_utils.constants import content_kinds + +from contentcuration.models import FlagFeedbackEvent +from contentcuration.tests import testdata +from contentcuration.tests.base import StudioAPITestCase + + +class CRUDTestCase(StudioAPITestCase): + @property + def flag_feedback_object(self): + return { + 'context': {'spam': 'Spam or misleading'}, + 'contentnode_id': self.contentNode.id, + 'content_id': self.contentNode.content_id, + 'target_channel_id': self.channel.id, + 'user': self.user.id, + 'feedback_type': 'FLAGGED', + 'feedback_reason': 'Some reason provided by the user' + } + + def setUp(self): + super(CRUDTestCase, self).setUp() + self.contentNode = testdata.node( + { + "kind_id": content_kinds.VIDEO, + "title": "Suspicious Video content", + }, + ) + self.channel = testdata.channel() + self.user = testdata.user(feature_flags={"test_dev_feature": True}) + + def test_create_flag_event(self): + self.client.force_authenticate(user=self.user) + flagged_content = self.flag_feedback_object + response = self.client.post( + reverse("flagged-list"), flagged_content, format="json", + ) + self.assertEqual(response.status_code, 201, response.content) + + def test_create_flag_event_fails_for_flag_test_dev_feature_disabled(self): + flagged_content = self.flag_feedback_object + self.user.feature_flags = {'test_dev_feature': False} + self.user.save() + self.client.force_authenticate(user=self.user) + response = self.client.post( + reverse("flagged-list"), flagged_content, format="json", + ) + self.assertEqual(response.status_code, 403, response.content) + + def test_create_flag_event_fails_for_flag_test_dev_feature_None(self): + flagged_content = self.flag_feedback_object + self.user.feature_flags = None + self.user.save() + self.client.force_authenticate(user=self.user) + response = self.client.post( + reverse("flagged-list"), flagged_content, format="json", + ) + self.assertEqual(response.status_code, 403, response.content) + + def test_create_flag_event_fails_for_unauthorized_user(self): + flagged_content = self.flag_feedback_object + response = self.client.post( + reverse("flagged-list"), flagged_content, format="json", + ) + self.assertEqual(response.status_code, 403, response.content) + + def test_list_flagged_content_super_admin(self): + self.user.is_admin = True + self.user.save() + self.client.force_authenticate(self.user) + response = self.client.get(reverse("flagged-list"), format="json") + self.assertEqual(response.status_code, 200, response.content) + + def test_retreive_fails_for_normal_user(self): + self.client.force_authenticate(user=self.user) + flag_feedback_object = FlagFeedbackEvent.objects.create( + **{ + 'context': {'spam': 'Spam or misleading'}, + 'contentnode_id': self.contentNode.id, + 'content_id': self.contentNode.content_id, + 'target_channel_id': self.channel.id, + 'feedback_type': 'FLAGGED', + 'feedback_reason': 'Some reason provided by the user' + }, + user=self.user, + ) + response = self.client.get(reverse("flagged-detail", kwargs={"pk": flag_feedback_object.id}), format="json") + self.assertEqual(response.status_code, 403, response.content) + + def test_list_fails_for_normal_user(self): + self.client.force_authenticate(user=self.user) + response = self.client.get(reverse("flagged-list"), format="json") + self.assertEqual(response.status_code, 403, response.content) + + def test_list_fails_for_user_dev_feature_enabled(self): + response = self.client.get(reverse("flagged-list"), format="json") + self.assertEqual(response.status_code, 403, response.content) + + def test_destroy_flagged_content_super_admin(self): + self.user.is_admin = True + self.user.save() + self.client.force_authenticate(self.user) + flag_feedback_object = FlagFeedbackEvent.objects.create( + **{ + 'context': {'spam': 'Spam or misleading'}, + 'contentnode_id': self.contentNode.id, + 'content_id': self.contentNode.content_id, + 'target_channel_id': self.channel.id, + 'feedback_type': 'FLAGGED', + 'feedback_reason': 'Some reason provided by the user' + }, + user=self.user, + ) + response = self.client.delete(reverse("flagged-detail", kwargs={"pk": flag_feedback_object.id}), format="json") + self.assertEqual(response.status_code, 204, response.content) + + def test_destroy_flagged_content_fails_for_user_with_feature_flag_disabled(self): + self.user.feature_flags = {'test_dev_feature': False} + self.user.save() + self.client.force_authenticate(user=self.user) + flag_feedback_object = FlagFeedbackEvent.objects.create( + **{ + 'context': {'spam': 'Spam or misleading'}, + 'contentnode_id': self.contentNode.id, + 'content_id': self.contentNode.content_id, + 'target_channel_id': self.channel.id, + 'feedback_type': 'FLAGGED', + 'feedback_reason': 'Some reason provided by the user' + }, + user=self.user, + ) + response = self.client.delete(reverse("flagged-detail", kwargs={"pk": flag_feedback_object.id}), format="json") + self.assertEqual(response.status_code, 403, response.content) diff --git a/contentcuration/contentcuration/urls.py b/contentcuration/contentcuration/urls.py index bb03f3876e..8047ca0bf4 100644 --- a/contentcuration/contentcuration/urls.py +++ b/contentcuration/contentcuration/urls.py @@ -39,6 +39,7 @@ from contentcuration.viewsets.channelset import ChannelSetViewSet from contentcuration.viewsets.clipboard import ClipboardViewSet from contentcuration.viewsets.contentnode import ContentNodeViewSet +from contentcuration.viewsets.feedback import FlagFeedbackEventViewSet from contentcuration.viewsets.file import FileViewSet from contentcuration.viewsets.invitation import InvitationViewSet from contentcuration.viewsets.sync.endpoint import SyncView @@ -67,6 +68,7 @@ def get_redirect_url(self, *args, **kwargs): router.register(r'assessmentitem', AssessmentItemViewSet) router.register(r'admin-users', AdminUserViewSet, basename='admin-users') router.register(r'clipboard', ClipboardViewSet, basename='clipboard') +router.register(r'flagged', FlagFeedbackEventViewSet, basename='flagged') urlpatterns = [ re_path(r'^api/', include(router.urls)), diff --git a/contentcuration/contentcuration/viewsets/feedback.py b/contentcuration/contentcuration/viewsets/feedback.py new file mode 100644 index 0000000000..ff46187fe3 --- /dev/null +++ b/contentcuration/contentcuration/viewsets/feedback.py @@ -0,0 +1,48 @@ +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 + + +class IsAdminForListAndDestroy(permissions.BasePermission): + def has_permission(self, request, view): + # only allow list and destroy of flagged content to admins + if view.action in ['list', 'destroy', 'retrieve']: + try: + return request.user and request.user.is_admin + except AttributeError: + return False + if request.user.check_feature_flag('test_dev_feature'): + return True + return False + + +class BaseFeedbackSerializer(serializers.ModelSerializer): + class Meta: + fields = ['id', 'context', 'contentnode_id', 'content_id'] + read_only_fields = ['id'] + + +class BaseFeedbackEventSerializer(serializers.ModelSerializer): + class Meta: + fields = ['user', 'target_channel_id'] + read_only_fields = ['user'] + + +class BaseFeedbackInteractionEventSerializer(serializers.ModelSerializer): + class Meta: + fields = ['feedback_type', 'feedback_reason'] + + +class FlagFeedbackEventSerializer(BaseFeedbackSerializer, BaseFeedbackEventSerializer, BaseFeedbackInteractionEventSerializer): + class Meta: + model = FlagFeedbackEvent + fields = BaseFeedbackSerializer.Meta.fields + BaseFeedbackEventSerializer.Meta.fields + BaseFeedbackInteractionEventSerializer.Meta.fields + + +class FlagFeedbackEventViewSet(viewsets.ModelViewSet): + queryset = FlagFeedbackEvent.objects.all() + serializer_class = FlagFeedbackEventSerializer + permission_classes = [IsAuthenticated, IsAdminForListAndDestroy]