diff --git a/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py b/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py new file mode 100644 index 000000000000..e3d05ac8deac --- /dev/null +++ b/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py @@ -0,0 +1,859 @@ +import pytest +# pylint: skip-file +"""Tests for django comment client views.""" + + +import json +import logging +from contextlib import contextmanager +from unittest import mock +from unittest.mock import ANY, Mock, patch + +import ddt +from django.contrib.auth.models import User +from django.core.management import call_command +from django.test.client import RequestFactory +from django.urls import reverse +from eventtracking.processors.exceptions import EventEmissionExit +from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.locator import CourseLocator +from openedx_events.learning.signals import FORUM_THREAD_CREATED, FORUM_THREAD_RESPONSE_CREATED, FORUM_RESPONSE_COMMENT_CREATED + +from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.course_modes.tests.factories import CourseModeFactory +from common.djangoapps.student.roles import CourseStaffRole, UserBasedRole +from common.djangoapps.student.tests.factories import CourseAccessRoleFactory, CourseEnrollmentFactory, UserFactory +from common.djangoapps.track.middleware import TrackMiddleware +from common.djangoapps.track.views import segmentio +from common.djangoapps.track.views.tests.base import SEGMENTIO_TEST_USER_ID, SegmentIOTrackingTestCaseBase +from common.djangoapps.util.testing import UrlResetMixin +from common.test.utils import MockSignalHandlerMixin, disable_signal +from lms.djangoapps.discussion.django_comment_client.base import views +from lms.djangoapps.discussion.django_comment_client.tests.group_id_v2 import ( + CohortedTopicGroupIdTestMixin, + GroupIdAssertionMixin, + NonCohortedTopicGroupIdTestMixin +) +from lms.djangoapps.discussion.django_comment_client.tests.unicode import UnicodeTestMixin +from lms.djangoapps.discussion.django_comment_client.tests.utils import CohortedTestCase, ForumsEnableMixin +from lms.djangoapps.teams.tests.factories import CourseTeamFactory, CourseTeamMembershipFactory +from openedx.core.djangoapps.course_groups.cohorts import set_course_cohorted +from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory +from openedx.core.djangoapps.django_comment_common.comment_client import Thread +from openedx.core.djangoapps.django_comment_common.models import ( + FORUM_ROLE_STUDENT, + CourseDiscussionSettings, + Role, + assign_role +) +from openedx.core.djangoapps.django_comment_common.utils import ( + ThreadContext, + seed_permissions_roles, +) +from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES +from openedx.core.lib.teams_config import TeamsConfig +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import ( + TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase, SharedModuleStoreTestCase, +) +from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory, check_mongo_calls + +from .event_transformers import ForumThreadViewedEventTransformer + +log = logging.getLogger(__name__) + +QUERY_COUNT_TABLE_IGNORELIST = WAFFLE_TABLES + +CS_PREFIX = "http://localhost:4567/api/v1" + +# pylint: disable=missing-docstring + + +@patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=True) +@patch('openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.create_thread', autospec=True) +class CreateThreadGroupIdTestCase( + CohortedTestCase, + CohortedTopicGroupIdTestMixin, + NonCohortedTopicGroupIdTestMixin +): + cs_endpoint = "/threads" + + def call_view(self, mock_create_thread, mock_is_forum_v2_enabled, commentable_id, user, group_id, pass_group_id=True): + mock_create_thread.return_value = {} + request_data = {"body": "body", "title": "title", "thread_type": "discussion"} + if pass_group_id: + request_data["group_id"] = group_id + request = RequestFactory().post("dummy_url", request_data) + request.user = user + request.view_name = "create_thread" + + return views.create_thread( + request, + course_id=str(self.course.id), + commentable_id=commentable_id + ) + + def test_group_info_in_response(self, mock_is_forum_v2_enabled, mock_request): + response = self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "cohorted_topic", + self.student, + '' + ) + self._assert_json_response_contains_group_info(response) + +@patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=True) +@disable_signal(views, 'thread_edited') +@disable_signal(views, 'thread_voted') +@disable_signal(views, 'thread_deleted') +class ThreadActionGroupIdTestCase( + CohortedTestCase, + GroupIdAssertionMixin +): + + def _get_mocked_instance_from_view_name(self, view_name): + """ + Get the relavent Mock function based on the view_name + """ + mocks = { + "create_thread": self.mock_create_thread, + "get_thread": self.mock_get_thread, + "update_thread": self.mock_update_thread, + "delete_thread": self.mock_delete_thread, + "vote_for_thread": self.mock_update_thread_votes, + } + return mocks.get(view_name) + + def setUp(self): + super().setUp() + # Mocking create_thread and get_thread methods + self.mock_create_thread = mock.patch('openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.create_thread', autospec=True).start() + self.mock_get_thread = mock.patch('openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_thread', autospec=True).start() + self.mock_update_thread = mock.patch('openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.update_thread', autospec=True).start() + self.mock_delete_thread = mock.patch('openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.delete_thread', autospec=True).start() + self.mock_update_thread_votes = mock.patch('openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.update_thread_votes', autospec=True).start() + self.mock_delete_thread_vote = mock.patch('openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.delete_thread_vote', autospec=True).start() + self.mock_update_thread_flag = mock.patch('openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.update_thread_flag', autospec=True).start() + self.mock_pin_thread = mock.patch('openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.pin_thread', autospec=True).start() + self.mock_unpin_thread = mock.patch('openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.unpin_thread', autospec=True).start() + + + + default_response = { + "user_id": str(self.student.id), + "group_id": self.student_cohort.id, + "closed": False, + "type": "thread", + "commentable_id": "non_team_dummy_id", + "body": "test body", + } + self.mock_create_thread.return_value = default_response + self.mock_get_thread.return_value = default_response + self.mock_update_thread.return_value = default_response + self.mock_delete_thread.return_value = default_response + self.mock_update_thread_votes.return_value = default_response + self.mock_delete_thread_vote.return_value = default_response + self.mock_update_thread_flag.return_value = default_response + self.mock_pin_thread.return_value = default_response + self.mock_unpin_thread.return_value = default_response + + self.get_course_id_by_thread = mock.patch('openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread', autospec=True).start() + self.get_course_id_by_thread.return_value = CourseLocator('dummy', 'test_123', 'test_run') + + self.addCleanup(mock.patch.stopall) # Ensure all mocks are stopped after tests + + + def call_view( + self, + view_name, + mock_is_forum_v2_enabled, + user=None, + post_params=None, + view_args=None + ): + mocked_view = self._get_mocked_instance_from_view_name(view_name) + if mocked_view: + mocked_view.return_value = { + "user_id": str(self.student.id), + "group_id": self.student_cohort.id, + "closed": False, + "type": "thread", + "commentable_id": "non_team_dummy_id", + "body": "test body", + } + request = RequestFactory().post("dummy_url", post_params or {}) + request.user = user or self.student + request.view_name = view_name + + return getattr(views, view_name)( + request, + course_id=str(self.course.id), + thread_id="dummy", + **(view_args or {}) + ) + + def test_update(self, mock_is_forum_v2_enabled): + response = self.call_view( + "update_thread", + mock_is_forum_v2_enabled, + post_params={"body": "body", "title": "title"} + ) + self._assert_json_response_contains_group_info(response) + + def test_delete(self, mock_is_forum_v2_enabled): + response = self.call_view("delete_thread", mock_is_forum_v2_enabled) + self._assert_json_response_contains_group_info(response) + + def test_vote(self, mock_is_forum_v2_enabled): + response = self.call_view( + "vote_for_thread", + mock_is_forum_v2_enabled, + view_args={"value": "up"} + ) + self._assert_json_response_contains_group_info(response) + response = self.call_view("undo_vote_for_thread", mock_is_forum_v2_enabled) + self._assert_json_response_contains_group_info(response) + + def test_flag(self, mock_is_forum_v2_enabled): + with mock.patch('openedx.core.djangoapps.django_comment_common.signals.thread_flagged.send') as signal_mock: + response = self.call_view("flag_abuse_for_thread", mock_is_forum_v2_enabled) + self._assert_json_response_contains_group_info(response) + self.assertEqual(signal_mock.call_count, 1) + response = self.call_view("un_flag_abuse_for_thread", mock_is_forum_v2_enabled) + self._assert_json_response_contains_group_info(response) + + def test_pin(self, mock_is_forum_v2_enabled): + response = self.call_view( + "pin_thread", + mock_is_forum_v2_enabled, + user=self.moderator + ) + self._assert_json_response_contains_group_info(response) + response = self.call_view( + "un_pin_thread", + mock_is_forum_v2_enabled, + user=self.moderator + ) + self._assert_json_response_contains_group_info(response) + + def test_openclose(self, mock_is_forum_v2_enabled): + response = self.call_view( + "openclose_thread", + mock_is_forum_v2_enabled, + user=self.moderator + ) + self._assert_json_response_contains_group_info( + response, + lambda d: d['content'] + ) + +class ViewsTestCaseMixin: + + def set_up_course(self, block_count=0): + """ + Creates a course, optionally with block_count discussion blocks, and + a user with appropriate permissions. + """ + + # create a course + self.course = CourseFactory.create( + org='MITx', course='999', + discussion_topics={"Some Topic": {"id": "some_topic"}}, + display_name='Robot Super Course', + ) + self.course_id = self.course.id + + # add some discussion blocks + for i in range(block_count): + BlockFactory.create( + parent_location=self.course.location, + category='discussion', + discussion_id=f'id_module_{i}', + discussion_category=f'Category {i}', + discussion_target=f'Discussion {i}' + ) + + # seed the forums permissions and roles + call_command('seed_permissions_roles', str(self.course_id)) + + # Patch the comment client user save method so it does not try + # to create a new cc user when creating a django user + with patch('common.djangoapps.student.models.user.cc.User.save'): + uname = 'student' + email = 'student@edx.org' + self.password = 'Password1234' + + # Create the user and make them active so we can log them in. + self.student = UserFactory.create(username=uname, email=email, password=self.password) + self.student.is_active = True + self.student.save() + + # Add a discussion moderator + self.moderator = UserFactory.create(password=self.password) + + # Enroll the student in the course + CourseEnrollmentFactory(user=self.student, + course_id=self.course_id) + + # Enroll the moderator and give them the appropriate roles + CourseEnrollmentFactory(user=self.moderator, course_id=self.course.id) + self.moderator.roles.add(Role.objects.get(name="Moderator", course_id=self.course.id)) + + assert self.client.login(username='student', password=self.password) + + + def _get_mocked_dict(self): + return { + "create_thread": self.mock_create_thread, + "get_thread": self.mock_get_thread, + "update_thread": self.mock_update_thread + } + + def _get_mocked_instance_from_view_name(self, view_name): + """ + Get the relavent Mock function based on the view_name + """ + return self._get_mocked_dict().get(view_name) + + + def _setup_mock_data(self, view_name="get_thread", include_depth=False): + """ + Ensure that mock_request returns the data necessary to make views + function correctly + """ + data = { + "user_id": str(self.student.id), + "closed": False, + "commentable_id": "non_team_dummy_id", + "thread_id": "dummy", + "thread_type": "discussion" + } + if include_depth: + data["depth"] = 0 + self._get_mocked_instance_from_view_name(view_name).return_value = data + + def create_thread_helper(self, mock_is_forum_v2_enabled, extra_request_data=None, extra_response_data=None): + """ + Issues a request to create a thread and verifies the result. + """ + self.mock_create_thread.return_value = { + "thread_type": "discussion", + "title": "Hello", + "body": "this is a post", + "course_id": "MITx/999/Robot_Super_Course", + "anonymous": False, + "anonymous_to_peers": False, + "commentable_id": "i4x-MITx-999-course-Robot_Super_Course", + "created_at": "2013-05-10T18:53:43Z", + "updated_at": "2013-05-10T18:53:43Z", + "at_position_list": [], + "closed": False, + "id": "518d4237b023791dca00000d", + "user_id": "1", + "username": "robot", + "votes": { + "count": 0, + "up_count": 0, + "down_count": 0, + "point": 0 + }, + "abuse_flaggers": [], + "type": "thread", + "group_id": None, + "pinned": False, + "endorsed": False, + "unread_comments_count": 0, + "read": False, + "comments_count": 0, + } + thread = { + "thread_type": "discussion", + "body": ["this is a post"], + "anonymous_to_peers": ["false"], + "auto_subscribe": ["false"], + "anonymous": ["false"], + "title": ["Hello"], + } + if extra_request_data: + thread.update(extra_request_data) + url = reverse('create_thread', kwargs={'commentable_id': 'i4x-MITx-999-course-Robot_Super_Course', + 'course_id': str(self.course_id)}) + response = self.client.post(url, data=thread) + assert self.mock_create_thread.called + expected_data = { + 'thread_type': 'discussion', + 'body': 'this is a post', + 'context': ThreadContext.COURSE, + 'anonymous_to_peers': False, + 'user_id': '1', + 'title': 'Hello', + 'commentable_id': 'i4x-MITx-999-course-Robot_Super_Course', + 'anonymous': False, + 'course_id': str(self.course_id), + } + if extra_response_data: + expected_data.update(extra_response_data) + + self.mock_create_thread.assert_called_with(**expected_data) + assert response.status_code == 200 + + + def update_thread_helper(self, mock_is_forum_v2_enabled): + """ + Issues a request to update a thread and verifies the result. + """ + self._setup_mock_data("get_thread") + self._setup_mock_data("update_thread") + # Mock out saving in order to test that content is correctly + # updated. Otherwise, the call to thread.save() receives the + # same mocked request data that the original call to retrieve + # the thread did, overwriting any changes. + with patch.object(Thread, 'save'): + response = self.client.post( + reverse("update_thread", kwargs={ + "thread_id": "dummy", + "course_id": str(self.course_id) + }), + data={"body": "foo", "title": "foo", "commentable_id": "some_topic"} + ) + assert response.status_code == 200 + data = json.loads(response.content.decode('utf-8')) + assert data['body'] == 'foo' + assert data['title'] == 'foo' + assert data['commentable_id'] == 'some_topic' + + +@ddt.ddt +@patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', return_value=True) +@disable_signal(views, 'thread_created') +@disable_signal(views, 'thread_edited') +class ViewsQueryCountTestCase( + ForumsEnableMixin, + UrlResetMixin, + ModuleStoreTestCase, + ViewsTestCaseMixin +): + + CREATE_USER = False + ENABLED_CACHES = ['default', 'mongo_metadata_inheritance', 'loc_cache'] + ENABLED_SIGNALS = ['course_published'] + + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + super().setUp() + self.mock_create_thread = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.create_thread', autospec=True + ).start() + self.mock_update_thread = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.update_thread', autospec=True + ).start() + self.mock_get_thread = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_thread', autospec=True + ).start() + + self.get_course_id_by_thread = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread', autospec=True + ).start() + self.get_course_id_by_thread.return_value = CourseLocator('MITx', '999', 'Robot_Super_Course') + + self.addCleanup(mock.patch.stopall) + + def count_queries(func): # pylint: disable=no-self-argument + """ + Decorates test methods to count mongo and SQL calls for a + particular modulestore. + """ + + def inner(self, default_store, block_count, mongo_calls, sql_queries, *args, **kwargs): + with modulestore().default_store(default_store): + self.set_up_course(block_count=block_count) + self.clear_caches() + with self.assertNumQueries(sql_queries, table_ignorelist=QUERY_COUNT_TABLE_IGNORELIST): + with check_mongo_calls(mongo_calls): + func(self, *args, **kwargs) + return inner + + @ddt.data( + (ModuleStoreEnum.Type.split, 3, 8, 41), + ) + @ddt.unpack + @count_queries + def test_create_thread(self, mock_is_forum_v2_enabled): + self.create_thread_helper(mock_is_forum_v2_enabled) + + @ddt.data( + (ModuleStoreEnum.Type.split, 3, 6, 40), + ) + @ddt.unpack + @count_queries + def test_update_thread(self, mock_is_forum_v2_enabled): + self.update_thread_helper(mock_is_forum_v2_enabled) + + +@ddt.ddt +@disable_signal(views, 'comment_flagged') +@disable_signal(views, 'thread_flagged') +@patch('lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled', autospec=True) +class ViewsTestCase( + ForumsEnableMixin, + UrlResetMixin, + SharedModuleStoreTestCase, + ViewsTestCaseMixin, + MockSignalHandlerMixin +): + + def _get_mocked_dict(self): + mocked_dict = super()._get_mocked_dict() + mocked_dict['create_comment'] = self.mock_create_parent_comment + return mocked_dict + + @classmethod + def setUpClass(cls): + # pylint: disable=super-method-not-called + with super().setUpClassAndTestData(): + cls.course = CourseFactory.create( + org='MITx', course='999', + discussion_topics={"Some Topic": {"id": "some_topic"}}, + display_name='Robot Super Course', + ) + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + cls.course_id = cls.course.id + + # seed the forums permissions and roles + call_command('seed_permissions_roles', str(cls.course_id)) + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + # Patching the ENABLE_DISCUSSION_SERVICE value affects the contents of urls.py, + # so we need to call super.setUp() which reloads urls.py (because + # of the UrlResetMixin) + super().setUp() + + # Patch the comment client user save method so it does not try + # to create a new cc user when creating a django user + with patch('common.djangoapps.student.models.user.cc.User.save'): + uname = 'student' + email = 'student@edx.org' + self.password = 'Password1234' + + # Create the user and make them active so we can log them in. + self.student = UserFactory.create(username=uname, email=email, password=self.password) + self.student.is_active = True + self.student.save() + + # Add a discussion moderator + self.moderator = UserFactory.create(password=self.password) + + # Enroll the student in the course + CourseEnrollmentFactory(user=self.student, + course_id=self.course_id) + + # Enroll the moderator and give them the appropriate roles + CourseEnrollmentFactory(user=self.moderator, course_id=self.course.id) + self.moderator.roles.add(Role.objects.get(name="Moderator", course_id=self.course.id)) + + assert self.client.login(username='student', password=self.password) + + + self.mock_create_thread = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.create_thread', autospec=True + ).start() + self.mock_update_thread = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.update_thread', autospec=True + ).start() + self.mock_get_thread = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_thread', autospec=True + ).start() + self.mock_create_subscription = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.create_subscription', autospec=True + ).start() + self.mock_delete_subscription = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.delete_subscription', autospec=True + ).start() + self.mock_delete_thread = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.delete_thread', autospec=True + ).start() + self.mock_delete_comment = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.delete_comment', autospec=True + ).start() + self.mock_get_parent_comment = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_parent_comment', autospec=True + ).start() + self.mock_create_parent_comment = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.create_parent_comment', autospec=True + ).start() + self.mock_update_comment = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.update_comment', autospec=True + ).start() + + default_response = { + "user_id": str(self.student.id), + "closed": False, + "type": "thread", + "commentable_id": "non_team_dummy_id", + "body": "test body", + } + self.mock_create_thread.return_value = default_response + self.mock_get_thread.return_value = default_response + self.mock_update_thread.return_value = default_response + self.mock_delete_thread.return_value = default_response + self.mock_delete_subscription.return_value = default_response + self.mock_get_parent_comment.return_value = default_response + + self.get_course_id_by_thread = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread', autospec=True + ).start() + self.get_course_id_by_thread.return_value = CourseLocator('MITx', '999', 'Robot_Super_Course') + + self.get_course_id_by_comment = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_comment', autospec=True + ).start() + self.get_course_id_by_comment.return_value = CourseLocator('MITx', '999', 'Robot_Super_Course') + # forum_api.create_subscription + + self.addCleanup(mock.patch.stopall) + + + @contextmanager + def assert_discussion_signals(self, signal, user=None): + if user is None: + user = self.student + with self.assert_signal_sent(views, signal, sender=None, user=user, exclude_args=('post',)): + yield + + def test_create_thread(self, mock_is_forum_v2_enabled,): + with self.assert_discussion_signals('thread_created'): + self.create_thread_helper(mock_is_forum_v2_enabled) + + def test_create_thread_standalone(self, mock_is_forum_v2_enabled): + team = CourseTeamFactory.create( + name="A Team", + course_id=self.course_id, + topic_id='topic_id', + discussion_topic_id="i4x-MITx-999-course-Robot_Super_Course" + ) + + # Add the student to the team so they can post to the commentable. + team.add_user(self.student) + + # create_thread_helper verifies that extra data are passed through to the comments service + self.create_thread_helper(mock_is_forum_v2_enabled, extra_response_data={'context': ThreadContext.STANDALONE}) + + + @ddt.data( + ('follow_thread', 'thread_followed'), + ('unfollow_thread', 'thread_unfollowed'), + ) + @ddt.unpack + def test_follow_unfollow_thread_signals(self, view_name, signal, mock_is_forum_v2_enabled): + self.create_thread_helper(mock_is_forum_v2_enabled) + with self.assert_discussion_signals(signal): + response = self.client.post( + reverse( + view_name, + kwargs={"course_id": str(self.course_id), "thread_id": 'i4x-MITx-999-course-Robot_Super_Course'} + ), + data = {} + ) + assert response.status_code == 200 + + def test_delete_thread(self, mock_is_forum_v2_enabled): + self.mock_delete_thread.return_value = { + "user_id": str(self.student.id), + "closed": False, + "body": "test body", + } + test_thread_id = "test_thread_id" + request = RequestFactory().post("dummy_url", {"id": test_thread_id}) + request.user = self.student + request.view_name = "delete_thread" + with self.assert_discussion_signals('thread_deleted'): + response = views.delete_thread( + request, + course_id=str(self.course.id), + thread_id=test_thread_id + ) + assert response.status_code == 200 + assert self.mock_delete_thread.called + + + def test_delete_comment(self, mock_is_forum_v2_enabled): + self.mock_delete_comment.return_value = { + "user_id": str(self.student.id), + "closed": False, + "body": "test body", + } + test_comment_id = "test_comment_id" + request = RequestFactory().post("dummy_url", {"id": test_comment_id}) + request.user = self.student + request.view_name = "delete_comment" + with self.assert_discussion_signals('comment_deleted'): + response = views.delete_comment( + request, + course_id=str(self.course.id), + comment_id=test_comment_id + ) + assert response.status_code == 200 + assert self.mock_delete_comment.called + + def _test_request_error(self, view_name, view_kwargs, data): + """ + Submit a request against the given view with the given data and ensure + that the result is a 400 error and that no data was posted using + mock_request + """ + mocked_view = self._get_mocked_instance_from_view_name(view_name) + if mocked_view: + mocked_view.return_value = {} + + response = self.client.post(reverse(view_name, kwargs=view_kwargs), data=data) + assert response.status_code == 400 + + def test_create_thread_no_title(self, mock_is_forum_v2_enabled): + self._test_request_error( + "create_thread", + {"commentable_id": "dummy", "course_id": str(self.course_id)}, + {"body": "foo"}, + ) + + + def test_create_thread_empty_title(self, mock_is_forum_v2_enabled): + self._test_request_error( + "create_thread", + {"commentable_id": "dummy", "course_id": str(self.course_id)}, + {"body": "foo", "title": " "}, + ) + + def test_create_thread_no_body(self, mock_is_forum_v2_enabled): + self._test_request_error( + "create_thread", + {"commentable_id": "dummy", "course_id": str(self.course_id)}, + {"title": "foo"}, + ) + + def test_create_thread_empty_body(self, mock_is_forum_v2_enabled): + self._test_request_error( + "create_thread", + {"commentable_id": "dummy", "course_id": str(self.course_id)}, + {"body": " ", "title": "foo"} + ) + + def test_update_thread_no_title(self, mock_is_forum_v2_enabled): + self._test_request_error( + "update_thread", + {"thread_id": "dummy", "course_id": str(self.course_id)}, + {"body": "foo"} + ) + + def test_update_thread_empty_title(self, mock_is_forum_v2_enabled): + self._test_request_error( + "update_thread", + {"thread_id": "dummy", "course_id": str(self.course_id)}, + {"body": "foo", "title": " "} + ) + + def test_update_thread_no_body(self, mock_is_forum_v2_enabled): + self._test_request_error( + "update_thread", + {"thread_id": "dummy", "course_id": str(self.course_id)}, + {"title": "foo"} + ) + + def test_update_thread_empty_body(self, mock_is_forum_v2_enabled): + self._test_request_error( + "update_thread", + {"thread_id": "dummy", "course_id": str(self.course_id)}, + {"body": " ", "title": "foo"} + ) + + def test_update_thread_course_topic(self, mock_is_forum_v2_enabled): + with self.assert_discussion_signals('thread_edited'): + self.update_thread_helper(mock_is_forum_v2_enabled) + + @patch( + 'lms.djangoapps.discussion.django_comment_client.utils.get_discussion_categories_ids', + return_value=["test_commentable"], + ) + def test_update_thread_wrong_commentable_id(self, mock_get_discussion_id_map, mock_is_forum_v2_enabled): + self._test_request_error( + "update_thread", + {"thread_id": "dummy", "course_id": str(self.course_id)}, + {"body": "foo", "title": "foo", "commentable_id": "wrong_commentable"}, + ) + + def test_create_comment(self, mock_is_forum_v2_enabled): + self.mock_create_parent_comment = {} + + with self.assert_discussion_signals('comment_created'): + response = self.client.post( + reverse( + "create_comment", + kwargs={"course_id": str(self.course_id), "thread_id": "dummy"} + ), + data={"body": "body"} + ) + assert response.status_code == 200 + + def test_create_comment_no_body(self, mock_is_forum_v2_enabled): + self._test_request_error( + "create_comment", + {"thread_id": "dummy", "course_id": str(self.course_id)}, + {}, + ) + + def test_create_comment_empty_body(self, mock_is_forum_v2_enabled): + self._test_request_error( + "create_comment", + {"thread_id": "dummy", "course_id": str(self.course_id)}, + {"body": " "}, + ) + + def test_create_sub_comment_no_body(self, mock_is_forum_v2_enabled): + self._test_request_error( + "create_sub_comment", + {"comment_id": "dummy", "course_id": str(self.course_id)}, + {}, + ) + + def test_create_sub_comment_empty_body(self, mock_is_forum_v2_enabled): + self._test_request_error( + "create_sub_comment", + {"comment_id": "dummy", "course_id": str(self.course_id)}, + {"body": " "} + ) + + def test_update_comment_no_body(self, mock_is_forum_v2_enabled): + self._test_request_error( + "update_comment", + {"comment_id": "dummy", "course_id": str(self.course_id)}, + {} + ) + + def test_update_comment_empty_body(self, mock_is_forum_v2_enabled): + self._test_request_error( + "update_comment", + {"comment_id": "dummy", "course_id": str(self.course_id)}, + {"body": " "} + ) + + def test_update_comment_basic(self, mock_is_forum_v2_enabled): + self.mock_update_comment.return_value = {} + comment_id = "test_comment_id" + updated_body = "updated body" + with self.assert_discussion_signals('comment_edited'): + response = self.client.post( + reverse( + "update_comment", + kwargs={"course_id": str(self.course_id), "comment_id": comment_id} + ), + data={"body": updated_body} + ) + assert response.status_code == 200 + assert self.mock_update_comment.call_args[1].get('body') == updated_body diff --git a/lms/djangoapps/discussion/django_comment_client/tests/group_id_v2.py b/lms/djangoapps/discussion/django_comment_client/tests/group_id_v2.py new file mode 100644 index 000000000000..874e6592cb03 --- /dev/null +++ b/lms/djangoapps/discussion/django_comment_client/tests/group_id_v2.py @@ -0,0 +1,345 @@ +# pylint: disable=missing-docstring + + +import json +import re + +from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.course_modes.tests.factories import CourseModeFactory +from lms.djangoapps.teams.tests.factories import CourseTeamFactory +from openedx.core.djangoapps.django_comment_common.models import ( + CourseDiscussionSettings, +) + + +from unittest.mock import patch + + +class GroupIdAssertionMixin: + def _assert_forum_api_called_with_group_id(self, mock_function, group_id=None): + assert mock_function.called + assert mock_function.call_args[1].get('group_id') == group_id + + def _assert_forum_api_called_without_group_id(self, mock_function): + assert mock_function.called + assert mock_function.call_args[1].get('group_id') is None + + def _assert_html_response_contains_group_info(self, response): + group_info = {"group_id": None, "group_name": None} + match = re.search(r'"group_id": (\d*),', response.content.decode("utf-8")) + if match and match.group(1) != "": + group_info["group_id"] = int(match.group(1)) + match = re.search(r'"group_name": "(\w*)"', response.content.decode("utf-8")) + if match: + group_info["group_name"] = match.group(1) + self._assert_thread_contains_group_info(group_info) + + def _assert_json_response_contains_group_info(self, response, extract_thread=None): + payload = json.loads(response.content.decode("utf-8")) + thread = extract_thread(payload) if extract_thread else payload + self._assert_thread_contains_group_info(thread) + + def _assert_thread_contains_group_info(self, thread): + assert thread["group_id"] == self.student_cohort.id + assert thread["group_name"] == self.student_cohort.name + + +class CohortedTopicGroupIdTestMixin(GroupIdAssertionMixin): + def call_view( + self, + mock_create_thread, + mock_is_forum_v2_enabled, + commentable_id, + user, + group_id, + pass_group_id=True, + ): + pass + + def test_cohorted_topic_student_without_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "cohorted_topic", + self.student, + "", + pass_group_id=False, + ) + self._assert_forum_api_called_with_group_id( + mock_create_thread, self.student_cohort.id + ) + + def test_cohorted_topic_student_none_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "cohorted_topic", + self.student, + "", + ) + self._assert_forum_api_called_with_group_id( + mock_create_thread, self.student_cohort.id + ) + + def test_cohorted_topic_student_with_own_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "cohorted_topic", + self.student, + self.student_cohort.id, + ) + self._assert_forum_api_called_with_group_id( + mock_create_thread, self.student_cohort.id + ) + + def test_cohorted_topic_student_with_other_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "cohorted_topic", + self.student, + self.moderator_cohort.id, + ) + self._assert_forum_api_called_with_group_id( + mock_create_thread, self.student_cohort.id + ) + + def test_cohorted_topic_moderator_without_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "cohorted_topic", + self.moderator, + "", + pass_group_id=False, + ) + self._assert_forum_api_called_without_group_id(mock_create_thread) + + def test_cohorted_topic_moderator_none_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "cohorted_topic", + self.moderator, + "", + ) + self._assert_forum_api_called_without_group_id(mock_create_thread) + + def test_cohorted_topic_moderator_with_own_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "cohorted_topic", + self.moderator, + self.moderator_cohort.id, + ) + self._assert_forum_api_called_with_group_id( + mock_create_thread, self.moderator_cohort.id + ) + + def test_cohorted_topic_moderator_with_other_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "cohorted_topic", + self.moderator, + self.student_cohort.id, + ) + self._assert_forum_api_called_with_group_id( + mock_create_thread, self.student_cohort.id + ) + + def test_cohorted_topic_moderator_with_invalid_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + invalid_id = self.student_cohort.id + self.moderator_cohort.id + response = self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "cohorted_topic", + self.moderator, + invalid_id, + ) + assert response.status_code == 500 + + def test_cohorted_topic_enrollment_track_invalid_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.AUDIT) + CourseModeFactory.create( + course_id=self.course.id, mode_slug=CourseMode.VERIFIED + ) + discussion_settings = CourseDiscussionSettings.get(self.course.id) + discussion_settings.update( + { + "divided_discussions": ["cohorted_topic"], + "division_scheme": CourseDiscussionSettings.ENROLLMENT_TRACK, + "always_divide_inline_discussions": True, + } + ) + + invalid_id = -1000 + response = self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "cohorted_topic", + self.moderator, + invalid_id, + ) + assert response.status_code == 500 + + +class NonCohortedTopicGroupIdTestMixin(GroupIdAssertionMixin): + def call_view( + self, + mock_create_thread, + mock_is_forum_v2_enabled, + commentable_id, + user, + group_id, + pass_group_id=True, + ): + pass + + def test_non_cohorted_topic_student_without_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "non_cohorted_topic", + self.student, + "", + pass_group_id=False, + ) + self._assert_forum_api_called_with_group_id(mock_create_thread) + + def test_non_cohorted_topic_student_none_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "non_cohorted_topic", + self.student, + "", + ) + self._assert_forum_api_called_with_group_id(mock_create_thread) + + def test_non_cohorted_topic_student_with_own_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "non_cohorted_topic", + self.student, + self.student_cohort.id + ) + self._assert_forum_api_called_without_group_id(mock_create_thread) + + def test_non_cohorted_topic_student_with_other_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "non_cohorted_topic", + self.student, + self.moderator_cohort.id + ) + self._assert_forum_api_called_without_group_id(mock_create_thread) + + def test_non_cohorted_topic_moderator_without_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "non_cohorted_topic", + self.moderator, + "", + pass_group_id=False, + ) + self._assert_forum_api_called_without_group_id(mock_create_thread) + + def test_non_cohorted_topic_moderator_none_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "non_cohorted_topic", + self.moderator, + "" + ) + self._assert_forum_api_called_without_group_id(mock_create_thread) + + def test_non_cohorted_topic_moderator_with_own_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "non_cohorted_topic", + self.moderator, + self.moderator_cohort.id, + ) + self._assert_forum_api_called_without_group_id(mock_create_thread) + + def test_non_cohorted_topic_moderator_with_other_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "non_cohorted_topic", + self.moderator, + self.student_cohort.id, + ) + self._assert_forum_api_called_without_group_id(mock_create_thread) + + def test_non_cohorted_topic_moderator_with_invalid_group_id( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + invalid_id = self.student_cohort.id + self.moderator_cohort.id + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + "non_cohorted_topic", + self.moderator, + invalid_id + ) + self._assert_forum_api_called_without_group_id(mock_create_thread) + + def test_team_discussion_id_not_cohorted( + self, mock_create_thread, mock_is_forum_v2_enabled + ): + team = CourseTeamFactory(course_id=self.course.id, topic_id="topic-id") + + team.add_user(self.student) + self.call_view( + mock_create_thread, + mock_is_forum_v2_enabled, + team.discussion_topic_id, + self.student, + "", + ) + self._assert_forum_api_called_without_group_id(mock_create_thread) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_serializers_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_serializers_v2.py new file mode 100644 index 000000000000..40bd97abc4b4 --- /dev/null +++ b/lms/djangoapps/discussion/rest_api/tests/test_serializers_v2.py @@ -0,0 +1,1509 @@ +""" +Tests for Discussion API Serializers + +This module contains tests for the Discussion API serializers. These tests are +replicated from 'lms/djangoapps/discussion/rest_api/tests/test_serializers.py' +and are adapted to use the forum v2 native APIs instead of the v1 HTTP calls. +""" + +import itertools +from unittest import mock + +import ddt +import httpretty +from django.test.client import RequestFactory +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + +from common.djangoapps.student.tests.factories import UserFactory +from common.djangoapps.util.testing import UrlResetMixin +from lms.djangoapps.discussion.django_comment_client.tests.utils import ( + ForumsEnableMixin, +) +from lms.djangoapps.discussion.rest_api.serializers import ( + CommentSerializer, + ThreadSerializer, + get_context, +) +from lms.djangoapps.discussion.rest_api.tests.utils_v2 import ( + CommentsServiceMockMixin, + make_minimal_cs_comment, + make_minimal_cs_thread, +) +from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory +from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment +from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread +from openedx.core.djangoapps.django_comment_common.models import ( + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_STUDENT, + Role, +) + + +@ddt.ddt +class SerializerTestMixin(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMixin): + """ + Test Mixin for Serializer tests + """ + + @classmethod + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create() + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def setUp(self): + super().setUp() + httpretty.reset() + httpretty.enable() + patcher = mock.patch( + "lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled", + return_value=True, + ) + patcher.start() + self.addCleanup(patcher.stop) + self.addCleanup(httpretty.reset) + self.addCleanup(httpretty.disable) + + # Patch get_user for the entire class + get_user_patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.get_user" + ) + self.mock_get_user = get_user_patcher.start() + self.addCleanup(get_user_patcher.stop) + + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_thread" + ) + self.mock_get_thread = patcher.start() + self.addCleanup(patcher.stop) + + self.maxDiff = None # pylint: disable=invalid-name + self.user = UserFactory.create() + self.register_get_user_response(self.user) + self.request = RequestFactory().get("/dummy") + self.request.user = self.user + self.author = UserFactory.create() + + def create_role(self, role_name, users, course=None): + """Create a Role in self.course with the given name and users""" + course = course or self.course + role = Role.objects.create(name=role_name, course_id=course.id) + role.users.set(users) + + @ddt.data( + (FORUM_ROLE_ADMINISTRATOR, True, False, True), + (FORUM_ROLE_ADMINISTRATOR, False, True, False), + (FORUM_ROLE_MODERATOR, True, False, True), + (FORUM_ROLE_MODERATOR, False, True, False), + (FORUM_ROLE_COMMUNITY_TA, True, False, True), + (FORUM_ROLE_COMMUNITY_TA, False, True, False), + (FORUM_ROLE_STUDENT, True, False, True), + (FORUM_ROLE_STUDENT, False, True, True), + ) + @ddt.unpack + def test_anonymity( + self, role_name, anonymous, anonymous_to_peers, expected_serialized_anonymous + ): + """ + Test that content is properly made anonymous. + + Content should be anonymous if the anonymous field is true or the + anonymous_to_peers field is true and the requester does not have a + privileged role. + + role_name is the name of the requester's role. + anonymous is the value of the anonymous field in the content. + anonymous_to_peers is the value of the anonymous_to_peers field in the + content. + expected_serialized_anonymous is whether the content should actually be + anonymous in the API output when requested by a user with the given + role. + """ + self.create_role(role_name, [self.user]) + serialized = self.serialize( + self.make_cs_content( + {"anonymous": anonymous, "anonymous_to_peers": anonymous_to_peers} + ) + ) + actual_serialized_anonymous = serialized["author"] is None + assert actual_serialized_anonymous == expected_serialized_anonymous + + @ddt.data( + (FORUM_ROLE_ADMINISTRATOR, False, "Moderator"), + (FORUM_ROLE_ADMINISTRATOR, True, None), + (FORUM_ROLE_MODERATOR, False, "Moderator"), + (FORUM_ROLE_MODERATOR, True, None), + (FORUM_ROLE_COMMUNITY_TA, False, "Community TA"), + (FORUM_ROLE_COMMUNITY_TA, True, None), + (FORUM_ROLE_STUDENT, False, None), + (FORUM_ROLE_STUDENT, True, None), + ) + @ddt.unpack + def test_author_labels(self, role_name, anonymous, expected_label): + """ + Test correctness of the author_label field. + + The label should be "Staff", "Moderator", or "Community TA" for the + Administrator, Moderator, and Community TA roles, respectively, but + the label should not be present if the content is anonymous. + + role_name is the name of the author's role. + anonymous is the value of the anonymous field in the content. + expected_label is the expected value of the author_label field in the + API output. + """ + self.create_role(role_name, [self.author]) + serialized = self.serialize(self.make_cs_content({"anonymous": anonymous})) + assert serialized["author_label"] == expected_label + + def test_abuse_flagged(self): + serialized = self.serialize( + self.make_cs_content({"abuse_flaggers": [str(self.user.id)]}) + ) + assert serialized["abuse_flagged"] is True + + def test_voted(self): + thread_id = "test_thread" + self.register_get_user_response(self.user, upvoted_ids=[thread_id]) + serialized = self.serialize(self.make_cs_content({"id": thread_id})) + assert serialized["voted"] is True + + +@ddt.ddt +class ThreadSerializerSerializationTest(SerializerTestMixin, SharedModuleStoreTestCase): + """Tests for ThreadSerializer serialization.""" + + def make_cs_content(self, overrides): + """ + Create a thread with the given overrides, plus some useful test data. + """ + merged_overrides = { + "course_id": str(self.course.id), + "user_id": str(self.author.id), + "username": self.author.username, + "read": True, + "endorsed": True, + "resp_total": 0, + } + merged_overrides.update(overrides) + return make_minimal_cs_thread(merged_overrides) + + def serialize(self, thread): + """ + Create a serializer with an appropriate context and use it to serialize + the given thread, returning the result. + """ + return ThreadSerializer( + thread, context=get_context(self.course, self.request) + ).data + + def test_basic(self): + thread = make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(self.author.id), + "username": self.author.username, + "title": "Test Title", + "body": "Test body", + "pinned": True, + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + } + ) + expected = self.expected_thread_data( + { + "author": self.author.username, + "can_delete": False, + "vote_count": 4, + "comment_count": 6, + "unread_comment_count": 3, + "pinned": True, + "editable_fields": [ + "abuse_flagged", + "copy_link", + "following", + "read", + "voted", + ], + "abuse_flagged_count": None, + "edit_by_label": None, + "closed_by_label": None, + } + ) + assert self.serialize(thread) == expected + + thread["thread_type"] = "question" + expected.update( + { + "type": "question", + "comment_list_url": None, + "endorsed_comment_list_url": ( + "http://testserver/api/discussion/v1/comments/?thread_id=test_thread&endorsed=True" + ), + "non_endorsed_comment_list_url": ( + "http://testserver/api/discussion/v1/comments/?thread_id=test_thread&endorsed=False" + ), + } + ) + assert self.serialize(thread) == expected + + def test_pinned_missing(self): + """ + Make sure that older threads in the comments service without the pinned + field do not break serialization + """ + thread_data = self.make_cs_content({}) + del thread_data["pinned"] + self.register_get_thread_response(thread_data) + serialized = self.serialize(thread_data) + assert serialized["pinned"] is False + + def test_group(self): + self.course.cohort_config = {"cohorted": True} + modulestore().update_item(self.course, ModuleStoreEnum.UserID.test) + cohort = CohortFactory.create(course_id=self.course.id) + serialized = self.serialize(self.make_cs_content({"group_id": cohort.id})) + assert serialized["group_id"] == cohort.id + assert serialized["group_name"] == cohort.name + + def test_following(self): + thread_id = "test_thread" + self.register_get_user_response(self.user, subscribed_thread_ids=[thread_id]) + serialized = self.serialize(self.make_cs_content({"id": thread_id})) + assert serialized["following"] is True + + def test_response_count(self): + thread_data = self.make_cs_content({"resp_total": 2}) + self.register_get_thread_response(thread_data) + serialized = self.serialize(thread_data) + assert serialized["response_count"] == 2 + + def test_response_count_missing(self): + thread_data = self.make_cs_content({}) + del thread_data["resp_total"] + self.register_get_thread_response(thread_data) + serialized = self.serialize(thread_data) + assert "response_count" not in serialized + + @ddt.data( + (FORUM_ROLE_MODERATOR, True), + (FORUM_ROLE_STUDENT, False), + ("author", True), + ) + @ddt.unpack + def test_closed_by_label_field(self, role, visible): + """ + Tests if closed by field is visible to author and priviledged users + """ + moderator = UserFactory() + request_role = FORUM_ROLE_STUDENT if role == "author" else role + author = self.user if role == "author" else self.author + self.create_role(FORUM_ROLE_MODERATOR, [moderator]) + self.create_role(request_role, [self.user]) + + thread = make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(author.id), + "username": author.username, + "title": "Test Title", + "body": "Test body", + "pinned": True, + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + "closed_by": moderator, + } + ) + closed_by_label = "Moderator" if visible else None + closed_by = moderator if visible else None + can_delete = role != FORUM_ROLE_STUDENT + editable_fields = ["abuse_flagged", "copy_link", "following", "read", "voted"] + if role == "author": + editable_fields.remove("voted") + editable_fields.extend( + ["anonymous", "raw_body", "title", "topic_id", "type"] + ) + elif role == FORUM_ROLE_MODERATOR: + editable_fields.extend( + [ + "close_reason_code", + "closed", + "edit_reason_code", + "pinned", + "raw_body", + "title", + "topic_id", + "type", + ] + ) + expected = self.expected_thread_data( + { + "author": author.username, + "can_delete": can_delete, + "vote_count": 4, + "comment_count": 6, + "unread_comment_count": 3, + "pinned": True, + "editable_fields": sorted(editable_fields), + "abuse_flagged_count": None, + "edit_by_label": None, + "closed_by_label": closed_by_label, + "closed_by": closed_by, + } + ) + assert self.serialize(thread) == expected + + @ddt.data( + (FORUM_ROLE_MODERATOR, True), + (FORUM_ROLE_STUDENT, False), + ("author", True), + ) + @ddt.unpack + def test_edit_by_label_field(self, role, visible): + """ + Tests if closed by field is visible to author and priviledged users + """ + moderator = UserFactory() + request_role = FORUM_ROLE_STUDENT if role == "author" else role + author = self.user if role == "author" else self.author + self.create_role(FORUM_ROLE_MODERATOR, [moderator]) + self.create_role(request_role, [self.user]) + + thread = make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(author.id), + "username": author.username, + "title": "Test Title", + "body": "Test body", + "pinned": True, + "votes": {"up_count": 4}, + "edit_history": [{"editor_username": moderator}], + "comments_count": 5, + "unread_comments_count": 3, + "closed_by": None, + } + ) + edit_by_label = "Moderator" if visible else None + can_delete = role != FORUM_ROLE_STUDENT + last_edit = ( + None if role == FORUM_ROLE_STUDENT else {"editor_username": moderator} + ) + editable_fields = ["abuse_flagged", "copy_link", "following", "read", "voted"] + + if role == "author": + editable_fields.remove("voted") + editable_fields.extend( + ["anonymous", "raw_body", "title", "topic_id", "type"] + ) + + elif role == FORUM_ROLE_MODERATOR: + editable_fields.extend( + [ + "close_reason_code", + "closed", + "edit_reason_code", + "pinned", + "raw_body", + "title", + "topic_id", + "type", + ] + ) + + expected = self.expected_thread_data( + { + "author": author.username, + "can_delete": can_delete, + "vote_count": 4, + "comment_count": 6, + "unread_comment_count": 3, + "pinned": True, + "editable_fields": sorted(editable_fields), + "abuse_flagged_count": None, + "last_edit": last_edit, + "edit_by_label": edit_by_label, + "closed_by_label": None, + "closed_by": None, + } + ) + assert self.serialize(thread) == expected + + def test_get_preview_body(self): + """ + Test for the 'get_preview_body' method. + + This test verifies that the 'get_preview_body' method returns a cleaned + version of the thread's body that is suitable for display as a preview. + The test specifically focuses on handling the presence of multiple + spaces within the body. + """ + thread_data = self.make_cs_content( + {"body": "

This is a test thread body with some text.

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

Test body

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

Test body

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

dummy

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

Test body

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

Original body

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

Original body

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

Original body

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

Original body

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

Test body

", + "preview_body": "Test body", + "abuse_flagged": False, + "abuse_flagged_count": None, + "voted": False, + "vote_count": 0, + "editable_fields": [ + "abuse_flagged", + "anonymous", + "copy_link", + "following", + "raw_body", + "read", + "title", + "topic_id", + "type", + ], + "course_id": str(self.course.id), + "topic_id": "test_topic", + "group_id": None, + "group_name": None, + "title": "Test Title", + "pinned": False, + "closed": False, + "can_delete": True, + "following": False, + "comment_count": 1, + "unread_comment_count": 0, + "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread", + "endorsed_comment_list_url": None, + "non_endorsed_comment_list_url": None, + "read": False, + "has_endorsed": False, + "id": "test_thread", + "type": "discussion", + "response_count": 0, + "last_edit": None, + "edit_by_label": None, + "closed_by": None, + "closed_by_label": None, + "close_reason": None, + "close_reason_code": None, + } + response_data.update(overrides or {}) + return response_data + + +def make_minimal_cs_thread(overrides=None): + """ + Create a dictionary containing all needed thread fields as returned by the + comments service with dummy data and optional overrides + """ + ret = { + "type": "thread", + "id": "dummy", + "course_id": "course-v1:dummy+dummy+dummy", + "commentable_id": "dummy", + "group_id": None, + "user_id": "0", + "username": "dummy", + "anonymous": False, + "anonymous_to_peers": False, + "created_at": "1970-01-01T00:00:00Z", + "updated_at": "1970-01-01T00:00:00Z", + "last_activity_at": "1970-01-01T00:00:00Z", + "thread_type": "discussion", + "title": "dummy", + "body": "dummy", + "pinned": False, + "closed": False, + "abuse_flaggers": [], + "abuse_flagged_count": None, + "votes": {"up_count": 0}, + "comments_count": 0, + "unread_comments_count": 0, + "children": [], + "read": False, + "endorsed": False, + "resp_total": 0, + "closed_by": None, + "close_reason_code": None, + } + ret.update(overrides or {}) + return ret + + +def make_minimal_cs_comment(overrides=None): + """ + Create a dictionary containing all needed comment fields as returned by the + comments service with dummy data and optional overrides + """ + ret = { + "type": "comment", + "id": "dummy", + "commentable_id": "dummy", + "thread_id": "dummy", + "parent_id": None, + "user_id": "0", + "username": "dummy", + "anonymous": False, + "anonymous_to_peers": False, + "created_at": "1970-01-01T00:00:00Z", + "updated_at": "1970-01-01T00:00:00Z", + "body": "dummy", + "abuse_flaggers": [], + "votes": {"up_count": 0}, + "endorsed": False, + "child_count": 0, + "children": [], + } + ret.update(overrides or {}) + return ret + + +def make_paginated_api_response( + results=None, count=0, num_pages=0, next_link=None, previous_link=None +): + """ + Generates the response dictionary of paginated APIs with passed data + """ + return { + "pagination": { + "next": next_link, + "previous": previous_link, + "count": count, + "num_pages": num_pages, + }, + "results": results or [], + } + + +class ProfileImageTestMixin: + """ + Mixin with utility methods for user profile image + """ + + TEST_PROFILE_IMAGE_UPLOADED_AT = datetime(2002, 1, 9, 15, 43, 1, tzinfo=UTC) + + def create_profile_image(self, user, storage): + """ + Creates profile image for user and checks that created image exists in storage + """ + with make_image_file() as image_file: + create_profile_images(image_file, get_profile_image_names(user.username)) + self.check_images(user, storage) + set_has_profile_image( + user.username, True, self.TEST_PROFILE_IMAGE_UPLOADED_AT + ) + + def check_images(self, user, storage, exist=True): + """ + If exist is True, make sure the images physically exist in storage + with correct sizes and formats. + + If exist is False, make sure none of the images exist. + """ + for size, name in get_profile_image_names(user.username).items(): + if exist: + assert storage.exists(name) + with closing(Image.open(storage.path(name))) as img: + assert img.size == (size, size) + assert img.format == "JPEG" + else: + assert not storage.exists(name) + + def get_expected_user_profile(self, username): + """ + Returns the expected user profile data for a given username + """ + url = "http://example-storage.com/profile-images/{filename}_{{size}}.jpg?v={timestamp}".format( + filename=hashlib.md5(b"secret" + username.encode("utf-8")).hexdigest(), + timestamp=self.TEST_PROFILE_IMAGE_UPLOADED_AT.strftime("%s"), + ) + return { + "profile": { + "image": { + "has_image": True, + "image_url_full": url.format(size=500), + "image_url_large": url.format(size=120), + "image_url_medium": url.format(size=50), + "image_url_small": url.format(size=30), + } + } + } + + +def parsed_body(request): + """Returns a parsed dictionary version of a request body""" + # This could just be HTTPrettyRequest.parsed_body, but that method double-decodes '%2B' -> '+' -> ' '. + # You can just remove this method when this issue is fixed: https://github.com/gabrielfalcao/HTTPretty/issues/240 + return parse_qs(request.body.decode("utf8")) + + +def querystring(request): + """Returns a parsed dictionary version of a query string""" + # This could just be HTTPrettyRequest.querystring, but that method double-decodes '%2B' -> '+' -> ' '. + # You can just remove this method when this issue is fixed: https://github.com/gabrielfalcao/HTTPretty/issues/240 + return parse_qs(request.path.split("?", 1)[-1]) + + +class ThreadMock(object): + """ + A mock thread object + """ + + def __init__(self, thread_id, creator, title, parent_id=None, body=""): + self.id = thread_id + self.user_id = str(creator.id) + self.username = creator.username + self.title = title + self.parent_id = parent_id + self.body = body + + def url_with_id(self, params): + return f"http://example.com/{params['id']}" diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/comment.py b/openedx/core/djangoapps/django_comment_common/comment_client/comment.py index 47a0c75c2b74..b46bb933bd35 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/comment.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/comment.py @@ -73,9 +73,19 @@ def flagAbuse(self, user, voteable): course_key = get_course_key(self.attributes.get("course_id")) if is_forum_v2_enabled(course_key): if voteable.type == 'thread': - response = forum_api.update_thread_flag(voteable.id, "flag", user.id, str(course_key)) + response = forum_api.update_thread_flag( + voteable.id, + "flag", + user.id, + str(course_key) if course_key else course_key + ) else: - response = forum_api.update_comment_flag(voteable.id, "flag", user.id, str(course_key)) + response = forum_api.update_comment_flag( + voteable.id, + "flag", + user.id, + str(course_key) if course_key else course_key + ) else: params = {'user_id': user.id} response = perform_request( @@ -102,7 +112,7 @@ def unFlagAbuse(self, user, voteable, removeAll): action="unflag", user_id=user.id, update_all=bool(removeAll), - course_id=str(course_key) + course_id=str(course_key) if course_key else course_key ) else: response = forum_api.update_comment_flag( @@ -110,7 +120,7 @@ def unFlagAbuse(self, user, voteable, removeAll): action="unflag", user_id=user.id, update_all=bool(removeAll), - course_id=str(course_key) + course_id=str(course_key) if course_key else course_key ) else: params = {'user_id': user.id} diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/models.py b/openedx/core/djangoapps/django_comment_common/comment_client/models.py index d3d6bde1078b..d8d29220d9cc 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/models.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/models.py @@ -78,7 +78,10 @@ def _retrieve(self, *args, **kwargs): response = None if is_forum_v2_enabled(course_key): if self.type == "comment": - response = forum_api.get_parent_comment(comment_id=self.attributes["id"], course_id=str(course_key)) + response = forum_api.get_parent_comment( + comment_id=self.attributes["id"], + course_id=str(course_key) if course_key else course_key + ) if response is None: raise CommentClientRequestError("Forum v2 API call is missing") else: @@ -177,9 +180,15 @@ def delete(self): if is_forum_v2_enabled(course_key): response = None if self.type == "comment": - response = forum_api.delete_comment(comment_id=self.attributes["id"], course_id=str(course_key)) + response = forum_api.delete_comment( + comment_id=self.attributes["id"], + course_id=str(course_key) if course_key else course_key + ) elif self.type == "thread": - response = forum_api.delete_thread(thread_id=self.attributes["id"], course_id=str(course_key)) + response = forum_api.delete_thread( + thread_id=self.attributes["id"], + course_id=str(course_key) if course_key else course_key + ) if response is None: raise CommentClientRequestError("Forum v2 API call is missing") else: @@ -225,11 +234,11 @@ def handle_update(self, params=None): if is_forum_v2_enabled(course_key): response = None if self.type == "comment": - response = self.handle_update_comment(request_params, str(course_key)) + response = self.handle_update_comment(request_params, str(course_key) if course_key else course_key) elif self.type == "thread": - response = self.handle_update_thread(request_params, str(course_key)) + response = self.handle_update_thread(request_params, str(course_key) if course_key else course_key) elif self.type == "user": - response = self.handle_update_user(request_params, str(course_key)) + response = self.handle_update_user(request_params, str(course_key) if course_key else course_key) if response is None: raise CommentClientRequestError("Forum v2 API call is missing") else: @@ -320,9 +329,9 @@ def handle_create(self, params=None): if is_forum_v2_enabled(course_key): response = None if self.type == "comment": - response = self.handle_create_comment(str(course_key)) + response = self.handle_create_comment(str(course_key) if course_key else course_key) elif self.type == "thread": - response = self.handle_create_thread(str(course_key)) + response = self.handle_create_thread(str(course_key) if course_key else course_key) if response is None: raise CommentClientRequestError("Forum v2 API call is missing") else: diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/subscriptions.py b/openedx/core/djangoapps/django_comment_common/comment_client/subscriptions.py index 4a3a9e5de6c6..5696306803e9 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/subscriptions.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/subscriptions.py @@ -41,7 +41,7 @@ def fetch(cls, thread_id, course_id, query_params): thread_id=thread_id, page=params["page"], per_page=params["per_page"], - course_id=str(course_key) + course_id=str(course_key) if course_key else course_key ) else: response = utils.perform_request( 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 710ce427aa81..ab3a76c168e7 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py @@ -183,7 +183,7 @@ def _retrieve(self, *args, **kwargs): response = forum_api.get_thread( thread_id=self.id, params=request_params, - course_id=str(course_key) + course_id=str(course_key) if course_key else course_key ) else: response = utils.perform_request( @@ -202,7 +202,12 @@ def flagAbuse(self, user, voteable): raise utils.CommentClientRequestError("Can only flag/unflag threads or comments") course_key = utils.get_course_key(self.attributes.get("course_id")) if is_forum_v2_enabled(course_key): - response = forum_api.update_thread_flag(voteable.id, "flag", user.id, str(course_key)) + response = forum_api.update_thread_flag( + voteable.id, + "flag", + user.id, + str(course_key) if course_key else course_key + ) else: params = {'user_id': user.id} response = utils.perform_request( @@ -226,7 +231,7 @@ def unFlagAbuse(self, user, voteable, removeAll): action="unflag", user_id=user.id, update_all=bool(removeAll), - course_id=str(course_key) + course_id=str(course_key) if course_key else course_key ) else: params = {'user_id': user.id} @@ -249,7 +254,7 @@ def pin(self, user, thread_id): response = forum_api.pin_thread( user_id=user.id, thread_id=thread_id, - course_id=str(course_key) + course_id=str(course_key) if course_key else course_key ) else: url = _url_for_pin_thread(thread_id) @@ -269,7 +274,7 @@ def un_pin(self, user, thread_id): response = forum_api.unpin_thread( user_id=user.id, thread_id=thread_id, - course_id=str(course_key) + course_id=str(course_key) if course_key else course_key ) else: url = _url_for_un_pin_thread(thread_id) 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 84e9dec3e2f6..90282d2a4b4c 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/user.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/user.py @@ -41,7 +41,7 @@ def read(self, source): course_id = self.attributes.get("course_id") course_key = utils.get_course_key(course_id) if is_forum_v2_enabled(course_key): - forum_api.mark_thread_as_read(self.id, source.id, course_id=str(course_id)) + forum_api.mark_thread_as_read(self.id, source.id, course_id=str(course_key) if course_key else course_key) else: params = {'source_type': source.type, 'source_id': source.id} utils.perform_request( @@ -58,7 +58,7 @@ def follow(self, source): forum_api.create_subscription( user_id=self.id, source_id=source.id, - course_id=str(course_key) + course_id=str(course_key) if course_key else course_key ) else: params = {'source_type': source.type, 'source_id': source.id} @@ -76,7 +76,7 @@ def unfollow(self, source): forum_api.delete_subscription( user_id=self.id, source_id=source.id, - course_id=str(course_key) + course_id=str(course_key) if course_key else course_key ) else: params = {'source_type': source.type, 'source_id': source.id} @@ -102,7 +102,7 @@ def vote(self, voteable, value): thread_id=voteable.id, user_id=self.id, value=value, - course_id=str(course_key) + course_id=str(course_key) if course_key else course_key ) else: response = forum_api.update_comment_votes( @@ -135,7 +135,7 @@ def unvote(self, voteable): response = forum_api.delete_thread_vote( thread_id=voteable.id, user_id=self.id, - course_id=str(course_key) + course_id=str(course_key) if course_key else course_key ) else: response = forum_api.delete_comment_vote( @@ -173,7 +173,7 @@ def active_threads(self, query_params=None): if count_flagged := params.get("count_flagged", False): params["count_flagged"] = str_to_bool(count_flagged) if not params.get("course_id"): - params["course_id"] = str(course_key) + params["course_id"] = str(course_key) if course_key else course_key response = forum_api.get_user_active_threads(**params) else: response = utils.perform_request( @@ -207,7 +207,7 @@ def subscribed_threads(self, query_params=None): if count_flagged := params.get("count_flagged", False): params["count_flagged"] = str_to_bool(count_flagged) if not params.get("course_id"): - params["course_id"] = str(course_key) + params["course_id"] = str(course_key) if course_key else course_key response = forum_api.get_user_threads(**params) else: response = utils.perform_request( @@ -237,7 +237,7 @@ def _retrieve(self, *args, **kwargs): course_key = utils.get_course_key(course_id) if is_forum_v2_enabled(course_key): if not retrieve_params.get("course_id"): - retrieve_params["course_id"] = str(course_key) + retrieve_params["course_id"] = str(course_key) if course_key else course_key try: response = forum_api.get_user(self.attributes["id"], retrieve_params) except ForumV2RequestError as e: @@ -271,7 +271,11 @@ def _retrieve(self, *args, **kwargs): def retire(self, retired_username): course_key = utils.get_course_key(self.attributes.get("course_id")) if is_forum_v2_enabled(course_key): - forum_api.retire_user(user_id=self.id, retired_username=retired_username, course_id=str(course_key)) + forum_api.retire_user( + user_id=self.id, + retired_username=retired_username, + course_id=str(course_key) if course_key else course_key + ) else: url = _url_for_retire(self.id) params = {'retired_username': retired_username} @@ -287,7 +291,7 @@ def retire(self, retired_username): def replace_username(self, new_username): course_key = utils.get_course_key(self.attributes.get("course_id")) if is_forum_v2_enabled(course_key): - forum_api.update_username(user_id=self.id, new_username=new_username, course_id=str(course_key)) + forum_api.update_username(user_id=self.id, new_username=new_username, course_id=str(course_key) if course_key else course_key) else: url = _url_for_username_replacement(self.id) params = {"new_username": new_username}