diff --git a/lms/djangoapps/discussion/django_comment_client/base/tests.py b/lms/djangoapps/discussion/django_comment_client/base/tests.py deleted file mode 100644 index d2d14cc13f..0000000000 --- a/lms/djangoapps/discussion/django_comment_client/base/tests.py +++ /dev/null @@ -1,636 +0,0 @@ -# pylint: skip-file -"""Tests for django comment client views.""" - -import pytest -import json -import logging -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 common.djangoapps.student.roles import CourseStaffRole, UserBasedRole -from common.djangoapps.student.tests.factories import 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 lms.djangoapps.discussion.django_comment_client.base import views -from lms.djangoapps.discussion.django_comment_client.tests.utils import ForumsEnableMixin -from lms.djangoapps.teams.tests.factories import CourseTeamFactory -from openedx.core.djangoapps.django_comment_common.comment_client import Thread -from openedx.core.djangoapps.django_comment_common.models import ( - FORUM_ROLE_STUDENT, - 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 xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.tests.django_utils import ( - TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase, SharedModuleStoreTestCase, -) -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory - -from .event_transformers import ForumThreadViewedEventTransformer - -log = logging.getLogger(__name__) - -CS_PREFIX = "http://localhost:4567/api/v1" - -QUERY_COUNT_TABLE_IGNORELIST = WAFFLE_TABLES - -# pylint: disable=missing-docstring - - -class MockRequestSetupMixin: - def _create_response_mock(self, data): - return Mock( - text=json.dumps(data), - json=Mock(return_value=data), - status_code=200 - ) - - def _set_mock_request_data(self, mock_request, data): - mock_request.return_value = self._create_response_mock(data) - - -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 _setup_mock_request(self, mock_request, 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._set_mock_request_data(mock_request, data) - - def create_thread_helper(self, mock_is_forum_v2_enabled, mock_request, extra_request_data=None, extra_response_data=None): - """ - Issues a request to create a thread and verifies the result. - """ - mock_is_forum_v2_enabled.return_value = False - self._set_mock_request_data(mock_request, { - "thread_type": "discussion", - "title": "Hello", - "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 mock_request.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) - mock_request.assert_called_with( - 'post', - f'{CS_PREFIX}/i4x-MITx-999-course-Robot_Super_Course/threads', - data=expected_data, - params={'request_id': ANY}, - headers=ANY, - timeout=5 - ) - assert response.status_code == 200 - - def update_thread_helper(self, mock_is_forum_v2_enabled, mock_request): - """ - Issues a request to update a thread and verifies the result. - """ - mock_is_forum_v2_enabled.return_value = False - self._setup_mock_request(mock_request) - # Mock out saving in order to test that content is correctly - # updated. Otherwise, the call to thread.save() receives the - # 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' - - -class UsersEndpointTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, MockRequestSetupMixin): - - @classmethod - def setUpClass(cls): - # pylint: disable=super-method-not-called - with super().setUpClassAndTestData(): - cls.course = CourseFactory.create() - - @classmethod - def setUpTestData(cls): - super().setUpTestData() - - seed_permissions_roles(cls.course.id) - - cls.student = UserFactory.create() - cls.enrollment = CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) - cls.other_user = UserFactory.create(username="other") - CourseEnrollmentFactory(user=cls.other_user, course_id=cls.course.id) - - def set_post_counts(self, mock_is_forum_v2_enabled, mock_request, threads_count=1, comments_count=1): - """ - sets up a mock response from the comments service for getting post counts for our other_user - """ - mock_is_forum_v2_enabled.return_value = False - self._set_mock_request_data(mock_request, { - "threads_count": threads_count, - "comments_count": comments_count, - }) - - def make_request(self, method='get', course_id=None, **kwargs): - course_id = course_id or self.course.id - request = getattr(RequestFactory(), method)("dummy_url", kwargs) - request.user = self.student - request.view_name = "users" - return views.users(request, course_id=str(course_id)) - - @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - @patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) - def test_finds_exact_match(self, mock_is_forum_v2_enabled, mock_request): - self.set_post_counts(mock_is_forum_v2_enabled, mock_request) - response = self.make_request(username="other") - assert response.status_code == 200 - assert json.loads(response.content.decode('utf-8'))['users'] == [{'id': self.other_user.id, 'username': self.other_user.username}] - - @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - @patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) - def test_finds_no_match(self, mock_is_forum_v2_enabled, mock_request): - self.set_post_counts(mock_is_forum_v2_enabled, mock_request) - response = self.make_request(username="othor") - assert response.status_code == 200 - assert json.loads(response.content.decode('utf-8'))['users'] == [] - - def test_requires_GET(self): - response = self.make_request(method='post', username="other") - assert response.status_code == 405 - - def test_requires_username_param(self): - response = self.make_request() - assert response.status_code == 400 - content = json.loads(response.content.decode('utf-8')) - assert 'errors' in content - assert 'users' not in content - - def test_course_does_not_exist(self): - course_id = CourseKey.from_string("does/not/exist") - response = self.make_request(course_id=course_id, username="other") - - assert response.status_code == 404 - content = json.loads(response.content.decode('utf-8')) - assert 'errors' in content - assert 'users' not in content - - def test_requires_requestor_enrolled_in_course(self): - # unenroll self.student from the course. - self.enrollment.delete() - - response = self.make_request(username="other") - assert response.status_code == 404 - content = json.loads(response.content.decode('utf-8')) - assert 'errors' in content - assert 'users' not in content - - @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - @patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) - def test_requires_matched_user_has_forum_content(self, mock_is_forum_v2_enabled, mock_request): - self.set_post_counts(mock_is_forum_v2_enabled, mock_request, 0, 0) - response = self.make_request(username="other") - assert response.status_code == 200 - assert json.loads(response.content.decode('utf-8'))['users'] == [] - - -@ddt.ddt -class SegmentIOForumThreadViewedEventTestCase(SegmentIOTrackingTestCaseBase): - - def _raise_navigation_event(self, label, include_name): - middleware = TrackMiddleware(get_response=lambda request: None) - kwargs = {'label': label} - if include_name: - kwargs['name'] = 'edx.bi.app.navigation.screen' - else: - kwargs['exclude_name'] = True - request = self.create_request( - data=self.create_segmentio_event_json(**kwargs), - content_type='application/json', - ) - User.objects.create(pk=SEGMENTIO_TEST_USER_ID, username=str(mock.sentinel.username)) - middleware.process_request(request) - try: - response = segmentio.segmentio_event(request) - assert response.status_code == 200 - finally: - middleware.process_response(request, None) - - @ddt.data(True, False) - def test_thread_viewed(self, include_name): - """ - Tests that a SegmentIO thread viewed event is accepted and transformed. - - Only tests that the transformation happens at all; does not - comprehensively test that it happens correctly. - ForumThreadViewedEventTransformerTestCase tests for correctness. - """ - self._raise_navigation_event('Forum: View Thread', include_name) - event = self.get_event() - assert event['name'] == 'edx.forum.thread.viewed' - assert event['event_type'] == event['name'] - - @ddt.data(True, False) - def test_non_thread_viewed(self, include_name): - """ - Tests that other BI events are thrown out. - """ - self._raise_navigation_event('Forum: Create Thread', include_name) - self.assert_no_events_emitted() - - -def _get_transformed_event(input_event): - transformer = ForumThreadViewedEventTransformer(**input_event) - transformer.transform() - return transformer - - -def _create_event( - label='Forum: View Thread', - include_context=True, - inner_context=None, - username=None, - course_id=None, - **event_data -): - result = {'name': 'edx.bi.app.navigation.screen'} - if include_context: - result['context'] = {'label': label} - if course_id: - result['context']['course_id'] = str(course_id) - if username: - result['username'] = username - if event_data: - result['event'] = event_data - if inner_context: - if not event_data: - result['event'] = {} - result['event']['context'] = inner_context - return result - - -def _create_and_transform_event(**kwargs): - event = _create_event(**kwargs) - return event, _get_transformed_event(event) - - -@ddt.ddt -class ForumThreadViewedEventTransformerTestCase(ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase): - """ - Test that the ForumThreadViewedEventTransformer transforms events correctly - and without raising exceptions. - - Because the events passed through the transformer can come from external - sources (e.g., a mobile app), we carefully test a myriad of cases, including - those with incomplete and malformed events. - """ - MODULESTORE = TEST_DATA_SPLIT_MODULESTORE - - CATEGORY_ID = 'i4x-edx-discussion-id' - CATEGORY_NAME = 'Discussion 1' - PARENT_CATEGORY_NAME = 'Chapter 1' - - TEAM_CATEGORY_ID = 'i4x-edx-team-discussion-id' - TEAM_CATEGORY_NAME = 'Team Chat' - TEAM_PARENT_CATEGORY_NAME = PARENT_CATEGORY_NAME - - DUMMY_CATEGORY_ID = 'i4x-edx-dummy-commentable-id' - DUMMY_THREAD_ID = 'dummy_thread_id' - - @mock.patch.dict("common.djangoapps.student.models.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - super().setUp() - self.course = CourseFactory.create( - org='TestX', - course='TR-101S', - run='Event_Transform_Test_Split', - default_store=ModuleStoreEnum.Type.split, - ) - self.student = UserFactory.create() - self.staff = UserFactory.create(is_staff=True) - UserBasedRole(user=self.staff, role=CourseStaffRole.ROLE).add_course(self.course.id) - CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) - self.category = BlockFactory.create( - parent_location=self.course.location, - category='discussion', - discussion_id=self.CATEGORY_ID, - discussion_category=self.PARENT_CATEGORY_NAME, - discussion_target=self.CATEGORY_NAME, - ) - self.team_category = BlockFactory.create( - parent_location=self.course.location, - category='discussion', - discussion_id=self.TEAM_CATEGORY_ID, - discussion_category=self.TEAM_PARENT_CATEGORY_NAME, - discussion_target=self.TEAM_CATEGORY_NAME, - ) - self.team = CourseTeamFactory.create( - name='Team 1', - course_id=self.course.id, - topic_id='arbitrary-topic-id', - discussion_topic_id=self.team_category.discussion_id, - ) - - def test_missing_context(self): - event = _create_event(include_context=False) - with pytest.raises(EventEmissionExit): - _get_transformed_event(event) - - def test_no_data(self): - event, event_trans = _create_and_transform_event() - event['name'] = 'edx.forum.thread.viewed' - event['event_type'] = event['name'] - event['event'] = {} - self.assertDictEqual(event_trans, event) - - def test_inner_context(self): - _, event_trans = _create_and_transform_event(inner_context={}) - assert 'context' not in event_trans['event'] - - def test_non_thread_view(self): - event = _create_event( - label='Forum: Create Thread', - course_id=self.course.id, - topic_id=self.DUMMY_CATEGORY_ID, - thread_id=self.DUMMY_THREAD_ID, - ) - with pytest.raises(EventEmissionExit): - _get_transformed_event(event) - - def test_bad_field_types(self): - event, event_trans = _create_and_transform_event( - course_id={}, - topic_id=3, - thread_id=object(), - action=3.14, - ) - event['name'] = 'edx.forum.thread.viewed' - event['event_type'] = event['name'] - self.assertDictEqual(event_trans, event) - - def test_bad_course_id(self): - event, event_trans = _create_and_transform_event(course_id='non-existent-course-id') - event_data = event_trans['event'] - assert 'category_id' not in event_data - assert 'category_name' not in event_data - assert 'url' not in event_data - assert 'user_forums_roles' not in event_data - assert 'user_course_roles' not in event_data - - def test_bad_username(self): - event, event_trans = _create_and_transform_event(username='non-existent-username') - event_data = event_trans['event'] - assert 'category_id' not in event_data - assert 'category_name' not in event_data - assert 'user_forums_roles' not in event_data - assert 'user_course_roles' not in event_data - - def test_bad_url(self): - event, event_trans = _create_and_transform_event( - course_id=self.course.id, - topic_id='malformed/commentable/id', - thread_id='malformed/thread/id', - ) - assert 'url' not in event_trans['event'] - - def test_renamed_fields(self): - AUTHOR = 'joe-the-plumber' - event, event_trans = _create_and_transform_event( - course_id=self.course.id, - topic_id=self.DUMMY_CATEGORY_ID, - thread_id=self.DUMMY_THREAD_ID, - author=AUTHOR, - ) - assert event_trans['event']['commentable_id'] == self.DUMMY_CATEGORY_ID - assert event_trans['event']['id'] == self.DUMMY_THREAD_ID - assert event_trans['event']['target_username'] == AUTHOR - - def test_titles(self): - - # No title - _, event_1_trans = _create_and_transform_event() - assert 'title' not in event_1_trans['event'] - assert 'title_truncated' not in event_1_trans['event'] - - # Short title - _, event_2_trans = _create_and_transform_event( - action='!', - ) - assert 'title' in event_2_trans['event'] - assert 'title_truncated' in event_2_trans['event'] - assert not event_2_trans['event']['title_truncated'] - - # Long title - _, event_3_trans = _create_and_transform_event( - action=('covfefe' * 200), - ) - assert 'title' in event_3_trans['event'] - assert 'title_truncated' in event_3_trans['event'] - assert event_3_trans['event']['title_truncated'] - - def test_urls(self): - commentable_id = self.DUMMY_CATEGORY_ID - thread_id = self.DUMMY_THREAD_ID - _, event_trans = _create_and_transform_event( - course_id=self.course.id, - topic_id=commentable_id, - thread_id=thread_id, - ) - expected_path = '/courses/{}/discussion/forum/{}/threads/{}'.format( - self.course.id, commentable_id, thread_id - ) - assert event_trans['event'].get('url').endswith(expected_path) - - def test_categories(self): - - # Bad category - _, event_trans_1 = _create_and_transform_event( - username=self.student.username, - course_id=self.course.id, - topic_id='non-existent-category-id', - ) - assert 'category_id' not in event_trans_1['event'] - assert 'category_name' not in event_trans_1['event'] - - # Good category - _, event_trans_2 = _create_and_transform_event( - username=self.student.username, - course_id=self.course.id, - topic_id=self.category.discussion_id, - ) - assert event_trans_2['event'].get('category_id') == self.category.discussion_id - full_category_name = f'{self.category.discussion_category} / {self.category.discussion_target}' - assert event_trans_2['event'].get('category_name') == full_category_name - - def test_roles(self): - - # No user - _, event_trans_1 = _create_and_transform_event( - course_id=self.course.id, - ) - assert 'user_forums_roles' not in event_trans_1['event'] - assert 'user_course_roles' not in event_trans_1['event'] - - # Student user - _, event_trans_2 = _create_and_transform_event( - course_id=self.course.id, - username=self.student.username, - ) - assert event_trans_2['event'].get('user_forums_roles') == [FORUM_ROLE_STUDENT] - assert event_trans_2['event'].get('user_course_roles') == [] - - # Course staff user - _, event_trans_3 = _create_and_transform_event( - course_id=self.course.id, - username=self.staff.username, - ) - assert event_trans_3['event'].get('user_forums_roles') == [] - assert event_trans_3['event'].get('user_course_roles') == [CourseStaffRole.ROLE] - - def test_teams(self): - - # No category - _, event_trans_1 = _create_and_transform_event( - course_id=self.course.id, - ) - assert 'team_id' not in event_trans_1 - - # Non-team category - _, event_trans_2 = _create_and_transform_event( - course_id=self.course.id, - topic_id=self.CATEGORY_ID, - ) - assert 'team_id' not in event_trans_2 - - # Team category - _, event_trans_3 = _create_and_transform_event( - course_id=self.course.id, - topic_id=self.TEAM_CATEGORY_ID, - ) - assert event_trans_3['event'].get('team_id') == self.team.team_id diff --git a/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py b/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py index d145a7b2b0..e18350bcb7 100644 --- a/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py +++ b/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py @@ -1,6 +1,7 @@ # pylint: skip-file """Tests for django comment client views.""" +import pytest import json import logging from contextlib import contextmanager @@ -2288,3 +2289,412 @@ class CreateSubCommentUnicodeTestCase( assert create_call_params["body"] == text finally: del Thread.commentable_id + + +class UsersEndpointTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, MockForumApiMixin): + + @classmethod + def setUpClass(cls): # pylint: disable=super-method-not-called + super().setUpClassAndForumMock() + + with super().setUpClassAndTestData(): + cls.course = CourseFactory.create() + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + seed_permissions_roles(cls.course.id) + + cls.student = UserFactory.create() + cls.enrollment = CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) + cls.other_user = UserFactory.create(username="other") + CourseEnrollmentFactory(user=cls.other_user, course_id=cls.course.id) + + def set_post_counts(self, threads_count=1, comments_count=1): + """ + sets up a mock response from the comments service for getting post counts for our other_user + """ + self.set_mock_return_value("get_user", { + "threads_count": threads_count, + "comments_count": comments_count, + }) + + def make_request(self, method='get', course_id=None, **kwargs): + course_id = course_id or self.course.id + request = getattr(RequestFactory(), method)("dummy_url", kwargs) + request.user = self.student + request.view_name = "users" + return views.users(request, course_id=str(course_id)) + + def test_finds_exact_match(self): + self.set_post_counts() + response = self.make_request(username="other") + assert response.status_code == 200 + assert json.loads(response.content.decode('utf-8'))['users'] == [ + {'id': self.other_user.id, 'username': self.other_user.username} + ] + + def test_finds_no_match(self): + self.set_post_counts() + response = self.make_request(username="othor") + assert response.status_code == 200 + assert json.loads(response.content.decode('utf-8'))['users'] == [] + + def test_requires_GET(self): + response = self.make_request(method='post', username="other") + assert response.status_code == 405 + + def test_requires_username_param(self): + response = self.make_request() + assert response.status_code == 400 + content = json.loads(response.content.decode('utf-8')) + assert 'errors' in content + assert 'users' not in content + + def test_course_does_not_exist(self): + course_id = CourseKey.from_string("does/not/exist") + response = self.make_request(course_id=course_id, username="other") + + assert response.status_code == 404 + content = json.loads(response.content.decode('utf-8')) + assert 'errors' in content + assert 'users' not in content + + def test_requires_requestor_enrolled_in_course(self): + # unenroll self.student from the course. + self.enrollment.delete() + + response = self.make_request(username="other") + assert response.status_code == 404 + content = json.loads(response.content.decode('utf-8')) + assert 'errors' in content + assert 'users' not in content + + def test_requires_matched_user_has_forum_content(self): + self.set_post_counts(0, 0) + response = self.make_request(username="other") + assert response.status_code == 200 + assert json.loads(response.content.decode('utf-8'))['users'] == [] + + +@ddt.ddt +class SegmentIOForumThreadViewedEventTestCase(SegmentIOTrackingTestCaseBase): + + def _raise_navigation_event(self, label, include_name): + middleware = TrackMiddleware(get_response=lambda request: None) + kwargs = {'label': label} + if include_name: + kwargs['name'] = 'edx.bi.app.navigation.screen' + else: + kwargs['exclude_name'] = True + request = self.create_request( + data=self.create_segmentio_event_json(**kwargs), + content_type='application/json', + ) + User.objects.create(pk=SEGMENTIO_TEST_USER_ID, username=str(mock.sentinel.username)) + middleware.process_request(request) + try: + response = segmentio.segmentio_event(request) + assert response.status_code == 200 + finally: + middleware.process_response(request, None) + + @ddt.data(True, False) + def test_thread_viewed(self, include_name): + """ + Tests that a SegmentIO thread viewed event is accepted and transformed. + + Only tests that the transformation happens at all; does not + comprehensively test that it happens correctly. + ForumThreadViewedEventTransformerTestCase tests for correctness. + """ + self._raise_navigation_event('Forum: View Thread', include_name) + event = self.get_event() + assert event['name'] == 'edx.forum.thread.viewed' + assert event['event_type'] == event['name'] + + @ddt.data(True, False) + def test_non_thread_viewed(self, include_name): + """ + Tests that other BI events are thrown out. + """ + self._raise_navigation_event('Forum: Create Thread', include_name) + self.assert_no_events_emitted() + + +def _get_transformed_event(input_event): + transformer = ForumThreadViewedEventTransformer(**input_event) + transformer.transform() + return transformer + + +def _create_event( + label='Forum: View Thread', + include_context=True, + inner_context=None, + username=None, + course_id=None, + **event_data +): + result = {'name': 'edx.bi.app.navigation.screen'} + if include_context: + result['context'] = {'label': label} + if course_id: + result['context']['course_id'] = str(course_id) + if username: + result['username'] = username + if event_data: + result['event'] = event_data + if inner_context: + if not event_data: + result['event'] = {} + result['event']['context'] = inner_context + return result + + +def _create_and_transform_event(**kwargs): + event = _create_event(**kwargs) + return event, _get_transformed_event(event) + + +@ddt.ddt +class ForumThreadViewedEventTransformerTestCase(ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase): + """ + Test that the ForumThreadViewedEventTransformer transforms events correctly + and without raising exceptions. + + Because the events passed through the transformer can come from external + sources (e.g., a mobile app), we carefully test a myriad of cases, including + those with incomplete and malformed events. + """ + MODULESTORE = TEST_DATA_SPLIT_MODULESTORE + + CATEGORY_ID = 'i4x-edx-discussion-id' + CATEGORY_NAME = 'Discussion 1' + PARENT_CATEGORY_NAME = 'Chapter 1' + + TEAM_CATEGORY_ID = 'i4x-edx-team-discussion-id' + TEAM_CATEGORY_NAME = 'Team Chat' + TEAM_PARENT_CATEGORY_NAME = PARENT_CATEGORY_NAME + + DUMMY_CATEGORY_ID = 'i4x-edx-dummy-commentable-id' + DUMMY_THREAD_ID = 'dummy_thread_id' + + @mock.patch.dict("common.djangoapps.student.models.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + super().setUp() + self.course = CourseFactory.create( + org='TestX', + course='TR-101S', + run='Event_Transform_Test_Split', + default_store=ModuleStoreEnum.Type.split, + ) + self.student = UserFactory.create() + self.staff = UserFactory.create(is_staff=True) + UserBasedRole(user=self.staff, role=CourseStaffRole.ROLE).add_course(self.course.id) + CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) + self.category = BlockFactory.create( + parent_location=self.course.location, + category='discussion', + discussion_id=self.CATEGORY_ID, + discussion_category=self.PARENT_CATEGORY_NAME, + discussion_target=self.CATEGORY_NAME, + ) + self.team_category = BlockFactory.create( + parent_location=self.course.location, + category='discussion', + discussion_id=self.TEAM_CATEGORY_ID, + discussion_category=self.TEAM_PARENT_CATEGORY_NAME, + discussion_target=self.TEAM_CATEGORY_NAME, + ) + self.team = CourseTeamFactory.create( + name='Team 1', + course_id=self.course.id, + topic_id='arbitrary-topic-id', + discussion_topic_id=self.team_category.discussion_id, + ) + + def test_missing_context(self): + event = _create_event(include_context=False) + with pytest.raises(EventEmissionExit): + _get_transformed_event(event) + + def test_no_data(self): + event, event_trans = _create_and_transform_event() + event['name'] = 'edx.forum.thread.viewed' + event['event_type'] = event['name'] + event['event'] = {} + self.assertDictEqual(event_trans, event) + + def test_inner_context(self): + _, event_trans = _create_and_transform_event(inner_context={}) + assert 'context' not in event_trans['event'] + + def test_non_thread_view(self): + event = _create_event( + label='Forum: Create Thread', + course_id=self.course.id, + topic_id=self.DUMMY_CATEGORY_ID, + thread_id=self.DUMMY_THREAD_ID, + ) + with pytest.raises(EventEmissionExit): + _get_transformed_event(event) + + def test_bad_field_types(self): + event, event_trans = _create_and_transform_event( + course_id={}, + topic_id=3, + thread_id=object(), + action=3.14, + ) + event['name'] = 'edx.forum.thread.viewed' + event['event_type'] = event['name'] + self.assertDictEqual(event_trans, event) + + def test_bad_course_id(self): + event, event_trans = _create_and_transform_event(course_id='non-existent-course-id') + event_data = event_trans['event'] + assert 'category_id' not in event_data + assert 'category_name' not in event_data + assert 'url' not in event_data + assert 'user_forums_roles' not in event_data + assert 'user_course_roles' not in event_data + + def test_bad_username(self): + event, event_trans = _create_and_transform_event(username='non-existent-username') + event_data = event_trans['event'] + assert 'category_id' not in event_data + assert 'category_name' not in event_data + assert 'user_forums_roles' not in event_data + assert 'user_course_roles' not in event_data + + def test_bad_url(self): + event, event_trans = _create_and_transform_event( + course_id=self.course.id, + topic_id='malformed/commentable/id', + thread_id='malformed/thread/id', + ) + assert 'url' not in event_trans['event'] + + def test_renamed_fields(self): + AUTHOR = 'joe-the-plumber' + event, event_trans = _create_and_transform_event( + course_id=self.course.id, + topic_id=self.DUMMY_CATEGORY_ID, + thread_id=self.DUMMY_THREAD_ID, + author=AUTHOR, + ) + assert event_trans['event']['commentable_id'] == self.DUMMY_CATEGORY_ID + assert event_trans['event']['id'] == self.DUMMY_THREAD_ID + assert event_trans['event']['target_username'] == AUTHOR + + def test_titles(self): + + # No title + _, event_1_trans = _create_and_transform_event() + assert 'title' not in event_1_trans['event'] + assert 'title_truncated' not in event_1_trans['event'] + + # Short title + _, event_2_trans = _create_and_transform_event( + action='!', + ) + assert 'title' in event_2_trans['event'] + assert 'title_truncated' in event_2_trans['event'] + assert not event_2_trans['event']['title_truncated'] + + # Long title + _, event_3_trans = _create_and_transform_event( + action=('covfefe' * 200), + ) + assert 'title' in event_3_trans['event'] + assert 'title_truncated' in event_3_trans['event'] + assert event_3_trans['event']['title_truncated'] + + def test_urls(self): + commentable_id = self.DUMMY_CATEGORY_ID + thread_id = self.DUMMY_THREAD_ID + _, event_trans = _create_and_transform_event( + course_id=self.course.id, + topic_id=commentable_id, + thread_id=thread_id, + ) + expected_path = '/courses/{}/discussion/forum/{}/threads/{}'.format( + self.course.id, commentable_id, thread_id + ) + assert event_trans['event'].get('url').endswith(expected_path) + + def test_categories(self): + + # Bad category + _, event_trans_1 = _create_and_transform_event( + username=self.student.username, + course_id=self.course.id, + topic_id='non-existent-category-id', + ) + assert 'category_id' not in event_trans_1['event'] + assert 'category_name' not in event_trans_1['event'] + + # Good category + _, event_trans_2 = _create_and_transform_event( + username=self.student.username, + course_id=self.course.id, + topic_id=self.category.discussion_id, + ) + assert event_trans_2['event'].get('category_id') == self.category.discussion_id + full_category_name = f'{self.category.discussion_category} / {self.category.discussion_target}' + assert event_trans_2['event'].get('category_name') == full_category_name + + def test_roles(self): + + # No user + _, event_trans_1 = _create_and_transform_event( + course_id=self.course.id, + ) + assert 'user_forums_roles' not in event_trans_1['event'] + assert 'user_course_roles' not in event_trans_1['event'] + + # Student user + _, event_trans_2 = _create_and_transform_event( + course_id=self.course.id, + username=self.student.username, + ) + assert event_trans_2['event'].get('user_forums_roles') == [FORUM_ROLE_STUDENT] + assert event_trans_2['event'].get('user_course_roles') == [] + + # Course staff user + _, event_trans_3 = _create_and_transform_event( + course_id=self.course.id, + username=self.staff.username, + ) + assert event_trans_3['event'].get('user_forums_roles') == [] + assert event_trans_3['event'].get('user_course_roles') == [CourseStaffRole.ROLE] + + def test_teams(self): + + # No category + _, event_trans_1 = _create_and_transform_event( + course_id=self.course.id, + ) + assert 'team_id' not in event_trans_1 + + # Non-team category + _, event_trans_2 = _create_and_transform_event( + course_id=self.course.id, + topic_id=self.CATEGORY_ID, + ) + assert 'team_id' not in event_trans_2 + + # Team category + _, event_trans_3 = _create_and_transform_event( + course_id=self.course.id, + topic_id=self.TEAM_CATEGORY_ID, + ) + assert event_trans_3['event'].get('team_id') == self.team.team_id diff --git a/lms/djangoapps/discussion/django_comment_client/tests/group_id.py b/lms/djangoapps/discussion/django_comment_client/tests/group_id.py index 12af53942e..1de062d2b2 100644 --- a/lms/djangoapps/discussion/django_comment_client/tests/group_id.py +++ b/lms/djangoapps/discussion/django_comment_client/tests/group_id.py @@ -60,28 +60,27 @@ class CohortedTopicGroupIdTestMixin(GroupIdAssertionMixin): Provides test cases to verify that views pass the correct `group_id` to the comments service when requesting content in cohorted discussions. """ - def call_view(self, mock_is_forum_v2_enabled, mock_request, commentable_id, user, group_id, pass_group_id=True): + def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id=True): """ Call the view for the implementing test class, constructing a request from the parameters. """ pass # lint-amnesty, pylint: disable=unnecessary-pass - def test_cohorted_topic_student_without_group_id(self, mock_is_forum_v2_enabled, mock_request): - self.call_view(mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.student, '', pass_group_id=False) + def test_cohorted_topic_student_without_group_id(self, mock_request): + self.call_view(mock_request, "cohorted_topic", self.student, '', pass_group_id=False) self._assert_comments_service_called_with_group_id(mock_request, self.student_cohort.id) - def test_cohorted_topic_student_none_group_id(self, mock_is_forum_v2_enabled, mock_request): - self.call_view(mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.student, "") + def test_cohorted_topic_student_none_group_id(self, mock_request): + self.call_view(mock_request, "cohorted_topic", self.student, "") self._assert_comments_service_called_with_group_id(mock_request, self.student_cohort.id) - def test_cohorted_topic_student_with_own_group_id(self, mock_is_forum_v2_enabled, mock_request): - self.call_view(mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.student, self.student_cohort.id) + def test_cohorted_topic_student_with_own_group_id(self, mock_request): + self.call_view(mock_request, "cohorted_topic", self.student, self.student_cohort.id) self._assert_comments_service_called_with_group_id(mock_request, self.student_cohort.id) - def test_cohorted_topic_student_with_other_group_id(self, mock_is_forum_v2_enabled, mock_request): + def test_cohorted_topic_student_with_other_group_id(self, mock_request): self.call_view( - mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.student, @@ -89,9 +88,8 @@ class CohortedTopicGroupIdTestMixin(GroupIdAssertionMixin): ) self._assert_comments_service_called_with_group_id(mock_request, self.student_cohort.id) - def test_cohorted_topic_moderator_without_group_id(self, mock_is_forum_v2_enabled, mock_request): + def test_cohorted_topic_moderator_without_group_id(self, mock_request): self.call_view( - mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.moderator, @@ -100,13 +98,12 @@ class CohortedTopicGroupIdTestMixin(GroupIdAssertionMixin): ) self._assert_comments_service_called_without_group_id(mock_request) - def test_cohorted_topic_moderator_none_group_id(self, mock_is_forum_v2_enabled, mock_request): - self.call_view(mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.moderator, "") + def test_cohorted_topic_moderator_none_group_id(self, mock_request): + self.call_view(mock_request, "cohorted_topic", self.moderator, "") self._assert_comments_service_called_without_group_id(mock_request) - def test_cohorted_topic_moderator_with_own_group_id(self, mock_is_forum_v2_enabled, mock_request): + def test_cohorted_topic_moderator_with_own_group_id(self, mock_request): self.call_view( - mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.moderator, @@ -114,9 +111,8 @@ class CohortedTopicGroupIdTestMixin(GroupIdAssertionMixin): ) self._assert_comments_service_called_with_group_id(mock_request, self.moderator_cohort.id) - def test_cohorted_topic_moderator_with_other_group_id(self, mock_is_forum_v2_enabled, mock_request): + def test_cohorted_topic_moderator_with_other_group_id(self, mock_request): self.call_view( - mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.moderator, @@ -124,12 +120,12 @@ class CohortedTopicGroupIdTestMixin(GroupIdAssertionMixin): ) self._assert_comments_service_called_with_group_id(mock_request, self.student_cohort.id) - def test_cohorted_topic_moderator_with_invalid_group_id(self, mock_is_forum_v2_enabled, mock_request): + def test_cohorted_topic_moderator_with_invalid_group_id(self, mock_request): invalid_id = self.student_cohort.id + self.moderator_cohort.id - response = self.call_view(mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.moderator, invalid_id) # lint-amnesty, pylint: disable=assignment-from-no-return + response = self.call_view(mock_request, "cohorted_topic", self.moderator, invalid_id) # lint-amnesty, pylint: disable=assignment-from-no-return assert response.status_code == 500 - def test_cohorted_topic_enrollment_track_invalid_group_id(self, mock_is_forum_v2_enabled, mock_request): + def test_cohorted_topic_enrollment_track_invalid_group_id(self, mock_request): CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.AUDIT) CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.VERIFIED) discussion_settings = CourseDiscussionSettings.get(self.course.id) @@ -140,7 +136,7 @@ class CohortedTopicGroupIdTestMixin(GroupIdAssertionMixin): }) invalid_id = -1000 - response = self.call_view(mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.moderator, invalid_id) # lint-amnesty, pylint: disable=assignment-from-no-return + response = self.call_view(mock_request, "cohorted_topic", self.moderator, invalid_id) # lint-amnesty, pylint: disable=assignment-from-no-return assert response.status_code == 500 @@ -149,16 +145,15 @@ class NonCohortedTopicGroupIdTestMixin(GroupIdAssertionMixin): Provides test cases to verify that views pass the correct `group_id` to the comments service when requesting content in non-cohorted discussions. """ - def call_view(self, mock_is_forum_v2_enabled, mock_request, commentable_id, user, group_id, pass_group_id=True): + def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id=True): """ Call the view for the implementing test class, constructing a request from the parameters. """ pass # lint-amnesty, pylint: disable=unnecessary-pass - def test_non_cohorted_topic_student_without_group_id(self, mock_is_forum_v2_enabled, mock_request): + def test_non_cohorted_topic_student_without_group_id(self, mock_request): self.call_view( - mock_is_forum_v2_enabled, mock_request, "non_cohorted_topic", self.student, @@ -167,13 +162,12 @@ class NonCohortedTopicGroupIdTestMixin(GroupIdAssertionMixin): ) self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_student_none_group_id(self, mock_is_forum_v2_enabled, mock_request): - self.call_view(mock_is_forum_v2_enabled, mock_request, "non_cohorted_topic", self.student, '') + def test_non_cohorted_topic_student_none_group_id(self, mock_request): + self.call_view(mock_request, "non_cohorted_topic", self.student, '') self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_student_with_own_group_id(self, mock_is_forum_v2_enabled, mock_request): + def test_non_cohorted_topic_student_with_own_group_id(self, mock_request): self.call_view( - mock_is_forum_v2_enabled, mock_request, "non_cohorted_topic", self.student, @@ -181,9 +175,8 @@ class NonCohortedTopicGroupIdTestMixin(GroupIdAssertionMixin): ) self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_student_with_other_group_id(self, mock_is_forum_v2_enabled, mock_request): + def test_non_cohorted_topic_student_with_other_group_id(self, mock_request): self.call_view( - mock_is_forum_v2_enabled, mock_request, "non_cohorted_topic", self.student, @@ -191,9 +184,8 @@ class NonCohortedTopicGroupIdTestMixin(GroupIdAssertionMixin): ) self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_moderator_without_group_id(self, mock_is_forum_v2_enabled, mock_request): + def test_non_cohorted_topic_moderator_without_group_id(self, mock_request): self.call_view( - mock_is_forum_v2_enabled, mock_request, "non_cohorted_topic", self.moderator, @@ -202,13 +194,12 @@ class NonCohortedTopicGroupIdTestMixin(GroupIdAssertionMixin): ) self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_moderator_none_group_id(self, mock_is_forum_v2_enabled, mock_request): - self.call_view(mock_is_forum_v2_enabled, mock_request, "non_cohorted_topic", self.moderator, '') + def test_non_cohorted_topic_moderator_none_group_id(self, mock_request): + self.call_view(mock_request, "non_cohorted_topic", self.moderator, '') self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_moderator_with_own_group_id(self, mock_is_forum_v2_enabled, mock_request): + def test_non_cohorted_topic_moderator_with_own_group_id(self, mock_request): self.call_view( - mock_is_forum_v2_enabled, mock_request, "non_cohorted_topic", self.moderator, @@ -216,9 +207,8 @@ class NonCohortedTopicGroupIdTestMixin(GroupIdAssertionMixin): ) self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_moderator_with_other_group_id(self, mock_is_forum_v2_enabled, mock_request): + def test_non_cohorted_topic_moderator_with_other_group_id(self, mock_request): self.call_view( - mock_is_forum_v2_enabled, mock_request, "non_cohorted_topic", self.moderator, @@ -226,19 +216,19 @@ class NonCohortedTopicGroupIdTestMixin(GroupIdAssertionMixin): ) self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_moderator_with_invalid_group_id(self, mock_is_forum_v2_enabled, mock_request): + def test_non_cohorted_topic_moderator_with_invalid_group_id(self, mock_request): invalid_id = self.student_cohort.id + self.moderator_cohort.id - self.call_view(mock_is_forum_v2_enabled, mock_request, "non_cohorted_topic", self.moderator, invalid_id) + self.call_view(mock_request, "non_cohorted_topic", self.moderator, invalid_id) self._assert_comments_service_called_without_group_id(mock_request) - def test_team_discussion_id_not_cohorted(self, mock_is_forum_v2_enabled, mock_request): + def test_team_discussion_id_not_cohorted(self, mock_request): team = CourseTeamFactory( course_id=self.course.id, topic_id='topic-id' ) team.add_user(self.student) - self.call_view(mock_is_forum_v2_enabled, mock_request, team.discussion_topic_id, self.student, '') + self.call_view(mock_request, team.discussion_topic_id, self.student, '') self._assert_comments_service_called_without_group_id(mock_request) diff --git a/lms/djangoapps/discussion/django_comment_client/tests/mixins.py b/lms/djangoapps/discussion/django_comment_client/tests/mixins.py index cdc8fbad4b..d811bcadef 100644 --- a/lms/djangoapps/discussion/django_comment_client/tests/mixins.py +++ b/lms/djangoapps/discussion/django_comment_client/tests/mixins.py @@ -17,13 +17,6 @@ class MockForumApiMixin: """ cls.mock_forum_api = mock.Mock() - # TODO: Remove this after moving all APIs - cls.flag_v2_patcher = mock.patch( - "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled" - ) - cls.mock_enable_forum_v2 = cls.flag_v2_patcher.start() - cls.mock_enable_forum_v2.return_value = True - patch_targets = [ "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api", "openedx.core.djangoapps.django_comment_common.comment_client.comment.forum_api", @@ -41,8 +34,6 @@ class MockForumApiMixin: @classmethod def disposeForumMocks(cls): """Stop patches after tests complete.""" - cls.flag_v2_patcher.stop() - for patcher in cls.forum_api_patchers: patcher.stop() diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api.py b/lms/djangoapps/discussion/rest_api/tests/test_api.py index d23a6a06b1..116a6f6013 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_api.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_api.py @@ -2,661 +2,52 @@ Tests for Discussion API internal interface """ -from datetime import datetime, timedelta from unittest import mock -from urllib.parse import urlencode, urlunparse import ddt -import httpretty import pytest -from django.test import override_settings from django.contrib.auth import get_user_model from django.test.client import RequestFactory from opaque_keys.edx.keys import CourseKey -from opaque_keys.edx.locator import CourseLocator -from pytz import UTC -from xmodule.modulestore.django import modulestore -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory -from xmodule.partitions.partitions import Group, UserPartition +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory from common.djangoapps.student.tests.factories import ( - BetaTesterFactory, - CourseEnrollmentFactory, - StaffFactory, 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.api import ( - get_course, - get_course_topics, - get_user_comments, -) -from lms.djangoapps.discussion.rest_api.exceptions import ( - DiscussionDisabledError, -) +from lms.djangoapps.discussion.rest_api.api import get_user_comments from lms.djangoapps.discussion.rest_api.tests.utils import ( - CommentsServiceMockMixin, + ForumMockUtilsMixin, make_minimal_cs_comment, ) -from openedx.core.djangoapps.course_groups.models import CourseUserGroupPartitionGroup -from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory -from openedx.core.djangoapps.django_comment_common.models import ( - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_COMMUNITY_TA, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_STUDENT, - Role -) from openedx.core.lib.exceptions import CourseNotFoundError, PageNotFoundError User = get_user_model() -def _remove_discussion_tab(course, user_id): - """ - Remove the discussion tab for the course. - - user_id is passed to the modulestore as the editor of the xblock. - """ - course.tabs = [tab for tab in course.tabs if not tab.type == 'discussion'] - modulestore().update_item(course, user_id) - - -def _discussion_disabled_course_for(user): - """ - Create and return a course with discussions disabled. - - The user passed in will be enrolled in the course. - """ - course_with_disabled_forums = CourseFactory.create() - CourseEnrollmentFactory.create(user=user, course_id=course_with_disabled_forums.id) - _remove_discussion_tab(course_with_disabled_forums, user.id) - - return course_with_disabled_forums - - -def _assign_role_to_user(user, course_id, role): - """ - Unset the blackout period for course discussions. - - Arguments: - user: User to assign role to - course_id: Course id of the course user will be assigned role in - role: Role assigned to user for course - """ - role = Role.objects.create(name=role, course_id=course_id) - role.users.set([user]) - - -@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"}) -@ddt.ddt -class GetCourseTest(ForumsEnableMixin, UrlResetMixin, SharedModuleStoreTestCase): - """Test for get_course""" - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.course = CourseFactory.create(org="x", course="y", run="z") - - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - super().setUp() - self.user = UserFactory.create() - CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) - self.request = RequestFactory().get("/dummy") - self.request.user = self.user - - def test_nonexistent_course(self): - with pytest.raises(CourseNotFoundError): - get_course(self.request, CourseLocator.from_string("course-v1:non+existent+course")) - - def test_not_enrolled(self): - unenrolled_user = UserFactory.create() - self.request.user = unenrolled_user - with pytest.raises(CourseNotFoundError): - get_course(self.request, self.course.id) - - def test_discussions_disabled(self): - with pytest.raises(DiscussionDisabledError): - get_course(self.request, _discussion_disabled_course_for(self.user).id) - - def test_discussions_disabled_v2(self): - data = get_course(self.request, _discussion_disabled_course_for(self.user).id, False) - assert data['show_discussions'] is False - - def test_basic(self): - assert get_course(self.request, self.course.id) == { - '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', - 'allow_anonymous': True, - 'allow_anonymous_to_peers': False, - 'enable_in_context': True, - 'group_at_subsection': False, - 'provider': 'legacy', - "has_bulk_delete_privileges": False, - 'has_moderation_privileges': False, - "is_course_staff": False, - "is_course_admin": 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, - 'is_notify_all_learners_enabled': False, - 'captcha_settings': { - 'enabled': False, - 'site_key': None, - }, - "is_email_verified": True, - "only_verified_users_can_post": False, - "content_creation_rate_limited": False - } - - @ddt.data( - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - ) - def test_privileged_roles(self, role): - """ - Test that the api returns the correct roles and privileges. - """ - _assign_role_to_user(user=self.user, course_id=self.course.id, role=role) - course_meta = get_course(self.request, self.course.id) - assert course_meta["has_moderation_privileges"] - assert course_meta["user_roles"] == {FORUM_ROLE_STUDENT} | {role} - - @ddt.ddt @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class GetCourseTestBlackouts(ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase): - """ - Tests of get_course for courses that have blackout dates. - """ - @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") - self.user = UserFactory.create() - CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) - self.request = RequestFactory().get("/dummy") - self.request.user = self.user - - def test_blackout(self): - # A variety of formats is accepted - self.course.discussion_blackouts = [ - ["2015-06-09T00:00:00Z", "6-10-15"], - [1433980800000, datetime(2015, 6, 12, tzinfo=UTC)], - ] - self.update_course(self.course, self.user.id) - result = get_course(self.request, self.course.id) - assert result['blackouts'] == [ - {'start': '2015-06-09T00:00:00Z', 'end': '2015-06-10T00:00:00Z'}, - {'start': '2015-06-11T00:00:00Z', 'end': '2015-06-12T00:00:00Z'} - ] - - @ddt.data(None, "not a datetime", "2015", []) - def test_blackout_errors(self, bad_value): - self.course.discussion_blackouts = [ - [bad_value, "2015-06-09T00:00:00Z"], - ["2015-06-10T00:00:00Z", "2015-06-11T00:00:00Z"], - ] - modulestore().update_item(self.course, self.user.id) - result = get_course(self.request, self.course.id) - assert result['blackouts'] == [] - - -@mock.patch.dict("django.conf.settings.FEATURES", {"DISABLE_START_DATES": False}) -@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class GetCourseTopicsTest(CommentsServiceMockMixin, ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase): - """Test for get_course_topics""" - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - httpretty.reset() - httpretty.enable() - self.addCleanup(httpretty.reset) - self.addCleanup(httpretty.disable) - super().setUp() - self.maxDiff = None # pylint: disable=invalid-name - self.partition = UserPartition( - 0, - "partition", - "Test Partition", - [Group(0, "Cohort A"), Group(1, "Cohort B")], - scheme_id="cohort" - ) - self.course = CourseFactory.create( - org="x", - course="y", - run="z", - start=datetime.now(UTC), - discussion_topics={"Test Topic": {"id": "non-courseware-topic-id"}}, - user_partitions=[self.partition], - cohort_config={"cohorted": True}, - days_early_for_beta=3 - ) - self.user = UserFactory.create() - self.request = RequestFactory().get("/dummy") - self.request.user = self.user - CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) - self.thread_counts_map = { - "courseware-1": {"discussion": 2, "question": 3}, - "courseware-2": {"discussion": 4, "question": 5}, - "courseware-3": {"discussion": 7, "question": 2}, - } - self.register_get_course_commentable_counts_response(self.course.id, self.thread_counts_map) - - def make_discussion_xblock(self, topic_id, category, subcategory, **kwargs): - """ - Build a discussion xblock in self.course. - """ - BlockFactory.create( - parent_location=self.course.location, - category="discussion", - discussion_id=topic_id, - discussion_category=category, - discussion_target=subcategory, - **kwargs - ) - - def get_thread_list_url(self, topic_id_list): - """ - Returns the URL for the thread_list_url field, given a list of topic_ids - """ - path = "http://testserver/api/discussion/v1/threads/" - topic_ids_to_query = [("topic_id", topic_id) for topic_id in topic_id_list] - query_list = [("course_id", str(self.course.id))] + topic_ids_to_query - return urlunparse(("", "", path, "", urlencode(query_list), "")) - - def get_course_topics(self): - """ - Get course topics for self.course, using the given user or self.user if - not provided, and generating absolute URIs with a test scheme/host. - """ - return get_course_topics(self.request, self.course.id) - - def make_expected_tree(self, topic_id, name, children=None): - """ - Build an expected result tree given a topic id, display name, and - children - """ - topic_id_list = [topic_id] if topic_id else [child["id"] for child in children] - children = children or [] - thread_counts = self.thread_counts_map.get(topic_id, {"discussion": 0, "question": 0}) - node = { - "id": topic_id, - "name": name, - "children": children, - "thread_list_url": self.get_thread_list_url(topic_id_list), - "thread_counts": thread_counts if not children else None - } - - return node - - def test_nonexistent_course(self): - with pytest.raises(CourseNotFoundError): - get_course_topics(self.request, CourseLocator.from_string("course-v1:non+existent+course")) - - def test_not_enrolled(self): - unenrolled_user = UserFactory.create() - self.request.user = unenrolled_user - with pytest.raises(CourseNotFoundError): - self.get_course_topics() - - def test_discussions_disabled(self): - _remove_discussion_tab(self.course, self.user.id) - with pytest.raises(DiscussionDisabledError): - self.get_course_topics() - - def test_without_courseware(self): - actual = self.get_course_topics() - expected = { - "courseware_topics": [], - "non_courseware_topics": [ - self.make_expected_tree("non-courseware-topic-id", "Test Topic") - ], - } - assert actual == expected - - def test_with_courseware(self): - self.make_discussion_xblock("courseware-topic-id", "Foo", "Bar") - actual = self.get_course_topics() - expected = { - "courseware_topics": [ - self.make_expected_tree( - None, - "Foo", - [self.make_expected_tree("courseware-topic-id", "Bar")] - ), - ], - "non_courseware_topics": [ - self.make_expected_tree("non-courseware-topic-id", "Test Topic") - ], - } - assert actual == expected - - def test_many(self): - with self.store.bulk_operations(self.course.id, emit_signals=False): - self.course.discussion_topics = { - "A": {"id": "non-courseware-1"}, - "B": {"id": "non-courseware-2"}, - } - self.store.update_item(self.course, self.user.id) - self.make_discussion_xblock("courseware-1", "Week 1", "1") - self.make_discussion_xblock("courseware-2", "Week 1", "2") - self.make_discussion_xblock("courseware-3", "Week 10", "1") - self.make_discussion_xblock("courseware-4", "Week 10", "2") - self.make_discussion_xblock("courseware-5", "Week 9", "1") - actual = self.get_course_topics() - expected = { - "courseware_topics": [ - self.make_expected_tree( - None, - "Week 1", - [ - self.make_expected_tree("courseware-1", "1"), - self.make_expected_tree("courseware-2", "2"), - ] - ), - self.make_expected_tree( - None, - "Week 9", - [self.make_expected_tree("courseware-5", "1")] - ), - self.make_expected_tree( - None, - "Week 10", - [ - self.make_expected_tree("courseware-3", "1"), - self.make_expected_tree("courseware-4", "2"), - ] - ), - ], - "non_courseware_topics": [ - self.make_expected_tree("non-courseware-1", "A"), - self.make_expected_tree("non-courseware-2", "B"), - ], - } - assert actual == expected - - def test_sort_key_doesnot_work(self): - """ - Test to check that providing sort_key doesn't change the sort order - """ - with self.store.bulk_operations(self.course.id, emit_signals=False): - self.course.discussion_topics = { - "W": {"id": "non-courseware-1", "sort_key": "Z"}, - "X": {"id": "non-courseware-2"}, - "Y": {"id": "non-courseware-3", "sort_key": "Y"}, - "Z": {"id": "non-courseware-4", "sort_key": "W"}, - } - self.store.update_item(self.course, self.user.id) - self.make_discussion_xblock("courseware-1", "First", "A", sort_key="B") - self.make_discussion_xblock("courseware-2", "First", "B", sort_key="D") - self.make_discussion_xblock("courseware-3", "First", "C", sort_key="E") - self.make_discussion_xblock("courseware-4", "Second", "A", sort_key="A") - self.make_discussion_xblock("courseware-5", "Second", "B", sort_key="B") - self.make_discussion_xblock("courseware-6", "Second", "C") - self.make_discussion_xblock("courseware-7", "Second", "D", sort_key="D") - - actual = self.get_course_topics() - expected = { - "courseware_topics": [ - self.make_expected_tree( - None, - "First", - [ - self.make_expected_tree("courseware-1", "A"), - self.make_expected_tree("courseware-2", "B"), - self.make_expected_tree("courseware-3", "C"), - ] - ), - self.make_expected_tree( - None, - "Second", - [ - self.make_expected_tree("courseware-4", "A"), - self.make_expected_tree("courseware-5", "B"), - self.make_expected_tree("courseware-6", "C"), - self.make_expected_tree("courseware-7", "D"), - ] - ), - ], - "non_courseware_topics": [ - self.make_expected_tree("non-courseware-1", "W"), - self.make_expected_tree("non-courseware-2", "X"), - self.make_expected_tree("non-courseware-3", "Y"), - self.make_expected_tree("non-courseware-4", "Z"), - ], - } - assert actual == expected - - def test_access_control(self): - """ - Test that only topics that a user has access to are returned. The - ways in which a user may not have access are: - - * Block is visible to staff only - * Block is accessible only to a group the user is not in - - Also, there is a case that ensures that a category with no accessible - subcategories does not appear in the result. - """ - beta_tester = BetaTesterFactory.create(course_key=self.course.id) - CourseEnrollmentFactory.create(user=beta_tester, course_id=self.course.id) - staff = StaffFactory.create(course_key=self.course.id) - for user, group_idx in [(self.user, 0), (beta_tester, 1)]: - cohort = CohortFactory.create( - course_id=self.course.id, - name=self.partition.groups[group_idx].name, - users=[user] - ) - CourseUserGroupPartitionGroup.objects.create( - course_user_group=cohort, - partition_id=self.partition.id, - group_id=self.partition.groups[group_idx].id - ) - - with self.store.bulk_operations(self.course.id, emit_signals=False): - self.store.update_item(self.course, self.user.id) - self.make_discussion_xblock( - "courseware-2", - "First", - "Cohort A", - group_access={self.partition.id: [self.partition.groups[0].id]} - ) - self.make_discussion_xblock( - "courseware-3", - "First", - "Cohort B", - group_access={self.partition.id: [self.partition.groups[1].id]} - ) - self.make_discussion_xblock("courseware-1", "First", "Everybody") - self.make_discussion_xblock( - "courseware-5", - "Second", - "Future Start Date", - start=datetime.now(UTC) + timedelta(days=1) - ) - self.make_discussion_xblock("courseware-4", "Second", "Staff Only", visible_to_staff_only=True) - - student_actual = self.get_course_topics() - student_expected = { - "courseware_topics": [ - self.make_expected_tree( - None, - "First", - [ - self.make_expected_tree("courseware-2", "Cohort A"), - self.make_expected_tree("courseware-1", "Everybody"), - ] - ), - ], - "non_courseware_topics": [ - self.make_expected_tree("non-courseware-topic-id", "Test Topic"), - ], - } - assert student_actual == student_expected - self.request.user = beta_tester - beta_actual = self.get_course_topics() - beta_expected = { - "courseware_topics": [ - self.make_expected_tree( - None, - "First", - [ - self.make_expected_tree("courseware-3", "Cohort B"), - self.make_expected_tree("courseware-1", "Everybody"), - ] - ) - ], - "non_courseware_topics": [ - self.make_expected_tree("non-courseware-topic-id", "Test Topic"), - ], - } - assert beta_actual == beta_expected - - self.request.user = staff - staff_actual = self.get_course_topics() - staff_expected = { - "courseware_topics": [ - self.make_expected_tree( - None, - "First", - [ - self.make_expected_tree("courseware-2", "Cohort A"), - self.make_expected_tree("courseware-3", "Cohort B"), - self.make_expected_tree("courseware-1", "Everybody"), - ] - ), - self.make_expected_tree( - None, - "Second", - [ - self.make_expected_tree("courseware-4", "Staff Only"), - ] - ), - ], - "non_courseware_topics": [ - self.make_expected_tree("non-courseware-topic-id", "Test Topic"), - ], - } - assert staff_actual == staff_expected - - def test_un_released_discussion_topic(self): - """ - Test discussion topics that have not yet started - """ - staff = StaffFactory.create(course_key=self.course.id) - with self.store.bulk_operations(self.course.id, emit_signals=False): - self.store.update_item(self.course, self.user.id) - self.make_discussion_xblock( - "courseware-2", - "First", - "Released", - start=datetime.now(UTC) - timedelta(days=1) - ) - self.make_discussion_xblock( - "courseware-3", - "First", - "Future release", - start=datetime.now(UTC) + timedelta(days=1) - ) - - self.request.user = staff - staff_actual = self.get_course_topics() - staff_expected = { - "courseware_topics": [ - self.make_expected_tree( - None, - "First", - [ - self.make_expected_tree("courseware-2", "Released"), - ] - ), - ], - "non_courseware_topics": [ - self.make_expected_tree("non-courseware-topic-id", "Test Topic"), - ], - } - assert staff_actual == staff_expected - - def test_discussion_topic(self): - """ - Tests discussion topic details against a requested topic id - """ - topic_id_1 = "topic_id_1" - topic_id_2 = "topic_id_2" - self.make_discussion_xblock(topic_id_1, "test_category_1", "test_target_1") - self.make_discussion_xblock(topic_id_2, "test_category_2", "test_target_2") - actual = get_course_topics(self.request, self.course.id, {"topic_id_1", "topic_id_2"}) - assert actual == { - 'non_courseware_topics': [], - 'courseware_topics': [ - { - 'children': [ - { - 'children': [], - 'id': 'topic_id_1', - 'thread_list_url': 'http://testserver/api/discussion/v1/threads/' - '?course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1', - 'name': 'test_target_1', - 'thread_counts': {'discussion': 0, 'question': 0}, - }, - ], - 'id': None, - 'thread_list_url': 'http://testserver/api/discussion/v1/threads/' - '?course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1', - 'name': 'test_category_1', - 'thread_counts': None, - }, - { - 'children': [ - { - 'children': [], - 'id': 'topic_id_2', - 'thread_list_url': 'http://testserver/api/discussion/v1/threads/' - '?course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2', - 'name': 'test_target_2', - 'thread_counts': {'discussion': 0, 'question': 0}, - } - ], - 'id': None, - 'thread_list_url': 'http://testserver/api/discussion/v1/threads/' - '?course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2', - 'name': 'test_category_2', - 'thread_counts': None, - } - ] - } - - -@ddt.ddt -@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class GetUserCommentsTest(ForumsEnableMixin, CommentsServiceMockMixin, SharedModuleStoreTestCase): +class GetUserCommentsTest(ForumsEnableMixin, ForumMockUtilsMixin, SharedModuleStoreTestCase): """ Tests for get_user_comments. """ + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() - httpretty.reset() - httpretty.enable() - self.addCleanup(httpretty.reset) - self.addCleanup(httpretty.disable) - self.course = CourseFactory.create() # create staff user so that we don't need to worry about @@ -712,10 +103,10 @@ class GetUserCommentsTest(ForumsEnableMixin, CommentsServiceMockMixin, SharedMod if page in (1, 2): assert response.data["pagination"]["next"] is not None - assert f"page={page + 1}" in response.data["pagination"]["next"] + assert f"page={page+1}" in response.data["pagination"]["next"] if page in (2, 3): assert response.data["pagination"]["previous"] is not None - assert f"page={page - 1}" in response.data["pagination"]["previous"] + assert f"page={page-1}" in response.data["pagination"]["previous"] if page == 1: assert response.data["pagination"]["previous"] is None if page == 3: diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py index f5b15b9056..8a9ecbb5e1 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py @@ -1,9 +1,10 @@ -# pylint: skip-file +# pylint: disable=unused-import """ Tests for the internal interface of the Discussion API (rest_api/api.py). This module directly tests the internal API functions of the Discussion API, such as create_thread, -create_comment, update_thread, update_comment, and related helpers, by invoking them with various data and request objects. +create_comment, update_thread, update_comment, and related helpers, by invoking them with various data and +request objects. """ import itertools @@ -1256,7 +1257,7 @@ class UpdateThreadTest( "thread_id": "test_thread", "action": "unflag", "user_id": "1", - "update_all": True if remove_all else False, + "update_all": bool(remove_all), "course_id": str(self.course.id), } @@ -1937,7 +1938,7 @@ class UpdateCommentTest( "comment_id": "test_comment", "action": "unflag", "user_id": "1", - "update_all": True if remove_all else False, + "update_all": bool(remove_all), "course_id": str(self.course.id), } self.check_mock_called_with("update_comment_flag", -1, **params) @@ -3315,7 +3316,7 @@ class GetThreadListTest( ) def test_following(self): - self.register_subscribed_threads_response(self.user, [], page=1, num_pages=0) + self.register_subscribed_threads_response(self.user, [], page=1, num_pages=1) result = get_thread_list( self.request, self.course.id, @@ -3325,7 +3326,7 @@ class GetThreadListTest( ).data expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None + results=[], count=0, num_pages=1, next_link=None, previous_link=None ) expected_result.update({"text_search_rewrite": None}) assert result == expected_result @@ -4230,3 +4231,561 @@ class CourseTopicsV2Test(ModuleStoreTestCase): user=self.user, order_by=TopicOrdering.ACTIVITY, ) + + +@mock.patch.dict("django.conf.settings.FEATURES", {"DISABLE_START_DATES": False}) +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class GetCourseTopicsTest(ForumMockUtilsMixin, ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase): + """Test for get_course_topics""" + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + httpretty.reset() + httpretty.enable() + self.addCleanup(httpretty.reset) + self.addCleanup(httpretty.disable) + super().setUp() + self.maxDiff = None # pylint: disable=invalid-name + self.partition = UserPartition( + 0, + "partition", + "Test Partition", + [Group(0, "Cohort A"), Group(1, "Cohort B")], + scheme_id="cohort" + ) + self.course = CourseFactory.create( + org="x", + course="y", + run="z", + start=datetime.now(UTC), + discussion_topics={"Test Topic": {"id": "non-courseware-topic-id"}}, + user_partitions=[self.partition], + cohort_config={"cohorted": True}, + days_early_for_beta=3 + ) + self.user = UserFactory.create() + self.request = RequestFactory().get("/dummy") + self.request.user = self.user + CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) + self.thread_counts_map = { + "courseware-1": {"discussion": 2, "question": 3}, + "courseware-2": {"discussion": 4, "question": 5}, + "courseware-3": {"discussion": 7, "question": 2}, + } + self.register_get_course_commentable_counts_response(self.course.id, self.thread_counts_map) + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() + + def make_discussion_xblock(self, topic_id, category, subcategory, **kwargs): + """ + Build a discussion xblock in self.course. + """ + BlockFactory.create( + parent_location=self.course.location, + category="discussion", + discussion_id=topic_id, + discussion_category=category, + discussion_target=subcategory, + **kwargs + ) + + def get_thread_list_url(self, topic_id_list): + """ + Returns the URL for the thread_list_url field, given a list of topic_ids + """ + path = "http://testserver/api/discussion/v1/threads/" + topic_ids_to_query = [("topic_id", topic_id) for topic_id in topic_id_list] + query_list = [("course_id", str(self.course.id))] + topic_ids_to_query + return urlunparse(("", "", path, "", urlencode(query_list), "")) + + def get_course_topics(self): + """ + Get course topics for self.course, using the given user or self.user if + not provided, and generating absolute URIs with a test scheme/host. + """ + return get_course_topics(self.request, self.course.id) + + def make_expected_tree(self, topic_id, name, children=None): + """ + Build an expected result tree given a topic id, display name, and + children + """ + topic_id_list = [topic_id] if topic_id else [child["id"] for child in children] + children = children or [] + thread_counts = self.thread_counts_map.get(topic_id, {"discussion": 0, "question": 0}) + node = { + "id": topic_id, + "name": name, + "children": children, + "thread_list_url": self.get_thread_list_url(topic_id_list), + "thread_counts": thread_counts if not children else None + } + + return node + + def test_nonexistent_course(self): + with pytest.raises(CourseNotFoundError): + get_course_topics(self.request, CourseLocator.from_string("course-v1:non+existent+course")) + + def test_not_enrolled(self): + unenrolled_user = UserFactory.create() + self.request.user = unenrolled_user + with pytest.raises(CourseNotFoundError): + self.get_course_topics() + + def test_discussions_disabled(self): + _remove_discussion_tab(self.course, self.user.id) + with pytest.raises(DiscussionDisabledError): + self.get_course_topics() + + def test_without_courseware(self): + actual = self.get_course_topics() + expected = { + "courseware_topics": [], + "non_courseware_topics": [ + self.make_expected_tree("non-courseware-topic-id", "Test Topic") + ], + } + assert actual == expected + + def test_with_courseware(self): + self.make_discussion_xblock("courseware-topic-id", "Foo", "Bar") + actual = self.get_course_topics() + expected = { + "courseware_topics": [ + self.make_expected_tree( + None, + "Foo", + [self.make_expected_tree("courseware-topic-id", "Bar")] + ), + ], + "non_courseware_topics": [ + self.make_expected_tree("non-courseware-topic-id", "Test Topic") + ], + } + assert actual == expected + + def test_many(self): + with self.store.bulk_operations(self.course.id, emit_signals=False): + self.course.discussion_topics = { + "A": {"id": "non-courseware-1"}, + "B": {"id": "non-courseware-2"}, + } + self.store.update_item(self.course, self.user.id) + self.make_discussion_xblock("courseware-1", "Week 1", "1") + self.make_discussion_xblock("courseware-2", "Week 1", "2") + self.make_discussion_xblock("courseware-3", "Week 10", "1") + self.make_discussion_xblock("courseware-4", "Week 10", "2") + self.make_discussion_xblock("courseware-5", "Week 9", "1") + actual = self.get_course_topics() + expected = { + "courseware_topics": [ + self.make_expected_tree( + None, + "Week 1", + [ + self.make_expected_tree("courseware-1", "1"), + self.make_expected_tree("courseware-2", "2"), + ] + ), + self.make_expected_tree( + None, + "Week 9", + [self.make_expected_tree("courseware-5", "1")] + ), + self.make_expected_tree( + None, + "Week 10", + [ + self.make_expected_tree("courseware-3", "1"), + self.make_expected_tree("courseware-4", "2"), + ] + ), + ], + "non_courseware_topics": [ + self.make_expected_tree("non-courseware-1", "A"), + self.make_expected_tree("non-courseware-2", "B"), + ], + } + assert actual == expected + + def test_sort_key_doesnot_work(self): + """ + Test to check that providing sort_key doesn't change the sort order + """ + with self.store.bulk_operations(self.course.id, emit_signals=False): + self.course.discussion_topics = { + "W": {"id": "non-courseware-1", "sort_key": "Z"}, + "X": {"id": "non-courseware-2"}, + "Y": {"id": "non-courseware-3", "sort_key": "Y"}, + "Z": {"id": "non-courseware-4", "sort_key": "W"}, + } + self.store.update_item(self.course, self.user.id) + self.make_discussion_xblock("courseware-1", "First", "A", sort_key="B") + self.make_discussion_xblock("courseware-2", "First", "B", sort_key="D") + self.make_discussion_xblock("courseware-3", "First", "C", sort_key="E") + self.make_discussion_xblock("courseware-4", "Second", "A", sort_key="A") + self.make_discussion_xblock("courseware-5", "Second", "B", sort_key="B") + self.make_discussion_xblock("courseware-6", "Second", "C") + self.make_discussion_xblock("courseware-7", "Second", "D", sort_key="D") + + actual = self.get_course_topics() + expected = { + "courseware_topics": [ + self.make_expected_tree( + None, + "First", + [ + self.make_expected_tree("courseware-1", "A"), + self.make_expected_tree("courseware-2", "B"), + self.make_expected_tree("courseware-3", "C"), + ] + ), + self.make_expected_tree( + None, + "Second", + [ + self.make_expected_tree("courseware-4", "A"), + self.make_expected_tree("courseware-5", "B"), + self.make_expected_tree("courseware-6", "C"), + self.make_expected_tree("courseware-7", "D"), + ] + ), + ], + "non_courseware_topics": [ + self.make_expected_tree("non-courseware-1", "W"), + self.make_expected_tree("non-courseware-2", "X"), + self.make_expected_tree("non-courseware-3", "Y"), + self.make_expected_tree("non-courseware-4", "Z"), + ], + } + assert actual == expected + + def test_access_control(self): + """ + Test that only topics that a user has access to are returned. The + ways in which a user may not have access are: + + * Block is visible to staff only + * Block is accessible only to a group the user is not in + + Also, there is a case that ensures that a category with no accessible + subcategories does not appear in the result. + """ + beta_tester = BetaTesterFactory.create(course_key=self.course.id) + CourseEnrollmentFactory.create(user=beta_tester, course_id=self.course.id) + staff = StaffFactory.create(course_key=self.course.id) + for user, group_idx in [(self.user, 0), (beta_tester, 1)]: + cohort = CohortFactory.create( + course_id=self.course.id, + name=self.partition.groups[group_idx].name, + users=[user] + ) + CourseUserGroupPartitionGroup.objects.create( + course_user_group=cohort, + partition_id=self.partition.id, + group_id=self.partition.groups[group_idx].id + ) + + with self.store.bulk_operations(self.course.id, emit_signals=False): + self.store.update_item(self.course, self.user.id) + self.make_discussion_xblock( + "courseware-2", + "First", + "Cohort A", + group_access={self.partition.id: [self.partition.groups[0].id]} + ) + self.make_discussion_xblock( + "courseware-3", + "First", + "Cohort B", + group_access={self.partition.id: [self.partition.groups[1].id]} + ) + self.make_discussion_xblock("courseware-1", "First", "Everybody") + self.make_discussion_xblock( + "courseware-5", + "Second", + "Future Start Date", + start=datetime.now(UTC) + timedelta(days=1) + ) + self.make_discussion_xblock("courseware-4", "Second", "Staff Only", visible_to_staff_only=True) + + student_actual = self.get_course_topics() + student_expected = { + "courseware_topics": [ + self.make_expected_tree( + None, + "First", + [ + self.make_expected_tree("courseware-2", "Cohort A"), + self.make_expected_tree("courseware-1", "Everybody"), + ] + ), + ], + "non_courseware_topics": [ + self.make_expected_tree("non-courseware-topic-id", "Test Topic"), + ], + } + assert student_actual == student_expected + self.request.user = beta_tester + beta_actual = self.get_course_topics() + beta_expected = { + "courseware_topics": [ + self.make_expected_tree( + None, + "First", + [ + self.make_expected_tree("courseware-3", "Cohort B"), + self.make_expected_tree("courseware-1", "Everybody"), + ] + ) + ], + "non_courseware_topics": [ + self.make_expected_tree("non-courseware-topic-id", "Test Topic"), + ], + } + assert beta_actual == beta_expected + + self.request.user = staff + staff_actual = self.get_course_topics() + staff_expected = { + "courseware_topics": [ + self.make_expected_tree( + None, + "First", + [ + self.make_expected_tree("courseware-2", "Cohort A"), + self.make_expected_tree("courseware-3", "Cohort B"), + self.make_expected_tree("courseware-1", "Everybody"), + ] + ), + self.make_expected_tree( + None, + "Second", + [ + self.make_expected_tree("courseware-4", "Staff Only"), + ] + ), + ], + "non_courseware_topics": [ + self.make_expected_tree("non-courseware-topic-id", "Test Topic"), + ], + } + assert staff_actual == staff_expected + + def test_un_released_discussion_topic(self): + """ + Test discussion topics that have not yet started + """ + staff = StaffFactory.create(course_key=self.course.id) + with self.store.bulk_operations(self.course.id, emit_signals=False): + self.store.update_item(self.course, self.user.id) + self.make_discussion_xblock( + "courseware-2", + "First", + "Released", + start=datetime.now(UTC) - timedelta(days=1) + ) + self.make_discussion_xblock( + "courseware-3", + "First", + "Future release", + start=datetime.now(UTC) + timedelta(days=1) + ) + + self.request.user = staff + staff_actual = self.get_course_topics() + staff_expected = { + "courseware_topics": [ + self.make_expected_tree( + None, + "First", + [ + self.make_expected_tree("courseware-2", "Released"), + ] + ), + ], + "non_courseware_topics": [ + self.make_expected_tree("non-courseware-topic-id", "Test Topic"), + ], + } + assert staff_actual == staff_expected + + def test_discussion_topic(self): + """ + Tests discussion topic details against a requested topic id + """ + topic_id_1 = "topic_id_1" + topic_id_2 = "topic_id_2" + self.make_discussion_xblock(topic_id_1, "test_category_1", "test_target_1") + self.make_discussion_xblock(topic_id_2, "test_category_2", "test_target_2") + actual = get_course_topics(self.request, self.course.id, {"topic_id_1", "topic_id_2"}) + assert actual == { + 'non_courseware_topics': [], + 'courseware_topics': [ + { + 'children': [ + { + 'children': [], + 'id': 'topic_id_1', + 'thread_list_url': 'http://testserver/api/discussion/v1/threads/' + '?course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1', + 'name': 'test_target_1', + 'thread_counts': {'discussion': 0, 'question': 0}, + }, + ], + 'id': None, + 'thread_list_url': 'http://testserver/api/discussion/v1/threads/' + '?course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1', + 'name': 'test_category_1', + 'thread_counts': None, + }, + { + 'children': [ + { + 'children': [], + 'id': 'topic_id_2', + 'thread_list_url': 'http://testserver/api/discussion/v1/threads/' + '?course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2', + 'name': 'test_target_2', + 'thread_counts': {'discussion': 0, 'question': 0}, + } + ], + 'id': None, + 'thread_list_url': 'http://testserver/api/discussion/v1/threads/' + '?course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2', + 'name': 'test_category_2', + 'thread_counts': None, + } + ] + } + + +@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"}) +@ddt.ddt +class GetCourseTest(ForumsEnableMixin, UrlResetMixin, SharedModuleStoreTestCase): + """Test for get_course""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create(org="x", course="y", run="z") + + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + super().setUp() + self.user = UserFactory.create() + CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) + self.request = RequestFactory().get("/dummy") + self.request.user = self.user + + def test_nonexistent_course(self): + with pytest.raises(CourseNotFoundError): + get_course(self.request, CourseLocator.from_string("course-v1:non+existent+course")) + + def test_not_enrolled(self): + unenrolled_user = UserFactory.create() + self.request.user = unenrolled_user + with pytest.raises(CourseNotFoundError): + get_course(self.request, self.course.id) + + def test_discussions_disabled(self): + with pytest.raises(DiscussionDisabledError): + get_course(self.request, _discussion_disabled_course_for(self.user).id) + + def test_discussions_disabled_v2(self): + data = get_course(self.request, _discussion_disabled_course_for(self.user).id, False) + assert data['show_discussions'] is False + + def test_basic(self): + assert get_course(self.request, self.course.id) == { + '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', + 'allow_anonymous': True, + 'allow_anonymous_to_peers': False, + 'enable_in_context': True, + 'group_at_subsection': False, + 'provider': 'legacy', + 'has_moderation_privileges': False, + "is_course_staff": False, + "is_course_admin": 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, + 'has_bulk_delete_privileges': False, + 'is_notify_all_learners_enabled': False, + 'captcha_settings': {'enabled': False, 'site_key': None}, + 'is_email_verified': True, + 'only_verified_users_can_post': False, + 'content_creation_rate_limited': False, + } + + @ddt.data( + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + ) + def test_privileged_roles(self, role): + """ + Test that the api returns the correct roles and privileges. + """ + _assign_role_to_user(user=self.user, course_id=self.course.id, role=role) + course_meta = get_course(self.request, self.course.id) + assert course_meta["has_moderation_privileges"] + assert course_meta["user_roles"] == {FORUM_ROLE_STUDENT} | {role} + + +@ddt.ddt +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class GetCourseTestBlackouts(ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase): + """ + Tests of get_course for courses that have blackout dates. + """ + @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") + self.user = UserFactory.create() + CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) + self.request = RequestFactory().get("/dummy") + self.request.user = self.user + + def test_blackout(self): + # A variety of formats is accepted + self.course.discussion_blackouts = [ + ["2015-06-09T00:00:00Z", "6-10-15"], + [1433980800000, datetime(2015, 6, 12, tzinfo=UTC)], + ] + self.update_course(self.course, self.user.id) + result = get_course(self.request, self.course.id) + assert result['blackouts'] == [ + {'start': '2015-06-09T00:00:00Z', 'end': '2015-06-10T00:00:00Z'}, + {'start': '2015-06-11T00:00:00Z', 'end': '2015-06-12T00:00:00Z'} + ] + + @ddt.data(None, "not a datetime", "2015", []) + def test_blackout_errors(self, bad_value): + self.course.discussion_blackouts = [ + [bad_value, "2015-06-09T00:00:00Z"], + ["2015-06-10T00:00:00Z", "2015-06-11T00:00:00Z"], + ] + modulestore().update_item(self.course, self.user.id) + result = get_course(self.request, self.course.id) + assert result['blackouts'] == [] diff --git a/lms/djangoapps/discussion/rest_api/tests/test_serializers.py b/lms/djangoapps/discussion/rest_api/tests/test_serializers.py deleted file mode 100644 index 0cbcc0bebd..0000000000 --- a/lms/djangoapps/discussion/rest_api/tests/test_serializers.py +++ /dev/null @@ -1,881 +0,0 @@ -""" -Tests for Discussion API serializers -""" - -import itertools -from unittest import mock - -import ddt -import httpretty -from django.test.client import RequestFactory -from django.test.utils import override_settings -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, - filter_spam_urls_from_html, - get_context -) -from lms.djangoapps.discussion.rest_api.tests.utils import ( - CommentsServiceMockMixin, - make_minimal_cs_comment, - make_minimal_cs_thread, - parsed_body, -) -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() - self.addCleanup(httpretty.reset) - self.addCleanup(httpretty.disable) - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - self.maxDiff = None # pylint: disable=invalid-name - self.user = UserFactory.create() - self.register_get_user_response(self.user) - 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( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - self.user = UserFactory.create() - self.register_get_user_response(self.user) - self.request = RequestFactory().get("/dummy") - 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. - """ - 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_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_update_empty(self): - self.register_put_thread_response(self.existing_thread.attributes) - self.save_and_reserialize({}, self.existing_thread) - assert parsed_body(httpretty.last_request()) == { - 'course_id': [str(self.course.id)], - 'commentable_id': ['original_topic'], - 'thread_type': ['discussion'], - 'title': ['Original Title'], - 'body': ['Original body'], - 'anonymous': ['False'], - 'anonymous_to_peers': ['False'], - 'closed': ['False'], - 'pinned': ['False'], - 'user_id': [str(self.user.id)], - 'read': ['False'] - } - - @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) - assert parsed_body(httpretty.last_request()) == { - 'course_id': [str(self.course.id)], - 'commentable_id': ['edited_topic'], - 'thread_type': ['question'], - 'title': ['Edited Title'], - 'body': ['Edited body'], - 'anonymous': ['False'], - 'anonymous_to_peers': ['False'], - 'closed': ['False'], - 'pinned': ['False'], - 'user_id': [str(self.user.id)], - 'read': [str(read)], - 'editing_user_id': [str(self.user.id)], - } - 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, - } - self.save_and_reserialize(data, self.existing_thread) - assert parsed_body(httpretty.last_request())["anonymous"] == ['True'] - - 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, - } - self.save_and_reserialize(data, self.existing_thread) - assert parsed_body(httpretty.last_request())["anonymous_to_peers"] == ['True'] - - @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( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" - ) - self.mock_get_course_id_by_comment = patcher.start() - self.addCleanup(patcher.stop) - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" - ) - self.mock_get_course_id_by_thread = patcher.start() - self.addCleanup(patcher.stop) - self.user = UserFactory.create() - self.register_get_user_response(self.user) - self.request = RequestFactory().get("/dummy") - 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 - - 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_update_empty(self): - self.register_put_comment_response(self.existing_comment.attributes) - self.save_and_reserialize({}, instance=self.existing_comment) - assert parsed_body(httpretty.last_request()) == { - 'body': ['Original body'], - 'course_id': [str(self.course.id)], - 'user_id': [str(self.user.id)], - 'anonymous': ['False'], - 'anonymous_to_peers': ['False'], - 'endorsed': ['False'] - } - - 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) - assert parsed_body(httpretty.last_request())["anonymous"] == ['True'] - - 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) - assert parsed_body(httpretty.last_request())["anonymous_to_peers"] == ['True'] - - @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.']} - - -class FilterSpamTest(SharedModuleStoreTestCase): - """ - Tests for the filter_spam method - """ - @override_settings(DISCUSSION_SPAM_URLS=['example.com']) - def test_filter(self): - self.assertEqual( - filter_spam_urls_from_html('')[0], - '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.register_get_user_response(self.user) + 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.register_get_user_response(self.user) + 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): + self.register_get_user_response(self.user) + serialized = self.serialize(self.make_cs_content(with_endorsement=True)) + assert serialized['endorsed_at'] == self.endorsed_at + + def test_children(self): + self.register_get_user_response(self.user) + 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 ThreadSerializerSerializationTest(SerializerTestMixin, SharedModuleStoreTestCase, ForumMockUtilsMixin): + """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." diff --git a/lms/djangoapps/discussion/rest_api/tests/test_tasks_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_tasks_v2.py index 153ba15604..09339558c4 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_tasks_v2.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_tasks_v2.py @@ -60,46 +60,19 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC self.course = CourseFactory.create() - # Patch 1 - patcher1 = mock.patch( - 'openedx.core.djangoapps.django_comment_common.comment_client.thread.is_forum_v2_enabled_for_thread', - autospec=True - ) - mock_forum_v2 = patcher1.start() - mock_forum_v2.return_value = (True, str(self.course.id)) - self.addCleanup(patcher1.stop) - - # Patch 2 - patcher2 = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher2.start() - self.addCleanup(patcher2.stop) - - # Patch 3 - patcher3 = mock.patch( + patcher = mock.patch( "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread", return_value=self.course.id ) - self.mock_get_course_id_by_thread = patcher3.start() - self.addCleanup(patcher3.stop) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) - # Patch 4 - patcher4 = mock.patch( + patcher = mock.patch( "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment", return_value=self.course.id ) - self.mock_get_course_id_by_comment = patcher4.start() - self.addCleanup(patcher4.stop) - - # Patch 5 - patcher5 = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.models.is_forum_v2_enabled_for_comment", - return_value=(True, str(self.course.id)) - ) - self.mock_is_forum_v2_enabled_for_comment = patcher5.start() - self.addCleanup(patcher5.stop) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) self.user_1 = UserFactory.create() CourseEnrollment.enroll(self.user_1, self.course.id) @@ -410,20 +383,6 @@ class TestSendCommentNotification(DiscussionAPIViewTestMixin, ModuleStoreTestCas super().setUp() httpretty.reset() httpretty.enable() - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - - patcher = mock.patch( - 'openedx.core.djangoapps.django_comment_common.comment_client.thread.is_forum_v2_enabled_for_thread', - autospec=True - ) - mock_forum_v2 = patcher.start() - mock_forum_v2.return_value = (True, str(self.course.id)) - self.addCleanup(patcher.stop) self.course = CourseFactory.create() patcher = mock.patch( @@ -439,13 +398,6 @@ class TestSendCommentNotification(DiscussionAPIViewTestMixin, ModuleStoreTestCas 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.is_forum_v2_enabled_for_comment", - return_value=(True, str(self.course.id)) - ) - self.mock_is_forum_v2_enabled_for_comment = patcher.start() - self.addCleanup(patcher.stop) - self.user_1 = UserFactory.create() CourseEnrollment.enroll(self.user_1, self.course.id) self.user_2 = UserFactory.create() diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views.py b/lms/djangoapps/discussion/rest_api/tests/test_views.py index be8a793abc..18a180b43a 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views.py @@ -2,311 +2,40 @@ Tests for Discussion API views """ - import json -import random from datetime import datetime from unittest import mock -from urllib.parse import parse_qs, urlencode, urlparse +from urllib.parse import urlencode 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.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 xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory -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 UrlResetMixin from lms.djangoapps.discussion.django_comment_client.tests.utils import ( ForumsEnableMixin, - config_course_discussions, - topic_name_to_id, ) from lms.djangoapps.discussion.rest_api.tests.utils import ( - CommentsServiceMockMixin, + ForumMockUtilsMixin, make_minimal_cs_comment, make_minimal_cs_thread, ) -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.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) - - 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)}) - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - - 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}) -@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_FORUM_V2": False}) class CommentViewSetListByUserTest( ForumsEnableMixin, - CommentsServiceMockMixin, + ForumMockUtilsMixin, UrlResetMixin, ModuleStoreTestCase, ): @@ -314,21 +43,20 @@ class CommentViewSetListByUserTest( Common test cases for views retrieving user-published content. """ + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() - httpretty.reset() - httpretty.enable() - self.addCleanup(httpretty.reset) - self.addCleanup(httpretty.disable) - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - self.user = UserFactory.create(password=self.TEST_PASSWORD) self.register_get_user_response(self.user) @@ -498,1530 +226,3 @@ class CommentViewSetListByUserTest( 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)}) - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - - 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_bulk_delete_privileges": 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, - 'is_notify_all_learners_enabled': False, - 'captcha_settings': { - 'enabled': False, - 'site_key': None, - }, - "is_email_verified": True, - "only_verified_users_can_post": False, - "content_creation_rate_limited": False - } - ) - - -@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") - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - - 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 test_basic(self): - """ - Check successful retirement case - """ - 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.assert_response_correct(response, 204, b"") - - def test_downstream_forums_error(self): - """ - Check that we bubble up errors from the comments service - """ - self.register_get_user_retire_response(self.user, status=500, body="Server error") - headers = self.build_jwt_headers(self.superuser) - data = {'username': self.user.username} - response = self.superuser_client.post(self.url, data, **headers) - 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") - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - - 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.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class CourseTopicsViewTest(DiscussionAPIViewTestMixin, CommentsServiceMockMixin, ModuleStoreTestCase): - """ - Tests for CourseTopicsView - """ - - def setUp(self): - httpretty.reset() - httpretty.enable() - self.addCleanup(httpretty.reset) - self.addCleanup(httpretty.disable) - super().setUp() - self.url = reverse("course_topics", kwargs={"course_id": str(self.course.id)}) - self.thread_counts_map = { - "courseware-1": {"discussion": 2, "question": 3}, - "courseware-2": {"discussion": 4, "question": 5}, - "courseware-3": {"discussion": 7, "question": 2}, - } - self.register_get_course_commentable_counts_response(self.course.id, self.thread_counts_map) - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - - def create_course(self, blocks_count, module_store, topics): - """ - Create a course in a specified module store with discussion xblocks and topics - """ - course = CourseFactory.create( - org="a", - course="b", - run="c", - start=datetime.now(UTC), - default_store=module_store, - discussion_topics=topics - ) - CourseEnrollmentFactory.create(user=self.user, course_id=course.id) - course_url = reverse("course_topics", kwargs={"course_id": str(course.id)}) - # add some discussion xblocks - for i in range(blocks_count): - BlockFactory.create( - parent_location=course.location, - category='discussion', - discussion_id=f'id_module_{i}', - discussion_category=f'Category {i}', - discussion_target=f'Discussion {i}', - publish_item=False, - ) - return course_url, course.id - - def make_discussion_xblock(self, topic_id, category, subcategory, **kwargs): - """ - Build a discussion xblock in self.course - """ - BlockFactory.create( - parent_location=self.course.location, - category="discussion", - discussion_id=topic_id, - discussion_category=category, - discussion_target=subcategory, - **kwargs - ) - - 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, - { - "courseware_topics": [], - "non_courseware_topics": [{ - "id": "test_topic", - "name": "Test Topic", - "children": [], - "thread_list_url": 'http://testserver/api/discussion/v1/threads/' - '?course_id=course-v1%3Ax%2By%2Bz&topic_id=test_topic', - "thread_counts": {"discussion": 0, "question": 0}, - }], - } - ) - - @ddt.data( - (2, ModuleStoreEnum.Type.split, 2, {"Test Topic 1": {"id": "test_topic_1"}}), - (2, ModuleStoreEnum.Type.split, 2, - {"Test Topic 1": {"id": "test_topic_1"}, "Test Topic 2": {"id": "test_topic_2"}}), - (10, ModuleStoreEnum.Type.split, 2, {"Test Topic 1": {"id": "test_topic_1"}}), - ) - @ddt.unpack - def test_bulk_response(self, blocks_count, module_store, mongo_calls, topics): - course_url, course_id = self.create_course(blocks_count, module_store, topics) - self.register_get_course_commentable_counts_response(course_id, {}) - with check_mongo_calls(mongo_calls): - with modulestore().default_store(module_store): - self.client.get(course_url) - - def test_discussion_topic_404(self): - """ - Tests discussion topic does not exist for the given topic id. - """ - topic_id = "courseware-topic-id" - self.make_discussion_xblock(topic_id, "test_category", "test_target") - url = f"{self.url}?topic_id=invalid_topic_id" - response = self.client.get(url) - self.assert_response_correct( - response, - 404, - {"developer_message": "Discussion not found for 'invalid_topic_id'."} - ) - - def test_topic_id(self): - """ - Tests discussion topic details against a requested topic id - """ - topic_id_1 = "topic_id_1" - topic_id_2 = "topic_id_2" - self.make_discussion_xblock(topic_id_1, "test_category_1", "test_target_1") - self.make_discussion_xblock(topic_id_2, "test_category_2", "test_target_2") - url = f"{self.url}?topic_id=topic_id_1,topic_id_2" - response = self.client.get(url) - self.assert_response_correct( - response, - 200, - { - "non_courseware_topics": [], - "courseware_topics": [ - { - "children": [{ - "children": [], - "id": "topic_id_1", - "thread_list_url": "http://testserver/api/discussion/v1/threads/?" - "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1", - "name": "test_target_1", - "thread_counts": {"discussion": 0, "question": 0}, - }], - "id": None, - "thread_list_url": "http://testserver/api/discussion/v1/threads/?" - "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1", - "name": "test_category_1", - "thread_counts": None, - }, - { - "children": - [{ - "children": [], - "id": "topic_id_2", - "thread_list_url": "http://testserver/api/discussion/v1/threads/?" - "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2", - "name": "test_target_2", - "thread_counts": {"discussion": 0, "question": 0}, - }], - "id": None, - "thread_list_url": "http://testserver/api/discussion/v1/threads/?" - "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2", - "name": "test_category_2", - "thread_counts": None, - } - ] - } - ) - - @override_waffle_flag(ENABLE_NEW_STRUCTURE_DISCUSSIONS, True) - def test_new_course_structure_response(self): - """ - Tests whether the new structure is available on old topics API - (For mobile compatibility) - """ - chapter = BlockFactory.create( - parent_location=self.course.location, - category='chapter', - display_name="Week 1", - start=datetime(2015, 3, 1, tzinfo=UTC), - ) - sequential = BlockFactory.create( - parent_location=chapter.location, - category='sequential', - display_name="Lesson 1", - start=datetime(2015, 3, 1, tzinfo=UTC), - ) - BlockFactory.create( - parent_location=sequential.location, - category='vertical', - display_name='vertical', - start=datetime(2015, 4, 1, tzinfo=UTC), - ) - DiscussionsConfiguration.objects.create( - context_key=self.course.id, - provider_type=Provider.OPEN_EDX - ) - update_discussions_settings_from_course_task(str(self.course.id)) - response = json.loads(self.client.get(self.url).content.decode()) - keys = ['children', 'id', 'name', 'thread_counts', 'thread_list_url'] - assert list(response.keys()) == ['courseware_topics', 'non_courseware_topics'] - assert len(response['courseware_topics']) == 1 - courseware_keys = list(response['courseware_topics'][0].keys()) - courseware_keys.sort() - assert courseware_keys == keys - assert len(response['non_courseware_topics']) == 1 - non_courseware_keys = list(response['non_courseware_topics'][0].keys()) - non_courseware_keys.sort() - assert non_courseware_keys == keys - - -@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), - } - patcher = mock.patch( - 'lms.djangoapps.discussion.rest_api.api.get_course_commentable_counts', - mock.Mock(return_value=self.topic_stats), - ) - patcher.start() - self.addCleanup(patcher.stop) - self.url = reverse("course_topics_v3", kwargs={"course_id": str(self.course.id)}) - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - - 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 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)}) - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - - 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, - } - - 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 - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "page": ["1"], - "per_page": ["10"], - "thread_type": [thread_type], - "sort_key": ['activity'], - "count_flagged": ["False"] - }) - - @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 - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "page": ["1"], - "per_page": ["10"], - "sort_key": [cc_query], - "count_flagged": ["False"] - }) - - @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 - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "page": ["1"], - "per_page": ["10"], - post_status: ['True'], - "sort_key": ['activity'], - "count_flagged": ["False"] - }) - - -@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) - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - - def _get_oauth_headers(self, user): - """Return the OAuth headers for testing OAuth authentication""" - 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() - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - 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() - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - self.course = CourseFactory.create() - self.course_key = str(self.course.id) - seed_permissions_roles(self.course.id) - 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 - - @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 - - @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) - assert urlparse( - httpretty.last_request().path # lint-amnesty, pylint: disable=no-member - ).path == f"/api/v1/users/{self.course_key}/stats" - assert parse_qs( - urlparse(httpretty.last_request().path).query # lint-amnesty, pylint: disable=no-member - ).get("sort_key", None) == [ordering_performed] - - @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) - assert urlparse( - httpretty.last_request().path # lint-amnesty, pylint: disable=no-member - ).path == f'/api/v1/users/{self.course_key}/stats' - assert parse_qs( - urlparse(httpretty.last_request().path).query # lint-amnesty, pylint: disable=no-member - ).get('usernames', [None]) == [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/test_views_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py index 4247cbcab0..40b2ca9154 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py @@ -1,4 +1,3 @@ -# pylint: skip-file """ Tests for the external REST API endpoints of the Discussion API (views_v2.py). @@ -8,44 +7,68 @@ and integration with the underlying discussion service. These tests ensure that various user roles, input data, and edge cases, and that they return appropriate HTTP status codes and response bodies. """ - import json +import random from datetime import datetime from unittest import mock import ddt -from forum.backends.mongodb.comments import Comment -from forum.backends.mongodb.threads import CommentThread 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 +from rest_framework.test import APIClient, APITestCase -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory -from common.djangoapps.student.tests.factories import ( - CourseEnrollmentFactory, - UserFactory, +from lms.djangoapps.discussion.django_comment_client.tests.utils import ( + ForumsEnableMixin, + config_course_discussions, + topic_name_to_id, ) -from common.djangoapps.util.testing import PatchMediaTypeMixin, UrlResetMixin -from common.test.utils import disable_signal -from lms.djangoapps.discussion.tests.utils import ( - make_minimal_cs_comment, - make_minimal_cs_thread, -) -from lms.djangoapps.discussion.django_comment_client.tests.utils import ForumsEnableMixin from lms.djangoapps.discussion.rest_api import api from lms.djangoapps.discussion.rest_api.tests.utils import ( + CommentsServiceMockMixin, ForumMockUtilsMixin, ProfileImageTestMixin, make_paginated_api_response, + make_minimal_cs_comment, + make_minimal_cs_thread, ) +from lms.djangoapps.discussion.rest_api.utils import get_usernames_from_search_string +from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE +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.tests.factories import ( + AdminFactory, + CourseEnrollmentFactory, + SuperuserFactory, + UserFactory +) +from common.djangoapps.student.models import get_retired_username_by_username, CourseEnrollment +from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole, GlobalStaff +from common.djangoapps.util.testing import PatchMediaTypeMixin, UrlResetMixin +from common.test.utils import disable_signal + +from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts +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.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.models import RetirementState, UserRetirementStatus from openedx.core.djangoapps.django_comment_common.models import ( - FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_MODERATOR, FORUM_ROLE_STUDENT, - assign_role + CourseDiscussionSettings, Role ) +from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles +from openedx.core.djangoapps.discussions.config.waffle import ENABLE_NEW_STRUCTURE_DISCUSSIONS from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_storage @@ -657,7 +680,7 @@ class ThreadViewSetListTest( @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) + self.register_subscribed_threads_response(self.user, [], page=1, num_pages=1) response = self.client.get( self.url, { @@ -667,7 +690,7 @@ class ThreadViewSetListTest( ) expected_response = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None + results=[], count=0, num_pages=1, next_link=None, previous_link=None ) expected_response.update({"text_search_rewrite": None}) self.assert_response_correct(response, 200, expected_response) @@ -868,81 +891,1780 @@ class BulkDeleteUserPostsTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): Tests for the BulkDeleteUserPostsViewSet """ - def setUp(self): + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self) -> None: super().setUp() - self.url = reverse("bulk_delete_user_posts", kwargs={"course_id": str(self.course.id)}) - self.user2 = UserFactory.create(password=self.password) - CourseEnrollmentFactory.create(user=self.user2, course_id=self.course.id) + 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}) + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + @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 + + @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 + + @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.check_mock_called("get_user_course_stats") + params = self.get_mock_func_calls("get_user_course_stats")[-1][1] + assert params["sort_key"] == ordering_performed + + @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.check_mock_called("get_user_course_stats") + params = self.get_mock_func_calls("get_user_course_stats")[-1][1] + assert params["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) def test_basic(self): """ - Intentionally left empty because this test case is inherited from parent + Basic test method required by DiscussionAPIViewTestMixin """ - def mock_comment_and_thread_count(self, comment_count=1, thread_count=1): + def user_login(self): """ - Patches count_documents() for Comment and CommentThread._collection. + Authenticates the test client with the example user. """ - thread_collection = mock.MagicMock() - thread_collection.count_documents.return_value = thread_count - patch_thread = mock.patch.object( - CommentThread, "_collection", new_callable=mock.PropertyMock, return_value=thread_collection + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) + + +@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)}) + + 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."} ) - comment_collection = mock.MagicMock() - comment_collection.count_documents.return_value = comment_count - patch_comment = mock.patch.object( - Comment, "_collection", new_callable=mock.PropertyMock, return_value=comment_collection + 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, + 'has_bulk_delete_privileges': False, + 'is_notify_all_learners_enabled': False, + 'captcha_settings': {'enabled': False, 'site_key': None}, + 'is_email_verified': True, + 'only_verified_users_can_post': False, + 'content_creation_rate_limited': False, + } ) - thread_mock = patch_thread.start() - comment_mock = patch_comment.start() - self.addCleanup(patch_comment.stop) - self.addCleanup(patch_thread.stop) - return thread_mock, comment_mock +@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""" - @ddt.data(FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_STUDENT) - def test_bulk_delete_denied_for_discussion_roles(self, role): + 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") + + def assert_response_correct(self, response, expected_status, expected_content): """ - Test bulk delete user posts denied with discussion roles. + Assert that the response has the given status code and content """ - thread_mock, comment_mock = self.mock_comment_and_thread_count(comment_count=1, thread_count=1) - assign_role(self.course.id, self.user, role) - response = self.client.post( - f"{self.url}?username={self.user2.username}", - format="json", + 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.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class CourseTopicsViewTest(DiscussionAPIViewTestMixin, CommentsServiceMockMixin, ModuleStoreTestCase): + """ + Tests for CourseTopicsView + """ + + def setUp(self): + httpretty.reset() + httpretty.enable() + self.addCleanup(httpretty.reset) + self.addCleanup(httpretty.disable) + super().setUp() + self.url = reverse("course_topics", kwargs={"course_id": str(self.course.id)}) + self.thread_counts_map = { + "courseware-1": {"discussion": 2, "question": 3}, + "courseware-2": {"discussion": 4, "question": 5}, + "courseware-3": {"discussion": 7, "question": 2}, + } + self.register_get_course_commentable_counts_response(self.course.id, self.thread_counts_map) + + def create_course(self, blocks_count, module_store, topics): + """ + Create a course in a specified module store with discussion xblocks and topics + """ + course = CourseFactory.create( + org="a", + course="b", + run="c", + start=datetime.now(UTC), + default_store=module_store, + discussion_topics=topics ) + CourseEnrollmentFactory.create(user=self.user, course_id=course.id) + course_url = reverse("course_topics", kwargs={"course_id": str(course.id)}) + # add some discussion xblocks + for i in range(blocks_count): + BlockFactory.create( + parent_location=course.location, + category='discussion', + discussion_id=f'id_module_{i}', + discussion_category=f'Category {i}', + discussion_target=f'Discussion {i}', + publish_item=False, + ) + return course_url, course.id + + def make_discussion_xblock(self, topic_id, category, subcategory, **kwargs): + """ + Build a discussion xblock in self.course + """ + BlockFactory.create( + parent_location=self.course.location, + category="discussion", + discussion_id=topic_id, + discussion_category=category, + discussion_target=subcategory, + **kwargs + ) + + 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, + { + "courseware_topics": [], + "non_courseware_topics": [{ + "id": "test_topic", + "name": "Test Topic", + "children": [], + "thread_list_url": 'http://testserver/api/discussion/v1/threads/' + '?course_id=course-v1%3Ax%2By%2Bz&topic_id=test_topic', + "thread_counts": {"discussion": 0, "question": 0}, + }], + } + ) + + @ddt.data( + (2, ModuleStoreEnum.Type.split, 2, {"Test Topic 1": {"id": "test_topic_1"}}), + (2, ModuleStoreEnum.Type.split, 2, + {"Test Topic 1": {"id": "test_topic_1"}, "Test Topic 2": {"id": "test_topic_2"}}), + (10, ModuleStoreEnum.Type.split, 2, {"Test Topic 1": {"id": "test_topic_1"}}), + ) + @ddt.unpack + def test_bulk_response(self, blocks_count, module_store, mongo_calls, topics): + course_url, course_id = self.create_course(blocks_count, module_store, topics) + self.register_get_course_commentable_counts_response(course_id, {}) + with check_mongo_calls(mongo_calls): + with modulestore().default_store(module_store): + self.client.get(course_url) + + def test_discussion_topic_404(self): + """ + Tests discussion topic does not exist for the given topic id. + """ + topic_id = "courseware-topic-id" + self.make_discussion_xblock(topic_id, "test_category", "test_target") + url = f"{self.url}?topic_id=invalid_topic_id" + response = self.client.get(url) + self.assert_response_correct( + response, + 404, + {"developer_message": "Discussion not found for 'invalid_topic_id'."} + ) + + def test_topic_id(self): + """ + Tests discussion topic details against a requested topic id + """ + topic_id_1 = "topic_id_1" + topic_id_2 = "topic_id_2" + self.make_discussion_xblock(topic_id_1, "test_category_1", "test_target_1") + self.make_discussion_xblock(topic_id_2, "test_category_2", "test_target_2") + url = f"{self.url}?topic_id=topic_id_1,topic_id_2" + response = self.client.get(url) + self.assert_response_correct( + response, + 200, + { + "non_courseware_topics": [], + "courseware_topics": [ + { + "children": [{ + "children": [], + "id": "topic_id_1", + "thread_list_url": "http://testserver/api/discussion/v1/threads/?" + "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1", + "name": "test_target_1", + "thread_counts": {"discussion": 0, "question": 0}, + }], + "id": None, + "thread_list_url": "http://testserver/api/discussion/v1/threads/?" + "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1", + "name": "test_category_1", + "thread_counts": None, + }, + { + "children": + [{ + "children": [], + "id": "topic_id_2", + "thread_list_url": "http://testserver/api/discussion/v1/threads/?" + "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2", + "name": "test_target_2", + "thread_counts": {"discussion": 0, "question": 0}, + }], + "id": None, + "thread_list_url": "http://testserver/api/discussion/v1/threads/?" + "course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2", + "name": "test_category_2", + "thread_counts": None, + } + ] + } + ) + + @override_waffle_flag(ENABLE_NEW_STRUCTURE_DISCUSSIONS, True) + def test_new_course_structure_response(self): + """ + Tests whether the new structure is available on old topics API + (For mobile compatibility) + """ + chapter = BlockFactory.create( + parent_location=self.course.location, + category='chapter', + display_name="Week 1", + start=datetime(2015, 3, 1, tzinfo=UTC), + ) + sequential = BlockFactory.create( + parent_location=chapter.location, + category='sequential', + display_name="Lesson 1", + start=datetime(2015, 3, 1, tzinfo=UTC), + ) + BlockFactory.create( + parent_location=sequential.location, + category='vertical', + display_name='vertical', + start=datetime(2015, 4, 1, tzinfo=UTC), + ) + DiscussionsConfiguration.objects.create( + context_key=self.course.id, + provider_type=Provider.OPEN_EDX + ) + update_discussions_settings_from_course_task(str(self.course.id)) + response = json.loads(self.client.get(self.url).content.decode()) + keys = ['children', 'id', 'name', 'thread_counts', 'thread_list_url'] + assert list(response.keys()) == ['courseware_topics', 'non_courseware_topics'] + assert len(response['courseware_topics']) == 1 + courseware_keys = list(response['courseware_topics'][0].keys()) + courseware_keys.sort() + assert courseware_keys == keys + assert len(response['non_courseware_topics']) == 1 + non_courseware_keys = list(response['non_courseware_topics'][0].keys()) + non_courseware_keys.sort() + assert non_courseware_keys == keys + + +@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, 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), + } + patcher = mock.patch( + 'lms.djangoapps.discussion.rest_api.api.get_course_commentable_counts', + mock.Mock(return_value=self.topic_stats), + ) + patcher.start() + self.addCleanup(patcher.stop) + self.url = reverse("course_topics_v3", kwargs={"course_id": str(self.course.id)}) + + 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 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)}) + + 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, + } + + 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 = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "page": 1, + "per_page": 10, + "thread_type": thread_type, + "sort_key": 'activity', + "count_flagged": False + } + + self.check_mock_called_with("get_user_active_threads", -1, **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 = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "page": 1, + "per_page": 10, + "sort_key": cc_query, + "count_flagged": False + } + self.check_mock_called_with("get_user_active_threads", -1, **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 = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "page": 1, + "per_page": 10, + post_status: True, + "sort_key": 'activity', + "count_flagged": False + } + self.check_mock_called_with("get_user_active_threads", -1, **params) + + +@ddt.ddt +@httpretty.activate +@override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True) +class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, ForumMockUtilsMixin, 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() + 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}) + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + @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 + + @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 + + @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.check_mock_called("get_user_course_stats") + params = self.get_mock_func_calls("get_user_course_stats")[-1][1] + assert params["sort_key"] == ordering_performed + + @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.check_mock_called("get_user_course_stats") + params = self.get_mock_func_calls("get_user_course_stats")[-1][1] + assert params["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) + + +@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") + + 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 test_basic(self): + """ + Check successful retirement case + """ + 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.assert_response_correct(response, 204, b"") + + 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 + + +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class UploadFileViewTest(ForumsEnableMixin, ForumMockUtilsMixin, 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)}) + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + 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 - thread_mock.count_documents.assert_not_called() - comment_mock.count_documents.assert_not_called() - @ddt.data(FORUM_ROLE_MODERATOR, FORUM_ROLE_ADMINISTRATOR) - def test_bulk_delete_allowed_for_discussion_roles(self, role): + def test_file_upload_by_enrolled_user(self): """ - Test bulk delete user posts passed with discussion roles. + Should succeed when a valid file is uploaded by an authenticated + user who's enrolled in the course. """ - self.mock_comment_and_thread_count(comment_count=1, thread_count=1) - assign_role(self.course.id, self.user, role) - response = self.client.post( - f"{self.url}?username={self.user2.username}", - format="json", - ) - assert response.status_code == status.HTTP_202_ACCEPTED - assert response.json() == {"comment_count": 1, "thread_count": 1} + self.user_login() + self.enroll_user_in_course() + response = self.client.post(self.url, self.valid_file) + self.assert_upload_success(response) - @mock.patch('lms.djangoapps.discussion.rest_api.views.delete_course_post_for_user.apply_async') - @ddt.data(True, False) - def test_task_only_runs_if_execute_param_is_true(self, execute, task_mock): + def test_file_upload_by_global_staff(self): """ - Test bulk delete user posts task runs only if execute parameter is set to true. + Should succeed when a valid file is uploaded by a global staff + member. """ - assign_role(self.course.id, self.user, FORUM_ROLE_MODERATOR) - self.mock_comment_and_thread_count(comment_count=1, thread_count=1) - response = self.client.post( - f"{self.url}?username={self.user2.username}&execute={str(execute).lower()}", - format="json", + 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 +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"}} ) - assert response.status_code == status.HTTP_202_ACCEPTED - assert response.json() == {"comment_count": 1, "thread_count": 1} - assert task_mock.called is execute + 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) + + 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() + 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'])) diff --git a/lms/djangoapps/discussion/rest_api/tests/utils.py b/lms/djangoapps/discussion/rest_api/tests/utils.py index 342afb0ada..37512c3573 100644 --- a/lms/djangoapps/discussion/rest_api/tests/utils.py +++ b/lms/djangoapps/discussion/rest_api/tests/utils.py @@ -635,7 +635,8 @@ class ForumMockUtilsMixin(MockForumApiMixin): self.set_mock_return_value('get_thread', thread) def register_get_comments_response(self, comments, page, num_pages): - self.set_mock_return_value('get_parent_comment', { + """Register a mock response for get_user_comments API call.""" + self.set_mock_return_value('get_user_comments', { "collection": comments, "page": page, "num_pages": num_pages, @@ -675,13 +676,21 @@ class ForumMockUtilsMixin(MockForumApiMixin): } self.set_mock_side_effect('get_user', make_user_callbacks(self.users_map)) - def register_get_user_retire_response(self, user, body=""): + def register_get_user_retire_response(self, user, status=200, body=""): self.set_mock_return_value('retire_user', body) def register_get_username_replacement_response(self, user, status=200, body=""): self.set_mock_return_value('update_username', body) def register_subscribed_threads_response(self, user, threads, page, num_pages): + """Register a mock response for get_user_threads and get_user_subscriptions API calls.""" + self.set_mock_return_value('get_user_threads', { + "collection": threads, + "page": page, + "num_pages": num_pages, + "thread_count": len(threads), + }) + # Also mock get_user_subscriptions for the Forum v2 API self.set_mock_return_value('get_user_subscriptions', { "collection": threads, "page": page, diff --git a/lms/djangoapps/discussion/tests/test_views.py b/lms/djangoapps/discussion/tests/test_views.py index e1b451b3bc..5f4953d1e6 100644 --- a/lms/djangoapps/discussion/tests/test_views.py +++ b/lms/djangoapps/discussion/tests/test_views.py @@ -7,19 +7,15 @@ from datetime import datetime from unittest import mock from unittest.mock import ANY, Mock, call, patch -import ddt import pytest -from django.conf import settings from django.test.client import Client, RequestFactory from django.test.utils import override_settings from django.urls import reverse from django.utils import translation from edx_django_utils.cache import RequestCache -from edx_toggles.toggles.testutils import override_waffle_flag from xmodule.modulestore.tests.django_utils import ( TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase, - SharedModuleStoreTestCase ) from xmodule.modulestore.tests.factories import ( CourseFactory, @@ -28,19 +24,17 @@ from xmodule.modulestore.tests.factories import ( from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.course_modes.tests.factories import CourseModeFactory -from common.djangoapps.student.tests.factories import AdminFactory, CourseEnrollmentFactory, UserFactory +from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory from common.djangoapps.util.testing import UrlResetMixin from lms.djangoapps.courseware.exceptions import CourseAccessRedirect from lms.djangoapps.discussion import views from lms.djangoapps.discussion.django_comment_client.constants import TYPE_ENTRY, TYPE_SUBCATEGORY from lms.djangoapps.discussion.django_comment_client.permissions import get_team -from lms.djangoapps.discussion.django_comment_client.tests.unicode import UnicodeTestMixin from lms.djangoapps.discussion.django_comment_client.tests.utils import ( ForumsEnableMixin, config_course_discussions, topic_name_to_id ) -from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE from lms.djangoapps.discussion.views import _get_discussion_default_topic_id, course_discussions_settings_handler from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts from openedx.core.djangoapps.course_groups.tests.test_views import CohortViewsTestCase @@ -91,12 +85,6 @@ class ViewsExceptionTestCase(UrlResetMixin, ModuleStoreTestCase): # lint-amnest config = ForumsConfig.current() config.enabled = True config.save() - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) patcher = mock.patch( "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" ) @@ -336,12 +324,6 @@ class CommentsServiceRequestHeadersTestCase(ForumsEnableMixin, UrlResetMixin, Mo @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) patcher = mock.patch( "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" ) @@ -409,36 +391,6 @@ class CommentsServiceRequestHeadersTestCase(ForumsEnableMixin, UrlResetMixin, Mo self.assert_all_calls_have_header(mock_request, "X-Edx-Api-Key", "test_api_key") -class UserProfileUnicodeTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring - - @classmethod - def setUpClass(cls): - # pylint: disable=super-method-not-called - with super().setUpClassAndTestData(): - cls.course = CourseFactory.create() - - @classmethod - def setUpTestData(cls): - super().setUpTestData() - - cls.student = UserFactory.create() - CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) - - @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def _test_unicode_data(self, text, mock_request): # lint-amnesty, pylint: disable=missing-function-docstring - mock_request.side_effect = make_mock_request_impl(course=self.course, text=text) - request = RequestFactory().get("dummy_url") - request.user = self.student - # so (request.headers.get('x-requested-with') == 'XMLHttpRequest') == True - request.META["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest" - - response = views.user_profile(request, str(self.course.id), str(self.student.id)) - assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data['discussion_data'][0]['title'] == text - assert response_data['discussion_data'][0]['body'] == text - - class EnrollmentTestCase(ForumsEnableMixin, ModuleStoreTestCase): """ Tests for the behavior of views depending on if the student is enrolled @@ -746,76 +698,3 @@ class DefaultTopicIdGetterTestCase(ModuleStoreTestCase): expected_id = 'another_discussion_id' result = _get_discussion_default_topic_id(course) assert expected_id == result - - -@ddt.ddt -@patch( - 'openedx.core.djangoapps.django_comment_common.comment_client.utils.perform_request', - Mock( - return_value={ - "id": "test_thread", - "title": "Title", - "body": "", - "default_sort_key": "date", - "upvoted_ids": [], - "downvoted_ids": [], - "subscribed_thread_ids": [], - } - ) -) -class ForumMFETestCase(ForumsEnableMixin, SharedModuleStoreTestCase): - """ - Tests that the MFE upgrade banner and MFE is shown in the correct situation with the correct UI - """ - - def setUp(self): - super().setUp() - self.course = CourseFactory.create() - self.user = UserFactory.create() - self.staff_user = AdminFactory.create() - CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) - - @override_settings(DISCUSSIONS_MICROFRONTEND_URL="http://test.url") - def test_redirect_from_legacy_base_url_to_new_experience(self): - """ - Verify that the legacy url is redirected to MFE homepage when - ENABLE_DISCUSSIONS_MFE flag is enabled. - """ - - with override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True): - self.client.login(username=self.user.username, password=self.TEST_PASSWORD) - url = reverse("forum_form_discussion", args=[self.course.id]) - response = self.client.get(url) - assert response.status_code == 302 - expected_url = f"{settings.DISCUSSIONS_MICROFRONTEND_URL}/{str(self.course.id)}" - assert response.url == expected_url - - @override_settings(DISCUSSIONS_MICROFRONTEND_URL="http://test.url") - def test_redirect_from_legacy_profile_url_to_new_experience(self): - """ - Verify that the requested user profile is redirected to MFE learners tab when - ENABLE_DISCUSSIONS_MFE flag is enabled - """ - - with override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True): - self.client.login(username=self.user.username, password=self.TEST_PASSWORD) - url = reverse("user_profile", args=[self.course.id, self.user.id]) - response = self.client.get(url) - assert response.status_code == 302 - expected_url = f"{settings.DISCUSSIONS_MICROFRONTEND_URL}/{str(self.course.id)}/learners" - assert response.url == expected_url - - @override_settings(DISCUSSIONS_MICROFRONTEND_URL="http://test.url") - def test_redirect_from_legacy_single_thread_to_new_experience(self): - """ - Verify that a legacy single url is redirected to corresponding MFE thread url when the ENABLE_DISCUSSIONS_MFE - flag is enabled - """ - - with override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True): - self.client.login(username=self.user.username, password=self.TEST_PASSWORD) - url = reverse("single_thread", args=[self.course.id, "test_discussion", "test_thread"]) - response = self.client.get(url) - assert response.status_code == 302 - expected_url = f"{settings.DISCUSSIONS_MICROFRONTEND_URL}/{str(self.course.id)}/posts/test_thread" - assert response.url == expected_url diff --git a/lms/djangoapps/discussion/tests/test_views_v2.py b/lms/djangoapps/discussion/tests/test_views_v2.py index f7d7e6f9f1..1e4f36b8f5 100644 --- a/lms/djangoapps/discussion/tests/test_views_v2.py +++ b/lms/djangoapps/discussion/tests/test_views_v2.py @@ -2022,3 +2022,115 @@ class FollowedThreadsUnicodeTestCase( response_data = json.loads(response.content.decode('utf-8')) assert response_data['discussion_data'][0]['title'] == text assert response_data['discussion_data'][0]['body'] == text + + +class UserProfileUnicodeTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin, ForumViewsUtilsMixin): # lint-amnesty, pylint: disable=missing-class-docstring + + @classmethod + def setUpClass(cls): + # pylint: disable=super-method-not-called + with super().setUpClassAndTestData(): + cls.course = CourseFactory.create() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + cls.student = UserFactory.create() + CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) + + def _test_unicode_data(self, text): # lint-amnesty, pylint: disable=missing-function-docstring + self._configure_mock_responses(course=self.course, text=text) + request = RequestFactory().get("dummy_url") + request.user = self.student + # so (request.headers.get('x-requested-with') == 'XMLHttpRequest') == True + request.META["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest" + + response = views.user_profile(request, str(self.course.id), str(self.student.id)) + assert response.status_code == 200 + response_data = json.loads(response.content.decode('utf-8')) + assert response_data['discussion_data'][0]['title'] == text + assert response_data['discussion_data'][0]['body'] == text + + +class ForumMFETestCase(ForumsEnableMixin, SharedModuleStoreTestCase, ModuleStoreTestCase, MockForumApiMixin): # lint-amnesty, pylint: disable=missing-class-docstring + """ + Tests that the MFE upgrade banner and MFE is shown in the correct situation with the correct UI + """ + + def setUp(self): + super().setUp() + self.course = CourseFactory.create() + self.user = UserFactory.create() + self.staff_user = AdminFactory.create() + CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) + self.set_mock_return_value("get_user", { + "id": "test_thread", + "title": "Title", + "body": "", + "default_sort_key": "date", + "upvoted_ids": [], + "downvoted_ids": [], + "subscribed_thread_ids": [], + }) + self.set_mock_return_value("get_user_active_threads", {}) + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + @override_settings(DISCUSSIONS_MICROFRONTEND_URL="http://test.url") + def test_redirect_from_legacy_base_url_to_new_experience(self): + """ + Verify that the legacy url is redirected to MFE homepage when + ENABLE_DISCUSSIONS_MFE flag is enabled. + """ + + with override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True): + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) + url = reverse("forum_form_discussion", args=[self.course.id]) + response = self.client.get(url) + assert response.status_code == 302 + expected_url = f"{settings.DISCUSSIONS_MICROFRONTEND_URL}/{str(self.course.id)}" + assert response.url == expected_url + + @override_settings(DISCUSSIONS_MICROFRONTEND_URL="http://test.url") + def test_redirect_from_legacy_profile_url_to_new_experience(self): + """ + Verify that the requested user profile is redirected to MFE learners tab when + ENABLE_DISCUSSIONS_MFE flag is enabled + """ + with override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True): + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) + url = reverse("user_profile", args=[self.course.id, self.user.id]) + response = self.client.get(url) + assert response.status_code == 302 + expected_url = f"{settings.DISCUSSIONS_MICROFRONTEND_URL}/{str(self.course.id)}/learners" + assert response.url == expected_url + + @override_settings(DISCUSSIONS_MICROFRONTEND_URL="http://test.url") + def test_redirect_from_legacy_single_thread_to_new_experience(self): + """ + Verify that a legacy single url is redirected to corresponding MFE thread url when the ENABLE_DISCUSSIONS_MFE + flag is enabled + """ + + with override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True): + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) + url = reverse("single_thread", args=[self.course.id, "test_discussion", "test_thread"]) + response = self.client.get(url) + assert response.status_code == 302 + expected_url = f"{settings.DISCUSSIONS_MICROFRONTEND_URL}/{str(self.course.id)}/posts/test_thread" + assert response.url == expected_url diff --git a/openedx/core/djangoapps/discussions/config/waffle.py b/openedx/core/djangoapps/discussions/config/waffle.py index eca6fc9708..1d4c67e9e1 100644 --- a/openedx/core/djangoapps/discussions/config/waffle.py +++ b/openedx/core/djangoapps/discussions/config/waffle.py @@ -2,8 +2,6 @@ This module contains various configuration settings via waffle switches for the discussions app. """ -from django.conf import settings - from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag WAFFLE_FLAG_NAMESPACE = "discussions" @@ -45,31 +43,3 @@ ENABLE_PAGES_AND_RESOURCES_MICROFRONTEND = CourseWaffleFlag( ENABLE_NEW_STRUCTURE_DISCUSSIONS = CourseWaffleFlag( f"{WAFFLE_FLAG_NAMESPACE}.enable_new_structure_discussions", __name__ ) - -# .. toggle_name: discussions.enable_forum_v2 -# .. toggle_implementation: CourseWaffleFlag -# .. toggle_default: False -# .. toggle_description: Waffle flag to use the forum v2 instead of v1(cs_comment_service) -# .. toggle_use_cases: temporary, open_edx -# .. toggle_creation_date: 2024-9-26 -# .. toggle_target_removal_date: 2025-12-05 -ENABLE_FORUM_V2 = CourseWaffleFlag(f"{WAFFLE_FLAG_NAMESPACE}.enable_forum_v2", __name__) - - -def is_forum_v2_enabled(course_key): - """ - Returns whether forum V2 is enabled on the course. This is a 2-step check: - - 1. Check value of settings.DISABLE_FORUM_V2: if it exists and is true, this setting overrides any course flag. - 2. Else, check the value of the corresponding course waffle flag. - """ - if is_forum_v2_disabled_globally(): - return False - return ENABLE_FORUM_V2.is_enabled(course_key) - - -def is_forum_v2_disabled_globally() -> bool: - """ - Return True if DISABLE_FORUM_V2 is defined and true-ish. - """ - return getattr(settings, "DISABLE_FORUM_V2", False) 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 a368d09830..2d1c53f62b 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/comment.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/comment.py @@ -104,6 +104,32 @@ class Comment(models.Model): soup = BeautifulSoup(self.body, 'html.parser') return soup.get_text() + @classmethod + def retrieve_all(cls, params=None): + """ + Retrieve all comments for a user in a course using Forum v2 API. + + Arguments: + params: Dictionary with keys: + - user_id: The ID of the user + - course_id: The ID of the course + - flagged: Boolean for flagged comments + - page: Page number + - per_page: Items per page + + Returns: + Dictionary with collection, comment_count, num_pages, page + """ + if params is None: + params = {} + return forum_api.get_user_comments( + user_id=params.get('user_id'), + course_id=params.get('course_id'), + flagged=params.get('flagged', False), + page=params.get('page', 1), + per_page=params.get('per_page', 10), + ) + @classmethod def get_user_comment_count(cls, user_id, course_ids): """ @@ -149,11 +175,3 @@ def _url_for_thread_comments(thread_id): def _url_for_comment(comment_id): return f"{settings.PREFIX}/comments/{comment_id}" - - -def _url_for_flag_abuse_comment(comment_id): - return f"{settings.PREFIX}/comments/{comment_id}/abuse_flag" - - -def _url_for_unflag_abuse_comment(comment_id): - return f"{settings.PREFIX}/comments/{comment_id}/abuse_unflag" diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/course.py b/openedx/core/djangoapps/django_comment_common/comment_client/course.py index 8cbb580e78..1dad0ca159 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/course.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/course.py @@ -8,9 +8,6 @@ from edx_django_utils.monitoring import function_trace from opaque_keys.edx.keys import CourseKey from forum import api as forum_api -from openedx.core.djangoapps.django_comment_common.comment_client import settings -from openedx.core.djangoapps.django_comment_common.comment_client.utils import perform_request -from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled def get_course_commentable_counts(course_key: CourseKey) -> Dict[str, Dict[str, int]]: @@ -31,19 +28,7 @@ def get_course_commentable_counts(course_key: CourseKey) -> Dict[str, Dict[str, } """ - if is_forum_v2_enabled(course_key): - commentable_stats = forum_api.get_commentables_stats(str(course_key)) - else: - url = f"{settings.PREFIX}/commentables/{course_key}/counts" - commentable_stats = perform_request( - 'get', - url, - metric_tags=[ - f"course_key:{course_key}", - "function:get_course_commentable_counts", - ], - metric_action='commentable_stats.retrieve', - ) + commentable_stats = forum_api.get_commentables_stats(str(course_key)) return commentable_stats @@ -81,20 +66,7 @@ def get_course_user_stats(course_key: CourseKey, params: Optional[Dict] = None) """ if params is None: params = {} - if is_forum_v2_enabled(course_key): - course_stats = forum_api.get_user_course_stats(str(course_key), **params) - else: - url = f"{settings.PREFIX}/users/{course_key}/stats" - course_stats = perform_request( - 'get', - url, - params, - metric_action='user.course_stats', - metric_tags=[ - f"course_key:{course_key}", - "function:get_course_user_stats", - ], - ) + course_stats = forum_api.get_user_course_stats(str(course_key), **params) return course_stats @@ -109,17 +81,5 @@ def update_course_users_stats(course_key: CourseKey) -> Dict: Returns: dict: data returned by API. Contains count of users updated. """ - if is_forum_v2_enabled(course_key): - course_stats = forum_api.update_users_in_course(str(course_key)) - else: - url = f"{settings.PREFIX}/users/{course_key}/update_stats" - course_stats = perform_request( - 'post', - url, - metric_action='user.update_course_stats', - metric_tags=[ - f"course_key:{course_key}", - "function:update_course_users_stats", - ], - ) + course_stats = forum_api.update_users_in_course(str(course_key)) return course_stats diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/models.py b/openedx/core/djangoapps/django_comment_common/comment_client/models.py index 4544a463ed..cbb6b25071 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/models.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/models.py @@ -2,11 +2,9 @@ import logging -import typing as t from .utils import CommentClientRequestError, extract, perform_request, get_course_key from forum import api as forum_api -from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled, is_forum_v2_disabled_globally log = logging.getLogger(__name__) @@ -74,10 +72,11 @@ class Model: def _retrieve(self, *args, **kwargs): course_id = self.attributes.get("course_id") or kwargs.get("course_key") if not course_id: - _, course_id = is_forum_v2_enabled_for_comment(self.id) + course_id = forum_api.get_course_id_by_comment(self.id) + response = None if self.type == "comment": response = forum_api.get_parent_comment(comment_id=self.attributes["id"], course_id=course_id) - else: + if response is None: raise CommentClientRequestError("Forum v2 API call is missing") self._update_from_response(response) @@ -206,18 +205,15 @@ class Model: request_params.update(params) course_id = self.attributes.get("course_id") or request_params.get("course_id") course_key = get_course_key(course_id) - if is_forum_v2_enabled(course_key): - response = None - if self.type == "comment": - response = self.handle_update_comment(request_params, str(course_key)) - elif self.type == "thread": - response = self.handle_update_thread(request_params, str(course_key)) - elif self.type == "user": - response = self.handle_update_user(request_params, str(course_key)) - if response is None: - raise CommentClientRequestError("Forum v2 API call is missing") - else: - response = self.perform_http_put_request(request_params) + response = None + if self.type == "comment": + response = self.handle_update_comment(request_params, str(course_key)) + elif self.type == "thread": + response = self.handle_update_thread(request_params, str(course_key)) + elif self.type == "user": + response = self.handle_update_user(request_params, str(course_key)) + if response is None: + raise CommentClientRequestError("Forum v2 API call is missing") return response def handle_update_user(self, request_params, course_id): @@ -274,28 +270,6 @@ class Model: response = forum_api.update_thread(**request_data) return response - def perform_http_put_request(self, request_params): - url = self.url(action="put", params=self.attributes) - response = perform_request( - "put", - url, - request_params, - metric_tags=self._metric_tags, - metric_action="model.update", - ) - return response - - def perform_http_post_request(self): - url = self.url(action="post", params=self.attributes) - response = perform_request( - "post", - url, - self.initializable_attributes(), - metric_tags=self._metric_tags, - metric_action="model.insert", - ) - return response - def handle_create(self, params=None): course_id = self.attributes.get("course_id") or params.get("course_id") course_key = str(get_course_key(course_id)) @@ -348,22 +322,3 @@ class Model: response = forum_api.create_thread(**params) return response - - -def is_forum_v2_enabled_for_comment(comment_id: str) -> tuple[bool, t.Optional[str]]: - """ - Figure out whether we use forum v2 for a given comment. - - See is_forum_v2_enabled_for_thread. - - Return: - - enabled (bool) - course_id (str or None) - """ - if is_forum_v2_disabled_globally(): - return False, None - - course_id = forum_api.get_course_id_by_comment(comment_id) - course_key = get_course_key(course_id) - return is_forum_v2_enabled(course_key), course_id 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 b884352ce3..ffb9147aca 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py @@ -3,19 +3,12 @@ import logging import time -import typing as t from eventtracking import tracker -from django.core.exceptions import ObjectDoesNotExist from forum import api as forum_api -from forum.api.threads import prepare_thread_api_response -from forum.backend import get_backend -from forum.backends.mongodb.threads import CommentThread -from forum.utils import ForumV2RequestError -from rest_framework.serializers import ValidationError +from forum.backends.mongodb.threads import CommentThread as ForumThread -from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled, is_forum_v2_disabled_globally from . import models, settings, utils @@ -169,7 +162,7 @@ class Thread(models.Model): request_params = utils.clean_forum_params(request_params) course_id = kwargs.get("course_id") if not course_id: - _, course_id = is_forum_v2_enabled_for_thread(self.id) + course_id = forum_api.get_course_id_by_thread(self.id) if user_id := request_params.get('user_id'): request_params['user_id'] = str(user_id) response = forum_api.get_thread( @@ -228,7 +221,7 @@ class Thread(models.Model): @classmethod def get_user_threads_count(cls, user_id, course_ids): """ - Returns threads and responses count of user in the given course_ids. + Returns threads count of user in the given course_ids. TODO: Add support for MySQL backend as well """ query_params = { @@ -236,51 +229,7 @@ class Thread(models.Model): "author_id": str(user_id), "_type": "CommentThread" } - return CommentThread()._collection.count_documents(query_params) # pylint: disable=protected-access - - @classmethod - def _delete_thread(cls, thread_id, course_id=None): - """ - Deletes a thread - """ - prefix = "<