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 0a5fbe4919..9907db95bb 100644 --- a/lms/djangoapps/discussion/django_comment_client/tests/group_id.py +++ b/lms/djangoapps/discussion/django_comment_client/tests/group_id.py @@ -241,3 +241,219 @@ class NonCohortedTopicGroupIdTestMixin(GroupIdAssertionMixin): 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) + + +class GroupIdAssertionMixinV2: + """ + Provides assertion methods for testing group_id functionality in forum v2. + + This mixin contains helper methods to verify that the comments service is called + with the correct group_id parameters and that responses contain the expected + group information. + """ + def _get_params_last_call(self, function_name): + """ + Returns the data or params dict that `mock_request` was called with. + """ + return self.get_mock_func_calls(function_name)[-1][1] + + def _assert_comments_service_called_with_group_id(self, group_id): + assert self.check_mock_called('get_user_threads') + assert self._get_params_last_call('get_user_threads')['group_id'] == group_id + + def _assert_comments_service_called_without_group_id(self): + assert self.check_mock_called('get_user_threads') + assert 'group_id' not in self._get_params_last_call('get_user_threads') + + 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): + """ + :param extract_thread: a function which accepts a dictionary (complete + json response payload) and returns another dictionary (first + occurrence of a thread model within that payload). if None is + passed, the identity function is assumed. + """ + 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 CohortedTopicGroupIdTestMixinV2(GroupIdAssertionMixinV2): + """ + Provides test cases to verify that views pass the correct `group_id` to + the comments service when requesting content in cohorted discussions for forum v2. + """ + def call_view(self, 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): + self.call_view("cohorted_topic", self.student, '', pass_group_id=False) + self._assert_comments_service_called_with_group_id(self.student_cohort.id) + + def test_cohorted_topic_student_none_group_id(self): + self.call_view("cohorted_topic", self.student, "") + self._assert_comments_service_called_with_group_id(self.student_cohort.id) + + def test_cohorted_topic_student_with_own_group_id(self): + self.call_view("cohorted_topic", self.student, self.student_cohort.id) + self._assert_comments_service_called_with_group_id(self.student_cohort.id) + + def test_cohorted_topic_student_with_other_group_id(self): + self.call_view( + "cohorted_topic", + self.student, + self.moderator_cohort.id + ) + self._assert_comments_service_called_with_group_id(self.student_cohort.id) + + def test_cohorted_topic_moderator_without_group_id(self): + self.call_view( + "cohorted_topic", + self.moderator, + '', + pass_group_id=False + ) + self._assert_comments_service_called_without_group_id() + + def test_cohorted_topic_moderator_none_group_id(self): + self.call_view("cohorted_topic", self.moderator, "") + self._assert_comments_service_called_without_group_id() + + def test_cohorted_topic_moderator_with_own_group_id(self): + self.call_view( + "cohorted_topic", + self.moderator, + self.moderator_cohort.id + ) + self._assert_comments_service_called_with_group_id(self.moderator_cohort.id) + + def test_cohorted_topic_moderator_with_other_group_id(self): + self.call_view( + "cohorted_topic", + self.moderator, + self.student_cohort.id + ) + self._assert_comments_service_called_with_group_id(self.student_cohort.id) + + def test_cohorted_topic_moderator_with_invalid_group_id(self): + invalid_id = self.student_cohort.id + self.moderator_cohort.id + response = self.call_view("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): + 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("cohorted_topic", self.moderator, invalid_id) # lint-amnesty, pylint: disable=assignment-from-no-return + assert response.status_code == 500 + + +class NonCohortedTopicGroupIdTestMixinV2(GroupIdAssertionMixinV2): + """ + Provides test cases to verify that views pass the correct `group_id` to + the comments service when requesting content in non-cohorted discussions for forum v2. + """ + def call_view(self, 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): + self.call_view( + "non_cohorted_topic", + self.student, + '', + pass_group_id=False + ) + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_student_none_group_id(self): + self.call_view("non_cohorted_topic", self.student, '') + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_student_with_own_group_id(self): + self.call_view( + "non_cohorted_topic", + self.student, + self.student_cohort.id + ) + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_student_with_other_group_id(self): + self.call_view( + "non_cohorted_topic", + self.student, + self.moderator_cohort.id + ) + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_moderator_without_group_id(self): + self.call_view( + "non_cohorted_topic", + self.moderator, + "", + pass_group_id=False, + ) + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_moderator_none_group_id(self): + self.call_view("non_cohorted_topic", self.moderator, '') + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_moderator_with_own_group_id(self): + self.call_view( + "non_cohorted_topic", + self.moderator, + self.moderator_cohort.id, + ) + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_moderator_with_other_group_id(self): + self.call_view( + "non_cohorted_topic", + self.moderator, + self.student_cohort.id, + ) + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_moderator_with_invalid_group_id(self): + invalid_id = self.student_cohort.id + self.moderator_cohort.id + self.call_view("non_cohorted_topic", self.moderator, invalid_id) + self._assert_comments_service_called_without_group_id() + + def test_team_discussion_id_not_cohorted(self): + team = CourseTeamFactory( + course_id=self.course.id, + topic_id='topic-id' + ) + + team.add_user(self.student) + self.call_view(team.discussion_topic_id, self.student, '') + + self._assert_comments_service_called_without_group_id() diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api.py b/lms/djangoapps/discussion/rest_api/tests/test_api.py index aec308bd62..48b943543c 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_api.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_api.py @@ -21,7 +21,6 @@ from opaque_keys.edx.locator import CourseLocator from pytz import UTC from rest_framework.exceptions import PermissionDenied -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 @@ -48,7 +47,6 @@ from lms.djangoapps.discussion.rest_api.api import ( get_course_topics, get_course_topics_v2, get_thread, - get_thread_list, get_user_comments, update_comment, update_thread @@ -77,7 +75,6 @@ from openedx.core.djangoapps.django_comment_common.models import ( FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_MODERATOR, FORUM_ROLE_STUDENT, - FORUM_ROLE_GROUP_MODERATOR, Role ) from openedx.core.lib.exceptions import CourseNotFoundError, PageNotFoundError @@ -698,542 +695,6 @@ class GetCourseTopicsTest(CommentsServiceMockMixin, ForumsEnableMixin, UrlResetM } -@ddt.ddt -@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class GetThreadListTest(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMixin, SharedModuleStoreTestCase): - """Test for get_thread_list""" - @classmethod - 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) - self.maxDiff = None # pylint: disable=invalid-name - self.user = UserFactory.create() - self.register_get_user_response(self.user) - self.request = RequestFactory().get("/test_path") - self.request.user = self.user - CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) - self.author = UserFactory.create() - self.course.cohort_config = {"cohorted": False} - modulestore().update_item(self.course, ModuleStoreEnum.UserID.test) - self.cohort = CohortFactory.create(course_id=self.course.id) - - def get_thread_list( - self, - threads, - page=1, - page_size=1, - num_pages=1, - course=None, - topic_id_list=None, - ): - """ - Register the appropriate comments service response, then call - get_thread_list and return the result. - """ - course = course or self.course - self.register_get_threads_response(threads, page, num_pages) - ret = get_thread_list(self.request, course.id, page, page_size, topic_id_list) - return ret - - def test_nonexistent_course(self): - with pytest.raises(CourseNotFoundError): - get_thread_list(self.request, CourseLocator.from_string("course-v1:non+existent+course"), 1, 1) - - def test_not_enrolled(self): - self.request.user = UserFactory.create() - with pytest.raises(CourseNotFoundError): - self.get_thread_list([]) - - def test_discussions_disabled(self): - with pytest.raises(DiscussionDisabledError): - self.get_thread_list([], course=_discussion_disabled_course_for(self.user)) - - def test_empty(self): - assert self.get_thread_list( - [], num_pages=0 - ).data == { - 'pagination': { - 'next': None, - 'previous': None, - 'num_pages': 0, - 'count': 0 - }, - 'results': [], - 'text_search_rewrite': None - } - - def test_get_threads_by_topic_id(self): - self.get_thread_list([], topic_id_list=["topic_x", "topic_meow"]) - assert urlparse(httpretty.last_request().path).path == '/api/v1/threads' # lint-amnesty, pylint: disable=no-member - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["1"], - "commentable_ids": ["topic_x,topic_meow"] - }) - - def test_basic_query_params(self): - self.get_thread_list([], page=6, page_size=14) - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["6"], - "per_page": ["14"], - }) - - def test_thread_content(self): - self.course.cohort_config = {"cohorted": True} - modulestore().update_item(self.course, ModuleStoreEnum.UserID.test) - source_threads = [ - make_minimal_cs_thread({ - "id": "test_thread_id_0", - "course_id": str(self.course.id), - "commentable_id": "topic_x", - "username": self.author.username, - "user_id": str(self.author.id), - "title": "Test Title", - "body": "Test body", - "votes": {"up_count": 4}, - "comments_count": 5, - "unread_comments_count": 3, - "endorsed": True, - "read": True, - "created_at": "2015-04-28T00:00:00Z", - "updated_at": "2015-04-28T11:11:11Z", - }), - make_minimal_cs_thread({ - "id": "test_thread_id_1", - "course_id": str(self.course.id), - "commentable_id": "topic_y", - "group_id": self.cohort.id, - "username": self.author.username, - "user_id": str(self.author.id), - "thread_type": "question", - "title": "Another Test Title", - "body": "More content", - "votes": {"up_count": 9}, - "comments_count": 18, - "created_at": "2015-04-28T22:22:22Z", - "updated_at": "2015-04-28T00:33:33Z", - }) - ] - expected_threads = [ - self.expected_thread_data({ - "id": "test_thread_id_0", - "author": self.author.username, - "topic_id": "topic_x", - "vote_count": 4, - "comment_count": 6, - "unread_comment_count": 3, - "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_0", - "editable_fields": ["abuse_flagged", "copy_link", "following", "read", "voted"], - "has_endorsed": True, - "read": True, - "created_at": "2015-04-28T00:00:00Z", - "updated_at": "2015-04-28T11:11:11Z", - "abuse_flagged_count": None, - "can_delete": False, - }), - self.expected_thread_data({ - "id": "test_thread_id_1", - "author": self.author.username, - "topic_id": "topic_y", - "group_id": self.cohort.id, - "group_name": self.cohort.name, - "type": "question", - "title": "Another Test Title", - "raw_body": "More content", - "preview_body": "More content", - "rendered_body": "
More content
", - "vote_count": 9, - "comment_count": 19, - "created_at": "2015-04-28T22:22:22Z", - "updated_at": "2015-04-28T00:33:33Z", - "comment_list_url": None, - "endorsed_comment_list_url": ( - "http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_1&endorsed=True" - ), - "non_endorsed_comment_list_url": ( - "http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_1&endorsed=False" - ), - "editable_fields": ["abuse_flagged", "copy_link", "following", "read", "voted"], - "abuse_flagged_count": None, - "can_delete": False, - }), - ] - - expected_result = make_paginated_api_response( - results=expected_threads, count=2, num_pages=1, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - assert self.get_thread_list(source_threads).data == expected_result - - @ddt.data( - *itertools.product( - [ - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - FORUM_ROLE_STUDENT, - FORUM_ROLE_GROUP_MODERATOR, - ], - [True, False] - ) - ) - @ddt.unpack - def test_request_group(self, role_name, course_is_cohorted): - cohort_course = CourseFactory.create(cohort_config={"cohorted": course_is_cohorted}) - CourseEnrollmentFactory.create(user=self.user, course_id=cohort_course.id) - CohortFactory.create(course_id=cohort_course.id, users=[self.user]) - _assign_role_to_user(user=self.user, course_id=cohort_course.id, role=role_name) - self.get_thread_list([], course=cohort_course) - actual_has_group = "group_id" in httpretty.last_request().querystring # lint-amnesty, pylint: disable=no-member - expected_has_group = (course_is_cohorted and - role_name in (FORUM_ROLE_STUDENT, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_GROUP_MODERATOR)) - assert actual_has_group == expected_has_group - - def test_pagination(self): - # N.B. Empty thread list is not realistic but convenient for this test - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=3, next_link="http://testserver/test_path?page=2", previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - assert self.get_thread_list([], page=1, num_pages=3).data == expected_result - - expected_result = make_paginated_api_response( - results=[], - count=0, - num_pages=3, - next_link="http://testserver/test_path?page=3", - previous_link="http://testserver/test_path?page=1" - ) - expected_result.update({"text_search_rewrite": None}) - assert self.get_thread_list([], page=2, num_pages=3).data == expected_result - - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=3, next_link=None, previous_link="http://testserver/test_path?page=2" - ) - expected_result.update({"text_search_rewrite": None}) - assert self.get_thread_list([], page=3, num_pages=3).data == expected_result - - # Test page past the last one - self.register_get_threads_response([], page=3, num_pages=3) - with pytest.raises(PageNotFoundError): - get_thread_list(self.request, self.course.id, page=4, page_size=10) - - @ddt.data(None, "rewritten search string") - def test_text_search(self, text_search_rewrite): - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": text_search_rewrite}) - self.register_get_threads_search_response([], text_search_rewrite, num_pages=0) - assert get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - text_search='test search string' - ).data == expected_result - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["10"], - "text": ["test search string"], - }) - - def test_filter_threads_by_author(self): - thread = make_minimal_cs_thread() - self.register_get_threads_response([thread], page=1, num_pages=10) - thread_results = get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - author=self.user.username, - ).data.get('results') - assert len(thread_results) == 1 - - expected_last_query_params = { - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["10"], - "author_id": [str(self.user.id)], - } - - self.assert_last_query_params(expected_last_query_params) - - def test_filter_threads_by_missing_author(self): - self.register_get_threads_response([make_minimal_cs_thread()], page=1, num_pages=10) - results = get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - author="a fake and missing username", - ).data.get('results') - assert len(results) == 0 - - @ddt.data('question', 'discussion', None) - def test_thread_type(self, thread_type): - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - - self.register_get_threads_response([], page=1, num_pages=0) - assert get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - thread_type=thread_type, - ).data == expected_result - - expected_last_query_params = { - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["10"], - "thread_type": [thread_type], - } - - if thread_type is None: - del expected_last_query_params["thread_type"] - - self.assert_last_query_params(expected_last_query_params) - - @ddt.data(True, False, None) - def test_flagged(self, flagged_boolean): - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - - self.register_get_threads_response([], page=1, num_pages=0) - assert get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - flagged=flagged_boolean, - ).data == expected_result - - expected_last_query_params = { - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["10"], - "flagged": [str(flagged_boolean)], - } - - if flagged_boolean is None: - del expected_last_query_params["flagged"] - - self.assert_last_query_params(expected_last_query_params) - - @ddt.data( - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - ) - def test_flagged_count(self, role): - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - - _assign_role_to_user(self.user, self.course.id, role=role) - - self.register_get_threads_response([], page=1, num_pages=0) - get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - count_flagged=True, - ) - - expected_last_query_params = { - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "count_flagged": ["True"], - "page": ["1"], - "per_page": ["10"], - } - - self.assert_last_query_params(expected_last_query_params) - - def test_flagged_count_denied(self): - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - - _assign_role_to_user(self.user, self.course.id, role=FORUM_ROLE_STUDENT) - - self.register_get_threads_response([], page=1, num_pages=0) - - with pytest.raises(PermissionDenied): - get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - count_flagged=True, - ) - - def test_following(self): - self.register_subscribed_threads_response(self.user, [], page=1, num_pages=0) - result = get_thread_list( - self.request, - self.course.id, - page=1, - page_size=11, - following=True, - ).data - - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - assert result == expected_result - assert urlparse( - httpretty.last_request().path # lint-amnesty, pylint: disable=no-member - ).path == f"/api/v1/users/{self.user.id}/subscribed_threads" - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["11"], - }) - - @ddt.data("unanswered", "unread") - def test_view_query(self, query): - self.register_get_threads_response([], page=1, num_pages=0) - result = get_thread_list( - self.request, - self.course.id, - page=1, - page_size=11, - view=query, - ).data - - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - assert result == expected_result - assert urlparse(httpretty.last_request().path).path == '/api/v1/threads' # lint-amnesty, pylint: disable=no-member - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["11"], - query: ["true"], - }) - - @ddt.data( - ("last_activity_at", "activity"), - ("comment_count", "comments"), - ("vote_count", "votes") - ) - @ddt.unpack - def test_order_by_query(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 - """ - self.register_get_threads_response([], page=1, num_pages=0) - result = get_thread_list( - self.request, - self.course.id, - page=1, - page_size=11, - order_by=http_query, - ).data - - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - assert result == expected_result - assert urlparse(httpretty.last_request().path).path == '/api/v1/threads' # lint-amnesty, pylint: disable=no-member - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": [cc_query], - "page": ["1"], - "per_page": ["11"], - }) - - def test_order_direction(self): - """ - Only "desc" is supported for order. Also, since it is simply swallowed, - it isn't included in the params. - """ - self.register_get_threads_response([], page=1, num_pages=0) - result = get_thread_list( - self.request, - self.course.id, - page=1, - page_size=11, - order_direction="desc", - ).data - - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - assert result == expected_result - assert urlparse(httpretty.last_request().path).path == '/api/v1/threads' # lint-amnesty, pylint: disable=no-member - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["11"], - }) - - def test_invalid_order_direction(self): - """ - Test with invalid order_direction (e.g. "asc") - """ - with pytest.raises(ValidationError) as assertion: - self.register_get_threads_response([], page=1, num_pages=0) - get_thread_list( # pylint: disable=expression-not-assigned - self.request, - self.course.id, - page=1, - page_size=11, - order_direction="asc", - ).data - assert 'order_direction' in assertion.value.message_dict - - @ddt.ddt @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) class GetCommentListTest(ForumsEnableMixin, CommentsServiceMockMixin, SharedModuleStoreTestCase): diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py index 4804c73a06..4efadd6385 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py @@ -92,6 +92,7 @@ from openedx.core.djangoapps.discussions.tasks import ( from openedx.core.djangoapps.django_comment_common.models import ( FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_GROUP_MODERATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_STUDENT, Role, @@ -1054,3 +1055,639 @@ class UpdateCommentTest( vote_count -= 1 assert result["vote_count"] == vote_count self.register_get_user_response(self.user, upvoted_ids=[]) + + +@ddt.ddt +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class GetThreadListTest( + ForumsEnableMixin, ForumMockUtilsMixin, UrlResetMixin, SharedModuleStoreTestCase +): + """Test for get_thread_list""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + cls.course = CourseFactory.create() + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() + + @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) + self.maxDiff = None # pylint: disable=invalid-name + self.user = UserFactory.create() + self.register_get_user_response(self.user) + self.request = RequestFactory().get("/test_path") + self.request.user = self.user + CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) + self.author = UserFactory.create() + self.course.cohort_config = {"cohorted": False} + modulestore().update_item(self.course, ModuleStoreEnum.UserID.test) + self.cohort = CohortFactory.create(course_id=self.course.id) + + def get_thread_list( + self, + threads, + page=1, + page_size=1, + num_pages=1, + course=None, + topic_id_list=None, + ): + """ + Register the appropriate comments service response, then call + get_thread_list and return the result. + """ + course = course or self.course + self.register_get_threads_response(threads, page, num_pages) + ret = get_thread_list(self.request, course.id, page, page_size, topic_id_list) + return ret + + def test_nonexistent_course(self): + with pytest.raises(CourseNotFoundError): + get_thread_list( + self.request, + CourseLocator.from_string("course-v1:non+existent+course"), + 1, + 1, + ) + + def test_not_enrolled(self): + self.request.user = UserFactory.create() + with pytest.raises(CourseNotFoundError): + self.get_thread_list([]) + + def test_discussions_disabled(self): + with pytest.raises(DiscussionDisabledError): + self.get_thread_list([], course=_discussion_disabled_course_for(self.user)) + + def test_empty(self): + assert self.get_thread_list([], num_pages=0).data == { + "pagination": {"next": None, "previous": None, "num_pages": 0, "count": 0}, + "results": [], + "text_search_rewrite": None, + } + + def test_get_threads_by_topic_id(self): + self.get_thread_list([], topic_id_list=["topic_x", "topic_meow"]) + self.check_mock_called("get_user_threads") + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 1, + "commentable_ids": ["topic_x", "topic_meow"], + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_basic_query_params(self): + self.get_thread_list([], page=6, page_size=14) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 6, + "per_page": 14, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_thread_content(self): + self.course.cohort_config = {"cohorted": True} + modulestore().update_item(self.course, ModuleStoreEnum.UserID.test) + source_threads = [ + make_minimal_cs_thread( + { + "id": "test_thread_id_0", + "course_id": str(self.course.id), + "commentable_id": "topic_x", + "username": self.author.username, + "user_id": str(self.author.id), + "title": "Test Title", + "body": "Test body", + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + "endorsed": True, + "read": True, + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + } + ), + make_minimal_cs_thread( + { + "id": "test_thread_id_1", + "course_id": str(self.course.id), + "commentable_id": "topic_y", + "group_id": self.cohort.id, + "username": self.author.username, + "user_id": str(self.author.id), + "thread_type": "question", + "title": "Another Test Title", + "body": "More content", + "votes": {"up_count": 9}, + "comments_count": 18, + "created_at": "2015-04-28T22:22:22Z", + "updated_at": "2015-04-28T00:33:33Z", + } + ), + ] + expected_threads = [ + self.expected_thread_data( + { + "id": "test_thread_id_0", + "author": self.author.username, + "topic_id": "topic_x", + "vote_count": 4, + "comment_count": 6, + "unread_comment_count": 3, + "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_0", + "editable_fields": [ + "abuse_flagged", + "copy_link", + "following", + "read", + "voted", + ], + "has_endorsed": True, + "read": True, + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + "abuse_flagged_count": None, + "can_delete": False, + } + ), + self.expected_thread_data( + { + "id": "test_thread_id_1", + "author": self.author.username, + "topic_id": "topic_y", + "group_id": self.cohort.id, + "group_name": self.cohort.name, + "type": "question", + "title": "Another Test Title", + "raw_body": "More content", + "preview_body": "More content", + "rendered_body": "More content
", + "vote_count": 9, + "comment_count": 19, + "created_at": "2015-04-28T22:22:22Z", + "updated_at": "2015-04-28T00:33:33Z", + "comment_list_url": None, + "endorsed_comment_list_url": ( + "http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_1&endorsed=True" + ), + "non_endorsed_comment_list_url": ( + "http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_1&endorsed=False" + ), + "editable_fields": [ + "abuse_flagged", + "copy_link", + "following", + "read", + "voted", + ], + "abuse_flagged_count": None, + "can_delete": False, + } + ), + ] + + expected_result = make_paginated_api_response( + results=expected_threads, + count=2, + num_pages=1, + next_link=None, + previous_link=None, + ) + expected_result.update({"text_search_rewrite": None}) + assert self.get_thread_list(source_threads).data == expected_result + + @ddt.data( + *itertools.product( + [ + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_STUDENT, + ], + [True, False], + ) + ) + @ddt.unpack + def test_request_group(self, role_name, course_is_cohorted): + cohort_course = CourseFactory.create( + cohort_config={"cohorted": course_is_cohorted} + ) + CourseEnrollmentFactory.create(user=self.user, course_id=cohort_course.id) + CohortFactory.create(course_id=cohort_course.id, users=[self.user]) + _assign_role_to_user(user=self.user, course_id=cohort_course.id, role=role_name) + self.get_thread_list([], course=cohort_course) + thread_func_params = self.get_mock_func_calls("get_user_threads")[-1][1] + actual_has_group = "group_id" in thread_func_params + expected_has_group = ( + course_is_cohorted and role_name in ( + FORUM_ROLE_STUDENT, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_GROUP_MODERATOR + ) + ) + assert actual_has_group == expected_has_group + + def test_pagination(self): + # N.B. Empty thread list is not realistic but convenient for this test + expected_result = make_paginated_api_response( + results=[], + count=0, + num_pages=3, + next_link="http://testserver/test_path?page=2", + previous_link=None, + ) + expected_result.update({"text_search_rewrite": None}) + assert self.get_thread_list([], page=1, num_pages=3).data == expected_result + + expected_result = make_paginated_api_response( + results=[], + count=0, + num_pages=3, + next_link="http://testserver/test_path?page=3", + previous_link="http://testserver/test_path?page=1", + ) + expected_result.update({"text_search_rewrite": None}) + assert self.get_thread_list([], page=2, num_pages=3).data == expected_result + + expected_result = make_paginated_api_response( + results=[], + count=0, + num_pages=3, + next_link=None, + previous_link="http://testserver/test_path?page=2", + ) + expected_result.update({"text_search_rewrite": None}) + assert self.get_thread_list([], page=3, num_pages=3).data == expected_result + + # Test page past the last one + self.register_get_threads_response([], page=3, num_pages=3) + with pytest.raises(PageNotFoundError): + get_thread_list(self.request, self.course.id, page=4, page_size=10) + + @ddt.data(None, "rewritten search string") + def test_text_search(self, text_search_rewrite): + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": text_search_rewrite}) + self.register_get_threads_search_response([], text_search_rewrite, num_pages=0) + assert ( + get_thread_list( + self.request, + self.course.id, + page=1, + page_size=10, + text_search="test search string", + ).data + == expected_result + ) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + "text": "test search string", + } + self.check_mock_called_with( + "search_threads", + -1, + **params, + ) + + def test_filter_threads_by_author(self): + thread = make_minimal_cs_thread() + self.register_get_threads_response([thread], page=1, num_pages=10) + thread_results = get_thread_list( + self.request, + self.course.id, + page=1, + page_size=10, + author=self.user.username, + ).data.get("results") + assert len(thread_results) == 1 + + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + "author_id": str(self.user.id), + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_filter_threads_by_missing_author(self): + self.register_get_threads_response( + [make_minimal_cs_thread()], page=1, num_pages=10 + ) + results = get_thread_list( + self.request, + self.course.id, + page=1, + page_size=10, + author="a fake and missing username", + ).data.get("results") + assert len(results) == 0 + + @ddt.data("question", "discussion", None) + def test_thread_type(self, thread_type): + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + + self.register_get_threads_response([], page=1, num_pages=0) + assert ( + get_thread_list( + self.request, + self.course.id, + page=1, + page_size=10, + thread_type=thread_type, + ).data + == expected_result + ) + + expected_last_query_params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + "thread_type": thread_type, + } + + if thread_type is None: + del expected_last_query_params["thread_type"] + + self.check_mock_called_with( + "get_user_threads", + -1, + **expected_last_query_params, + ) + + @ddt.data(True, False, None) + def test_flagged(self, flagged_boolean): + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + + self.register_get_threads_response([], page=1, num_pages=0) + assert ( + get_thread_list( + self.request, + self.course.id, + page=1, + page_size=10, + flagged=flagged_boolean, + ).data + == expected_result + ) + + expected_last_query_params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + "flagged": flagged_boolean, + } + + if flagged_boolean is None: + del expected_last_query_params["flagged"] + + self.check_mock_called_with( + "get_user_threads", + -1, + **expected_last_query_params, + ) + + @ddt.data( + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + ) + def test_flagged_count(self, role): + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + + _assign_role_to_user(self.user, self.course.id, role=role) + + self.register_get_threads_response([], page=1, num_pages=0) + get_thread_list( + self.request, + self.course.id, + page=1, + page_size=10, + count_flagged=True, + ) + + expected_last_query_params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "count_flagged": True, + "page": 1, + "per_page": 10, + } + + self.check_mock_called_with( + "get_user_threads", -1, **expected_last_query_params + ) + + def test_flagged_count_denied(self): + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + + _assign_role_to_user(self.user, self.course.id, role=FORUM_ROLE_STUDENT) + + self.register_get_threads_response([], page=1, num_pages=0) + + with pytest.raises(PermissionDenied): + get_thread_list( + self.request, + self.course.id, + page=1, + page_size=10, + count_flagged=True, + ) + + def test_following(self): + self.register_subscribed_threads_response(self.user, [], page=1, num_pages=0) + result = get_thread_list( + self.request, + self.course.id, + page=1, + page_size=11, + following=True, + ).data + + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + assert result == expected_result + self.check_mock_called("get_user_subscriptions") + + params = { + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 11, + } + self.check_mock_called_with( + "get_user_subscriptions", -1, str(self.user.id), str(self.course.id), params + ) + + @ddt.data("unanswered", "unread") + def test_view_query(self, query): + self.register_get_threads_response([], page=1, num_pages=0) + result = get_thread_list( + self.request, + self.course.id, + page=1, + page_size=11, + view=query, + ).data + + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + assert result == expected_result + self.check_mock_called("get_user_threads") + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 11, + query: True, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + @ddt.data( + ("last_activity_at", "activity"), + ("comment_count", "comments"), + ("vote_count", "votes"), + ) + @ddt.unpack + def test_order_by_query(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 + """ + self.register_get_threads_response([], page=1, num_pages=0) + result = get_thread_list( + self.request, + self.course.id, + page=1, + page_size=11, + order_by=http_query, + ).data + + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + assert result == expected_result + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": cc_query, + "page": 1, + "per_page": 11, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_order_direction(self): + """ + Only "desc" is supported for order. Also, since it is simply swallowed, + it isn't included in the params. + """ + self.register_get_threads_response([], page=1, num_pages=0) + result = get_thread_list( + self.request, + self.course.id, + page=1, + page_size=11, + order_direction="desc", + ).data + + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + assert result == expected_result + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 11, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_invalid_order_direction(self): + """ + Test with invalid order_direction (e.g. "asc") + """ + with pytest.raises(ValidationError) as assertion: + self.register_get_threads_response([], page=1, num_pages=0) + get_thread_list( # pylint: disable=expression-not-assigned + self.request, + self.course.id, + page=1, + page_size=11, + order_direction="asc", + ).data + assert "order_direction" in assertion.value.message_dict diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views.py b/lms/djangoapps/discussion/rest_api/tests/test_views.py index 84efa95378..1df43ee4f9 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views.py @@ -1056,354 +1056,6 @@ class CourseTopicsViewV3Test(DiscussionAPIViewTestMixin, CommentsServiceMockMixi 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") - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - - 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, - })] - self.register_get_threads_response(source_threads, page=1, num_pages=2) - 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 - ) - self.assert_last_query_params({ - "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) - self.client.get( - self.url, - { - "course_id": str(self.course.id), - "view": query, - } - ) - self.assert_last_query_params({ - "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) - 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)."} - ) - self.assert_last_query_params({ - "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.assert_last_query_params({ - "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 - ) - assert urlparse( - httpretty.last_request().path # lint-amnesty, pylint: disable=no-member - ).path == f"/api/v1/users/{self.user.id}/subscribed_threads" - - @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.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "page": ["1"], - "per_page": ["10"], - "sort_key": [cc_query], - }) - - 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.assert_last_query_params({ - "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.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", - "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}) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py index 29e469c9a9..9c41c11b24 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py @@ -21,7 +21,9 @@ 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 lms.djangoapps.discussion.django_comment_client.tests.mixins import MockForumApiMixin +from lms.djangoapps.discussion.django_comment_client.tests.mixins import ( + MockForumApiMixin, +) from opaque_keys.edx.keys import CourseKey from pytz import UTC from rest_framework import status @@ -32,18 +34,32 @@ 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 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.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 + UserFactory, ) from common.djangoapps.util.testing import PatchMediaTypeMixin, UrlResetMixin from common.test.utils import disable_signal @@ -65,15 +81,34 @@ from lms.djangoapps.discussion.rest_api.tests.utils import ( 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.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 +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, ForumMockUtilsMixin, UrlResetMixin): @@ -86,7 +121,9 @@ class DiscussionAPIViewTestMixin(ForumsEnableMixin, ForumMockUtilsMixin, UrlRese client_class = APIClient - @mock.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() self.maxDiff = None # pylint: disable=invalid-name @@ -95,7 +132,7 @@ class DiscussionAPIViewTestMixin(ForumsEnableMixin, ForumMockUtilsMixin, UrlRese course="y", run="z", start=datetime.now(UTC), - discussion_topics={"Test Topic": {"id": "test_topic"}} + discussion_topics={"Test Topic": {"id": "test_topic"}}, ) self.password = "Password1234" self.user = UserFactory.create(password=self.password) @@ -120,23 +157,25 @@ class DiscussionAPIViewTestMixin(ForumsEnableMixin, ForumMockUtilsMixin, UrlRese 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')) + 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 = 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) @@ -145,14 +184,16 @@ class DiscussionAPIViewTestMixin(ForumsEnableMixin, ForumMockUtilsMixin, UrlRese """ 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 = 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) @@ -164,7 +205,7 @@ class DiscussionAPIViewTestMixin(ForumsEnableMixin, ForumMockUtilsMixin, UrlRese self.assert_response_correct( response, 401, - {"developer_message": "Authentication credentials were not provided."} + {"developer_message": "Authentication credentials were not provided."}, ) def test_inactive(self): @@ -174,9 +215,11 @@ class DiscussionAPIViewTestMixin(ForumsEnableMixin, ForumMockUtilsMixin, UrlRese @ddt.ddt @httpretty.activate -@disable_signal(api, 'thread_edited') +@disable_signal(api, "thread_edited") @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class ThreadViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin): +class ThreadViewSetPartialUpdateTest( + DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin +): """Tests for ThreadViewSet partial_update""" def setUp(self): @@ -186,47 +229,58 @@ class ThreadViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTest def test_basic(self): self.register_get_user_response(self.user) - self.register_thread({ - "created_at": "Test Created Date", - "updated_at": "Test Updated Date", - "read": True, - "resp_total": 2, - }) + self.register_thread( + { + "created_at": "Test Created Date", + "updated_at": "Test Updated Date", + "read": True, + "resp_total": 2, + } + ) 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_thread_data({ - 'raw_body': 'Edited body', - 'rendered_body': 'Edited body
', - 'preview_body': 'Edited body', - 'editable_fields': [ - 'abuse_flagged', 'anonymous', 'copy_link', 'following', 'raw_body', 'read', - 'title', 'topic_id', 'type' - ], - 'created_at': 'Test Created Date', - 'updated_at': 'Test Updated Date', - 'comment_count': 1, - 'read': True, - 'response_count': 2, - }) + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == self.expected_thread_data( + { + "raw_body": "Edited body", + "rendered_body": "Edited body
", + "preview_body": "Edited body", + "editable_fields": [ + "abuse_flagged", + "anonymous", + "copy_link", + "following", + "raw_body", + "read", + "title", + "topic_id", + "type", + ], + "created_at": "Test Created Date", + "updated_at": "Test Updated Date", + "comment_count": 1, + "read": True, + "response_count": 2, + } + ) params = { - 'thread_id': 'test_thread', - 'course_id': str(self.course.id), - 'commentable_id': 'test_topic', - 'thread_type': 'discussion', - 'title': 'Test Title', - 'body': 'Edited body', - 'user_id': str(self.user.id), - 'anonymous': False, - 'anonymous_to_peers': False, - 'closed': False, - 'pinned': False, - 'read': True, - 'editing_user_id': str(self.user.id), + "thread_id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "thread_type": "discussion", + "title": "Test Title", + "body": "Edited body", + "user_id": str(self.user.id), + "anonymous": False, + "anonymous_to_peers": False, + "closed": False, + "pinned": False, + "read": True, + "editing_user_id": str(self.user.id), } - self.check_mock_called_with('update_thread', -1, **params) + self.check_mock_called_with("update_thread", -1, **params) def test_error(self): self.register_get_user_response(self.user) @@ -234,10 +288,12 @@ class ThreadViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTest request_data = {"title": ""} response = self.request_patch(request_data) expected_response_data = { - "field_errors": {"title": {"developer_message": "This field may not be blank."}} + "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')) + response_data = json.loads(response.content.decode("utf-8")) assert response_data == expected_response_data @ddt.data( @@ -252,14 +308,17 @@ class ThreadViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTest 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 - }) + 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"), @@ -283,47 +342,68 @@ class ThreadViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTest 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 - }) + 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, + } + ) 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_thread({ - "username": thread_owner_user.username, - "user_id": str(thread_owner_user.id), - "resp_total": 2, - }) + 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} response = self.request_patch(request_data) assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - expected_data = self.expected_thread_data({ - 'author': str(thread_owner_user.username), - 'comment_count': 1, - 'can_delete': False, - 'read': True, - 'editable_fields': ['abuse_flagged', 'copy_link', 'following', 'read', 'voted'], - 'response_count': 2 - }) + response_data = json.loads(response.content.decode("utf-8")) + expected_data = self.expected_thread_data( + { + "author": str(thread_owner_user.username), + "comment_count": 1, + "can_delete": False, + "read": True, + "editable_fields": [ + "abuse_flagged", + "copy_link", + "following", + "read", + "voted", + ], + "response_count": 2, + } + ) assert response_data == expected_data @ddt.ddt -@disable_signal(api, 'comment_edited') +@disable_signal(api, "comment_edited") @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class CommentViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin): +class CommentViewSetPartialUpdateTest( + DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin +): """Tests for CommentViewSet partial_update""" def setUp(self): @@ -375,29 +455,33 @@ class CommentViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTes def test_basic(self): self.register_thread() - self.register_comment({"created_at": "Test Created Date", "updated_at": "Test Updated Date"}) + 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': 'Edited body', - 'rendered_body': 'Edited body
', - 'editable_fields': ['abuse_flagged', 'anonymous', 'raw_body'], - 'created_at': 'Test Created Date', - 'updated_at': 'Test Updated Date' - }) + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == self.expected_response_data( + { + "raw_body": "Edited body", + "rendered_body": "Edited body
", + "editable_fields": ["abuse_flagged", "anonymous", "raw_body"], + "created_at": "Test Created Date", + "updated_at": "Test Updated Date", + } + ) params = { - '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), + "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), } - self.check_mock_called_with('update_comment', -1, **params) + self.check_mock_called_with("update_comment", -1, **params) def test_error(self): self.register_thread() @@ -405,10 +489,12 @@ class CommentViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTes 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."}} + "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')) + response_data = json.loads(response.content.decode("utf-8")) assert response_data == expected_response_data @ddt.data( @@ -423,12 +509,14 @@ class CommentViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTes 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'] - }) + 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"], + } + ) @ddt.data( ("raw_body", "Edited body"), @@ -442,3 +530,396 @@ class CommentViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTes request_data = {field: value} response = self.request_patch(request_data) assert response.status_code == 400 + + +@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") + + 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, + } + ) + ] + self.register_get_threads_response(source_threads, page=1, num_pages=2) + 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) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + @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) + self.client.get( + self.url, + { + "course_id": str(self.course.id), + "view": query, + }, + ) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + query: True, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_pagination(self): + self.register_get_user_response(self.user) + self.register_get_threads_response([], page=1, num_pages=1) + 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)."}, + ) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 18, + "per_page": 4, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + 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) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + "text": "test search string", + } + self.check_mock_called_with( + "search_threads", + -1, + **params, + ) + + @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.check_mock_called("get_user_subscriptions") + + @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, + }, + ) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "page": 1, + "per_page": 10, + "sort_key": cc_query, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + 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", + }, + ) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + 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.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", + "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"] diff --git a/lms/djangoapps/discussion/tests/test_views.py b/lms/djangoapps/discussion/tests/test_views.py index facdb368f1..8bb0b45500 100644 --- a/lms/djangoapps/discussion/tests/test_views.py +++ b/lms/djangoapps/discussion/tests/test_views.py @@ -17,8 +17,6 @@ from django.urls import reverse from django.utils import translation from edx_django_utils.cache import RequestCache from edx_toggles.toggles.testutils import override_waffle_flag -from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import ( TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase, @@ -27,7 +25,6 @@ from xmodule.modulestore.tests.django_utils import ( from xmodule.modulestore.tests.factories import ( CourseFactory, BlockFactory, - check_mongo_calls ) from common.djangoapps.course_modes.models import CourseMode @@ -42,7 +39,6 @@ from lms.djangoapps.discussion.django_comment_client.permissions import get_team from lms.djangoapps.discussion.django_comment_client.tests.group_id 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 ( @@ -55,7 +51,6 @@ from lms.djangoapps.discussion.django_comment_client.utils import strip_none from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE from lms.djangoapps.discussion.views import _get_discussion_default_topic_id, course_discussions_settings_handler from lms.djangoapps.teams.tests.factories import CourseTeamFactory, CourseTeamMembershipFactory -from openedx.core.djangoapps.course_groups.models import CourseUserGroup from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts from openedx.core.djangoapps.course_groups.tests.test_views import CohortViewsTestCase from openedx.core.djangoapps.django_comment_common.comment_client.utils import CommentClientPaginatedResult @@ -68,8 +63,6 @@ from openedx.core.djangoapps.django_comment_common.utils import ThreadContext, s from openedx.core.djangoapps.util.testing import ContentGroupTestCase from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES from openedx.core.lib.teams_config import TeamsConfig -from openedx.features.content_type_gating.models import ContentTypeGatingConfig -from openedx.features.enterprise_support.tests.mixins.enterprise import EnterpriseTestConsentRequired log = logging.getLogger(__name__) @@ -529,93 +522,6 @@ class AllowPlusOrMinusOneInt(int): return f"({self.value} +/- 1)" -@ddt.ddt -@patch('requests.request', autospec=True) -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( - 'openedx.core.djangoapps.discussions.config.waffle.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), - (False, 50, 2, 2, 21, 8), - - # Enabling Enterprise integration should have no effect on the number of mongo queries made. - # split mongo: 3 queries, regardless of thread response size. - (True, 1, 2, 2, 21, 8), - (True, 50, 2, 2, 21, 8), - ) - @ddt.unpack - def test_number_of_mongo_queries( - self, - enterprise_enabled, - num_thread_responses, - num_uncached_mongo_calls, - num_cached_mongo_calls, - num_uncached_sql_queries, - num_cached_sql_queries, - mock_request - ): - ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1)) - with modulestore().default_store(ModuleStoreEnum.Type.split): - course = CourseFactory.create(discussion_topics={'dummy discussion': {'id': 'dummy_discussion_id'}}) - - student = UserFactory.create() - CourseEnrollmentFactory.create(user=student, course_id=course.id) - - test_thread_id = "test_thread_id" - mock_request.side_effect = make_mock_request_impl( - course=course, text="dummy content", thread_id=test_thread_id, num_thread_responses=num_thread_responses - ) - request = RequestFactory().get( - "dummy_url", - HTTP_X_REQUESTED_WITH="XMLHttpRequest" - ) - request.user = student - - def call_single_thread(): - """ - Call single_thread and assert that it returns what we expect. - """ - with patch.dict("django.conf.settings.FEATURES", dict(ENABLE_ENTERPRISE_INTEGRATION=enterprise_enabled)): - response = views.single_thread( - request, - str(course.id), - "dummy_discussion_id", - test_thread_id - ) - assert response.status_code == 200 - assert len(json.loads(response.content.decode('utf-8'))['content']['children']) == num_thread_responses - - # Test uncached first, then cached now that the cache is warm. - cached_calls = [ - [num_uncached_mongo_calls, num_uncached_sql_queries], - # Sometimes there will be one more or fewer sql call than expected, because the call to - # CourseMode.modes_for_course sometimes does / doesn't get cached and does / doesn't hit the DB. - # EDUCATOR-5167 - [num_cached_mongo_calls, AllowPlusOrMinusOneInt(num_cached_sql_queries)], - ] - for expected_mongo_calls, expected_sql_queries in cached_calls: - with self.assertNumQueries(expected_sql_queries, table_ignorelist=QUERY_COUNT_TABLE_IGNORELIST): - with check_mongo_calls(expected_mongo_calls): - call_single_thread() - - @patch('requests.request', autospec=True) class SingleCohortedThreadTestCase(CohortedTestCase): # lint-amnesty, pylint: disable=missing-class-docstring @@ -868,92 +774,6 @@ class SingleThreadGroupIdTestCase(CohortedTestCase, GroupIdAssertionMixin): # l ) -@patch('requests.request', autospec=True) -class ForumFormDiscussionContentGroupTestCase(ForumsEnableMixin, ContentGroupTestCase): - """ - Tests `forum_form_discussion api` works with different content groups. - Discussion blocks are setup in ContentGroupTestCase class i.e - alpha_block => alpha_group_discussion => alpha_cohort => alpha_user/community_ta - beta_block => beta_group_discussion => beta_cohort => beta_user - """ - - @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - super().setUp() - self.thread_list = [ - {"thread_id": "test_general_thread_id"}, - {"thread_id": "test_global_group_thread_id", "commentable_id": self.global_block.discussion_id}, - {"thread_id": "test_alpha_group_thread_id", "group_id": self.alpha_block.group_access[0][0], - "commentable_id": self.alpha_block.discussion_id}, - {"thread_id": "test_beta_group_thread_id", "group_id": self.beta_block.group_access[0][0], - "commentable_id": self.beta_block.discussion_id} - ] - - def assert_has_access(self, response, expected_discussion_threads): - """ - Verify that a users have access to the threads in their assigned - cohorts and non-cohorted blocks. - """ - discussion_data = json.loads(response.content.decode('utf-8'))['discussion_data'] - assert len(discussion_data) == expected_discussion_threads - - def call_view(self, mock_request, user): # lint-amnesty, pylint: disable=missing-function-docstring - mock_request.side_effect = make_mock_request_impl( - course=self.course, - text="dummy content", - thread_list=self.thread_list - ) - self.client.login(username=user.username, password=self.TEST_PASSWORD) - return self.client.get( - reverse("forum_form_discussion", args=[str(self.course.id)]), - HTTP_X_REQUESTED_WITH="XMLHttpRequest" - ) - - def test_community_ta_user(self, mock_request): - """ - Verify that community_ta user has access to all threads regardless - of cohort. - """ - response = self.call_view( - mock_request, - self.community_ta - ) - self.assert_has_access(response, 4) - - def test_alpha_cohort_user(self, mock_request): - """ - Verify that alpha_user has access to alpha_cohort and non-cohorted - threads. - """ - response = self.call_view( - mock_request, - self.alpha_user - ) - self.assert_has_access(response, 3) - - def test_beta_cohort_user(self, mock_request): - """ - Verify that beta_user has access to beta_cohort and non-cohorted - threads. - """ - response = self.call_view( - mock_request, - self.beta_user - ) - self.assert_has_access(response, 3) - - def test_global_staff_user(self, mock_request): - """ - Verify that global staff user has access to all threads regardless - of cohort. - """ - response = self.call_view( - mock_request, - self.staff_user - ) - self.assert_has_access(response, 4) - - @patch('requests.request', autospec=True) class SingleThreadContentGroupTestCase(ForumsEnableMixin, UrlResetMixin, ContentGroupTestCase): # lint-amnesty, pylint: disable=missing-class-docstring @@ -1080,417 +900,6 @@ class SingleThreadContentGroupTestCase(ForumsEnableMixin, UrlResetMixin, Content self.assert_can_access(self.beta_user, self.alpha_block.discussion_id, thread_id, True) -@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) -class InlineDiscussionContextTestCase(ForumsEnableMixin, ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring - - def setUp(self): - super().setUp() - self.course = CourseFactory.create() - CourseEnrollmentFactory(user=self.user, course_id=self.course.id) - self.discussion_topic_id = "dummy_topic" - self.team = CourseTeamFactory( - name="A team", - course_id=self.course.id, - topic_id='topic_id', - discussion_topic_id=self.discussion_topic_id - ) - - self.team.add_user(self.user) - self.user_not_in_team = UserFactory.create() - - def test_context_can_be_standalone(self, mock_request): - mock_request.side_effect = make_mock_request_impl( - course=self.course, - text="dummy text", - commentable_id=self.discussion_topic_id - ) - - request = RequestFactory().get("dummy_url") - request.user = self.user - - response = views.inline_discussion( - request, - str(self.course.id), - self.discussion_topic_id, - ) - - json_response = json.loads(response.content.decode('utf-8')) - assert json_response['discussion_data'][0]['context'] == ThreadContext.STANDALONE - - def test_private_team_discussion(self, mock_request): - # First set the team discussion to be private - CourseEnrollmentFactory(user=self.user_not_in_team, course_id=self.course.id) - request = RequestFactory().get("dummy_url") - request.user = self.user_not_in_team - - mock_request.side_effect = make_mock_request_impl( - course=self.course, - text="dummy text", - commentable_id=self.discussion_topic_id - ) - - with patch('lms.djangoapps.teams.api.is_team_discussion_private', autospec=True) as mocked: - mocked.return_value = True - response = views.inline_discussion( - request, - str(self.course.id), - self.discussion_topic_id, - ) - assert response.status_code == 403 - assert response.content.decode('utf-8') == views.TEAM_PERMISSION_MESSAGE - - -@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) -@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) -class InlineDiscussionGroupIdTestCase( # lint-amnesty, pylint: disable=missing-class-docstring - CohortedTestCase, - CohortedTopicGroupIdTestMixin, - NonCohortedTopicGroupIdTestMixin -): - cs_endpoint = "/threads" - - def setUp(self): - super().setUp() - self.cohorted_commentable_id = 'cohorted_topic' - 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 - # to find a group name for the group_id, when we're testing with - # an invalid one. - try: - CourseUserGroup.objects.get(id=group_id) - kwargs['group_id'] = group_id - except CourseUserGroup.DoesNotExist: - pass - mock_request.side_effect = make_mock_request_impl(self.course, "dummy content", **kwargs) - - request_data = {} - if pass_group_id: - request_data["group_id"] = group_id - request = RequestFactory().get( - "dummy_url", - data=request_data - ) - request.user = user - return views.inline_discussion( - request, - str(self.course.id), - commentable_id - ) - - 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, - self.student_cohort.id - ) - self._assert_json_response_contains_group_info( - response, lambda d: d['discussion_data'][0] - ) - - -@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) -@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) -class ForumFormDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGroupIdTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring - cs_endpoint = "/threads" - - 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 - mock_request.side_effect = make_mock_request_impl(self.course, "dummy content", **kwargs) - - request_data = {} - if pass_group_id: - request_data["group_id"] = group_id - headers = {} - if is_ajax: - headers['HTTP_X_REQUESTED_WITH'] = "XMLHttpRequest" - - self.client.login(username=user.username, password=self.TEST_PASSWORD) - return self.client.get( - reverse("forum_form_discussion", args=[str(self.course.id)]), - data=request_data, - **headers - ) - - 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, - self.student_cohort.id - ) - self._assert_html_response_contains_group_info(response) - - 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, - self.student_cohort.id, - is_ajax=True - ) - self._assert_json_response_contains_group_info( - response, lambda d: d['discussion_data'][0] - ) - - -@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) -@patch('openedx.core.djangoapps.discussions.config.waffle.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_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 - mock_request.side_effect = make_mock_request_impl(self.course, "dummy content", **kwargs) - - request_data = {} - if pass_group_id: - request_data["group_id"] = group_id - headers = {} - if is_ajax: - headers['HTTP_X_REQUESTED_WITH'] = "XMLHttpRequest" - - self.client.login(username=requesting_user.username, password=self.TEST_PASSWORD) - return self.client.get( - reverse('user_profile', args=[str(self.course.id), profiled_user.id]), - data=request_data, - **headers - ) - - 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_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_is_forum_v2_enabled, mock_request): - response = self.call_view( - mock_is_forum_v2_enabled, - mock_request, - "cohorted_topic", - self.student, - self.student_cohort.id, - is_ajax=False - ) - self._assert_html_response_contains_group_info(response) - - 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, - self.student_cohort.id, - is_ajax=True - ) - self._assert_json_response_contains_group_info( - response, lambda d: d['discussion_data'][0] - ) - - def _test_group_id_passed_to_user_profile( - 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. - """ - - def get_params_from_user_info_call(for_specific_course): - """ - Returns the request parameters for the user info call with either course_id specified or not, - depending on value of 'for_specific_course'. - """ - # There will be 3 calls from user_profile. One has the cs_endpoint "active_threads", and it is already - # tested. The other 2 calls are for user info; one of those calls is for general information about the user, - # and it does not specify a course_id. The other call does specify a course_id, and if the caller did not - # have discussion moderator privileges, it should also contain a group_id. - for r_call in mock_request.call_args_list: - if not r_call[0][1].endswith(self.cs_endpoint): - params = r_call[1]["params"] - has_course_id = "course_id" in params - if (for_specific_course and has_course_id) or (not for_specific_course and not has_course_id): - return params - pytest.fail(f"Did not find appropriate user_profile call for 'for_specific_course'={for_specific_course}") - - mock_request.reset_mock() - self.call_view_for_profiled_user( - mock_is_forum_v2_enabled, - mock_request, - requesting_user, - profiled_user, - group_id, - pass_group_id=pass_group_id, - is_ajax=False - ) - # Should never have a group_id if course_id was not included in the request. - params_without_course_id = get_params_from_user_info_call(False) - assert 'group_id' not in params_without_course_id - - params_with_course_id = get_params_from_user_info_call(True) - if expect_group_id_in_request: - assert 'group_id' in params_with_course_id - assert group_id == params_with_course_id['group_id'] - else: - assert 'group_id' not in params_with_course_id - - 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. - """ - def verify_group_id_always_present(profiled_user, pass_group_id): - """ - Helper method to verify that group_id is always present for student in course - (non-privileged user). - """ - self._test_group_id_passed_to_user_profile( - 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). - # The profile returned on behalf of the student is for the profiled_user. - verify_group_id_always_present(profiled_user=self.student, pass_group_id=True) - verify_group_id_always_present(profiled_user=self.student, pass_group_id=False) - 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_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. - """ - def verify_group_id_present(profiled_user, pass_group_id, requested_cohort=self.moderator_cohort): - """ - Helper method to verify that group_id is present. - """ - self._test_group_id_passed_to_user_profile( - 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): - """ - Helper method to verify that group_id is not present. - """ - self._test_group_id_passed_to_user_profile( - 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). - - # If the group_id is explicitly passed, it will be present in the request. - verify_group_id_present(profiled_user=self.student, pass_group_id=True) - verify_group_id_present(profiled_user=self.moderator, pass_group_id=True) - verify_group_id_present( - profiled_user=self.student, pass_group_id=True, requested_cohort=self.student_cohort - ) - - # If the group_id is not explicitly passed, it will not be present because the requesting_user - # has discussion moderator privileges. - verify_group_id_not_present(profiled_user=self.student, pass_group_id=False) - verify_group_id_not_present(profiled_user=self.moderator, pass_group_id=False) - - @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) @patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) class FollowedThreadsDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGroupIdTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring @@ -1547,189 +956,6 @@ class FollowedThreadsDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGr ) -@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) -class InlineDiscussionTestCase(ForumsEnableMixin, ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring - - def setUp(self): - super().setUp() - - self.course = CourseFactory.create( - org="TestX", - number="101", - display_name="Test Course", - teams_configuration=TeamsConfig({ - 'topics': [{ - 'id': 'topic_id', - 'name': 'A topic', - 'description': 'A topic', - }] - }) - ) - self.student = UserFactory.create() - CourseEnrollmentFactory(user=self.student, course_id=self.course.id) - self.discussion1 = BlockFactory.create( - parent_location=self.course.location, - category="discussion", - discussion_id="discussion1", - display_name='Discussion1', - discussion_category="Chapter", - discussion_target="Discussion1" - ) - - def send_request(self, mock_request, params=None): - """ - Creates and returns a request with params set, and configures - mock_request to return appropriate values. - """ - request = RequestFactory().get("dummy_url", params if params else {}) - request.user = self.student - mock_request.side_effect = make_mock_request_impl( - course=self.course, text="dummy content", commentable_id=self.discussion1.discussion_id - ) - return views.inline_discussion( - request, str(self.course.id), self.discussion1.discussion_id - ) - - def test_context(self, mock_request): - team = CourseTeamFactory( - name='Team Name', - topic_id='topic_id', - course_id=self.course.id, - discussion_topic_id=self.discussion1.discussion_id - ) - - team.add_user(self.student) - - self.send_request(mock_request) - assert mock_request.call_args[1]['params']['context'] == ThreadContext.STANDALONE - - -@patch('requests.request', autospec=True) -class UserProfileTestCase(ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring - - TEST_THREAD_TEXT = 'userprofile-test-text' - TEST_THREAD_ID = 'userprofile-test-thread-id' - - @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - super().setUp() - - self.course = CourseFactory.create() - self.student = UserFactory.create() - self.profiled_user = UserFactory.create() - CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) - CourseEnrollmentFactory.create(user=self.profiled_user, course_id=self.course.id) - - def get_response(self, mock_request, params, **headers): # lint-amnesty, pylint: disable=missing-function-docstring - mock_request.side_effect = make_mock_request_impl( - course=self.course, text=self.TEST_THREAD_TEXT, thread_id=self.TEST_THREAD_ID - ) - self.client.login(username=self.student.username, password=self.TEST_PASSWORD) - - response = self.client.get( - reverse('user_profile', kwargs={ - 'course_id': str(self.course.id), - 'user_id': self.profiled_user.id, - }), - data=params, - **headers - ) - mock_request.assert_any_call( - "get", - StringEndsWithMatcher(f'/users/{self.profiled_user.id}/active_threads'), - data=None, - params=PartialDictMatcher({ - "course_id": str(self.course.id), - "page": params.get("page", 1), - "per_page": views.THREADS_PER_PAGE - }), - headers=ANY, - timeout=ANY - ) - return response - - def check_html(self, mock_request, **params): # lint-amnesty, pylint: disable=missing-function-docstring - response = self.get_response(mock_request, params) - assert response.status_code == 200 - assert response['Content-Type'] == 'text/html; charset=utf-8' - html = response.content.decode('utf-8') - self.assertRegex(html, r'data-page="1"') - self.assertRegex(html, r'data-num-pages="1"') - self.assertRegex(html, r'1 discussion started') - self.assertRegex(html, r'2 comments') - self.assertRegex(html, f''id': '{self.TEST_THREAD_ID}'') - self.assertRegex(html, f''title': '{self.TEST_THREAD_TEXT}'') - self.assertRegex(html, f''body': '{self.TEST_THREAD_TEXT}'') - self.assertRegex(html, f''username': '{self.student.username}'') - - def check_ajax(self, mock_request, **params): # lint-amnesty, pylint: disable=missing-function-docstring - response = self.get_response(mock_request, params, HTTP_X_REQUESTED_WITH="XMLHttpRequest") - assert response.status_code == 200 - assert response['Content-Type'] == 'application/json; charset=utf-8' - response_data = json.loads(response.content.decode('utf-8')) - assert sorted(response_data.keys()) == ['annotated_content_info', 'discussion_data', 'num_pages', 'page'] - assert len(response_data['discussion_data']) == 1 - assert response_data['page'] == 1 - assert response_data['num_pages'] == 1 - assert response_data['discussion_data'][0]['id'] == self.TEST_THREAD_ID - assert response_data['discussion_data'][0]['title'] == self.TEST_THREAD_TEXT - assert response_data['discussion_data'][0]['body'] == self.TEST_THREAD_TEXT - - def test_html(self, mock_request): - self.check_html(mock_request) - - def test_ajax(self, mock_request): - self.check_ajax(mock_request) - - def test_404_non_enrolled_user(self, __): - """ - Test that when student try to visit un-enrolled students' discussion profile, - the system raises Http404. - """ - unenrolled_user = UserFactory.create() - request = RequestFactory().get("dummy_url") - request.user = self.student - with pytest.raises(Http404): - views.user_profile( - request, - str(self.course.id), - unenrolled_user.id - ) - - def test_404_profiled_user(self, _mock_request): - request = RequestFactory().get("dummy_url") - request.user = self.student - with pytest.raises(Http404): - views.user_profile( - request, - str(self.course.id), - -999 - ) - - def test_404_course(self, _mock_request): - request = RequestFactory().get("dummy_url") - request.user = self.student - with pytest.raises(Http404): - views.user_profile( - request, - "non/existent/course", - self.profiled_user.id - ) - - def test_post(self, mock_request): - mock_request.side_effect = make_mock_request_impl( - course=self.course, text=self.TEST_THREAD_TEXT, thread_id=self.TEST_THREAD_ID - ) - request = RequestFactory().post("dummy_url") - request.user = self.student - response = views.user_profile( - request, - str(self.course.id), - self.profiled_user.id - ) - assert response.status_code == 405 - - @patch('requests.request', autospec=True) class CommentsServiceRequestHeadersTestCase(ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring @@ -1811,155 +1037,6 @@ class CommentsServiceRequestHeadersTestCase(ForumsEnableMixin, UrlResetMixin, Mo self.assert_all_calls_have_header(mock_request, "X-Edx-Api-Key", "test_api_key") -class InlineDiscussionUnicodeTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring - - @classmethod - def setUpClass(cls): - # pylint: disable=super-method-not-called - with super().setUpClassAndTestData(): - cls.course = CourseFactory.create() - - @classmethod - def setUpTestData(cls): - super().setUpTestData() - - cls.student = UserFactory.create() - 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): # lint-amnesty, pylint: disable=missing-function-docstring - mock_request.side_effect = make_mock_request_impl(course=self.course, text=text) - request = RequestFactory().get("dummy_url") - request.user = self.student - - response = views.inline_discussion( - request, str(self.course.id), self.course.discussion_topics['General']['id'] - ) - assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data['discussion_data'][0]['title'] == text - assert response_data['discussion_data'][0]['body'] == text - - -class ForumFormDiscussionUnicodeTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring - - @classmethod - def setUpClass(cls): - # pylint: disable=super-method-not-called - with super().setUpClassAndTestData(): - cls.course = CourseFactory.create() - - @classmethod - def setUpTestData(cls): - super().setUpTestData() - - cls.student = UserFactory.create() - 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): # lint-amnesty, pylint: disable=missing-function-docstring - mock_request.side_effect = make_mock_request_impl(course=self.course, text=text) - request = RequestFactory().get("dummy_url") - request.user = self.student - # so (request.headers.get('x-requested-with') == 'XMLHttpRequest') == True - request.META["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest" - - response = views.forum_form_discussion(request, str(self.course.id)) - assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data['discussion_data'][0]['title'] == text - assert response_data['discussion_data'][0]['body'] == text - - -@ddt.ddt -@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) -class ForumDiscussionXSSTestCase(ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring - - @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - super().setUp() - - username = "foo" - password = "bar" - - self.course = CourseFactory.create() - self.student = UserFactory.create(username=username, password=password) - CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) - assert self.client.login(username=username, password=password) - - @ddt.data('">', '', '') - @patch('common.djangoapps.student.models.user.cc.User.from_django_user') - def test_forum_discussion_xss_prevent(self, malicious_code, mock_user, mock_req): - """ - Test that XSS attack is prevented - """ - mock_user.return_value.to_dict.return_value = {} - mock_req.return_value.status_code = 200 - reverse_url = "{}{}".format(reverse( - "forum_form_discussion", - kwargs={"course_id": str(self.course.id)}), '/forum_form_discussion') - # Test that malicious code does not appear in html - url = "{}?{}={}".format(reverse_url, 'sort_key', malicious_code) - resp = self.client.get(url) - self.assertNotContains(resp, malicious_code) - - @ddt.data('">', '', '') - @patch('common.djangoapps.student.models.user.cc.User.from_django_user') - @patch('common.djangoapps.student.models.user.cc.User.active_threads') - def test_forum_user_profile_xss_prevent(self, malicious_code, mock_threads, mock_from_django_user, mock_request): - """ - Test that XSS attack is prevented - """ - mock_threads.return_value = [], 1, 1 - mock_from_django_user.return_value.to_dict.return_value = { - 'upvoted_ids': [], - 'downvoted_ids': [], - 'subscribed_thread_ids': [] - } - mock_request.side_effect = make_mock_request_impl(course=self.course, text='dummy') - - url = reverse('user_profile', - kwargs={'course_id': str(self.course.id), 'user_id': str(self.student.id)}) - # Test that malicious code does not appear in html - url_string = "{}?{}={}".format(url, 'page', malicious_code) - resp = self.client.get(url_string) - self.assertNotContains(resp, malicious_code) - - -class ForumDiscussionSearchUnicodeTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring - - @classmethod - def setUpClass(cls): - # pylint: disable=super-method-not-called - with super().setUpClassAndTestData(): - cls.course = CourseFactory.create() - - @classmethod - def setUpTestData(cls): - super().setUpTestData() - - cls.student = UserFactory.create() - 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): # lint-amnesty, pylint: disable=missing-function-docstring - mock_request.side_effect = make_mock_request_impl(course=self.course, text=text) - data = { - "ajax": 1, - "text": text, - } - request = RequestFactory().get("dummy_url", data) - request.user = self.student - # so (request.headers.get('x-requested-with') == 'XMLHttpRequest') == True - request.META["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest" - - response = views.forum_form_discussion(request, str(self.course.id)) - assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data['discussion_data'][0]['title'] == text - assert response_data['discussion_data'][0]['body'] == text - - class SingleThreadUnicodeTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring @classmethod @@ -2087,60 +1164,6 @@ class EnrollmentTestCase(ForumsEnableMixin, ModuleStoreTestCase): views.forum_form_discussion(request, course_id=str(self.course.id)) # pylint: disable=no-value-for-parameter, unexpected-keyword-arg -@patch('requests.request', autospec=True) -class EnterpriseConsentTestCase(EnterpriseTestConsentRequired, ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase): - """ - Ensure that the Enterprise Data Consent redirects are in place only when consent is required. - """ - CREATE_USER = False - - @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - # Invoke UrlResetMixin setUp - super().setUp() - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.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" - - self.discussion_id = 'dummy_discussion_id' - self.course = CourseFactory.create(discussion_topics={'dummy discussion': {'id': self.discussion_id}}) - self.student = UserFactory.create(username=username, password=password) - CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) - assert self.client.login(username=username, password=password) - - self.addCleanup(translation.deactivate) - - @patch('openedx.features.enterprise_support.api.enterprise_customer_for_request') - def test_consent_required(self, mock_enterprise_customer_for_request, mock_request): - """ - Test that enterprise data sharing consent is required when enabled for the various discussion views. - """ - # ENT-924: Temporary solution to replace sensitive SSO usernames. - mock_enterprise_customer_for_request.return_value = None - - thread_id = 'dummy' - course_id = str(self.course.id) - mock_request.side_effect = make_mock_request_impl(course=self.course, text='dummy', thread_id=thread_id) - - for url in ( - reverse('forum_form_discussion', - kwargs=dict(course_id=course_id)), - reverse('single_thread', - kwargs=dict(course_id=course_id, discussion_id=self.discussion_id, thread_id=thread_id)), - ): - self.verify_consent_required(self.client, url) # pylint: disable=no-value-for-parameter - - class DividedDiscussionsTestCase(CohortViewsTestCase): # lint-amnesty, pylint: disable=missing-class-docstring def create_divided_discussions(self): diff --git a/lms/djangoapps/discussion/tests/test_views_v2.py b/lms/djangoapps/discussion/tests/test_views_v2.py new file mode 100644 index 0000000000..3ac375ed03 --- /dev/null +++ b/lms/djangoapps/discussion/tests/test_views_v2.py @@ -0,0 +1,1264 @@ +# pylint: disable=unused-import +""" +Tests the forum notification views. +""" + +import json +import logging +from datetime import datetime +from unittest import mock +from unittest.mock import ANY, Mock, call, patch + +import ddt +import pytest +from django.conf import settings +from django.http import Http404 +from django.test.client import Client, RequestFactory +from django.test.utils import override_settings +from django.urls import reverse +from django.utils import translation +from edx_django_utils.cache import RequestCache +from edx_toggles.toggles.testutils import override_waffle_flag +from lms.djangoapps.discussion.django_comment_client.tests.mixins import ( + MockForumApiMixin, +) +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 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 ( + AdminFactory, + CourseEnrollmentFactory, + UserFactory, +) +from common.djangoapps.util.testing import EventTestMixin, UrlResetMixin +from lms.djangoapps.courseware.exceptions import CourseAccessRedirect +from lms.djangoapps.discussion import views +from lms.djangoapps.discussion.django_comment_client.constants import ( + TYPE_ENTRY, + TYPE_SUBCATEGORY, +) +from lms.djangoapps.discussion.django_comment_client.permissions import get_team +from lms.djangoapps.discussion.django_comment_client.tests.group_id import ( + CohortedTopicGroupIdTestMixinV2, + GroupIdAssertionMixinV2, + NonCohortedTopicGroupIdTestMixinV2, +) +from lms.djangoapps.discussion.django_comment_client.tests.unicode import ( + UnicodeTestMixin, +) +from lms.djangoapps.discussion.django_comment_client.tests.utils import ( + CohortedTestCase, + ForumsEnableMixin, + config_course_discussions, + topic_name_to_id, +) +from lms.djangoapps.discussion.django_comment_client.utils import strip_none +from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE +from lms.djangoapps.discussion.views import ( + _get_discussion_default_topic_id, + course_discussions_settings_handler, +) +from lms.djangoapps.teams.tests.factories import ( + CourseTeamFactory, + CourseTeamMembershipFactory, +) +from openedx.core.djangoapps.course_groups.models import CourseUserGroup +from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts +from openedx.core.djangoapps.course_groups.tests.test_views import CohortViewsTestCase +from openedx.core.djangoapps.django_comment_common.comment_client.utils import ( + CommentClientPaginatedResult, +) +from openedx.core.djangoapps.django_comment_common.models import ( + FORUM_ROLE_STUDENT, + CourseDiscussionSettings, + ForumsConfig, +) +from openedx.core.djangoapps.django_comment_common.utils import ( + ThreadContext, + seed_permissions_roles, +) +from openedx.core.djangoapps.util.testing import ContentGroupTestCase +from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES +from openedx.core.lib.teams_config import TeamsConfig +from openedx.features.content_type_gating.models import ContentTypeGatingConfig +from openedx.features.enterprise_support.tests.mixins.enterprise import ( + EnterpriseTestConsentRequired, +) + +log = logging.getLogger(__name__) + +QUERY_COUNT_TABLE_IGNORELIST = WAFFLE_TABLES + + +def make_mock_thread_data( + course, + text, + thread_id, + num_children, + group_id=None, + group_name=None, + commentable_id=None, + is_commentable_divided=None, + anonymous=False, + anonymous_to_peers=False, +): + """ + Creates mock thread data for testing purposes. + """ + data_commentable_id = ( + commentable_id + or course.discussion_topics.get("General", {}).get("id") + or "dummy_commentable_id" + ) + thread_data = { + "id": thread_id, + "type": "thread", + "title": text, + "body": text, + "commentable_id": data_commentable_id, + "resp_total": 42, + "resp_skip": 25, + "resp_limit": 5, + "group_id": group_id, + "anonymous": anonymous, + "anonymous_to_peers": anonymous_to_peers, + "context": ( + ThreadContext.COURSE + if get_team(data_commentable_id) is None + else ThreadContext.STANDALONE + ), + } + if group_id is not None: + thread_data["group_name"] = group_name + if is_commentable_divided is not None: + thread_data["is_commentable_divided"] = is_commentable_divided + if num_children is not None: + thread_data["children"] = [ + { + "id": f"dummy_comment_id_{i}", + "type": "comment", + "body": text, + } + for i in range(num_children) + ] + return thread_data + + +def make_mock_collection_data( + course, + text, + thread_id, + num_children=None, + group_id=None, + commentable_id=None, + thread_list=None, +): + """ + Creates mock collection data for testing purposes. + """ + if thread_list: + return [ + make_mock_thread_data( + course=course, text=text, num_children=num_children, **thread + ) + for thread in thread_list + ] + else: + return [ + make_mock_thread_data( + course=course, + text=text, + thread_id=thread_id, + num_children=num_children, + group_id=group_id, + commentable_id=commentable_id, + ) + ] + + +def make_collection_callback( + course, + text, + thread_id="dummy_thread_id", + group_id=None, + commentable_id=None, + thread_list=None, +): + """ + Creates a callback function for simulating collection data. + """ + + def callback(*args, **kwargs): + # Simulate default user thread response + return { + "collection": make_mock_collection_data( + course, text, thread_id, None, group_id, commentable_id, thread_list + ) + } + + return callback + + +def make_thread_callback( + course, + text, + thread_id="dummy_thread_id", + group_id=None, + commentable_id=None, + num_thread_responses=1, + anonymous=False, + anonymous_to_peers=False, +): + """ + Creates a callback function for simulating thread data. + """ + + def callback(*args, **kwargs): + # Simulate default user thread response + return make_mock_thread_data( + course=course, + text=text, + thread_id=thread_id, + num_children=num_thread_responses, + group_id=group_id, + commentable_id=commentable_id, + anonymous=anonymous, + anonymous_to_peers=anonymous_to_peers, + ) + + return callback + + +def make_user_callback(): + """ + Creates a callback function for simulating user data. + """ + + def callback(*args, **kwargs): + res = { + "default_sort_key": "date", + "upvoted_ids": [], + "downvoted_ids": [], + "subscribed_thread_ids": [], + } + # comments service adds these attributes when course_id param is present + if kwargs.get("course_id"): + res.update({"threads_count": 1, "comments_count": 2}) + return res + + return callback + + +class ForumViewsUtilsMixin(MockForumApiMixin): + """ + Utils for the Forum Views. + """ + + def _configure_mock_responses( + self, + course, + text, + thread_id="dummy_thread_id", + group_id=None, + commentable_id=None, + num_thread_responses=1, + thread_list=None, + anonymous=False, + anonymous_to_peers=False, + ): + """ + Configure mock responses for the Forum Views. + """ + for func_name in [ + "search_threads", + "get_user_active_threads", + "get_user_threads", + ]: + self.set_mock_side_effect( + func_name, + make_collection_callback( + course, + text, + thread_id, + group_id, + commentable_id, + thread_list, + ), + ) + + self.set_mock_side_effect( + "get_thread", + make_thread_callback( + course, + text, + thread_id, + group_id, + commentable_id, + num_thread_responses, + anonymous, + anonymous_to_peers, + ), + ) + + self.set_mock_side_effect("get_user", make_user_callback()) + + +class ForumFormDiscussionContentGroupTestCase( + ForumsEnableMixin, ContentGroupTestCase, ForumViewsUtilsMixin +): + """ + Tests `forum_form_discussion api` works with different content groups. + Discussion blocks are setup in ContentGroupTestCase class i.e + alpha_block => alpha_group_discussion => alpha_cohort => alpha_user/community_ta + beta_block => beta_group_discussion => beta_cohort => beta_user + """ + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + super().setUp() + self.thread_list = [ + {"thread_id": "test_general_thread_id"}, + { + "thread_id": "test_global_group_thread_id", + "commentable_id": self.global_block.discussion_id, + }, + { + "thread_id": "test_alpha_group_thread_id", + "group_id": self.alpha_block.group_access[0][0], + "commentable_id": self.alpha_block.discussion_id, + }, + { + "thread_id": "test_beta_group_thread_id", + "group_id": self.beta_block.group_access[0][0], + "commentable_id": self.beta_block.discussion_id, + }, + ] + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def assert_has_access(self, response, expected_discussion_threads): + """ + Verify that a users have access to the threads in their assigned + cohorts and non-cohorted blocks. + """ + discussion_data = json.loads(response.content.decode("utf-8"))[ + "discussion_data" + ] + assert len(discussion_data) == expected_discussion_threads + + def call_view( + self, user + ): # lint-amnesty, pylint: disable=missing-function-docstring + self._configure_mock_responses( + course=self.course, text="dummy content", thread_list=self.thread_list + ) + self.client.login(username=user.username, password=self.TEST_PASSWORD) + return self.client.get( + reverse("forum_form_discussion", args=[str(self.course.id)]), + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + + def test_community_ta_user(self): + """ + Verify that community_ta user has access to all threads regardless + of cohort. + """ + response = self.call_view(self.community_ta) + self.assert_has_access(response, 4) + + def test_alpha_cohort_user(self): + """ + Verify that alpha_user has access to alpha_cohort and non-cohorted + threads. + """ + response = self.call_view(self.alpha_user) + self.assert_has_access(response, 3) + + def test_beta_cohort_user(self): + """ + Verify that beta_user has access to beta_cohort and non-cohorted + threads. + """ + response = self.call_view(self.beta_user) + self.assert_has_access(response, 3) + + def test_global_staff_user(self): + """ + Verify that global staff user has access to all threads regardless + of cohort. + """ + response = self.call_view(self.staff_user) + self.assert_has_access(response, 4) + + +class ForumFormDiscussionUnicodeTestCase( + ForumsEnableMixin, + SharedModuleStoreTestCase, + UnicodeTestMixin, + ForumViewsUtilsMixin, +): + """ + Discussiin Unicode Tests. + """ + + @classmethod + def setUpClass(cls): # pylint: disable=super-method-not-called + super().setUpClassAndForumMock() + + with super().setUpClassAndTestData(): + cls.course = CourseFactory.create() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + cls.student = UserFactory.create() + CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) + + def _test_unicode_data( + self, text + ): # lint-amnesty, pylint: disable=missing-function-docstring + self._configure_mock_responses(course=self.course, text=text) + request = RequestFactory().get("dummy_url") + request.user = self.student + # so (request.headers.get('x-requested-with') == 'XMLHttpRequest') == True + request.META["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest" + + response = views.forum_form_discussion(request, str(self.course.id)) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data["discussion_data"][0]["title"] == text + assert response_data["discussion_data"][0]["body"] == text + + +class EnterpriseConsentTestCase( + EnterpriseTestConsentRequired, + ForumsEnableMixin, + UrlResetMixin, + ModuleStoreTestCase, + ForumViewsUtilsMixin, +): + """ + Ensure that the Enterprise Data Consent redirects are in place only when consent is required. + """ + + CREATE_USER = False + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + # Invoke UrlResetMixin setUp + super().setUp() + username = "foo" + password = "bar" + + self.discussion_id = "dummy_discussion_id" + self.course = CourseFactory.create( + discussion_topics={"dummy discussion": {"id": self.discussion_id}} + ) + self.student = UserFactory.create(username=username, password=password) + CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) + assert self.client.login(username=username, password=password) + + self.addCleanup(translation.deactivate) + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + @patch("openedx.features.enterprise_support.api.enterprise_customer_for_request") + def test_consent_required(self, mock_enterprise_customer_for_request): + """ + Test that enterprise data sharing consent is required when enabled for the various discussion views. + """ + # ENT-924: Temporary solution to replace sensitive SSO usernames. + mock_enterprise_customer_for_request.return_value = None + + thread_id = "dummy" + course_id = str(self.course.id) + self._configure_mock_responses( + course=self.course, text="dummy", thread_id=thread_id + ) + + for url in ( + reverse("forum_form_discussion", kwargs=dict(course_id=course_id)), + reverse( + "single_thread", + kwargs=dict( + course_id=course_id, + discussion_id=self.discussion_id, + thread_id=thread_id, + ), + ), + ): + self.verify_consent_required( # pylint: disable=no-value-for-parameter + self.client, url + ) + + +class InlineDiscussionGroupIdTestCase( # lint-amnesty, pylint: disable=missing-class-docstring + CohortedTestCase, + CohortedTopicGroupIdTestMixinV2, + NonCohortedTopicGroupIdTestMixinV2, + ForumViewsUtilsMixin, +): + function_name = "get_user_threads" + + def setUp(self): + super().setUp() + self.cohorted_commentable_id = "cohorted_topic" + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def call_view( + self, commentable_id, user, group_id, pass_group_id=True + ): # pylint: disable=arguments-differ + kwargs = {"commentable_id": self.cohorted_commentable_id} + if group_id: + # avoid causing a server error when the LMS chokes attempting + # to find a group name for the group_id, when we're testing with + # an invalid one. + try: + CourseUserGroup.objects.get(id=group_id) + kwargs["group_id"] = group_id + except CourseUserGroup.DoesNotExist: + pass + self._configure_mock_responses(self.course, "dummy content", **kwargs) + + request_data = {} + if pass_group_id: + request_data["group_id"] = group_id + request = RequestFactory().get("dummy_url", data=request_data) + request.user = user + return views.inline_discussion(request, str(self.course.id), commentable_id) + + def test_group_info_in_ajax_response(self): + response = self.call_view( + self.cohorted_commentable_id, self.student, self.student_cohort.id + ) + self._assert_json_response_contains_group_info( + response, lambda d: d["discussion_data"][0] + ) + + +class InlineDiscussionContextTestCase( + ForumsEnableMixin, ModuleStoreTestCase, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + + def setUp(self): + super().setUp() + self.course = CourseFactory.create() + CourseEnrollmentFactory(user=self.user, course_id=self.course.id) + self.discussion_topic_id = "dummy_topic" + self.team = CourseTeamFactory( + name="A team", + course_id=self.course.id, + topic_id="topic_id", + discussion_topic_id=self.discussion_topic_id, + ) + + self.team.add_user(self.user) + self.user_not_in_team = UserFactory.create() + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def test_context_can_be_standalone(self): + self._configure_mock_responses( + course=self.course, + text="dummy text", + commentable_id=self.discussion_topic_id, + ) + + request = RequestFactory().get("dummy_url") + request.user = self.user + + response = views.inline_discussion( + request, + str(self.course.id), + self.discussion_topic_id, + ) + + json_response = json.loads(response.content.decode("utf-8")) + assert ( + json_response["discussion_data"][0]["context"] == ThreadContext.STANDALONE + ) + + def test_private_team_discussion(self): + # First set the team discussion to be private + CourseEnrollmentFactory(user=self.user_not_in_team, course_id=self.course.id) + request = RequestFactory().get("dummy_url") + request.user = self.user_not_in_team + + self._configure_mock_responses( + course=self.course, + text="dummy text", + commentable_id=self.discussion_topic_id, + ) + + with patch( + "lms.djangoapps.teams.api.is_team_discussion_private", autospec=True + ) as mocked: + mocked.return_value = True + response = views.inline_discussion( + request, + str(self.course.id), + self.discussion_topic_id, + ) + assert response.status_code == 403 + assert response.content.decode("utf-8") == views.TEAM_PERMISSION_MESSAGE + + +class UserProfileDiscussionGroupIdTestCase( + CohortedTestCase, CohortedTopicGroupIdTestMixinV2, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + function_name = "get_user_active_threads" + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def call_view_for_profiled_user( + self, 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". + """ + kwargs = {} + if group_id: + kwargs["group_id"] = group_id + self._configure_mock_responses(self.course, "dummy content", **kwargs) + + request_data = {} + if pass_group_id: + request_data["group_id"] = group_id + headers = {} + if is_ajax: + headers["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest" + + self.client.login( + username=requesting_user.username, password=self.TEST_PASSWORD + ) + return self.client.get( + reverse("user_profile", args=[str(self.course.id), profiled_user.id]), + data=request_data, + **headers, + ) + + def call_view( + self, _commentable_id, user, group_id, pass_group_id=True, is_ajax=False + ): # pylint: disable=arguments-differ + return self.call_view_for_profiled_user( + user, user, group_id, pass_group_id=pass_group_id, is_ajax=is_ajax + ) + + def test_group_info_in_html_response(self): + response = self.call_view( + "cohorted_topic", self.student, self.student_cohort.id, is_ajax=False + ) + self._assert_html_response_contains_group_info(response) + + def test_group_info_in_ajax_response(self): + response = self.call_view( + "cohorted_topic", self.student, self.student_cohort.id, is_ajax=True + ) + self._assert_json_response_contains_group_info( + response, lambda d: d["discussion_data"][0] + ) + + def _test_group_id_passed_to_user_profile( + self, + 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. + """ + + def get_params_from_user_info_call(for_specific_course): + """ + Returns the request parameters for the user info call with either course_id specified or not, + depending on value of 'for_specific_course'. + """ + # There will be 3 calls from user_profile. One has the cs_endpoint "active_threads", and it is already + # tested. The other 2 calls are for user info; one of those calls is for general information about the user, + # and it does not specify a course_id. The other call does specify a course_id, and if the caller did not + # have discussion moderator privileges, it should also contain a group_id. + user_func_calls = self.get_mock_func_calls("get_user") + for r_call in user_func_calls: + has_course_id = "course_id" in r_call[1] + if (for_specific_course and has_course_id) or ( + not for_specific_course and not has_course_id + ): + return r_call[1] + pytest.fail( + f"Did not find appropriate user_profile call for 'for_specific_course'={for_specific_course}" + ) + + self.call_view_for_profiled_user( + requesting_user, + profiled_user, + group_id, + pass_group_id=pass_group_id, + is_ajax=False, + ) + # Should never have a group_id if course_id was not included in the request. + params_without_course_id = get_params_from_user_info_call(False) + assert "group_ids" not in params_without_course_id + + params_with_course_id = get_params_from_user_info_call(True) + if expect_group_id_in_request: + assert "group_ids" in params_with_course_id + assert [group_id] == params_with_course_id["group_ids"] + else: + assert "group_ids" not in params_with_course_id + + def test_group_id_passed_to_user_profile_student(self): + """ + 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. + """ + + def verify_group_id_always_present(profiled_user, pass_group_id): + """ + Helper method to verify that group_id is always present for student in course + (non-privileged user). + """ + self._test_group_id_passed_to_user_profile( + 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). + # The profile returned on behalf of the student is for the profiled_user. + verify_group_id_always_present(profiled_user=self.student, pass_group_id=True) + verify_group_id_always_present(profiled_user=self.student, pass_group_id=False) + 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): + """ + 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. + """ + + def verify_group_id_present( + profiled_user, pass_group_id, requested_cohort=self.moderator_cohort + ): + """ + Helper method to verify that group_id is present. + """ + self._test_group_id_passed_to_user_profile( + 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 + ): + """ + Helper method to verify that group_id is not present. + """ + self._test_group_id_passed_to_user_profile( + False, self.moderator, profiled_user, requested_cohort.id, pass_group_id + ) + + # In all these test cases, the requesting_user is the moderator (privileged user). + + # If the group_id is explicitly passed, it will be present in the request. + verify_group_id_present(profiled_user=self.student, pass_group_id=True) + verify_group_id_present(profiled_user=self.moderator, pass_group_id=True) + verify_group_id_present( + profiled_user=self.student, + pass_group_id=True, + requested_cohort=self.student_cohort, + ) + + # If the group_id is not explicitly passed, it will not be present because the requesting_user + # has discussion moderator privileges. + verify_group_id_not_present(profiled_user=self.student, pass_group_id=False) + verify_group_id_not_present(profiled_user=self.moderator, pass_group_id=False) + + +@ddt.ddt +class ForumDiscussionXSSTestCase( + ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + super().setUp() + + username = "foo" + password = "bar" + + self.course = CourseFactory.create() + self.student = UserFactory.create(username=username, password=password) + CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) + assert self.client.login(username=username, password=password) + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + @ddt.data( + '">', + "", + "", + ) + @patch("common.djangoapps.student.models.user.cc.User.from_django_user") + def test_forum_discussion_xss_prevent(self, malicious_code, mock_user): + """ + Test that XSS attack is prevented + """ + self.set_mock_return_value("get_user", {}) + self.set_mock_return_value("get_user_threads", {}) + self.set_mock_return_value("get_user_active_threads", {}) + mock_user.return_value.to_dict.return_value = {} + reverse_url = "{}{}".format( + reverse("forum_form_discussion", kwargs={"course_id": str(self.course.id)}), + "/forum_form_discussion", + ) + # Test that malicious code does not appear in html + url = "{}?{}={}".format(reverse_url, "sort_key", malicious_code) + resp = self.client.get(url) + self.assertNotContains(resp, malicious_code) + + @ddt.data( + '">', + "", + "", + ) + @patch("common.djangoapps.student.models.user.cc.User.from_django_user") + @patch("common.djangoapps.student.models.user.cc.User.active_threads") + def test_forum_user_profile_xss_prevent( + self, malicious_code, mock_threads, mock_from_django_user + ): + """ + Test that XSS attack is prevented + """ + mock_threads.return_value = [], 1, 1 + mock_from_django_user.return_value.to_dict.return_value = { + "upvoted_ids": [], + "downvoted_ids": [], + "subscribed_thread_ids": [], + } + self._configure_mock_responses(course=self.course, text="dummy") + + url = reverse( + "user_profile", + kwargs={"course_id": str(self.course.id), "user_id": str(self.student.id)}, + ) + # Test that malicious code does not appear in html + url_string = "{}?{}={}".format(url, "page", malicious_code) + resp = self.client.get(url_string) + self.assertNotContains(resp, malicious_code) + + +class InlineDiscussionTestCase( + ForumsEnableMixin, ModuleStoreTestCase, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + + def setUp(self): + super().setUp() + + self.course = CourseFactory.create( + org="TestX", + number="101", + display_name="Test Course", + teams_configuration=TeamsConfig( + { + "topics": [ + { + "id": "topic_id", + "name": "A topic", + "description": "A topic", + } + ] + } + ), + ) + self.student = UserFactory.create() + CourseEnrollmentFactory(user=self.student, course_id=self.course.id) + self.discussion1 = BlockFactory.create( + parent_location=self.course.location, + category="discussion", + discussion_id="discussion1", + display_name="Discussion1", + discussion_category="Chapter", + discussion_target="Discussion1", + ) + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def send_request(self, params=None): + """ + Creates and returns a request with params set, and configures + mock_request to return appropriate values. + """ + request = RequestFactory().get("dummy_url", params if params else {}) + request.user = self.student + self._configure_mock_responses( + course=self.course, + text="dummy content", + commentable_id=self.discussion1.discussion_id, + ) + return views.inline_discussion( + request, str(self.course.id), self.discussion1.discussion_id + ) + + def test_context(self): + team = CourseTeamFactory( + name="Team Name", + topic_id="topic_id", + course_id=self.course.id, + discussion_topic_id=self.discussion1.discussion_id, + ) + + team.add_user(self.student) + + self.send_request() + last_call = self.get_mock_func_calls("get_user_threads")[-1][1] + assert last_call["context"] == ThreadContext.STANDALONE + + +class ForumDiscussionSearchUnicodeTestCase( + ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + + @classmethod + def setUpClass(cls): # pylint: disable=super-method-not-called + super().setUpClassAndForumMock() + with super().setUpClassAndTestData(): + cls.course = CourseFactory.create() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + cls.student = UserFactory.create() + CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) + + def _test_unicode_data( + self, text + ): # lint-amnesty, pylint: disable=missing-function-docstring + self._configure_mock_responses(course=self.course, text=text) + data = { + "ajax": 1, + "text": text, + } + request = RequestFactory().get("dummy_url", data) + request.user = self.student + # so (request.headers.get('x-requested-with') == 'XMLHttpRequest') == True + request.META["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest" + + response = views.forum_form_discussion(request, str(self.course.id)) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data["discussion_data"][0]["title"] == text + assert response_data["discussion_data"][0]["body"] == text + + +class InlineDiscussionUnicodeTestCase( + ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + + @classmethod + def setUpClass(cls): # pylint: disable=super-method-not-called + super().setUpClassAndForumMock() + + with super().setUpClassAndTestData(): + cls.course = CourseFactory.create() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + cls.student = UserFactory.create() + CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) + + def _test_unicode_data( + self, text + ): # lint-amnesty, pylint: disable=missing-function-docstring + self._configure_mock_responses(course=self.course, text=text) + request = RequestFactory().get("dummy_url") + request.user = self.student + + response = views.inline_discussion( + request, str(self.course.id), self.course.discussion_topics["General"]["id"] + ) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data["discussion_data"][0]["title"] == text + assert response_data["discussion_data"][0]["body"] == text + + +class ForumFormDiscussionGroupIdTestCase( + CohortedTestCase, CohortedTopicGroupIdTestMixinV2, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + function_name = "get_user_threads" + + def call_view( + self, commentable_id, user, group_id, pass_group_id=True, is_ajax=False + ): # pylint: disable=arguments-differ + kwargs = {} + if group_id: + kwargs["group_id"] = group_id + self._configure_mock_responses(self.course, "dummy content", **kwargs) + + request_data = {} + if pass_group_id: + request_data["group_id"] = group_id + headers = {} + if is_ajax: + headers["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest" + + self.client.login(username=user.username, password=self.TEST_PASSWORD) + return self.client.get( + reverse("forum_form_discussion", args=[str(self.course.id)]), + data=request_data, + **headers, + ) + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def test_group_info_in_html_response(self): + response = self.call_view( + "cohorted_topic", self.student, self.student_cohort.id + ) + self._assert_html_response_contains_group_info(response) + + def test_group_info_in_ajax_response(self): + response = self.call_view( + "cohorted_topic", self.student, self.student_cohort.id, is_ajax=True + ) + self._assert_json_response_contains_group_info( + response, lambda d: d["discussion_data"][0] + ) + + +class UserProfileTestCase( + ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + + TEST_THREAD_TEXT = "userprofile-test-text" + TEST_THREAD_ID = "userprofile-test-thread-id" + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + super().setUp() + + self.course = CourseFactory.create() + self.student = UserFactory.create() + self.profiled_user = UserFactory.create() + CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) + CourseEnrollmentFactory.create( + user=self.profiled_user, course_id=self.course.id + ) + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def get_response( + self, params, **headers + ): # lint-amnesty, pylint: disable=missing-function-docstring + self._configure_mock_responses( + course=self.course, + text=self.TEST_THREAD_TEXT, + thread_id=self.TEST_THREAD_ID, + ) + self.client.login(username=self.student.username, password=self.TEST_PASSWORD) + + response = self.client.get( + reverse( + "user_profile", + kwargs={ + "course_id": str(self.course.id), + "user_id": self.profiled_user.id, + }, + ), + data=params, + **headers, + ) + params = { + "course_id": str(self.course.id), + "page": params.get("page", 1), + "per_page": views.THREADS_PER_PAGE, + } + self.check_mock_called_with("get_user_active_threads", -1, **params) + return response + + def check_html( + self, **params + ): # lint-amnesty, pylint: disable=missing-function-docstring + response = self.get_response(params) + assert response.status_code == 200 + assert response["Content-Type"] == "text/html; charset=utf-8" + html = response.content.decode("utf-8") + self.assertRegex(html, r'data-page="1"') + self.assertRegex(html, r'data-num-pages="1"') + self.assertRegex( + html, r'1 discussion started' + ) + self.assertRegex(html, r'2 comments') + self.assertRegex(html, f"'id': '{self.TEST_THREAD_ID}'") + self.assertRegex(html, f"'title': '{self.TEST_THREAD_TEXT}'") + self.assertRegex(html, f"'body': '{self.TEST_THREAD_TEXT}'") + self.assertRegex(html, f"'username': '{self.student.username}'") + + def check_ajax( + self, **params + ): # lint-amnesty, pylint: disable=missing-function-docstring + response = self.get_response(params, HTTP_X_REQUESTED_WITH="XMLHttpRequest") + assert response.status_code == 200 + assert response["Content-Type"] == "application/json; charset=utf-8" + response_data = json.loads(response.content.decode("utf-8")) + assert sorted(response_data.keys()) == [ + "annotated_content_info", + "discussion_data", + "num_pages", + "page", + ] + assert len(response_data["discussion_data"]) == 1 + assert response_data["page"] == 1 + assert response_data["num_pages"] == 1 + assert response_data["discussion_data"][0]["id"] == self.TEST_THREAD_ID + assert response_data["discussion_data"][0]["title"] == self.TEST_THREAD_TEXT + assert response_data["discussion_data"][0]["body"] == self.TEST_THREAD_TEXT + + def test_html(self): + self.check_html() + + def test_ajax(self): + self.check_ajax() + + def test_404_non_enrolled_user(self): + """ + Test that when student try to visit un-enrolled students' discussion profile, + the system raises Http404. + """ + unenrolled_user = UserFactory.create() + request = RequestFactory().get("dummy_url") + request.user = self.student + with pytest.raises(Http404): + views.user_profile(request, str(self.course.id), unenrolled_user.id) + + def test_404_profiled_user(self): + request = RequestFactory().get("dummy_url") + request.user = self.student + with pytest.raises(Http404): + views.user_profile(request, str(self.course.id), -999) + + def test_404_course(self): + request = RequestFactory().get("dummy_url") + request.user = self.student + with pytest.raises(Http404): + views.user_profile(request, "non/existent/course", self.profiled_user.id) + + def test_post(self): + self._configure_mock_responses( + course=self.course, + text=self.TEST_THREAD_TEXT, + thread_id=self.TEST_THREAD_ID, + ) + request = RequestFactory().post("dummy_url") + request.user = self.student + response = views.user_profile( + request, str(self.course.id), self.profiled_user.id + ) + assert response.status_code == 405 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 49aa8f9bc1..de994bb13a 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py @@ -56,42 +56,28 @@ class Thread(models.Model): utils.strip_blank(utils.strip_none(query_params)) ) - if query_params.get('text'): - url = cls.url(action='search') - else: - url = cls.url(action='get_all', params=utils.extract(params, 'commentable_id')) - if params.get('commentable_id'): - del params['commentable_id'] + # Convert user_id and author_id to strings if present + for field in ['user_id', 'author_id']: + if value := params.get(field): + params[field] = str(value) - 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 user_id := params.get('user_id'): - params['user_id'] = str(user_id) - response = forum_api.get_user_threads(**params) + # Handle commentable_ids/commentable_id conversion + if commentable_ids := params.get('commentable_ids'): + params['commentable_ids'] = commentable_ids.split(',') + elif commentable_id := params.get('commentable_id'): + params['commentable_ids'] = [commentable_id] + params.pop('commentable_id', None) + + params = utils.clean_forum_params(params) + if query_params.get('text'): # Handle group_ids/group_id conversion + if group_ids := params.get('group_ids'): + params['group_ids'] = [int(group_id) for group_id in group_ids.split(',')] + elif group_id := params.get('group_id'): + params['group_ids'] = [int(group_id)] + params.pop('group_id', None) + response = forum_api.search_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 - ) + response = forum_api.get_user_threads(**params) if query_params.get('text'): search_query = query_params['text'] @@ -124,7 +110,6 @@ class Thread(models.Model): total_results=total_results ) ) - return utils.CommentClientPaginatedResult( collection=response.get('collection', []), page=response.get('page', 1), 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 731825aa71..ee9591e51d 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/user.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/user.py @@ -181,7 +181,7 @@ class User(models.Model): user_id = params.pop("user_id", None) if "text" in params: params.pop("text") - response = forum_api.get_user_subscriptions(user_id, str(course_key), params) + response = forum_api.get_user_subscriptions(user_id, str(course_key), utils.clean_forum_params(params)) else: response = utils.perform_request( 'get', @@ -218,21 +218,17 @@ class User(models.Model): if is_forum_v2_enabled(course_key): group_ids = [retrieve_params['group_id']] if 'group_id' in retrieve_params else [] is_complete = retrieve_params['complete'] + params = utils.clean_forum_params({ + "user_id": self.attributes["id"], + "group_ids": group_ids, + "course_id": course_id, + "complete": is_complete + }) try: - response = forum_api.get_user( - self.attributes["id"], - group_ids=group_ids, - course_id=course_id, - complete=is_complete - ) + response = forum_api.get_user(**params) except ForumV2RequestError as e: self.save({"course_id": course_id}) - response = forum_api.get_user( - self.attributes["id"], - group_ids=group_ids, - course_id=course_id, - complete=is_complete - ) + response = forum_api.get_user(**params) else: try: response = utils.perform_request( 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 e77f39e627..26625ed3a7 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/utils.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/utils.py @@ -103,6 +103,23 @@ def perform_request(method, url, data_or_params=None, raw=False, return data +def clean_forum_params(params): + """Convert string booleans to actual booleans and remove None values and empty lists from forum parameters.""" + result = {} + for k, v in params.items(): + if v is not None and v != []: + if isinstance(v, str): + if v.lower() == 'true': + result[k] = True + elif v.lower() == 'false': + result[k] = False + else: + result[k] = v + else: + result[k] = v + return result + + class CommentClientError(Exception): pass