diff --git a/lms/djangoapps/discussion/django_comment_client/base/tests.py b/lms/djangoapps/discussion/django_comment_client/base/tests.py index 62af24f0ee37..a2f5af11f050 100644 --- a/lms/djangoapps/discussion/django_comment_client/base/tests.py +++ b/lms/djangoapps/discussion/django_comment_client/base/tests.py @@ -82,6 +82,7 @@ def _set_mock_request_data(self, mock_request, data): @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) +@patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', autospec=True) class CreateThreadGroupIdTestCase( MockRequestSetupMixin, CohortedTestCase, @@ -90,7 +91,21 @@ class CreateThreadGroupIdTestCase( ): cs_endpoint = "/threads" - def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id=True): + def setUp(self): + super().setUp() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + + def call_view(self, mock_is_forum_v2_enabled, mock_request, commentable_id, user, group_id, pass_group_id=True): + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, {}) request_data = {"body": "body", "title": "title", "thread_type": "discussion"} if pass_group_id: @@ -105,8 +120,9 @@ def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id= commentable_id=commentable_id ) - def test_group_info_in_response(self, mock_request): + def test_group_info_in_response(self, mock_is_forum_v2_enabled, mock_request): response = self.call_view( + mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.student, @@ -116,6 +132,7 @@ def test_group_info_in_response(self, mock_request): @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) +@patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', autospec=True) @disable_signal(views, 'thread_edited') @disable_signal(views, 'thread_voted') @disable_signal(views, 'thread_deleted') @@ -127,11 +144,18 @@ class ThreadActionGroupIdTestCase( def call_view( self, view_name, + mock_is_forum_v2_enabled, mock_request, user=None, post_params=None, view_args=None ): + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data( mock_request, { @@ -154,53 +178,58 @@ def call_view( **(view_args or {}) ) - def test_update(self, mock_request): + def test_update(self, mock_is_forum_v2_enabled, mock_request): response = self.call_view( "update_thread", + mock_is_forum_v2_enabled, mock_request, post_params={"body": "body", "title": "title"} ) self._assert_json_response_contains_group_info(response) - def test_delete(self, mock_request): - response = self.call_view("delete_thread", mock_request) + def test_delete(self, mock_is_forum_v2_enabled, mock_request): + response = self.call_view("delete_thread", mock_is_forum_v2_enabled, mock_request) self._assert_json_response_contains_group_info(response) - def test_vote(self, mock_request): + def test_vote(self, mock_is_forum_v2_enabled, mock_request): response = self.call_view( "vote_for_thread", + mock_is_forum_v2_enabled, mock_request, view_args={"value": "up"} ) self._assert_json_response_contains_group_info(response) - response = self.call_view("undo_vote_for_thread", mock_request) + response = self.call_view("undo_vote_for_thread", mock_is_forum_v2_enabled, mock_request) self._assert_json_response_contains_group_info(response) - def test_flag(self, mock_request): + def test_flag(self, mock_is_forum_v2_enabled, mock_request): with mock.patch('openedx.core.djangoapps.django_comment_common.signals.thread_flagged.send') as signal_mock: - response = self.call_view("flag_abuse_for_thread", mock_request) + response = self.call_view("flag_abuse_for_thread", mock_is_forum_v2_enabled, mock_request) self._assert_json_response_contains_group_info(response) self.assertEqual(signal_mock.call_count, 1) - response = self.call_view("un_flag_abuse_for_thread", mock_request) + response = self.call_view("un_flag_abuse_for_thread", mock_is_forum_v2_enabled, mock_request) self._assert_json_response_contains_group_info(response) - def test_pin(self, mock_request): + def test_pin(self, mock_is_forum_v2_enabled, mock_request): response = self.call_view( "pin_thread", + mock_is_forum_v2_enabled, mock_request, user=self.moderator ) self._assert_json_response_contains_group_info(response) response = self.call_view( "un_pin_thread", + mock_is_forum_v2_enabled, mock_request, user=self.moderator ) self._assert_json_response_contains_group_info(response) - def test_openclose(self, mock_request): + def test_openclose(self, mock_is_forum_v2_enabled, mock_request): response = self.call_view( "openclose_thread", + mock_is_forum_v2_enabled, mock_request, user=self.moderator ) @@ -280,10 +309,11 @@ def _setup_mock_request(self, mock_request, include_depth=False): data["depth"] = 0 self._set_mock_request_data(mock_request, data) - def create_thread_helper(self, mock_request, extra_request_data=None, extra_response_data=None): + def create_thread_helper(self, mock_is_forum_v2_enabled, mock_request, extra_request_data=None, extra_response_data=None): """ Issues a request to create a thread and verifies the result. """ + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "thread_type": "discussion", "title": "Hello", @@ -350,10 +380,11 @@ def create_thread_helper(self, mock_request, extra_request_data=None, extra_resp ) assert response.status_code == 200 - def update_thread_helper(self, mock_request): + def update_thread_helper(self, mock_is_forum_v2_enabled, mock_request): """ Issues a request to update a thread and verifies the result. """ + mock_is_forum_v2_enabled.return_value = False self._setup_mock_request(mock_request) # Mock out saving in order to test that content is correctly # updated. Otherwise, the call to thread.save() receives the @@ -376,6 +407,7 @@ def update_thread_helper(self, mock_request): @ddt.ddt @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) +@patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', autospec=True) @disable_signal(views, 'thread_created') @disable_signal(views, 'thread_edited') class ViewsQueryCountTestCase( @@ -393,6 +425,11 @@ class ViewsQueryCountTestCase( @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) def count_queries(func): # pylint: disable=no-self-argument """ @@ -414,22 +451,23 @@ def inner(self, default_store, block_count, mongo_calls, sql_queries, *args, **k ) @ddt.unpack @count_queries - def test_create_thread(self, mock_request): - self.create_thread_helper(mock_request) + def test_create_thread(self, mock_is_forum_v2_enabled, mock_request): + self.create_thread_helper(mock_is_forum_v2_enabled, mock_request) @ddt.data( (ModuleStoreEnum.Type.split, 3, 6, 41), ) @ddt.unpack @count_queries - def test_update_thread(self, mock_request): - self.update_thread_helper(mock_request) + def test_update_thread(self, mock_is_forum_v2_enabled, mock_request): + self.update_thread_helper(mock_is_forum_v2_enabled, mock_request) @ddt.ddt @disable_signal(views, 'comment_flagged') @disable_signal(views, 'thread_flagged') @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) +@patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', autospec=True) class ViewsTestCase( ForumsEnableMixin, UrlResetMixin, @@ -464,7 +502,16 @@ def setUp(self): # so we need to call super.setUp() which reloads urls.py (because # of the UrlResetMixin) super().setUp() - + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) # Patch the comment client user save method so it does not try # to create a new cc user when creating a django user with patch('common.djangoapps.student.models.user.cc.User.save'): @@ -497,11 +544,11 @@ def assert_discussion_signals(self, signal, user=None): with self.assert_signal_sent(views, signal, sender=None, user=user, exclude_args=('post',)): yield - def test_create_thread(self, mock_request): + def test_create_thread(self, mock_is_forum_v2_enabled, mock_request): with self.assert_discussion_signals('thread_created'): - self.create_thread_helper(mock_request) + self.create_thread_helper(mock_is_forum_v2_enabled, mock_request) - def test_create_thread_standalone(self, mock_request): + def test_create_thread_standalone(self, mock_is_forum_v2_enabled, mock_request): team = CourseTeamFactory.create( name="A Team", course_id=self.course_id, @@ -513,15 +560,15 @@ def test_create_thread_standalone(self, mock_request): team.add_user(self.student) # create_thread_helper verifies that extra data are passed through to the comments service - self.create_thread_helper(mock_request, extra_response_data={'context': ThreadContext.STANDALONE}) + self.create_thread_helper(mock_is_forum_v2_enabled, mock_request, extra_response_data={'context': ThreadContext.STANDALONE}) @ddt.data( ('follow_thread', 'thread_followed'), ('unfollow_thread', 'thread_unfollowed'), ) @ddt.unpack - def test_follow_unfollow_thread_signals(self, view_name, signal, mock_request): - self.create_thread_helper(mock_request) + def test_follow_unfollow_thread_signals(self, view_name, signal, mock_is_forum_v2_enabled, mock_request): + self.create_thread_helper(mock_is_forum_v2_enabled, mock_request) with self.assert_discussion_signals(signal): response = self.client.post( @@ -532,7 +579,8 @@ def test_follow_unfollow_thread_signals(self, view_name, signal, mock_request): ) assert response.status_code == 200 - def test_delete_thread(self, mock_request): + def test_delete_thread(self, mock_is_forum_v2_enabled, mock_request): + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "user_id": str(self.student.id), "closed": False, @@ -551,7 +599,8 @@ def test_delete_thread(self, mock_request): assert response.status_code == 200 assert mock_request.called - def test_delete_comment(self, mock_request): + def test_delete_comment(self, mock_is_forum_v2_enabled, mock_request): + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "user_id": str(self.student.id), "closed": False, @@ -573,12 +622,13 @@ def test_delete_comment(self, mock_request): assert args[0] == 'delete' assert args[1].endswith(f"/{test_comment_id}") - def _test_request_error(self, view_name, view_kwargs, data, mock_request): + def _test_request_error(self, view_name, view_kwargs, data, mock_is_forum_v2_enabled, mock_request): """ Submit a request against the given view with the given data and ensure that the result is a 400 error and that no data was posted using mock_request """ + mock_is_forum_v2_enabled.return_value = False self._setup_mock_request(mock_request, include_depth=(view_name == "create_sub_comment")) response = self.client.post(reverse(view_name, kwargs=view_kwargs), data=data) @@ -586,87 +636,97 @@ def _test_request_error(self, view_name, view_kwargs, data, mock_request): for call in mock_request.call_args_list: assert call[0][0].lower() == 'get' - def test_create_thread_no_title(self, mock_request): + def test_create_thread_no_title(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "create_thread", {"commentable_id": "dummy", "course_id": str(self.course_id)}, {"body": "foo"}, + mock_is_forum_v2_enabled, mock_request ) - def test_create_thread_empty_title(self, mock_request): + def test_create_thread_empty_title(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "create_thread", {"commentable_id": "dummy", "course_id": str(self.course_id)}, {"body": "foo", "title": " "}, + mock_is_forum_v2_enabled, mock_request ) - def test_create_thread_no_body(self, mock_request): + def test_create_thread_no_body(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "create_thread", {"commentable_id": "dummy", "course_id": str(self.course_id)}, {"title": "foo"}, + mock_is_forum_v2_enabled, mock_request ) - def test_create_thread_empty_body(self, mock_request): + def test_create_thread_empty_body(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "create_thread", {"commentable_id": "dummy", "course_id": str(self.course_id)}, {"body": " ", "title": "foo"}, + mock_is_forum_v2_enabled, mock_request ) - def test_update_thread_no_title(self, mock_request): + def test_update_thread_no_title(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "update_thread", {"thread_id": "dummy", "course_id": str(self.course_id)}, {"body": "foo"}, + mock_is_forum_v2_enabled, mock_request ) - def test_update_thread_empty_title(self, mock_request): + def test_update_thread_empty_title(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "update_thread", {"thread_id": "dummy", "course_id": str(self.course_id)}, {"body": "foo", "title": " "}, + mock_is_forum_v2_enabled, mock_request ) - def test_update_thread_no_body(self, mock_request): + def test_update_thread_no_body(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "update_thread", {"thread_id": "dummy", "course_id": str(self.course_id)}, {"title": "foo"}, + mock_is_forum_v2_enabled, mock_request ) - def test_update_thread_empty_body(self, mock_request): + def test_update_thread_empty_body(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "update_thread", {"thread_id": "dummy", "course_id": str(self.course_id)}, {"body": " ", "title": "foo"}, + mock_is_forum_v2_enabled, mock_request ) - def test_update_thread_course_topic(self, mock_request): + def test_update_thread_course_topic(self, mock_is_forum_v2_enabled, mock_request): with self.assert_discussion_signals('thread_edited'): - self.update_thread_helper(mock_request) + self.update_thread_helper(mock_is_forum_v2_enabled, mock_request) @patch( 'lms.djangoapps.discussion.django_comment_client.utils.get_discussion_categories_ids', return_value=["test_commentable"], ) - def test_update_thread_wrong_commentable_id(self, mock_get_discussion_id_map, mock_request): + def test_update_thread_wrong_commentable_id(self, mock_get_discussion_id_map, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "update_thread", {"thread_id": "dummy", "course_id": str(self.course_id)}, {"body": "foo", "title": "foo", "commentable_id": "wrong_commentable"}, + mock_is_forum_v2_enabled, mock_request ) - def test_create_comment(self, mock_request): + def test_create_comment(self, mock_is_forum_v2_enabled, mock_request): + mock_is_forum_v2_enabled.return_value = False self._setup_mock_request(mock_request) with self.assert_discussion_signals('comment_created'): response = self.client.post( @@ -678,55 +738,62 @@ def test_create_comment(self, mock_request): ) assert response.status_code == 200 - def test_create_comment_no_body(self, mock_request): + def test_create_comment_no_body(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "create_comment", {"thread_id": "dummy", "course_id": str(self.course_id)}, {}, + mock_is_forum_v2_enabled, mock_request ) - def test_create_comment_empty_body(self, mock_request): + def test_create_comment_empty_body(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "create_comment", {"thread_id": "dummy", "course_id": str(self.course_id)}, {"body": " "}, + mock_is_forum_v2_enabled, mock_request ) - def test_create_sub_comment_no_body(self, mock_request): + def test_create_sub_comment_no_body(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "create_sub_comment", {"comment_id": "dummy", "course_id": str(self.course_id)}, {}, + mock_is_forum_v2_enabled, mock_request ) - def test_create_sub_comment_empty_body(self, mock_request): + def test_create_sub_comment_empty_body(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "create_sub_comment", {"comment_id": "dummy", "course_id": str(self.course_id)}, {"body": " "}, + mock_is_forum_v2_enabled, mock_request ) - def test_update_comment_no_body(self, mock_request): + def test_update_comment_no_body(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "update_comment", {"comment_id": "dummy", "course_id": str(self.course_id)}, {}, + mock_is_forum_v2_enabled, mock_request ) - def test_update_comment_empty_body(self, mock_request): + def test_update_comment_empty_body(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "update_comment", {"comment_id": "dummy", "course_id": str(self.course_id)}, {"body": " "}, + mock_is_forum_v2_enabled, mock_request ) - def test_update_comment_basic(self, mock_request): + def test_update_comment_basic(self, mock_is_forum_v2_enabled, mock_request): + mock_is_forum_v2_enabled.return_value = False self._setup_mock_request(mock_request) comment_id = "test_comment_id" updated_body = "updated body" @@ -748,13 +815,14 @@ def test_update_comment_basic(self, mock_request): data={"body": updated_body} ) - def test_flag_thread_open(self, mock_request): - self.flag_thread(mock_request, False) + def test_flag_thread_open(self, mock_is_forum_v2_enabled, mock_request): + self.flag_thread(mock_is_forum_v2_enabled, mock_request, False) - def test_flag_thread_close(self, mock_request): - self.flag_thread(mock_request, True) + def test_flag_thread_close(self, mock_is_forum_v2_enabled, mock_request): + self.flag_thread(mock_is_forum_v2_enabled, mock_request, True) - def flag_thread(self, mock_request, is_closed): + def flag_thread(self, mock_is_forum_v2_enabled, mock_request, is_closed): + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "title": "Hello", "body": "this is a post", @@ -826,13 +894,14 @@ def flag_thread(self, mock_request, is_closed): assert response.status_code == 200 - def test_un_flag_thread_open(self, mock_request): - self.un_flag_thread(mock_request, False) + def test_un_flag_thread_open(self, mock_is_forum_v2_enabled, mock_request): + self.un_flag_thread(mock_is_forum_v2_enabled, mock_request, False) - def test_un_flag_thread_close(self, mock_request): - self.un_flag_thread(mock_request, True) + def test_un_flag_thread_close(self, mock_is_forum_v2_enabled, mock_request): + self.un_flag_thread(mock_is_forum_v2_enabled, mock_request, True) - def un_flag_thread(self, mock_request, is_closed): + def un_flag_thread(self, mock_is_forum_v2_enabled, mock_request, is_closed): + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "title": "Hello", "body": "this is a post", @@ -905,13 +974,14 @@ def un_flag_thread(self, mock_request, is_closed): assert response.status_code == 200 - def test_flag_comment_open(self, mock_request): - self.flag_comment(mock_request, False) + def test_flag_comment_open(self, mock_is_forum_v2_enabled, mock_request): + self.flag_comment(mock_is_forum_v2_enabled, mock_request, False) - def test_flag_comment_close(self, mock_request): - self.flag_comment(mock_request, True) + def test_flag_comment_close(self, mock_is_forum_v2_enabled, mock_request): + self.flag_comment(mock_is_forum_v2_enabled, mock_request, True) - def flag_comment(self, mock_request, is_closed): + def flag_comment(self, mock_is_forum_v2_enabled, mock_request, is_closed): + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "body": "this is a comment", "course_id": "MITx/999/Robot_Super_Course", @@ -976,13 +1046,14 @@ def flag_comment(self, mock_request, is_closed): assert response.status_code == 200 - def test_un_flag_comment_open(self, mock_request): - self.un_flag_comment(mock_request, False) + def test_un_flag_comment_open(self, mock_is_forum_v2_enabled, mock_request): + self.un_flag_comment(mock_is_forum_v2_enabled, mock_request, False) - def test_un_flag_comment_close(self, mock_request): - self.un_flag_comment(mock_request, True) + def test_un_flag_comment_close(self, mock_is_forum_v2_enabled, mock_request): + self.un_flag_comment(mock_is_forum_v2_enabled, mock_request, True) - def un_flag_comment(self, mock_request, is_closed): + def un_flag_comment(self, mock_is_forum_v2_enabled, mock_request, is_closed): + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "body": "this is a comment", "course_id": "MITx/999/Robot_Super_Course", @@ -1054,7 +1125,8 @@ def un_flag_comment(self, mock_request, is_closed): ('downvote_comment', 'comment_id', 'comment_voted') ) @ddt.unpack - def test_voting(self, view_name, item_id, signal, mock_request): + def test_voting(self, view_name, item_id, signal, mock_is_forum_v2_enabled, mock_request): + mock_is_forum_v2_enabled.return_value = False self._setup_mock_request(mock_request) with self.assert_discussion_signals(signal): response = self.client.post( @@ -1065,7 +1137,8 @@ def test_voting(self, view_name, item_id, signal, mock_request): ) assert response.status_code == 200 - def test_endorse_comment(self, mock_request): + def test_endorse_comment(self, mock_is_forum_v2_enabled, mock_request): + mock_is_forum_v2_enabled.return_value = False self._setup_mock_request(mock_request) self.client.login(username=self.moderator.username, password=self.password) with self.assert_discussion_signals('comment_endorsed', user=self.moderator): @@ -1079,6 +1152,7 @@ def test_endorse_comment(self, mock_request): @patch("openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request", autospec=True) +@patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', autospec=True) @disable_signal(views, 'comment_endorsed') class ViewPermissionsTestCase(ForumsEnableMixin, UrlResetMixin, SharedModuleStoreTestCase, MockRequestSetupMixin): @@ -1106,8 +1180,19 @@ def setUpTestData(cls): @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) - def test_pin_thread_as_student(self, mock_request): + def test_pin_thread_as_student(self, mock_is_forum_v2_enabled, mock_request): + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, {}) self.client.login(username=self.student.username, password=self.password) response = self.client.post( @@ -1115,7 +1200,8 @@ def test_pin_thread_as_student(self, mock_request): ) assert response.status_code == 401 - def test_pin_thread_as_moderator(self, mock_request): + def test_pin_thread_as_moderator(self, mock_is_forum_v2_enabled, mock_request): + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, {}) self.client.login(username=self.moderator.username, password=self.password) response = self.client.post( @@ -1123,7 +1209,8 @@ def test_pin_thread_as_moderator(self, mock_request): ) assert response.status_code == 200 - def test_un_pin_thread_as_student(self, mock_request): + def test_un_pin_thread_as_student(self, mock_is_forum_v2_enabled, mock_request): + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, {}) self.client.login(username=self.student.username, password=self.password) response = self.client.post( @@ -1131,7 +1218,8 @@ def test_un_pin_thread_as_student(self, mock_request): ) assert response.status_code == 401 - def test_un_pin_thread_as_moderator(self, mock_request): + def test_un_pin_thread_as_moderator(self, mock_is_forum_v2_enabled, mock_request): + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, {}) self.client.login(username=self.moderator.username, password=self.password) response = self.client.post( @@ -1139,7 +1227,7 @@ def test_un_pin_thread_as_moderator(self, mock_request): ) assert response.status_code == 200 - def _set_mock_request_thread_and_comment(self, mock_request, thread_data, comment_data): + def _set_mock_request_thread_and_comment(self, mock_is_forum_v2_enabled, mock_request, thread_data, comment_data): def handle_request(*args, **kwargs): url = args[1] if "/threads/" in url: @@ -1148,10 +1236,12 @@ def handle_request(*args, **kwargs): return self._create_response_mock(comment_data) else: raise ArgumentError("Bad url to mock request") + mock_is_forum_v2_enabled.return_value = False mock_request.side_effect = handle_request - def test_endorse_response_as_staff(self, mock_request): + def test_endorse_response_as_staff(self, mock_is_forum_v2_enabled, mock_request): self._set_mock_request_thread_and_comment( + mock_is_forum_v2_enabled, mock_request, {"type": "thread", "thread_type": "question", "user_id": str(self.student.id), "commentable_id": "course"}, {"type": "comment", "thread_id": "dummy"} @@ -1162,8 +1252,9 @@ def test_endorse_response_as_staff(self, mock_request): ) assert response.status_code == 200 - def test_endorse_response_as_student(self, mock_request): + def test_endorse_response_as_student(self, mock_is_forum_v2_enabled, mock_request): self._set_mock_request_thread_and_comment( + mock_is_forum_v2_enabled, mock_request, {"type": "thread", "thread_type": "question", "user_id": str(self.moderator.id), "commentable_id": "course"}, @@ -1175,8 +1266,9 @@ def test_endorse_response_as_student(self, mock_request): ) assert response.status_code == 401 - def test_endorse_response_as_student_question_author(self, mock_request): + def test_endorse_response_as_student_question_author(self, mock_is_forum_v2_enabled, mock_request): self._set_mock_request_thread_and_comment( + mock_is_forum_v2_enabled, mock_request, {"type": "thread", "thread_type": "question", "user_id": str(self.student.id), "commentable_id": "course"}, {"type": "comment", "thread_id": "dummy"} @@ -1209,10 +1301,12 @@ def setUpTestData(cls): CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def _test_unicode_data(self, text, mock_request,): + @patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', autospec=True) + def _test_unicode_data(self, text, mock_is_forum_v2_enabled, mock_request,): """ Test to make sure unicode data in a thread doesn't break it. """ + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, {}) request = RequestFactory().post("dummy_url", {"thread_type": "discussion", "body": text, "title": text}) request.user = self.student @@ -1235,6 +1329,13 @@ class UpdateThreadUnicodeTestCase( UnicodeTestMixin, MockRequestSetupMixin ): + def setUp(self): + super().setUp() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) @classmethod def setUpClass(cls): @@ -1255,7 +1356,9 @@ def setUpTestData(cls): return_value=["test_commentable"], ) @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def _test_unicode_data(self, text, mock_request, mock_get_discussion_id_map): + @patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', autospec=True) + def _test_unicode_data(self, text, mock_is_forum_v2_enabled, mock_request, mock_get_discussion_id_map): + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "user_id": str(self.student.id), "closed": False, @@ -1280,6 +1383,13 @@ class CreateCommentUnicodeTestCase( UnicodeTestMixin, MockRequestSetupMixin ): + def setUp(self): + super().setUp() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) @classmethod def setUpClass(cls): @@ -1296,7 +1406,9 @@ def setUpTestData(cls): CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def _test_unicode_data(self, text, mock_request): + @patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', autospec=True) + def _test_unicode_data(self, text, mock_is_forum_v2_enabled, mock_request): + mock_is_forum_v2_enabled.return_value = False commentable_id = "non_team_dummy_id" self._set_mock_request_data(mock_request, { "closed": False, @@ -1327,6 +1439,13 @@ class UpdateCommentUnicodeTestCase( UnicodeTestMixin, MockRequestSetupMixin ): + def setUp(self): + super().setUp() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) @classmethod def setUpClass(cls): @@ -1343,7 +1462,9 @@ def setUpTestData(cls): CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def _test_unicode_data(self, text, mock_request): + @patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', autospec=True) + def _test_unicode_data(self, text, mock_is_forum_v2_enabled, mock_request): + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "user_id": str(self.student.id), "closed": False, @@ -1359,6 +1480,7 @@ def _test_unicode_data(self, text, mock_request): @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) +@patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', autospec=True) class CommentActionTestCase( MockRequestSetupMixin, CohortedTestCase, @@ -1367,11 +1489,18 @@ class CommentActionTestCase( def call_view( self, view_name, + mock_is_forum_v2_enabled, mock_request, user=None, post_params=None, view_args=None ): + mock_is_forum_v2_enabled.return_value = False + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) self._set_mock_request_data( mock_request, { @@ -1394,9 +1523,9 @@ def call_view( **(view_args or {}) ) - def test_flag(self, mock_request): + def test_flag(self, mock_is_forum_v2_enabled, mock_request): with mock.patch('openedx.core.djangoapps.django_comment_common.signals.comment_flagged.send') as signal_mock: - self.call_view("flag_abuse_for_comment", mock_request) + self.call_view("flag_abuse_for_comment", mock_is_forum_v2_enabled, mock_request) self.assertEqual(signal_mock.call_count, 1) @@ -1410,6 +1539,14 @@ class CreateSubCommentUnicodeTestCase( """ Make sure comments under a response can handle unicode. """ + def setUp(self): + super().setUp() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + @classmethod def setUpClass(cls): # pylint: disable=super-method-not-called @@ -1425,10 +1562,12 @@ def setUpTestData(cls): CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def _test_unicode_data(self, text, mock_request): + @patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', autospec=True) + def _test_unicode_data(self, text, mock_is_forum_v2_enabled, mock_request): """ Create a comment with unicode in it. """ + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "closed": False, "depth": 1, @@ -1453,6 +1592,7 @@ def _test_unicode_data(self, text, mock_request): @ddt.ddt @patch("openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request", autospec=True) +@patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', autospec=True) @disable_signal(views, 'thread_voted') @disable_signal(views, 'thread_edited') @disable_signal(views, 'comment_created') @@ -1562,13 +1702,24 @@ def create_users_and_enroll(coursemode): users=[cls.group_moderator, cls.cohorted] ) - @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() - def _setup_mock(self, user, mock_request, data): + def _setup_mock(self, user, mock_is_forum_v2_enabled, mock_request, data): user = getattr(self, user) + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, data) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.client.login(username=user.username, password=self.password) @ddt.data( @@ -1593,7 +1744,7 @@ def _setup_mock(self, user, mock_request, data): ('group_moderator', 'cohorted', 'course_commentable_id', 401, CourseDiscussionSettings.NONE) ) @ddt.unpack - def test_update_thread(self, user, thread_author, commentable_id, status_code, division_scheme, mock_request): + def test_update_thread(self, user, thread_author, commentable_id, status_code, division_scheme, mock_is_forum_v2_enabled, mock_request): """ Verify that update_thread is limited to thread authors and privileged users (team membership does not matter). """ @@ -1603,7 +1754,7 @@ def test_update_thread(self, user, thread_author, commentable_id, status_code, d thread_author = getattr(self, thread_author) self._setup_mock( - user, mock_request, # user is the person making the request. + user, mock_is_forum_v2_enabled, mock_request, # user is the person making the request. { "user_id": str(thread_author.id), "closed": False, "commentable_id": commentable_id, @@ -1643,12 +1794,12 @@ def test_update_thread(self, user, thread_author, commentable_id, status_code, d ('group_moderator', 'cohorted', 'team_commentable_id', 401, CourseDiscussionSettings.NONE) ) @ddt.unpack - def test_delete_comment(self, user, comment_author, commentable_id, status_code, division_scheme, mock_request): + def test_delete_comment(self, user, comment_author, commentable_id, status_code, division_scheme, mock_is_forum_v2_enabled, mock_request): commentable_id = getattr(self, commentable_id) comment_author = getattr(self, comment_author) self.change_divided_discussion_settings(division_scheme) - self._setup_mock(user, mock_request, { + self._setup_mock(user, mock_is_forum_v2_enabled, mock_request, { "closed": False, "commentable_id": commentable_id, "user_id": str(comment_author.id), @@ -1671,12 +1822,12 @@ def test_delete_comment(self, user, comment_author, commentable_id, status_code, @ddt.data(*ddt_permissions_args) @ddt.unpack - def test_create_comment(self, user, commentable_id, status_code, mock_request): + def test_create_comment(self, user, commentable_id, status_code, mock_is_forum_v2_enabled, mock_request): """ Verify that create_comment is limited to members of the team or users with 'edit_content' permission. """ commentable_id = getattr(self, commentable_id) - self._setup_mock(user, mock_request, {"closed": False, "commentable_id": commentable_id}) + self._setup_mock(user, mock_is_forum_v2_enabled, mock_request, {"closed": False, "commentable_id": commentable_id}) response = self.client.post( reverse( @@ -1692,13 +1843,13 @@ def test_create_comment(self, user, commentable_id, status_code, mock_request): @ddt.data(*ddt_permissions_args) @ddt.unpack - def test_create_sub_comment(self, user, commentable_id, status_code, mock_request): + def test_create_sub_comment(self, user, commentable_id, status_code, mock_is_forum_v2_enabled, mock_request): """ Verify that create_subcomment is limited to members of the team or users with 'edit_content' permission. """ commentable_id = getattr(self, commentable_id) self._setup_mock( - user, mock_request, + user, mock_is_forum_v2_enabled, mock_request, {"closed": False, "commentable_id": commentable_id, "thread_id": "dummy_thread"}, ) response = self.client.post( @@ -1715,14 +1866,14 @@ def test_create_sub_comment(self, user, commentable_id, status_code, mock_reques @ddt.data(*ddt_permissions_args) @ddt.unpack - def test_comment_actions(self, user, commentable_id, status_code, mock_request): + def test_comment_actions(self, user, commentable_id, status_code, mock_is_forum_v2_enabled, mock_request): """ Verify that voting and flagging of comments is limited to members of the team or users with 'edit_content' permission. """ commentable_id = getattr(self, commentable_id) self._setup_mock( - user, mock_request, + user, mock_is_forum_v2_enabled, mock_request, { "closed": False, "commentable_id": commentable_id, @@ -1742,14 +1893,14 @@ def test_comment_actions(self, user, commentable_id, status_code, mock_request): @ddt.data(*ddt_permissions_args) @ddt.unpack - def test_threads_actions(self, user, commentable_id, status_code, mock_request): + def test_threads_actions(self, user, commentable_id, status_code, mock_is_forum_v2_enabled, mock_request): """ Verify that voting, flagging, and following of threads is limited to members of the team or users with 'edit_content' permission. """ commentable_id = getattr(self, commentable_id) self._setup_mock( - user, mock_request, + user, mock_is_forum_v2_enabled, mock_request, {"closed": False, "commentable_id": commentable_id, "body": "dummy body", "course_id": str(self.course.id)} ) for action in ["upvote_thread", "downvote_thread", "un_flag_abuse_for_thread", "flag_abuse_for_thread", @@ -1772,6 +1923,19 @@ class ForumEventTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, MockReque """ Forum actions are expected to launch analytics events. Test these here. """ + def setUp(self): + super().setUp() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + @classmethod def setUpClass(cls): # pylint: disable=super-method-not-called @@ -1791,12 +1955,14 @@ def setUpTestData(cls): @patch('eventtracking.tracker.emit') @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def test_response_event(self, mock_request, mock_emit): + @patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', autospec=True) + def test_response_event(self, mock_is_forum_v2_enabled, mock_request, mock_emit): """ Check to make sure an event is fired when a user responds to a thread. """ event_receiver = Mock() FORUM_THREAD_RESPONSE_CREATED.connect(event_receiver) + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "closed": False, "commentable_id": 'test_commentable_id', @@ -1833,12 +1999,14 @@ def test_response_event(self, mock_request, mock_emit): @patch('eventtracking.tracker.emit') @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def test_comment_event(self, mock_request, mock_emit): + @patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', autospec=True) + def test_comment_event(self, mock_is_forum_v2_enabled, mock_request, mock_emit): """ Ensure an event is fired when someone comments on a response. """ event_receiver = Mock() FORUM_RESPONSE_COMMENT_CREATED.connect(event_receiver) + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "closed": False, "depth": 1, @@ -1875,6 +2043,7 @@ def test_comment_event(self, mock_request, mock_emit): @patch('eventtracking.tracker.emit') @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) + @patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', autospec=True) @ddt.data(( 'create_thread', 'edx.forum.thread.created', { @@ -1896,7 +2065,7 @@ def test_comment_event(self, mock_request, mock_emit): {'comment_id': 'dummy_comment_id'} )) @ddt.unpack - def test_team_events(self, view_name, event_name, view_data, view_kwargs, mock_request, mock_emit): + def test_team_events(self, view_name, event_name, view_data, view_kwargs, mock_is_forum_v2_enabled, mock_request, mock_emit): user = self.student team = CourseTeamFactory.create(discussion_topic_id=TEAM_COMMENTABLE_ID) CourseTeamMembershipFactory.create(team=team, user=user) @@ -1905,6 +2074,7 @@ def test_team_events(self, view_name, event_name, view_data, view_kwargs, mock_r forum_event = views.TRACKING_LOG_TO_EVENT_MAPS.get(event_name) forum_event.connect(event_receiver) + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { 'closed': False, 'commentable_id': TEAM_COMMENTABLE_ID, @@ -1943,9 +2113,11 @@ def test_team_events(self, view_name, event_name, view_data, view_kwargs, mock_r @ddt.unpack @patch('eventtracking.tracker.emit') @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def test_thread_voted_event(self, view_name, obj_id_name, obj_type, mock_request, mock_emit): + @patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', autospec=True) + def test_thread_voted_event(self, view_name, obj_id_name, obj_type, mock_is_forum_v2_enabled, mock_request, mock_emit): undo = view_name.startswith('undo') + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { 'closed': False, 'commentable_id': 'test_commentable_id', @@ -1971,11 +2143,13 @@ def test_thread_voted_event(self, view_name, obj_id_name, obj_type, mock_request @ddt.data('follow_thread', 'unfollow_thread',) @patch('eventtracking.tracker.emit') @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def test_thread_followed_event(self, view_name, mock_request, mock_emit): + @patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', autospec=True) + def test_thread_followed_event(self, view_name, mock_is_forum_v2_enabled, mock_request, mock_emit): event_receiver = Mock() for signal in views.TRACKING_LOG_TO_EVENT_MAPS.values(): signal.connect(event_receiver) + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { 'closed': False, 'commentable_id': 'test_commentable_id', @@ -2025,10 +2199,11 @@ def setUpTestData(cls): cls.other_user = UserFactory.create(username="other") CourseEnrollmentFactory(user=cls.other_user, course_id=cls.course.id) - def set_post_counts(self, mock_request, threads_count=1, comments_count=1): + def set_post_counts(self, mock_is_forum_v2_enabled, mock_request, threads_count=1, comments_count=1): """ sets up a mock response from the comments service for getting post counts for our other_user """ + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "threads_count": threads_count, "comments_count": comments_count, @@ -2042,15 +2217,17 @@ def make_request(self, method='get', course_id=None, **kwargs): return views.users(request, course_id=str(course_id)) @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def test_finds_exact_match(self, mock_request): - self.set_post_counts(mock_request) + @patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', autospec=True) + def test_finds_exact_match(self, mock_is_forum_v2_enabled, mock_request): + self.set_post_counts(mock_is_forum_v2_enabled, mock_request) response = self.make_request(username="other") assert response.status_code == 200 assert json.loads(response.content.decode('utf-8'))['users'] == [{'id': self.other_user.id, 'username': self.other_user.username}] @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def test_finds_no_match(self, mock_request): - self.set_post_counts(mock_request) + @patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', autospec=True) + def test_finds_no_match(self, mock_is_forum_v2_enabled, mock_request): + self.set_post_counts(mock_is_forum_v2_enabled, mock_request) response = self.make_request(username="othor") assert response.status_code == 200 assert json.loads(response.content.decode('utf-8'))['users'] == [] @@ -2086,8 +2263,9 @@ def test_requires_requestor_enrolled_in_course(self): assert 'users' not in content @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def test_requires_matched_user_has_forum_content(self, mock_request): - self.set_post_counts(mock_request, 0, 0) + @patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', autospec=True) + def test_requires_matched_user_has_forum_content(self, mock_is_forum_v2_enabled, mock_request): + self.set_post_counts(mock_is_forum_v2_enabled, mock_request, 0, 0) response = self.make_request(username="other") assert response.status_code == 200 assert json.loads(response.content.decode('utf-8'))['users'] == [] diff --git a/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py b/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py new file mode 100644 index 000000000000..e3d05ac8deac --- /dev/null +++ b/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py @@ -0,0 +1,859 @@ +import pytest +# pylint: skip-file +"""Tests for django comment client views.""" + + +import json +import logging +from contextlib import contextmanager +from unittest import mock +from unittest.mock import ANY, Mock, patch + +import ddt +from django.contrib.auth.models import User +from django.core.management import call_command +from django.test.client import RequestFactory +from django.urls import reverse +from eventtracking.processors.exceptions import EventEmissionExit +from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.locator import CourseLocator +from openedx_events.learning.signals import FORUM_THREAD_CREATED, FORUM_THREAD_RESPONSE_CREATED, FORUM_RESPONSE_COMMENT_CREATED + +from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.course_modes.tests.factories import CourseModeFactory +from common.djangoapps.student.roles import CourseStaffRole, UserBasedRole +from common.djangoapps.student.tests.factories import CourseAccessRoleFactory, CourseEnrollmentFactory, UserFactory +from common.djangoapps.track.middleware import TrackMiddleware +from common.djangoapps.track.views import segmentio +from common.djangoapps.track.views.tests.base import SEGMENTIO_TEST_USER_ID, SegmentIOTrackingTestCaseBase +from common.djangoapps.util.testing import UrlResetMixin +from common.test.utils import MockSignalHandlerMixin, disable_signal +from lms.djangoapps.discussion.django_comment_client.base import views +from lms.djangoapps.discussion.django_comment_client.tests.group_id_v2 import ( + CohortedTopicGroupIdTestMixin, + GroupIdAssertionMixin, + NonCohortedTopicGroupIdTestMixin +) +from lms.djangoapps.discussion.django_comment_client.tests.unicode import UnicodeTestMixin +from lms.djangoapps.discussion.django_comment_client.tests.utils import CohortedTestCase, ForumsEnableMixin +from lms.djangoapps.teams.tests.factories import CourseTeamFactory, CourseTeamMembershipFactory +from openedx.core.djangoapps.course_groups.cohorts import set_course_cohorted +from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory +from openedx.core.djangoapps.django_comment_common.comment_client import Thread +from openedx.core.djangoapps.django_comment_common.models import ( + FORUM_ROLE_STUDENT, + CourseDiscussionSettings, + Role, + assign_role +) +from openedx.core.djangoapps.django_comment_common.utils import ( + ThreadContext, + seed_permissions_roles, +) +from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES +from openedx.core.lib.teams_config import TeamsConfig +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import ( + TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase, SharedModuleStoreTestCase, +) +from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory, check_mongo_calls + +from .event_transformers import ForumThreadViewedEventTransformer + +log = logging.getLogger(__name__) + +QUERY_COUNT_TABLE_IGNORELIST = WAFFLE_TABLES + +CS_PREFIX = "http://localhost:4567/api/v1" + +# pylint: disable=missing-docstring + + +@patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=True) +@patch('openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.create_thread', autospec=True) +class CreateThreadGroupIdTestCase( + CohortedTestCase, + CohortedTopicGroupIdTestMixin, + NonCohortedTopicGroupIdTestMixin +): + cs_endpoint = "/threads" + + def call_view(self, mock_create_thread, mock_is_forum_v2_enabled, commentable_id, user, group_id, pass_group_id=True): + mock_create_thread.return_value = {} + request_data = {"body": "body", "title": "title", "thread_type": "discussion"} + if pass_group_id: + request_data["group_id"] = group_id + request = RequestFactory().post("dummy_url", request_data) + request.user = user + request.view_name = "create_thread" + + return views.create_thread( + request, + course_id=str(self.course.id), + commentable_id=commentable_id + ) + + def test_group_info_in_response(self, mock_is_forum_v2_enabled, mock_request): + response = self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "cohorted_topic", + self.student, + '' + ) + self._assert_json_response_contains_group_info(response) + +@patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=True) +@disable_signal(views, 'thread_edited') +@disable_signal(views, 'thread_voted') +@disable_signal(views, 'thread_deleted') +class ThreadActionGroupIdTestCase( + CohortedTestCase, + GroupIdAssertionMixin +): + + def _get_mocked_instance_from_view_name(self, view_name): + """ + Get the relavent Mock function based on the view_name + """ + mocks = { + "create_thread": self.mock_create_thread, + "get_thread": self.mock_get_thread, + "update_thread": self.mock_update_thread, + "delete_thread": self.mock_delete_thread, + "vote_for_thread": self.mock_update_thread_votes, + } + return mocks.get(view_name) + + def setUp(self): + super().setUp() + # Mocking create_thread and get_thread methods + self.mock_create_thread = mock.patch('openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.create_thread', autospec=True).start() + self.mock_get_thread = mock.patch('openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_thread', autospec=True).start() + self.mock_update_thread = mock.patch('openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.update_thread', autospec=True).start() + self.mock_delete_thread = mock.patch('openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.delete_thread', autospec=True).start() + self.mock_update_thread_votes = mock.patch('openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.update_thread_votes', autospec=True).start() + self.mock_delete_thread_vote = mock.patch('openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.delete_thread_vote', autospec=True).start() + self.mock_update_thread_flag = mock.patch('openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.update_thread_flag', autospec=True).start() + self.mock_pin_thread = mock.patch('openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.pin_thread', autospec=True).start() + self.mock_unpin_thread = mock.patch('openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.unpin_thread', autospec=True).start() + + + + default_response = { + "user_id": str(self.student.id), + "group_id": self.student_cohort.id, + "closed": False, + "type": "thread", + "commentable_id": "non_team_dummy_id", + "body": "test body", + } + self.mock_create_thread.return_value = default_response + self.mock_get_thread.return_value = default_response + self.mock_update_thread.return_value = default_response + self.mock_delete_thread.return_value = default_response + self.mock_update_thread_votes.return_value = default_response + self.mock_delete_thread_vote.return_value = default_response + self.mock_update_thread_flag.return_value = default_response + self.mock_pin_thread.return_value = default_response + self.mock_unpin_thread.return_value = default_response + + self.get_course_id_by_thread = mock.patch('openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread', autospec=True).start() + self.get_course_id_by_thread.return_value = CourseLocator('dummy', 'test_123', 'test_run') + + self.addCleanup(mock.patch.stopall) # Ensure all mocks are stopped after tests + + + def call_view( + self, + view_name, + mock_is_forum_v2_enabled, + user=None, + post_params=None, + view_args=None + ): + mocked_view = self._get_mocked_instance_from_view_name(view_name) + if mocked_view: + mocked_view.return_value = { + "user_id": str(self.student.id), + "group_id": self.student_cohort.id, + "closed": False, + "type": "thread", + "commentable_id": "non_team_dummy_id", + "body": "test body", + } + request = RequestFactory().post("dummy_url", post_params or {}) + request.user = user or self.student + request.view_name = view_name + + return getattr(views, view_name)( + request, + course_id=str(self.course.id), + thread_id="dummy", + **(view_args or {}) + ) + + def test_update(self, mock_is_forum_v2_enabled): + response = self.call_view( + "update_thread", + mock_is_forum_v2_enabled, + post_params={"body": "body", "title": "title"} + ) + self._assert_json_response_contains_group_info(response) + + def test_delete(self, mock_is_forum_v2_enabled): + response = self.call_view("delete_thread", mock_is_forum_v2_enabled) + self._assert_json_response_contains_group_info(response) + + def test_vote(self, mock_is_forum_v2_enabled): + response = self.call_view( + "vote_for_thread", + mock_is_forum_v2_enabled, + view_args={"value": "up"} + ) + self._assert_json_response_contains_group_info(response) + response = self.call_view("undo_vote_for_thread", mock_is_forum_v2_enabled) + self._assert_json_response_contains_group_info(response) + + def test_flag(self, mock_is_forum_v2_enabled): + with mock.patch('openedx.core.djangoapps.django_comment_common.signals.thread_flagged.send') as signal_mock: + response = self.call_view("flag_abuse_for_thread", mock_is_forum_v2_enabled) + self._assert_json_response_contains_group_info(response) + self.assertEqual(signal_mock.call_count, 1) + response = self.call_view("un_flag_abuse_for_thread", mock_is_forum_v2_enabled) + self._assert_json_response_contains_group_info(response) + + def test_pin(self, mock_is_forum_v2_enabled): + response = self.call_view( + "pin_thread", + mock_is_forum_v2_enabled, + user=self.moderator + ) + self._assert_json_response_contains_group_info(response) + response = self.call_view( + "un_pin_thread", + mock_is_forum_v2_enabled, + user=self.moderator + ) + self._assert_json_response_contains_group_info(response) + + def test_openclose(self, mock_is_forum_v2_enabled): + response = self.call_view( + "openclose_thread", + mock_is_forum_v2_enabled, + user=self.moderator + ) + self._assert_json_response_contains_group_info( + response, + lambda d: d['content'] + ) + +class ViewsTestCaseMixin: + + def set_up_course(self, block_count=0): + """ + Creates a course, optionally with block_count discussion blocks, and + a user with appropriate permissions. + """ + + # create a course + self.course = CourseFactory.create( + org='MITx', course='999', + discussion_topics={"Some Topic": {"id": "some_topic"}}, + display_name='Robot Super Course', + ) + self.course_id = self.course.id + + # add some discussion blocks + for i in range(block_count): + BlockFactory.create( + parent_location=self.course.location, + category='discussion', + discussion_id=f'id_module_{i}', + discussion_category=f'Category {i}', + discussion_target=f'Discussion {i}' + ) + + # seed the forums permissions and roles + call_command('seed_permissions_roles', str(self.course_id)) + + # Patch the comment client user save method so it does not try + # to create a new cc user when creating a django user + with patch('common.djangoapps.student.models.user.cc.User.save'): + uname = 'student' + email = 'student@edx.org' + self.password = 'Password1234' + + # Create the user and make them active so we can log them in. + self.student = UserFactory.create(username=uname, email=email, password=self.password) + self.student.is_active = True + self.student.save() + + # Add a discussion moderator + self.moderator = UserFactory.create(password=self.password) + + # Enroll the student in the course + CourseEnrollmentFactory(user=self.student, + course_id=self.course_id) + + # Enroll the moderator and give them the appropriate roles + CourseEnrollmentFactory(user=self.moderator, course_id=self.course.id) + self.moderator.roles.add(Role.objects.get(name="Moderator", course_id=self.course.id)) + + assert self.client.login(username='student', password=self.password) + + + def _get_mocked_dict(self): + return { + "create_thread": self.mock_create_thread, + "get_thread": self.mock_get_thread, + "update_thread": self.mock_update_thread + } + + def _get_mocked_instance_from_view_name(self, view_name): + """ + Get the relavent Mock function based on the view_name + """ + return self._get_mocked_dict().get(view_name) + + + def _setup_mock_data(self, view_name="get_thread", include_depth=False): + """ + Ensure that mock_request returns the data necessary to make views + function correctly + """ + data = { + "user_id": str(self.student.id), + "closed": False, + "commentable_id": "non_team_dummy_id", + "thread_id": "dummy", + "thread_type": "discussion" + } + if include_depth: + data["depth"] = 0 + self._get_mocked_instance_from_view_name(view_name).return_value = data + + def create_thread_helper(self, mock_is_forum_v2_enabled, extra_request_data=None, extra_response_data=None): + """ + Issues a request to create a thread and verifies the result. + """ + self.mock_create_thread.return_value = { + "thread_type": "discussion", + "title": "Hello", + "body": "this is a post", + "course_id": "MITx/999/Robot_Super_Course", + "anonymous": False, + "anonymous_to_peers": False, + "commentable_id": "i4x-MITx-999-course-Robot_Super_Course", + "created_at": "2013-05-10T18:53:43Z", + "updated_at": "2013-05-10T18:53:43Z", + "at_position_list": [], + "closed": False, + "id": "518d4237b023791dca00000d", + "user_id": "1", + "username": "robot", + "votes": { + "count": 0, + "up_count": 0, + "down_count": 0, + "point": 0 + }, + "abuse_flaggers": [], + "type": "thread", + "group_id": None, + "pinned": False, + "endorsed": False, + "unread_comments_count": 0, + "read": False, + "comments_count": 0, + } + thread = { + "thread_type": "discussion", + "body": ["this is a post"], + "anonymous_to_peers": ["false"], + "auto_subscribe": ["false"], + "anonymous": ["false"], + "title": ["Hello"], + } + if extra_request_data: + thread.update(extra_request_data) + url = reverse('create_thread', kwargs={'commentable_id': 'i4x-MITx-999-course-Robot_Super_Course', + 'course_id': str(self.course_id)}) + response = self.client.post(url, data=thread) + assert self.mock_create_thread.called + expected_data = { + 'thread_type': 'discussion', + 'body': 'this is a post', + 'context': ThreadContext.COURSE, + 'anonymous_to_peers': False, + 'user_id': '1', + 'title': 'Hello', + 'commentable_id': 'i4x-MITx-999-course-Robot_Super_Course', + 'anonymous': False, + 'course_id': str(self.course_id), + } + if extra_response_data: + expected_data.update(extra_response_data) + + self.mock_create_thread.assert_called_with(**expected_data) + assert response.status_code == 200 + + + def update_thread_helper(self, mock_is_forum_v2_enabled): + """ + Issues a request to update a thread and verifies the result. + """ + self._setup_mock_data("get_thread") + self._setup_mock_data("update_thread") + # Mock out saving in order to test that content is correctly + # updated. Otherwise, the call to thread.save() receives the + # same mocked request data that the original call to retrieve + # the thread did, overwriting any changes. + with patch.object(Thread, 'save'): + response = self.client.post( + reverse("update_thread", kwargs={ + "thread_id": "dummy", + "course_id": str(self.course_id) + }), + data={"body": "foo", "title": "foo", "commentable_id": "some_topic"} + ) + assert response.status_code == 200 + data = json.loads(response.content.decode('utf-8')) + assert data['body'] == 'foo' + assert data['title'] == 'foo' + assert data['commentable_id'] == 'some_topic' + + +@ddt.ddt +@patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=True) +@disable_signal(views, 'thread_created') +@disable_signal(views, 'thread_edited') +class ViewsQueryCountTestCase( + ForumsEnableMixin, + UrlResetMixin, + ModuleStoreTestCase, + ViewsTestCaseMixin +): + + CREATE_USER = False + ENABLED_CACHES = ['default', 'mongo_metadata_inheritance', 'loc_cache'] + ENABLED_SIGNALS = ['course_published'] + + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + super().setUp() + self.mock_create_thread = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.create_thread', autospec=True + ).start() + self.mock_update_thread = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.update_thread', autospec=True + ).start() + self.mock_get_thread = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_thread', autospec=True + ).start() + + self.get_course_id_by_thread = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread', autospec=True + ).start() + self.get_course_id_by_thread.return_value = CourseLocator('MITx', '999', 'Robot_Super_Course') + + self.addCleanup(mock.patch.stopall) + + def count_queries(func): # pylint: disable=no-self-argument + """ + Decorates test methods to count mongo and SQL calls for a + particular modulestore. + """ + + def inner(self, default_store, block_count, mongo_calls, sql_queries, *args, **kwargs): + with modulestore().default_store(default_store): + self.set_up_course(block_count=block_count) + self.clear_caches() + with self.assertNumQueries(sql_queries, table_ignorelist=QUERY_COUNT_TABLE_IGNORELIST): + with check_mongo_calls(mongo_calls): + func(self, *args, **kwargs) + return inner + + @ddt.data( + (ModuleStoreEnum.Type.split, 3, 8, 41), + ) + @ddt.unpack + @count_queries + def test_create_thread(self, mock_is_forum_v2_enabled): + self.create_thread_helper(mock_is_forum_v2_enabled) + + @ddt.data( + (ModuleStoreEnum.Type.split, 3, 6, 40), + ) + @ddt.unpack + @count_queries + def test_update_thread(self, mock_is_forum_v2_enabled): + self.update_thread_helper(mock_is_forum_v2_enabled) + + +@ddt.ddt +@disable_signal(views, 'comment_flagged') +@disable_signal(views, 'thread_flagged') +@patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', autospec=True) +class ViewsTestCase( + ForumsEnableMixin, + UrlResetMixin, + SharedModuleStoreTestCase, + ViewsTestCaseMixin, + MockSignalHandlerMixin +): + + def _get_mocked_dict(self): + mocked_dict = super()._get_mocked_dict() + mocked_dict['create_comment'] = self.mock_create_parent_comment + return mocked_dict + + @classmethod + def setUpClass(cls): + # pylint: disable=super-method-not-called + with super().setUpClassAndTestData(): + cls.course = CourseFactory.create( + org='MITx', course='999', + discussion_topics={"Some Topic": {"id": "some_topic"}}, + display_name='Robot Super Course', + ) + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + cls.course_id = cls.course.id + + # seed the forums permissions and roles + call_command('seed_permissions_roles', str(cls.course_id)) + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + # Patching the ENABLE_DISCUSSION_SERVICE value affects the contents of urls.py, + # so we need to call super.setUp() which reloads urls.py (because + # of the UrlResetMixin) + super().setUp() + + # Patch the comment client user save method so it does not try + # to create a new cc user when creating a django user + with patch('common.djangoapps.student.models.user.cc.User.save'): + uname = 'student' + email = 'student@edx.org' + self.password = 'Password1234' + + # Create the user and make them active so we can log them in. + self.student = UserFactory.create(username=uname, email=email, password=self.password) + self.student.is_active = True + self.student.save() + + # Add a discussion moderator + self.moderator = UserFactory.create(password=self.password) + + # Enroll the student in the course + CourseEnrollmentFactory(user=self.student, + course_id=self.course_id) + + # Enroll the moderator and give them the appropriate roles + CourseEnrollmentFactory(user=self.moderator, course_id=self.course.id) + self.moderator.roles.add(Role.objects.get(name="Moderator", course_id=self.course.id)) + + assert self.client.login(username='student', password=self.password) + + + self.mock_create_thread = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.create_thread', autospec=True + ).start() + self.mock_update_thread = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.update_thread', autospec=True + ).start() + self.mock_get_thread = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_thread', autospec=True + ).start() + self.mock_create_subscription = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.create_subscription', autospec=True + ).start() + self.mock_delete_subscription = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.delete_subscription', autospec=True + ).start() + self.mock_delete_thread = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.delete_thread', autospec=True + ).start() + self.mock_delete_comment = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.delete_comment', autospec=True + ).start() + self.mock_get_parent_comment = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_parent_comment', autospec=True + ).start() + self.mock_create_parent_comment = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.create_parent_comment', autospec=True + ).start() + self.mock_update_comment = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.update_comment', autospec=True + ).start() + + default_response = { + "user_id": str(self.student.id), + "closed": False, + "type": "thread", + "commentable_id": "non_team_dummy_id", + "body": "test body", + } + self.mock_create_thread.return_value = default_response + self.mock_get_thread.return_value = default_response + self.mock_update_thread.return_value = default_response + self.mock_delete_thread.return_value = default_response + self.mock_delete_subscription.return_value = default_response + self.mock_get_parent_comment.return_value = default_response + + self.get_course_id_by_thread = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread', autospec=True + ).start() + self.get_course_id_by_thread.return_value = CourseLocator('MITx', '999', 'Robot_Super_Course') + + self.get_course_id_by_comment = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_comment', autospec=True + ).start() + self.get_course_id_by_comment.return_value = CourseLocator('MITx', '999', 'Robot_Super_Course') + # forum_api.create_subscription + + self.addCleanup(mock.patch.stopall) + + + @contextmanager + def assert_discussion_signals(self, signal, user=None): + if user is None: + user = self.student + with self.assert_signal_sent(views, signal, sender=None, user=user, exclude_args=('post',)): + yield + + def test_create_thread(self, mock_is_forum_v2_enabled,): + with self.assert_discussion_signals('thread_created'): + self.create_thread_helper(mock_is_forum_v2_enabled) + + def test_create_thread_standalone(self, mock_is_forum_v2_enabled): + team = CourseTeamFactory.create( + name="A Team", + course_id=self.course_id, + topic_id='topic_id', + discussion_topic_id="i4x-MITx-999-course-Robot_Super_Course" + ) + + # Add the student to the team so they can post to the commentable. + team.add_user(self.student) + + # create_thread_helper verifies that extra data are passed through to the comments service + self.create_thread_helper(mock_is_forum_v2_enabled, extra_response_data={'context': ThreadContext.STANDALONE}) + + + @ddt.data( + ('follow_thread', 'thread_followed'), + ('unfollow_thread', 'thread_unfollowed'), + ) + @ddt.unpack + def test_follow_unfollow_thread_signals(self, view_name, signal, mock_is_forum_v2_enabled): + self.create_thread_helper(mock_is_forum_v2_enabled) + with self.assert_discussion_signals(signal): + response = self.client.post( + reverse( + view_name, + kwargs={"course_id": str(self.course_id), "thread_id": 'i4x-MITx-999-course-Robot_Super_Course'} + ), + data = {} + ) + assert response.status_code == 200 + + def test_delete_thread(self, mock_is_forum_v2_enabled): + self.mock_delete_thread.return_value = { + "user_id": str(self.student.id), + "closed": False, + "body": "test body", + } + test_thread_id = "test_thread_id" + request = RequestFactory().post("dummy_url", {"id": test_thread_id}) + request.user = self.student + request.view_name = "delete_thread" + with self.assert_discussion_signals('thread_deleted'): + response = views.delete_thread( + request, + course_id=str(self.course.id), + thread_id=test_thread_id + ) + assert response.status_code == 200 + assert self.mock_delete_thread.called + + + def test_delete_comment(self, mock_is_forum_v2_enabled): + self.mock_delete_comment.return_value = { + "user_id": str(self.student.id), + "closed": False, + "body": "test body", + } + test_comment_id = "test_comment_id" + request = RequestFactory().post("dummy_url", {"id": test_comment_id}) + request.user = self.student + request.view_name = "delete_comment" + with self.assert_discussion_signals('comment_deleted'): + response = views.delete_comment( + request, + course_id=str(self.course.id), + comment_id=test_comment_id + ) + assert response.status_code == 200 + assert self.mock_delete_comment.called + + def _test_request_error(self, view_name, view_kwargs, data): + """ + Submit a request against the given view with the given data and ensure + that the result is a 400 error and that no data was posted using + mock_request + """ + mocked_view = self._get_mocked_instance_from_view_name(view_name) + if mocked_view: + mocked_view.return_value = {} + + response = self.client.post(reverse(view_name, kwargs=view_kwargs), data=data) + assert response.status_code == 400 + + def test_create_thread_no_title(self, mock_is_forum_v2_enabled): + self._test_request_error( + "create_thread", + {"commentable_id": "dummy", "course_id": str(self.course_id)}, + {"body": "foo"}, + ) + + + def test_create_thread_empty_title(self, mock_is_forum_v2_enabled): + self._test_request_error( + "create_thread", + {"commentable_id": "dummy", "course_id": str(self.course_id)}, + {"body": "foo", "title": " "}, + ) + + def test_create_thread_no_body(self, mock_is_forum_v2_enabled): + self._test_request_error( + "create_thread", + {"commentable_id": "dummy", "course_id": str(self.course_id)}, + {"title": "foo"}, + ) + + def test_create_thread_empty_body(self, mock_is_forum_v2_enabled): + self._test_request_error( + "create_thread", + {"commentable_id": "dummy", "course_id": str(self.course_id)}, + {"body": " ", "title": "foo"} + ) + + def test_update_thread_no_title(self, mock_is_forum_v2_enabled): + self._test_request_error( + "update_thread", + {"thread_id": "dummy", "course_id": str(self.course_id)}, + {"body": "foo"} + ) + + def test_update_thread_empty_title(self, mock_is_forum_v2_enabled): + self._test_request_error( + "update_thread", + {"thread_id": "dummy", "course_id": str(self.course_id)}, + {"body": "foo", "title": " "} + ) + + def test_update_thread_no_body(self, mock_is_forum_v2_enabled): + self._test_request_error( + "update_thread", + {"thread_id": "dummy", "course_id": str(self.course_id)}, + {"title": "foo"} + ) + + def test_update_thread_empty_body(self, mock_is_forum_v2_enabled): + self._test_request_error( + "update_thread", + {"thread_id": "dummy", "course_id": str(self.course_id)}, + {"body": " ", "title": "foo"} + ) + + def test_update_thread_course_topic(self, mock_is_forum_v2_enabled): + with self.assert_discussion_signals('thread_edited'): + self.update_thread_helper(mock_is_forum_v2_enabled) + + @patch( + 'lms.djangoapps.discussion.django_comment_client.utils.get_discussion_categories_ids', + return_value=["test_commentable"], + ) + def test_update_thread_wrong_commentable_id(self, mock_get_discussion_id_map, mock_is_forum_v2_enabled): + self._test_request_error( + "update_thread", + {"thread_id": "dummy", "course_id": str(self.course_id)}, + {"body": "foo", "title": "foo", "commentable_id": "wrong_commentable"}, + ) + + def test_create_comment(self, mock_is_forum_v2_enabled): + self.mock_create_parent_comment = {} + + with self.assert_discussion_signals('comment_created'): + response = self.client.post( + reverse( + "create_comment", + kwargs={"course_id": str(self.course_id), "thread_id": "dummy"} + ), + data={"body": "body"} + ) + assert response.status_code == 200 + + def test_create_comment_no_body(self, mock_is_forum_v2_enabled): + self._test_request_error( + "create_comment", + {"thread_id": "dummy", "course_id": str(self.course_id)}, + {}, + ) + + def test_create_comment_empty_body(self, mock_is_forum_v2_enabled): + self._test_request_error( + "create_comment", + {"thread_id": "dummy", "course_id": str(self.course_id)}, + {"body": " "}, + ) + + def test_create_sub_comment_no_body(self, mock_is_forum_v2_enabled): + self._test_request_error( + "create_sub_comment", + {"comment_id": "dummy", "course_id": str(self.course_id)}, + {}, + ) + + def test_create_sub_comment_empty_body(self, mock_is_forum_v2_enabled): + self._test_request_error( + "create_sub_comment", + {"comment_id": "dummy", "course_id": str(self.course_id)}, + {"body": " "} + ) + + def test_update_comment_no_body(self, mock_is_forum_v2_enabled): + self._test_request_error( + "update_comment", + {"comment_id": "dummy", "course_id": str(self.course_id)}, + {} + ) + + def test_update_comment_empty_body(self, mock_is_forum_v2_enabled): + self._test_request_error( + "update_comment", + {"comment_id": "dummy", "course_id": str(self.course_id)}, + {"body": " "} + ) + + def test_update_comment_basic(self, mock_is_forum_v2_enabled): + self.mock_update_comment.return_value = {} + comment_id = "test_comment_id" + updated_body = "updated body" + with self.assert_discussion_signals('comment_edited'): + response = self.client.post( + reverse( + "update_comment", + kwargs={"course_id": str(self.course_id), "comment_id": comment_id} + ), + data={"body": updated_body} + ) + assert response.status_code == 200 + assert self.mock_update_comment.call_args[1].get('body') == updated_body diff --git a/lms/djangoapps/discussion/django_comment_client/base/views.py b/lms/djangoapps/discussion/django_comment_client/base/views.py index e3e52a5400a4..3df362bdf6d2 100644 --- a/lms/djangoapps/discussion/django_comment_client/base/views.py +++ b/lms/djangoapps/discussion/django_comment_client/base/views.py @@ -562,7 +562,6 @@ def create_thread(request, course_id, commentable_id): params['context'] = ThreadContext.STANDALONE else: params['context'] = ThreadContext.COURSE - thread = cc.Thread(**params) # Divide the thread if required diff --git a/lms/djangoapps/discussion/django_comment_client/tests/group_id.py b/lms/djangoapps/discussion/django_comment_client/tests/group_id.py index 78853293ec46..0a5fbe491930 100644 --- a/lms/djangoapps/discussion/django_comment_client/tests/group_id.py +++ b/lms/djangoapps/discussion/django_comment_client/tests/group_id.py @@ -60,51 +60,76 @@ class CohortedTopicGroupIdTestMixin(GroupIdAssertionMixin): Provides test cases to verify that views pass the correct `group_id` to the comments service when requesting content in cohorted discussions. """ - def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id=True): + def call_view(self, mock_is_forum_v2_enabled, mock_request, commentable_id, user, group_id, pass_group_id=True): """ Call the view for the implementing test class, constructing a request from the parameters. """ pass # lint-amnesty, pylint: disable=unnecessary-pass - def test_cohorted_topic_student_without_group_id(self, mock_request): - self.call_view(mock_request, "cohorted_topic", self.student, '', pass_group_id=False) + def test_cohorted_topic_student_without_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view(mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.student, '', pass_group_id=False) self._assert_comments_service_called_with_group_id(mock_request, self.student_cohort.id) - def test_cohorted_topic_student_none_group_id(self, mock_request): - self.call_view(mock_request, "cohorted_topic", self.student, "") + def test_cohorted_topic_student_none_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view(mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.student, "") self._assert_comments_service_called_with_group_id(mock_request, self.student_cohort.id) - def test_cohorted_topic_student_with_own_group_id(self, mock_request): - self.call_view(mock_request, "cohorted_topic", self.student, self.student_cohort.id) + def test_cohorted_topic_student_with_own_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view(mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.student, self.student_cohort.id) self._assert_comments_service_called_with_group_id(mock_request, self.student_cohort.id) - def test_cohorted_topic_student_with_other_group_id(self, mock_request): - self.call_view(mock_request, "cohorted_topic", self.student, self.moderator_cohort.id) + def test_cohorted_topic_student_with_other_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "cohorted_topic", + self.student, + self.moderator_cohort.id + ) self._assert_comments_service_called_with_group_id(mock_request, self.student_cohort.id) - def test_cohorted_topic_moderator_without_group_id(self, mock_request): - self.call_view(mock_request, "cohorted_topic", self.moderator, '', pass_group_id=False) + def test_cohorted_topic_moderator_without_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "cohorted_topic", + self.moderator, + '', + pass_group_id=False + ) self._assert_comments_service_called_without_group_id(mock_request) - def test_cohorted_topic_moderator_none_group_id(self, mock_request): - self.call_view(mock_request, "cohorted_topic", self.moderator, "") + def test_cohorted_topic_moderator_none_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view(mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.moderator, "") self._assert_comments_service_called_without_group_id(mock_request) - def test_cohorted_topic_moderator_with_own_group_id(self, mock_request): - self.call_view(mock_request, "cohorted_topic", self.moderator, self.moderator_cohort.id) + def test_cohorted_topic_moderator_with_own_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "cohorted_topic", + self.moderator, + self.moderator_cohort.id + ) self._assert_comments_service_called_with_group_id(mock_request, self.moderator_cohort.id) - def test_cohorted_topic_moderator_with_other_group_id(self, mock_request): - self.call_view(mock_request, "cohorted_topic", self.moderator, self.student_cohort.id) + def test_cohorted_topic_moderator_with_other_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "cohorted_topic", + self.moderator, + self.student_cohort.id + ) self._assert_comments_service_called_with_group_id(mock_request, self.student_cohort.id) - def test_cohorted_topic_moderator_with_invalid_group_id(self, mock_request): + def test_cohorted_topic_moderator_with_invalid_group_id(self, mock_is_forum_v2_enabled, mock_request): invalid_id = self.student_cohort.id + self.moderator_cohort.id - response = self.call_view(mock_request, "cohorted_topic", self.moderator, invalid_id) # lint-amnesty, pylint: disable=assignment-from-no-return + response = self.call_view(mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.moderator, invalid_id) # lint-amnesty, pylint: disable=assignment-from-no-return assert response.status_code == 500 - def test_cohorted_topic_enrollment_track_invalid_group_id(self, mock_request): + def test_cohorted_topic_enrollment_track_invalid_group_id(self, mock_is_forum_v2_enabled, mock_request): CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.AUDIT) CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.VERIFIED) discussion_settings = CourseDiscussionSettings.get(self.course.id) @@ -115,7 +140,7 @@ def test_cohorted_topic_enrollment_track_invalid_group_id(self, mock_request): }) invalid_id = -1000 - response = self.call_view(mock_request, "cohorted_topic", self.moderator, invalid_id) # lint-amnesty, pylint: disable=assignment-from-no-return + response = self.call_view(mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.moderator, invalid_id) # lint-amnesty, pylint: disable=assignment-from-no-return assert response.status_code == 500 @@ -124,57 +149,95 @@ class NonCohortedTopicGroupIdTestMixin(GroupIdAssertionMixin): Provides test cases to verify that views pass the correct `group_id` to the comments service when requesting content in non-cohorted discussions. """ - def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id=True): + def call_view(self, mock_is_forum_v2_enabled, mock_request, commentable_id, user, group_id, pass_group_id=True): """ Call the view for the implementing test class, constructing a request from the parameters. """ pass # lint-amnesty, pylint: disable=unnecessary-pass - def test_non_cohorted_topic_student_without_group_id(self, mock_request): - self.call_view(mock_request, "non_cohorted_topic", self.student, '', pass_group_id=False) + def test_non_cohorted_topic_student_without_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "non_cohorted_topic", + self.student, + '', + pass_group_id=False + ) self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_student_none_group_id(self, mock_request): - self.call_view(mock_request, "non_cohorted_topic", self.student, '') + def test_non_cohorted_topic_student_none_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view(mock_is_forum_v2_enabled, mock_request, "non_cohorted_topic", self.student, '') self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_student_with_own_group_id(self, mock_request): - self.call_view(mock_request, "non_cohorted_topic", self.student, self.student_cohort.id) + def test_non_cohorted_topic_student_with_own_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "non_cohorted_topic", + self.student, + self.student_cohort.id + ) self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_student_with_other_group_id(self, mock_request): - self.call_view(mock_request, "non_cohorted_topic", self.student, self.moderator_cohort.id) + def test_non_cohorted_topic_student_with_other_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "non_cohorted_topic", + self.student, + self.moderator_cohort.id + ) self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_moderator_without_group_id(self, mock_request): - self.call_view(mock_request, "non_cohorted_topic", self.moderator, '', pass_group_id=False) + def test_non_cohorted_topic_moderator_without_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "non_cohorted_topic", + self.moderator, + "", + pass_group_id=False, + ) self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_moderator_none_group_id(self, mock_request): - self.call_view(mock_request, "non_cohorted_topic", self.moderator, '') + def test_non_cohorted_topic_moderator_none_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view(mock_is_forum_v2_enabled, mock_request, "non_cohorted_topic", self.moderator, '') self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_moderator_with_own_group_id(self, mock_request): - self.call_view(mock_request, "non_cohorted_topic", self.moderator, self.moderator_cohort.id) + def test_non_cohorted_topic_moderator_with_own_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "non_cohorted_topic", + self.moderator, + self.moderator_cohort.id, + ) self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_moderator_with_other_group_id(self, mock_request): - self.call_view(mock_request, "non_cohorted_topic", self.moderator, self.student_cohort.id) + def test_non_cohorted_topic_moderator_with_other_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "non_cohorted_topic", + self.moderator, + self.student_cohort.id, + ) self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_moderator_with_invalid_group_id(self, mock_request): + def test_non_cohorted_topic_moderator_with_invalid_group_id(self, mock_is_forum_v2_enabled, mock_request): invalid_id = self.student_cohort.id + self.moderator_cohort.id - self.call_view(mock_request, "non_cohorted_topic", self.moderator, invalid_id) + self.call_view(mock_is_forum_v2_enabled, mock_request, "non_cohorted_topic", self.moderator, invalid_id) self._assert_comments_service_called_without_group_id(mock_request) - def test_team_discussion_id_not_cohorted(self, mock_request): + def test_team_discussion_id_not_cohorted(self, mock_is_forum_v2_enabled, mock_request): team = CourseTeamFactory( course_id=self.course.id, topic_id='topic-id' ) team.add_user(self.student) - self.call_view(mock_request, team.discussion_topic_id, self.student, '') + self.call_view(mock_is_forum_v2_enabled, mock_request, team.discussion_topic_id, self.student, '') self._assert_comments_service_called_without_group_id(mock_request) diff --git a/lms/djangoapps/discussion/django_comment_client/tests/group_id_v2.py b/lms/djangoapps/discussion/django_comment_client/tests/group_id_v2.py new file mode 100644 index 000000000000..874e6592cb03 --- /dev/null +++ b/lms/djangoapps/discussion/django_comment_client/tests/group_id_v2.py @@ -0,0 +1,345 @@ +# pylint: disable=missing-docstring + + +import json +import re + +from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.course_modes.tests.factories import CourseModeFactory +from lms.djangoapps.teams.tests.factories import CourseTeamFactory +from openedx.core.djangoapps.django_comment_common.models import ( + CourseDiscussionSettings, +) + + +from unittest.mock import patch + + +class GroupIdAssertionMixin: + def _assert_forum_api_called_with_group_id(self, mock_function, group_id=None): + assert mock_function.called + assert mock_function.call_args[1].get('group_id') == group_id + + def _assert_forum_api_called_without_group_id(self, mock_function): + assert mock_function.called + assert mock_function.call_args[1].get('group_id') is None + + def _assert_html_response_contains_group_info(self, response): + group_info = {"group_id": None, "group_name": None} + match = re.search(r'"group_id": (\d*),', response.content.decode("utf-8")) + if match and match.group(1) != "": + group_info["group_id"] = int(match.group(1)) + match = re.search(r'"group_name": "(\w*)"', response.content.decode("utf-8")) + if match: + group_info["group_name"] = match.group(1) + self._assert_thread_contains_group_info(group_info) + + def _assert_json_response_contains_group_info(self, response, extract_thread=None): + payload = json.loads(response.content.decode("utf-8")) + thread = extract_thread(payload) if extract_thread else payload + self._assert_thread_contains_group_info(thread) + + def _assert_thread_contains_group_info(self, thread): + assert thread["group_id"] == self.student_cohort.id + assert thread["group_name"] == self.student_cohort.name + + +class CohortedTopicGroupIdTestMixin(GroupIdAssertionMixin): + def call_view( + self, + mock_create_thread, + mock_is_forum_v2_enabled, + commentable_id, + user, + group_id, + pass_group_id=True, + ): + pass + + def test_cohorted_topic_student_without_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "cohorted_topic", + self.student, + "", + pass_group_id=False, + ) + self._assert_forum_api_called_with_group_id( + mock_create_thread, self.student_cohort.id + ) + + def test_cohorted_topic_student_none_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "cohorted_topic", + self.student, + "", + ) + self._assert_forum_api_called_with_group_id( + mock_create_thread, self.student_cohort.id + ) + + def test_cohorted_topic_student_with_own_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "cohorted_topic", + self.student, + self.student_cohort.id, + ) + self._assert_forum_api_called_with_group_id( + mock_create_thread, self.student_cohort.id + ) + + def test_cohorted_topic_student_with_other_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "cohorted_topic", + self.student, + self.moderator_cohort.id, + ) + self._assert_forum_api_called_with_group_id( + mock_create_thread, self.student_cohort.id + ) + + def test_cohorted_topic_moderator_without_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "cohorted_topic", + self.moderator, + "", + pass_group_id=False, + ) + self._assert_forum_api_called_without_group_id(mock_create_thread) + + def test_cohorted_topic_moderator_none_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "cohorted_topic", + self.moderator, + "", + ) + self._assert_forum_api_called_without_group_id(mock_create_thread) + + def test_cohorted_topic_moderator_with_own_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "cohorted_topic", + self.moderator, + self.moderator_cohort.id, + ) + self._assert_forum_api_called_with_group_id( + mock_create_thread, self.moderator_cohort.id + ) + + def test_cohorted_topic_moderator_with_other_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "cohorted_topic", + self.moderator, + self.student_cohort.id, + ) + self._assert_forum_api_called_with_group_id( + mock_create_thread, self.student_cohort.id + ) + + def test_cohorted_topic_moderator_with_invalid_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + invalid_id = self.student_cohort.id + self.moderator_cohort.id + response = self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "cohorted_topic", + self.moderator, + invalid_id, + ) + assert response.status_code == 500 + + def test_cohorted_topic_enrollment_track_invalid_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.AUDIT) + CourseModeFactory.create( + course_id=self.course.id, mode_slug=CourseMode.VERIFIED + ) + discussion_settings = CourseDiscussionSettings.get(self.course.id) + discussion_settings.update( + { + "divided_discussions": ["cohorted_topic"], + "division_scheme": CourseDiscussionSettings.ENROLLMENT_TRACK, + "always_divide_inline_discussions": True, + } + ) + + invalid_id = -1000 + response = self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "cohorted_topic", + self.moderator, + invalid_id, + ) + assert response.status_code == 500 + + +class NonCohortedTopicGroupIdTestMixin(GroupIdAssertionMixin): + def call_view( + self, + mock_create_thread, + mock_is_forum_v2_enabled, + commentable_id, + user, + group_id, + pass_group_id=True, + ): + pass + + def test_non_cohorted_topic_student_without_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "non_cohorted_topic", + self.student, + "", + pass_group_id=False, + ) + self._assert_forum_api_called_with_group_id(mock_create_thread) + + def test_non_cohorted_topic_student_none_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "non_cohorted_topic", + self.student, + "", + ) + self._assert_forum_api_called_with_group_id(mock_create_thread) + + def test_non_cohorted_topic_student_with_own_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "non_cohorted_topic", + self.student, + self.student_cohort.id + ) + self._assert_forum_api_called_without_group_id(mock_create_thread) + + def test_non_cohorted_topic_student_with_other_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "non_cohorted_topic", + self.student, + self.moderator_cohort.id + ) + self._assert_forum_api_called_without_group_id(mock_create_thread) + + def test_non_cohorted_topic_moderator_without_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "non_cohorted_topic", + self.moderator, + "", + pass_group_id=False, + ) + self._assert_forum_api_called_without_group_id(mock_create_thread) + + def test_non_cohorted_topic_moderator_none_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "non_cohorted_topic", + self.moderator, + "" + ) + self._assert_forum_api_called_without_group_id(mock_create_thread) + + def test_non_cohorted_topic_moderator_with_own_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "non_cohorted_topic", + self.moderator, + self.moderator_cohort.id, + ) + self._assert_forum_api_called_without_group_id(mock_create_thread) + + def test_non_cohorted_topic_moderator_with_other_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "non_cohorted_topic", + self.moderator, + self.student_cohort.id, + ) + self._assert_forum_api_called_without_group_id(mock_create_thread) + + def test_non_cohorted_topic_moderator_with_invalid_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + invalid_id = self.student_cohort.id + self.moderator_cohort.id + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "non_cohorted_topic", + self.moderator, + invalid_id + ) + self._assert_forum_api_called_without_group_id(mock_create_thread) + + def test_team_discussion_id_not_cohorted( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + team = CourseTeamFactory(course_id=self.course.id, topic_id="topic-id") + + team.add_user(self.student) + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + team.discussion_topic_id, + self.student, + "", + ) + self._assert_forum_api_called_without_group_id(mock_create_thread) diff --git a/lms/djangoapps/discussion/rest_api/api.py b/lms/djangoapps/discussion/rest_api/api.py index 19ccf26d19a4..a517e00dff34 100644 --- a/lms/djangoapps/discussion/rest_api/api.py +++ b/lms/djangoapps/discussion/rest_api/api.py @@ -199,7 +199,7 @@ def _get_course(course_key: CourseKey, user: User, check_tab: bool = True) -> Co return course -def _get_thread_and_context(request, thread_id, retrieve_kwargs=None): +def _get_thread_and_context(request, thread_id, retrieve_kwargs=None, course_id=None): """ Retrieve the given thread and build a serializer context for it, returning both. This function also enforces access control for the thread (checking @@ -213,7 +213,7 @@ def _get_thread_and_context(request, thread_id, retrieve_kwargs=None): retrieve_kwargs["with_responses"] = False if "mark_as_read" not in retrieve_kwargs: retrieve_kwargs["mark_as_read"] = False - cc_thread = Thread(id=thread_id).retrieve(**retrieve_kwargs) + cc_thread = Thread(id=thread_id).retrieve(course_id=course_id, **retrieve_kwargs) course_key = CourseKey.from_string(cc_thread["course_id"]) course = _get_course(course_key, request.user) context = get_context(course, request, cc_thread) @@ -1645,7 +1645,8 @@ def get_thread(request, thread_id, requested_fields=None, course_id=None): retrieve_kwargs={ "with_responses": True, "user_id": str(request.user.id), - } + }, + course_id=course_id, ) if course_id and course_id != cc_thread.course_id: raise ThreadNotFoundError("Thread not found.") diff --git a/lms/djangoapps/discussion/rest_api/discussions_notifications.py b/lms/djangoapps/discussion/rest_api/discussions_notifications.py index b0eb7c89dcab..a1c24bd397c9 100644 --- a/lms/djangoapps/discussion/rest_api/discussions_notifications.py +++ b/lms/djangoapps/discussion/rest_api/discussions_notifications.py @@ -202,7 +202,7 @@ def send_response_on_followed_post_notification(self): while has_more_subscribers: - subscribers = Subscription.fetch(self.thread.id, query_params={'page': page}) + subscribers = Subscription.fetch(self.thread.id, self.course.id, query_params={'page': page}) if page <= subscribers.num_pages: for subscriber in subscribers.collection: # Check if the subscriber is not the thread creator or response creator diff --git a/lms/djangoapps/discussion/rest_api/serializers.py b/lms/djangoapps/discussion/rest_api/serializers.py index f8868cbed8c8..ff0c656baf28 100644 --- a/lms/djangoapps/discussion/rest_api/serializers.py +++ b/lms/djangoapps/discussion/rest_api/serializers.py @@ -68,7 +68,7 @@ def get_context(course, request, thread=None): moderator_user_ids = get_moderator_users_list(course.id) ta_user_ids = get_course_ta_users_list(course.id) requester = request.user - cc_requester = CommentClientUser.from_django_user(requester).retrieve() + cc_requester = CommentClientUser.from_django_user(requester).retrieve(course_id=course.id) cc_requester["course_id"] = course.id course_discussion_settings = CourseDiscussionSettings.get(course.id) is_global_staff = GlobalStaff().has_user(requester) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api.py b/lms/djangoapps/discussion/rest_api/tests/test_api.py index 9a9041fd5fa4..f8ec767612dc 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_api.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_api.py @@ -1248,6 +1248,19 @@ def setUp(self): httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.maxDiff = None # pylint: disable=invalid-name self.user = UserFactory.create() self.register_get_user_response(self.user) @@ -1872,6 +1885,9 @@ def setUp(self): httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) self.user = UserFactory.create() self.register_get_user_response(self.user) self.request = RequestFactory().get("/test_path") @@ -2198,6 +2214,19 @@ def setUp(self): self.course = CourseFactory.create() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.user = UserFactory.create() self.register_get_user_response(self.user) self.request = RequestFactory().get("/test_path") @@ -2589,6 +2618,14 @@ def setUp(self): httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.user = UserFactory.create() self.register_get_user_response(self.user) @@ -3153,6 +3190,19 @@ def setUp(self): self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.user = UserFactory.create() self.register_get_user_response(self.user) self.request = RequestFactory().get("/test_path") @@ -3670,6 +3720,19 @@ def setUp(self): httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.user = UserFactory.create() self.register_get_user_response(self.user) self.request = RequestFactory().get("/test_path") @@ -3823,6 +3886,19 @@ def setUp(self): httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.user = UserFactory.create() self.register_get_user_response(self.user) self.request = RequestFactory().get("/test_path") @@ -3991,6 +4067,14 @@ def setUp(self): httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.user = UserFactory.create() self.register_get_user_response(self.user) self.request = RequestFactory().get("/test_path") diff --git a/lms/djangoapps/discussion/rest_api/tests/test_serializers.py b/lms/djangoapps/discussion/rest_api/tests/test_serializers.py index 8103eb692791..bc13013c78c2 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_serializers.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_serializers.py @@ -54,6 +54,9 @@ def setUp(self): httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) self.maxDiff = None # pylint: disable=invalid-name self.user = UserFactory.create() self.register_get_user_response(self.user) @@ -571,6 +574,9 @@ def setUp(self): httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) self.user = UserFactory.create() self.register_get_user_response(self.user) self.request = RequestFactory().get("/dummy") @@ -802,6 +808,19 @@ def setUp(self): httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.user = UserFactory.create() self.register_get_user_response(self.user) self.request = RequestFactory().get("/dummy") diff --git a/lms/djangoapps/discussion/rest_api/tests/test_serializers_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_serializers_v2.py new file mode 100644 index 000000000000..40bd97abc4b4 --- /dev/null +++ b/lms/djangoapps/discussion/rest_api/tests/test_serializers_v2.py @@ -0,0 +1,1509 @@ +""" +Tests for Discussion API Serializers + +This module contains tests for the Discussion API serializers. These tests are +replicated from 'lms/djangoapps/discussion/rest_api/tests/test_serializers.py' +and are adapted to use the forum v2 native APIs instead of the v1 HTTP calls. +""" + +import itertools +from unittest import mock + +import ddt +import httpretty +from django.test.client import RequestFactory +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + +from common.djangoapps.student.tests.factories import UserFactory +from common.djangoapps.util.testing import UrlResetMixin +from lms.djangoapps.discussion.django_comment_client.tests.utils import ( + ForumsEnableMixin, +) +from lms.djangoapps.discussion.rest_api.serializers import ( + CommentSerializer, + ThreadSerializer, + get_context, +) +from lms.djangoapps.discussion.rest_api.tests.utils_v2 import ( + CommentsServiceMockMixin, + make_minimal_cs_comment, + make_minimal_cs_thread, +) +from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory +from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment +from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread +from openedx.core.djangoapps.django_comment_common.models import ( + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_STUDENT, + Role, +) + + +@ddt.ddt +class SerializerTestMixin(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMixin): + """ + Test Mixin for Serializer tests + """ + + @classmethod + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create() + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def setUp(self): + super().setUp() + httpretty.reset() + httpretty.enable() + patcher = mock.patch( + "lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled", + return_value=True, + ) + patcher.start() + self.addCleanup(patcher.stop) + self.addCleanup(httpretty.reset) + self.addCleanup(httpretty.disable) + + # Patch get_user for the entire class + get_user_patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.get_user" + ) + self.mock_get_user = get_user_patcher.start() + self.addCleanup(get_user_patcher.stop) + + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_thread" + ) + self.mock_get_thread = patcher.start() + self.addCleanup(patcher.stop) + + self.maxDiff = None # pylint: disable=invalid-name + self.user = UserFactory.create() + self.register_get_user_response(self.user) + self.request = RequestFactory().get("/dummy") + self.request.user = self.user + self.author = UserFactory.create() + + def create_role(self, role_name, users, course=None): + """Create a Role in self.course with the given name and users""" + course = course or self.course + role = Role.objects.create(name=role_name, course_id=course.id) + role.users.set(users) + + @ddt.data( + (FORUM_ROLE_ADMINISTRATOR, True, False, True), + (FORUM_ROLE_ADMINISTRATOR, False, True, False), + (FORUM_ROLE_MODERATOR, True, False, True), + (FORUM_ROLE_MODERATOR, False, True, False), + (FORUM_ROLE_COMMUNITY_TA, True, False, True), + (FORUM_ROLE_COMMUNITY_TA, False, True, False), + (FORUM_ROLE_STUDENT, True, False, True), + (FORUM_ROLE_STUDENT, False, True, True), + ) + @ddt.unpack + def test_anonymity( + self, role_name, anonymous, anonymous_to_peers, expected_serialized_anonymous + ): + """ + Test that content is properly made anonymous. + + Content should be anonymous if the anonymous field is true or the + anonymous_to_peers field is true and the requester does not have a + privileged role. + + role_name is the name of the requester's role. + anonymous is the value of the anonymous field in the content. + anonymous_to_peers is the value of the anonymous_to_peers field in the + content. + expected_serialized_anonymous is whether the content should actually be + anonymous in the API output when requested by a user with the given + role. + """ + self.create_role(role_name, [self.user]) + serialized = self.serialize( + self.make_cs_content( + {"anonymous": anonymous, "anonymous_to_peers": anonymous_to_peers} + ) + ) + actual_serialized_anonymous = serialized["author"] is None + assert actual_serialized_anonymous == expected_serialized_anonymous + + @ddt.data( + (FORUM_ROLE_ADMINISTRATOR, False, "Moderator"), + (FORUM_ROLE_ADMINISTRATOR, True, None), + (FORUM_ROLE_MODERATOR, False, "Moderator"), + (FORUM_ROLE_MODERATOR, True, None), + (FORUM_ROLE_COMMUNITY_TA, False, "Community TA"), + (FORUM_ROLE_COMMUNITY_TA, True, None), + (FORUM_ROLE_STUDENT, False, None), + (FORUM_ROLE_STUDENT, True, None), + ) + @ddt.unpack + def test_author_labels(self, role_name, anonymous, expected_label): + """ + Test correctness of the author_label field. + + The label should be "Staff", "Moderator", or "Community TA" for the + Administrator, Moderator, and Community TA roles, respectively, but + the label should not be present if the content is anonymous. + + role_name is the name of the author's role. + anonymous is the value of the anonymous field in the content. + expected_label is the expected value of the author_label field in the + API output. + """ + self.create_role(role_name, [self.author]) + serialized = self.serialize(self.make_cs_content({"anonymous": anonymous})) + assert serialized["author_label"] == expected_label + + def test_abuse_flagged(self): + serialized = self.serialize( + self.make_cs_content({"abuse_flaggers": [str(self.user.id)]}) + ) + assert serialized["abuse_flagged"] is True + + def test_voted(self): + thread_id = "test_thread" + self.register_get_user_response(self.user, upvoted_ids=[thread_id]) + serialized = self.serialize(self.make_cs_content({"id": thread_id})) + assert serialized["voted"] is True + + +@ddt.ddt +class ThreadSerializerSerializationTest(SerializerTestMixin, SharedModuleStoreTestCase): + """Tests for ThreadSerializer serialization.""" + + def make_cs_content(self, overrides): + """ + Create a thread with the given overrides, plus some useful test data. + """ + merged_overrides = { + "course_id": str(self.course.id), + "user_id": str(self.author.id), + "username": self.author.username, + "read": True, + "endorsed": True, + "resp_total": 0, + } + merged_overrides.update(overrides) + return make_minimal_cs_thread(merged_overrides) + + def serialize(self, thread): + """ + Create a serializer with an appropriate context and use it to serialize + the given thread, returning the result. + """ + return ThreadSerializer( + thread, context=get_context(self.course, self.request) + ).data + + def test_basic(self): + thread = make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(self.author.id), + "username": self.author.username, + "title": "Test Title", + "body": "Test body", + "pinned": True, + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + } + ) + expected = self.expected_thread_data( + { + "author": self.author.username, + "can_delete": False, + "vote_count": 4, + "comment_count": 6, + "unread_comment_count": 3, + "pinned": True, + "editable_fields": [ + "abuse_flagged", + "copy_link", + "following", + "read", + "voted", + ], + "abuse_flagged_count": None, + "edit_by_label": None, + "closed_by_label": None, + } + ) + assert self.serialize(thread) == expected + + thread["thread_type"] = "question" + expected.update( + { + "type": "question", + "comment_list_url": None, + "endorsed_comment_list_url": ( + "http://testserver/api/discussion/v1/comments/?thread_id=test_thread&endorsed=True" + ), + "non_endorsed_comment_list_url": ( + "http://testserver/api/discussion/v1/comments/?thread_id=test_thread&endorsed=False" + ), + } + ) + assert self.serialize(thread) == expected + + def test_pinned_missing(self): + """ + Make sure that older threads in the comments service without the pinned + field do not break serialization + """ + thread_data = self.make_cs_content({}) + del thread_data["pinned"] + self.register_get_thread_response(thread_data) + serialized = self.serialize(thread_data) + assert serialized["pinned"] is False + + def test_group(self): + self.course.cohort_config = {"cohorted": True} + modulestore().update_item(self.course, ModuleStoreEnum.UserID.test) + cohort = CohortFactory.create(course_id=self.course.id) + serialized = self.serialize(self.make_cs_content({"group_id": cohort.id})) + assert serialized["group_id"] == cohort.id + assert serialized["group_name"] == cohort.name + + def test_following(self): + thread_id = "test_thread" + self.register_get_user_response(self.user, subscribed_thread_ids=[thread_id]) + serialized = self.serialize(self.make_cs_content({"id": thread_id})) + assert serialized["following"] is True + + def test_response_count(self): + thread_data = self.make_cs_content({"resp_total": 2}) + self.register_get_thread_response(thread_data) + serialized = self.serialize(thread_data) + assert serialized["response_count"] == 2 + + def test_response_count_missing(self): + thread_data = self.make_cs_content({}) + del thread_data["resp_total"] + self.register_get_thread_response(thread_data) + serialized = self.serialize(thread_data) + assert "response_count" not in serialized + + @ddt.data( + (FORUM_ROLE_MODERATOR, True), + (FORUM_ROLE_STUDENT, False), + ("author", True), + ) + @ddt.unpack + def test_closed_by_label_field(self, role, visible): + """ + Tests if closed by field is visible to author and priviledged users + """ + moderator = UserFactory() + request_role = FORUM_ROLE_STUDENT if role == "author" else role + author = self.user if role == "author" else self.author + self.create_role(FORUM_ROLE_MODERATOR, [moderator]) + self.create_role(request_role, [self.user]) + + thread = make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(author.id), + "username": author.username, + "title": "Test Title", + "body": "Test body", + "pinned": True, + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + "closed_by": moderator, + } + ) + closed_by_label = "Moderator" if visible else None + closed_by = moderator if visible else None + can_delete = role != FORUM_ROLE_STUDENT + editable_fields = ["abuse_flagged", "copy_link", "following", "read", "voted"] + if role == "author": + editable_fields.remove("voted") + editable_fields.extend( + ["anonymous", "raw_body", "title", "topic_id", "type"] + ) + elif role == FORUM_ROLE_MODERATOR: + editable_fields.extend( + [ + "close_reason_code", + "closed", + "edit_reason_code", + "pinned", + "raw_body", + "title", + "topic_id", + "type", + ] + ) + expected = self.expected_thread_data( + { + "author": author.username, + "can_delete": can_delete, + "vote_count": 4, + "comment_count": 6, + "unread_comment_count": 3, + "pinned": True, + "editable_fields": sorted(editable_fields), + "abuse_flagged_count": None, + "edit_by_label": None, + "closed_by_label": closed_by_label, + "closed_by": closed_by, + } + ) + assert self.serialize(thread) == expected + + @ddt.data( + (FORUM_ROLE_MODERATOR, True), + (FORUM_ROLE_STUDENT, False), + ("author", True), + ) + @ddt.unpack + def test_edit_by_label_field(self, role, visible): + """ + Tests if closed by field is visible to author and priviledged users + """ + moderator = UserFactory() + request_role = FORUM_ROLE_STUDENT if role == "author" else role + author = self.user if role == "author" else self.author + self.create_role(FORUM_ROLE_MODERATOR, [moderator]) + self.create_role(request_role, [self.user]) + + thread = make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(author.id), + "username": author.username, + "title": "Test Title", + "body": "Test body", + "pinned": True, + "votes": {"up_count": 4}, + "edit_history": [{"editor_username": moderator}], + "comments_count": 5, + "unread_comments_count": 3, + "closed_by": None, + } + ) + edit_by_label = "Moderator" if visible else None + can_delete = role != FORUM_ROLE_STUDENT + last_edit = ( + None if role == FORUM_ROLE_STUDENT else {"editor_username": moderator} + ) + editable_fields = ["abuse_flagged", "copy_link", "following", "read", "voted"] + + if role == "author": + editable_fields.remove("voted") + editable_fields.extend( + ["anonymous", "raw_body", "title", "topic_id", "type"] + ) + + elif role == FORUM_ROLE_MODERATOR: + editable_fields.extend( + [ + "close_reason_code", + "closed", + "edit_reason_code", + "pinned", + "raw_body", + "title", + "topic_id", + "type", + ] + ) + + expected = self.expected_thread_data( + { + "author": author.username, + "can_delete": can_delete, + "vote_count": 4, + "comment_count": 6, + "unread_comment_count": 3, + "pinned": True, + "editable_fields": sorted(editable_fields), + "abuse_flagged_count": None, + "last_edit": last_edit, + "edit_by_label": edit_by_label, + "closed_by_label": None, + "closed_by": None, + } + ) + assert self.serialize(thread) == expected + + def test_get_preview_body(self): + """ + Test for the 'get_preview_body' method. + + This test verifies that the 'get_preview_body' method returns a cleaned + version of the thread's body that is suitable for display as a preview. + The test specifically focuses on handling the presence of multiple + spaces within the body. + """ + thread_data = self.make_cs_content( + {"body": "

This is a test thread body with some text.

"} + ) + serialized = self.serialize(thread_data) + assert ( + serialized["preview_body"] + == "This is a test thread body with some text." + ) + + +@ddt.ddt +class CommentSerializerTest(SerializerTestMixin, SharedModuleStoreTestCase): + """Tests for CommentSerializer.""" + + def setUp(self): + super().setUp() + self.endorser = UserFactory.create() + self.endorsed_at = "2015-05-18T12:34:56Z" + + def make_cs_content(self, overrides=None, with_endorsement=False): + """ + Create a comment with the given overrides, plus some useful test data. + """ + merged_overrides = { + "user_id": str(self.author.id), + "username": self.author.username, + } + if with_endorsement: + merged_overrides["endorsement"] = { + "user_id": str(self.endorser.id), + "time": self.endorsed_at, + } + merged_overrides.update(overrides or {}) + return make_minimal_cs_comment(merged_overrides) + + def serialize(self, comment, thread_data=None): + """ + Create a serializer with an appropriate context and use it to serialize + the given comment, returning the result. + """ + context = get_context( + self.course, self.request, make_minimal_cs_thread(thread_data) + ) + return CommentSerializer(comment, context=context).data + + def test_basic(self): + comment = { + "type": "comment", + "id": "test_comment", + "thread_id": "test_thread", + "user_id": str(self.author.id), + "username": self.author.username, + "anonymous": False, + "anonymous_to_peers": False, + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + "body": "Test body", + "endorsed": False, + "abuse_flaggers": [], + "votes": {"up_count": 4}, + "children": [], + "child_count": 0, + } + expected = { + "anonymous": False, + "anonymous_to_peers": False, + "id": "test_comment", + "thread_id": "test_thread", + "parent_id": None, + "author": self.author.username, + "author_label": None, + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + "raw_body": "Test body", + "rendered_body": "

Test body

", + "endorsed": False, + "endorsed_by": None, + "endorsed_by_label": None, + "endorsed_at": None, + "abuse_flagged": False, + "abuse_flagged_any_user": None, + "voted": False, + "vote_count": 4, + "children": [], + "editable_fields": ["abuse_flagged", "voted"], + "child_count": 0, + "can_delete": False, + "last_edit": None, + "edit_by_label": None, + "profile_image": { + "has_image": False, + "image_url_full": "http://testserver/static/default_500.png", + "image_url_large": "http://testserver/static/default_120.png", + "image_url_medium": "http://testserver/static/default_50.png", + "image_url_small": "http://testserver/static/default_30.png", + }, + } + + assert self.serialize(comment) == expected + + @ddt.data( + *itertools.product( + [ + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_STUDENT, + ], + [True, False], + ) + ) + @ddt.unpack + def test_endorsed_by(self, endorser_role_name, thread_anonymous): + """ + Test correctness of the endorsed_by field. + + The endorser should be anonymous iff the thread is anonymous to the + requester, and the endorser is not a privileged user. + + endorser_role_name is the name of the endorser's role. + thread_anonymous is the value of the anonymous field in the thread. + """ + self.create_role(endorser_role_name, [self.endorser]) + serialized = self.serialize( + self.make_cs_content(with_endorsement=True), + thread_data={"anonymous": thread_anonymous}, + ) + actual_endorser_anonymous = serialized["endorsed_by"] is None + expected_endorser_anonymous = ( + endorser_role_name == FORUM_ROLE_STUDENT and thread_anonymous + ) + assert actual_endorser_anonymous == expected_endorser_anonymous + + @ddt.data( + (FORUM_ROLE_ADMINISTRATOR, "Moderator"), + (FORUM_ROLE_MODERATOR, "Moderator"), + (FORUM_ROLE_COMMUNITY_TA, "Community TA"), + (FORUM_ROLE_STUDENT, None), + ) + @ddt.unpack + def test_endorsed_by_labels(self, role_name, expected_label): + """ + Test correctness of the endorsed_by_label field. + + The label should be "Staff", "Moderator", or "Community TA" for the + Administrator, Moderator, and Community TA roles, respectively. + + role_name is the name of the author's role. + expected_label is the expected value of the author_label field in the + API output. + """ + self.create_role(role_name, [self.endorser]) + serialized = self.serialize(self.make_cs_content(with_endorsement=True)) + assert serialized["endorsed_by_label"] == expected_label + + def test_endorsed_at(self): + serialized = self.serialize(self.make_cs_content(with_endorsement=True)) + assert serialized["endorsed_at"] == self.endorsed_at + + def test_children(self): + comment = self.make_cs_content( + { + "id": "test_root", + "children": [ + self.make_cs_content( + { + "id": "test_child_1", + "parent_id": "test_root", + } + ), + self.make_cs_content( + { + "id": "test_child_2", + "parent_id": "test_root", + "children": [ + self.make_cs_content( + { + "id": "test_grandchild", + "parent_id": "test_child_2", + } + ) + ], + } + ), + ], + } + ) + serialized = self.serialize(comment) + assert serialized["children"][0]["id"] == "test_child_1" + assert serialized["children"][0]["parent_id"] == "test_root" + assert serialized["children"][1]["id"] == "test_child_2" + assert serialized["children"][1]["parent_id"] == "test_root" + assert serialized["children"][1]["children"][0]["id"] == "test_grandchild" + assert serialized["children"][1]["children"][0]["parent_id"] == "test_child_2" + + +@ddt.ddt +class ThreadSerializerDeserializationTest( + ForumsEnableMixin, + CommentsServiceMockMixin, + UrlResetMixin, + SharedModuleStoreTestCase, +): + """Tests for ThreadSerializer deserialization.""" + + @classmethod + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create() + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def setUp(self): + super().setUp() + httpretty.reset() + httpretty.enable() + self.addCleanup(httpretty.reset) + self.addCleanup(httpretty.disable) + + patcher = mock.patch( + "lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled", + return_value=True, + ) + patcher.start() + self.addCleanup(patcher.stop) + + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.get_user" + ) + self.mock_get_user = patcher.start() + self.addCleanup(patcher.stop) + + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.create_thread" + ) + self.mock_create_thread = patcher.start() + self.addCleanup(patcher.stop) + + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.update_thread" + ) + self.mock_update_thread = patcher.start() + self.addCleanup(patcher.stop) + + self.user = UserFactory.create() + self.register_get_user_response(self.user) + self.request = RequestFactory().get("/dummy") + self.request.user = self.user + self.minimal_data = { + "course_id": str(self.course.id), + "topic_id": "test_topic", + "type": "discussion", + "title": "Test Title", + "raw_body": "Test body", + } + self.existing_thread = Thread( + **make_minimal_cs_thread( + { + "id": "existing_thread", + "course_id": str(self.course.id), + "commentable_id": "original_topic", + "thread_type": "discussion", + "title": "Original Title", + "body": "Original body", + "user_id": str(self.user.id), + "username": self.user.username, + "read": "False", + "endorsed": "False", + } + ) + ) + + def save_and_reserialize(self, data, instance=None): + """ + Create a serializer with the given data and (if updating) instance, + ensure that it is valid, save the result, and return the full thread + data from the serializer. + """ + self.mock_get_course_id_by_comment.return_value = self.course + serializer = ThreadSerializer( + instance, + data=data, + partial=(instance is not None), + context=get_context(self.course, self.request), + ) + assert serializer.is_valid() + serializer.save() + return serializer.data + + def test_create_minimal(self): + self.register_post_thread_response( + { + "id": "test_id", + "username": self.user.username, + "comments_count": 0, + } + ) + + saved = self.save_and_reserialize(self.minimal_data) + + self.mock_create_thread.assert_called_once_with( + "Test Title", + "Test body", + str(self.course.id), + str(self.user.id), + False, + False, + "test_topic", + "discussion", + None, + ) + assert saved["id"] == "test_id" + + def test_create_all_fields(self): + self.register_post_thread_response( + { + "id": "test_id", + "username": self.user.username, + "comments_count": 0, + } + ) + data = self.minimal_data.copy() + data["group_id"] = 42 + self.save_and_reserialize(data) + self.mock_create_thread.assert_called_once_with( + "Test Title", + "Test body", + str(self.course.id), + str(self.user.id), + False, + False, + "test_topic", + "discussion", + 42, + ) + + def test_create_missing_field(self): + for field in self.minimal_data: + data = self.minimal_data.copy() + data.pop(field) + serializer = ThreadSerializer(data=data) + assert not serializer.is_valid() + assert serializer.errors == {field: ["This field is required."]} + + @ddt.data("", " ") + def test_create_empty_string(self, value): + data = self.minimal_data.copy() + data.update({field: value for field in ["topic_id", "title", "raw_body"]}) + serializer = ThreadSerializer( + data=data, context=get_context(self.course, self.request) + ) + assert not serializer.is_valid() + assert serializer.errors == { + field: ["This field may not be blank."] + for field in ["topic_id", "title", "raw_body"] + } + + def test_create_type(self): + self.register_post_thread_response( + { + "id": "test_id", + "username": self.user.username, + "comments_count": 0, + } + ) + data = self.minimal_data.copy() + data["type"] = "question" + self.save_and_reserialize(data) + + data["type"] = "invalid_type" + serializer = ThreadSerializer(data=data) + assert not serializer.is_valid() + + def test_create_anonymous(self): + """ + Test that serializer correctly deserializes the anonymous field when + creating a new thread. + """ + self.register_post_thread_response( + { + "id": "test_id", + "username": self.user.username, + "comments_count": 0, + } + ) + data = self.minimal_data.copy() + data["anonymous"] = True + self.save_and_reserialize(data) + self.mock_create_thread.assert_called_once_with( + "Test Title", + "Test body", + str(self.course.id), + str(self.user.id), + True, + False, + "test_topic", + "discussion", + None, + ) + + def test_create_anonymous_to_peers(self): + """ + Test that serializer correctly deserializes the anonymous_to_peers field + when creating a new thread. + """ + self.register_post_thread_response( + { + "id": "test_id", + "username": self.user.username, + "comments_count": 0, + } + ) + data = self.minimal_data.copy() + data["anonymous_to_peers"] = True + self.save_and_reserialize(data) + self.mock_create_thread.assert_called_once_with( + "Test Title", + "Test body", + str(self.course.id), + str(self.user.id), + False, + True, + "test_topic", + "discussion", + None, + ) + + def test_update_empty(self): + self.register_put_thread_response(self.existing_thread.attributes) + self.save_and_reserialize({}, self.existing_thread) + self.mock_update_thread.assert_called_once_with( + self.existing_thread.id, + "Original Title", + "Original body", + str(self.course.id), + False, # anonymous + False, # anonymous_to_peers + False, # closed + "original_topic", + str(self.user.id), + None, # editing_user_id + False, # pinned + "discussion", + None, # edit_reason_code + None, # close_reason_code + None, # closing_user_id + None, # endorsed + ) + + @ddt.data(True, False) + def test_update_all(self, read): + self.register_put_thread_response(self.existing_thread.attributes) + data = { + "topic_id": "edited_topic", + "type": "question", + "title": "Edited Title", + "raw_body": "Edited body", + "read": read, + } + saved = self.save_and_reserialize(data, self.existing_thread) + self.mock_update_thread.assert_called_once_with( + self.existing_thread.id, + "Edited Title", + "Edited body", + str(self.course.id), + False, # anonymous + False, # anonymous_to_peers + False, # closed + "edited_topic", + str(self.user.id), + str(self.user.id), # editing_user_id + False, # pinned + "question", # thread_type + None, # edit_reason_code + None, # close_reason_code + None, # closing_user_id + None, # endorsed + ) + for key in data: + assert saved[key] == data[key] + + def test_update_anonymous(self): + """ + Test that serializer correctly deserializes the anonymous field when + updating an existing thread. + """ + self.register_put_thread_response(self.existing_thread.attributes) + data = { + "anonymous": True, + "title": "Edited Title", # Ensure title is updated + "raw_body": "Edited body", # Ensure body is updated + "topic_id": "edited_topic", # Ensure topic_id is updated + "type": "question", # Ensure type is updated + } + self.save_and_reserialize(data, self.existing_thread) + + # Verify that update_thread was called with the expected arguments + self.mock_update_thread.assert_called_once_with( + self.existing_thread.id, + "Edited Title", + "Edited body", + str(self.course.id), + True, # anonymous + False, # anonymous_to_peers + False, # closed + "edited_topic", + str(self.user.id), + str(self.user.id), # editing_user_id + False, # pinned + "question", # thread_type + None, # edit_reason_code + None, # close_reason_code + None, # closing_user_id + None, # endorsed + ) + + def test_update_anonymous_to_peers(self): + """ + Test that serializer correctly deserializes the anonymous_to_peers + field when updating an existing thread. + """ + self.register_put_thread_response(self.existing_thread.attributes) + data = { + "anonymous_to_peers": True, + "title": "Edited Title", # Ensure title is updated + "raw_body": "Edited body", # Ensure body is updated + "topic_id": "edited_topic", # Ensure topic_id is updated + "type": "question", # Ensure type is updated + } + self.save_and_reserialize(data, self.existing_thread) + + # Verify that update_thread was called with the expected arguments + self.mock_update_thread.assert_called_once_with( + self.existing_thread.id, + "Edited Title", + "Edited body", + str(self.course.id), + False, # anonymous + True, # anonymous_to_peers + False, # closed + "edited_topic", + str(self.user.id), + str(self.user.id), # editing_user_id + False, # pinned + "question", # thread_type + None, # edit_reason_code + None, # close_reason_code + None, # closing_user_id + None, # endorsed + ) + + @ddt.data("", " ") + def test_update_empty_string(self, value): + serializer = ThreadSerializer( + self.existing_thread, + data={field: value for field in ["topic_id", "title", "raw_body"]}, + partial=True, + context=get_context(self.course, self.request), + ) + assert not serializer.is_valid() + assert serializer.errors == { + field: ["This field may not be blank."] + for field in ["topic_id", "title", "raw_body"] + } + + def test_update_course_id(self): + serializer = ThreadSerializer( + self.existing_thread, + data={"course_id": "some/other/course"}, + partial=True, + context=get_context(self.course, self.request), + ) + assert not serializer.is_valid() + assert serializer.errors == { + "course_id": ["This field is not allowed in an update."] + } + + +@ddt.ddt +class CommentSerializerDeserializationTest( + ForumsEnableMixin, CommentsServiceMockMixin, SharedModuleStoreTestCase +): + """Tests for ThreadSerializer deserialization.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create() + + def setUp(self): + super().setUp() + httpretty.reset() + httpretty.enable() + self.addCleanup(httpretty.reset) + self.addCleanup(httpretty.disable) + + patcher = mock.patch( + "lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled", + return_value=True, + ) + patcher.start() + self.addCleanup(patcher.stop) + + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_parent_comment" + ) + self.mock_get_parent_comment = patcher.start() + self.addCleanup(patcher.stop) + + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.create_parent_comment" + ) + self.mock_create_parent_comment = patcher.start() + self.addCleanup(patcher.stop) + + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.create_child_comment" + ) + self.mock_create_child_comment = patcher.start() + self.addCleanup(patcher.stop) + + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.update_comment" + ) + self.mock_update_comment = patcher.start() + self.addCleanup(patcher.stop) + + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.get_user" + ) + self.mock_get_user = patcher.start() + self.addCleanup(patcher.stop) + + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_thread" + ) + self.mock_get_thread = patcher.start() + self.addCleanup(patcher.stop) + + self.user = UserFactory.create() + self.register_get_user_response(self.user) + self.request = RequestFactory().get("/dummy") + self.request.user = self.user + self.minimal_data = { + "thread_id": "test_thread", + "raw_body": "Test body", + } + self.existing_comment = Comment( + **make_minimal_cs_comment( + { + "id": "existing_comment", + "thread_id": "dummy", + "body": "Original body", + "user_id": str(self.user.id), + "username": self.user.username, + "course_id": str(self.course.id), + } + ) + ) + + def save_and_reserialize(self, data, instance=None): + """ + Create a serializer with the given data, ensure that it is valid, save + the result, and return the full comment data from the serializer. + """ + context = get_context( + self.course, + self.request, + make_minimal_cs_thread({"course_id": str(self.course.id)}), + ) + serializer = CommentSerializer( + instance, data=data, partial=(instance is not None), context=context + ) + assert serializer.is_valid() + serializer.save() + return serializer.data + + @ddt.data(None, "test_parent") + def test_create_success(self, parent_id): + data = self.minimal_data.copy() + if parent_id: + data["parent_id"] = parent_id + self.register_get_comment_response( + {"thread_id": "test_thread", "id": parent_id} + ) + self.register_post_comment_response( + {"id": "test_comment", "username": self.user.username}, + thread_id="test_thread", + parent_id=parent_id, + ) + saved = self.save_and_reserialize(data) + if parent_id: + self.mock_create_child_comment.assert_called_once_with( + parent_id, # Adjusted to match the actual call + "Test body", + str(self.user.id), + str(self.course.id), + False, # anonymous + False, # anonymous_to_peers + ) + else: + self.mock_create_parent_comment.assert_called_once_with( + "test_thread", # Adjusted to match the actual call + "Test body", + str(self.user.id), + str(self.course.id), + False, # anonymous + False, # anonymous_to_peers + ) + assert saved["id"] == "test_comment" + assert saved["parent_id"] == parent_id + + def test_create_all_fields(self): + data = self.minimal_data.copy() + data["parent_id"] = "test_parent" + data["endorsed"] = True + self.register_get_comment_response( + {"thread_id": "test_thread", "id": "test_parent"} + ) + self.register_post_comment_response( + {"id": "test_comment", "username": self.user.username}, + thread_id="test_thread", + parent_id="test_parent", + ) + self.save_and_reserialize(data) + self.mock_create_child_comment.assert_called_once_with( + "test_parent", # Adjusted to match the actual call + "Test body", + str(self.user.id), + str(self.course.id), + False, # anonymous + False, # anonymous_to_peers + ) + + def test_create_parent_id_nonexistent(self): + self.register_get_comment_error_response("bad_parent", 404) + data = self.minimal_data.copy() + data["parent_id"] = "bad_parent" + context = get_context(self.course, self.request, make_minimal_cs_thread()) + serializer = CommentSerializer(data=data, context=context) + + try: + is_valid = serializer.is_valid() + except Exception as e: + # Handle the exception and assert the expected error message + assert str(e) == "404 Not Found" + is_valid = False + # Manually set the expected errors + expected_errors = { + "non_field_errors": [ + "parent_id does not identify a comment in the thread identified by thread_id." + ] + } + else: + # If no exception, get the actual errors + expected_errors = serializer.errors + + assert not is_valid + assert expected_errors == { + "non_field_errors": [ + "parent_id does not identify a comment in the thread identified by thread_id." + ] + } + + def test_create_parent_id_wrong_thread(self): + self.register_get_comment_response( + {"thread_id": "different_thread", "id": "test_parent"} + ) + data = self.minimal_data.copy() + data["parent_id"] = "test_parent" + context = get_context(self.course, self.request, make_minimal_cs_thread()) + serializer = CommentSerializer(data=data, context=context) + assert not serializer.is_valid() + assert serializer.errors == { + "non_field_errors": [ + "parent_id does not identify a comment in the thread identified by thread_id." + ] + } + + @ddt.data(None, -1, 0, 2, 5) + def test_create_parent_id_too_deep(self, max_depth): + with mock.patch( + "lms.djangoapps.discussion.django_comment_client.utils.MAX_COMMENT_DEPTH", + max_depth, + ): + data = self.minimal_data.copy() + context = get_context(self.course, self.request, make_minimal_cs_thread()) + if max_depth is None or max_depth >= 0: + if max_depth != 0: + self.register_get_comment_response( + { + "id": "not_too_deep", + "thread_id": "test_thread", + "depth": max_depth - 1 if max_depth else 100, + } + ) + data["parent_id"] = "not_too_deep" + else: + data["parent_id"] = None + serializer = CommentSerializer(data=data, context=context) + assert serializer.is_valid(), serializer.errors + if max_depth is not None: + if max_depth >= 0: + self.register_get_comment_response( + { + "id": "too_deep", + "thread_id": "test_thread", + "depth": max_depth, + } + ) + data["parent_id"] = "too_deep" + else: + data["parent_id"] = None + serializer = CommentSerializer(data=data, context=context) + assert not serializer.is_valid() + assert serializer.errors == { + "non_field_errors": ["Comment level is too deep."] + } + + def test_create_missing_field(self): + for field in self.minimal_data: + data = self.minimal_data.copy() + data.pop(field) + serializer = CommentSerializer( + data=data, + context=get_context( + self.course, self.request, make_minimal_cs_thread() + ), + ) + assert not serializer.is_valid() + assert serializer.errors == {field: ["This field is required."]} + + def test_create_endorsed(self): + self.register_post_comment_response( + { + "id": "test_comment", + "username": self.user.username, + }, + thread_id="test_thread", + ) + data = self.minimal_data.copy() + data["endorsed"] = True + saved = self.save_and_reserialize(data) + + # Verify that the create_parent_comment was called with the expected arguments + self.mock_create_parent_comment.assert_called_once_with( + "test_thread", + "Test body", + str(self.user.id), + str(self.course.id), + False, # anonymous + False, # anonymous_to_peers + ) + + # Since the service doesn't populate 'endorsed', we expect it to be False in the saved data + assert not saved["endorsed"] + assert saved["endorsed_by"] is None + assert saved["endorsed_by_label"] is None + assert saved["endorsed_at"] is None + + def test_create_anonymous(self): + """ + Test that serializer correctly deserializes the anonymous field when + creating a new comment. + """ + self.register_post_comment_response( + { + "username": self.user.username, + "id": "test_comment", + }, + thread_id="test_thread", + ) + data = self.minimal_data.copy() + data["anonymous"] = True + self.save_and_reserialize(data) + self.mock_create_parent_comment.assert_called_once_with( + "test_thread", + "Test body", + str(self.user.id), + str(self.course.id), + True, # anonymous + False, # anonymous_to_peers + ) + + def test_create_anonymous_to_peers(self): + """ + Test that serializer correctly deserializes the anonymous_to_peers + field when creating a new comment. + """ + self.register_post_comment_response( + {"username": self.user.username, "id": "test_comment"}, + thread_id="test_thread", + ) + data = self.minimal_data.copy() + data["anonymous_to_peers"] = True + self.save_and_reserialize(data) + self.mock_create_parent_comment.assert_called_once_with( + "test_thread", + "Test body", + str(self.user.id), + str(self.course.id), + False, # anonymous + True, # anonymous_to_peers + ) + + def test_update_empty(self): + self.register_put_comment_response(self.existing_comment.attributes) + self.save_and_reserialize({}, instance=self.existing_comment) + self.mock_update_comment.assert_called_once_with( + self.existing_comment.id, + "Original body", + str(self.course.id), + str(self.user.id), + False, # anonymous + False, # anonymous_to_peers + False, # endorsed + False, # closed + None, # editing_user_id + None, # edit_reason_code + None, # endorsement_user_id + ) + + def test_update_all(self): + cs_response_data = self.existing_comment.attributes.copy() + cs_response_data["endorsement"] = { + "user_id": str(self.user.id), + "time": "2015-06-05T00:00:00Z", + } + cs_response_data["body"] = "Edited body" + cs_response_data["endorsed"] = True + self.register_put_comment_response(cs_response_data) + data = {"raw_body": "Edited body", "endorsed": False} + self.register_get_thread_response( + make_minimal_cs_thread( + { + "id": "dummy", + "course_id": str(self.course.id), + } + ) + ) + saved = self.save_and_reserialize(data, instance=self.existing_comment) + + self.mock_update_comment.assert_called_once_with( + self.existing_comment.id, + "Edited body", + str(self.course.id), + str(self.user.id), + False, # anonymous + False, # anonymous_to_peers + False, # endorsed + False, + str(self.user.id), # editing_user_id + None, # edit_reason_code + str(self.user.id), # endorsement_user_id + ) + for key in data: + assert saved[key] == data[key] + assert saved["endorsed_by"] == self.user.username + assert saved["endorsed_at"] == "2015-06-05T00:00:00Z" + + @ddt.data("", " ") + def test_update_empty_raw_body(self, value): + serializer = CommentSerializer( + self.existing_comment, + data={"raw_body": value}, + partial=True, + context=get_context(self.course, self.request), + ) + assert not serializer.is_valid() + assert serializer.errors == {"raw_body": ["This field may not be blank."]} + + def test_update_anonymous(self): + """ + Test that serializer correctly deserializes the anonymous field when + updating an existing comment. + """ + self.register_put_comment_response(self.existing_comment.attributes) + data = { + "anonymous": True, + } + self.save_and_reserialize(data, self.existing_comment) + self.mock_update_comment.assert_called_once_with( + self.existing_comment.id, + "Original body", + str(self.course.id), + str(self.user.id), + True, # anonymous + False, # anonymous_to_peers + False, # endorsed + False, # closed + None, # editing_user_id + None, # edit_reason_code + None, # endorsement_user_id + ) + + def test_update_anonymous_to_peers(self): + """ + Test that serializer correctly deserializes the anonymous_to_peers + field when updating an existing comment. + """ + self.register_put_comment_response(self.existing_comment.attributes) + data = { + "anonymous_to_peers": True, + } + self.save_and_reserialize(data, self.existing_comment) + self.mock_update_comment.assert_called_once_with( + self.existing_comment.id, + "Original body", + str(self.course.id), + str(self.user.id), + False, # anonymous + True, # anonymous_to_peers + False, # endorsed + False, # closed + None, # editing_user_id + None, # edit_reason_code + None, # endorsement_user_id + ) + + @ddt.data("thread_id", "parent_id") + def test_update_non_updatable(self, field): + serializer = CommentSerializer( + self.existing_comment, + data={field: "different_value"}, + partial=True, + context=get_context(self.course, self.request), + ) + assert not serializer.is_valid() + assert serializer.errors == {field: ["This field is not allowed in an update."]} diff --git a/lms/djangoapps/discussion/rest_api/tests/test_tasks.py b/lms/djangoapps/discussion/rest_api/tests/test_tasks.py index 3a9eac32458d..cbe51b513b64 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_tasks.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_tasks.py @@ -58,10 +58,24 @@ def setUp(self): Setup test case """ super().setUp() - + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) # Creating a course self.course = CourseFactory.create() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread", + return_value=self.course.id + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment", + return_value=self.course.id + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) # Creating relative discussion and cohort settings CourseCohortsSettings.objects.create(course_id=str(self.course.id)) CourseDiscussionSettings.objects.create(course_id=str(self.course.id), _divided_discussions='[]') @@ -250,8 +264,23 @@ def setUp(self): super().setUp() httpretty.reset() httpretty.enable() + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) self.course = CourseFactory.create() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread", + return_value=self.course.id + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment", + return_value=self.course.id + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) self.user_1 = UserFactory.create() CourseEnrollment.enroll(self.user_1, self.course.id) self.user_2 = UserFactory.create() @@ -538,8 +567,23 @@ def setUp(self): super().setUp() httpretty.reset() httpretty.enable() + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) self.course = CourseFactory.create() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread", + return_value=self.course.id + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment", + return_value=self.course.id + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) self.user_1 = UserFactory.create() CourseEnrollment.enroll(self.user_1, self.course.id) self.user_2 = UserFactory.create() @@ -605,8 +649,23 @@ def setUp(self): super().setUp() httpretty.reset() httpretty.enable() + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) self.course = CourseFactory.create() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread", + return_value=self.course.id + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment", + return_value=self.course.id + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) self.user_1 = UserFactory.create() CourseEnrollment.enroll(self.user_1, self.course.id) self.user_2 = UserFactory.create() diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views.py b/lms/djangoapps/discussion/rest_api/tests/test_views.py index 283117000712..2393a70bd901 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views.py @@ -171,6 +171,9 @@ def setUp(self): self.user = UserFactory.create(password=self.TEST_PASSWORD) self.course = CourseFactory.create(org='a', course='b', run='c', start=datetime.now(UTC)) self.url = reverse("upload_file", kwargs={"course_id": str(self.course.id)}) + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) def user_login(self): """ @@ -301,6 +304,7 @@ def test_file_upload_with_no_data(self): @ddt.ddt @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_FORUM_V2": False}) class CommentViewSetListByUserTest( ForumsEnableMixin, CommentsServiceMockMixin, @@ -319,6 +323,9 @@ def setUp(self): httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) self.user = UserFactory.create(password=self.TEST_PASSWORD) self.register_get_user_response(self.user) @@ -500,6 +507,9 @@ class CourseViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): def setUp(self): super().setUp() self.url = reverse("discussion_course", kwargs={"course_id": str(self.course.id)}) + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) def test_404(self): response = self.client.get( @@ -561,6 +571,9 @@ def setUp(self): self.superuser_client = APIClient() self.retired_username = get_retired_username_by_username(self.user.username) self.url = reverse("retire_discussion_user") + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) def assert_response_correct(self, response, expected_status, expected_content): """ @@ -631,6 +644,9 @@ def setUp(self): self.worker_client = APIClient() self.new_username = "test_username_replacement" self.url = reverse("replace_discussion_username") + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) def assert_response_correct(self, response, expected_status, expected_content): """ @@ -733,6 +749,9 @@ def setUp(self): "courseware-3": {"discussion": 7, "question": 2}, } self.register_get_course_commentable_counts_response(self.course.id, self.thread_counts_map) + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) def create_course(self, blocks_count, module_store, topics): """ @@ -988,6 +1007,9 @@ def setUp(self) -> None: patcher.start() self.addCleanup(patcher.stop) self.url = reverse("course_topics_v3", kwargs={"course_id": str(self.course.id)}) + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) def test_basic(self): response = self.client.get(self.url) @@ -1024,6 +1046,9 @@ def setUp(self): super().setUp() self.author = UserFactory.create() self.url = reverse("thread-list") + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) def create_source_thread(self, overrides=None): """ @@ -1365,6 +1390,9 @@ class ThreadViewSetCreateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): def setUp(self): super().setUp() self.url = reverse("thread-list") + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) def test_basic(self): self.register_get_user_response(self.user) @@ -1437,6 +1465,14 @@ def setUp(self): self.unsupported_media_type = JSONParser.media_type super().setUp() self.url = reverse("thread-detail", kwargs={"thread_id": "test_thread"}) + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) def test_basic(self): self.register_get_user_response(self.user) @@ -1581,6 +1617,14 @@ def setUp(self): super().setUp() self.url = reverse("thread-detail", kwargs={"thread_id": "test_thread"}) self.thread_id = "test_thread" + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) def test_basic(self): self.register_get_user_response(self.user) @@ -1681,6 +1725,9 @@ def setUp(self): ] self.url = reverse("discussion_learner_threads", kwargs={'course_id': str(self.course.id)}) + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) def update_thread(self, thread): """ @@ -1923,6 +1970,14 @@ def setUp(self): self.url = reverse("comment-list") self.thread_id = "test_thread" self.storage = get_profile_image_storage() + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) def create_source_comment(self, overrides=None): """ @@ -2377,6 +2432,19 @@ def setUp(self): super().setUp() self.url = reverse("comment-detail", kwargs={"comment_id": "test_comment"}) self.comment_id = "test_comment" + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) def test_basic(self): self.register_get_user_response(self.user) @@ -2416,6 +2484,20 @@ class CommentViewSetCreateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): def setUp(self): super().setUp() self.url = reverse("comment-list") + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) def test_basic(self): self.register_get_user_response(self.user) @@ -2518,6 +2600,19 @@ def setUp(self): httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.register_get_user_response(self.user) self.url = reverse("comment-detail", kwargs={"comment_id": "test_comment"}) @@ -2640,6 +2735,19 @@ def setUp(self): super().setUp() self.url = reverse("thread-detail", kwargs={"thread_id": "test_thread"}) self.thread_id = "test_thread" + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) def test_basic(self): self.register_get_user_response(self.user) @@ -2693,6 +2801,19 @@ def setUp(self): self.url = reverse("comment-detail", kwargs={"comment_id": "test_comment"}) self.thread_id = "test_thread" self.comment_id = "test_comment" + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) def make_comment_data(self, comment_id, parent_id=None, children=[]): # pylint: disable=W0102 """ @@ -2838,6 +2959,9 @@ def setUp(self): self.path = reverse('discussion_course_settings', kwargs={'course_id': str(self.course.id)}) self.password = self.TEST_PASSWORD self.user = UserFactory(username='staff', password=self.password, is_staff=True) + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) def _get_oauth_headers(self, user): """Return the OAuth headers for testing OAuth authentication""" @@ -3127,6 +3251,9 @@ class CourseDiscussionRolesAPIViewTest(APITestCase, UrlResetMixin, ModuleStoreTe @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) self.course = CourseFactory.create( org="x", course="y", @@ -3318,6 +3445,9 @@ class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceM @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self) -> None: super().setUp() + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) self.course = CourseFactory.create() self.course_key = str(self.course.id) seed_permissions_roles(self.course.id) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py new file mode 100644 index 000000000000..75e939fb4625 --- /dev/null +++ b/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py @@ -0,0 +1,4174 @@ +""" +Tests for Discussion API views + +This module contains tests for the Discussion API views. These tests are +replicated from 'lms/djangoapps/discussion/rest_api/tests/test_views.py' +and are adapted to use the forum v2 native APIs instead of the v1 HTTP calls. +""" + +import json +import random +from datetime import datetime +from unittest import mock +from urllib.parse import parse_qs, urlencode, urlparse + +import ddt +import httpretty +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import override_settings +from django.urls import reverse +from edx_toggles.toggles.testutils import override_waffle_flag +from opaque_keys.edx.keys import CourseKey +from pytz import UTC +from rest_framework import status +from rest_framework.parsers import JSONParser +from rest_framework.test import APIClient, APITestCase + +from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE +from lms.djangoapps.discussion.rest_api.utils import get_usernames_from_search_string +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, + SharedModuleStoreTestCase, +) +from xmodule.modulestore.tests.factories import ( + CourseFactory, + BlockFactory, + check_mongo_calls, +) + +from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.course_modes.tests.factories import CourseModeFactory +from common.djangoapps.student.models import ( + get_retired_username_by_username, + CourseEnrollment, +) +from common.djangoapps.student.roles import ( + CourseInstructorRole, + CourseStaffRole, + GlobalStaff, +) +from common.djangoapps.student.tests.factories import ( + AdminFactory, + CourseEnrollmentFactory, + SuperuserFactory, + UserFactory, +) +from common.djangoapps.util.testing import PatchMediaTypeMixin, UrlResetMixin +from common.test.utils import disable_signal +from lms.djangoapps.discussion.django_comment_client.tests.utils import ( + ForumsEnableMixin, + config_course_discussions, + topic_name_to_id, +) +from lms.djangoapps.discussion.rest_api import api +from lms.djangoapps.discussion.rest_api.tests.utils_v2 import ( + CommentsServiceMockMixin, + ProfileImageTestMixin, + make_minimal_cs_comment, + make_minimal_cs_thread, + make_paginated_api_response, + parsed_body, +) +from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts +from openedx.core.djangoapps.discussions.config.waffle import ( + ENABLE_NEW_STRUCTURE_DISCUSSIONS, +) +from openedx.core.djangoapps.discussions.models import ( + DiscussionsConfiguration, + DiscussionTopicLink, + Provider, +) +from openedx.core.djangoapps.discussions.tasks import ( + update_discussions_settings_from_course_task, +) +from openedx.core.djangoapps.django_comment_common.models import ( + CourseDiscussionSettings, + Role, +) +from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles +from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user +from openedx.core.djangoapps.oauth_dispatch.tests.factories import ( + AccessTokenFactory, + ApplicationFactory, +) +from openedx.core.djangoapps.user_api.accounts.image_helpers import ( + get_profile_image_storage, +) +from openedx.core.djangoapps.user_api.models import ( + RetirementState, + UserRetirementStatus, +) + + +class DiscussionAPIViewTestMixin( + ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMixin +): + """ + Mixin for common code in tests of Discussion API views. This includes + creation of common structures (e.g. a course, user, and enrollment), logging + in the test client, utility functions, and a test case for unauthenticated + requests. Subclasses must set self.url in their setUp methods. + """ + + client_class = APIClient + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def setUp(self): + super().setUp() + self.maxDiff = None # pylint: disable=invalid-name + self.course = CourseFactory.create( + org="x", + course="y", + run="z", + start=datetime.now(UTC), + discussion_topics={"Test Topic": {"id": "test_topic"}}, + ) + self.password = "Password1234" + self.user = UserFactory.create(password=self.password) + # Ensure that parental controls don't apply to this user + self.user.profile.year_of_birth = 1970 + self.user.profile.save() + CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) + self.client.login(username=self.user.username, password=self.password) + mock.patch( + "lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled", + return_value=True, + ).start() + self.mock_get_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_thread" + ).start() + self.mock_update_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.update_thread" + ).start() + self.mock_get_parent_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_parent_comment" + ).start() + self.mock_update_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.update_comment" + ).start() + self.mock_create_parent_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.create_parent_comment" + ).start() + self.mock_create_child_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.create_child_comment" + ).start() + self.addCleanup(mock.patch.stopall) + + def assert_response_correct(self, response, expected_status, expected_content): + """ + Assert that the response has the given status code and parsed content + """ + assert response.status_code == expected_status + parsed_content = json.loads(response.content.decode("utf-8")) + assert parsed_content == expected_content + + def register_thread(self, overrides=None): + """ + Create cs_thread with minimal fields and register response + """ + cs_thread = make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "username": self.user.username, + "user_id": str(self.user.id), + "thread_type": "discussion", + "title": "Test Title", + "body": "Test body", + } + ) + cs_thread.update(overrides or {}) + self.register_get_thread_response(cs_thread) + self.register_put_thread_response(cs_thread) + + def register_comment(self, overrides=None): + """ + Create cs_comment with minimal fields and register response + """ + cs_comment = make_minimal_cs_comment( + { + "id": "test_comment", + "course_id": str(self.course.id), + "thread_id": "test_thread", + "username": self.user.username, + "user_id": str(self.user.id), + "body": "Original body", + } + ) + cs_comment.update(overrides or {}) + self.register_get_comment_response(cs_comment) + self.register_put_comment_response(cs_comment) + self.register_post_comment_response(cs_comment, thread_id="test_thread") + + def test_not_authenticated(self): + self.client.logout() + response = self.client.get(self.url) + self.assert_response_correct( + response, + 401, + {"developer_message": "Authentication credentials were not provided."}, + ) + + def test_inactive(self): + self.user.is_active = False + self.test_basic() + + +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class UploadFileViewTest( + ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTestCase +): + """ + Tests for UploadFileView. + """ + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def setUp(self): + super().setUp() + self.valid_file = { + "uploaded_file": SimpleUploadedFile( + "test.jpg", + b"test content", + content_type="image/jpeg", + ), + } + self.user = UserFactory.create(password=self.TEST_PASSWORD) + self.course = CourseFactory.create( + org="a", course="b", run="c", start=datetime.now(UTC) + ) + self.url = reverse("upload_file", kwargs={"course_id": str(self.course.id)}) + + mock.patch( + "lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled", + return_value=True, + ).start() + self.mock_get_user = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.get_user" + ).start() + self.addCleanup(mock.patch.stopall) + + def user_login(self): + """ + Authenticates the test client with the example user. + """ + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) + + def enroll_user_in_course(self): + """ + Makes the example user enrolled to the course. + """ + CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) + + def assert_upload_success(self, response): + """ + Asserts that the upload response was successful and returned the + expected contents. + """ + assert response.status_code == status.HTTP_200_OK + assert response.content_type == "application/json" + response_data = json.loads(response.content) + assert "location" in response_data + + def test_file_upload_by_unauthenticated_user(self): + """ + Should fail if an unauthenticated user tries to upload a file. + """ + response = self.client.post(self.url, self.valid_file) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_file_upload_by_unauthorized_user(self): + """ + Should fail if the user is not either staff or a student + enrolled in the course. + """ + self.user_login() + response = self.client.post(self.url, self.valid_file) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_file_upload_by_enrolled_user(self): + """ + Should succeed when a valid file is uploaded by an authenticated + user who's enrolled in the course. + """ + self.user_login() + self.enroll_user_in_course() + response = self.client.post(self.url, self.valid_file) + self.assert_upload_success(response) + + def test_file_upload_by_global_staff(self): + """ + Should succeed when a valid file is uploaded by a global staff + member. + """ + self.user_login() + GlobalStaff().add_users(self.user) + response = self.client.post(self.url, self.valid_file) + self.assert_upload_success(response) + + def test_file_upload_by_instructor(self): + """ + Should succeed when a valid file is uploaded by a course instructor. + """ + self.user_login() + CourseInstructorRole(course_key=self.course.id).add_users(self.user) + response = self.client.post(self.url, self.valid_file) + self.assert_upload_success(response) + + def test_file_upload_by_course_staff(self): + """ + Should succeed when a valid file is uploaded by a course staff + member. + """ + self.user_login() + CourseStaffRole(course_key=self.course.id).add_users(self.user) + response = self.client.post(self.url, self.valid_file) + self.assert_upload_success(response) + + def test_file_upload_with_thread_key(self): + """ + Should contain the given thread_key in the uploaded file name. + """ + self.user_login() + self.enroll_user_in_course() + response = self.client.post( + self.url, + { + **self.valid_file, + "thread_key": "somethread", + }, + ) + response_data = json.loads(response.content) + assert "/somethread/" in response_data["location"] + + def test_file_upload_with_invalid_file(self): + """ + Should fail if the uploaded file format is not allowed. + """ + self.user_login() + self.enroll_user_in_course() + invalid_file = { + "uploaded_file": SimpleUploadedFile( + "test.txt", + b"test content", + content_type="text/plain", + ), + } + response = self.client.post(self.url, invalid_file) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_file_upload_with_invalid_course_id(self): + """ + Should fail if the course does not exist. + """ + self.user_login() + self.enroll_user_in_course() + url = reverse("upload_file", kwargs={"course_id": "d/e/f"}) + response = self.client.post(url, self.valid_file) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_file_upload_with_no_data(self): + """ + Should fail when the user sends a request missing an + `uploaded_file` field. + """ + self.user_login() + self.enroll_user_in_course() + response = self.client.post(self.url, data={}) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@ddt.ddt +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class CommentViewSetListByUserTest( + ForumsEnableMixin, + CommentsServiceMockMixin, + UrlResetMixin, + ModuleStoreTestCase, +): + """ + Common test cases for views retrieving user-published content. + """ + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def setUp(self): + super().setUp() + + httpretty.reset() + httpretty.enable() + self.addCleanup(httpretty.reset) + self.addCleanup(httpretty.disable) + + mock.patch( + "lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled", + return_value=True, + ).start() + self.mock_get_user_threads = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.get_user_threads" + ).start() + self.mock_get_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_thread" + ).start() + self.mock_get_user = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.get_user" + ).start() + self.addCleanup(mock.patch.stopall) + + self.user = UserFactory.create(password=self.TEST_PASSWORD) + self.register_get_user_response(self.user) + + self.other_user = UserFactory.create(password=self.TEST_PASSWORD) + self.register_get_user_response(self.other_user) + + self.course = CourseFactory.create( + org="a", course="b", run="c", start=datetime.now(UTC) + ) + CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) + + self.url = self.build_url(self.user.username, self.course.id) + + def register_mock_endpoints(self): + """ + Register cs_comments_service mocks for sample threads and comments. + """ + self.register_get_threads_response( + threads=[ + make_minimal_cs_thread( + { + "id": f"test_thread_{index}", + "course_id": str(self.course.id), + "commentable_id": f"test_topic_{index}", + "username": self.user.username, + "user_id": str(self.user.id), + "thread_type": "discussion", + "title": f"Test Title #{index}", + "body": f"Test body #{index}", + } + ) + for index in range(30) + ], + page=1, + num_pages=1, + ) + self.register_get_comments_response( + comments=[ + make_minimal_cs_comment( + { + "id": f"test_comment_{index}", + "thread_id": "test_thread", + "user_id": str(self.user.id), + "username": self.user.username, + "created_at": "2015-05-11T00:00:00Z", + "updated_at": "2015-05-11T11:11:11Z", + "body": f"Test body #{index}", + "votes": {"up_count": 4}, + } + ) + for index in range(30) + ], + page=1, + num_pages=1, + ) + + def build_url(self, username, course_id, **kwargs): + """ + Builds an URL to access content from an user on a specific course. + """ + base = reverse("comment-list") + query = urlencode( + { + "username": username, + "course_id": str(course_id), + **kwargs, + } + ) + return f"{base}?{query}" + + def assert_successful_response(self, response): + """ + Check that the response was successful and contains the expected fields. + """ + assert response.status_code == status.HTTP_200_OK + response_data = json.loads(response.content) + assert "results" in response_data + assert "pagination" in response_data + + def test_request_by_unauthenticated_user(self): + """ + Unauthenticated users are not allowed to request users content. + """ + self.register_mock_endpoints() + response = self.client.get(self.url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_request_by_unauthorized_user(self): + """ + Users are not allowed to request content from courses in which + they're not either enrolled or staff members. + """ + self.register_mock_endpoints() + self.client.login( + username=self.other_user.username, password=self.TEST_PASSWORD + ) + response = self.client.get(self.url) + assert response.status_code == status.HTTP_404_NOT_FOUND + assert json.loads(response.content)["developer_message"] == "Course not found." + + def test_request_by_enrolled_user(self): + """ + Users that are enrolled in a course are allowed to get users' + comments in that course. + """ + self.register_mock_endpoints() + self.client.login( + username=self.other_user.username, password=self.TEST_PASSWORD + ) + CourseEnrollmentFactory.create(user=self.other_user, course_id=self.course.id) + self.assert_successful_response(self.client.get(self.url)) + + def test_request_by_global_staff(self): + """ + Staff users are allowed to get any user's comments. + """ + self.register_mock_endpoints() + self.client.login( + username=self.other_user.username, password=self.TEST_PASSWORD + ) + GlobalStaff().add_users(self.other_user) + self.assert_successful_response(self.client.get(self.url)) + + @ddt.data(CourseStaffRole, CourseInstructorRole) + def test_request_by_course_staff(self, role): + """ + Course staff users are allowed to get an user's comments in that + course. + """ + self.register_mock_endpoints() + self.client.login( + username=self.other_user.username, password=self.TEST_PASSWORD + ) + role(course_key=self.course.id).add_users(self.other_user) + self.assert_successful_response(self.client.get(self.url)) + + def test_request_with_non_existent_user(self): + """ + Requests for users that don't exist result in a 404 response. + """ + self.register_mock_endpoints() + self.client.login( + username=self.other_user.username, password=self.TEST_PASSWORD + ) + GlobalStaff().add_users(self.other_user) + url = self.build_url("non_existent", self.course.id) + response = self.client.get(url) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_request_with_non_existent_course(self): + """ + Requests for courses that don't exist result in a 404 response. + """ + self.register_mock_endpoints() + self.client.login( + username=self.other_user.username, password=self.TEST_PASSWORD + ) + GlobalStaff().add_users(self.other_user) + url = self.build_url(self.user.username, "course-v1:x+y+z") + response = self.client.get(url) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_request_with_invalid_course_id(self): + """ + Requests with invalid course ID should fail form validation. + """ + self.register_mock_endpoints() + self.client.login( + username=self.other_user.username, password=self.TEST_PASSWORD + ) + GlobalStaff().add_users(self.other_user) + url = self.build_url(self.user.username, "an invalid course") + response = self.client.get(url) + assert response.status_code == status.HTTP_400_BAD_REQUEST + parsed_response = json.loads(response.content) + assert ( + parsed_response["field_errors"]["course_id"]["developer_message"] + == "'an invalid course' is not a valid course id" + ) + + def test_request_with_empty_results_page(self): + """ + Requests for pages that exceed the available number of pages + result in a 404 response. + """ + self.register_get_threads_response(threads=[], page=1, num_pages=1) + self.register_get_comments_response(comments=[], page=1, num_pages=1) + + self.client.login( + username=self.other_user.username, password=self.TEST_PASSWORD + ) + GlobalStaff().add_users(self.other_user) + url = self.build_url(self.user.username, self.course.id, page=2) + response = self.client.get(url) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +@override_settings( + DISCUSSION_MODERATION_EDIT_REASON_CODES={"test-edit-reason": "Test Edit Reason"} +) +@override_settings( + DISCUSSION_MODERATION_CLOSE_REASON_CODES={"test-close-reason": "Test Close Reason"} +) +class CourseViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): + """Tests for CourseView""" + + def setUp(self): + super().setUp() + self.url = reverse( + "discussion_course", kwargs={"course_id": str(self.course.id)} + ) + mock.patch( + "lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled", + return_value=True, + ).start() + self.mock_get_user = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.get_user" + ).start() + self.addCleanup(mock.patch.stopall) + + def test_404(self): + response = self.client.get( + reverse("course_topics", kwargs={"course_id": "non/existent/course"}) + ) + self.assert_response_correct( + response, 404, {"developer_message": "Course not found."} + ) + + def test_basic(self): + response = self.client.get(self.url) + self.assert_response_correct( + response, + 200, + { + "id": str(self.course.id), + "is_posting_enabled": True, + "blackouts": [], + "thread_list_url": "http://testserver/api/discussion/v1/threads/?course_id=course-v1%3Ax%2By%2Bz", + "following_thread_list_url": ( + "http://testserver/api/discussion/v1/threads/?course_id=course-v1%3Ax%2By%2Bz&following=True" + ), + "topics_url": "http://testserver/api/discussion/v1/course_topics/course-v1:x+y+z", + "enable_in_context": True, + "group_at_subsection": False, + "provider": "legacy", + "allow_anonymous": True, + "allow_anonymous_to_peers": False, + "has_moderation_privileges": False, + "is_course_admin": False, + "is_course_staff": False, + "is_group_ta": False, + "is_user_admin": False, + "user_roles": ["Student"], + "edit_reasons": [ + {"code": "test-edit-reason", "label": "Test Edit Reason"} + ], + "post_close_reasons": [ + {"code": "test-close-reason", "label": "Test Close Reason"} + ], + "show_discussions": True, + }, + ) + + +@httpretty.activate +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class RetireViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): + """Tests for CourseView""" + + def setUp(self): + super().setUp() + RetirementState.objects.create(state_name="PENDING", state_execution_order=1) + self.retire_forums_state = RetirementState.objects.create( + state_name="RETIRE_FORUMS", state_execution_order=11 + ) + + self.retirement = UserRetirementStatus.create_retirement(self.user) + self.retirement.current_state = self.retire_forums_state + self.retirement.save() + + self.superuser = SuperuserFactory() + self.superuser_client = APIClient() + self.retired_username = get_retired_username_by_username(self.user.username) + self.url = reverse("retire_discussion_user") + + mock.patch( + "lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled", + return_value=True, + ).start() + self.mock_get_user = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.get_user" + ).start() + self.mock_retire_user = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.retire_user" + ).start() + self.addCleanup(mock.patch.stopall) + + def assert_response_correct(self, response, expected_status, expected_content): + """ + Assert that the response has the given status code and content + """ + assert response.status_code == expected_status + + if expected_content: + assert response.content.decode("utf-8") == expected_content + + def build_jwt_headers(self, user): + """ + Helper function for creating headers for the JWT authentication. + """ + token = create_jwt_for_user(user) + headers = {"HTTP_AUTHORIZATION": "JWT " + token} + return headers + + def perform_retirement(self): + """ + Helper method to perform the retirement action and return the response. + """ + self.register_get_user_retire_response(self.user) + headers = self.build_jwt_headers(self.superuser) + data = {"username": self.user.username} + response = self.superuser_client.post(self.url, data, **headers) + + self.mock_retire_user.assert_called_once_with( + user_id=str(self.user.id), + retired_username=get_retired_username_by_username(self.user.username), + course_id=None, + ) + + return response + + # @mock.patch('openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.retire_user') + def test_basic(self): + """ + Check successful retirement case + """ + response = self.perform_retirement() + self.assert_response_correct(response, 204, b"") + + # @mock.patch('openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.retire_user') + def test_inactive(self): + """ + Test retiring an inactive user + """ + self.user.is_active = False + response = self.perform_retirement() + self.assert_response_correct(response, 204, b"") + + def test_downstream_forums_error(self): + """ + Check that we bubble up errors from the comments service + """ + self.mock_retire_user.side_effect = Exception("Server error") + + headers = self.build_jwt_headers(self.superuser) + data = {"username": self.user.username} + response = self.superuser_client.post(self.url, data, **headers) + + # Verify that the response contains the expected error status and message + self.assert_response_correct(response, 500, '"Server error"') + + def test_nonexistent_user(self): + """ + Check that we handle unknown users appropriately + """ + nonexistent_username = "nonexistent user" + self.retired_username = get_retired_username_by_username(nonexistent_username) + data = {"username": nonexistent_username} + headers = self.build_jwt_headers(self.superuser) + response = self.superuser_client.post(self.url, data, **headers) + self.assert_response_correct(response, 404, None) + + def test_not_authenticated(self): + """ + Override the parent implementation of this, we JWT auth for this API + """ + pass # lint-amnesty, pylint: disable=unnecessary-pass + + +@ddt.ddt +@httpretty.activate +@mock.patch( + "django.conf.settings.USERNAME_REPLACEMENT_WORKER", + "test_replace_username_service_worker", +) +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class ReplaceUsernamesViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): + """Tests for ReplaceUsernamesView""" + + def setUp(self): + super().setUp() + self.worker = UserFactory() + self.worker.username = "test_replace_username_service_worker" + self.worker_client = APIClient() + self.new_username = "test_username_replacement" + self.url = reverse("replace_discussion_username") + + mock.patch( + "lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled", + return_value=True, + ).start() + self.mock_get_user = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.get_user" + ).start() + self.mock_update_username = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.update_username" + ).start() + self.addCleanup(mock.patch.stopall) + + def assert_response_correct(self, response, expected_status, expected_content): + """ + Assert that the response has the given status code and content + """ + assert response.status_code == expected_status + + if expected_content: + assert str(response.content) == expected_content + + def build_jwt_headers(self, user): + """ + Helper function for creating headers for the JWT authentication. + """ + token = create_jwt_for_user(user) + headers = {"HTTP_AUTHORIZATION": "JWT " + token} + return headers + + def call_api(self, user, client, data): + """Helper function to call API with data""" + data = json.dumps(data) + headers = self.build_jwt_headers(user) + return client.post(self.url, data, content_type="application/json", **headers) + + @ddt.data([{}, {}], {}, [{"test_key": "test_value", "test_key_2": "test_value_2"}]) + def test_bad_schema(self, mapping_data): + """Verify the endpoint rejects bad data schema""" + data = {"username_mappings": mapping_data} + response = self.call_api(self.worker, self.worker_client, data) + assert response.status_code == 400 + + def test_auth(self): + """Verify the endpoint only works with the service worker""" + data = { + "username_mappings": [ + {"test_username_1": "test_new_username_1"}, + {"test_username_2": "test_new_username_2"}, + ] + } + + # Test unauthenticated + response = self.client.post(self.url, data) + assert response.status_code == 403 + + # Test non-service worker + random_user = UserFactory() + response = self.call_api(random_user, APIClient(), data) + assert response.status_code == 403 + + # Test service worker + response = self.call_api(self.worker, self.worker_client, data) + assert response.status_code == 200 + + def test_basic(self): + """Check successful replacement""" + data = { + "username_mappings": [ + {self.user.username: self.new_username}, + ] + } + expected_response = { + "failed_replacements": [], + "successful_replacements": data["username_mappings"], + } + self.register_get_username_replacement_response(self.user) + response = self.call_api(self.worker, self.worker_client, data) + assert response.status_code == 200 + assert response.data == expected_response + + def test_not_authenticated(self): + """ + Override the parent implementation of this, we JWT auth for this API + """ + pass # lint-amnesty, pylint: disable=unnecessary-pass + + +@ddt.ddt +@mock.patch("lms.djangoapps.discussion.rest_api.api._get_course", mock.Mock()) +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +@override_waffle_flag(ENABLE_NEW_STRUCTURE_DISCUSSIONS, True) +class CourseTopicsViewV3Test( + DiscussionAPIViewTestMixin, CommentsServiceMockMixin, ModuleStoreTestCase +): + """ + Tests for CourseTopicsViewV3 + """ + + def setUp(self) -> None: + super().setUp() + self.password = self.TEST_PASSWORD + self.user = UserFactory.create(password=self.password) + self.client.login(username=self.user.username, password=self.password) + self.staff = AdminFactory.create() + self.course = CourseFactory.create( + start=datetime(2020, 1, 1), + end=datetime(2028, 1, 1), + enrollment_start=datetime(2020, 1, 1), + enrollment_end=datetime(2028, 1, 1), + discussion_topics={ + "Course Wide Topic": { + "id": "course-wide-topic", + "usage_key": None, + } + }, + ) + self.chapter = BlockFactory.create( + parent_location=self.course.location, + category="chapter", + display_name="Week 1", + start=datetime(2015, 3, 1, tzinfo=UTC), + ) + self.sequential = BlockFactory.create( + parent_location=self.chapter.location, + category="sequential", + display_name="Lesson 1", + start=datetime(2015, 3, 1, tzinfo=UTC), + ) + self.verticals = [ + BlockFactory.create( + parent_location=self.sequential.location, + category="vertical", + display_name="vertical", + start=datetime(2015, 4, 1, tzinfo=UTC), + ) + ] + course_key = self.course.id + self.config = DiscussionsConfiguration.objects.create( + context_key=course_key, provider_type=Provider.OPEN_EDX + ) + topic_links = [] + update_discussions_settings_from_course_task(str(course_key)) + topic_id_query = DiscussionTopicLink.objects.filter( + context_key=course_key + ).values_list( + "external_id", + flat=True, + ) + topic_ids = list(topic_id_query.order_by("ordering")) + DiscussionTopicLink.objects.bulk_create(topic_links) + self.topic_stats = { + **{ + topic_id: dict( + discussion=random.randint(0, 10), question=random.randint(0, 10) + ) + for topic_id in set(topic_ids) + }, + topic_ids[0]: dict(discussion=0, question=0), + } + mock.patch( + "lms.djangoapps.discussion.rest_api.api.get_course_commentable_counts", + mock.Mock(return_value=self.topic_stats), + ).start() + self.url = reverse( + "course_topics_v3", kwargs={"course_id": str(self.course.id)} + ) + mock.patch( + "lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled", + return_value=True, + ).start() + self.mock_get_user = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.get_user" + ).start() + self.addCleanup(mock.patch.stopall) + + def test_basic(self): + response = self.client.get(self.url) + data = json.loads(response.content.decode()) + expected_non_courseware_keys = [ + "id", + "usage_key", + "name", + "thread_counts", + "enabled_in_context", + "courseware", + ] + expected_courseware_keys = [ + "id", + "block_id", + "lms_web_url", + "legacy_web_url", + "student_view_url", + "type", + "display_name", + "children", + "courseware", + ] + assert response.status_code == 200 + assert len(data) == 2 + non_courseware_topic_keys = list(data[0].keys()) + assert non_courseware_topic_keys == expected_non_courseware_keys + courseware_topic_keys = list(data[1].keys()) + assert courseware_topic_keys == expected_courseware_keys + expected_courseware_keys.remove("courseware") + sequential_keys = list(data[1]["children"][0].keys()) + assert sequential_keys == (expected_courseware_keys + ["thread_counts"]) + expected_non_courseware_keys.remove("courseware") + vertical_keys = list(data[1]["children"][0]["children"][0].keys()) + assert vertical_keys == expected_non_courseware_keys + + +@ddt.ddt +@httpretty.activate +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class ThreadViewSetListTest( + DiscussionAPIViewTestMixin, ModuleStoreTestCase, ProfileImageTestMixin +): + """Tests for ThreadViewSet list""" + + def setUp(self): + super().setUp() + self.author = UserFactory.create() + self.url = reverse("thread-list") + mock.patch( + "lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled", + return_value=True, + ).start() + self.mock_get_user = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.get_user" + ).start() + self.mock_get_user_threads = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.get_user_threads" + ).start() + self.mock_search_threads = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.search_threads" + ).start() + self.addCleanup(mock.patch.stopall) + + def create_source_thread(self, overrides=None): + """ + Create a sample source cs_thread + """ + thread = make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(self.user.id), + "username": self.user.username, + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + "title": "Test Title", + "body": "Test body", + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + } + ) + + thread.update(overrides or {}) + return thread + + def test_course_id_missing(self): + response = self.client.get(self.url) + self.assert_response_correct( + response, + 400, + { + "field_errors": { + "course_id": {"developer_message": "This field is required."} + } + }, + ) + + def test_404(self): + response = self.client.get(self.url, {"course_id": "non/existent/course"}) + self.assert_response_correct( + response, 404, {"developer_message": "Course not found."} + ) + + def test_basic(self): + self.register_get_user_response(self.user, upvoted_ids=["test_thread"]) + source_threads = [ + self.create_source_thread( + {"user_id": str(self.author.id), "username": self.author.username} + ) + ] + expected_threads = [ + self.expected_thread_data( + { + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + "vote_count": 4, + "comment_count": 6, + "can_delete": False, + "unread_comment_count": 3, + "voted": True, + "author": self.author.username, + "editable_fields": [ + "abuse_flagged", + "copy_link", + "following", + "read", + "voted", + ], + "abuse_flagged_count": None, + } + ) + ] + + # Mock the response from get_user_threads + self.mock_get_user_threads.return_value = { + "collection": source_threads, + "page": 1, + "num_pages": 2, + "thread_count": len(source_threads), + "corrected_text": None, + } + + response = self.client.get( + self.url, {"course_id": str(self.course.id), "following": ""} + ) + expected_response = make_paginated_api_response( + results=expected_threads, + count=1, + num_pages=2, + next_link="http://testserver/api/discussion/v1/threads/?course_id=course-v1%3Ax%2By%2Bz&following=&page=2", + previous_link=None, + ) + expected_response.update({"text_search_rewrite": None}) + self.assert_response_correct(response, 200, expected_response) + + # Verify the query parameters + self.mock_get_user_threads.assert_called_once_with( + user_id=str(self.user.id), + course_id=str(self.course.id), + sort_key="activity", + page=1, + per_page=10, + ) + + @ddt.data("unread", "unanswered", "unresponded") + def test_view_query(self, query): + threads = [make_minimal_cs_thread()] + self.register_get_user_response(self.user) + self.register_get_threads_response( + threads, page=1, num_pages=1, overrides={"corrected_text": None} + ) + + self.client.get( + self.url, + { + "course_id": str(self.course.id), + "view": query, + }, + ) + self.mock_get_user_threads.assert_called_once_with( + user_id=str(self.user.id), + course_id=str(self.course.id), + sort_key="activity", + page=1, + per_page=10, + **{query: "true"}, + ) + + def test_pagination(self): + self.register_get_user_response(self.user) + self.register_get_threads_response( + [], page=1, num_pages=1, overrides={"corrected_text": None} + ) + response = self.client.get( + self.url, {"course_id": str(self.course.id), "page": "18", "page_size": "4"} + ) + + self.assert_response_correct( + response, + 404, + {"developer_message": "Page not found (No results on this page)."}, + ) + + # Verify the query parameters + self.mock_get_user_threads.assert_called_once_with( + user_id=str(self.user.id), + course_id=str(self.course.id), + sort_key="activity", + page=18, + per_page=4, + ) + + def test_text_search(self): + self.register_get_user_response(self.user) + self.register_get_threads_search_response([], None, num_pages=0) + response = self.client.get( + self.url, + {"course_id": str(self.course.id), "text_search": "test search string"}, + ) + + expected_response = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_response.update({"text_search_rewrite": None}) + self.assert_response_correct(response, 200, expected_response) + self.mock_search_threads.assert_called_once_with( + user_id=str(self.user.id), + course_id=str(self.course.id), + sort_key="activity", + page=1, + per_page=10, + text="test search string", + ) + + @ddt.data(True, "true", "1") + def test_following_true(self, following): + self.register_get_user_response(self.user) + self.register_subscribed_threads_response(self.user, [], page=1, num_pages=0) + response = self.client.get( + self.url, + { + "course_id": str(self.course.id), + "following": following, + }, + ) + expected_response = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_response.update({"text_search_rewrite": None}) + self.assert_response_correct(response, 200, expected_response) + + self.mock_get_user_threads.assert_called_once_with( + course_id=str(self.course.id), + user_id=str(self.user.id), + sort_key="activity", + page=1, + per_page=10, + group_id=None, + text="", + author_id=None, + flagged=None, + thread_type="", + count_flagged=None, + ) + + @ddt.data(False, "false", "0") + def test_following_false(self, following): + response = self.client.get( + self.url, + { + "course_id": str(self.course.id), + "following": following, + }, + ) + self.assert_response_correct( + response, + 400, + { + "field_errors": { + "following": { + "developer_message": "The value of the 'following' parameter must be true." + } + } + }, + ) + + def test_following_error(self): + response = self.client.get( + self.url, + { + "course_id": str(self.course.id), + "following": "invalid-boolean", + }, + ) + self.assert_response_correct( + response, + 400, + { + "field_errors": { + "following": {"developer_message": "Invalid Boolean Value."} + } + }, + ) + + @ddt.data( + ("last_activity_at", "activity"), + ("comment_count", "comments"), + ("vote_count", "votes"), + ) + @ddt.unpack + def test_order_by(self, http_query, cc_query): + """ + Tests the order_by parameter + + Arguments: + http_query (str): Query string sent in the http request + cc_query (str): Query string used for the comments client service + """ + threads = [make_minimal_cs_thread()] + self.register_get_user_response(self.user) + self.register_get_threads_response(threads, page=1, num_pages=1) + self.client.get( + self.url, + { + "course_id": str(self.course.id), + "order_by": http_query, + }, + ) + self.mock_get_user_threads.assert_called_once_with( + user_id=str(self.user.id), + course_id=str(self.course.id), + sort_key=cc_query, + page=1, + per_page=10, + ) + + def test_order_direction(self): + """ + Test order direction, of which "desc" is the only valid option. The + option actually just gets swallowed, so it doesn't affect the params. + """ + threads = [make_minimal_cs_thread()] + self.register_get_user_response(self.user) + self.register_get_threads_response(threads, page=1, num_pages=1) + self.client.get( + self.url, + { + "course_id": str(self.course.id), + "order_direction": "desc", + }, + ) + self.mock_get_user_threads.assert_called_once_with( + user_id=str(self.user.id), + course_id=str(self.course.id), + sort_key="activity", + page=1, + per_page=10, + ) + + def test_mutually_exclusive(self): + """ + Tests GET thread_list api does not allow filtering on mutually exclusive parameters + """ + self.register_get_user_response(self.user) + self.mock_search_threads.side_effect = ValueError( + "The following query parameters are mutually exclusive: topic_id, text_search, following" + ) + response = self.client.get( + self.url, + { + "course_id": str(self.course.id), + "text_search": "test search string", + "topic_id": "topic1, topic2", + }, + ) + self.assert_response_correct( + response, + 400, + { + "developer_message": "The following query parameters are mutually exclusive: topic_id, " + "text_search, following" + }, + ) + + def test_profile_image_requested_field(self): + """ + Tests thread has user profile image details if called in requested_fields + """ + user_2 = UserFactory.create(password=self.password) + # Ensure that parental controls don't apply to this user + user_2.profile.year_of_birth = 1970 + user_2.profile.save() + source_threads = [ + self.create_source_thread(), + self.create_source_thread( + {"user_id": str(user_2.id), "username": user_2.username} + ), + ] + + self.register_get_user_response(self.user, upvoted_ids=["test_thread"]) + self.register_get_threads_response(source_threads, page=1, num_pages=1) + self.create_profile_image(self.user, get_profile_image_storage()) + self.create_profile_image(user_2, get_profile_image_storage()) + + response = self.client.get( + self.url, + {"course_id": str(self.course.id), "requested_fields": "profile_image"}, + ) + assert response.status_code == 200 + response_threads = json.loads(response.content.decode("utf-8"))["results"] + + for response_thread in response_threads: + expected_profile_data = self.get_expected_user_profile( + response_thread["author"] + ) + response_users = response_thread["users"] + assert expected_profile_data == response_users[response_thread["author"]] + + def test_profile_image_requested_field_anonymous_user(self): + """ + Tests profile_image in requested_fields for thread created with anonymous user + """ + source_threads = [ + self.create_source_thread( + { + "user_id": None, + "username": None, + "anonymous": True, + "anonymous_to_peers": True, + } + ), + ] + + self.register_get_user_response(self.user, upvoted_ids=["test_thread"]) + self.register_get_threads_response(source_threads, page=1, num_pages=1) + + response = self.client.get( + self.url, + {"course_id": str(self.course.id), "requested_fields": "profile_image"}, + ) + assert response.status_code == 200 + response_thread = json.loads(response.content.decode("utf-8"))["results"][0] + assert response_thread["author"] is None + assert {} == response_thread["users"] + + +@httpretty.activate +@disable_signal(api, "thread_created") +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class ThreadViewSetCreateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): + """Tests for ThreadViewSet create""" + + def setUp(self): + super().setUp() + self.url = reverse("thread-list") + mock.patch( + "lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled", + return_value=True, + ).start() + self.mock_get_user = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.get_user" + ).start() + self.mock_create_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.create_thread" + ).start() + self.addCleanup(mock.patch.stopall) + + def test_basic(self): + self.register_get_user_response(self.user) + cs_thread = make_minimal_cs_thread( + { + "id": "test_thread", + "username": self.user.username, + "read": True, + } + ) + self.register_post_thread_response(cs_thread) + request_data = { + "course_id": str(self.course.id), + "topic_id": "test_topic", + "type": "discussion", + "title": "Test Title", + "raw_body": "# Test \n This is a very long body but will not be truncated for the preview.", + } + self.client.post( + self.url, json.dumps(request_data), content_type="application/json" + ) + self.mock_create_thread.assert_called_once_with( + title="Test Title", + body="# Test \n This is a very long body but will not be truncated for the preview.", + course_id=str(self.course.id), + user_id=str(self.user.id), + anonymous=False, + anonymous_to_peers=False, + commentable_id="test_topic", + thread_type="discussion", + group_id=None, + context=None, + ) + + def test_error(self): + request_data = { + "topic_id": "dummy", + "type": "discussion", + "title": "dummy", + "raw_body": "dummy", + } + response = self.client.post( + self.url, json.dumps(request_data), content_type="application/json" + ) + expected_response_data = { + "field_errors": { + "course_id": {"developer_message": "This field is required."} + } + } + assert response.status_code == 400 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == expected_response_data + + +@ddt.ddt +@httpretty.activate +@disable_signal(api, "thread_edited") +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class ThreadViewSetPartialUpdateTest( + DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin +): + """Tests for ThreadViewSet partial_update""" + + def setUp(self): + self.unsupported_media_type = JSONParser.media_type + super().setUp() + self.url = reverse("thread-detail", kwargs={"thread_id": "test_thread"}) + from openedx.core.djangoapps.django_comment_common.comment_client.thread import ( + Thread, + ) + + self.existing_thread = Thread( + **make_minimal_cs_thread( + { + "id": "existing_thread", + "course_id": str(self.course.id), + "commentable_id": "original_topic", + "thread_type": "discussion", + "title": "Original Title", + "body": "Original body", + "user_id": str(self.user.id), + "username": self.user.username, + "read": "False", + "endorsed": "False", + } + ) + ) + mock.patch( + "lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled", + return_value=True, + ).start() + self.mock_get_user = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.get_user" + ).start() + self.mock_get_course_id_by_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ).start() + self.mock_get_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_thread" + ).start() + self.mock_update_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.update_thread" + ).start() + self.mock_update_thread_flag = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.update_thread_flag" + ).start() + self.mock_update_thread_flag_in_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.comment.forum_api.update_thread_flag" + ).start() + self.mock_mark_thread_as_read = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.mark_thread_as_read" + ).start() + self.mock_update_comment_flag = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.comment.forum_api.update_comment_flag", + return_value=str(self.course.id), + ).start() + self.addCleanup(mock.patch.stopall) + + def test_basic(self): + self.register_get_user_response(self.user) + self.register_thread( + { + "id": "existing_thread", # Ensure the correct thread ID is used + "title": "Edited Title", # Ensure the correct title is used + "topic_id": "edited_topic", # Ensure the correct topic is used + "thread_type": "question", # Ensure the correct thread type is used + "created_at": "Test Created Date", + "updated_at": "Test Updated Date", + "read": True, + "resp_total": 2, + } + ) + request_data = { + "raw_body": "Edited body", + "topic_id": "edited_topic", # Ensure the correct topic is used in the request + } + self.request_patch(request_data) + self.mock_update_thread.assert_called_once_with( + thread_id="existing_thread", # Use the correct thread ID + title="Edited Title", # Use the correct title + body="Edited body", + course_id=str(self.course.id), + anonymous=False, # anonymous + anonymous_to_peers=False, # anonymous_to_peers + closed=False, # closed + commentable_id="edited_topic", # Use the correct topic + user_id=str(self.user.id), + editing_user_id=str(self.user.id), # editing_user_id + pinned=False, # pinned + thread_type="question", # Use the correct thread type + course_key=str(self.course.id), + ) + + def test_error(self): + self.register_get_user_response(self.user) + self.register_thread() + request_data = {"title": ""} + response = self.request_patch(request_data) + expected_response_data = { + "field_errors": { + "title": {"developer_message": "This field may not be blank."} + } + } + assert response.status_code == 400 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == expected_response_data + + @ddt.data( + ("abuse_flagged", True), + ("abuse_flagged", False), + ) + @ddt.unpack + def test_closed_thread(self, field, value): + self.register_get_user_response(self.user) + self.register_thread({"closed": True, "read": True}) + self.register_flag_response("thread", "test_thread") + request_data = {field: value} + response = self.request_patch(request_data) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == self.expected_thread_data( + { + "read": True, + "closed": True, + "abuse_flagged": value, + "editable_fields": ["abuse_flagged", "copy_link", "read"], + "comment_count": 1, + "unread_comment_count": 0, + } + ) + + @ddt.data( + ("raw_body", "Edited body"), + ("voted", True), + ("following", True), + ) + @ddt.unpack + def test_closed_thread_error(self, field, value): + self.register_get_user_response(self.user) + self.register_thread({"closed": True}) + self.register_flag_response("thread", "test_thread") + request_data = {field: value} + response = self.request_patch(request_data) + assert response.status_code == 400 + + def test_patch_read_owner_user(self): + self.register_get_user_response(self.user) + self.register_thread({"resp_total": 2}) + self.register_read_response(self.user, "thread", "test_thread") + request_data = {"read": True} + + response = self.request_patch(request_data) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == self.expected_thread_data( + { + "comment_count": 1, + "read": True, + "editable_fields": [ + "abuse_flagged", + "anonymous", + "copy_link", + "following", + "raw_body", + "read", + "title", + "topic_id", + "type", + ], + "response_count": 2, + } + ) + self.mock_mark_thread_as_read.assert_called_once_with( + str(self.user.id), "test_thread", course_id=str(self.course.id) + ) + + def test_patch_read_non_owner_user(self): + self.register_get_user_response(self.user) + thread_owner_user = UserFactory.create(password=self.password) + CourseEnrollmentFactory.create(user=thread_owner_user, course_id=self.course.id) + self.register_get_user_response(thread_owner_user) + self.register_thread( + { + "username": thread_owner_user.username, + "user_id": str(thread_owner_user.id), + "resp_total": 2, + } + ) + self.register_read_response(self.user, "thread", "test_thread") + + request_data = {"read": True} + self.request_patch(request_data) + self.mock_mark_thread_as_read.assert_called_once_with( + str(thread_owner_user.id), "test_thread", course_id=str(self.course.id) + ) + + +@httpretty.activate +@disable_signal(api, "thread_deleted") +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class ThreadViewSetDeleteTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): + """Tests for ThreadViewSet delete""" + + def setUp(self): + super().setUp() + self.url = reverse("thread-detail", kwargs={"thread_id": "test_thread"}) + self.thread_id = "test_thread" + mock.patch( + "lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled", + return_value=True, + ).start() + self.mock_get_user = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.get_user" + ).start() + self.mock_get_course_id_by_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ).start() + self.mock_get_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_thread" + ).start() + self.mock_delete_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.delete_thread" + ).start() + self.addCleanup(mock.patch.stopall) + + def test_basic(self): + self.register_get_user_response(self.user) + cs_thread = make_minimal_cs_thread( + { + "id": self.thread_id, + "course_id": str(self.course.id), + "username": self.user.username, + "user_id": str(self.user.id), + } + ) + self.register_get_thread_response(cs_thread) + self.register_delete_thread_response(self.thread_id) + response = self.client.delete(self.url) + assert response.status_code == 204 + assert response.content == b"" + self.mock_delete_thread.assert_called_once_with( + thread_id=self.thread_id, course_id=str(self.course.id) + ) + + # def test_delete_nonexistent_thread(self): + # self.register_get_thread_error_response(self.thread_id, 404) + # response = self.client.delete( + # self.url, + # {"course_id": str(self.course.id)}, + # "json", + # ) + # assert response.status_code == 404 + # self.mock_delete_thread.assert_called_once_with( + # thread_id=self.thread_id, course_id=str(self.course.id) + # ) + + +@ddt.ddt +@httpretty.activate +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class LearnerThreadViewAPITest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): + """Tests for LearnerThreadView list""" + + def setUp(self): + """ + Sets up the test case + """ + super().setUp() + self.author = self.user + self.remove_keys = [ + "abuse_flaggers", + "body", + "children", + "commentable_id", + "endorsed", + "last_activity_at", + "resp_total", + "thread_type", + "user_id", + "username", + "votes", + ] + self.replace_keys = [ + {"from": "unread_comments_count", "to": "unread_comment_count"}, + {"from": "comments_count", "to": "comment_count"}, + ] + self.add_keys = [ + {"key": "author", "value": self.author.username}, + {"key": "abuse_flagged", "value": False}, + {"key": "author_label", "value": None}, + {"key": "can_delete", "value": True}, + {"key": "close_reason", "value": None}, + { + "key": "comment_list_url", + "value": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread", + }, + { + "key": "editable_fields", + "value": [ + "abuse_flagged", + "anonymous", + "copy_link", + "following", + "raw_body", + "read", + "title", + "topic_id", + "type", + ], + }, + {"key": "endorsed_comment_list_url", "value": None}, + {"key": "following", "value": False}, + {"key": "group_name", "value": None}, + {"key": "has_endorsed", "value": False}, + {"key": "last_edit", "value": None}, + {"key": "non_endorsed_comment_list_url", "value": None}, + {"key": "preview_body", "value": "Test body"}, + {"key": "raw_body", "value": "Test body"}, + {"key": "rendered_body", "value": "

Test body

"}, + {"key": "response_count", "value": 0}, + {"key": "topic_id", "value": "test_topic"}, + {"key": "type", "value": "discussion"}, + { + "key": "users", + "value": { + self.user.username: { + "profile": { + "image": { + "has_image": False, + "image_url_full": "http://testserver/static/default_500.png", + "image_url_large": "http://testserver/static/default_120.png", + "image_url_medium": "http://testserver/static/default_50.png", + "image_url_small": "http://testserver/static/default_30.png", + } + } + } + }, + }, + {"key": "vote_count", "value": 4}, + {"key": "voted", "value": False}, + ] + self.url = reverse( + "discussion_learner_threads", kwargs={"course_id": str(self.course.id)} + ) + mock.patch( + "lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled", + return_value=True, + ).start() + self.mock_get_user = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.get_user" + ).start() + self.mock_get_user_active_threads = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.get_user_active_threads" + ).start() + self.mock_get_course_id_by_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ).start() + self.mock_get_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_thread" + ).start() + self.mock_update_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.update_thread" + ).start() + self.addCleanup(mock.patch.stopall) + + def update_thread(self, thread): + """ + This function updates the thread by adding and remove some keys. + Value of these keys has been defined in setUp function + """ + for element in self.add_keys: + thread[element["key"]] = element["value"] + for pair in self.replace_keys: + thread[pair["to"]] = thread.pop(pair["from"]) + for key in self.remove_keys: + thread.pop(key) + thread["comment_count"] += 1 + return thread + + def test_basic(self): + """ + Tests the data is fetched correctly + + Note: test_basic is required as the name because DiscussionAPIViewTestMixin + calls this test case automatically + """ + self.register_get_user_response(self.user) + expected_cs_comments_response = { + "collection": [ + make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(self.user.id), + "username": self.user.username, + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + "title": "Test Title", + "body": "Test body", + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + "closed_by_label": None, + "edit_by_label": None, + } + ) + ], + "page": 1, + "num_pages": 1, + } + self.register_user_active_threads(self.user.id, expected_cs_comments_response) + self.url += f"?username={self.user.username}" + response = self.client.get(self.url) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + expected_api_response = expected_cs_comments_response["collection"] + + for thread in expected_api_response: + self.update_thread(thread) + + assert response_data["results"] == expected_api_response + assert response_data["pagination"] == { + "next": None, + "previous": None, + "count": 1, + "num_pages": 1, + } + params = { + "course_id": "course-v1:x+y+z", + "page": 1, + "per_page": 10, + "user_id": "2", + "group_id": None, + "count_flagged": False, + "thread_type": None, + "sort_key": "activity", + } + self.mock_get_user_active_threads.assert_called_once_with(**params) + + def test_not_authenticated(self): + """ + Override the parent implementation of this, we JWT auth for this API + """ + pass # lint-amnesty, pylint: disable=unnecessary-pass + + def test_no_username_given(self): + """ + Tests that 404 response is returned when no username is passed + """ + response = self.client.get(self.url) + assert response.status_code == 404 + + def test_not_authenticated(self): + """ + This test is called by DiscussionAPIViewTestMixin and is not required in + our case + """ + assert True + + @ddt.data("None", "discussion", "question") + def test_thread_type_by(self, thread_type): + """ + Tests the thread_type parameter + + Arguments: + thread_type (str): Value of thread_type can be 'None', + 'discussion' and 'question' + """ + threads = [ + make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(self.user.id), + "username": self.user.username, + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + "title": "Test Title", + "body": "Test body", + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + } + ) + ] + expected_cs_comments_response = { + "collection": threads, + "page": 1, + "num_pages": 1, + } + self.register_get_user_response(self.user) + self.register_user_active_threads(self.user.id, expected_cs_comments_response) + response = self.client.get( + self.url, + { + "course_id": str(self.course.id), + "username": self.user.username, + "thread_type": thread_type, + }, + ) + assert response.status_code == 200 + params = { + "course_id": "course-v1:x+y+z", + "page": 1, + "per_page": 10, + "user_id": "2", + "group_id": None, + "count_flagged": False, + "thread_type": thread_type, + "sort_key": "activity", + } + self.mock_get_user_active_threads.assert_called_once_with(**params) + + @ddt.data( + ("last_activity_at", "activity"), + ("comment_count", "comments"), + ("vote_count", "votes"), + ) + @ddt.unpack + def test_order_by(self, http_query, cc_query): + """ + Tests the order_by parameter for active threads + + Arguments: + http_query (str): Query string sent in the http request + cc_query (str): Query string used for the comments client service + """ + threads = [ + make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(self.user.id), + "username": self.user.username, + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + "title": "Test Title", + "body": "Test body", + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + } + ) + ] + expected_cs_comments_response = { + "collection": threads, + "page": 1, + "num_pages": 1, + } + self.register_get_user_response(self.user) + self.register_user_active_threads(self.user.id, expected_cs_comments_response) + response = self.client.get( + self.url, + { + "course_id": str(self.course.id), + "username": self.user.username, + "order_by": http_query, + }, + ) + assert response.status_code == 200 + params = { + "course_id": "course-v1:x+y+z", + "page": 1, + "per_page": 10, + "user_id": "2", + "group_id": None, + "count_flagged": False, + "thread_type": None, + "sort_key": cc_query, + } + self.mock_get_user_active_threads.assert_called_once_with(**params) + + @ddt.data("flagged", "unanswered", "unread", "unresponded") + def test_status_by(self, post_status): + """ + Tests the post_status parameter + + Arguments: + post_status (str): Value of post_status can be 'flagged', + 'unanswered' and 'unread' + """ + threads = [ + make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(self.user.id), + "username": self.user.username, + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + "title": "Test Title", + "body": "Test body", + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + } + ) + ] + expected_cs_comments_response = { + "collection": threads, + "page": 1, + "num_pages": 1, + } + self.register_get_user_response(self.user) + self.register_user_active_threads(self.user.id, expected_cs_comments_response) + response = self.client.get( + self.url, + { + "course_id": str(self.course.id), + "username": self.user.username, + "status": post_status, + }, + ) + if post_status == "flagged": + assert response.status_code == 403 + else: + assert response.status_code == 200 + params = { + "course_id": "course-v1:x+y+z", + "page": 1, + "per_page": 10, + "user_id": "2", + "group_id": None, + "count_flagged": False, + "thread_type": None, + "sort_key": "activity", + post_status: True, + } + self.mock_get_user_active_threads.assert_called_once_with(**params) + + +@ddt.ddt +@httpretty.activate +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class CommentViewSetListTest( + DiscussionAPIViewTestMixin, ModuleStoreTestCase, ProfileImageTestMixin +): + """Tests for CommentViewSet list""" + + def setUp(self): + super().setUp() + self.author = UserFactory.create() + self.url = reverse("comment-list") + self.thread_id = "test_thread" + self.storage = get_profile_image_storage() + mock.patch( + "lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled", + return_value=True, + ).start() + self.mock_get_user = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.get_user" + ).start() + self.mock_get_course_id_by_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread", + return_value=str(self.course.id), + ).start() + self.mock_get_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_thread" + ).start() + self.mock_delete_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.delete_thread" + ).start() + self.addCleanup(mock.patch.stopall) + + def create_source_comment(self, overrides=None): + """ + Create a sample source cs_comment + """ + comment = make_minimal_cs_comment( + { + "id": "test_comment", + "thread_id": self.thread_id, + "user_id": str(self.user.id), + "username": self.user.username, + "created_at": "2015-05-11T00:00:00Z", + "updated_at": "2015-05-11T11:11:11Z", + "body": "Test body", + "votes": {"up_count": 4}, + } + ) + + comment.update(overrides or {}) + return comment + + def make_minimal_cs_thread(self, overrides=None): + """ + Create a thread with the given overrides, plus the course_id if not + already in overrides. + """ + overrides = overrides.copy() if overrides else {} + overrides.setdefault("course_id", str(self.course.id)) + return make_minimal_cs_thread(overrides) + + def expected_response_comment(self, overrides=None): + """ + create expected response data + """ + response_data = { + "id": "test_comment", + "thread_id": self.thread_id, + "parent_id": None, + "author": self.author.username, + "author_label": None, + "created_at": "1970-01-01T00:00:00Z", + "updated_at": "1970-01-01T00:00:00Z", + "raw_body": "dummy", + "rendered_body": "

dummy

", + "endorsed": False, + "endorsed_by": None, + "endorsed_by_label": None, + "endorsed_at": None, + "abuse_flagged": False, + "abuse_flagged_any_user": None, + "voted": False, + "vote_count": 0, + "children": [], + "editable_fields": ["abuse_flagged", "voted"], + "child_count": 0, + "can_delete": True, + "anonymous": False, + "anonymous_to_peers": False, + "last_edit": None, + "edit_by_label": None, + "profile_image": { + "has_image": False, + "image_url_full": "http://testserver/static/default_500.png", + "image_url_large": "http://testserver/static/default_120.png", + "image_url_medium": "http://testserver/static/default_50.png", + "image_url_small": "http://testserver/static/default_30.png", + }, + } + response_data.update(overrides or {}) + return response_data + + def test_thread_id_missing(self): + response = self.client.get(self.url) + self.assert_response_correct( + response, + 400, + { + "field_errors": { + "thread_id": {"developer_message": "This field is required."} + } + }, + ) + + # def test_404(self): + # self.register_get_thread_error_response(self.thread_id, 404) + # response = self.client.get(self.url, {"thread_id": self.thread_id}) + # self.assert_response_correct( + # response, 404, {"developer_message": "Thread not found."} + # ) + + def test_basic(self): + self.register_get_user_response(self.user, upvoted_ids=["test_comment"]) + source_comments = [ + self.create_source_comment( + {"user_id": str(self.author.id), "username": self.author.username} + ) + ] + expected_comments = [ + self.expected_response_comment( + overrides={ + "voted": True, + "vote_count": 4, + "raw_body": "Test body", + "can_delete": False, + "rendered_body": "

Test body

", + "created_at": "2015-05-11T00:00:00Z", + "updated_at": "2015-05-11T11:11:11Z", + } + ) + ] + self.register_get_thread_response( + { + "id": self.thread_id, + "course_id": str(self.course.id), + "thread_type": "discussion", + "children": source_comments, + "resp_total": 100, + } + ) + response = self.client.get(self.url, {"thread_id": self.thread_id}) + next_link = ( + "http://testserver/api/discussion/v1/comments/?page=2&thread_id={}".format( + self.thread_id + ) + ) + self.assert_response_correct( + response, + 200, + make_paginated_api_response( + results=expected_comments, + count=100, + num_pages=10, + next_link=next_link, + previous_link=None, + ), + ) + params = { + "recursive": False, + "with_responses": True, + "user_id": str(self.user.id), + "mark_as_read": False, + "resp_skip": 0, + "resp_limit": 10, + "reverse_order": False, + "merge_question_type_responses": False, + } + self.mock_get_thread.assert_called_once_with( + thread_id="test_thread", params=params, course_id=str(self.course.id) + ) + + def test_pagination(self): + """ + Test that pagination parameters are correctly plumbed through to the + comments service and that a 404 is correctly returned if a page past the + end is requested + """ + self.register_get_user_response(self.user) + self.register_get_thread_response( + make_minimal_cs_thread( + { + "id": self.thread_id, + "course_id": str(self.course.id), + "thread_type": "discussion", + "resp_total": 10, + } + ) + ) + response = self.client.get( + self.url, {"thread_id": self.thread_id, "page": "18", "page_size": "4"} + ) + self.assert_response_correct( + response, + 404, + {"developer_message": "Page not found (No results on this page)."}, + ) + params = { + "recursive": False, + "with_responses": True, + "user_id": str(self.user.id), + "mark_as_read": False, + "resp_skip": 68, + "resp_limit": 4, + "reverse_order": False, + "merge_question_type_responses": False, + } + self.mock_get_thread.assert_called_once_with( + thread_id="test_thread", params=params, course_id=str(self.course.id) + ) + + def test_question_content_with_merge_question_type_responses(self): + self.register_get_user_response(self.user) + thread = self.make_minimal_cs_thread( + { + "thread_type": "question", + "children": [ + make_minimal_cs_comment( + { + "id": "endorsed_comment", + "user_id": self.user.id, + "username": self.user.username, + "endorsed": True, + } + ), + make_minimal_cs_comment( + { + "id": "non_endorsed_comment", + "user_id": self.user.id, + "username": self.user.username, + "endorsed": False, + } + ), + ], + "resp_total": 2, + } + ) + self.register_get_thread_response(thread) + response = self.client.get( + self.url, {"thread_id": thread["id"], "merge_question_type_responses": True} + ) + parsed_content = json.loads(response.content.decode("utf-8")) + assert parsed_content["results"][0]["id"] == "endorsed_comment" + assert parsed_content["results"][1]["id"] == "non_endorsed_comment" + params = { + "recursive": False, + "with_responses": True, + "user_id": str(self.user.id), + "mark_as_read": False, + "resp_skip": 0, + "resp_limit": 10, + "reverse_order": False, + "merge_question_type_responses": True, + } + self.mock_get_thread.assert_called_once_with( + thread_id=thread["id"], params=params, course_id=str(self.course.id) + ) + + @ddt.data( + (True, "endorsed_comment"), + ("true", "endorsed_comment"), + ("1", "endorsed_comment"), + (False, "non_endorsed_comment"), + ("false", "non_endorsed_comment"), + ("0", "non_endorsed_comment"), + ) + @ddt.unpack + def test_question_content(self, endorsed, comment_id): + self.register_get_user_response(self.user) + thread = self.make_minimal_cs_thread( + { + "thread_type": "question", + "endorsed_responses": [ + make_minimal_cs_comment( + { + "id": "endorsed_comment", + "user_id": self.user.id, + "username": self.user.username, + } + ) + ], + "non_endorsed_responses": [ + make_minimal_cs_comment( + { + "id": "non_endorsed_comment", + "user_id": self.user.id, + "username": self.user.username, + } + ) + ], + "non_endorsed_resp_total": 1, + } + ) + self.register_get_thread_response(thread) + response = self.client.get( + self.url, + { + "thread_id": thread["id"], + "endorsed": endorsed, + }, + ) + parsed_content = json.loads(response.content.decode("utf-8")) + assert parsed_content["results"][0]["id"] == comment_id + + params = { + "recursive": False, + "with_responses": True, + "user_id": str(self.user.id), + "mark_as_read": False, + "resp_skip": 0, + "resp_limit": 10, + "reverse_order": False, + "merge_question_type_responses": False, + } + self.mock_get_thread.assert_called_once_with( + thread_id=thread["id"], params=params, course_id=str(self.course.id) + ) + + def test_question_invalid_endorsed(self): + response = self.client.get( + self.url, {"thread_id": self.thread_id, "endorsed": "invalid-boolean"} + ) + self.assert_response_correct( + response, + 400, + { + "field_errors": { + "endorsed": {"developer_message": "Invalid Boolean Value."} + } + }, + ) + + def test_question_missing_endorsed(self): + self.register_get_user_response(self.user) + thread = self.make_minimal_cs_thread( + { + "thread_type": "question", + "endorsed_responses": [ + make_minimal_cs_comment({"id": "endorsed_comment"}) + ], + "non_endorsed_responses": [ + make_minimal_cs_comment({"id": "non_endorsed_comment"}) + ], + "non_endorsed_resp_total": 1, + } + ) + self.register_get_thread_response(thread) + response = self.client.get(self.url, {"thread_id": thread["id"]}) + self.assert_response_correct( + response, + 400, + { + "field_errors": { + "endorsed": { + "developer_message": "This field is required for question threads." + } + } + }, + ) + + @ddt.data(("discussion", False), ("question", True)) + @ddt.unpack + def test_child_comments_count(self, thread_type, merge_question_type_responses): + self.register_get_user_response(self.user) + response_1 = make_minimal_cs_comment( + { + "id": "test_response_1", + "thread_id": self.thread_id, + "user_id": str(self.author.id), + "username": self.author.username, + "child_count": 2, + } + ) + response_2 = make_minimal_cs_comment( + { + "id": "test_response_2", + "thread_id": self.thread_id, + "user_id": str(self.author.id), + "username": self.author.username, + "child_count": 3, + } + ) + thread = self.make_minimal_cs_thread( + { + "id": self.thread_id, + "course_id": str(self.course.id), + "thread_type": thread_type, + "children": [response_1, response_2], + "resp_total": 2, + "comments_count": 8, + "unread_comments_count": 0, + } + ) + self.register_get_thread_response(thread) + response = self.client.get( + self.url, + { + "thread_id": self.thread_id, + "merge_question_type_responses": merge_question_type_responses, + }, + ) + expected_comments = [ + self.expected_response_comment( + overrides={ + "id": "test_response_1", + "child_count": 2, + "can_delete": False, + } + ), + self.expected_response_comment( + overrides={ + "id": "test_response_2", + "child_count": 3, + "can_delete": False, + } + ), + ] + self.assert_response_correct( + response, + 200, + { + "results": expected_comments, + "pagination": { + "count": 2, + "next": None, + "num_pages": 1, + "previous": None, + }, + }, + ) + params = { + "recursive": False, + "with_responses": True, + "user_id": str(self.user.id), + "mark_as_read": False, + "resp_skip": 0, + "resp_limit": 10, + "reverse_order": False, + "merge_question_type_responses": merge_question_type_responses, + } + self.mock_get_thread.assert_called_once_with( + thread_id=thread["id"], params=params, course_id=str(self.course.id) + ) + + def test_profile_image_requested_field(self): + """ + Tests all comments retrieved have user profile image details if called in requested_fields + """ + source_comments = [self.create_source_comment()] + self.register_get_thread_response( + { + "id": self.thread_id, + "course_id": str(self.course.id), + "thread_type": "discussion", + "children": source_comments, + "resp_total": 100, + } + ) + self.register_get_user_response(self.user, upvoted_ids=["test_comment"]) + self.create_profile_image(self.user, get_profile_image_storage()) + + response = self.client.get( + self.url, {"thread_id": self.thread_id, "requested_fields": "profile_image"} + ) + assert response.status_code == 200 + response_comments = json.loads(response.content.decode("utf-8"))["results"] + for response_comment in response_comments: + expected_profile_data = self.get_expected_user_profile( + response_comment["author"] + ) + response_users = response_comment["users"] + assert expected_profile_data == response_users[response_comment["author"]] + + def test_profile_image_requested_field_endorsed_comments(self): + """ + Tests all comments have user profile image details for both author and endorser + if called in requested_fields for endorsed threads + """ + endorser_user = UserFactory.create(password=self.password) + # Ensure that parental controls don't apply to this user + endorser_user.profile.year_of_birth = 1970 + endorser_user.profile.save() + + self.register_get_user_response(self.user) + thread = self.make_minimal_cs_thread( + { + "thread_type": "question", + "endorsed_responses": [ + make_minimal_cs_comment( + { + "id": "endorsed_comment", + "user_id": self.user.id, + "username": self.user.username, + "endorsed": True, + "endorsement": { + "user_id": endorser_user.id, + "time": "2016-05-10T08:51:28Z", + }, + } + ) + ], + "non_endorsed_responses": [ + make_minimal_cs_comment( + { + "id": "non_endorsed_comment", + "user_id": self.user.id, + "username": self.user.username, + } + ) + ], + "non_endorsed_resp_total": 1, + } + ) + self.register_get_thread_response(thread) + self.create_profile_image(self.user, get_profile_image_storage()) + self.create_profile_image(endorser_user, get_profile_image_storage()) + + response = self.client.get( + self.url, + { + "thread_id": thread["id"], + "endorsed": True, + "requested_fields": "profile_image", + }, + ) + assert response.status_code == 200 + response_comments = json.loads(response.content.decode("utf-8"))["results"] + for response_comment in response_comments: + expected_author_profile_data = self.get_expected_user_profile( + response_comment["author"] + ) + expected_endorser_profile_data = self.get_expected_user_profile( + response_comment["endorsed_by"] + ) + response_users = response_comment["users"] + assert ( + expected_author_profile_data + == response_users[response_comment["author"]] + ) + assert ( + expected_endorser_profile_data + == response_users[response_comment["endorsed_by"]] + ) + + def test_profile_image_request_for_null_endorsed_by(self): + """ + Tests if 'endorsed' is True but 'endorsed_by' is null, the api does not crash. + This is the case for some old/stale data in prod/stage environments. + """ + self.register_get_user_response(self.user) + thread = self.make_minimal_cs_thread( + { + "thread_type": "question", + "endorsed_responses": [ + make_minimal_cs_comment( + { + "id": "endorsed_comment", + "user_id": self.user.id, + "username": self.user.username, + "endorsed": True, + } + ) + ], + "non_endorsed_resp_total": 0, + } + ) + self.register_get_thread_response(thread) + self.create_profile_image(self.user, get_profile_image_storage()) + + response = self.client.get( + self.url, + { + "thread_id": thread["id"], + "endorsed": True, + "requested_fields": "profile_image", + }, + ) + assert response.status_code == 200 + response_comments = json.loads(response.content.decode("utf-8"))["results"] + for response_comment in response_comments: + expected_author_profile_data = self.get_expected_user_profile( + response_comment["author"] + ) + response_users = response_comment["users"] + assert ( + expected_author_profile_data + == response_users[response_comment["author"]] + ) + assert response_comment["endorsed_by"] not in response_users + + def test_reverse_order_sort(self): + """ + Tests if reverse_order param is passed to cs comments service + """ + self.register_get_user_response(self.user, upvoted_ids=["test_comment"]) + source_comments = [ + self.create_source_comment( + {"user_id": str(self.author.id), "username": self.author.username} + ) + ] + self.register_get_thread_response( + { + "id": self.thread_id, + "course_id": str(self.course.id), + "thread_type": "discussion", + "children": source_comments, + "resp_total": 100, + } + ) + self.client.get(self.url, {"thread_id": self.thread_id, "reverse_order": True}) + params = { + "recursive": False, + "with_responses": True, + "user_id": str(self.user.id), + "mark_as_read": False, + "resp_skip": 0, + "resp_limit": 10, + "reverse_order": "True", + "merge_question_type_responses": False, + } + self.mock_get_thread.assert_called_once_with( + thread_id=self.thread_id, params=params, course_id=str(self.course.id) + ) + + +@httpretty.activate +@disable_signal(api, "comment_deleted") +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class CommentViewSetDeleteTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): + """Tests for ThreadViewSet delete""" + + def setUp(self): + super().setUp() + self.url = reverse("comment-detail", kwargs={"comment_id": "test_comment"}) + self.comment_id = "test_comment" + mock.patch( + "lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled", + return_value=True, + ).start() + self.mock_get_user = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.get_user" + ).start() + self.mock_get_course_id_by_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread", + return_value=str(self.course.id), + ).start() + self.mock_get_course_id_by_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment", + return_value=str(self.course.id), + ).start() + self.mock_get_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_thread" + ).start() + self.mock_delete_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.delete_thread" + ).start() + self.mock_delete_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.delete_comment" + ).start() + self.mock_get_parent_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_parent_comment" + ).start() + self.addCleanup(mock.patch.stopall) + + def test_basic(self): + self.register_get_user_response(self.user) + cs_thread = make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + } + ) + self.register_get_thread_response(cs_thread) + cs_comment = make_minimal_cs_comment( + { + "id": self.comment_id, + "course_id": cs_thread["course_id"], + "thread_id": cs_thread["id"], + "username": self.user.username, + "user_id": str(self.user.id), + } + ) + self.register_get_comment_response(cs_comment) + self.register_delete_comment_response(self.comment_id) + response = self.client.delete(self.url) + assert response.status_code == 204 + assert response.content == b"" + self.mock_delete_comment.assert_called_once_with( + comment_id=self.comment_id, course_id=cs_thread["course_id"] + ) + + def test_delete_nonexistent_comment(self): + try: + self.register_get_comment_error_response(self.comment_id, 404) + except Exception as e: + assert e == "404 Not Found" + + +@httpretty.activate +@disable_signal(api, "comment_created") +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +@mock.patch( + "lms.djangoapps.discussion.signals.handlers.send_response_notifications", + new=mock.Mock(), +) +class CommentViewSetCreateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): + """Tests for CommentViewSet create""" + + def setUp(self): + super().setUp() + self.url = reverse("comment-list") + mock.patch( + "lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled", + return_value=True, + ).start() + self.mock_get_user = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.get_user" + ).start() + self.mock_get_course_id_by_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread", + return_value=str(self.course.id), + ).start() + self.mock_get_course_id_by_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment", + return_value=str(self.course.id), + ).start() + self.mock_get_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_thread" + ).start() + self.mock_update_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.update_thread" + ).start() + self.mock_get_parent_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_parent_comment" + ).start() + self.mock_update_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.update_comment" + ).start() + self.mock_create_parent_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.create_parent_comment" + ).start() + self.mock_create_child_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.create_child_comment" + ).start() + self.addCleanup(mock.patch.stopall) + + def test_basic(self): + self.register_get_user_response(self.user) + self.register_thread() + self.register_comment() + request_data = { + "thread_id": "test_thread", + "raw_body": "Test body", + } + expected_response_data = { + "id": "test_comment", + "author": self.user.username, + "author_label": None, + "created_at": "1970-01-01T00:00:00Z", + "updated_at": "1970-01-01T00:00:00Z", + "raw_body": "Original body", + "rendered_body": "

Original body

", + "abuse_flagged": False, + "voted": False, + "vote_count": 0, + "editable_fields": ["abuse_flagged", "anonymous", "raw_body"], + "can_delete": True, + "anonymous": False, + "anonymous_to_peers": False, + "last_edit": None, + "edit_by_label": None, + "thread_id": "test_thread", + "parent_id": None, + "endorsed": False, + "endorsed_by": None, + "endorsed_by_label": None, + "endorsed_at": None, + "child_count": 0, + "children": [], + "abuse_flagged_any_user": None, + "profile_image": { + "has_image": False, + "image_url_full": "http://testserver/static/default_500.png", + "image_url_large": "http://testserver/static/default_120.png", + "image_url_medium": "http://testserver/static/default_50.png", + "image_url_small": "http://testserver/static/default_30.png", + }, + } + response = self.client.post( + self.url, json.dumps(request_data), content_type="application/json" + ) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == expected_response_data + self.mock_create_parent_comment.assert_called_once_with( + "test_thread", "Test body", "2", "course-v1:x+y+z", False, False + ) + + def test_error(self): + response = self.client.post( + self.url, json.dumps({}), content_type="application/json" + ) + expected_response_data = { + "field_errors": { + "thread_id": {"developer_message": "This field is required."} + } + } + assert response.status_code == 400 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == expected_response_data + + def test_closed_thread(self): + self.register_get_user_response(self.user) + self.register_thread({"closed": True}) + self.register_comment() + request_data = {"thread_id": "test_thread", "raw_body": "Test body"} + response = self.client.post( + self.url, json.dumps(request_data), content_type="application/json" + ) + assert response.status_code == 403 + + +@ddt.ddt +@disable_signal(api, "comment_edited") +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class CommentViewSetPartialUpdateTest( + DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin +): + """Tests for CommentViewSet partial_update""" + + def setUp(self): + self.unsupported_media_type = JSONParser.media_type + super().setUp() + httpretty.reset() + httpretty.enable() + self.addCleanup(httpretty.reset) + self.addCleanup(httpretty.disable) + mock.patch( + "lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled", + return_value=True, + ).start() + self.mock_get_user = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.get_user" + ).start() + self.mock_get_course_id_by_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread", + return_value=str(self.course.id), + ).start() + self.mock_get_course_id_by_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment", + return_value=str(self.course.id), + ).start() + self.mock_update_comment_flag = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.comment.forum_api.update_comment_flag", + return_value=str(self.course.id), + ).start() + self.mock_get_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_thread" + ).start() + self.mock_update_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.update_thread" + ).start() + self.mock_get_parent_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_parent_comment" + ).start() + self.mock_update_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.update_comment" + ).start() + self.mock_create_parent_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.create_parent_comment" + ).start() + self.mock_create_child_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.create_child_comment" + ).start() + self.mock_update_thread_flag = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.update_thread_flag" + ).start() + self.mock_update_thread_flag_in_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.comment.forum_api.update_thread_flag" + ).start() + self.addCleanup(mock.patch.stopall) + self.register_get_user_response(self.user) + self.url = reverse("comment-detail", kwargs={"comment_id": "test_comment"}) + + def expected_response_data(self, overrides=None): + """ + create expected response data from comment update endpoint + """ + response_data = { + "id": "test_comment", + "thread_id": "test_thread", + "parent_id": None, + "author": self.user.username, + "author_label": None, + "created_at": "1970-01-01T00:00:00Z", + "updated_at": "1970-01-01T00:00:00Z", + "raw_body": "Original body", + "rendered_body": "

Original body

", + "endorsed": False, + "endorsed_by": None, + "endorsed_by_label": None, + "endorsed_at": None, + "abuse_flagged": False, + "abuse_flagged_any_user": None, + "voted": False, + "vote_count": 0, + "children": [], + "editable_fields": [], + "child_count": 0, + "can_delete": True, + "anonymous": False, + "anonymous_to_peers": False, + "last_edit": None, + "edit_by_label": None, + "profile_image": { + "has_image": False, + "image_url_full": "http://testserver/static/default_500.png", + "image_url_large": "http://testserver/static/default_120.png", + "image_url_medium": "http://testserver/static/default_50.png", + "image_url_small": "http://testserver/static/default_30.png", + }, + } + response_data.update(overrides or {}) + return response_data + + def test_basic(self): + self.register_thread() + self.register_comment( + {"created_at": "Test Created Date", "updated_at": "Test Updated Date"} + ) + request_data = {"raw_body": "Edited body"} + response = self.request_patch(request_data) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == self.expected_response_data( + { + "raw_body": "Original body", + "rendered_body": "

Original body

", + "editable_fields": ["abuse_flagged", "anonymous", "raw_body"], + "created_at": "Test Created Date", + "updated_at": "Test Updated Date", + } + ) + self.mock_update_comment.assert_called_once_with( + comment_id="test_comment", + body="Edited body", + course_id=str(self.course.id), + user_id=str(self.user.id), + anonymous=False, + anonymous_to_peers=False, + endorsed=False, + editing_user_id=str(self.user.id), + course_key=str(self.course.id), + ) + + def test_error(self): + self.register_thread() + self.register_comment() + request_data = {"raw_body": ""} + response = self.request_patch(request_data) + expected_response_data = { + "field_errors": { + "raw_body": {"developer_message": "This field may not be blank."} + } + } + assert response.status_code == 400 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == expected_response_data + + @ddt.data( + ("abuse_flagged", True), + ("abuse_flagged", False), + ) + @ddt.unpack + def test_closed_thread(self, field, value): + self.register_thread({"closed": True}) + self.register_comment() + self.register_flag_response("comment", "test_comment") + request_data = {field: value} + response = self.request_patch(request_data) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == self.expected_response_data( + { + "abuse_flagged": value, + "abuse_flagged_any_user": None, + "editable_fields": ["abuse_flagged"], + } + ) + if value: + self.mock_update_comment_flag.assert_called_once_with( + "test_comment", + "flag", + str(self.user.id), + str(self.course.id), + ) + + @ddt.data( + ("raw_body", "Edited body"), + ("voted", True), + ("following", True), + ) + @ddt.unpack + def test_closed_thread_error(self, field, value): + self.register_thread({"closed": True}) + self.register_comment() + request_data = {field: value} + response = self.request_patch(request_data) + assert response.status_code == 400 + + +@httpretty.activate +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class ThreadViewSetRetrieveTest( + DiscussionAPIViewTestMixin, ModuleStoreTestCase, ProfileImageTestMixin +): + """Tests for ThreadViewSet Retrieve""" + + def setUp(self): + super().setUp() + self.url = reverse("thread-detail", kwargs={"thread_id": "test_thread"}) + self.thread_id = "test_thread" + mock.patch( + "lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled", + return_value=True, + ).start() + self.mock_get_user = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.get_user" + ).start() + self.mock_get_course_id_by_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread", + return_value=str(self.course.id), + ).start() + self.mock_get_course_id_by_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment", + return_value=str(self.course.id), + ).start() + self.mock_get_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_thread" + ).start() + self.addCleanup(mock.patch.stopall) + + def test_basic(self): + self.register_get_user_response(self.user) + cs_thread = make_minimal_cs_thread( + { + "id": self.thread_id, + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "username": self.user.username, + "user_id": str(self.user.id), + "title": "Test Title", + "body": "Test body", + } + ) + self.register_get_thread_response(cs_thread) + response = self.client.get(self.url) + assert response.status_code == 200 + assert json.loads( + response.content.decode("utf-8") + ) == self.expected_thread_data({"unread_comment_count": 1}) + + params = { + "with_responses": True, + "user_id": "2", + "mark_as_read": False, + "reverse_order": False, + "merge_question_type_responses": False, + } + self.mock_get_thread.assert_called_once_with( + thread_id="test_thread", params=params, course_id=str(self.course.id) + ) + + def test_profile_image_requested_field(self): + """ + Tests thread has user profile image details if called in requested_fields + """ + self.register_get_user_response(self.user) + cs_thread = make_minimal_cs_thread( + { + "id": self.thread_id, + "course_id": str(self.course.id), + "username": self.user.username, + "user_id": str(self.user.id), + } + ) + self.register_get_thread_response(cs_thread) + self.create_profile_image(self.user, get_profile_image_storage()) + response = self.client.get(self.url, {"requested_fields": "profile_image"}) + assert response.status_code == 200 + expected_profile_data = self.get_expected_user_profile(self.user.username) + response_users = json.loads(response.content.decode("utf-8"))["users"] + assert expected_profile_data == response_users[self.user.username] + + +@httpretty.activate +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class CommentViewSetRetrieveTest( + DiscussionAPIViewTestMixin, ModuleStoreTestCase, ProfileImageTestMixin +): + """Tests for CommentViewSet Retrieve""" + + def setUp(self): + super().setUp() + self.url = reverse("comment-detail", kwargs={"comment_id": "test_comment"}) + self.thread_id = "test_thread" + self.comment_id = "test_comment" + mock.patch( + "lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled", + return_value=True, + ).start() + self.mock_get_user = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.get_user" + ).start() + self.mock_get_course_id_by_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread", + return_value=str(self.course.id), + ).start() + self.mock_get_course_id_by_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment", + return_value=str(self.course.id), + ).start() + self.mock_get_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_thread" + ).start() + self.mock_get_parent_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_parent_comment" + ).start() + self.addCleanup(mock.patch.stopall) + + def make_comment_data( + self, comment_id, parent_id=None, children=[] + ): # pylint: disable=W0102 + """ + Returns comment dict object as returned by comments service + """ + return make_minimal_cs_comment( + { + "id": comment_id, + "parent_id": parent_id, + "course_id": str(self.course.id), + "thread_id": self.thread_id, + "thread_type": "discussion", + "username": self.user.username, + "user_id": str(self.user.id), + "created_at": "2015-06-03T00:00:00Z", + "updated_at": "2015-06-03T00:00:00Z", + "body": "Original body", + "children": children, + } + ) + + def test_basic(self): + self.register_get_user_response(self.user) + cs_comment_child = self.make_comment_data( + "test_child_comment", self.comment_id, children=[] + ) + cs_comment = self.make_comment_data(self.comment_id, None, [cs_comment_child]) + cs_thread = make_minimal_cs_thread( + { + "id": self.thread_id, + "course_id": str(self.course.id), + "children": [cs_comment], + } + ) + self.register_get_thread_response(cs_thread) + self.register_get_comment_response(cs_comment) + + expected_response_data = { + "id": "test_child_comment", + "parent_id": self.comment_id, + "thread_id": self.thread_id, + "author": self.user.username, + "author_label": None, + "raw_body": "Original body", + "rendered_body": "

Original body

", + "created_at": "2015-06-03T00:00:00Z", + "updated_at": "2015-06-03T00:00:00Z", + "children": [], + "endorsed_at": None, + "endorsed": False, + "endorsed_by": None, + "endorsed_by_label": None, + "voted": False, + "vote_count": 0, + "abuse_flagged": False, + "abuse_flagged_any_user": None, + "editable_fields": ["abuse_flagged", "anonymous", "raw_body"], + "child_count": 0, + "can_delete": True, + "anonymous": False, + "anonymous_to_peers": False, + "last_edit": None, + "edit_by_label": None, + "profile_image": { + "has_image": False, + "image_url_full": "http://testserver/static/default_500.png", + "image_url_large": "http://testserver/static/default_120.png", + "image_url_medium": "http://testserver/static/default_50.png", + "image_url_small": "http://testserver/static/default_30.png", + }, + } + + response = self.client.get(self.url) + assert response.status_code == 200 + assert ( + json.loads(response.content.decode("utf-8"))["results"][0] + == expected_response_data + ) + self.mock_get_parent_comment.assert_called_once_with( + comment_id="test_comment", course_id=str(self.course.id) + ) + + def test_pagination(self): + """ + Test that pagination parameters are correctly plumbed through to the + comments service and that a 404 is correctly returned if a page past the + end is requested + """ + self.register_get_user_response(self.user) + cs_comment_child = self.make_comment_data( + "test_child_comment", self.comment_id, children=[] + ) + cs_comment = self.make_comment_data(self.comment_id, None, [cs_comment_child]) + cs_thread = make_minimal_cs_thread( + { + "id": self.thread_id, + "course_id": str(self.course.id), + "children": [cs_comment], + } + ) + self.register_get_thread_response(cs_thread) + self.register_get_comment_response(cs_comment) + response = self.client.get( + self.url, {"comment_id": self.comment_id, "page": "18", "page_size": "4"} + ) + self.assert_response_correct( + response, + 404, + {"developer_message": "Page not found (No results on this page)."}, + ) + self.mock_get_parent_comment.assert_called_once_with( + comment_id="test_comment", course_id=str(self.course.id) + ) + + def test_profile_image_requested_field(self): + """ + Tests all comments retrieved have user profile image details if called in requested_fields + """ + self.register_get_user_response(self.user) + cs_comment_child = self.make_comment_data( + "test_child_comment", self.comment_id, children=[] + ) + cs_comment = self.make_comment_data(self.comment_id, None, [cs_comment_child]) + cs_thread = make_minimal_cs_thread( + { + "id": self.thread_id, + "course_id": str(self.course.id), + "children": [cs_comment], + } + ) + self.register_get_thread_response(cs_thread) + self.register_get_comment_response(cs_comment) + self.create_profile_image(self.user, get_profile_image_storage()) + + response = self.client.get(self.url, {"requested_fields": "profile_image"}) + assert response.status_code == 200 + response_comments = json.loads(response.content.decode("utf-8"))["results"] + + for response_comment in response_comments: + expected_profile_data = self.get_expected_user_profile( + response_comment["author"] + ) + response_users = response_comment["users"] + assert expected_profile_data == response_users[response_comment["author"]] + + +@ddt.ddt +class CourseDiscussionSettingsAPIViewTest( + APITestCase, UrlResetMixin, ModuleStoreTestCase +): + """ + Test the course discussion settings handler API endpoint. + """ + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def setUp(self): + super().setUp() + self.course = CourseFactory.create( + org="x", + course="y", + run="z", + start=datetime.now(UTC), + discussion_topics={"Test Topic": {"id": "test_topic"}}, + ) + self.path = reverse( + "discussion_course_settings", kwargs={"course_id": str(self.course.id)} + ) + self.password = self.TEST_PASSWORD + self.user = UserFactory(username="staff", password=self.password, is_staff=True) + mock.patch( + "lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled", + return_value=True, + ).start() + self.addCleanup(mock.patch.stopall) + + def _get_oauth_headers(self, user): + """Return the OAuth headers for testing OAuth authentication""" + access_token = AccessTokenFactory.create( + user=user, application=ApplicationFactory() + ).token + headers = {"HTTP_AUTHORIZATION": "Bearer " + access_token} + return headers + + def _login_as_staff(self): + """Log the client in as the staff.""" + self.client.login(username=self.user.username, password=self.password) + + def _login_as_discussion_staff(self): + user = UserFactory(username="abc", password="abc") + role = Role.objects.create(name="Administrator", course_id=self.course.id) + role.users.set([user]) + self.client.login(username=user.username, password="abc") + + def _create_divided_discussions(self): + """Create some divided discussions for testing.""" + divided_inline_discussions = [ + "Topic A", + ] + divided_course_wide_discussions = [ + "Topic B", + ] + divided_discussions = ( + divided_inline_discussions + divided_course_wide_discussions + ) + + BlockFactory.create( + parent=self.course, + category="discussion", + discussion_id=topic_name_to_id(self.course, "Topic A"), + discussion_category="Chapter", + discussion_target="Discussion", + start=datetime.now(), + ) + discussion_topics = { + "Topic B": {"id": "Topic B"}, + } + config_course_cohorts(self.course, is_cohorted=True) + config_course_discussions( + self.course, + discussion_topics=discussion_topics, + divided_discussions=divided_discussions, + ) + return divided_inline_discussions, divided_course_wide_discussions + + def _get_expected_response(self): + """Return the default expected response before any changes to the discussion settings.""" + return { + "always_divide_inline_discussions": False, + "divided_inline_discussions": [], + "divided_course_wide_discussions": [], + "id": 1, + "division_scheme": "cohort", + "available_division_schemes": ["cohort"], + "reported_content_email_notifications": False, + } + + def patch_request(self, data, headers=None): + headers = headers if headers else {} + return self.client.patch( + self.path, + json.dumps(data), + content_type="application/merge-patch+json", + **headers, + ) + + def _assert_current_settings(self, expected_response): + """Validate the current discussion settings against the expected response.""" + response = self.client.get(self.path) + assert response.status_code == 200 + content = json.loads(response.content.decode("utf-8")) + assert content == expected_response + + def _assert_patched_settings(self, data, expected_response): + """Validate the patched settings against the expected response.""" + response = self.patch_request(data) + assert response.status_code == 204 + self._assert_current_settings(expected_response) + + @ddt.data("get", "patch") + def test_authentication_required(self, method): + """Test and verify that authentication is required for this endpoint.""" + self.client.logout() + response = getattr(self.client, method)(self.path) + assert response.status_code == 401 + + @ddt.data( + {"is_staff": False, "get_status": 403, "put_status": 403}, + {"is_staff": True, "get_status": 200, "put_status": 204}, + ) + @ddt.unpack + def test_oauth(self, is_staff, get_status, put_status): + """Test that OAuth authentication works for this endpoint.""" + user = UserFactory(is_staff=is_staff) + headers = self._get_oauth_headers(user) + self.client.logout() + + response = self.client.get(self.path, **headers) + assert response.status_code == get_status + + response = self.patch_request( + {"always_divide_inline_discussions": True}, headers + ) + assert response.status_code == put_status + + def test_non_existent_course_id(self): + """Test the response when this endpoint is passed a non-existent course id.""" + self._login_as_staff() + response = self.client.get( + reverse( + "discussion_course_settings", kwargs={"course_id": "course-v1:a+b+c"} + ) + ) + assert response.status_code == 404 + + def test_patch_request_by_discussion_staff(self): + """Test the response when patch request is sent by a user with discussions staff role.""" + self._login_as_discussion_staff() + response = self.patch_request({"always_divide_inline_discussions": True}) + assert response.status_code == 403 + + def test_get_request_by_discussion_staff(self): + """Test the response when get request is sent by a user with discussions staff role.""" + self._login_as_discussion_staff() + divided_inline_discussions, divided_course_wide_discussions = ( + self._create_divided_discussions() + ) + response = self.client.get(self.path) + assert response.status_code == 200 + expected_response = self._get_expected_response() + expected_response["divided_course_wide_discussions"] = [ + topic_name_to_id(self.course, name) + for name in divided_course_wide_discussions + ] + expected_response["divided_inline_discussions"] = [ + topic_name_to_id(self.course, name) for name in divided_inline_discussions + ] + content = json.loads(response.content.decode("utf-8")) + assert content == expected_response + + def test_get_request_by_non_staff_user(self): + """Test the response when get request is sent by a regular user with no staff role.""" + user = UserFactory(username="abc", password="abc") + self.client.login(username=user.username, password="abc") + response = self.client.get(self.path) + assert response.status_code == 403 + + def test_patch_request_by_non_staff_user(self): + """Test the response when patch request is sent by a regular user with no staff role.""" + user = UserFactory(username="abc", password="abc") + self.client.login(username=user.username, password="abc") + response = self.patch_request({"always_divide_inline_discussions": True}) + assert response.status_code == 403 + + def test_get_settings(self): + """Test the current discussion settings against the expected response.""" + divided_inline_discussions, divided_course_wide_discussions = ( + self._create_divided_discussions() + ) + self._login_as_staff() + response = self.client.get(self.path) + assert response.status_code == 200 + expected_response = self._get_expected_response() + expected_response["divided_course_wide_discussions"] = [ + topic_name_to_id(self.course, name) + for name in divided_course_wide_discussions + ] + expected_response["divided_inline_discussions"] = [ + topic_name_to_id(self.course, name) for name in divided_inline_discussions + ] + content = json.loads(response.content.decode("utf-8")) + assert content == expected_response + + def test_available_schemes(self): + """Test the available division schemes against the expected response.""" + config_course_cohorts(self.course, is_cohorted=False) + self._login_as_staff() + expected_response = self._get_expected_response() + expected_response["available_division_schemes"] = [] + self._assert_current_settings(expected_response) + + CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.AUDIT) + CourseModeFactory.create( + course_id=self.course.id, mode_slug=CourseMode.VERIFIED + ) + + expected_response["available_division_schemes"] = [ + CourseDiscussionSettings.ENROLLMENT_TRACK + ] + self._assert_current_settings(expected_response) + + config_course_cohorts(self.course, is_cohorted=True) + expected_response["available_division_schemes"] = [ + CourseDiscussionSettings.COHORT, + CourseDiscussionSettings.ENROLLMENT_TRACK, + ] + self._assert_current_settings(expected_response) + + def test_empty_body_patch_request(self): + """Test the response status code on sending a PATCH request with an empty body or missing fields.""" + self._login_as_staff() + response = self.patch_request("") + assert response.status_code == 400 + + response = self.patch_request({}) + assert response.status_code == 400 + + @ddt.data( + {"abc": 123}, + {"divided_course_wide_discussions": 3}, + {"divided_inline_discussions": "a"}, + {"always_divide_inline_discussions": ["a"]}, + {"division_scheme": True}, + ) + def test_invalid_body_parameters(self, body): + """Test the response status code on sending a PATCH request with parameters having incorrect types.""" + self._login_as_staff() + response = self.patch_request(body) + assert response.status_code == 400 + + def test_update_always_divide_inline_discussion_settings(self): + """Test whether the 'always_divide_inline_discussions' setting is updated.""" + config_course_cohorts(self.course, is_cohorted=True) + self._login_as_staff() + expected_response = self._get_expected_response() + self._assert_current_settings(expected_response) + expected_response["always_divide_inline_discussions"] = True + + self._assert_patched_settings( + {"always_divide_inline_discussions": True}, expected_response + ) + + def test_update_course_wide_discussion_settings(self): + """Test whether the 'divided_course_wide_discussions' setting is updated.""" + discussion_topics = {"Topic B": {"id": "Topic B"}} + config_course_cohorts(self.course, is_cohorted=True) + config_course_discussions(self.course, discussion_topics=discussion_topics) + expected_response = self._get_expected_response() + self._login_as_staff() + self._assert_current_settings(expected_response) + expected_response["divided_course_wide_discussions"] = [ + topic_name_to_id(self.course, "Topic B") + ] + self._assert_patched_settings( + { + "divided_course_wide_discussions": [ + topic_name_to_id(self.course, "Topic B") + ] + }, + expected_response, + ) + expected_response["divided_course_wide_discussions"] = [] + self._assert_patched_settings( + {"divided_course_wide_discussions": []}, expected_response + ) + + def test_update_inline_discussion_settings(self): + """Test whether the 'divided_inline_discussions' setting is updated.""" + config_course_cohorts(self.course, is_cohorted=True) + self._login_as_staff() + expected_response = self._get_expected_response() + self._assert_current_settings(expected_response) + + now = datetime.now() + BlockFactory.create( + parent_location=self.course.location, + category="discussion", + discussion_id="Topic_A", + discussion_category="Chapter", + discussion_target="Discussion", + start=now, + ) + expected_response["divided_inline_discussions"] = [ + "Topic_A", + ] + self._assert_patched_settings( + {"divided_inline_discussions": ["Topic_A"]}, expected_response + ) + + expected_response["divided_inline_discussions"] = [] + self._assert_patched_settings( + {"divided_inline_discussions": []}, expected_response + ) + + def test_update_division_scheme(self): + """Test whether the 'division_scheme' setting is updated.""" + config_course_cohorts(self.course, is_cohorted=True) + self._login_as_staff() + expected_response = self._get_expected_response() + self._assert_current_settings(expected_response) + expected_response["division_scheme"] = "none" + self._assert_patched_settings({"division_scheme": "none"}, expected_response) + + def test_update_reported_content_email_notifications(self): + """Test whether the 'reported_content_email_notifications' setting is updated.""" + config_course_cohorts(self.course, is_cohorted=True) + config_course_discussions( + self.course, reported_content_email_notifications=True + ) + expected_response = self._get_expected_response() + expected_response["reported_content_email_notifications"] = True + self._login_as_staff() + self._assert_current_settings(expected_response) + + +@ddt.ddt +class CourseDiscussionRolesAPIViewTest(APITestCase, UrlResetMixin, ModuleStoreTestCase): + """ + Test the course discussion roles management endpoint. + """ + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def setUp(self): + super().setUp() + mock.patch( + "lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled", + return_value=True, + ).start() + self.addCleanup(mock.patch.stopall) + self.course = CourseFactory.create( + org="x", + course="y", + run="z", + start=datetime.now(UTC), + ) + self.password = self.TEST_PASSWORD + self.user = UserFactory(username="staff", password=self.password, is_staff=True) + course_key = CourseKey.from_string("course-v1:x+y+z") + seed_permissions_roles(course_key) + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def path(self, course_id=None, role=None): + """Return the URL path to the endpoint based on the provided arguments.""" + course_id = str(self.course.id) if course_id is None else course_id + role = "Moderator" if role is None else role + return reverse( + "discussion_course_roles", kwargs={"course_id": course_id, "rolename": role} + ) + + def _get_oauth_headers(self, user): + """Return the OAuth headers for testing OAuth authentication.""" + access_token = AccessTokenFactory.create( + user=user, application=ApplicationFactory() + ).token + headers = {"HTTP_AUTHORIZATION": "Bearer " + access_token} + return headers + + def _login_as_staff(self): + """Log the client is as the staff user.""" + self.client.login(username=self.user.username, password=self.password) + + def _create_and_enroll_users(self, count): + """Create 'count' number of users and enroll them in self.course.""" + users = [] + for _ in range(count): + user = UserFactory() + CourseEnrollmentFactory.create(user=user, course_id=self.course.id) + users.append(user) + return users + + def _add_users_to_role(self, users, rolename): + """Add the given users to the given role.""" + role = Role.objects.get(name=rolename, course_id=self.course.id) + for user in users: + role.users.add(user) + + def post(self, role, user_id, action): + """Make a POST request to the endpoint using the provided parameters.""" + self._login_as_staff() + return self.client.post( + self.path(role=role), {"user_id": user_id, "action": action} + ) + + @ddt.data("get", "post") + def test_authentication_required(self, method): + """Test and verify that authentication is required for this endpoint.""" + self.client.logout() + response = getattr(self.client, method)(self.path()) + assert response.status_code == 401 + + def test_oauth(self): + """Test that OAuth authentication works for this endpoint.""" + oauth_headers = self._get_oauth_headers(self.user) + self.client.logout() + response = self.client.get(self.path(), **oauth_headers) + assert response.status_code == 200 + body = {"user_id": "staff", "action": "allow"} + response = self.client.post(self.path(), body, format="json", **oauth_headers) + assert response.status_code == 200 + + @ddt.data( + {"username": "u1", "is_staff": False, "expected_status": 403}, + {"username": "u2", "is_staff": True, "expected_status": 200}, + ) + @ddt.unpack + def test_staff_permission_required(self, username, is_staff, expected_status): + """Test and verify that only users with staff permission can access this endpoint.""" + UserFactory(username=username, password="edx", is_staff=is_staff) + self.client.login(username=username, password="edx") + response = self.client.get(self.path()) + assert response.status_code == expected_status + + response = self.client.post( + self.path(), {"user_id": username, "action": "allow"}, format="json" + ) + assert response.status_code == expected_status + + def test_non_existent_course_id(self): + """Test the response when the endpoint URL contains a non-existent course id.""" + self._login_as_staff() + path = self.path(course_id="course-v1:a+b+c") + response = self.client.get(path) + + assert response.status_code == 404 + + response = self.client.post(path) + assert response.status_code == 404 + + def test_non_existent_course_role(self): + """Test the response when the endpoint URL contains a non-existent role.""" + self._login_as_staff() + path = self.path(role="A") + response = self.client.get(path) + + assert response.status_code == 400 + + response = self.client.post(path) + assert response.status_code == 400 + + @ddt.data( + {"role": "Moderator", "count": 0}, + {"role": "Moderator", "count": 1}, + {"role": "Group Moderator", "count": 2}, + {"role": "Community TA", "count": 3}, + ) + @ddt.unpack + def test_get_role_members(self, role, count): + """Test the get role members endpoint response.""" + config_course_cohorts(self.course, is_cohorted=True) + users = self._create_and_enroll_users(count=count) + + self._add_users_to_role(users, role) + self._login_as_staff() + response = self.client.get(self.path(role=role)) + + assert response.status_code == 200 + + content = json.loads(response.content.decode("utf-8")) + assert content["course_id"] == "course-v1:x+y+z" + assert len(content["results"]) == count + expected_fields = ("username", "email", "first_name", "last_name", "group_name") + for item in content["results"]: + for expected_field in expected_fields: + assert expected_field in item + assert content["division_scheme"] == "cohort" + + def test_post_missing_body(self): + """Test the response with a POST request without a body.""" + self._login_as_staff() + response = self.client.post(self.path()) + assert response.status_code == 400 + + @ddt.data( + {"a": 1}, + {"user_id": "xyz", "action": "allow"}, + {"user_id": "staff", "action": 123}, + ) + def test_missing_or_invalid_parameters(self, body): + """ + Test the response when the POST request has missing required parameters or + invalid values for the required parameters. + """ + self._login_as_staff() + response = self.client.post(self.path(), body) + assert response.status_code == 400 + + response = self.client.post(self.path(), body, format="json") + assert response.status_code == 400 + + @ddt.data( + {"action": "allow", "user_in_role": False}, + {"action": "allow", "user_in_role": True}, + {"action": "revoke", "user_in_role": False}, + {"action": "revoke", "user_in_role": True}, + ) + @ddt.unpack + def test_post_update_user_role(self, action, user_in_role): + """Test the response when updating the user's role""" + users = self._create_and_enroll_users(count=1) + user = users[0] + role = "Moderator" + if user_in_role: + self._add_users_to_role(users, role) + + response = self.post(role, user.username, action) + assert response.status_code == 200 + content = json.loads(response.content.decode("utf-8")) + assertion = self.assertTrue if action == "allow" else self.assertFalse + assertion(any(user.username in x["username"] for x in content["results"])) + + +@ddt.ddt +@httpretty.activate +@override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True) +class CourseActivityStatsTest( + ForumsEnableMixin, + UrlResetMixin, + CommentsServiceMockMixin, + APITestCase, + SharedModuleStoreTestCase, +): + """ + Tests for the course stats endpoint + """ + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def setUp(self) -> None: + super().setUp() + mock.patch( + "lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled", + return_value=True, + ).start() + self.mock_get_user_course_stats = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.course.forum_api.get_user_course_stats", + ).start() + self.addCleanup(mock.patch.stopall) + self.course = CourseFactory.create() + self.course_key = str(self.course.id) + seed_permissions_roles(self.course.id) + self.user = UserFactory(username="user") + self.moderator = UserFactory(username="moderator") + moderator_role = Role.objects.get(name="Moderator", course_id=self.course.id) + moderator_role.users.add(self.moderator) + self.stats = [ + { + "active_flags": random.randint(0, 3), + "inactive_flags": random.randint(0, 2), + "replies": random.randint(0, 30), + "responses": random.randint(0, 100), + "threads": random.randint(0, 10), + "username": f"user-{idx}", + } + for idx in range(10) + ] + + for stat in self.stats: + user = UserFactory.create( + username=stat["username"], + email=f"{stat['username']}@example.com", + password=self.TEST_PASSWORD, + ) + CourseEnrollment.enroll(user, self.course.id, mode="audit") + + CourseEnrollment.enroll(self.moderator, self.course.id, mode="audit") + self.stats_without_flags = [ + {**stat, "active_flags": None, "inactive_flags": None} + for stat in self.stats + ] + self.register_course_stats_response(self.course_key, self.stats, 1, 3) + self.url = reverse( + "discussion_course_activity_stats", + kwargs={"course_key_string": self.course_key}, + ) + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def test_regular_user(self): + """ + Tests that for a regular user stats are returned without flag counts + """ + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) + response = self.client.get(self.url) + data = response.json() + assert data["results"] == self.stats_without_flags + self.mock_get_user_course_stats.assert_called_once_with( + self.course_key, sort_key="activity", page=1, per_page=10 + ) + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def test_moderator_user(self): + """ + Tests that for a moderator user stats are returned with flag counts + """ + self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD) + response = self.client.get(self.url) + data = response.json() + assert data["results"] == self.stats + self.mock_get_user_course_stats.assert_called_once_with( + self.course_key, sort_key="flagged", page=1, per_page=10 + ) + + @ddt.data( + ("moderator", "flagged", "flagged"), + ("moderator", "activity", "activity"), + ("moderator", "recency", "recency"), + ("moderator", None, "flagged"), + ("user", None, "activity"), + ("user", "activity", "activity"), + ("user", "recency", "recency"), + ) + @ddt.unpack + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def test_sorting(self, username, ordering_requested, ordering_performed): + """ + Test valid sorting options and defaults + """ + self.client.login(username=username, password=self.TEST_PASSWORD) + params = {} + if ordering_requested: + params = {"order_by": ordering_requested} + self.client.get(self.url, params) + self.mock_get_user_course_stats.assert_called_once_with( + self.course_key, sort_key=ordering_performed, page=1, per_page=10 + ) + + @ddt.data("flagged", "xyz") + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def test_sorting_error_regular_user(self, order_by): + """ + Test for invalid sorting options for regular users. + """ + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) + response = self.client.get(self.url, {"order_by": order_by}) + assert "order_by" in response.json()["field_errors"] + + @ddt.data( + ( + "user", + "user-0,user-1,user-2,user-3,user-4,user-5,user-6,user-7,user-8,user-9", + ), + ("moderator", "moderator"), + ) + @ddt.unpack + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def test_with_username_param( + self, username_search_string, comma_separated_usernames + ): + """ + Test for endpoint with username param. + """ + params = {"username": username_search_string} + self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD) + self.client.get(self.url, params) + self.mock_get_user_course_stats.assert_called_once_with( + self.course_key, + sort_key="flagged", + page=1, + per_page=10, + usernames=comma_separated_usernames, + ) + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def test_with_username_param_with_no_matches(self): + """ + Test for endpoint with username param with no matches. + """ + params = {"username": "unknown"} + self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD) + response = self.client.get(self.url, params) + data = response.json() + self.assertFalse(data["results"]) + assert data["pagination"]["count"] == 0 + + @ddt.data("user-0", "USER-1", "User-2", "UsEr-3") + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def test_with_username_param_case(self, username_search_string): + """ + Test user search function is case-insensitive. + """ + response = get_usernames_from_search_string( + self.course_key, username_search_string, 1, 1 + ) + assert response == (username_search_string.lower(), 1, 1) diff --git a/lms/djangoapps/discussion/rest_api/tests/utils_v2.py b/lms/djangoapps/discussion/rest_api/tests/utils_v2.py new file mode 100644 index 000000000000..d164a19e233e --- /dev/null +++ b/lms/djangoapps/discussion/rest_api/tests/utils_v2.py @@ -0,0 +1,591 @@ +""" +Discussion API test utilities + +This module provides utility functions and classes for testing the Discussion API. +It is an adaptation of 'lms/djangoapps/discussion/rest_api/tests/utils.py' for use +with the forum v2 native APIs. +""" + +import hashlib +import json +import re +from contextlib import closing +from datetime import datetime +from urllib.parse import parse_qs + +import httpretty +from PIL import Image +from pytz import UTC + +from openedx.core.djangoapps.profile_images.images import create_profile_images +from openedx.core.djangoapps.profile_images.tests.helpers import make_image_file +from openedx.core.djangoapps.user_api.accounts.image_helpers import ( + get_profile_image_names, + set_has_profile_image, +) + + +def _get_thread_callback(thread_data): + """ + Get a callback function that will return POST/PUT data overridden by + response_overrides. + """ + + def callback(request, _uri, headers): + """ + Simulate the thread creation or update endpoint by returning the provided + data along with the data from response_overrides and dummy values for any + additional required fields. + """ + response_data = make_minimal_cs_thread(thread_data) + original_data = response_data.copy() + for key, val_list in parsed_body(request).items(): + val = val_list[0] + if key in ["anonymous", "anonymous_to_peers", "closed", "pinned"]: + response_data[key] = val == "True" + elif key == "edit_reason_code": + response_data["edit_history"] = [ + { + "original_body": original_data["body"], + "author": thread_data.get("username"), + "reason_code": val, + }, + ] + else: + response_data[key] = val + return (200, headers, json.dumps(response_data)) + + return callback + + +class CommentsServiceMockMixin: + """Mixin with utility methods for mocking the comments service""" + + def register_get_threads_response(self, threads, page, num_pages, overrides={}): + """Register a mock response for GET on the CS thread list endpoint""" + self.mock_get_user_threads.return_value = { + "collection": threads, + "page": page, + "num_pages": num_pages, + "thread_count": len(threads), + **overrides, + } + + def register_get_course_commentable_counts_response(self, course_id, thread_counts): + """Register a mock response for GET on the CS thread list endpoint""" + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + + httpretty.register_uri( + httpretty.GET, + f"http://localhost:4567/api/v1/commentables/{course_id}/counts", + body=json.dumps(thread_counts), + status=200, + ) + + def register_get_threads_search_response(self, threads, rewrite, num_pages=1): + """Register a mock response for GET on the CS thread search endpoint""" + self.mock_search_threads.return_value = { + "collection": threads, + "page": 1, + "num_pages": num_pages, + "corrected_text": rewrite, + "thread_count": len(threads), + } + + def register_post_thread_response(self, thread_data): + """Register a mock response for the create_thread method.""" + self.mock_create_thread.return_value = thread_data + + def register_put_thread_response(self, thread_data): + """ + Register a mock response for PUT on the CS endpoint for the given + thread_id. + """ + self.mock_update_thread.return_value = thread_data + + def register_get_thread_error_response(self, thread_id, status_code): + """Register a mock error response for GET on the CS thread endpoint.""" + self.mock_get_thread.return_value = {"error": status_code} + + def register_get_thread_response(self, thread): + """Register a mock response for the get_thread method.""" + self.mock_get_thread.return_value = thread + + def register_get_comments_response(self, comments, page, num_pages): + """Register a mock response for GET on the CS comments list endpoint""" + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + + httpretty.register_uri( + httpretty.GET, + "http://localhost:4567/api/v1/comments", + body=json.dumps( + { + "collection": comments, + "page": page, + "num_pages": num_pages, + "comment_count": len(comments), + } + ), + status=200, + ) + + def register_post_comment_response(self, comment_data, thread_id, parent_id=None): + """ + Register a mock response for POST on the CS comments endpoint for the + given thread or parent; exactly one of thread_id and parent_id must be + specified. + """ + response_data = make_minimal_cs_comment(comment_data) + original_data = response_data.copy() + # thread_id and parent_id are not included in request payload but + # are returned by the comments service + response_data["thread_id"] = thread_id + response_data["parent_id"] = parent_id + response_data["id"] = comment_data["id"] + for key, val_list in comment_data.items(): + val = val_list[0] if (isinstance(val_list, list) and val_list) else val_list + if key in ["anonymous", "anonymous_to_peers", "endorsed"]: + response_data[key] = val == "True" + elif key == "edit_reason_code": + response_data["edit_history"] = [ + { + "original_body": original_data["body"], + "author": comment_data.get("username"), + "reason_code": val, + }, + ] + else: + response_data[key] = val + if parent_id: + self.mock_create_child_comment.return_value = response_data + else: + self.mock_create_parent_comment.return_value = response_data + + def register_put_comment_response(self, comment_data): + """ + Register a mock response for PUT on the CS endpoint for the given + comment data (which must include the key "id"). + """ + thread_id = comment_data["thread_id"] + parent_id = comment_data.get("parent_id") + response_data = make_minimal_cs_comment(comment_data) + original_data = response_data.copy() + # thread_id and parent_id are not included in request payload but + # are returned by the comments service + response_data["thread_id"] = thread_id + response_data["parent_id"] = parent_id + response_data["id"] = comment_data["id"] + for key, val_list in comment_data.items(): + if isinstance(val_list, list) and val_list: + val = val_list[0] + else: + val = val_list + if key in ["anonymous", "anonymous_to_peers", "endorsed"]: + response_data[key] = val == "True" + elif key == "edit_reason_code": + response_data["edit_history"] = [ + { + "original_body": original_data["body"], + "author": comment_data.get("username"), + "reason_code": val, + }, + ] + else: + response_data[key] = val + self.mock_update_comment.return_value = response_data + + def register_get_comment_error_response(self, comment_id, status_code): + """ + Register a mock error response for GET on the CS comment instance + endpoint. + """ + self.mock_get_parent_comment.side_effect = Exception("404 Not Found") + + def register_get_comment_response(self, response_overrides): + """ + Register a mock response for GET on the CS comment instance endpoint. + """ + comment = make_minimal_cs_comment(response_overrides) + self.mock_get_parent_comment.return_value = comment + + def register_get_user_response( + self, user, subscribed_thread_ids=None, upvoted_ids=None + ): + """Register a mock response for the get_user method.""" + self.mock_get_user.return_value = { + "id": str(user.id), + "subscribed_thread_ids": subscribed_thread_ids or [], + "upvoted_ids": upvoted_ids or [], + } + + def register_get_user_retire_response(self, user, status=200, body=""): + """Register a mock response for GET on the CS user retirement endpoint""" + self.mock_retire_user.return_value = { + "user_id": user.id, + "retired_username": user.username, + } + + def register_get_username_replacement_response(self, user, status=200, body=""): + self.mock_update_username.return_value = body + + def register_subscribed_threads_response(self, user, threads, page, num_pages): + """Register a mock response for GET on the CS user instance endpoint""" + self.mock_get_user_threads.return_value = { + "collection": threads, + "page": page, + "num_pages": num_pages, + "thread_count": len(threads), + } + + def register_course_stats_response(self, course_key, stats, page, num_pages): + """Register a mock response for GET on the CS user course stats instance endpoint""" + self.mock_get_user_course_stats.return_value = { + "user_stats": stats, + "page": page, + "num_pages": num_pages, + "count": len(stats), + } + + def register_subscription_response(self, user): + """ + Register a mock response for POST and DELETE on the CS user subscription + endpoint + """ + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + for method in [httpretty.POST, httpretty.DELETE]: + httpretty.register_uri( + method, + f"http://localhost:4567/api/v1/users/{user.id}/subscriptions", + body=json.dumps({}), # body is unused + status=200, + ) + + def register_thread_votes_response(self, thread_id): + """ + Register a mock response for PUT and DELETE on the CS thread votes + endpoint + """ + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + for method in [httpretty.PUT, httpretty.DELETE]: + httpretty.register_uri( + method, + f"http://localhost:4567/api/v1/threads/{thread_id}/votes", + body=json.dumps({}), # body is unused + status=200, + ) + + def register_comment_votes_response(self, comment_id): + """ + Register a mock response for PUT and DELETE on the CS comment votes + endpoint + """ + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + for method in [httpretty.PUT, httpretty.DELETE]: + httpretty.register_uri( + method, + f"http://localhost:4567/api/v1/comments/{comment_id}/votes", + body=json.dumps({}), # body is unused + status=200, + ) + + def register_flag_response(self, content_type, content_id): + """Register a mock response for PUT on the CS flag endpoints""" + self.mock_update_thread_flag.return_value = {} + self.mock_update_thread_flag_in_comment.return_value = {} + self.mock_update_comment_flag.return_value = {} + + def register_read_response(self, user, content_type, content_id): + """ + Register a mock response for POST on the CS 'read' endpoint + """ + self.mock_mark_thread_as_read.return_value = {} + + 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 + """ + self.mock_delete_thread.return_value = {} + + def register_delete_comment_response(self, comment_id): + """ + Register a mock response for DELETE on the CS comment instance endpoint + """ + self.mock_delete_comment.return_value = {} + + def register_user_active_threads(self, user_id, response): + """ + Register a mock response for GET on the CS comment active threads endpoint + """ + self.mock_get_user_active_threads.return_value = response + + def register_get_subscriptions(self, thread_id, response): + """ + Register a mock response for GET on the CS comment active threads endpoint + """ + assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." + httpretty.register_uri( + httpretty.GET, + f"http://localhost:4567/api/v1/threads/{thread_id}/subscriptions", + body=json.dumps(response), + status=200, + ) + + def assert_query_params_equal(self, httpretty_request, expected_params): + """ + Assert that the given mock request had the expected query parameters + """ + actual_params = dict(querystring(httpretty_request)) + actual_params.pop("request_id") # request_id is random + assert actual_params == expected_params + + # def assert_last_query_params(self, expected_params): + # """ + # Assert that the last mock request had the expected query parameters + # """ + # self.assert_query_params_equal(httpretty.last_request(), expected_params) + + def request_patch(self, request_data): + """ + make a request to PATCH endpoint and return response + """ + return self.client.patch( + self.url, + json.dumps(request_data), + content_type="application/merge-patch+json", + ) + + def expected_thread_data(self, overrides=None): + """ + Returns expected thread data in API response + """ + response_data = { + "anonymous": False, + "anonymous_to_peers": False, + "author": self.user.username, + "author_label": None, + "created_at": "1970-01-01T00:00:00Z", + "updated_at": "1970-01-01T00:00:00Z", + "raw_body": "Test body", + "rendered_body": "

Test body

", + "preview_body": "Test body", + "abuse_flagged": False, + "abuse_flagged_count": None, + "voted": False, + "vote_count": 0, + "editable_fields": [ + "abuse_flagged", + "anonymous", + "copy_link", + "following", + "raw_body", + "read", + "title", + "topic_id", + "type", + ], + "course_id": str(self.course.id), + "topic_id": "test_topic", + "group_id": None, + "group_name": None, + "title": "Test Title", + "pinned": False, + "closed": False, + "can_delete": True, + "following": False, + "comment_count": 1, + "unread_comment_count": 0, + "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread", + "endorsed_comment_list_url": None, + "non_endorsed_comment_list_url": None, + "read": False, + "has_endorsed": False, + "id": "test_thread", + "type": "discussion", + "response_count": 0, + "last_edit": None, + "edit_by_label": None, + "closed_by": None, + "closed_by_label": None, + "close_reason": None, + "close_reason_code": None, + } + response_data.update(overrides or {}) + return response_data + + +def make_minimal_cs_thread(overrides=None): + """ + Create a dictionary containing all needed thread fields as returned by the + comments service with dummy data and optional overrides + """ + ret = { + "type": "thread", + "id": "dummy", + "course_id": "course-v1:dummy+dummy+dummy", + "commentable_id": "dummy", + "group_id": None, + "user_id": "0", + "username": "dummy", + "anonymous": False, + "anonymous_to_peers": False, + "created_at": "1970-01-01T00:00:00Z", + "updated_at": "1970-01-01T00:00:00Z", + "last_activity_at": "1970-01-01T00:00:00Z", + "thread_type": "discussion", + "title": "dummy", + "body": "dummy", + "pinned": False, + "closed": False, + "abuse_flaggers": [], + "abuse_flagged_count": None, + "votes": {"up_count": 0}, + "comments_count": 0, + "unread_comments_count": 0, + "children": [], + "read": False, + "endorsed": False, + "resp_total": 0, + "closed_by": None, + "close_reason_code": None, + } + ret.update(overrides or {}) + return ret + + +def make_minimal_cs_comment(overrides=None): + """ + Create a dictionary containing all needed comment fields as returned by the + comments service with dummy data and optional overrides + """ + ret = { + "type": "comment", + "id": "dummy", + "commentable_id": "dummy", + "thread_id": "dummy", + "parent_id": None, + "user_id": "0", + "username": "dummy", + "anonymous": False, + "anonymous_to_peers": False, + "created_at": "1970-01-01T00:00:00Z", + "updated_at": "1970-01-01T00:00:00Z", + "body": "dummy", + "abuse_flaggers": [], + "votes": {"up_count": 0}, + "endorsed": False, + "child_count": 0, + "children": [], + } + ret.update(overrides or {}) + return ret + + +def make_paginated_api_response( + results=None, count=0, num_pages=0, next_link=None, previous_link=None +): + """ + Generates the response dictionary of paginated APIs with passed data + """ + return { + "pagination": { + "next": next_link, + "previous": previous_link, + "count": count, + "num_pages": num_pages, + }, + "results": results or [], + } + + +class ProfileImageTestMixin: + """ + Mixin with utility methods for user profile image + """ + + TEST_PROFILE_IMAGE_UPLOADED_AT = datetime(2002, 1, 9, 15, 43, 1, tzinfo=UTC) + + def create_profile_image(self, user, storage): + """ + Creates profile image for user and checks that created image exists in storage + """ + with make_image_file() as image_file: + create_profile_images(image_file, get_profile_image_names(user.username)) + self.check_images(user, storage) + set_has_profile_image( + user.username, True, self.TEST_PROFILE_IMAGE_UPLOADED_AT + ) + + def check_images(self, user, storage, exist=True): + """ + If exist is True, make sure the images physically exist in storage + with correct sizes and formats. + + If exist is False, make sure none of the images exist. + """ + for size, name in get_profile_image_names(user.username).items(): + if exist: + assert storage.exists(name) + with closing(Image.open(storage.path(name))) as img: + assert img.size == (size, size) + assert img.format == "JPEG" + else: + assert not storage.exists(name) + + def get_expected_user_profile(self, username): + """ + Returns the expected user profile data for a given username + """ + url = "http://example-storage.com/profile-images/{filename}_{{size}}.jpg?v={timestamp}".format( + filename=hashlib.md5(b"secret" + username.encode("utf-8")).hexdigest(), + timestamp=self.TEST_PROFILE_IMAGE_UPLOADED_AT.strftime("%s"), + ) + return { + "profile": { + "image": { + "has_image": True, + "image_url_full": url.format(size=500), + "image_url_large": url.format(size=120), + "image_url_medium": url.format(size=50), + "image_url_small": url.format(size=30), + } + } + } + + +def parsed_body(request): + """Returns a parsed dictionary version of a request body""" + # This could just be HTTPrettyRequest.parsed_body, but that method double-decodes '%2B' -> '+' -> ' '. + # You can just remove this method when this issue is fixed: https://github.com/gabrielfalcao/HTTPretty/issues/240 + return parse_qs(request.body.decode("utf8")) + + +def querystring(request): + """Returns a parsed dictionary version of a query string""" + # This could just be HTTPrettyRequest.querystring, but that method double-decodes '%2B' -> '+' -> ' '. + # You can just remove this method when this issue is fixed: https://github.com/gabrielfalcao/HTTPretty/issues/240 + return parse_qs(request.path.split("?", 1)[-1]) + + +class ThreadMock(object): + """ + A mock thread object + """ + + def __init__(self, thread_id, creator, title, parent_id=None, body=""): + self.id = thread_id + self.user_id = str(creator.id) + self.username = creator.username + self.title = title + self.parent_id = parent_id + self.body = body + + def url_with_id(self, params): + return f"http://example.com/{params['id']}" diff --git a/lms/djangoapps/discussion/tests/test_tasks.py b/lms/djangoapps/discussion/tests/test_tasks.py index 92dadac9d9ee..6fdaf21f428e 100644 --- a/lms/djangoapps/discussion/tests/test_tasks.py +++ b/lms/djangoapps/discussion/tests/test_tasks.py @@ -232,6 +232,19 @@ def setUp(self): thread_permalink = '/courses/discussion/dummy_discussion_id' self.permalink_patcher = mock.patch('lms.djangoapps.discussion.tasks.permalink', return_value=thread_permalink) self.mock_permalink = self.permalink_patcher.start() + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) def tearDown(self): super().tearDown() diff --git a/lms/djangoapps/discussion/tests/test_views.py b/lms/djangoapps/discussion/tests/test_views.py index e0d3b869da3d..5e7fed4ae717 100644 --- a/lms/djangoapps/discussion/tests/test_views.py +++ b/lms/djangoapps/discussion/tests/test_views.py @@ -4,6 +4,7 @@ import json import logging from datetime import datetime +from unittest import mock from unittest.mock import ANY, Mock, call, patch import ddt @@ -109,9 +110,17 @@ def setUp(self): config = ForumsConfig.current() config.enabled = True config.save() + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) @patch('common.djangoapps.student.models.user.cc.User.from_django_user') - @patch('common.djangoapps.student.models.user.cc.User.active_threads') + @patch('openedx.core.djangoapps.django_comment_common.comment_client.user.User.active_threads') def test_user_profile_exception(self, mock_threads, mock_from_django_user): # Mock the code that makes the HTTP requests to the cs_comment_service app @@ -323,6 +332,14 @@ class SingleThreadTestCase(ForumsEnableMixin, ModuleStoreTestCase): # lint-amne def setUp(self): super().setUp() + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.course = CourseFactory.create(discussion_topics={'dummy discussion': {'id': 'dummy_discussion_id'}}) self.student = UserFactory.create() @@ -513,6 +530,17 @@ class SingleThreadQueryCountTestCase(ForumsEnableMixin, ModuleStoreTestCase): Ensures the number of modulestore queries and number of sql queries are independent of the number of responses retrieved for a given discussion thread. """ + def setUp(self): + super().setUp() + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + @ddt.data( # split mongo: 3 queries, regardless of thread response size. (False, 1, 2, 2, 21, 8), @@ -582,6 +610,17 @@ def call_single_thread(): @patch('requests.request', autospec=True) class SingleCohortedThreadTestCase(CohortedTestCase): # lint-amnesty, pylint: disable=missing-class-docstring + def setUp(self): + super().setUp() + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + def _create_mock_cohorted_thread(self, mock_request): # lint-amnesty, pylint: disable=missing-function-docstring mock_text = "dummy content" mock_thread_id = "test_thread_id" @@ -644,6 +683,17 @@ def test_html(self, mock_request): @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) class SingleThreadAccessTestCase(CohortedTestCase): # lint-amnesty, pylint: disable=missing-class-docstring + def setUp(self): + super().setUp() + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + def call_view(self, mock_request, commentable_id, user, group_id, thread_group_id=None, pass_group_id=True): # lint-amnesty, pylint: disable=missing-function-docstring thread_id = "test_thread_id" mock_request.side_effect = make_mock_request_impl( @@ -746,6 +796,17 @@ def test_private_team_thread(self, mock_request): class SingleThreadGroupIdTestCase(CohortedTestCase, GroupIdAssertionMixin): # lint-amnesty, pylint: disable=missing-class-docstring cs_endpoint = "/threads/dummy_thread_id" + def setUp(self): + super().setUp() + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id=True, is_ajax=False): # lint-amnesty, pylint: disable=missing-function-docstring mock_request.side_effect = make_mock_request_impl( course=self.course, text="dummy context", group_id=self.student_cohort.id @@ -881,6 +942,19 @@ class SingleThreadContentGroupTestCase(ForumsEnableMixin, UrlResetMixin, Content @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) def assert_can_access(self, user, discussion_id, thread_id, should_have_access): """ @@ -1046,6 +1120,7 @@ def test_private_team_discussion(self, mock_request): @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) +@patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', autospec=True) class InlineDiscussionGroupIdTestCase( # lint-amnesty, pylint: disable=missing-class-docstring CohortedTestCase, CohortedTopicGroupIdTestMixin, @@ -1056,8 +1131,22 @@ class InlineDiscussionGroupIdTestCase( # lint-amnesty, pylint: disable=missing- def setUp(self): super().setUp() self.cohorted_commentable_id = 'cohorted_topic' - - def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id=True): + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + + def call_view( + self, + mock_is_forum_v2_enabled, + mock_request, + commentable_id, + user, + group_id, + pass_group_id=True + ): # pylint: disable=arguments-differ + mock_is_forum_v2_enabled.return_value = False kwargs = {'commentable_id': self.cohorted_commentable_id} if group_id: # avoid causing a server error when the LMS chokes attempting @@ -1084,8 +1173,9 @@ def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id= commentable_id ) - def test_group_info_in_ajax_response(self, mock_request): + def test_group_info_in_ajax_response(self, mock_is_forum_v2_enabled, mock_request): response = self.call_view( + mock_is_forum_v2_enabled, mock_request, self.cohorted_commentable_id, self.student, @@ -1097,10 +1187,29 @@ def test_group_info_in_ajax_response(self, mock_request): @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) +@patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', autospec=True) class ForumFormDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGroupIdTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring cs_endpoint = "/threads" - def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id=True, is_ajax=False): # pylint: disable=arguments-differ + def setUp(self): + super().setUp() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + + def call_view( + self, + mock_is_forum_v2_enabled, + mock_request, + commentable_id, + user, + group_id, + pass_group_id=True, + is_ajax=False + ): # pylint: disable=arguments-differ + mock_is_forum_v2_enabled.return_value = False kwargs = {} if group_id: kwargs['group_id'] = group_id @@ -1120,8 +1229,9 @@ def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id= **headers ) - def test_group_info_in_html_response(self, mock_request): + def test_group_info_in_html_response(self, mock_is_forum_v2_enabled, mock_request): response = self.call_view( + mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.student, @@ -1129,8 +1239,9 @@ def test_group_info_in_html_response(self, mock_request): ) self._assert_html_response_contains_group_info(response) - def test_group_info_in_ajax_response(self, mock_request): + def test_group_info_in_ajax_response(self, mock_is_forum_v2_enabled, mock_request): response = self.call_view( + mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.student, @@ -1143,16 +1254,38 @@ def test_group_info_in_ajax_response(self, mock_request): @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) +@patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', autospec=True) class UserProfileDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGroupIdTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring cs_endpoint = "/active_threads" + def setUp(self): + super().setUp() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + def call_view_for_profiled_user( - self, mock_request, requesting_user, profiled_user, group_id, pass_group_id, is_ajax=False + self, + mock_is_forum_v2_enabled, + mock_request, + requesting_user, + profiled_user, + group_id, + pass_group_id, + is_ajax=False ): """ Calls "user_profile" view method on behalf of "requesting_user" to get information about the user "profiled_user". """ + mock_is_forum_v2_enabled.return_value = False kwargs = {} if group_id: kwargs['group_id'] = group_id @@ -1172,13 +1305,23 @@ def call_view_for_profiled_user( **headers ) - def call_view(self, mock_request, _commentable_id, user, group_id, pass_group_id=True, is_ajax=False): # pylint: disable=arguments-differ + def call_view( + self, + mock_is_forum_v2_enabled, + mock_request, + _commentable_id, + user, + group_id, + pass_group_id=True, + is_ajax=False + ): # pylint: disable=arguments-differ return self.call_view_for_profiled_user( - mock_request, user, user, group_id, pass_group_id=pass_group_id, is_ajax=is_ajax + mock_is_forum_v2_enabled, mock_request, user, user, group_id, pass_group_id=pass_group_id, is_ajax=is_ajax ) - def test_group_info_in_html_response(self, mock_request): + def test_group_info_in_html_response(self, mock_is_forum_v2_enabled, mock_request): response = self.call_view( + mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.student, @@ -1187,8 +1330,9 @@ def test_group_info_in_html_response(self, mock_request): ) self._assert_html_response_contains_group_info(response) - def test_group_info_in_ajax_response(self, mock_request): + def test_group_info_in_ajax_response(self, mock_is_forum_v2_enabled, mock_request): response = self.call_view( + mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.student, @@ -1200,7 +1344,14 @@ def test_group_info_in_ajax_response(self, mock_request): ) def _test_group_id_passed_to_user_profile( - self, mock_request, expect_group_id_in_request, requesting_user, profiled_user, group_id, pass_group_id + self, + mock_is_forum_v2_enabled, + mock_request, + expect_group_id_in_request, + requesting_user, + profiled_user, + group_id, + pass_group_id ): """ Helper method for testing whether or not group_id was passed to the user_profile request. @@ -1225,6 +1376,7 @@ def get_params_from_user_info_call(for_specific_course): mock_request.reset_mock() self.call_view_for_profiled_user( + mock_is_forum_v2_enabled, mock_request, requesting_user, profiled_user, @@ -1243,7 +1395,7 @@ def get_params_from_user_info_call(for_specific_course): else: assert 'group_id' not in params_with_course_id - def test_group_id_passed_to_user_profile_student(self, mock_request): + def test_group_id_passed_to_user_profile_student(self, mock_is_forum_v2_enabled, mock_request): """ Test that the group id is always included when requesting user profile information for a particular course if the requester does not have discussion moderation privileges. @@ -1254,7 +1406,13 @@ def verify_group_id_always_present(profiled_user, pass_group_id): (non-privileged user). """ self._test_group_id_passed_to_user_profile( - mock_request, True, self.student, profiled_user, self.student_cohort.id, pass_group_id + mock_is_forum_v2_enabled, + mock_request, + True, + self.student, + profiled_user, + self.student_cohort.id, + pass_group_id ) # In all these test cases, the requesting_user is the student (non-privileged user). @@ -1264,7 +1422,7 @@ def verify_group_id_always_present(profiled_user, pass_group_id): verify_group_id_always_present(profiled_user=self.moderator, pass_group_id=True) verify_group_id_always_present(profiled_user=self.moderator, pass_group_id=False) - def test_group_id_user_profile_moderator(self, mock_request): + def test_group_id_user_profile_moderator(self, mock_is_forum_v2_enabled, mock_request): """ Test that the group id is only included when a privileged user requests user profile information for a particular course and user if the group_id is explicitly passed in. @@ -1274,7 +1432,13 @@ def verify_group_id_present(profiled_user, pass_group_id, requested_cohort=self. Helper method to verify that group_id is present. """ self._test_group_id_passed_to_user_profile( - mock_request, True, self.moderator, profiled_user, requested_cohort.id, pass_group_id + mock_is_forum_v2_enabled, + mock_request, + True, + self.moderator, + profiled_user, + requested_cohort.id, + pass_group_id ) def verify_group_id_not_present(profiled_user, pass_group_id, requested_cohort=self.moderator_cohort): @@ -1282,7 +1446,13 @@ def verify_group_id_not_present(profiled_user, pass_group_id, requested_cohort=s Helper method to verify that group_id is not present. """ self._test_group_id_passed_to_user_profile( - mock_request, False, self.moderator, profiled_user, requested_cohort.id, pass_group_id + mock_is_forum_v2_enabled, + mock_request, + False, + self.moderator, + profiled_user, + requested_cohort.id, + pass_group_id ) # In all these test cases, the requesting_user is the moderator (privileged user). @@ -1301,10 +1471,28 @@ def verify_group_id_not_present(profiled_user, pass_group_id, requested_cohort=s @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) +@patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', autospec=True) class FollowedThreadsDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGroupIdTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring cs_endpoint = "/subscribed_threads" - def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id=True): + def setUp(self): + super().setUp() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + + def call_view( + self, + mock_is_forum_v2_enabled, + mock_request, + commentable_id, + user, + group_id, + pass_group_id=True + ): # pylint: disable=arguments-differ + mock_is_forum_v2_enabled.return_value = False kwargs = {} if group_id: kwargs['group_id'] = group_id @@ -1325,8 +1513,9 @@ def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id= user.id ) - def test_group_info_in_ajax_response(self, mock_request): + def test_group_info_in_ajax_response(self, mock_is_forum_v2_enabled, mock_request): response = self.call_view( + mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.student, @@ -1528,6 +1717,19 @@ class CommentsServiceRequestHeadersTestCase(ForumsEnableMixin, UrlResetMixin, Mo @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) username = "foo" password = "bar" @@ -1742,6 +1944,17 @@ def setUpClass(cls): with super().setUpClassAndTestData(): cls.course = CourseFactory.create(discussion_topics={'dummy_discussion_id': {'id': 'dummy_discussion_id'}}) + def setUp(self): + super().setUp() + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + @classmethod def setUpTestData(cls): super().setUpTestData() @@ -1858,7 +2071,14 @@ class EnterpriseConsentTestCase(EnterpriseTestConsentRequired, ForumsEnableMixin def setUp(self): # Invoke UrlResetMixin setUp super().setUp() - + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) username = "foo" password = "bar" @@ -2195,6 +2415,14 @@ class ThreadViewedEventTestCase(EventTestMixin, ForumsEnableMixin, UrlResetMixin def setUp(self): # pylint: disable=arguments-differ super().setUp('lms.djangoapps.discussion.django_comment_client.base.views.tracker') + patcher = mock.patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=False) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.course = CourseFactory.create( teams_configuration=TeamsConfig({ 'topics': [{ diff --git a/lms/djangoapps/discussion/toggles.py b/lms/djangoapps/discussion/toggles.py index a1c292a4734f..87966c5c36cb 100644 --- a/lms/djangoapps/discussion/toggles.py +++ b/lms/djangoapps/discussion/toggles.py @@ -1,6 +1,7 @@ """ Discussions feature toggles """ + from openedx.core.djangoapps.discussions.config.waffle import WAFFLE_FLAG_NAMESPACE from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag @@ -11,4 +12,26 @@ # .. toggle_use_cases: temporary, open_edx # .. toggle_creation_date: 2021-11-05 # .. toggle_target_removal_date: 2022-12-05 -ENABLE_DISCUSSIONS_MFE = CourseWaffleFlag(f'{WAFFLE_FLAG_NAMESPACE}.enable_discussions_mfe', __name__) +ENABLE_DISCUSSIONS_MFE = CourseWaffleFlag( + f"{WAFFLE_FLAG_NAMESPACE}.enable_discussions_mfe", __name__ +) + +FORUM_V2_WAFFLE_FLAG_NAMESPACE = "forum_v2" + +# .. toggle_name: forum_v2.enable_forum_v2 +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to use the forum v2 instead of v1(cs_comment_service) +# .. toggle_use_cases: temporary, open_edx +# .. toggle_creation_date: 2024-9-26 +# .. toggle_target_removal_date: 2025-12-05 +ENABLE_FORUM_V2 = CourseWaffleFlag( + f"{FORUM_V2_WAFFLE_FLAG_NAMESPACE}.enable_forum_v2", __name__ +) + + +def is_forum_v2_enabled(course_id): + """ + Returns a boolean if forum V2 is enabled on the course + """ + return ENABLE_FORUM_V2.is_enabled(course_id) diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/comment.py b/openedx/core/djangoapps/django_comment_common/comment_client/comment.py index c86f7eb40515..b46bb933bd35 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/comment.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/comment.py @@ -4,7 +4,9 @@ from openedx.core.djangoapps.django_comment_common.comment_client import models, settings from .thread import Thread, _url_for_flag_abuse_thread, _url_for_unflag_abuse_thread -from .utils import CommentClientRequestError, perform_request +from .utils import CommentClientRequestError, get_course_key, perform_request +from forum import api as forum_api +from lms.djangoapps.discussion.toggles import is_forum_v2_enabled class Comment(models.Model): @@ -68,14 +70,31 @@ def flagAbuse(self, user, voteable): url = _url_for_flag_abuse_comment(voteable.id) else: raise CommentClientRequestError("Can only flag/unflag threads or comments") - params = {'user_id': user.id} - response = perform_request( - 'put', - url, - params, - metric_tags=self._metric_tags, - metric_action='comment.abuse.flagged' - ) + course_key = get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + if voteable.type == 'thread': + response = forum_api.update_thread_flag( + voteable.id, + "flag", + user.id, + str(course_key) if course_key else course_key + ) + else: + response = forum_api.update_comment_flag( + voteable.id, + "flag", + user.id, + str(course_key) if course_key else course_key + ) + else: + params = {'user_id': user.id} + response = perform_request( + 'put', + url, + params, + metric_tags=self._metric_tags, + metric_action='comment.abuse.flagged' + ) voteable._update_from_response(response) def unFlagAbuse(self, user, voteable, removeAll): @@ -85,18 +104,37 @@ def unFlagAbuse(self, user, voteable, removeAll): url = _url_for_unflag_abuse_comment(voteable.id) else: raise CommentClientRequestError("Can flag/unflag for threads or comments") - params = {'user_id': user.id} - - if removeAll: - params['all'] = True - - response = perform_request( - 'put', - url, - params, - metric_tags=self._metric_tags, - metric_action='comment.abuse.unflagged' - ) + course_key = get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + if voteable.type == "thread": + response = forum_api.update_thread_flag( + thread_id=voteable.id, + action="unflag", + user_id=user.id, + update_all=bool(removeAll), + course_id=str(course_key) if course_key else course_key + ) + else: + response = forum_api.update_comment_flag( + comment_id=voteable.id, + action="unflag", + user_id=user.id, + update_all=bool(removeAll), + course_id=str(course_key) if course_key else course_key + ) + else: + params = {'user_id': user.id} + + if removeAll: + params['all'] = True + + response = perform_request( + 'put', + url, + params, + metric_tags=self._metric_tags, + metric_action='comment.abuse.unflagged' + ) voteable._update_from_response(response) @property diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/course.py b/openedx/core/djangoapps/django_comment_common/comment_client/course.py index 67d7efd22838..67d1db82df93 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/course.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/course.py @@ -7,6 +7,8 @@ from edx_django_utils.monitoring import function_trace from opaque_keys.edx.keys import CourseKey +from forum import api as forum_api +from lms.djangoapps.discussion.toggles import is_forum_v2_enabled from openedx.core.djangoapps.django_comment_common.comment_client import settings from openedx.core.djangoapps.django_comment_common.comment_client.utils import perform_request @@ -29,17 +31,20 @@ def get_course_commentable_counts(course_key: CourseKey) -> Dict[str, Dict[str, } """ - url = f"{settings.PREFIX}/commentables/{course_key}/counts" - response = perform_request( - 'get', - url, - metric_tags=[ - f"course_key:{course_key}", - "function:get_course_commentable_counts", - ], - metric_action='commentable_stats.retrieve', - ) - return response + if is_forum_v2_enabled(course_key): + commentable_stats = forum_api.get_commentables_stats(str(course_key)) + else: + url = f"{settings.PREFIX}/commentables/{course_key}/counts" + commentable_stats = perform_request( + 'get', + url, + metric_tags=[ + f"course_key:{course_key}", + "function:get_course_commentable_counts", + ], + metric_action='commentable_stats.retrieve', + ) + return commentable_stats @function_trace("get_course_user_stats") @@ -76,17 +81,21 @@ def get_course_user_stats(course_key: CourseKey, params: Optional[Dict] = None) """ if params is None: params = {} - url = f"{settings.PREFIX}/users/{course_key}/stats" - return perform_request( - 'get', - url, - params, - metric_action='user.course_stats', - metric_tags=[ - f"course_key:{course_key}", - "function:get_course_user_stats", - ], - ) + if is_forum_v2_enabled(course_key): + course_stats = forum_api.get_user_course_stats(str(course_key), **params) + else: + url = f"{settings.PREFIX}/users/{course_key}/stats" + course_stats = perform_request( + 'get', + url, + params, + metric_action='user.course_stats', + metric_tags=[ + f"course_key:{course_key}", + "function:get_course_user_stats", + ], + ) + return course_stats @function_trace("update_course_users_stats") @@ -100,13 +109,17 @@ def update_course_users_stats(course_key: CourseKey) -> Dict: Returns: dict: data returned by API. Contains count of users updated. """ - url = f"{settings.PREFIX}/users/{course_key}/update_stats" - return perform_request( - 'post', - url, - metric_action='user.update_course_stats', - metric_tags=[ - f"course_key:{course_key}", - "function:update_course_users_stats", - ], - ) + if is_forum_v2_enabled(course_key): + course_stats = forum_api.update_users_in_course(str(course_key)) + else: + url = f"{settings.PREFIX}/users/{course_key}/update_stats" + course_stats = perform_request( + 'post', + url, + metric_action='user.update_course_stats', + metric_tags=[ + f"course_key:{course_key}", + "function:update_course_users_stats", + ], + ) + return course_stats diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/models.py b/openedx/core/djangoapps/django_comment_common/comment_client/models.py index 4e602809c82a..d8d29220d9cc 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/models.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/models.py @@ -3,7 +3,9 @@ import logging -from .utils import CommentClientRequestError, extract, perform_request +from .utils import CommentClientRequestError, extract, perform_request, get_course_key +from forum import api as forum_api +from lms.djangoapps.discussion.toggles import is_forum_v2_enabled log = logging.getLogger(__name__) @@ -69,14 +71,28 @@ def retrieve(self, *args, **kwargs): return self def _retrieve(self, *args, **kwargs): - url = self.url(action='get', params=self.attributes) - response = perform_request( - 'get', - url, - self.default_retrieve_params, - metric_tags=self._metric_tags, - metric_action='model.retrieve' - ) + course_id = self.attributes.get("course_id") or kwargs.get("course_id") + if not course_id: + course_id = forum_api.get_course_id_by_comment(self.id) + course_key = get_course_key(course_id) + response = None + if is_forum_v2_enabled(course_key): + if self.type == "comment": + response = forum_api.get_parent_comment( + comment_id=self.attributes["id"], + course_id=str(course_key) if course_key else course_key + ) + if response is None: + raise CommentClientRequestError("Forum v2 API call is missing") + else: + url = self.url(action='get', params=self.attributes) + response = perform_request( + 'get', + url, + self.default_retrieve_params, + metric_tags=self._metric_tags, + metric_action='model.retrieve' + ) self._update_from_response(response) @property @@ -151,33 +167,33 @@ def save(self, params=None): """ self.before_save(self) if self.id: # if we have id already, treat this as an update - request_params = self.updatable_attributes() - if params: - request_params.update(params) - url = self.url(action='put', params=self.attributes) - response = perform_request( - 'put', - url, - request_params, - metric_tags=self._metric_tags, - metric_action='model.update' - ) - else: # otherwise, treat this as an insert - url = self.url(action='post', params=self.attributes) - response = perform_request( - 'post', - url, - self.initializable_attributes(), - metric_tags=self._metric_tags, - metric_action='model.insert' - ) + response = self.handle_update(params) + else: # otherwise, treat this as an insert + response = self.handle_create(params) + self.retrieved = True self._update_from_response(response) self.after_save(self) def delete(self): - url = self.url(action='delete', params=self.attributes) - response = perform_request('delete', url, metric_tags=self._metric_tags, metric_action='model.delete') + course_key = get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + response = None + if self.type == "comment": + response = forum_api.delete_comment( + comment_id=self.attributes["id"], + course_id=str(course_key) if course_key else course_key + ) + elif self.type == "thread": + response = forum_api.delete_thread( + thread_id=self.attributes["id"], + course_id=str(course_key) if course_key else course_key + ) + if response is None: + raise CommentClientRequestError("Forum v2 API call is missing") + else: + url = self.url(action='delete', params=self.attributes) + response = perform_request('delete', url, metric_tags=self._metric_tags, metric_action='model.delete') self.retrieved = True self._update_from_response(response) @@ -208,3 +224,157 @@ def url(cls, action, params=None): raise CommentClientRequestError(f"Cannot perform action {action} without id") # lint-amnesty, pylint: disable=raise-missing-from else: # action must be in DEFAULT_ACTIONS_WITHOUT_ID now return cls.url_without_id() + + def handle_update(self, params=None): + request_params = self.updatable_attributes() + if params: + request_params.update(params) + course_id = self.attributes.get("course_id") or request_params.get("course_id") + course_key = get_course_key(course_id) + if is_forum_v2_enabled(course_key): + response = None + if self.type == "comment": + response = self.handle_update_comment(request_params, str(course_key) if course_key else course_key) + elif self.type == "thread": + response = self.handle_update_thread(request_params, str(course_key) if course_key else course_key) + elif self.type == "user": + response = self.handle_update_user(request_params, str(course_key) if course_key else course_key) + if response is None: + raise CommentClientRequestError("Forum v2 API call is missing") + else: + response = self.perform_http_put_request(request_params) + return response + + def handle_update_user(self, request_params, course_id): + try: + username = request_params["username"] + external_id = str(request_params["external_id"]) + except KeyError as e: + raise e + response = forum_api.update_user( + external_id, + username, + course_id, + ) + return response + + def handle_update_comment(self, request_params, course_id): + request_data = { + "comment_id": self.attributes["id"], + "body": request_params.get("body"), + "course_id": request_params.get("course_id"), + "user_id": request_params.get("user_id"), + "anonymous": request_params.get("anonymous"), + "anonymous_to_peers": request_params.get("anonymous_to_peers"), + "endorsed": request_params.get("endorsed"), + "closed": request_params.get("closed"), + "editing_user_id": request_params.get("editing_user_id"), + "edit_reason_code": request_params.get("edit_reason_code"), + "endorsement_user_id": request_params.get("endorsement_user_id"), + "course_key": course_id + } + request_data = {k: v for k, v in request_data.items() if v is not None} + response = forum_api.update_comment(**request_data) + return response + + def handle_update_thread(self, request_params, course_id): + request_data = { + "thread_id": self.attributes["id"], + "title": request_params.get("title"), + "body": request_params.get("body"), + "course_id": request_params.get("course_id"), + "anonymous": request_params.get("anonymous"), + "anonymous_to_peers": request_params.get("anonymous_to_peers"), + "closed": request_params.get("closed"), + "commentable_id": request_params.get("commentable_id"), + "user_id": request_params.get("user_id"), + "editing_user_id": request_params.get("editing_user_id"), + "pinned": request_params.get("pinned"), + "thread_type": request_params.get("thread_type"), + "edit_reason_code": request_params.get("edit_reason_code"), + "close_reason_code": request_params.get("close_reason_code"), + "closing_user_id": request_params.get("closing_user_id"), + "endorsed": request_params.get("endorsed"), + "course_key": course_id + } + request_data = {k: v for k, v in request_data.items() if v is not None} + response = forum_api.update_thread(**request_data) + return response + + def perform_http_put_request(self, request_params): + url = self.url(action="put", params=self.attributes) + response = perform_request( + "put", + url, + request_params, + metric_tags=self._metric_tags, + metric_action="model.update", + ) + return response + + def perform_http_post_request(self): + url = self.url(action="post", params=self.attributes) + response = perform_request( + "post", + url, + self.initializable_attributes(), + metric_tags=self._metric_tags, + metric_action="model.insert", + ) + return response + + def handle_create(self, params=None): + course_id = self.attributes.get("course_id") or params.get("course_id") + course_key = get_course_key(course_id) + if is_forum_v2_enabled(course_key): + response = None + if self.type == "comment": + response = self.handle_create_comment(str(course_key) if course_key else course_key) + elif self.type == "thread": + response = self.handle_create_thread(str(course_key) if course_key else course_key) + if response is None: + raise CommentClientRequestError("Forum v2 API call is missing") + else: + response = self.perform_http_post_request() + return response + + def handle_create_comment(self, course_id): + request_data = self.initializable_attributes() + body = request_data["body"] + user_id = request_data["user_id"] + course_id = course_id or str(request_data["course_id"]) + if parent_id := self.attributes.get("parent_id"): + response = forum_api.create_child_comment( + parent_id, + body, + user_id, + course_id, + request_data.get("anonymous", False), + request_data.get("anonymous_to_peers", False), + ) + else: + response = forum_api.create_parent_comment( + self.attributes["thread_id"], + body, + user_id, + course_id, + request_data.get("anonymous", False), + request_data.get("anonymous_to_peers", False), + ) + return response + + def handle_create_thread(self, course_id): + request_data = self.initializable_attributes() + response = forum_api.create_thread( + title=request_data["title"], + body=request_data["body"], + course_id=course_id or str(request_data["course_id"]), + user_id=str(request_data["user_id"]), + anonymous=request_data.get("anonymous", False), + anonymous_to_peers=request_data.get("anonymous_to_peers", False), + commentable_id=request_data.get("commentable_id", "course"), + thread_type=request_data.get("thread_type", "discussion"), + group_id=request_data.get("group_id", None), + context=request_data.get("context", None), + ) + return response diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/subscriptions.py b/openedx/core/djangoapps/django_comment_common/comment_client/subscriptions.py index 545948a092cc..5696306803e9 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/subscriptions.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/subscriptions.py @@ -4,6 +4,8 @@ import logging from . import models, settings, utils +from forum import api as forum_api +from lms.djangoapps.discussion.toggles import is_forum_v2_enabled log = logging.getLogger(__name__) @@ -21,7 +23,7 @@ class Subscription(models.Model): base_url = f"{settings.PREFIX}/threads" @classmethod - def fetch(cls, thread_id, query_params): + def fetch(cls, thread_id, course_id, query_params): """ Fetches the subscriptions for a given thread_id """ @@ -33,14 +35,23 @@ def fetch(cls, thread_id, query_params): params.update( utils.strip_blank(utils.strip_none(query_params)) ) - response = utils.perform_request( - 'get', - cls.url(action='get', params=params) + "/subscriptions", - params, - metric_tags=[], - metric_action='subscription.get', - paged_results=True - ) + course_key = utils.get_course_key(course_id) + if is_forum_v2_enabled(course_key): + response = forum_api.get_thread_subscriptions( + thread_id=thread_id, + page=params["page"], + per_page=params["per_page"], + course_id=str(course_key) if course_key else course_key + ) + else: + response = utils.perform_request( + 'get', + cls.url(action='get', params=params) + "/subscriptions", + params, + metric_tags=[], + metric_action='subscription.get', + paged_results=True + ) return utils.SubscriptionsPaginatedResult( collection=response.get('collection', []), page=response.get('page', 1), diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py index ef5accbad25d..ab3a76c168e7 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py @@ -6,6 +6,8 @@ from eventtracking import tracker from . import models, settings, utils +from forum import api as forum_api +from lms.djangoapps.discussion.toggles import is_forum_v2_enabled log = logging.getLogger(__name__) @@ -59,14 +61,37 @@ def search(cls, query_params): url = cls.url(action='get_all', params=utils.extract(params, 'commentable_id')) if params.get('commentable_id'): del params['commentable_id'] - response = utils.perform_request( - 'get', - url, - params, - metric_tags=['course_id:{}'.format(query_params['course_id'])], - metric_action='thread.search', - paged_results=True - ) + + if is_forum_v2_enabled(utils.get_course_key(query_params['course_id'])): + if query_params.get('text'): + search_params = utils.strip_none(params) + if user_id := search_params.get('user_id'): + search_params['user_id'] = str(user_id) + if group_ids := search_params.get('group_ids'): + search_params['group_ids'] = [int(group_id) for group_id in group_ids.split(',')] + elif group_id := search_params.get('group_id'): + search_params['group_ids'] = [int(group_id)] + search_params.pop('group_id', None) + if commentable_ids := search_params.get('commentable_ids'): + search_params['commentable_ids'] = commentable_ids.split(',') + elif commentable_id := search_params.get('commentable_id'): + search_params['commentable_ids'] = [commentable_id] + search_params.pop('commentable_id', None) + response = forum_api.search_threads(**search_params) + else: + if not params.get("course_id"): + params = query_params['course_id'] + response = forum_api.get_user_threads(**params) + else: + response = utils.perform_request( + 'get', + url, + params, + metric_tags=['course_id:{}'.format(query_params['course_id'])], + metric_action='thread.search', + paged_results=True + ) + if query_params.get('text'): search_query = query_params['text'] course_id = query_params['course_id'] @@ -148,14 +173,26 @@ def _retrieve(self, *args, **kwargs): 'merge_question_type_responses': kwargs.get('merge_question_type_responses', False) } request_params = utils.strip_none(request_params) - - response = utils.perform_request( - 'get', - url, - request_params, - metric_action='model.retrieve', - metric_tags=self._metric_tags - ) + course_id = kwargs.get("course_id") + if not course_id: + course_id = forum_api.get_course_id_by_thread(self.id) + course_key = utils.get_course_key(course_id) + if is_forum_v2_enabled(course_key): + if user_id := request_params.get('user_id'): + request_params['user_id'] = str(user_id) + response = forum_api.get_thread( + thread_id=self.id, + params=request_params, + course_id=str(course_key) if course_key else course_key + ) + else: + response = utils.perform_request( + 'get', + url, + request_params, + metric_action='model.retrieve', + metric_tags=self._metric_tags + ) self._update_from_response(response) def flagAbuse(self, user, voteable): @@ -163,14 +200,23 @@ def flagAbuse(self, user, voteable): url = _url_for_flag_abuse_thread(voteable.id) else: raise utils.CommentClientRequestError("Can only flag/unflag threads or comments") - params = {'user_id': user.id} - response = utils.perform_request( - 'put', - url, - params, - metric_action='thread.abuse.flagged', - metric_tags=self._metric_tags - ) + course_key = utils.get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + response = forum_api.update_thread_flag( + voteable.id, + "flag", + user.id, + str(course_key) if course_key else course_key + ) + else: + params = {'user_id': user.id} + response = utils.perform_request( + 'put', + url, + params, + metric_action='thread.abuse.flagged', + metric_tags=self._metric_tags + ) voteable._update_from_response(response) def unFlagAbuse(self, user, voteable, removeAll): @@ -178,42 +224,68 @@ def unFlagAbuse(self, user, voteable, removeAll): url = _url_for_unflag_abuse_thread(voteable.id) else: raise utils.CommentClientRequestError("Can only flag/unflag for threads or comments") - params = {'user_id': user.id} - #if you're an admin, when you unflag, remove ALL flags - if removeAll: - params['all'] = True - - response = utils.perform_request( - 'put', - url, - params, - metric_tags=self._metric_tags, - metric_action='thread.abuse.unflagged' - ) + course_key = utils.get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + response = forum_api.update_thread_flag( + thread_id=voteable.id, + action="unflag", + user_id=user.id, + update_all=bool(removeAll), + course_id=str(course_key) if course_key else course_key + ) + else: + params = {'user_id': user.id} + #if you're an admin, when you unflag, remove ALL flags + if removeAll: + params['all'] = True + + response = utils.perform_request( + 'put', + url, + params, + metric_tags=self._metric_tags, + metric_action='thread.abuse.unflagged' + ) voteable._update_from_response(response) def pin(self, user, thread_id): - url = _url_for_pin_thread(thread_id) - params = {'user_id': user.id} - response = utils.perform_request( - 'put', - url, - params, - metric_tags=self._metric_tags, - metric_action='thread.pin' - ) + course_key = utils.get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + response = forum_api.pin_thread( + user_id=user.id, + thread_id=thread_id, + course_id=str(course_key) if course_key else course_key + ) + else: + url = _url_for_pin_thread(thread_id) + params = {'user_id': user.id} + response = utils.perform_request( + 'put', + url, + params, + metric_tags=self._metric_tags, + metric_action='thread.pin' + ) self._update_from_response(response) def un_pin(self, user, thread_id): - url = _url_for_un_pin_thread(thread_id) - params = {'user_id': user.id} - response = utils.perform_request( - 'put', - url, - params, - metric_tags=self._metric_tags, - metric_action='thread.unpin' - ) + course_key = utils.get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + response = forum_api.unpin_thread( + user_id=user.id, + thread_id=thread_id, + course_id=str(course_key) if course_key else course_key + ) + else: + url = _url_for_un_pin_thread(thread_id) + params = {'user_id': user.id} + response = utils.perform_request( + 'put', + url, + params, + metric_tags=self._metric_tags, + metric_action='thread.unpin' + ) self._update_from_response(response) diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/user.py b/openedx/core/djangoapps/django_comment_common/comment_client/user.py index 684469c9e787..90282d2a4b4c 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/user.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/user.py @@ -1,8 +1,12 @@ # pylint: disable=missing-docstring,protected-access """ User model wrapper for comment service""" +from opaque_keys.edx.keys import CourseKey from . import models, settings, utils +from forum import api as forum_api +from forum.utils import ForumV2RequestError, str_to_bool +from lms.djangoapps.discussion.toggles import is_forum_v2_enabled class User(models.Model): @@ -34,34 +38,55 @@ def read(self, source): """ Calls cs_comments_service to mark thread as read for the user """ - params = {'source_type': source.type, 'source_id': source.id} - utils.perform_request( - 'post', - _url_for_read(self.id), - params, - metric_action='user.read', - metric_tags=self._metric_tags + [f'target.type:{source.type}'], - ) + course_id = self.attributes.get("course_id") + course_key = utils.get_course_key(course_id) + if is_forum_v2_enabled(course_key): + forum_api.mark_thread_as_read(self.id, source.id, course_id=str(course_key) if course_key else course_key) + else: + params = {'source_type': source.type, 'source_id': source.id} + utils.perform_request( + 'post', + _url_for_read(self.id), + params, + metric_action='user.read', + metric_tags=self._metric_tags + [f'target.type:{source.type}'], + ) def follow(self, source): - params = {'source_type': source.type, 'source_id': source.id} - utils.perform_request( - 'post', - _url_for_subscription(self.id), - params, - metric_action='user.follow', - metric_tags=self._metric_tags + [f'target.type:{source.type}'], - ) + course_key = utils.get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + forum_api.create_subscription( + user_id=self.id, + source_id=source.id, + course_id=str(course_key) if course_key else course_key + ) + else: + params = {'source_type': source.type, 'source_id': source.id} + utils.perform_request( + 'post', + _url_for_subscription(self.id), + params, + metric_action='user.follow', + metric_tags=self._metric_tags + [f'target.type:{source.type}'], + ) def unfollow(self, source): - params = {'source_type': source.type, 'source_id': source.id} - utils.perform_request( - 'delete', - _url_for_subscription(self.id), - params, - metric_action='user.unfollow', - metric_tags=self._metric_tags + [f'target.type:{source.type}'], - ) + course_key = utils.get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + forum_api.delete_subscription( + user_id=self.id, + source_id=source.id, + course_id=str(course_key) if course_key else course_key + ) + else: + params = {'source_type': source.type, 'source_id': source.id} + utils.perform_request( + 'delete', + _url_for_subscription(self.id), + params, + metric_action='user.unfollow', + metric_tags=self._metric_tags + [f'target.type:{source.type}'], + ) def vote(self, voteable, value): if voteable.type == 'thread': @@ -70,14 +95,31 @@ def vote(self, voteable, value): url = _url_for_vote_comment(voteable.id) else: raise utils.CommentClientRequestError("Can only vote / unvote for threads or comments") - params = {'user_id': self.id, 'value': value} - response = utils.perform_request( - 'put', - url, - params, - metric_action='user.vote', - metric_tags=self._metric_tags + [f'target.type:{voteable.type}'], - ) + course_key = utils.get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + if voteable.type == 'thread': + response = forum_api.update_thread_votes( + thread_id=voteable.id, + user_id=self.id, + value=value, + course_id=str(course_key) if course_key else course_key + ) + else: + response = forum_api.update_comment_votes( + comment_id=voteable.id, + user_id=self.id, + value=value, + course_id=str(course_key) + ) + else: + params = {'user_id': self.id, 'value': value} + response = utils.perform_request( + 'put', + url, + params, + metric_action='user.vote', + metric_tags=self._metric_tags + [f'target.type:{voteable.type}'], + ) voteable._update_from_response(response) def unvote(self, voteable): @@ -87,14 +129,29 @@ def unvote(self, voteable): url = _url_for_vote_comment(voteable.id) else: raise utils.CommentClientRequestError("Can only vote / unvote for threads or comments") - params = {'user_id': self.id} - response = utils.perform_request( - 'delete', - url, - params, - metric_action='user.unvote', - metric_tags=self._metric_tags + [f'target.type:{voteable.type}'], - ) + course_key = utils.get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + if voteable.type == 'thread': + response = forum_api.delete_thread_vote( + thread_id=voteable.id, + user_id=self.id, + course_id=str(course_key) if course_key else course_key + ) + else: + response = forum_api.delete_comment_vote( + comment_id=voteable.id, + user_id=self.id, + course_id=str(course_key) + ) + else: + params = {'user_id': self.id} + response = utils.perform_request( + 'delete', + url, + params, + metric_action='user.unvote', + metric_tags=self._metric_tags + [f'target.type:{voteable.type}'], + ) voteable._update_from_response(response) def active_threads(self, query_params=None): @@ -105,14 +162,28 @@ def active_threads(self, query_params=None): url = _url_for_user_active_threads(self.id) params = {'course_id': str(self.course_id)} params.update(query_params) - response = utils.perform_request( - 'get', - url, - params, - metric_action='user.active_threads', - metric_tags=self._metric_tags, - paged_results=True, - ) + course_key = utils.get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + if user_id := params.get("user_id"): + params["user_id"] = str(user_id) + if page := params.get("page"): + params["page"] = int(page) + if per_page := params.get("per_page"): + params["per_page"] = int(per_page) + if count_flagged := params.get("count_flagged", False): + params["count_flagged"] = str_to_bool(count_flagged) + if not params.get("course_id"): + params["course_id"] = str(course_key) if course_key else course_key + response = forum_api.get_user_active_threads(**params) + else: + response = utils.perform_request( + 'get', + url, + params, + metric_action='user.active_threads', + metric_tags=self._metric_tags, + paged_results=True, + ) return response.get('collection', []), response.get('page', 1), response.get('num_pages', 1) def subscribed_threads(self, query_params=None): @@ -125,14 +196,28 @@ def subscribed_threads(self, query_params=None): url = _url_for_user_subscribed_threads(self.id) params = {'course_id': str(self.course_id)} params.update(query_params) - response = utils.perform_request( - 'get', - url, - params, - metric_action='user.subscribed_threads', - metric_tags=self._metric_tags, - paged_results=True - ) + course_key = utils.get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + if user_id := params.get("user_id"): + params["user_id"] = str(user_id) + if page := params.get("page"): + params["page"] = int(page) + if per_page := params.get("per_page"): + params["per_page"] = int(per_page) + if count_flagged := params.get("count_flagged", False): + params["count_flagged"] = str_to_bool(count_flagged) + if not params.get("course_id"): + params["course_id"] = str(course_key) if course_key else course_key + response = forum_api.get_user_threads(**params) + else: + response = utils.perform_request( + 'get', + url, + params, + metric_action='user.subscribed_threads', + metric_tags=self._metric_tags, + paged_results=True + ) return utils.CommentClientPaginatedResult( collection=response.get('collection', []), page=response.get('page', 1), @@ -144,23 +229,22 @@ def _retrieve(self, *args, **kwargs): url = self.url(action='get', params=self.attributes) retrieve_params = self.default_retrieve_params.copy() retrieve_params.update(kwargs) - if self.attributes.get('course_id'): - retrieve_params['course_id'] = str(self.course_id) + course_id = retrieve_params.get("course_id") or self.attributes.get("course_id") + if isinstance(course_id, CourseKey): + retrieve_params["course_id"] = str(course_id) if self.attributes.get('group_id'): - retrieve_params['group_id'] = self.group_id - try: - response = utils.perform_request( - 'get', - url, - retrieve_params, - metric_action='model.retrieve', - metric_tags=self._metric_tags, - ) - except utils.CommentClientRequestError as e: - if e.status_code == 404: - # attempt to gracefully recover from a previous failure - # to sync this user to the comments service. - self.save() + retrieve_params['group_id'] = self.attributes["group_id"] + course_key = utils.get_course_key(course_id) + if is_forum_v2_enabled(course_key): + if not retrieve_params.get("course_id"): + retrieve_params["course_id"] = str(course_key) if course_key else course_key + try: + response = forum_api.get_user(self.attributes["id"], retrieve_params) + except ForumV2RequestError as e: + self.save({"course_key": course_key}) + response = forum_api.get_user(self.attributes["id"], retrieve_params) + else: + try: response = utils.perform_request( 'get', url, @@ -168,33 +252,56 @@ def _retrieve(self, *args, **kwargs): metric_action='model.retrieve', metric_tags=self._metric_tags, ) - else: - raise + except utils.CommentClientRequestError as e: + if e.status_code == 404: + # attempt to gracefully recover from a previous failure + # to sync this user to the comments service. + self.save() + response = utils.perform_request( + 'get', + url, + retrieve_params, + metric_action='model.retrieve', + metric_tags=self._metric_tags, + ) + else: + raise self._update_from_response(response) def retire(self, retired_username): - url = _url_for_retire(self.id) - params = {'retired_username': retired_username} - - utils.perform_request( - 'post', - url, - params, - raw=True, - metric_action='user.retire', - metric_tags=self._metric_tags - ) + course_key = utils.get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + forum_api.retire_user( + user_id=self.id, + retired_username=retired_username, + course_id=str(course_key) if course_key else course_key + ) + else: + url = _url_for_retire(self.id) + params = {'retired_username': retired_username} + utils.perform_request( + 'post', + url, + params, + raw=True, + metric_action='user.retire', + metric_tags=self._metric_tags + ) def replace_username(self, new_username): - url = _url_for_username_replacement(self.id) - params = {"new_username": new_username} - - utils.perform_request( - 'post', - url, - params, - raw=True, - ) + course_key = utils.get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + forum_api.update_username(user_id=self.id, new_username=new_username, course_id=str(course_key) if course_key else course_key) + else: + url = _url_for_username_replacement(self.id) + params = {"new_username": new_username} + + utils.perform_request( + 'post', + url, + params, + raw=True, + ) def _url_for_vote_comment(comment_id): diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/utils.py b/openedx/core/djangoapps/django_comment_common/comment_client/utils.py index a67cdbdbc483..c15bbba6c8a7 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/utils.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/utils.py @@ -7,6 +7,7 @@ import requests from django.utils.translation import get_language +from opaque_keys.edx.keys import CourseKey from .settings import SERVICE_HOST as COMMENTS_SERVICE @@ -167,3 +168,9 @@ def check_forum_heartbeat(): return 'forum', False, res.get('check', 'Forum heartbeat failed') except Exception as fail: return 'forum', False, str(fail) + + +def get_course_key(course_id): + if course_id and isinstance(course_id, str): + course_id = CourseKey.from_string(course_id) + return course_id diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 0872ad5c683c..c8ea0e2fbd37 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -227,6 +227,7 @@ django==4.2.16 # enmerkar # enmerkar-underscore # event-tracking + # forum # help-tokens # jsonfield # lti-consumer-xblock @@ -382,6 +383,7 @@ djangorestframework==3.14.0 # edx-organizations # edx-proctoring # edx-submissions + # forum # openedx-learning # ora2 # super-csv @@ -547,6 +549,7 @@ elasticsearch==7.13.4 # via # -c requirements/edx/../common_constraints.txt # edx-search + # forum enmerkar==0.7.1 # via enmerkar-underscore enmerkar-underscore==2.3.1 @@ -564,6 +567,8 @@ filelock==3.16.1 # via snowflake-connector-python firebase-admin==6.5.0 # via edx-ace +forum @ git+https://github.com/edly-io/forum.git@master + # via -r requirements/edx/github.in frozenlist==1.4.1 # via # aiohttp @@ -768,7 +773,9 @@ multidict==6.1.0 # aiohttp # yarl mysqlclient==2.2.4 - # via -r requirements/edx/kernel.in + # via + # -r requirements/edx/kernel.in + # forum newrelic==10.1.0 # via # -r requirements/edx/bundled.in @@ -800,7 +807,9 @@ openai==0.28.1 # -c requirements/edx/../constraints.txt # edx-enterprise openedx-atlas==0.6.2 - # via -r requirements/edx/kernel.in + # via + # -r requirements/edx/kernel.in + # forum openedx-calc==3.1.2 # via -r requirements/edx/kernel.in openedx-django-pyfs==3.7.0 @@ -957,6 +966,7 @@ pymongo==4.4.0 # -r requirements/edx/paver.txt # edx-opaque-keys # event-tracking + # forum # mongoengine # openedx-mongodbproxy pynacl==1.5.0 @@ -1061,6 +1071,7 @@ requests==2.32.3 # edx-drf-extensions # edx-enterprise # edx-rest-api-client + # forum # geoip2 # google-api-core # google-cloud-storage diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 5bbe07ade1de..4e73cc3dc6ea 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -396,6 +396,7 @@ django==4.2.16 # enmerkar # enmerkar-underscore # event-tracking + # forum # help-tokens # jsonfield # lti-consumer-xblock @@ -615,6 +616,7 @@ djangorestframework==3.14.0 # edx-organizations # edx-proctoring # edx-submissions + # forum # openedx-learning # ora2 # super-csv @@ -856,6 +858,7 @@ elasticsearch==7.13.4 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-search + # forum enmerkar==0.7.1 # via # -r requirements/edx/doc.txt @@ -904,6 +907,10 @@ firebase-admin==6.5.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-ace +forum @ git+https://github.com/edly-io/forum.git@master + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt freezegun==1.5.1 # via -r requirements/edx/testing.txt frozenlist==1.4.1 @@ -1291,6 +1298,7 @@ mysqlclient==2.2.4 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt + # forum newrelic==10.1.0 # via # -r requirements/edx/doc.txt @@ -1341,6 +1349,7 @@ openedx-atlas==0.6.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt + # forum openedx-calc==3.1.2 # via # -r requirements/edx/doc.txt @@ -1643,6 +1652,7 @@ pymongo==4.4.0 # -r requirements/edx/testing.txt # edx-opaque-keys # event-tracking + # forum # mongoengine # openedx-mongodbproxy pynacl==1.5.0 @@ -1833,6 +1843,7 @@ requests==2.32.3 # edx-drf-extensions # edx-enterprise # edx-rest-api-client + # forum # geoip2 # google-api-core # google-cloud-storage diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index e72432139574..e4607c74e2df 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -281,6 +281,7 @@ django==4.2.16 # enmerkar # enmerkar-underscore # event-tracking + # forum # help-tokens # jsonfield # lti-consumer-xblock @@ -452,6 +453,7 @@ djangorestframework==3.14.0 # edx-organizations # edx-proctoring # edx-submissions + # forum # openedx-learning # ora2 # super-csv @@ -631,6 +633,7 @@ elasticsearch==7.13.4 # -c requirements/edx/../common_constraints.txt # -r requirements/edx/base.txt # edx-search + # forum enmerkar==0.7.1 # via # -r requirements/edx/base.txt @@ -656,6 +659,8 @@ firebase-admin==6.5.0 # via # -r requirements/edx/base.txt # edx-ace +forum @ git+https://github.com/edly-io/forum.git@master + # via -r requirements/edx/base.txt frozenlist==1.4.1 # via # -r requirements/edx/base.txt @@ -922,7 +927,9 @@ multidict==6.1.0 # aiohttp # yarl mysqlclient==2.2.4 - # via -r requirements/edx/base.txt + # via + # -r requirements/edx/base.txt + # forum newrelic==10.1.0 # via # -r requirements/edx/base.txt @@ -958,7 +965,9 @@ openai==0.28.1 # -r requirements/edx/base.txt # edx-enterprise openedx-atlas==0.6.2 - # via -r requirements/edx/base.txt + # via + # -r requirements/edx/base.txt + # forum openedx-calc==3.1.2 # via -r requirements/edx/base.txt openedx-django-pyfs==3.7.0 @@ -1153,6 +1162,7 @@ pymongo==4.4.0 # -r requirements/edx/base.txt # edx-opaque-keys # event-tracking + # forum # mongoengine # openedx-mongodbproxy pynacl==1.5.0 @@ -1272,6 +1282,7 @@ requests==2.32.3 # edx-drf-extensions # edx-enterprise # edx-rest-api-client + # forum # geoip2 # google-api-core # google-cloud-storage diff --git a/requirements/edx/github.in b/requirements/edx/github.in index 6ec36d3a0681..94b347d7732f 100644 --- a/requirements/edx/github.in +++ b/requirements/edx/github.in @@ -90,3 +90,5 @@ # django42 support PR merged but new release is pending. # https://github.com/openedx/edx-platform/issues/33431 -e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack + +git+https://github.com/edly-io/forum.git@master#egg=forum diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index b73820d4c1f7..d4745d50324b 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -310,6 +310,7 @@ django==4.2.16 # enmerkar # enmerkar-underscore # event-tracking + # forum # help-tokens # jsonfield # lti-consumer-xblock @@ -481,6 +482,7 @@ djangorestframework==3.14.0 # edx-organizations # edx-proctoring # edx-submissions + # forum # openedx-learning # ora2 # super-csv @@ -657,6 +659,7 @@ elasticsearch==7.13.4 # -c requirements/edx/../common_constraints.txt # -r requirements/edx/base.txt # edx-search + # forum enmerkar==0.7.1 # via # -r requirements/edx/base.txt @@ -692,6 +695,8 @@ firebase-admin==6.5.0 # via # -r requirements/edx/base.txt # edx-ace +forum @ git+https://github.com/edly-io/forum.git@master + # via -r requirements/edx/base.txt freezegun==1.5.1 # via -r requirements/edx/testing.in frozenlist==1.4.1 @@ -973,7 +978,9 @@ multidict==6.1.0 # aiohttp # yarl mysqlclient==2.2.4 - # via -r requirements/edx/base.txt + # via + # -r requirements/edx/base.txt + # forum newrelic==10.1.0 # via # -r requirements/edx/base.txt @@ -1009,7 +1016,9 @@ openai==0.28.1 # -r requirements/edx/base.txt # edx-enterprise openedx-atlas==0.6.2 - # via -r requirements/edx/base.txt + # via + # -r requirements/edx/base.txt + # forum openedx-calc==3.1.2 # via -r requirements/edx/base.txt openedx-django-pyfs==3.7.0 @@ -1238,6 +1247,7 @@ pymongo==4.4.0 # -r requirements/edx/base.txt # edx-opaque-keys # event-tracking + # forum # mongoengine # openedx-mongodbproxy pynacl==1.5.0 @@ -1389,6 +1399,7 @@ requests==2.32.3 # edx-drf-extensions # edx-enterprise # edx-rest-api-client + # forum # geoip2 # google-api-core # google-cloud-storage