diff --git a/lms/djangoapps/discussion_api/api.py b/lms/djangoapps/discussion_api/api.py index 0e842d2931..a3a81b33ce 100644 --- a/lms/djangoapps/discussion_api/api.py +++ b/lms/djangoapps/discussion_api/api.py @@ -389,6 +389,11 @@ def _do_extra_actions(api_content, cc_content, request_fields, actions_form, con context["cc_requester"].follow(cc_content) else: context["cc_requester"].unfollow(cc_content) + elif field == "abuse_flagged": + if form_value: + cc_content.flagAbuse(context["cc_requester"], cc_content) + else: + cc_content.unFlagAbuse(context["cc_requester"], cc_content, removeAll=False) else: assert field == "voted" if form_value: diff --git a/lms/djangoapps/discussion_api/forms.py b/lms/djangoapps/discussion_api/forms.py index 6ebc6bcd9d..eaa9d69598 100644 --- a/lms/djangoapps/discussion_api/forms.py +++ b/lms/djangoapps/discussion_api/forms.py @@ -90,6 +90,7 @@ class ThreadActionsForm(Form): """ following = BooleanField(required=False) voted = BooleanField(required=False) + abuse_flagged = BooleanField(required=False) class CommentListGetForm(_PaginationForm): @@ -108,3 +109,4 @@ class CommentActionsForm(Form): interactions with the comments service. """ voted = BooleanField(required=False) + abuse_flagged = BooleanField(required=False) diff --git a/lms/djangoapps/discussion_api/permissions.py b/lms/djangoapps/discussion_api/permissions.py index b76b58cbbe..596e3792f4 100644 --- a/lms/djangoapps/discussion_api/permissions.py +++ b/lms/djangoapps/discussion_api/permissions.py @@ -23,7 +23,7 @@ def get_editable_fields(cc_content, context): Return the set of fields that the requester can edit on the given content """ # Shared fields - ret = {"voted"} + ret = {"abuse_flagged", "voted"} if _is_author_or_privileged(cc_content, context): ret |= {"raw_body"} diff --git a/lms/djangoapps/discussion_api/tests/test_api.py b/lms/djangoapps/discussion_api/tests/test_api.py index 2008b2a907..8286d2b0fa 100644 --- a/lms/djangoapps/discussion_api/tests/test_api.py +++ b/lms/djangoapps/discussion_api/tests/test_api.py @@ -614,7 +614,7 @@ class GetThreadListTest(CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTest "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_0", "endorsed_comment_list_url": None, "non_endorsed_comment_list_url": None, - "editable_fields": ["following", "voted"], + "editable_fields": ["abuse_flagged", "following", "voted"], }, { "id": "test_thread_id_1", @@ -645,7 +645,7 @@ class GetThreadListTest(CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTest "non_endorsed_comment_list_url": ( "http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_1&endorsed=False" ), - "editable_fields": ["following", "voted"], + "editable_fields": ["abuse_flagged", "following", "voted"], }, ] self.assertEqual( @@ -998,7 +998,7 @@ class GetCommentListTest(CommentsServiceMockMixin, ModuleStoreTestCase): "voted": False, "vote_count": 4, "children": [], - "editable_fields": ["voted"], + "editable_fields": ["abuse_flagged", "voted"], }, { "id": "test_comment_2", @@ -1018,7 +1018,7 @@ class GetCommentListTest(CommentsServiceMockMixin, ModuleStoreTestCase): "voted": False, "vote_count": 7, "children": [], - "editable_fields": ["voted"], + "editable_fields": ["abuse_flagged", "voted"], }, ] actual_comments = self.get_comment_list( @@ -1238,7 +1238,7 @@ class CreateThreadTest(CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTestC "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_id", "endorsed_comment_list_url": None, "non_endorsed_comment_list_url": None, - "editable_fields": ["following", "raw_body", "title", "topic_id", "type", "voted"], + "editable_fields": ["abuse_flagged", "following", "raw_body", "title", "topic_id", "type", "voted"], } self.assertEqual(actual, expected) self.assertEqual( @@ -1306,6 +1306,18 @@ class CreateThreadTest(CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTestC {"user_id": [str(self.user.id)], "value": ["up"]} ) + def test_abuse_flagged(self): + self.register_post_thread_response({"id": "test_id"}) + self.register_thread_flag_response("test_id") + data = self.minimal_data.copy() + data["abuse_flagged"] = "True" + result = create_thread(self.request, data) + self.assertEqual(result["abuse_flagged"], True) + cs_request = httpretty.last_request() + self.assertEqual(urlparse(cs_request.path).path, "/api/v1/threads/test_id/abuse_flag") + self.assertEqual(cs_request.method, "PUT") + self.assertEqual(cs_request.parsed_body, {"user_id": [str(self.user.id)]}) + def test_course_id_missing(self): with self.assertRaises(ValidationError) as assertion: create_thread(self.request, {}) @@ -1404,7 +1416,7 @@ class CreateCommentTest(CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTest "voted": False, "vote_count": 0, "children": [], - "editable_fields": ["raw_body", "voted"] + "editable_fields": ["abuse_flagged", "raw_body", "voted"] } self.assertEqual(actual, expected) expected_url = ( @@ -1459,6 +1471,18 @@ class CreateCommentTest(CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTest {"user_id": [str(self.user.id)], "value": ["up"]} ) + def test_abuse_flagged(self): + self.register_post_comment_response({"id": "test_comment"}, "test_thread") + self.register_comment_flag_response("test_comment") + data = self.minimal_data.copy() + data["abuse_flagged"] = "True" + result = create_comment(self.request, data) + self.assertEqual(result["abuse_flagged"], True) + cs_request = httpretty.last_request() + self.assertEqual(urlparse(cs_request.path).path, "/api/v1/comments/test_comment/abuse_flag") + self.assertEqual(cs_request.method, "PUT") + self.assertEqual(cs_request.parsed_body, {"user_id": [str(self.user.id)]}) + def test_thread_id_missing(self): with self.assertRaises(ValidationError) as assertion: create_comment(self.request, {}) @@ -1618,7 +1642,7 @@ class UpdateThreadTest(CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTestC "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread", "endorsed_comment_list_url": None, "non_endorsed_comment_list_url": None, - "editable_fields": ["following", "raw_body", "title", "topic_id", "type", "voted"], + "editable_fields": ["abuse_flagged", "following", "raw_body", "title", "topic_id", "type", "voted"], } self.assertEqual(actual, expected) self.assertEqual( @@ -1798,6 +1822,41 @@ class UpdateThreadTest(CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTestC expected_request_data["value"] = ["up"] self.assertEqual(actual_request_data, expected_request_data) + @ddt.data(*itertools.product([True, False], [True, False])) + @ddt.unpack + def test_abuse_flagged(self, old_flagged, new_flagged): + """ + Test attempts to edit the "abuse_flagged" field. + + old_flagged indicates whether the thread should be flagged at the start + of the test. new_flagged indicates the value for the "abuse_flagged" + field in the update. If old_flagged and new_flagged are the same, no + update should be made. Otherwise, a PUT should be made to the flag or + or unflag endpoint according to the new_flagged value. + """ + self.register_get_user_response(self.user) + self.register_thread_flag_response("test_thread") + self.register_thread({"abuse_flaggers": [str(self.user.id)] if old_flagged else []}) + data = {"abuse_flagged": new_flagged} + result = update_thread(self.request, "test_thread", data) + self.assertEqual(result["abuse_flagged"], new_flagged) + last_request_path = urlparse(httpretty.last_request().path).path + flag_url = "/api/v1/threads/test_thread/abuse_flag" + unflag_url = "/api/v1/threads/test_thread/abuse_unflag" + if old_flagged == new_flagged: + self.assertNotEqual(last_request_path, flag_url) + self.assertNotEqual(last_request_path, unflag_url) + else: + self.assertEqual( + last_request_path, + flag_url if new_flagged else unflag_url + ) + self.assertEqual(httpretty.last_request().method, "PUT") + self.assertEqual( + httpretty.last_request().parsed_body, + {"user_id": [str(self.user.id)]} + ) + def test_invalid_field(self): self.register_thread() with self.assertRaises(ValidationError) as assertion: @@ -1880,7 +1939,7 @@ class UpdateCommentTest(CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTest "voted": False, "vote_count": 0, "children": [], - "editable_fields": ["raw_body", "voted"] + "editable_fields": ["abuse_flagged", "raw_body", "voted"] } self.assertEqual(actual, expected) self.assertEqual( @@ -2066,6 +2125,41 @@ class UpdateCommentTest(CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTest expected_request_data["value"] = ["up"] self.assertEqual(actual_request_data, expected_request_data) + @ddt.data(*itertools.product([True, False], [True, False])) + @ddt.unpack + def test_abuse_flagged(self, old_flagged, new_flagged): + """ + Test attempts to edit the "abuse_flagged" field. + + old_flagged indicates whether the comment should be flagged at the start + of the test. new_flagged indicates the value for the "abuse_flagged" + field in the update. If old_flagged and new_flagged are the same, no + update should be made. Otherwise, a PUT should be made to the flag or + or unflag endpoint according to the new_flagged value. + """ + self.register_get_user_response(self.user) + self.register_comment_flag_response("test_comment") + self.register_comment({"abuse_flaggers": [str(self.user.id)] if old_flagged else []}) + data = {"abuse_flagged": new_flagged} + result = update_comment(self.request, "test_comment", data) + self.assertEqual(result["abuse_flagged"], new_flagged) + last_request_path = urlparse(httpretty.last_request().path).path + flag_url = "/api/v1/comments/test_comment/abuse_flag" + unflag_url = "/api/v1/comments/test_comment/abuse_unflag" + if old_flagged == new_flagged: + self.assertNotEqual(last_request_path, flag_url) + self.assertNotEqual(last_request_path, unflag_url) + else: + self.assertEqual( + last_request_path, + flag_url if new_flagged else unflag_url + ) + self.assertEqual(httpretty.last_request().method, "PUT") + self.assertEqual( + httpretty.last_request().parsed_body, + {"user_id": [str(self.user.id)]} + ) + @ddt.ddt class DeleteThreadTest(CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTestCase): diff --git a/lms/djangoapps/discussion_api/tests/test_permissions.py b/lms/djangoapps/discussion_api/tests/test_permissions.py index 66e8e84a8d..0de1c13d54 100644 --- a/lms/djangoapps/discussion_api/tests/test_permissions.py +++ b/lms/djangoapps/discussion_api/tests/test_permissions.py @@ -30,7 +30,7 @@ class GetEditableFieldsTest(TestCase): thread = Thread(user_id="5" if is_author else "6", type="thread") context = _get_context(requester_id="5", is_requester_privileged=is_privileged) actual = get_editable_fields(thread, context) - expected = {"following", "voted"} + expected = {"abuse_flagged", "following", "voted"} if is_author or is_privileged: expected |= {"topic_id", "type", "title", "raw_body"} self.assertEqual(actual, expected) @@ -45,7 +45,7 @@ class GetEditableFieldsTest(TestCase): thread=Thread(user_id="5" if is_thread_author else "6", thread_type=thread_type) ) actual = get_editable_fields(comment, context) - expected = {"voted"} + expected = {"abuse_flagged", "voted"} if is_author or is_privileged: expected |= {"raw_body"} if (is_thread_author and thread_type == "question") or is_privileged: diff --git a/lms/djangoapps/discussion_api/tests/test_serializers.py b/lms/djangoapps/discussion_api/tests/test_serializers.py index db4f180cb3..7992715a30 100644 --- a/lms/djangoapps/discussion_api/tests/test_serializers.py +++ b/lms/djangoapps/discussion_api/tests/test_serializers.py @@ -197,7 +197,7 @@ class ThreadSerializerSerializationTest(SerializerTestMixin, ModuleStoreTestCase "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread", "endorsed_comment_list_url": None, "non_endorsed_comment_list_url": None, - "editable_fields": ["following", "voted"], + "editable_fields": ["abuse_flagged", "following", "voted"], } self.assertEqual(self.serialize(thread), expected) @@ -305,7 +305,7 @@ class CommentSerializerTest(SerializerTestMixin, ModuleStoreTestCase): "voted": False, "vote_count": 4, "children": [], - "editable_fields": ["voted"], + "editable_fields": ["abuse_flagged", "voted"], } self.assertEqual(self.serialize(comment), expected) diff --git a/lms/djangoapps/discussion_api/tests/test_views.py b/lms/djangoapps/discussion_api/tests/test_views.py index fff1722d82..71807c9558 100644 --- a/lms/djangoapps/discussion_api/tests/test_views.py +++ b/lms/djangoapps/discussion_api/tests/test_views.py @@ -207,7 +207,7 @@ class ThreadViewSetListTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread", "endorsed_comment_list_url": None, "non_endorsed_comment_list_url": None, - "editable_fields": ["following", "voted"], + "editable_fields": ["abuse_flagged", "following", "voted"], }] self.register_get_threads_response(source_threads, page=1, num_pages=2) response = self.client.get(self.url, {"course_id": unicode(self.course.id)}) @@ -343,7 +343,7 @@ class ThreadViewSetCreateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread", "endorsed_comment_list_url": None, "non_endorsed_comment_list_url": None, - "editable_fields": ["following", "raw_body", "title", "topic_id", "type", "voted"], + "editable_fields": ["abuse_flagged", "following", "raw_body", "title", "topic_id", "type", "voted"], } response = self.client.post( self.url, @@ -434,7 +434,7 @@ class ThreadViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTest "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread", "endorsed_comment_list_url": None, "non_endorsed_comment_list_url": None, - "editable_fields": ["following", "raw_body", "title", "topic_id", "type", "voted"], + "editable_fields": ["abuse_flagged", "following", "raw_body", "title", "topic_id", "type", "voted"], } response = self.client.patch( # pylint: disable=no-member self.url, @@ -578,7 +578,7 @@ class CommentViewSetListTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): "voted": True, "vote_count": 4, "children": [], - "editable_fields": ["voted"], + "editable_fields": ["abuse_flagged", "voted"], }] self.register_get_thread_response({ "id": self.thread_id, @@ -732,7 +732,7 @@ class CommentViewSetCreateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): "voted": False, "vote_count": 0, "children": [], - "editable_fields": ["raw_body", "voted"], + "editable_fields": ["abuse_flagged", "raw_body", "voted"], } response = self.client.post( self.url, @@ -816,7 +816,7 @@ class CommentViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTes "voted": False, "vote_count": 0, "children": [], - "editable_fields": ["raw_body", "voted"], + "editable_fields": ["abuse_flagged", "raw_body", "voted"], } response = self.client.patch( # pylint: disable=no-member self.url, diff --git a/lms/djangoapps/discussion_api/tests/utils.py b/lms/djangoapps/discussion_api/tests/utils.py index aacab5b42d..61ed6974fa 100644 --- a/lms/djangoapps/discussion_api/tests/utils.py +++ b/lms/djangoapps/discussion_api/tests/utils.py @@ -243,6 +243,28 @@ class CommentsServiceMockMixin(object): status=200 ) + def register_flag_response(self, content_type, content_id): + """Register a mock response for PUT on the CS flag endpoints""" + for path in ["abuse_flag", "abuse_unflag"]: + httpretty.register_uri( + "PUT", + "http://localhost:4567/api/v1/{content_type}s/{content_id}/{path}".format( + content_type=content_type, + content_id=content_id, + path=path + ), + body=json.dumps({}), # body is unused + status=200 + ) + + def register_thread_flag_response(self, thread_id): + """Register a mock response for PUT on the CS thread flag endpoints""" + self.register_flag_response("thread", thread_id) + + def register_comment_flag_response(self, comment_id): + """Register a mock response for PUT on the CS comment flag endpoints""" + self.register_flag_response("comment", comment_id) + def register_delete_thread_response(self, thread_id): """ Register a mock response for DELETE on the CS thread instance endpoint