From ea63816f91f5b7b1caf6b6d8f6aa6c45d346e80f Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Thu, 11 Dec 2025 11:18:06 -0500 Subject: [PATCH 1/2] fix: CourseLimitedStaffRole should not be able to access studio. We previously fixed this when the CourseLimitedStaffRole was applied to a course but did not handle the case where the role is applied to a user for a whole org. The underlying issue is that the CourseLimitedStaffRole is a subclass of the CourseStaffRole and much of the system assumes that subclesses are for giving more access not less access. To prevent that from happening for the case of the CourseLimitedStaffRole, when we do CourseStaffRole access checks, we use the strict_role_checking context manager to ensure that we're not accidentally granting the limited_staff role too much access. --- .../contentstore/tests/test_course_listing.py | 44 +++++++++++++++++++ cms/djangoapps/contentstore/views/course.py | 5 ++- common/djangoapps/student/auth.py | 6 ++- common/djangoapps/student/tests/test_authz.py | 18 ++++++++ 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_course_listing.py b/cms/djangoapps/contentstore/tests/test_course_listing.py index e46b493b7b39..a2b6f07d15ef 100644 --- a/cms/djangoapps/contentstore/tests/test_course_listing.py +++ b/cms/djangoapps/contentstore/tests/test_course_listing.py @@ -24,8 +24,10 @@ get_courses_accessible_to_user ) from common.djangoapps.course_action_state.models import CourseRerunState +from common.djangoapps.student.models.user import CourseAccessRole from common.djangoapps.student.roles import ( CourseInstructorRole, + CourseLimitedStaffRole, CourseStaffRole, GlobalStaff, OrgInstructorRole, @@ -188,6 +190,48 @@ def test_staff_course_listing(self): with self.assertNumQueries(2): list(_accessible_courses_summary_iter(self.request)) + def test_course_limited_staff_course_listing(self): + # Setup a new course + course_location = self.store.make_course_key('Org', 'CreatedCourse', 'Run') + CourseFactory.create( + org=course_location.org, + number=course_location.course, + run=course_location.run + ) + course = CourseOverviewFactory.create(id=course_location, org=course_location.org) + + # Add the user as a course_limited_staff on the course + CourseLimitedStaffRole(course.id).add_users(self.user) + self.assertTrue(CourseLimitedStaffRole(course.id).has_user(self.user)) + + # Fetch accessible courses list & verify their count + courses_list_by_staff, __ = get_courses_accessible_to_user(self.request) + + # Limited Course Staff should not be able to list courses in Studio + assert len(list(courses_list_by_staff)) == 0 + + def test_org_limited_staff_course_listing(self): + + # Setup a new course + course_location = self.store.make_course_key('Org', 'CreatedCourse', 'Run') + CourseFactory.create( + org=course_location.org, + number=course_location.course, + run=course_location.run + ) + course = CourseOverviewFactory.create(id=course_location, org=course_location.org) + + # Add a user as course_limited_staff on the org + # This is not possible using the course roles classes but is possible via Django admin so we + # insert a row into the model directly to test that scenario. + CourseAccessRole.objects.create(user=self.user, org=course_location.org, role=CourseLimitedStaffRole.ROLE) + + # Fetch accessible courses list & verify their count + courses_list_by_staff, __ = get_courses_accessible_to_user(self.request) + + # Limited Course Staff should not be able to list courses in Studio + assert len(list(courses_list_by_staff)) == 0 + def test_get_course_list_with_invalid_course_location(self): """ Test getting courses with invalid course location (course deleted from modulestore). diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 81ad1eb6ddde..deeb4a4ee2fe 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -53,6 +53,7 @@ GlobalStaff, UserBasedRole, OrgStaffRole, + strict_role_checking, ) from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadRequest, expect_json from common.djangoapps.util.string_utils import _has_non_ascii_characters @@ -533,7 +534,9 @@ def filter_ccx(course_access): return not isinstance(course_access.course_id, CCXLocator) instructor_courses = UserBasedRole(request.user, CourseInstructorRole.ROLE).courses_with_role() - staff_courses = UserBasedRole(request.user, CourseStaffRole.ROLE).courses_with_role() + with strict_role_checking(): + staff_courses = UserBasedRole(request.user, CourseStaffRole.ROLE).courses_with_role() + all_courses = list(filter(filter_ccx, instructor_courses | staff_courses)) courses_list = [] course_keys = {} diff --git a/common/djangoapps/student/auth.py b/common/djangoapps/student/auth.py index e199142fe377..047f0174a062 100644 --- a/common/djangoapps/student/auth.py +++ b/common/djangoapps/student/auth.py @@ -24,6 +24,7 @@ OrgInstructorRole, OrgLibraryUserRole, OrgStaffRole, + strict_role_checking, ) # Studio permissions: @@ -115,8 +116,9 @@ def get_user_permissions(user, course_key, org=None, service_variant=None): return STUDIO_NO_PERMISSIONS # Staff have all permissions except EDIT_ROLES: - if OrgStaffRole(org=org).has_user(user) or (course_key and user_has_role(user, CourseStaffRole(course_key))): - return STUDIO_VIEW_USERS | STUDIO_EDIT_CONTENT | STUDIO_VIEW_CONTENT + with strict_role_checking(): + if OrgStaffRole(org=org).has_user(user) or (course_key and user_has_role(user, CourseStaffRole(course_key))): + return STUDIO_VIEW_USERS | STUDIO_EDIT_CONTENT | STUDIO_VIEW_CONTENT # Otherwise, for libraries, users can view only: if course_key and isinstance(course_key, LibraryLocator): diff --git a/common/djangoapps/student/tests/test_authz.py b/common/djangoapps/student/tests/test_authz.py index c0b88e6318b5..70636e04b68a 100644 --- a/common/djangoapps/student/tests/test_authz.py +++ b/common/djangoapps/student/tests/test_authz.py @@ -11,6 +11,7 @@ from django.test import TestCase, override_settings from opaque_keys.edx.locator import CourseLocator +from common.djangoapps.student.models.user import CourseAccessRole from common.djangoapps.student.auth import ( add_users, has_studio_read_access, @@ -305,6 +306,23 @@ def test_limited_staff_no_studio_access_cms(self): assert not has_studio_read_access(self.limited_staff, self.course_key) assert not has_studio_write_access(self.limited_staff, self.course_key) + @override_settings(SERVICE_VARIANT='cms') + def test_limited_org_staff_no_studio_access_cms(self): + """ + Verifies that course limited staff have no read and no write access when SERVICE_VARIANT is not 'lms'. + """ + # Add a user as course_limited_staff on the org + # This is not possible using the course roles classes but is possible via Django admin so we + # insert a row into the model directly to test that scenario. + CourseAccessRole.objects.create( + user=self.limited_staff, + org=self.course_key.org, + role=CourseLimitedStaffRole.ROLE, + ) + + assert not has_studio_read_access(self.limited_staff, self.course_key) + assert not has_studio_write_access(self.limited_staff, self.course_key) + class CourseOrgGroupTest(TestCase): """ From e53048ddd821b5e70c22031aa4d69a59702a6f8a Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed <68893403+taimoor-ahmed-1@users.noreply.github.com> Date: Thu, 1 Jan 2026 14:48:41 +0500 Subject: [PATCH 2/2] fix: discussions app update for latest forum tag (#37785) Co-authored-by: ayesha waris <73840786+ayesha-waris@users.noreply.github.com> --- .../student/tests/test_admin_views.py | 2 +- .../course_goals/tests/test_user_activity.py | 5 - .../django_comment_client/base/tests.py | 2587 --------- .../django_comment_client/base/tests_v2.py | 2696 ++++++++++ .../django_comment_client/base/views.py | 6 +- .../django_comment_client/tests/group_id.py | 292 +- .../django_comment_client/tests/mixins.py | 97 + .../django_comment_client/tests/test_utils.py | 31 - .../django_comment_client/tests/utils.py | 16 +- lms/djangoapps/discussion/plugins.py | 2 +- lms/djangoapps/discussion/rest_api/api.py | 24 +- .../discussion/rest_api/tests/test_api.py | 4385 +-------------- .../discussion/rest_api/tests/test_api_v2.py | 4781 +++++++++++++++++ ..._serializers.py => test_serializers_v2.py} | 1623 +++--- .../discussion/rest_api/tests/test_tasks.py | 456 +- .../rest_api/tests/test_tasks_v2.py | 756 +++ .../discussion/rest_api/tests/test_utils.py | 3 +- .../discussion/rest_api/tests/test_views.py | 3457 +----------- .../rest_api/tests/test_views_v2.py | 2669 +++++++++ .../discussion/rest_api/tests/utils.py | 309 ++ lms/djangoapps/discussion/tests/test_tasks.py | 80 +- .../discussion/tests/test_tasks_v2.py | 489 ++ lms/djangoapps/discussion/tests/test_views.py | 1978 +------ .../discussion/tests/test_views_v2.py | 2132 ++++++++ lms/djangoapps/discussion/tests/utils.py | 70 + lms/urls.py | 2 - .../core/djangoapps/discussions/README.rst | 9 +- .../djangoapps/discussions/config/waffle.py | 30 - .../comment_client/comment.py | 163 +- .../comment_client/course.py | 46 +- .../comment_client/models.py | 216 +- .../comment_client/subscriptions.py | 23 +- .../comment_client/thread.py | 292 +- .../comment_client/user.py | 341 +- .../comment_client/utils.py | 118 +- requirements/common_constraints.txt | 6 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- requirements/pip.txt | 4 +- 41 files changed, 15686 insertions(+), 14518 deletions(-) delete mode 100644 lms/djangoapps/discussion/django_comment_client/base/tests.py create mode 100644 lms/djangoapps/discussion/django_comment_client/base/tests_v2.py create mode 100644 lms/djangoapps/discussion/django_comment_client/tests/mixins.py create mode 100644 lms/djangoapps/discussion/rest_api/tests/test_api_v2.py rename lms/djangoapps/discussion/rest_api/tests/{test_serializers.py => test_serializers_v2.py} (80%) create mode 100644 lms/djangoapps/discussion/rest_api/tests/test_tasks_v2.py create mode 100644 lms/djangoapps/discussion/rest_api/tests/test_views_v2.py create mode 100644 lms/djangoapps/discussion/tests/test_tasks_v2.py create mode 100644 lms/djangoapps/discussion/tests/test_views_v2.py create mode 100644 lms/djangoapps/discussion/tests/utils.py diff --git a/common/djangoapps/student/tests/test_admin_views.py b/common/djangoapps/student/tests/test_admin_views.py index 2914bcd61c43..f03934760427 100644 --- a/common/djangoapps/student/tests/test_admin_views.py +++ b/common/djangoapps/student/tests/test_admin_views.py @@ -198,7 +198,7 @@ def test_username_is_readonly_for_user(self): Changing the username is still possible using the database or from the model directly. - However, changing the username might cause issues with the logs and/or the cs_comments_service since it + However, changing the username might cause issues with the logs and/or the forum service since it stores the username in a different database. """ request = Mock() diff --git a/lms/djangoapps/course_goals/tests/test_user_activity.py b/lms/djangoapps/course_goals/tests/test_user_activity.py index 04eb267152d4..285c538862db 100644 --- a/lms/djangoapps/course_goals/tests/test_user_activity.py +++ b/lms/djangoapps/course_goals/tests/test_user_activity.py @@ -20,7 +20,6 @@ from common.djangoapps.student.tests.factories import UserFactory from common.djangoapps.util.testing import UrlResetMixin from lms.djangoapps.course_goals.models import UserActivity -from openedx.core.djangoapps.django_comment_common.models import ForumsConfig from openedx.features.course_experience import ENABLE_COURSE_GOALS User = get_user_model() @@ -53,10 +52,6 @@ def setUp(self): self.request = RequestFactory().get('foo') self.request.user = self.user - config = ForumsConfig.current() - config.enabled = True - config.save() - def test_mfe_tabs_call_user_activity(self): ''' New style tabs call one of two metadata endpoints 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 d2a4f921f28b..000000000000 --- a/lms/djangoapps/discussion/django_comment_client/base/tests.py +++ /dev/null @@ -1,2587 +0,0 @@ -import pytest -# pylint: skip-file -"""Tests for django comment client views.""" - - -import json -import logging -from contextlib import contextmanager -from unittest import mock -from unittest.mock import ANY, Mock, patch - -import ddt -from django.contrib.auth.models import User -from django.core.management import call_command -from django.test.client import RequestFactory -from django.urls import reverse -from eventtracking.processors.exceptions import EventEmissionExit -from opaque_keys.edx.keys import CourseKey -from openedx_events.learning.signals import FORUM_THREAD_CREATED, FORUM_THREAD_RESPONSE_CREATED, FORUM_RESPONSE_COMMENT_CREATED - -from common.djangoapps.course_modes.models import CourseMode -from common.djangoapps.course_modes.tests.factories import CourseModeFactory -from common.djangoapps.student.roles import CourseStaffRole, UserBasedRole -from common.djangoapps.student.tests.factories import CourseAccessRoleFactory, CourseEnrollmentFactory, UserFactory -from common.djangoapps.track.middleware import TrackMiddleware -from common.djangoapps.track.views import segmentio -from common.djangoapps.track.views.tests.base import SEGMENTIO_TEST_USER_ID, SegmentIOTrackingTestCaseBase -from common.djangoapps.util.testing import UrlResetMixin -from common.test.utils import MockSignalHandlerMixin, disable_signal -from lms.djangoapps.discussion.django_comment_client.base import views -from lms.djangoapps.discussion.django_comment_client.tests.group_id import ( - CohortedTopicGroupIdTestMixin, - GroupIdAssertionMixin, - NonCohortedTopicGroupIdTestMixin -) -from lms.djangoapps.discussion.django_comment_client.tests.unicode import UnicodeTestMixin -from lms.djangoapps.discussion.django_comment_client.tests.utils import CohortedTestCase, ForumsEnableMixin -from lms.djangoapps.teams.tests.factories import CourseTeamFactory, CourseTeamMembershipFactory -from openedx.core.djangoapps.course_groups.cohorts import set_course_cohorted -from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory -from openedx.core.djangoapps.django_comment_common.comment_client import Thread -from openedx.core.djangoapps.django_comment_common.models import ( - FORUM_ROLE_STUDENT, - CourseDiscussionSettings, - Role, - assign_role -) -from openedx.core.djangoapps.django_comment_common.utils import ( - ThreadContext, - seed_permissions_roles, -) -from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES -from openedx.core.lib.teams_config import TeamsConfig -from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.django import modulestore -from xmodule.modulestore.tests.django_utils import ( - TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase, SharedModuleStoreTestCase, -) -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory, check_mongo_calls - -from .event_transformers import ForumThreadViewedEventTransformer - -log = logging.getLogger(__name__) - -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) - - -@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) -@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) -class CreateThreadGroupIdTestCase( - MockRequestSetupMixin, - CohortedTestCase, - CohortedTopicGroupIdTestMixin, - NonCohortedTopicGroupIdTestMixin -): - cs_endpoint = "/threads" - - def setUp(self): - super().setUp() - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" - ) - self.mock_get_course_id_by_thread = patcher.start() - self.addCleanup(patcher.stop) - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" - ) - self.mock_get_course_id_by_comment = patcher.start() - self.addCleanup(patcher.stop) - - def call_view(self, mock_is_forum_v2_enabled, mock_request, commentable_id, user, group_id, pass_group_id=True): - mock_is_forum_v2_enabled.return_value = False - self._set_mock_request_data(mock_request, {}) - request_data = {"body": "body", "title": "title", "thread_type": "discussion"} - if pass_group_id: - request_data["group_id"] = group_id - request = RequestFactory().post("dummy_url", request_data) - request.user = user - request.view_name = "create_thread" - - return views.create_thread( - request, - course_id=str(self.course.id), - commentable_id=commentable_id - ) - - def test_group_info_in_response(self, mock_is_forum_v2_enabled, mock_request): - response = self.call_view( - mock_is_forum_v2_enabled, - mock_request, - "cohorted_topic", - self.student, - '' - ) - self._assert_json_response_contains_group_info(response) - - -@patch('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) -@disable_signal(views, 'thread_edited') -@disable_signal(views, 'thread_voted') -@disable_signal(views, 'thread_deleted') -class ThreadActionGroupIdTestCase( - MockRequestSetupMixin, - CohortedTestCase, - GroupIdAssertionMixin -): - def call_view( - self, - view_name, - mock_is_forum_v2_enabled, - mock_request, - user=None, - post_params=None, - view_args=None - ): - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" - ) - self.mock_get_course_id_by_thread = patcher.start() - self.addCleanup(patcher.stop) - mock_is_forum_v2_enabled.return_value = False - self._set_mock_request_data( - mock_request, - { - "user_id": str(self.student.id), - "group_id": self.student_cohort.id, - "closed": False, - "type": "thread", - "commentable_id": "non_team_dummy_id", - "body": "test body", - } - ) - request = RequestFactory().post("dummy_url", post_params or {}) - request.user = user or self.student - request.view_name = view_name - - return getattr(views, view_name)( - request, - course_id=str(self.course.id), - thread_id="dummy", - **(view_args or {}) - ) - - def test_update(self, mock_is_forum_v2_enabled, mock_request): - response = self.call_view( - "update_thread", - mock_is_forum_v2_enabled, - mock_request, - post_params={"body": "body", "title": "title"} - ) - self._assert_json_response_contains_group_info(response) - - def test_delete(self, mock_is_forum_v2_enabled, mock_request): - response = self.call_view("delete_thread", mock_is_forum_v2_enabled, mock_request) - self._assert_json_response_contains_group_info(response) - - def test_vote(self, mock_is_forum_v2_enabled, mock_request): - response = self.call_view( - "vote_for_thread", - mock_is_forum_v2_enabled, - mock_request, - view_args={"value": "up"} - ) - self._assert_json_response_contains_group_info(response) - response = self.call_view("undo_vote_for_thread", mock_is_forum_v2_enabled, mock_request) - self._assert_json_response_contains_group_info(response) - - def test_flag(self, mock_is_forum_v2_enabled, mock_request): - with mock.patch('openedx.core.djangoapps.django_comment_common.signals.thread_flagged.send') as signal_mock: - response = self.call_view("flag_abuse_for_thread", mock_is_forum_v2_enabled, mock_request) - self._assert_json_response_contains_group_info(response) - self.assertEqual(signal_mock.call_count, 1) - response = self.call_view("un_flag_abuse_for_thread", mock_is_forum_v2_enabled, mock_request) - self._assert_json_response_contains_group_info(response) - - def test_pin(self, mock_is_forum_v2_enabled, mock_request): - response = self.call_view( - "pin_thread", - mock_is_forum_v2_enabled, - mock_request, - user=self.moderator - ) - self._assert_json_response_contains_group_info(response) - response = self.call_view( - "un_pin_thread", - mock_is_forum_v2_enabled, - mock_request, - user=self.moderator - ) - self._assert_json_response_contains_group_info(response) - - def test_openclose(self, mock_is_forum_v2_enabled, mock_request): - response = self.call_view( - "openclose_thread", - mock_is_forum_v2_enabled, - mock_request, - user=self.moderator - ) - self._assert_json_response_contains_group_info( - response, - lambda d: d['content'] - ) - - -class ViewsTestCaseMixin: - - def set_up_course(self, block_count=0): - """ - Creates a course, optionally with block_count discussion blocks, and - a user with appropriate permissions. - """ - - # create a course - self.course = CourseFactory.create( - org='MITx', course='999', - discussion_topics={"Some Topic": {"id": "some_topic"}}, - display_name='Robot Super Course', - ) - self.course_id = self.course.id - - # add some discussion blocks - for i in range(block_count): - BlockFactory.create( - parent_location=self.course.location, - category='discussion', - discussion_id=f'id_module_{i}', - discussion_category=f'Category {i}', - discussion_target=f'Discussion {i}' - ) - - # seed the forums permissions and roles - call_command('seed_permissions_roles', str(self.course_id)) - - # Patch the comment client user save method so it does not try - # to create a new cc user when creating a django user - with patch('common.djangoapps.student.models.user.cc.User.save'): - uname = 'student' - email = 'student@edx.org' - self.password = 'Password1234' - - # Create the user and make them active so we can log them in. - self.student = UserFactory.create(username=uname, email=email, password=self.password) - self.student.is_active = True - self.student.save() - - # Add a discussion moderator - self.moderator = UserFactory.create(password=self.password) - - # Enroll the student in the course - CourseEnrollmentFactory(user=self.student, - course_id=self.course_id) - - # Enroll the moderator and give them the appropriate roles - CourseEnrollmentFactory(user=self.moderator, course_id=self.course.id) - self.moderator.roles.add(Role.objects.get(name="Moderator", course_id=self.course.id)) - - assert self.client.login(username='student', password=self.password) - - def _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' - - -@ddt.ddt -@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) -@disable_signal(views, 'thread_created') -@disable_signal(views, 'thread_edited') -class ViewsQueryCountTestCase( - ForumsEnableMixin, - UrlResetMixin, - ModuleStoreTestCase, - MockRequestSetupMixin, - ViewsTestCaseMixin -): - - CREATE_USER = False - ENABLED_CACHES = ['default', 'mongo_metadata_inheritance', 'loc_cache'] - ENABLED_SIGNALS = ['course_published'] - - @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - super().setUp() - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" - ) - self.mock_get_course_id_by_thread = patcher.start() - self.addCleanup(patcher.stop) - - def count_queries(func): # pylint: disable=no-self-argument - """ - Decorates test methods to count mongo and SQL calls for a - particular modulestore. - """ - - def inner(self, default_store, block_count, mongo_calls, sql_queries, *args, **kwargs): - with modulestore().default_store(default_store): - self.set_up_course(block_count=block_count) - self.clear_caches() - with self.assertNumQueries(sql_queries, table_ignorelist=QUERY_COUNT_TABLE_IGNORELIST): - with check_mongo_calls(mongo_calls): - func(self, *args, **kwargs) - return inner - - @ddt.data( - (ModuleStoreEnum.Type.split, 3, 8, 42), - ) - @ddt.unpack - @count_queries - def test_create_thread(self, mock_is_forum_v2_enabled, mock_request): - self.create_thread_helper(mock_is_forum_v2_enabled, mock_request) - - @ddt.data( - (ModuleStoreEnum.Type.split, 3, 6, 41), - ) - @ddt.unpack - @count_queries - def test_update_thread(self, mock_is_forum_v2_enabled, mock_request): - self.update_thread_helper(mock_is_forum_v2_enabled, mock_request) - - -@ddt.ddt -@disable_signal(views, 'comment_flagged') -@disable_signal(views, 'thread_flagged') -@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) -@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) -class ViewsTestCase( - ForumsEnableMixin, - UrlResetMixin, - SharedModuleStoreTestCase, - MockRequestSetupMixin, - ViewsTestCaseMixin, - MockSignalHandlerMixin -): - - @classmethod - def setUpClass(cls): - # pylint: disable=super-method-not-called - with super().setUpClassAndTestData(): - cls.course = CourseFactory.create( - org='MITx', course='999', - discussion_topics={"Some Topic": {"id": "some_topic"}}, - display_name='Robot Super Course', - ) - - @classmethod - def setUpTestData(cls): - super().setUpTestData() - - cls.course_id = cls.course.id - - # seed the forums permissions and roles - call_command('seed_permissions_roles', str(cls.course_id)) - - @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - # Patching the ENABLE_DISCUSSION_SERVICE value affects the contents of urls.py, - # so we need to call super.setUp() which reloads urls.py (because - # of the UrlResetMixin) - super().setUp() - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" - ) - self.mock_get_course_id_by_comment = patcher.start() - self.addCleanup(patcher.stop) - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" - ) - self.mock_get_course_id_by_thread = patcher.start() - self.addCleanup(patcher.stop) - # Patch the comment client user save method so it does not try - # to create a new cc user when creating a django user - with patch('common.djangoapps.student.models.user.cc.User.save'): - 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) - - @contextmanager - def assert_discussion_signals(self, signal, user=None): - if user is None: - user = self.student - with self.assert_signal_sent(views, signal, sender=None, user=user, exclude_args=('post',)): - yield - - def test_create_thread(self, mock_is_forum_v2_enabled, mock_request): - with self.assert_discussion_signals('thread_created'): - self.create_thread_helper(mock_is_forum_v2_enabled, mock_request) - - def test_create_thread_standalone(self, mock_is_forum_v2_enabled, mock_request): - team = CourseTeamFactory.create( - name="A Team", - course_id=self.course_id, - topic_id='topic_id', - discussion_topic_id="i4x-MITx-999-course-Robot_Super_Course" - ) - - # Add the student to the team so they can post to the commentable. - team.add_user(self.student) - - # create_thread_helper verifies that extra data are passed through to the comments service - self.create_thread_helper(mock_is_forum_v2_enabled, mock_request, extra_response_data={'context': ThreadContext.STANDALONE}) - - @ddt.data( - ('follow_thread', 'thread_followed'), - ('unfollow_thread', 'thread_unfollowed'), - ) - @ddt.unpack - def test_follow_unfollow_thread_signals(self, view_name, signal, mock_is_forum_v2_enabled, mock_request): - self.create_thread_helper(mock_is_forum_v2_enabled, mock_request) - - with self.assert_discussion_signals(signal): - response = self.client.post( - reverse( - view_name, - kwargs={"course_id": str(self.course_id), "thread_id": 'i4x-MITx-999-course-Robot_Super_Course'} - ) - ) - assert response.status_code == 200 - - def test_delete_thread(self, mock_is_forum_v2_enabled, mock_request): - mock_is_forum_v2_enabled.return_value = False - self._set_mock_request_data(mock_request, { - "user_id": str(self.student.id), - "closed": False, - "body": "test body", - }) - test_thread_id = "test_thread_id" - request = RequestFactory().post("dummy_url", {"id": test_thread_id}) - request.user = self.student - request.view_name = "delete_thread" - with self.assert_discussion_signals('thread_deleted'): - response = views.delete_thread( - request, - course_id=str(self.course.id), - thread_id=test_thread_id - ) - assert response.status_code == 200 - assert mock_request.called - - def test_delete_comment(self, mock_is_forum_v2_enabled, mock_request): - mock_is_forum_v2_enabled.return_value = False - self._set_mock_request_data(mock_request, { - "user_id": str(self.student.id), - "closed": False, - "body": "test body", - }) - test_comment_id = "test_comment_id" - request = RequestFactory().post("dummy_url", {"id": test_comment_id}) - request.user = self.student - request.view_name = "delete_comment" - with self.assert_discussion_signals('comment_deleted'): - response = views.delete_comment( - request, - course_id=str(self.course.id), - comment_id=test_comment_id - ) - assert response.status_code == 200 - assert mock_request.called - args = mock_request.call_args[0] - assert args[0] == 'delete' - assert args[1].endswith(f"/{test_comment_id}") - - def _test_request_error(self, view_name, view_kwargs, data, mock_is_forum_v2_enabled, mock_request): - """ - Submit a request against the given view with the given data and ensure - that the result is a 400 error and that no data was posted using - mock_request - """ - mock_is_forum_v2_enabled.return_value = False - self._setup_mock_request(mock_request, include_depth=(view_name == "create_sub_comment")) - - response = self.client.post(reverse(view_name, kwargs=view_kwargs), data=data) - assert response.status_code == 400 - for call in mock_request.call_args_list: - assert call[0][0].lower() == 'get' - - def test_create_thread_no_title(self, mock_is_forum_v2_enabled, mock_request): - self._test_request_error( - "create_thread", - {"commentable_id": "dummy", "course_id": str(self.course_id)}, - {"body": "foo"}, - mock_is_forum_v2_enabled, - mock_request - ) - - def test_create_thread_empty_title(self, mock_is_forum_v2_enabled, mock_request): - self._test_request_error( - "create_thread", - {"commentable_id": "dummy", "course_id": str(self.course_id)}, - {"body": "foo", "title": " "}, - mock_is_forum_v2_enabled, - mock_request - ) - - def test_create_thread_no_body(self, mock_is_forum_v2_enabled, mock_request): - self._test_request_error( - "create_thread", - {"commentable_id": "dummy", "course_id": str(self.course_id)}, - {"title": "foo"}, - mock_is_forum_v2_enabled, - mock_request - ) - - def test_create_thread_empty_body(self, mock_is_forum_v2_enabled, mock_request): - self._test_request_error( - "create_thread", - {"commentable_id": "dummy", "course_id": str(self.course_id)}, - {"body": " ", "title": "foo"}, - mock_is_forum_v2_enabled, - mock_request - ) - - def test_update_thread_no_title(self, mock_is_forum_v2_enabled, mock_request): - self._test_request_error( - "update_thread", - {"thread_id": "dummy", "course_id": str(self.course_id)}, - {"body": "foo"}, - mock_is_forum_v2_enabled, - mock_request - ) - - def test_update_thread_empty_title(self, mock_is_forum_v2_enabled, mock_request): - self._test_request_error( - "update_thread", - {"thread_id": "dummy", "course_id": str(self.course_id)}, - {"body": "foo", "title": " "}, - mock_is_forum_v2_enabled, - mock_request - ) - - def test_update_thread_no_body(self, mock_is_forum_v2_enabled, mock_request): - self._test_request_error( - "update_thread", - {"thread_id": "dummy", "course_id": str(self.course_id)}, - {"title": "foo"}, - mock_is_forum_v2_enabled, - mock_request - ) - - def test_update_thread_empty_body(self, mock_is_forum_v2_enabled, mock_request): - self._test_request_error( - "update_thread", - {"thread_id": "dummy", "course_id": str(self.course_id)}, - {"body": " ", "title": "foo"}, - mock_is_forum_v2_enabled, - mock_request - ) - - def test_update_thread_course_topic(self, mock_is_forum_v2_enabled, mock_request): - with self.assert_discussion_signals('thread_edited'): - self.update_thread_helper(mock_is_forum_v2_enabled, mock_request) - - @patch( - 'lms.djangoapps.discussion.django_comment_client.utils.get_discussion_categories_ids', - return_value=["test_commentable"], - ) - def test_update_thread_wrong_commentable_id(self, mock_get_discussion_id_map, mock_is_forum_v2_enabled, mock_request): - self._test_request_error( - "update_thread", - {"thread_id": "dummy", "course_id": str(self.course_id)}, - {"body": "foo", "title": "foo", "commentable_id": "wrong_commentable"}, - mock_is_forum_v2_enabled, - mock_request - ) - - def test_create_comment(self, mock_is_forum_v2_enabled, mock_request): - mock_is_forum_v2_enabled.return_value = False - self._setup_mock_request(mock_request) - with self.assert_discussion_signals('comment_created'): - response = self.client.post( - reverse( - "create_comment", - kwargs={"course_id": str(self.course_id), "thread_id": "dummy"} - ), - data={"body": "body"} - ) - assert response.status_code == 200 - - def test_create_comment_no_body(self, mock_is_forum_v2_enabled, mock_request): - self._test_request_error( - "create_comment", - {"thread_id": "dummy", "course_id": str(self.course_id)}, - {}, - mock_is_forum_v2_enabled, - mock_request - ) - - def test_create_comment_empty_body(self, mock_is_forum_v2_enabled, mock_request): - self._test_request_error( - "create_comment", - {"thread_id": "dummy", "course_id": str(self.course_id)}, - {"body": " "}, - mock_is_forum_v2_enabled, - mock_request - ) - - def test_create_sub_comment_no_body(self, mock_is_forum_v2_enabled, mock_request): - self._test_request_error( - "create_sub_comment", - {"comment_id": "dummy", "course_id": str(self.course_id)}, - {}, - mock_is_forum_v2_enabled, - mock_request - ) - - def test_create_sub_comment_empty_body(self, mock_is_forum_v2_enabled, mock_request): - self._test_request_error( - "create_sub_comment", - {"comment_id": "dummy", "course_id": str(self.course_id)}, - {"body": " "}, - mock_is_forum_v2_enabled, - mock_request - ) - - def test_update_comment_no_body(self, mock_is_forum_v2_enabled, mock_request): - self._test_request_error( - "update_comment", - {"comment_id": "dummy", "course_id": str(self.course_id)}, - {}, - mock_is_forum_v2_enabled, - mock_request - ) - - def test_update_comment_empty_body(self, mock_is_forum_v2_enabled, mock_request): - self._test_request_error( - "update_comment", - {"comment_id": "dummy", "course_id": str(self.course_id)}, - {"body": " "}, - mock_is_forum_v2_enabled, - mock_request - ) - - def test_update_comment_basic(self, mock_is_forum_v2_enabled, mock_request): - mock_is_forum_v2_enabled.return_value = False - self._setup_mock_request(mock_request) - comment_id = "test_comment_id" - updated_body = "updated body" - with self.assert_discussion_signals('comment_edited'): - response = self.client.post( - reverse( - "update_comment", - kwargs={"course_id": str(self.course_id), "comment_id": comment_id} - ), - data={"body": updated_body} - ) - assert response.status_code == 200 - mock_request.assert_called_with( - "put", - f"{CS_PREFIX}/comments/{comment_id}", - headers=ANY, - params=ANY, - timeout=ANY, - data={"body": updated_body, "course_id": str(self.course_id)} - ) - - def test_flag_thread_open(self, mock_is_forum_v2_enabled, mock_request): - self.flag_thread(mock_is_forum_v2_enabled, mock_request, False) - - def test_flag_thread_close(self, mock_is_forum_v2_enabled, mock_request): - self.flag_thread(mock_is_forum_v2_enabled, mock_request, True) - - def flag_thread(self, mock_is_forum_v2_enabled, mock_request, is_closed): - mock_is_forum_v2_enabled.return_value = False - self._set_mock_request_data(mock_request, { - "title": "Hello", - "body": "this is a post", - "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": is_closed, - "id": "518d4237b023791dca00000d", - "user_id": "1", "username": "robot", - "votes": { - "count": 0, - "up_count": 0, - "down_count": 0, - "point": 0 - }, - "abuse_flaggers": [1], - "type": "thread", - "group_id": None, - "pinned": False, - "endorsed": False, - "unread_comments_count": 0, - "read": False, - "comments_count": 0, - }) - url = reverse('flag_abuse_for_thread', kwargs={ - 'thread_id': '518d4237b023791dca00000d', - 'course_id': str(self.course_id) - }) - response = self.client.post(url) - assert mock_request.called - - call_list = [ - ( - ('get', f'{CS_PREFIX}/threads/518d4237b023791dca00000d'), - { - 'data': None, - 'params': {'mark_as_read': True, 'request_id': ANY, 'with_responses': False, 'reverse_order': False, - 'merge_question_type_responses': False}, - 'headers': ANY, - 'timeout': 5 - } - ), - ( - ('put', f'{CS_PREFIX}/threads/518d4237b023791dca00000d/abuse_flag'), - { - 'data': {'user_id': '1'}, - 'params': {'request_id': ANY}, - 'headers': ANY, - 'timeout': 5 - } - ), - ( - ('get', f'{CS_PREFIX}/threads/518d4237b023791dca00000d'), - { - 'data': None, - 'params': {'mark_as_read': True, 'request_id': ANY, 'with_responses': False, 'reverse_order': False, - 'merge_question_type_responses': False}, - 'headers': ANY, - 'timeout': 5 - } - ) - ] - - assert mock_request.call_args_list == call_list - - assert response.status_code == 200 - - def test_un_flag_thread_open(self, mock_is_forum_v2_enabled, mock_request): - self.un_flag_thread(mock_is_forum_v2_enabled, mock_request, False) - - def test_un_flag_thread_close(self, mock_is_forum_v2_enabled, mock_request): - self.un_flag_thread(mock_is_forum_v2_enabled, mock_request, True) - - def un_flag_thread(self, mock_is_forum_v2_enabled, mock_request, is_closed): - mock_is_forum_v2_enabled.return_value = False - self._set_mock_request_data(mock_request, { - "title": "Hello", - "body": "this is a post", - "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": is_closed, - "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 - }) - url = reverse('un_flag_abuse_for_thread', kwargs={ - 'thread_id': '518d4237b023791dca00000d', - 'course_id': str(self.course_id) - }) - response = self.client.post(url) - assert mock_request.called - - call_list = [ - ( - ('get', f'{CS_PREFIX}/threads/518d4237b023791dca00000d'), - { - 'data': None, - 'params': {'mark_as_read': True, 'request_id': ANY, 'with_responses': False, 'reverse_order': False, - 'merge_question_type_responses': False}, - 'headers': ANY, - 'timeout': 5 - } - ), - ( - ('put', f'{CS_PREFIX}/threads/518d4237b023791dca00000d/abuse_unflag'), - { - 'data': {'user_id': '1'}, - 'params': {'request_id': ANY}, - 'headers': ANY, - 'timeout': 5 - } - ), - ( - ('get', f'{CS_PREFIX}/threads/518d4237b023791dca00000d'), - { - 'data': None, - 'params': {'mark_as_read': True, 'request_id': ANY, 'with_responses': False, 'reverse_order': False, - 'merge_question_type_responses': False}, - 'headers': ANY, - 'timeout': 5 - } - ) - ] - - assert mock_request.call_args_list == call_list - - assert response.status_code == 200 - - def test_flag_comment_open(self, mock_is_forum_v2_enabled, mock_request): - self.flag_comment(mock_is_forum_v2_enabled, mock_request, False) - - def test_flag_comment_close(self, mock_is_forum_v2_enabled, mock_request): - self.flag_comment(mock_is_forum_v2_enabled, mock_request, True) - - def flag_comment(self, mock_is_forum_v2_enabled, mock_request, is_closed): - mock_is_forum_v2_enabled.return_value = False - self._set_mock_request_data(mock_request, { - "body": "this is a comment", - "course_id": "MITx/999/Robot_Super_Course", - "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": is_closed, - "id": "518d4237b023791dca00000d", - "user_id": "1", - "username": "robot", - "votes": { - "count": 0, - "up_count": 0, - "down_count": 0, - "point": 0 - }, - "abuse_flaggers": [1], - "type": "comment", - "endorsed": False - }) - url = reverse('flag_abuse_for_comment', kwargs={ - 'comment_id': '518d4237b023791dca00000d', - 'course_id': str(self.course_id) - }) - response = self.client.post(url) - assert mock_request.called - - call_list = [ - ( - ('get', f'{CS_PREFIX}/comments/518d4237b023791dca00000d'), - { - 'data': None, - 'params': {'request_id': ANY}, - 'headers': ANY, - 'timeout': 5 - } - ), - ( - ('put', f'{CS_PREFIX}/comments/518d4237b023791dca00000d/abuse_flag'), - { - 'data': {'user_id': '1'}, - 'params': {'request_id': ANY}, - 'headers': ANY, - 'timeout': 5 - } - ), - ( - ('get', f'{CS_PREFIX}/comments/518d4237b023791dca00000d'), - { - 'data': None, - 'params': {'request_id': ANY}, - 'headers': ANY, - 'timeout': 5 - } - ) - ] - - assert mock_request.call_args_list == call_list - - assert response.status_code == 200 - - def test_un_flag_comment_open(self, mock_is_forum_v2_enabled, mock_request): - self.un_flag_comment(mock_is_forum_v2_enabled, mock_request, False) - - def test_un_flag_comment_close(self, mock_is_forum_v2_enabled, mock_request): - self.un_flag_comment(mock_is_forum_v2_enabled, mock_request, True) - - def un_flag_comment(self, mock_is_forum_v2_enabled, mock_request, is_closed): - mock_is_forum_v2_enabled.return_value = False - self._set_mock_request_data(mock_request, { - "body": "this is a comment", - "course_id": "MITx/999/Robot_Super_Course", - "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": is_closed, - "id": "518d4237b023791dca00000d", - "user_id": "1", - "username": "robot", - "votes": { - "count": 0, - "up_count": 0, - "down_count": 0, - "point": 0 - }, - "abuse_flaggers": [], - "type": "comment", - "endorsed": False - }) - url = reverse('un_flag_abuse_for_comment', kwargs={ - 'comment_id': '518d4237b023791dca00000d', - 'course_id': str(self.course_id) - }) - response = self.client.post(url) - assert mock_request.called - - call_list = [ - ( - ('get', f'{CS_PREFIX}/comments/518d4237b023791dca00000d'), - { - 'data': None, - 'params': {'request_id': ANY}, - 'headers': ANY, - 'timeout': 5 - } - ), - ( - ('put', f'{CS_PREFIX}/comments/518d4237b023791dca00000d/abuse_unflag'), - { - 'data': {'user_id': '1'}, - 'params': {'request_id': ANY}, - 'headers': ANY, - 'timeout': 5 - } - ), - ( - ('get', f'{CS_PREFIX}/comments/518d4237b023791dca00000d'), - { - 'data': None, - 'params': {'request_id': ANY}, - 'headers': ANY, - 'timeout': 5 - } - ) - ] - - assert mock_request.call_args_list == call_list - - assert response.status_code == 200 - - @ddt.data( - ('upvote_thread', 'thread_id', 'thread_voted'), - ('upvote_comment', 'comment_id', 'comment_voted'), - ('downvote_thread', 'thread_id', 'thread_voted'), - ('downvote_comment', 'comment_id', 'comment_voted') - ) - @ddt.unpack - def test_voting(self, view_name, item_id, signal, mock_is_forum_v2_enabled, mock_request): - mock_is_forum_v2_enabled.return_value = False - self._setup_mock_request(mock_request) - with self.assert_discussion_signals(signal): - response = self.client.post( - reverse( - view_name, - kwargs={item_id: 'dummy', 'course_id': str(self.course_id)} - ) - ) - assert response.status_code == 200 - - def test_endorse_comment(self, mock_is_forum_v2_enabled, mock_request): - mock_is_forum_v2_enabled.return_value = False - self._setup_mock_request(mock_request) - self.client.login(username=self.moderator.username, password=self.password) - with self.assert_discussion_signals('comment_endorsed', user=self.moderator): - response = self.client.post( - reverse( - 'endorse_comment', - kwargs={'comment_id': 'dummy', 'course_id': str(self.course_id)} - ) - ) - assert response.status_code == 200 - - -@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) -@disable_signal(views, 'comment_endorsed') -class ViewPermissionsTestCase(ForumsEnableMixin, UrlResetMixin, 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.password = "test password" - cls.student = UserFactory.create(password=cls.password) - cls.moderator = UserFactory.create(password=cls.password) - - CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) - CourseEnrollmentFactory(user=cls.moderator, course_id=cls.course.id) - - cls.moderator.roles.add(Role.objects.get(name="Moderator", course_id=cls.course.id)) - - @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - super().setUp() - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" - ) - self.mock_get_course_id_by_comment = patcher.start() - self.addCleanup(patcher.stop) - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" - ) - self.mock_get_course_id_by_thread = patcher.start() - self.addCleanup(patcher.stop) - - def test_pin_thread_as_student(self, mock_is_forum_v2_enabled, mock_request): - mock_is_forum_v2_enabled.return_value = False - self._set_mock_request_data(mock_request, {}) - self.client.login(username=self.student.username, password=self.password) - response = self.client.post( - reverse("pin_thread", kwargs={"course_id": str(self.course.id), "thread_id": "dummy"}) - ) - assert response.status_code == 401 - - def test_pin_thread_as_moderator(self, mock_is_forum_v2_enabled, mock_request): - mock_is_forum_v2_enabled.return_value = False - self._set_mock_request_data(mock_request, {}) - self.client.login(username=self.moderator.username, password=self.password) - response = self.client.post( - reverse("pin_thread", kwargs={"course_id": str(self.course.id), "thread_id": "dummy"}) - ) - assert response.status_code == 200 - - def test_un_pin_thread_as_student(self, mock_is_forum_v2_enabled, mock_request): - mock_is_forum_v2_enabled.return_value = False - self._set_mock_request_data(mock_request, {}) - self.client.login(username=self.student.username, password=self.password) - response = self.client.post( - reverse("un_pin_thread", kwargs={"course_id": str(self.course.id), "thread_id": "dummy"}) - ) - assert response.status_code == 401 - - def test_un_pin_thread_as_moderator(self, mock_is_forum_v2_enabled, mock_request): - mock_is_forum_v2_enabled.return_value = False - self._set_mock_request_data(mock_request, {}) - self.client.login(username=self.moderator.username, password=self.password) - response = self.client.post( - reverse("un_pin_thread", kwargs={"course_id": str(self.course.id), "thread_id": "dummy"}) - ) - assert response.status_code == 200 - - def _set_mock_request_thread_and_comment(self, mock_is_forum_v2_enabled, mock_request, thread_data, comment_data): - def handle_request(*args, **kwargs): - url = args[1] - if "/threads/" in url: - return self._create_response_mock(thread_data) - elif "/comments/" in url: - return self._create_response_mock(comment_data) - else: - raise ArgumentError("Bad url to mock request") - mock_is_forum_v2_enabled.return_value = False - mock_request.side_effect = handle_request - - def test_endorse_response_as_staff(self, mock_is_forum_v2_enabled, mock_request): - self._set_mock_request_thread_and_comment( - mock_is_forum_v2_enabled, - mock_request, - {"type": "thread", "thread_type": "question", "user_id": str(self.student.id), "commentable_id": "course"}, - {"type": "comment", "thread_id": "dummy"} - ) - self.client.login(username=self.moderator.username, password=self.password) - response = self.client.post( - reverse("endorse_comment", kwargs={"course_id": str(self.course.id), "comment_id": "dummy"}) - ) - assert response.status_code == 200 - - def test_endorse_response_as_student(self, mock_is_forum_v2_enabled, mock_request): - self._set_mock_request_thread_and_comment( - mock_is_forum_v2_enabled, - mock_request, - {"type": "thread", "thread_type": "question", - "user_id": str(self.moderator.id), "commentable_id": "course"}, - {"type": "comment", "thread_id": "dummy"} - ) - self.client.login(username=self.student.username, password=self.password) - response = self.client.post( - reverse("endorse_comment", kwargs={"course_id": str(self.course.id), "comment_id": "dummy"}) - ) - assert response.status_code == 401 - - def test_endorse_response_as_student_question_author(self, mock_is_forum_v2_enabled, mock_request): - self._set_mock_request_thread_and_comment( - mock_is_forum_v2_enabled, - mock_request, - {"type": "thread", "thread_type": "question", "user_id": str(self.student.id), "commentable_id": "course"}, - {"type": "comment", "thread_id": "dummy"} - ) - self.client.login(username=self.student.username, password=self.password) - response = self.client.post( - reverse("endorse_comment", kwargs={"course_id": str(self.course.id), "comment_id": "dummy"}) - ) - assert response.status_code == 200 - - -class CreateThreadUnicodeTestCase( - ForumsEnableMixin, - SharedModuleStoreTestCase, - UnicodeTestMixin, - 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() - CourseEnrollmentFactory(user=cls.student, course_id=cls.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_unicode_data(self, text, mock_is_forum_v2_enabled, mock_request,): - """ - Test to make sure unicode data in a thread doesn't break it. - """ - mock_is_forum_v2_enabled.return_value = False - self._set_mock_request_data(mock_request, {}) - request = RequestFactory().post("dummy_url", {"thread_type": "discussion", "body": text, "title": text}) - request.user = self.student - request.view_name = "create_thread" - response = views.create_thread( - # The commentable ID contains a username, the Unicode char below ensures it works fine - request, course_id=str(self.course.id), commentable_id="non_tåem_dummy_id" - ) - - assert response.status_code == 200 - assert mock_request.called - assert mock_request.call_args[1]['data']['body'] == text - assert mock_request.call_args[1]['data']['title'] == text - - -@disable_signal(views, 'thread_edited') -class UpdateThreadUnicodeTestCase( - ForumsEnableMixin, - SharedModuleStoreTestCase, - UnicodeTestMixin, - MockRequestSetupMixin -): - def setUp(self): - super().setUp() - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" - ) - self.mock_get_course_id_by_thread = patcher.start() - self.addCleanup(patcher.stop) - - @classmethod - def setUpClass(cls): - # 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() - CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) - - @patch( - 'lms.djangoapps.discussion.django_comment_client.utils.get_discussion_categories_ids', - return_value=["test_commentable"], - ) - @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_unicode_data(self, text, mock_is_forum_v2_enabled, mock_request, mock_get_discussion_id_map): - mock_is_forum_v2_enabled.return_value = False - self._set_mock_request_data(mock_request, { - "user_id": str(self.student.id), - "closed": False, - }) - request = RequestFactory().post("dummy_url", {"body": text, "title": text, "thread_type": "question", "commentable_id": "test_commentable"}) - request.user = self.student - request.view_name = "update_thread" - response = views.update_thread(request, course_id=str(self.course.id), thread_id="dummy_thread_id") - - assert response.status_code == 200 - assert mock_request.called - assert mock_request.call_args[1]['data']['body'] == text - assert mock_request.call_args[1]['data']['title'] == text - assert mock_request.call_args[1]['data']['thread_type'] == 'question' - assert mock_request.call_args[1]['data']['commentable_id'] == 'test_commentable' - - -@disable_signal(views, 'comment_created') -class CreateCommentUnicodeTestCase( - ForumsEnableMixin, - SharedModuleStoreTestCase, - UnicodeTestMixin, - MockRequestSetupMixin -): - def setUp(self): - super().setUp() - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" - ) - self.mock_get_course_id_by_thread = patcher.start() - self.addCleanup(patcher.stop) - - @classmethod - def setUpClass(cls): - # 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() - CourseEnrollmentFactory(user=cls.student, course_id=cls.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_unicode_data(self, text, mock_is_forum_v2_enabled, mock_request): - mock_is_forum_v2_enabled.return_value = False - commentable_id = "non_team_dummy_id" - self._set_mock_request_data(mock_request, { - "closed": False, - "commentable_id": commentable_id - }) - # We have to get clever here due to Thread's setters and getters. - # Patch won't work with it. - try: - Thread.commentable_id = commentable_id - request = RequestFactory().post("dummy_url", {"body": text}) - request.user = self.student - request.view_name = "create_comment" - response = views.create_comment( - request, course_id=str(self.course.id), thread_id="dummy_thread_id" - ) - - assert response.status_code == 200 - assert mock_request.called - assert mock_request.call_args[1]['data']['body'] == text - finally: - del Thread.commentable_id - - -@disable_signal(views, 'comment_edited') -class UpdateCommentUnicodeTestCase( - ForumsEnableMixin, - SharedModuleStoreTestCase, - UnicodeTestMixin, - MockRequestSetupMixin -): - def setUp(self): - super().setUp() - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" - ) - self.mock_get_course_id_by_comment = patcher.start() - self.addCleanup(patcher.stop) - - @classmethod - def setUpClass(cls): - # 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() - CourseEnrollmentFactory(user=cls.student, course_id=cls.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_unicode_data(self, text, mock_is_forum_v2_enabled, mock_request): - mock_is_forum_v2_enabled.return_value = False - self._set_mock_request_data(mock_request, { - "user_id": str(self.student.id), - "closed": False, - }) - request = RequestFactory().post("dummy_url", {"body": text}) - request.user = self.student - request.view_name = "update_comment" - response = views.update_comment(request, course_id=str(self.course.id), comment_id="dummy_comment_id") - - assert response.status_code == 200 - assert mock_request.called - assert mock_request.call_args[1]['data']['body'] == text - - -@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) -@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) -class CommentActionTestCase( - MockRequestSetupMixin, - CohortedTestCase, - GroupIdAssertionMixin -): - def call_view( - self, - view_name, - mock_is_forum_v2_enabled, - mock_request, - user=None, - post_params=None, - view_args=None - ): - mock_is_forum_v2_enabled.return_value = False - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" - ) - self.mock_get_course_id_by_comment = patcher.start() - self.addCleanup(patcher.stop) - self._set_mock_request_data( - mock_request, - { - "user_id": str(self.student.id), - "group_id": self.student_cohort.id, - "closed": False, - "type": "thread", - "commentable_id": "non_team_dummy_id", - "body": "test body", - } - ) - request = RequestFactory().post("dummy_url", post_params or {}) - request.user = user or self.student - request.view_name = view_name - - return getattr(views, view_name)( - request, - course_id=str(self.course.id), - comment_id="dummy", - **(view_args or {}) - ) - - def test_flag(self, mock_is_forum_v2_enabled, mock_request): - with mock.patch('openedx.core.djangoapps.django_comment_common.signals.comment_flagged.send') as signal_mock: - self.call_view("flag_abuse_for_comment", mock_is_forum_v2_enabled, mock_request) - self.assertEqual(signal_mock.call_count, 1) - - -@disable_signal(views, 'comment_created') -class CreateSubCommentUnicodeTestCase( - ForumsEnableMixin, - SharedModuleStoreTestCase, - UnicodeTestMixin, - MockRequestSetupMixin -): - """ - Make sure comments under a response can handle unicode. - """ - def setUp(self): - super().setUp() - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" - ) - self.mock_get_course_id_by_comment = patcher.start() - self.addCleanup(patcher.stop) - - @classmethod - def setUpClass(cls): - # pylint: disable=super-method-not-called - with super().setUpClassAndTestData(): - cls.course = CourseFactory.create() - - @classmethod - def setUpTestData(cls): - super().setUpTestData() - - seed_permissions_roles(cls.course.id) - 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) - @patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) - def _test_unicode_data(self, text, mock_is_forum_v2_enabled, mock_request): - """ - Create a comment with unicode in it. - """ - mock_is_forum_v2_enabled.return_value = False - self._set_mock_request_data(mock_request, { - "closed": False, - "depth": 1, - "thread_id": "test_thread", - "commentable_id": "non_team_dummy_id" - }) - request = RequestFactory().post("dummy_url", {"body": text}) - request.user = self.student - request.view_name = "create_sub_comment" - Thread.commentable_id = "test_commentable" - try: - response = views.create_sub_comment( - request, course_id=str(self.course.id), comment_id="dummy_comment_id" - ) - - assert response.status_code == 200 - assert mock_request.called - assert mock_request.call_args[1]['data']['body'] == text - finally: - del Thread.commentable_id - - -@ddt.ddt -@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) -@disable_signal(views, 'thread_voted') -@disable_signal(views, 'thread_edited') -@disable_signal(views, 'comment_created') -@disable_signal(views, 'comment_voted') -@disable_signal(views, 'comment_deleted') -@disable_signal(views, 'comment_flagged') -@disable_signal(views, 'thread_flagged') -class TeamsPermissionsTestCase(ForumsEnableMixin, UrlResetMixin, SharedModuleStoreTestCase, MockRequestSetupMixin): - # Most of the test points use the same ddt data. - # args: user, commentable_id, status_code - ddt_permissions_args = [ - # Student in team can do operations on threads/comments within the team commentable. - ('student_in_team', 'team_commentable_id', 200), - # Non-team commentables can be edited by any student. - ('student_in_team', 'course_commentable_id', 200), - # Student not in team cannot do operations within the team commentable. - ('student_not_in_team', 'team_commentable_id', 401), - # Non-team commentables can be edited by any student. - ('student_not_in_team', 'course_commentable_id', 200), - # Moderators can always operator on threads within a team, regardless of team membership. - ('moderator', 'team_commentable_id', 200), - # Group moderators have regular student privileges for creating a thread and commenting - ('group_moderator', 'course_commentable_id', 200) - ] - - def change_divided_discussion_settings(self, scheme): - """ - Change divided discussion settings for the current course. - If dividing by cohorts, create and assign users to a cohort. - """ - enable_cohorts = True if scheme is CourseDiscussionSettings.COHORT else False - discussion_settings = CourseDiscussionSettings.get(self.course.id) - discussion_settings.update({ - 'enable_cohorts': enable_cohorts, - 'divided_discussions': [], - 'always_divide_inline_discussions': True, - 'division_scheme': scheme, - }) - set_course_cohorted(self.course.id, enable_cohorts) - - @classmethod - def setUpClass(cls): - # pylint: disable=super-method-not-called - with super().setUpClassAndTestData(): - teams_config_data = { - 'topics': [{'id': "topic_id", 'name': 'Solar Power', 'description': 'Solar power is hot'}] - } - cls.course = CourseFactory.create(teams_configuration=TeamsConfig(teams_config_data)) - - @classmethod - def setUpTestData(cls): - super().setUpTestData() - cls.password = "test password" - seed_permissions_roles(cls.course.id) - - # Create enrollment tracks - CourseModeFactory.create( - course_id=cls.course.id, - mode_slug=CourseMode.VERIFIED - ) - CourseModeFactory.create( - course_id=cls.course.id, - mode_slug=CourseMode.AUDIT - ) - - # Create 6 users-- - # student in team (in the team, audit) - # student not in team (not in the team, audit) - # cohorted (in the cohort, audit) - # verified (not in the cohort, verified) - # moderator (in the cohort, audit, moderator permissions) - # group moderator (in the cohort, verified, group moderator permissions) - def create_users_and_enroll(coursemode): - student = UserFactory.create(password=cls.password) - CourseEnrollmentFactory( - course_id=cls.course.id, - user=student, - mode=coursemode - ) - return student - - cls.student_in_team, cls.student_not_in_team, cls.moderator, cls.cohorted = ( - [create_users_and_enroll(CourseMode.AUDIT) for _ in range(4)]) - cls.verified, cls.group_moderator = [create_users_and_enroll(CourseMode.VERIFIED) for _ in range(2)] - - # Give moderator and group moderator permissions - cls.moderator.roles.add(Role.objects.get(name="Moderator", course_id=cls.course.id)) - assign_role(cls.course.id, cls.group_moderator, 'Group Moderator') - - # Create a team - cls.team_commentable_id = "team_discussion_id" - cls.team = CourseTeamFactory.create( - name='The Only Team', - course_id=cls.course.id, - topic_id='topic_id', - discussion_topic_id=cls.team_commentable_id - ) - CourseTeamMembershipFactory.create(team=cls.team, user=cls.student_in_team) - - # Dummy commentable ID not linked to a team - cls.course_commentable_id = "course_level_commentable" - - # Create cohort and add students to it - CohortFactory( - course_id=cls.course.id, - name='Test Cohort', - users=[cls.group_moderator, cls.cohorted] - ) - - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - super().setUp() - - def _setup_mock(self, user, mock_is_forum_v2_enabled, mock_request, data): - user = getattr(self, user) - mock_is_forum_v2_enabled.return_value = False - self._set_mock_request_data(mock_request, data) - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" - ) - self.mock_get_course_id_by_comment = patcher.start() - self.addCleanup(patcher.stop) - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" - ) - self.mock_get_course_id_by_thread = patcher.start() - self.addCleanup(patcher.stop) - self.client.login(username=user.username, password=self.password) - - @ddt.data( - # student_in_team will be able to update their own post, regardless of team membership - ('student_in_team', 'student_in_team', 'team_commentable_id', 200, CourseDiscussionSettings.NONE), - ('student_in_team', 'student_in_team', 'course_commentable_id', 200, CourseDiscussionSettings.NONE), - # students can only update their own posts - ('student_in_team', 'moderator', 'team_commentable_id', 401, CourseDiscussionSettings.NONE), - # Even though student_not_in_team is not in the team, he can still modify posts he created while in the team. - ('student_not_in_team', 'student_not_in_team', 'team_commentable_id', 200, CourseDiscussionSettings.NONE), - # Moderators can change their own posts and other people's posts. - ('moderator', 'moderator', 'team_commentable_id', 200, CourseDiscussionSettings.NONE), - ('moderator', 'student_in_team', 'team_commentable_id', 200, CourseDiscussionSettings.NONE), - # Group moderator can do operations on commentables within their group if the course is divided - ('group_moderator', 'verified', 'course_commentable_id', 200, CourseDiscussionSettings.ENROLLMENT_TRACK), - ('group_moderator', 'cohorted', 'course_commentable_id', 200, CourseDiscussionSettings.COHORT), - # Group moderators cannot do operations on commentables outside of their group - ('group_moderator', 'verified', 'course_commentable_id', 401, CourseDiscussionSettings.COHORT), - ('group_moderator', 'cohorted', 'course_commentable_id', 401, CourseDiscussionSettings.ENROLLMENT_TRACK), - # Group moderators cannot do operations when the course is not divided - ('group_moderator', 'verified', 'course_commentable_id', 401, CourseDiscussionSettings.NONE), - ('group_moderator', 'cohorted', 'course_commentable_id', 401, CourseDiscussionSettings.NONE) - ) - @ddt.unpack - def test_update_thread(self, user, thread_author, commentable_id, status_code, division_scheme, mock_is_forum_v2_enabled, mock_request): - """ - Verify that update_thread is limited to thread authors and privileged users (team membership does not matter). - """ - self.change_divided_discussion_settings(division_scheme) - commentable_id = getattr(self, commentable_id) - # thread_author is who is marked as the author of the thread being updated. - thread_author = getattr(self, thread_author) - - self._setup_mock( - user, mock_is_forum_v2_enabled, mock_request, # user is the person making the request. - { - "user_id": str(thread_author.id), - "closed": False, "commentable_id": commentable_id, - "context": "standalone", - "username": thread_author.username, - "course_id": str(self.course.id) - } - ) - response = self.client.post( - reverse( - "update_thread", - kwargs={ - "course_id": str(self.course.id), - "thread_id": "dummy" - } - ), - data={"body": "foo", "title": "foo", "commentable_id": commentable_id} - ) - assert response.status_code == status_code - - @ddt.data( - # Students can delete their own posts - ('student_in_team', 'student_in_team', 'team_commentable_id', 200, CourseDiscussionSettings.NONE), - # Moderators can delete any post - ('moderator', 'student_in_team', 'team_commentable_id', 200, CourseDiscussionSettings.NONE), - # Others cannot delete posts - ('student_in_team', 'moderator', 'team_commentable_id', 401, CourseDiscussionSettings.NONE), - ('student_not_in_team', 'student_in_team', 'team_commentable_id', 401, CourseDiscussionSettings.NONE), - # Group moderator can do operations on commentables within their group if the course is divided - ('group_moderator', 'verified', 'team_commentable_id', 200, CourseDiscussionSettings.ENROLLMENT_TRACK), - ('group_moderator', 'cohorted', 'team_commentable_id', 200, CourseDiscussionSettings.COHORT), - # Group moderators cannot do operations on commentables outside of their group - ('group_moderator', 'verified', 'team_commentable_id', 401, CourseDiscussionSettings.COHORT), - ('group_moderator', 'cohorted', 'team_commentable_id', 401, CourseDiscussionSettings.ENROLLMENT_TRACK), - # Group moderators cannot do operations when the course is not divided - ('group_moderator', 'verified', 'team_commentable_id', 401, CourseDiscussionSettings.NONE), - ('group_moderator', 'cohorted', 'team_commentable_id', 401, CourseDiscussionSettings.NONE) - ) - @ddt.unpack - def test_delete_comment(self, user, comment_author, commentable_id, status_code, division_scheme, mock_is_forum_v2_enabled, mock_request): - commentable_id = getattr(self, commentable_id) - comment_author = getattr(self, comment_author) - self.change_divided_discussion_settings(division_scheme) - - self._setup_mock(user, mock_is_forum_v2_enabled, mock_request, { - "closed": False, - "commentable_id": commentable_id, - "user_id": str(comment_author.id), - "username": comment_author.username, - "course_id": str(self.course.id), - "body": "test body", - }) - - response = self.client.post( - reverse( - "delete_comment", - kwargs={ - "course_id": str(self.course.id), - "comment_id": "dummy" - } - ), - data={"body": "foo", "title": "foo"} - ) - assert response.status_code == status_code - - @ddt.data(*ddt_permissions_args) - @ddt.unpack - def test_create_comment(self, user, commentable_id, status_code, mock_is_forum_v2_enabled, mock_request): - """ - Verify that create_comment is limited to members of the team or users with 'edit_content' permission. - """ - commentable_id = getattr(self, commentable_id) - self._setup_mock(user, mock_is_forum_v2_enabled, mock_request, {"closed": False, "commentable_id": commentable_id}) - - response = self.client.post( - reverse( - "create_comment", - kwargs={ - "course_id": str(self.course.id), - "thread_id": "dummy" - } - ), - data={"body": "foo", "title": "foo"} - ) - assert response.status_code == status_code - - @ddt.data(*ddt_permissions_args) - @ddt.unpack - def test_create_sub_comment(self, user, commentable_id, status_code, mock_is_forum_v2_enabled, mock_request): - """ - Verify that create_subcomment is limited to members of the team or users with 'edit_content' permission. - """ - commentable_id = getattr(self, commentable_id) - self._setup_mock( - user, mock_is_forum_v2_enabled, mock_request, - {"closed": False, "commentable_id": commentable_id, "thread_id": "dummy_thread"}, - ) - response = self.client.post( - reverse( - "create_sub_comment", - kwargs={ - "course_id": str(self.course.id), - "comment_id": "dummy_comment" - } - ), - data={"body": "foo", "title": "foo"} - ) - assert response.status_code == status_code - - @ddt.data(*ddt_permissions_args) - @ddt.unpack - def test_comment_actions(self, user, commentable_id, status_code, mock_is_forum_v2_enabled, mock_request): - """ - Verify that voting and flagging of comments is limited to members of the team or users with - 'edit_content' permission. - """ - commentable_id = getattr(self, commentable_id) - self._setup_mock( - user, mock_is_forum_v2_enabled, mock_request, - { - "closed": False, - "commentable_id": commentable_id, - "thread_id": "dummy_thread", - "body": 'dummy body', - "course_id": str(self.course.id) - }, - ) - for action in ["upvote_comment", "downvote_comment", "un_flag_abuse_for_comment", "flag_abuse_for_comment"]: - response = self.client.post( - reverse( - action, - kwargs={"course_id": str(self.course.id), "comment_id": "dummy_comment"} - ) - ) - assert response.status_code == status_code - - @ddt.data(*ddt_permissions_args) - @ddt.unpack - def test_threads_actions(self, user, commentable_id, status_code, mock_is_forum_v2_enabled, mock_request): - """ - Verify that voting, flagging, and following of threads is limited to members of the team or users with - 'edit_content' permission. - """ - commentable_id = getattr(self, commentable_id) - self._setup_mock( - user, mock_is_forum_v2_enabled, mock_request, - {"closed": False, "commentable_id": commentable_id, "body": "dummy body", "course_id": str(self.course.id)} - ) - for action in ["upvote_thread", "downvote_thread", "un_flag_abuse_for_thread", "flag_abuse_for_thread", - "follow_thread", "unfollow_thread"]: - response = self.client.post( - reverse( - action, - kwargs={"course_id": str(self.course.id), "thread_id": "dummy_thread"} - ) - ) - assert response.status_code == status_code - - -TEAM_COMMENTABLE_ID = 'test-team-discussion' - - -@disable_signal(views, 'comment_created') -@ddt.ddt -class ForumEventTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, MockRequestSetupMixin): - """ - Forum actions are expected to launch analytics events. Test these here. - """ - def setUp(self): - super().setUp() - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" - ) - self.mock_get_course_id_by_comment = patcher.start() - self.addCleanup(patcher.stop) - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" - ) - self.mock_get_course_id_by_thread = patcher.start() - self.addCleanup(patcher.stop) - - @classmethod - def setUpClass(cls): - # pylint: disable=super-method-not-called - with super().setUpClassAndTestData(): - cls.course = CourseFactory.create() - - @classmethod - def setUpTestData(cls): - super().setUpTestData() - - seed_permissions_roles(cls.course.id) - - cls.student = UserFactory.create() - CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) - cls.student.roles.add(Role.objects.get(name="Student", course_id=cls.course.id)) - CourseAccessRoleFactory(course_id=cls.course.id, user=cls.student, role='Wizard') - - @patch('eventtracking.tracker.emit') - @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_response_event(self, mock_is_forum_v2_enabled, mock_request, mock_emit): - """ - Check to make sure an event is fired when a user responds to a thread. - """ - event_receiver = Mock() - FORUM_THREAD_RESPONSE_CREATED.connect(event_receiver) - mock_is_forum_v2_enabled.return_value = False - self._set_mock_request_data(mock_request, { - "closed": False, - "commentable_id": 'test_commentable_id', - 'thread_id': 'test_thread_id', - }) - request = RequestFactory().post("dummy_url", {"body": "Test comment", 'auto_subscribe': True}) - request.user = self.student - request.view_name = "create_comment" - views.create_comment(request, course_id=str(self.course.id), thread_id='test_thread_id') - - event_name, event = mock_emit.call_args[0] - assert event_name == 'edx.forum.response.created' - assert event['body'] == 'Test comment' - assert event['commentable_id'] == 'test_commentable_id' - assert event['user_forums_roles'] == ['Student'] - assert event['user_course_roles'] == ['Wizard'] - assert event['discussion']['id'] == 'test_thread_id' - assert event['options']['followed'] is True - - event_receiver.assert_called_once() - - self.assertDictContainsSubset( - { - "signal": FORUM_THREAD_RESPONSE_CREATED, - "sender": None, - }, - event_receiver.call_args.kwargs - ) - - self.assertIn( - "thread", - event_receiver.call_args.kwargs - ) - - @patch('eventtracking.tracker.emit') - @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_comment_event(self, mock_is_forum_v2_enabled, mock_request, mock_emit): - """ - Ensure an event is fired when someone comments on a response. - """ - event_receiver = Mock() - FORUM_RESPONSE_COMMENT_CREATED.connect(event_receiver) - mock_is_forum_v2_enabled.return_value = False - self._set_mock_request_data(mock_request, { - "closed": False, - "depth": 1, - "thread_id": "test_thread_id", - "commentable_id": "test_commentable_id", - "parent_id": "test_response_id" - }) - request = RequestFactory().post("dummy_url", {"body": "Another comment"}) - request.user = self.student - request.view_name = "create_sub_comment" - views.create_sub_comment(request, course_id=str(self.course.id), comment_id="dummy_comment_id") - - event_name, event = mock_emit.call_args[0] - assert event_name == 'edx.forum.comment.created' - assert event['body'] == 'Another comment' - assert event['discussion']['id'] == 'test_thread_id' - assert event['response']['id'] == 'test_response_id' - assert event['user_forums_roles'] == ['Student'] - assert event['user_course_roles'] == ['Wizard'] - assert event['options']['followed'] is False - - self.assertDictContainsSubset( - { - "signal": FORUM_RESPONSE_COMMENT_CREATED, - "sender": None, - }, - event_receiver.call_args.kwargs - ) - - self.assertIn( - "thread", - event_receiver.call_args.kwargs - ) - - @patch('eventtracking.tracker.emit') - @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) - @ddt.data(( - 'create_thread', - 'edx.forum.thread.created', { - 'thread_type': 'discussion', - 'body': 'Test text', - 'title': 'Test', - 'auto_subscribe': True - }, - {'commentable_id': TEAM_COMMENTABLE_ID} - ), ( - 'create_comment', - 'edx.forum.response.created', - {'body': 'Test comment', 'auto_subscribe': True}, - {'thread_id': 'test_thread_id'} - ), ( - 'create_sub_comment', - 'edx.forum.comment.created', - {'body': 'Another comment'}, - {'comment_id': 'dummy_comment_id'} - )) - @ddt.unpack - def test_team_events(self, view_name, event_name, view_data, view_kwargs, mock_is_forum_v2_enabled, mock_request, mock_emit): - user = self.student - team = CourseTeamFactory.create(discussion_topic_id=TEAM_COMMENTABLE_ID) - CourseTeamMembershipFactory.create(team=team, user=user) - - event_receiver = Mock() - forum_event = views.TRACKING_LOG_TO_EVENT_MAPS.get(event_name) - forum_event.connect(event_receiver) - - mock_is_forum_v2_enabled.return_value = False - self._set_mock_request_data(mock_request, { - 'closed': False, - 'commentable_id': TEAM_COMMENTABLE_ID, - 'thread_id': 'test_thread_id', - }) - - request = RequestFactory().post('dummy_url', view_data) - request.user = user - request.view_name = view_name - - getattr(views, view_name)(request, course_id=str(self.course.id), **view_kwargs) - - name, event = mock_emit.call_args[0] - assert name == event_name - assert event['team_id'] == team.team_id - - self.assertDictContainsSubset( - { - "signal": forum_event, - "sender": None, - }, - event_receiver.call_args.kwargs - ) - - self.assertIn( - "thread", - event_receiver.call_args.kwargs - ) - - @ddt.data( - ('vote_for_thread', 'thread_id', 'thread'), - ('undo_vote_for_thread', 'thread_id', 'thread'), - ('vote_for_comment', 'comment_id', 'response'), - ('undo_vote_for_comment', 'comment_id', 'response'), - ) - @ddt.unpack - @patch('eventtracking.tracker.emit') - @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_thread_voted_event(self, view_name, obj_id_name, obj_type, mock_is_forum_v2_enabled, mock_request, mock_emit): - undo = view_name.startswith('undo') - - mock_is_forum_v2_enabled.return_value = False - self._set_mock_request_data(mock_request, { - 'closed': False, - 'commentable_id': 'test_commentable_id', - 'username': 'gumprecht', - }) - request = RequestFactory().post('dummy_url', {}) - request.user = self.student - request.view_name = view_name - view_function = getattr(views, view_name) - kwargs = dict(course_id=str(self.course.id)) - kwargs[obj_id_name] = obj_id_name - if not undo: - kwargs.update(value='up') - view_function(request, **kwargs) - - assert mock_emit.called - event_name, event = mock_emit.call_args[0] - assert event_name == f'edx.forum.{obj_type}.voted' - assert event['target_username'] == 'gumprecht' - assert event['undo_vote'] == undo - assert event['vote_value'] == 'up' - - @ddt.data('follow_thread', 'unfollow_thread',) - @patch('eventtracking.tracker.emit') - @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_thread_followed_event(self, view_name, mock_is_forum_v2_enabled, mock_request, mock_emit): - event_receiver = Mock() - for signal in views.TRACKING_LOG_TO_EVENT_MAPS.values(): - signal.connect(event_receiver) - - mock_is_forum_v2_enabled.return_value = False - self._set_mock_request_data(mock_request, { - 'closed': False, - 'commentable_id': 'test_commentable_id', - 'username': 'test_user', - }) - request = RequestFactory().post('dummy_url', {}) - request.user = self.student - request.view_name = view_name - view_function = getattr(views, view_name) - kwargs = dict(course_id=str(self.course.id)) - kwargs['thread_id'] = 'thread_id' - view_function(request, **kwargs) - - assert mock_emit.called - event_name, event_data = mock_emit.call_args[0] - action_name = 'followed' if view_name == 'follow_thread' else 'unfollowed' - expected_action_value = True if view_name == 'follow_thread' else False - assert event_name == f'edx.forum.thread.{action_name}' - assert event_data['commentable_id'] == 'test_commentable_id' - assert event_data['id'] == 'thread_id' - assert event_data['followed'] == expected_action_value - assert event_data['user_forums_roles'] == ['Student'] - assert event_data['user_course_roles'] == ['Wizard'] - - # In case of events that doesn't have a correspondig Open edX events signal - # we need to check that none of the openedx signals is called. - # This is tested for all the events that are not tested above. - event_receiver.assert_not_called() - - -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 new file mode 100644 index 000000000000..b54501dc15b9 --- /dev/null +++ b/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py @@ -0,0 +1,2696 @@ +# pylint: skip-file +"""Tests for django comment client views.""" + +import pytest +import json +import logging +from contextlib import contextmanager +from unittest import mock +from unittest.mock import ANY, Mock, patch + +import ddt +from django.contrib.auth.models import User +from django.core.management import call_command +from django.test.client import RequestFactory +from django.urls import reverse +from eventtracking.processors.exceptions import EventEmissionExit +from opaque_keys.edx.keys import CourseKey +from openedx_events.learning.signals import ( + FORUM_THREAD_CREATED, + FORUM_THREAD_RESPONSE_CREATED, + FORUM_RESPONSE_COMMENT_CREATED, +) + +from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.course_modes.tests.factories import CourseModeFactory +from common.djangoapps.student.roles import CourseStaffRole, UserBasedRole +from common.djangoapps.student.tests.factories import ( + CourseAccessRoleFactory, + CourseEnrollmentFactory, + UserFactory, +) +from common.djangoapps.track.middleware import TrackMiddleware +from common.djangoapps.track.views import segmentio +from common.djangoapps.track.views.tests.base import ( + SEGMENTIO_TEST_USER_ID, + SegmentIOTrackingTestCaseBase, +) +from common.djangoapps.util.testing import UrlResetMixin +from common.test.utils import MockSignalHandlerMixin, disable_signal +from lms.djangoapps.discussion.django_comment_client.base import views +from lms.djangoapps.discussion.django_comment_client.tests.group_id import ( + CohortedTopicGroupIdTestMixinV2, + GroupIdAssertionMixinV2, + NonCohortedTopicGroupIdTestMixinV2, +) +from lms.djangoapps.discussion.django_comment_client.tests.unicode import ( + UnicodeTestMixin, +) +from lms.djangoapps.discussion.django_comment_client.tests.utils import ( + CohortedTestCase, +) +from lms.djangoapps.teams.tests.factories import ( + CourseTeamFactory, + CourseTeamMembershipFactory, +) +from openedx.core.djangoapps.course_groups.cohorts import set_course_cohorted +from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory +from openedx.core.djangoapps.django_comment_common.comment_client import Thread +from openedx.core.djangoapps.django_comment_common.models import ( + FORUM_ROLE_STUDENT, + CourseDiscussionSettings, + Role, + assign_role, +) +from openedx.core.djangoapps.django_comment_common.utils import ( + ThreadContext, + seed_permissions_roles, +) +from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES +from openedx.core.lib.teams_config import TeamsConfig +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import ( + TEST_DATA_SPLIT_MODULESTORE, + ModuleStoreTestCase, + SharedModuleStoreTestCase, +) +from xmodule.modulestore.tests.factories import ( + CourseFactory, + BlockFactory, + check_mongo_calls, +) + +from .event_transformers import ForumThreadViewedEventTransformer +from lms.djangoapps.discussion.django_comment_client.tests.mixins import ( + MockForumApiMixin, +) + +from lms.djangoapps.discussion.tests.utils import ( + make_minimal_cs_thread, + make_minimal_cs_comment, +) + +QUERY_COUNT_TABLE_IGNORELIST = WAFFLE_TABLES + + +class CreateThreadGroupIdTestCase( + MockForumApiMixin, + CohortedTestCase, + CohortedTopicGroupIdTestMixinV2, + NonCohortedTopicGroupIdTestMixinV2, +): + function_name = "create_thread" + + @classmethod + def setUpClass(cls): + """Set up class and forum mock.""" + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() + + def call_view(self, commentable_id, user, group_id, pass_group_id=True): + self.set_mock_return_value("get_thread", {}) + self.set_mock_return_value("create_thread", {}) + request_data = {"body": "body", "title": "title", "thread_type": "discussion"} + if pass_group_id: + request_data["group_id"] = group_id + request = RequestFactory().post("dummy_url", request_data) + request.user = user + request.view_name = "create_thread" + + return views.create_thread( + request, course_id=str(self.course.id), commentable_id=commentable_id + ) + + def test_group_info_in_response(self): + response = self.call_view("cohorted_topic", self.student, "") + self._assert_json_response_contains_group_info(response) + + +@disable_signal(views, "thread_edited") +@disable_signal(views, "thread_voted") +@disable_signal(views, "thread_deleted") +class ThreadActionGroupIdTestCase( + CohortedTestCase, GroupIdAssertionMixinV2, MockForumApiMixin +): + """Test case for thread actions with group ID assertions.""" + + @classmethod + def setUpClass(cls): + """Set up class and forum mock.""" + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() + + def call_view( + self, view_name, mock_function, user=None, post_params=None, view_args=None + ): + """Call a view with the given parameters.""" + thread_response = make_minimal_cs_thread( + {"user_id": str(self.student.id), "group_id": self.student_cohort.id} + ) + + self.set_mock_return_value("get_course_id_by_thread", str(self.course.id)) + self.set_mock_return_value("get_thread", thread_response) + self.set_mock_return_value(mock_function, thread_response) + + request = RequestFactory().post("dummy_url", post_params or {}) + request.user = user or self.student + request.view_name = view_name + + return getattr(views, view_name)( + request, + course_id=str(self.course.id), + thread_id="dummy", + **(view_args or {}), + ) + + def test_flag(self): + with mock.patch( + "openedx.core.djangoapps.django_comment_common.signals.thread_flagged.send" + ) as signal_mock: + response = self.call_view("flag_abuse_for_thread", "update_thread_flag") + self._assert_json_response_contains_group_info(response) + self.assertEqual(signal_mock.call_count, 1) + response = self.call_view("un_flag_abuse_for_thread", "update_thread_flag") + self._assert_json_response_contains_group_info(response) + + def test_pin_thread(self): + """Test pinning a thread.""" + response = self.call_view("pin_thread", "pin_thread", user=self.moderator) + assert response.status_code == 200 + self._assert_json_response_contains_group_info(response) + + response = self.call_view("un_pin_thread", "unpin_thread", user=self.moderator) + assert response.status_code == 200 + self._assert_json_response_contains_group_info(response) + + def test_vote(self): + response = self.call_view( + "vote_for_thread", "update_thread_votes", view_args={"value": "up"} + ) + self._assert_json_response_contains_group_info(response) + response = self.call_view("undo_vote_for_thread", "delete_thread_vote") + self._assert_json_response_contains_group_info(response) + + def test_update(self): + response = self.call_view( + "update_thread", + "update_thread", + post_params={"body": "body", "title": "title"}, + ) + self._assert_json_response_contains_group_info(response) + + def test_delete(self): + response = self.call_view("delete_thread", "delete_thread") + self._assert_json_response_contains_group_info(response) + + def test_openclose(self): + response = self.call_view( + "openclose_thread", "update_thread", user=self.moderator + ) + self._assert_json_response_contains_group_info(response, lambda d: d["content"]) + + +class ViewsTestCaseMixin: + + def set_up_course(self, block_count=0): + """ + Creates a course, optionally with block_count discussion blocks, and + a user with appropriate permissions. + """ + + # create a course + self.course = CourseFactory.create( + org="MITx", + course="999", + discussion_topics={"Some Topic": {"id": "some_topic"}}, + display_name="Robot Super Course", + ) + self.course_id = self.course.id + + # add some discussion blocks + for i in range(block_count): + BlockFactory.create( + parent_location=self.course.location, + category="discussion", + discussion_id=f"id_module_{i}", + discussion_category=f"Category {i}", + discussion_target=f"Discussion {i}", + ) + + # seed the forums permissions and roles + call_command("seed_permissions_roles", str(self.course_id)) + + # Patch the comment client user save method so it does not try + # to create a new cc user when creating a django user + with patch("common.djangoapps.student.models.user.cc.User.save"): + uname = "student" + email = "student@edx.org" + self.password = "Password1234" + + # Create the user and make them active so we can log them in. + self.student = UserFactory.create( + username=uname, email=email, password=self.password + ) + self.student.is_active = True + self.student.save() + + # Add a discussion moderator + self.moderator = UserFactory.create(password=self.password) + + # Enroll the student in the course + CourseEnrollmentFactory(user=self.student, course_id=self.course_id) + + # Enroll the moderator and give them the appropriate roles + CourseEnrollmentFactory(user=self.moderator, course_id=self.course.id) + self.moderator.roles.add( + Role.objects.get(name="Moderator", course_id=self.course.id) + ) + + assert self.client.login(username="student", password=self.password) + + def _setup_mock_request(self, mock_function, 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_return_value(mock_function, data) + + def create_thread_helper(self, extra_request_data=None, extra_response_data=None): + """ + Issues a request to create a thread and verifies the result. + """ + thread_data = { + "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, + } + self.set_mock_return_value("create_thread", thread_data) + self.set_mock_return_value("get_thread", thread_data) + + url = reverse( + "create_thread", + kwargs={ + "commentable_id": "i4x-MITx-999-course-Robot_Super_Course", + "course_id": str(self.course_id), + }, + ) + + 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) + + response = self.client.post(url, data=thread) + self.check_mock_called("create_thread") + expected_data = { + "thread_type": "discussion", + "body": "this is a post", + "context": ThreadContext.COURSE, + "anonymous_to_peers": False, + "user_id": "1", + "title": "Hello", + "commentable_id": "i4x-MITx-999-course-Robot_Super_Course", + "anonymous": False, + "course_id": str(self.course_id), + } + if extra_response_data: + expected_data.update(extra_response_data) + self.check_mock_called_with("create_thread", -1, **expected_data) + assert response.status_code == 200 + + def update_thread_helper(self): + """ + Issues a request to update a thread and verifies the result. + """ + self._setup_mock_request("get_thread") + self._setup_mock_request("update_thread") + + # Mock out saving in order to test that content is correctly + # updated. Otherwise, the call to thread.save() receives the + # same mocked request data that the original call to retrieve + # the thread did, overwriting any changes. + with patch.object(Thread, "save"): + response = self.client.post( + reverse( + "update_thread", + kwargs={"thread_id": "dummy", "course_id": str(self.course_id)}, + ), + data={"body": "foo", "title": "foo", "commentable_id": "some_topic"}, + ) + assert response.status_code == 200 + data = json.loads(response.content.decode("utf-8")) + assert data["body"] == "foo" + assert data["title"] == "foo" + assert data["commentable_id"] == "some_topic" + + +@ddt.ddt +@disable_signal(views, "comment_flagged") +@disable_signal(views, "thread_flagged") +class ViewsTestCase( + MockForumApiMixin, + UrlResetMixin, + SharedModuleStoreTestCase, + ViewsTestCaseMixin, + MockSignalHandlerMixin, +): + + @classmethod + def setUpClass(cls): + # pylint: disable=super-method-not-called + super().setUpClassAndForumMock() + with super().setUpClassAndTestData(): + cls.course = CourseFactory.create( + org="MITx", + course="999", + discussion_topics={"Some Topic": {"id": "some_topic"}}, + display_name="Robot Super Course", + ) + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + cls.course_id = cls.course.id + + # seed the forums permissions and roles + call_command("seed_permissions_roles", str(cls.course_id)) + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + # Patching the ENABLE_DISCUSSION_SERVICE value affects the contents of urls.py, + # so we need to call super.setUp() which reloads urls.py (because + # of the UrlResetMixin) + super().setUp() + # Patch the comment client user save method so it does not try + # to create a new cc user when creating a django user + with patch("common.djangoapps.student.models.user.cc.User.save"): + uname = "student" + email = "student@edx.org" + self.password = "Password1234" + + # Create the user and make them active so we can log them in. + self.student = UserFactory.create( + username=uname, email=email, password=self.password + ) + self.student.is_active = True + self.student.save() + + # Add a discussion moderator + self.moderator = UserFactory.create(password=self.password) + + # Enroll the student in the course + CourseEnrollmentFactory(user=self.student, course_id=self.course_id) + + # Enroll the moderator and give them the appropriate roles + CourseEnrollmentFactory(user=self.moderator, course_id=self.course.id) + self.moderator.roles.add( + Role.objects.get(name="Moderator", course_id=self.course.id) + ) + + assert self.client.login(username="student", password=self.password) + + self.set_mock_return_value("get_course_id_by_thread", str(self.course.id)) + self.set_mock_return_value("get_course_id_by_comment", str(self.course.id)) + + @contextmanager + def assert_discussion_signals(self, signal, user=None): + if user is None: + user = self.student + with self.assert_signal_sent( + views, signal, sender=None, user=user, exclude_args=("post",) + ): + yield + + def test_create_thread(self): + with self.assert_discussion_signals("thread_created"): + self.create_thread_helper() + + def test_create_thread_standalone(self): + team = CourseTeamFactory.create( + name="A Team", + course_id=self.course_id, + topic_id="topic_id", + discussion_topic_id="i4x-MITx-999-course-Robot_Super_Course", + ) + + # Add the student to the team so they can post to the commentable. + team.add_user(self.student) + + # create_thread_helper verifies that extra data are passed through to the comments service + self.create_thread_helper( + extra_response_data={"context": ThreadContext.STANDALONE} + ) + + def test_delete_thread(self): + mocked_data = { + "user_id": str(self.student.id), + "closed": False, + "body": "test body", + } + self.set_mock_return_value("get_thread", mocked_data) + self.set_mock_return_value("delete_thread", mocked_data) + test_thread_id = "test_thread_id" + request = RequestFactory().post("dummy_url", {"id": test_thread_id}) + request.user = self.student + request.view_name = "delete_thread" + with self.assert_discussion_signals("thread_deleted"): + response = views.delete_thread( + request, course_id=str(self.course.id), thread_id=test_thread_id + ) + assert response.status_code == 200 + self.check_mock_called("delete_thread") + + def test_delete_comment(self): + mocked_data = { + "user_id": str(self.student.id), + "closed": False, + "body": "test body", + } + self.set_mock_return_value("get_thread", mocked_data) + self.set_mock_return_value("get_parent_comment", mocked_data) + self.set_mock_return_value("delete_comment", mocked_data) + test_comment_id = "test_comment_id" + request = RequestFactory().post("dummy_url", {"id": test_comment_id}) + request.user = self.student + request.view_name = "delete_comment" + with self.assert_discussion_signals("comment_deleted"): + response = views.delete_comment( + request, course_id=str(self.course.id), comment_id=test_comment_id + ) + assert response.status_code == 200 + self.check_mock_called("delete_comment") + + def _test_request_error(self, view_name, view_kwargs, data, mock_functions): + """ + Submit a request against the given view with the given data and ensure + that the result is a 400 error and that no data was posted using + mock_request + """ + mock_functions = mock_functions or [] + for mock_func in mock_functions: + self._setup_mock_request( + mock_func, include_depth=(view_name == "create_sub_comment") + ) + + response = self.client.post(reverse(view_name, kwargs=view_kwargs), data=data) + assert response.status_code == 400 + + def test_create_thread_no_title(self): + self._test_request_error( + "create_thread", + {"commentable_id": "dummy", "course_id": str(self.course_id)}, + {"body": "foo"}, + ["create_thread"], + ) + + def test_create_thread_empty_title(self): + self._test_request_error( + "create_thread", + {"commentable_id": "dummy", "course_id": str(self.course_id)}, + {"body": "foo", "title": " "}, + ["create_thread"], + ) + + def test_create_thread_no_body(self): + self._test_request_error( + "create_thread", + {"commentable_id": "dummy", "course_id": str(self.course_id)}, + {"title": "foo"}, + ["create_thread"], + ) + + def test_create_thread_empty_body(self): + self._test_request_error( + "create_thread", + {"commentable_id": "dummy", "course_id": str(self.course_id)}, + {"body": " ", "title": "foo"}, + ["create_thread"], + ) + + def test_update_thread_no_title(self): + self._test_request_error( + "update_thread", + {"thread_id": "dummy", "course_id": str(self.course_id)}, + {"body": "foo"}, + ["get_thread", "update_thread"], + ) + + def test_update_thread_empty_title(self): + self._test_request_error( + "update_thread", + {"thread_id": "dummy", "course_id": str(self.course_id)}, + {"body": "foo", "title": " "}, + ["get_thread", "update_thread"], + ) + + def test_update_thread_no_body(self): + self._test_request_error( + "update_thread", + {"thread_id": "dummy", "course_id": str(self.course_id)}, + {"title": "foo"}, + ["get_thread", "update_thread"], + ) + + def test_update_thread_empty_body(self): + self._test_request_error( + "update_thread", + {"thread_id": "dummy", "course_id": str(self.course_id)}, + {"body": " ", "title": "foo"}, + ["get_thread", "update_thread"], + ) + + def test_update_thread_course_topic(self): + with self.assert_discussion_signals("thread_edited"): + self.update_thread_helper() + + @patch( + "lms.djangoapps.discussion.django_comment_client.utils.get_discussion_categories_ids", + return_value=["test_commentable"], + ) + def test_update_thread_wrong_commentable_id(self, mock_get_discussion_id_map): + self._test_request_error( + "update_thread", + {"thread_id": "dummy", "course_id": str(self.course_id)}, + {"body": "foo", "title": "foo", "commentable_id": "wrong_commentable"}, + ["get_thread", "update_thread"], + ) + + def test_create_comment(self): + self._setup_mock_request("get_thread") + self._setup_mock_request("create_parent_comment") + with self.assert_discussion_signals("comment_created"): + response = self.client.post( + reverse( + "create_comment", + kwargs={"course_id": str(self.course_id), "thread_id": "dummy"}, + ), + data={"body": "body"}, + ) + assert response.status_code == 200 + + def test_create_comment_no_body(self): + self._test_request_error( + "create_comment", + {"thread_id": "dummy", "course_id": str(self.course_id)}, + {}, + ["get_thread", "create_parent_comment"], + ) + + def test_create_comment_empty_body(self): + self._test_request_error( + "create_comment", + {"thread_id": "dummy", "course_id": str(self.course_id)}, + {"body": " "}, + ["get_thread", "create_parent_comment"], + ) + + def test_create_sub_comment_no_body(self): + self._test_request_error( + "create_sub_comment", + {"comment_id": "dummy", "course_id": str(self.course_id)}, + {}, + ["get_thread", "get_parent_comment"], + ) + + def test_create_sub_comment_empty_body(self): + self._test_request_error( + "create_sub_comment", + {"comment_id": "dummy", "course_id": str(self.course_id)}, + {"body": " "}, + ["get_thread", "get_parent_comment", "create_child_comment"], + ) + + def test_update_comment_no_body(self): + self._test_request_error( + "update_comment", + {"comment_id": "dummy", "course_id": str(self.course_id)}, + {}, + ["get_thread", "get_parent_comment", "update_comment"], + ) + + def test_update_comment_empty_body(self): + self._test_request_error( + "update_comment", + {"comment_id": "dummy", "course_id": str(self.course_id)}, + {"body": " "}, + ["get_thread", "get_parent_comment", "update_comment"], + ) + + def test_update_comment_basic(self): + self._setup_mock_request("get_parent_comment") + self._setup_mock_request("update_comment") + comment_id = "test_comment_id" + updated_body = "updated body" + with self.assert_discussion_signals("comment_edited"): + response = self.client.post( + reverse( + "update_comment", + kwargs={"course_id": str(self.course_id), "comment_id": comment_id}, + ), + data={"body": updated_body}, + ) + assert response.status_code == 200 + params = { + "comment_id": comment_id, + "body": updated_body, + "course_id": str(self.course_id), + } + self.check_mock_called_with("update_comment", -1, **params) + + def test_endorse_comment(self): + self._setup_mock_request("get_thread") + self._setup_mock_request("get_parent_comment") + self._setup_mock_request("update_comment") + self.client.login(username=self.moderator.username, password=self.password) + with self.assert_discussion_signals("comment_endorsed", user=self.moderator): + response = self.client.post( + reverse( + "endorse_comment", + kwargs={"comment_id": "dummy", "course_id": str(self.course_id)}, + ) + ) + assert response.status_code == 200 + + def test_flag_thread_open(self): + self.flag_thread(False) + + def test_flag_thread_close(self): + self.flag_thread(True) + + def flag_thread(self, is_closed): + thread_data = make_minimal_cs_thread( + { + "id": "518d4237b023791dca00000d", + "course_id": str(self.course_id), + "closed": is_closed, + "user_id": "1", + "username": "robot", + "abuse_flaggers": [1], + } + ) + self.set_mock_return_value("get_thread", thread_data) + self.set_mock_return_value("update_thread_flag", thread_data) + url = reverse( + "flag_abuse_for_thread", + kwargs={ + "thread_id": "518d4237b023791dca00000d", + "course_id": str(self.course_id), + }, + ) + response = self.client.post(url) + self.check_mock_called("update_thread_flag") + + self.check_mock_called_with( + "get_thread", + 0, + thread_id="518d4237b023791dca00000d", + params={ + "mark_as_read": True, + "with_responses": False, + "reverse_order": False, + "merge_question_type_responses": False, + }, + course_id=str(self.course_id), + ) + + self.check_mock_called_with( + "update_thread_flag", + 0, + thread_id="518d4237b023791dca00000d", + action="flag", + user_id=ANY, + course_id=str(self.course_id), + ) + + self.check_mock_called_with( + "get_thread", + 1, + thread_id="518d4237b023791dca00000d", + params={ + "mark_as_read": True, + "with_responses": False, + "reverse_order": False, + "merge_question_type_responses": False, + }, + course_id=str(self.course_id), + ) + + assert response.status_code == 200 + + def test_un_flag_thread_open(self): + self.un_flag_thread(False) + + def test_un_flag_thread_close(self): + self.un_flag_thread(True) + + def un_flag_thread(self, is_closed): + thread_data = make_minimal_cs_thread( + { + "id": "518d4237b023791dca00000d", + "course_id": str(self.course_id), + "closed": is_closed, + "user_id": "1", + "username": "robot", + "abuse_flaggers": [1], + } + ) + + self.set_mock_return_value("get_thread", thread_data) + self.set_mock_return_value("update_thread_flag", thread_data) + url = reverse( + "un_flag_abuse_for_thread", + kwargs={ + "thread_id": "518d4237b023791dca00000d", + "course_id": str(self.course_id), + }, + ) + response = self.client.post(url) + self.check_mock_called("update_thread_flag") + + self.check_mock_called_with( + "get_thread", + 0, + thread_id="518d4237b023791dca00000d", + params={ + "mark_as_read": True, + "with_responses": False, + "reverse_order": False, + "merge_question_type_responses": False, + }, + course_id=str(self.course_id), + ) + + self.check_mock_called_with( + "update_thread_flag", + 0, + thread_id="518d4237b023791dca00000d", + action="unflag", + user_id=ANY, + update_all=False, + course_id=str(self.course_id), + ) + + self.check_mock_called_with( + "get_thread", + 1, + thread_id="518d4237b023791dca00000d", + params={ + "mark_as_read": True, + "with_responses": False, + "reverse_order": False, + "merge_question_type_responses": False, + }, + course_id=str(self.course_id), + ) + + assert response.status_code == 200 + + def test_flag_comment_open(self): + self.flag_comment(False) + + def test_flag_comment_close(self): + self.flag_comment(True) + + def flag_comment(self, is_closed): + comment_data = make_minimal_cs_comment( + { + "id": "518d4237b023791dca00000d", + "body": "this is a comment", + "course_id": str(self.course_id), + "closed": is_closed, + "user_id": "1", + "username": "robot", + "abuse_flaggers": [1], + } + ) + + self.set_mock_return_value("get_parent_comment", comment_data) + self.set_mock_return_value("update_comment_flag", comment_data) + url = reverse( + "flag_abuse_for_comment", + kwargs={ + "comment_id": "518d4237b023791dca00000d", + "course_id": str(self.course_id), + }, + ) + + response = self.client.post(url) + self.check_mock_called("update_thread_flag") + + self.check_mock_called_with( + "get_parent_comment", + 0, + comment_id="518d4237b023791dca00000d", + course_id=str(self.course_id), + ) + + self.check_mock_called_with( + "update_comment_flag", + 0, + comment_id="518d4237b023791dca00000d", + action="flag", + user_id=ANY, + course_id=str(self.course_id), + ) + + self.check_mock_called_with( + "get_parent_comment", + 1, + comment_id="518d4237b023791dca00000d", + course_id=str(self.course_id), + ) + + assert response.status_code == 200 + + def test_un_flag_comment_open(self): + self.un_flag_comment(False) + + def test_un_flag_comment_close(self): + self.un_flag_comment(True) + + def un_flag_comment(self, is_closed): + comment_data = make_minimal_cs_comment( + { + "id": "518d4237b023791dca00000d", + "body": "this is a comment", + "course_id": str(self.course_id), + "closed": is_closed, + "user_id": "1", + "username": "robot", + "abuse_flaggers": [], + } + ) + + self.set_mock_return_value("get_parent_comment", comment_data) + self.set_mock_return_value("update_comment_flag", comment_data) + url = reverse( + "un_flag_abuse_for_comment", + kwargs={ + "comment_id": "518d4237b023791dca00000d", + "course_id": str(self.course_id), + }, + ) + + response = self.client.post(url) + self.check_mock_called("update_thread_flag") + + self.check_mock_called_with( + "get_parent_comment", + 0, + comment_id="518d4237b023791dca00000d", + course_id=str(self.course_id), + ) + + self.check_mock_called_with( + "update_comment_flag", + 0, + comment_id="518d4237b023791dca00000d", + action="unflag", + update_all=False, + user_id=ANY, + course_id=str(self.course_id), + ) + + self.check_mock_called_with( + "get_parent_comment", + 1, + comment_id="518d4237b023791dca00000d", + course_id=str(self.course_id), + ) + + assert response.status_code == 200 + + @ddt.data( + ("upvote_thread", "update_thread_votes", "thread_id", "thread_voted"), + ("upvote_comment", "update_comment_votes", "comment_id", "comment_voted"), + ("downvote_thread", "update_thread_votes", "thread_id", "thread_voted"), + ("downvote_comment", "update_comment_votes", "comment_id", "comment_voted"), + ) + @ddt.unpack + def test_voting(self, view_name, function_name, item_id, signal): + self._setup_mock_request("get_thread") + self._setup_mock_request("get_parent_comment") + self._setup_mock_request(function_name) + with self.assert_discussion_signals(signal): + response = self.client.post( + reverse( + view_name, + kwargs={item_id: "dummy", "course_id": str(self.course_id)}, + ) + ) + assert response.status_code == 200 + + @ddt.data( + ("follow_thread", "thread_followed"), + ("unfollow_thread", "thread_unfollowed"), + ) + @ddt.unpack + def test_follow_unfollow_thread_signals(self, view_name, signal): + self._setup_mock_request("get_thread") + with self.assert_discussion_signals(signal): + response = self.client.post( + reverse( + view_name, + kwargs={ + "course_id": str(self.course_id), + "thread_id": "i4x-MITx-999-course-Robot_Super_Course", + }, + ) + ) + assert response.status_code == 200 + + +@disable_signal(views, "comment_endorsed") +class ViewPermissionsTestCase( + UrlResetMixin, + SharedModuleStoreTestCase, + MockForumApiMixin, +): + """Test case for view permissions.""" + + @classmethod + def setUpClass(cls): # pylint: disable=super-method-not-called + """Set up class and forum mock.""" + 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): + """Set up test data.""" + super().setUpTestData() + + seed_permissions_roles(cls.course.id) + + cls.password = "test password" + cls.student = UserFactory.create(password=cls.password) + cls.moderator = UserFactory.create(password=cls.password) + + CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) + CourseEnrollmentFactory(user=cls.moderator, course_id=cls.course.id) + + cls.moderator.roles.add( + Role.objects.get(name="Moderator", course_id=cls.course.id) + ) + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + """Set up the test case.""" + super().setUp() + + # Set return values dynamically using the mixin method + self.set_mock_return_value("get_course_id_by_comment", self.course.id) + self.set_mock_return_value("get_course_id_by_thread", self.course.id) + self.set_mock_return_value("get_thread", {}) + self.set_mock_return_value("pin_thread", {}) + self.set_mock_return_value("unpin_thread", {}) + + def _set_mock_data_thread_and_comment(self, thread_data, comment_data): + """Set up mocked data for threads and comments""" + self.set_mock_return_value("get_thread", thread_data) + self.set_mock_return_value("get_parent_comment", comment_data) + self.set_mock_return_value("update_thread", thread_data) + self.set_mock_return_value("update_comment", comment_data) + + def test_pin_thread_as_student(self): + """Test pinning a thread as a student.""" + self.client.login(username=self.student.username, password=self.password) + response = self.client.post( + reverse( + "pin_thread", + kwargs={"course_id": str(self.course.id), "thread_id": "dummy"}, + ) + ) + assert response.status_code == 401 + + def test_pin_thread_as_moderator(self): + """Test pinning a thread as a moderator.""" + self.client.login(username=self.moderator.username, password=self.password) + response = self.client.post( + reverse( + "pin_thread", + kwargs={"course_id": str(self.course.id), "thread_id": "dummy"}, + ) + ) + assert response.status_code == 200 + + def test_un_pin_thread_as_student(self): + """Test unpinning a thread as a student.""" + self.client.login(username=self.student.username, password=self.password) + response = self.client.post( + reverse( + "un_pin_thread", + kwargs={"course_id": str(self.course.id), "thread_id": "dummy"}, + ) + ) + assert response.status_code == 401 + + def test_un_pin_thread_as_moderator(self): + """Test unpinning a thread as a moderator.""" + self.client.login(username=self.moderator.username, password=self.password) + response = self.client.post( + reverse( + "un_pin_thread", + kwargs={"course_id": str(self.course.id), "thread_id": "dummy"}, + ) + ) + assert response.status_code == 200 + + def test_endorse_response_as_staff(self): + thread_data = { + "type": "thread", + "thread_type": "question", + "user_id": str(self.student.id), + "commentable_id": "course", + } + comment_data = {"type": "comment", "thread_id": "dummy"} + self._set_mock_data_thread_and_comment(thread_data, comment_data) + + self.client.login(username=self.moderator.username, password=self.password) + response = self.client.post( + reverse( + "endorse_comment", + kwargs={"course_id": str(self.course.id), "comment_id": "dummy"}, + ) + ) + assert response.status_code == 200 + + def test_endorse_response_as_student(self): + thread_data = { + "type": "thread", + "thread_type": "question", + "user_id": str(self.moderator.id), + "commentable_id": "course", + } + comment_data = {"type": "comment", "thread_id": "dummy"} + self._set_mock_data_thread_and_comment(thread_data, comment_data) + + self.client.login(username=self.student.username, password=self.password) + response = self.client.post( + reverse( + "endorse_comment", + kwargs={"course_id": str(self.course.id), "comment_id": "dummy"}, + ) + ) + assert response.status_code == 401 + + def test_endorse_response_as_student_question_author(self): + thread_data = { + "type": "thread", + "thread_type": "question", + "user_id": str(self.student.id), + "commentable_id": "course", + } + comment_data = {"type": "comment", "thread_id": "dummy"} + self._set_mock_data_thread_and_comment(thread_data, comment_data) + + self.client.login(username=self.student.username, password=self.password) + response = self.client.post( + reverse( + "endorse_comment", + kwargs={"course_id": str(self.course.id), "comment_id": "dummy"}, + ) + ) + assert response.status_code == 200 + + +class CommentActionTestCase(CohortedTestCase, MockForumApiMixin): + """Test case for thread actions with group ID assertions.""" + + @classmethod + def setUpClass(cls): + """Set up class and forum mock.""" + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() + + def call_view( + self, view_name, mock_function, user=None, post_params=None, view_args=None + ): + """Call a view with the given parameters.""" + comment_response = make_minimal_cs_comment( + {"user_id": str(self.student.id), "group_id": self.student_cohort.id} + ) + + self.set_mock_return_value("get_course_id_by_comment", str(self.course.id)) + self.set_mock_return_value("get_parent_comment", comment_response) + self.set_mock_return_value(mock_function, comment_response) + + request = RequestFactory().post("dummy_url", post_params or {}) + request.user = user or self.student + request.view_name = view_name + + return getattr(views, view_name)( + request, + course_id=str(self.course.id), + comment_id="dummy", + **(view_args or {}), + ) + + def test_flag(self): + with mock.patch( + "openedx.core.djangoapps.django_comment_common.signals.comment_flagged.send" + ) as signal_mock: + self.call_view("flag_abuse_for_comment", "update_comment_flag") + self.assertEqual(signal_mock.call_count, 1) + + +@ddt.ddt +@disable_signal(views, "thread_voted") +@disable_signal(views, "thread_edited") +@disable_signal(views, "comment_created") +@disable_signal(views, "comment_voted") +@disable_signal(views, "comment_deleted") +@disable_signal(views, "comment_flagged") +@disable_signal(views, "thread_flagged") +class TeamsPermissionsTestCase( + UrlResetMixin, SharedModuleStoreTestCase, MockForumApiMixin +): + # Most of the test points use the same ddt data. + # args: user, commentable_id, status_code + ddt_permissions_args = [ + # Student in team can do operations on threads/comments within the team commentable. + ("student_in_team", "team_commentable_id", 200), + # Non-team commentables can be edited by any student. + ("student_in_team", "course_commentable_id", 200), + # Student not in team cannot do operations within the team commentable. + ("student_not_in_team", "team_commentable_id", 401), + # Non-team commentables can be edited by any student. + ("student_not_in_team", "course_commentable_id", 200), + # Moderators can always operator on threads within a team, regardless of team membership. + ("moderator", "team_commentable_id", 200), + # Group moderators have regular student privileges for creating a thread and commenting + ("group_moderator", "course_commentable_id", 200), + ] + + def change_divided_discussion_settings(self, scheme): + """ + Change divided discussion settings for the current course. + If dividing by cohorts, create and assign users to a cohort. + """ + enable_cohorts = True if scheme is CourseDiscussionSettings.COHORT else False + discussion_settings = CourseDiscussionSettings.get(self.course.id) + discussion_settings.update( + { + "enable_cohorts": enable_cohorts, + "divided_discussions": [], + "always_divide_inline_discussions": True, + "division_scheme": scheme, + } + ) + set_course_cohorted(self.course.id, enable_cohorts) + + @classmethod + def setUpClass(cls): + super().setUpClassAndForumMock() + # pylint: disable=super-method-not-called + with super().setUpClassAndTestData(): + teams_config_data = { + "topics": [ + { + "id": "topic_id", + "name": "Solar Power", + "description": "Solar power is hot", + } + ] + } + cls.course = CourseFactory.create( + teams_configuration=TeamsConfig(teams_config_data) + ) + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.password = "test password" + seed_permissions_roles(cls.course.id) + + # Create enrollment tracks + CourseModeFactory.create(course_id=cls.course.id, mode_slug=CourseMode.VERIFIED) + CourseModeFactory.create(course_id=cls.course.id, mode_slug=CourseMode.AUDIT) + + # Create 6 users-- + # student in team (in the team, audit) + # student not in team (not in the team, audit) + # cohorted (in the cohort, audit) + # verified (not in the cohort, verified) + # moderator (in the cohort, audit, moderator permissions) + # group moderator (in the cohort, verified, group moderator permissions) + def create_users_and_enroll(coursemode): + student = UserFactory.create(password=cls.password) + CourseEnrollmentFactory( + course_id=cls.course.id, user=student, mode=coursemode + ) + return student + + cls.student_in_team, cls.student_not_in_team, cls.moderator, cls.cohorted = [ + create_users_and_enroll(CourseMode.AUDIT) for _ in range(4) + ] + cls.verified, cls.group_moderator = [ + create_users_and_enroll(CourseMode.VERIFIED) for _ in range(2) + ] + + # Give moderator and group moderator permissions + cls.moderator.roles.add( + Role.objects.get(name="Moderator", course_id=cls.course.id) + ) + assign_role(cls.course.id, cls.group_moderator, "Group Moderator") + + # Create a team + cls.team_commentable_id = "team_discussion_id" + cls.team = CourseTeamFactory.create( + name="The Only Team", + course_id=cls.course.id, + topic_id="topic_id", + discussion_topic_id=cls.team_commentable_id, + ) + CourseTeamMembershipFactory.create(team=cls.team, user=cls.student_in_team) + + # Dummy commentable ID not linked to a team + cls.course_commentable_id = "course_level_commentable" + + # Create cohort and add students to it + CohortFactory( + course_id=cls.course.id, + name="Test Cohort", + users=[cls.group_moderator, cls.cohorted], + ) + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def setUp(self): + super().setUp() + + def _setup_mock(self, user, mock_functions=[], data=None): + user = getattr(self, user) + mock_functions = mock_functions or [] + for mock_func in mock_functions: + self.set_mock_return_value(mock_func, data or {}) + self.client.login(username=user.username, password=self.password) + + @ddt.data(*ddt_permissions_args) + @ddt.unpack + def test_comment_actions(self, user, commentable_id, status_code): + """ + Verify that voting and flagging of comments is limited to members of the team or users with + 'edit_content' permission. + """ + commentable_id = getattr(self, commentable_id) + self._setup_mock( + user, + [ + "get_parent_comment", + "update_comment_flag", + "update_comment_votes", + "delete_comment_vote", + ], + make_minimal_cs_comment( + { + "closed": False, + "commentable_id": commentable_id, + "course_id": str(self.course.id), + } + ), + ) + # "un_flag_abuse_for_comment", "flag_abuse_for_comment", + for action in ["upvote_comment", "downvote_comment"]: + response = self.client.post( + reverse( + action, + kwargs={ + "course_id": str(self.course.id), + "comment_id": "dummy", + }, + ) + ) + assert response.status_code == status_code + + @ddt.data(*ddt_permissions_args) + @ddt.unpack + def test_threads_actions(self, user, commentable_id, status_code): + """ + Verify that voting, flagging, and following of threads is limited to members of the team or users with + 'edit_content' permission. + """ + commentable_id = getattr(self, commentable_id) + self._setup_mock( + user, + [ + "get_thread", + "update_thread_flag", + "update_thread_votes", + "delete_thread_vote", + ], + make_minimal_cs_thread( + { + "commentable_id": commentable_id, + "course_id": str(self.course.id), + } + ), + ) + + for action in [ + "un_flag_abuse_for_thread", + "flag_abuse_for_thread", + "upvote_thread", + "downvote_thread", + ]: + response = self.client.post( + reverse( + action, + kwargs={ + "course_id": str(self.course.id), + "thread_id": "dummy", + }, + ) + ) + assert response.status_code == status_code + + @ddt.data( + # student_in_team will be able to update their own post, regardless of team membership + ( + "student_in_team", + "student_in_team", + "team_commentable_id", + 200, + CourseDiscussionSettings.NONE, + ), + ( + "student_in_team", + "student_in_team", + "course_commentable_id", + 200, + CourseDiscussionSettings.NONE, + ), + # students can only update their own posts + ( + "student_in_team", + "moderator", + "team_commentable_id", + 401, + CourseDiscussionSettings.NONE, + ), + # Even though student_not_in_team is not in the team, he can still modify posts he created while in the team. + ( + "student_not_in_team", + "student_not_in_team", + "team_commentable_id", + 200, + CourseDiscussionSettings.NONE, + ), + # Moderators can change their own posts and other people's posts. + ( + "moderator", + "moderator", + "team_commentable_id", + 200, + CourseDiscussionSettings.NONE, + ), + ( + "moderator", + "student_in_team", + "team_commentable_id", + 200, + CourseDiscussionSettings.NONE, + ), + # Group moderator can do operations on commentables within their group if the course is divided + ( + "group_moderator", + "verified", + "course_commentable_id", + 200, + CourseDiscussionSettings.ENROLLMENT_TRACK, + ), + ( + "group_moderator", + "cohorted", + "course_commentable_id", + 200, + CourseDiscussionSettings.COHORT, + ), + # Group moderators cannot do operations on commentables outside of their group + ( + "group_moderator", + "verified", + "course_commentable_id", + 401, + CourseDiscussionSettings.COHORT, + ), + ( + "group_moderator", + "cohorted", + "course_commentable_id", + 401, + CourseDiscussionSettings.ENROLLMENT_TRACK, + ), + # Group moderators cannot do operations when the course is not divided + ( + "group_moderator", + "verified", + "course_commentable_id", + 401, + CourseDiscussionSettings.NONE, + ), + ( + "group_moderator", + "cohorted", + "course_commentable_id", + 401, + CourseDiscussionSettings.NONE, + ), + ) + @ddt.unpack + def test_update_thread( + self, user, thread_author, commentable_id, status_code, division_scheme + ): + """ + Verify that update_thread is limited to thread authors and privileged users (team membership does not matter). + """ + self.change_divided_discussion_settings(division_scheme) + commentable_id = getattr(self, commentable_id) + # thread_author is who is marked as the author of the thread being updated. + thread_author = getattr(self, thread_author) + + self._setup_mock( + user, + ["get_thread", "update_thread"], # user is the person making the request. + { + "user_id": str(thread_author.id), + "closed": False, + "commentable_id": commentable_id, + "context": "standalone", + "username": thread_author.username, + "course_id": str(self.course.id), + }, + ) + response = self.client.post( + reverse( + "update_thread", + kwargs={"course_id": str(self.course.id), "thread_id": "dummy"}, + ), + data={"body": "foo", "title": "foo", "commentable_id": commentable_id}, + ) + assert response.status_code == status_code + + @ddt.data( + # Students can delete their own posts + ( + "student_in_team", + "student_in_team", + "team_commentable_id", + 200, + CourseDiscussionSettings.NONE, + ), + # Moderators can delete any post + ( + "moderator", + "student_in_team", + "team_commentable_id", + 200, + CourseDiscussionSettings.NONE, + ), + # Others cannot delete posts + ( + "student_in_team", + "moderator", + "team_commentable_id", + 401, + CourseDiscussionSettings.NONE, + ), + ( + "student_not_in_team", + "student_in_team", + "team_commentable_id", + 401, + CourseDiscussionSettings.NONE, + ), + # Group moderator can do operations on commentables within their group if the course is divided + ( + "group_moderator", + "verified", + "team_commentable_id", + 200, + CourseDiscussionSettings.ENROLLMENT_TRACK, + ), + ( + "group_moderator", + "cohorted", + "team_commentable_id", + 200, + CourseDiscussionSettings.COHORT, + ), + # Group moderators cannot do operations on commentables outside of their group + ( + "group_moderator", + "verified", + "team_commentable_id", + 401, + CourseDiscussionSettings.COHORT, + ), + ( + "group_moderator", + "cohorted", + "team_commentable_id", + 401, + CourseDiscussionSettings.ENROLLMENT_TRACK, + ), + # Group moderators cannot do operations when the course is not divided + ( + "group_moderator", + "verified", + "team_commentable_id", + 401, + CourseDiscussionSettings.NONE, + ), + ( + "group_moderator", + "cohorted", + "team_commentable_id", + 401, + CourseDiscussionSettings.NONE, + ), + ) + @ddt.unpack + def test_delete_comment( + self, user, comment_author, commentable_id, status_code, division_scheme + ): + commentable_id = getattr(self, commentable_id) + comment_author = getattr(self, comment_author) + self.change_divided_discussion_settings(division_scheme) + + self._setup_mock( + user, + ["get_thread", "get_parent_comment", "delete_comment"], + { + "closed": False, + "commentable_id": commentable_id, + "user_id": str(comment_author.id), + "username": comment_author.username, + "course_id": str(self.course.id), + "body": "test body", + }, + ) + + response = self.client.post( + reverse( + "delete_comment", + kwargs={"course_id": str(self.course.id), "comment_id": "dummy"}, + ), + data={"body": "foo", "title": "foo"}, + ) + assert response.status_code == status_code + + @ddt.data(*ddt_permissions_args) + @ddt.unpack + def test_create_comment(self, user, commentable_id, status_code): + """ + Verify that create_comment is limited to members of the team or users with 'edit_content' permission. + """ + commentable_id = getattr(self, commentable_id) + self._setup_mock( + user, + ["get_thread", "create_parent_comment"], + {"closed": False, "commentable_id": commentable_id}, + ) + + response = self.client.post( + reverse( + "create_comment", + kwargs={"course_id": str(self.course.id), "thread_id": "dummy"}, + ), + data={"body": "foo", "title": "foo"}, + ) + assert response.status_code == status_code + + @ddt.data(*ddt_permissions_args) + @ddt.unpack + def test_create_sub_comment(self, user, commentable_id, status_code): + """ + Verify that create_subcomment is limited to members of the team or users with 'edit_content' permission. + """ + commentable_id = getattr(self, commentable_id) + self._setup_mock( + user, + ["get_thread", "get_parent_comment", "create_child_comment"], + { + "closed": False, + "commentable_id": commentable_id, + "thread_id": "dummy_thread", + }, + ) + response = self.client.post( + reverse( + "create_sub_comment", + kwargs={ + "course_id": str(self.course.id), + "comment_id": "dummy_comment", + }, + ), + data={"body": "foo", "title": "foo"}, + ) + assert response.status_code == status_code + + +TEAM_COMMENTABLE_ID = "test-team-discussion" + + +@disable_signal(views, "comment_created") +@ddt.ddt +class ForumEventTestCase( + SharedModuleStoreTestCase, MockForumApiMixin +): + """ + Forum actions are expected to launch analytics events. Test these here. + """ + + @classmethod + def setUpClass(cls): + super().setUpClassAndForumMock() + # pylint: disable=super-method-not-called + 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() + CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) + cls.student.roles.add(Role.objects.get(name="Student", course_id=cls.course.id)) + CourseAccessRoleFactory( + course_id=cls.course.id, user=cls.student, role="Wizard" + ) + + @ddt.data( + ("vote_for_thread", "update_thread_votes", "thread_id", "thread"), + ("undo_vote_for_thread", "delete_thread_vote", "thread_id", "thread"), + ("vote_for_comment", "update_comment_votes", "comment_id", "response"), + ("undo_vote_for_comment", "delete_comment_vote", "comment_id", "response"), + ) + @ddt.unpack + @patch("eventtracking.tracker.emit") + def test_thread_voted_event( + self, view_name, function_name, obj_id_name, obj_type, mock_emit + ): + undo = view_name.startswith("undo") + cs_thread = make_minimal_cs_thread( + { + "commentable_id": "test_commentable_id", + "username": "gumprecht", + } + ) + cs_comment = make_minimal_cs_comment( + { + "closed": False, + "commentable_id": "test_commentable_id", + "username": "gumprecht", + } + ) + self.set_mock_return_value("get_thread", cs_thread) + self.set_mock_return_value("get_parent_comment", cs_comment) + self.set_mock_return_value( + function_name, cs_thread if "thread" in view_name else cs_comment + ) + + request = RequestFactory().post("dummy_url", {}) + request.user = self.student + request.view_name = view_name + view_function = getattr(views, view_name) + kwargs = dict(course_id=str(self.course.id)) + kwargs[obj_id_name] = obj_id_name + if not undo: + kwargs.update(value="up") + view_function(request, **kwargs) + + assert mock_emit.called + event_name, event = mock_emit.call_args[0] + assert event_name == f"edx.forum.{obj_type}.voted" + assert event["target_username"] == "gumprecht" + assert event["undo_vote"] == undo + assert event["vote_value"] == "up" + + @patch("eventtracking.tracker.emit") + @ddt.data( + ( + "create_thread", + "edx.forum.thread.created", + { + "thread_type": "discussion", + "body": "Test text", + "title": "Test", + "auto_subscribe": True, + }, + {"commentable_id": TEAM_COMMENTABLE_ID}, + ), + ( + "create_comment", + "edx.forum.response.created", + {"body": "Test comment", "auto_subscribe": True}, + {"thread_id": "test_thread_id"}, + ), + ( + "create_sub_comment", + "edx.forum.comment.created", + {"body": "Another comment"}, + {"comment_id": "dummy_comment_id"}, + ), + ) + @ddt.unpack + def test_team_events( + self, view_name, event_name, view_data, view_kwargs, mock_emit + ): + user = self.student + team = CourseTeamFactory.create(discussion_topic_id=TEAM_COMMENTABLE_ID) + CourseTeamMembershipFactory.create(team=team, user=user) + mock_request_data = { + "closed": False, + "commentable_id": TEAM_COMMENTABLE_ID, + "thread_id": "test_thread_id", + } + self.set_mock_return_value("create_thread", mock_request_data) + self.set_mock_return_value("get_thread", mock_request_data) + self.set_mock_return_value("create_comment", mock_request_data) + self.set_mock_return_value("create_parent_comment", mock_request_data) + self.set_mock_return_value("get_parent_comment", mock_request_data) + self.set_mock_return_value("create_child_comment", mock_request_data) + self.set_mock_return_value("create_sub_comment", mock_request_data) + + event_receiver = Mock() + forum_event = views.TRACKING_LOG_TO_EVENT_MAPS.get(event_name) + forum_event.connect(event_receiver) + + request = RequestFactory().post("dummy_url", view_data) + request.user = user + request.view_name = view_name + + getattr(views, view_name)(request, course_id=str(self.course.id), **view_kwargs) + + name, event = mock_emit.call_args[0] + assert name == event_name + assert event["team_id"] == team.team_id + + self.assertDictContainsSubset( + { + "signal": forum_event, + "sender": None, + }, + event_receiver.call_args.kwargs, + ) + + self.assertIn("thread", event_receiver.call_args.kwargs) + + @ddt.data( + "follow_thread", + "unfollow_thread", + ) + @patch("eventtracking.tracker.emit") + def test_thread_followed_event(self, view_name, mock_emit): + event_receiver = Mock() + for signal in views.TRACKING_LOG_TO_EVENT_MAPS.values(): + signal.connect(event_receiver) + + mock_request_data = { + "closed": False, + "commentable_id": "test_commentable_id", + "username": "test_user", + } + self.set_mock_return_value("get_thread", mock_request_data) + self.set_mock_return_value("follow_thread", mock_request_data) + self.set_mock_return_value("unfollow_thread", mock_request_data) + request = RequestFactory().post("dummy_url", {}) + request.user = self.student + request.view_name = view_name + view_function = getattr(views, view_name) + kwargs = dict(course_id=str(self.course.id)) + kwargs["thread_id"] = "thread_id" + view_function(request, **kwargs) + + assert mock_emit.called + event_name, event_data = mock_emit.call_args[0] + action_name = "followed" if view_name == "follow_thread" else "unfollowed" + expected_action_value = True if view_name == "follow_thread" else False + assert event_name == f"edx.forum.thread.{action_name}" + assert event_data["commentable_id"] == "test_commentable_id" + assert event_data["id"] == "thread_id" + assert event_data["followed"] == expected_action_value + assert event_data["user_forums_roles"] == ["Student"] + assert event_data["user_course_roles"] == ["Wizard"] + + # In case of events that doesn't have a correspondig Open edX events signal + # we need to check that none of the openedx signals is called. + # This is tested for all the events that are not tested above. + event_receiver.assert_not_called() + + @patch("eventtracking.tracker.emit") + def test_response_event(self, mock_emit): + """ + Check to make sure an event is fired when a user responds to a thread. + """ + event_receiver = Mock() + FORUM_THREAD_RESPONSE_CREATED.connect(event_receiver) + mocked_data = { + "closed": False, + "commentable_id": "test_commentable_id", + "thread_id": "test_thread_id", + } + self.set_mock_return_value("get_thread", mocked_data) + self.set_mock_return_value("create_parent_comment", mocked_data) + request = RequestFactory().post( + "dummy_url", {"body": "Test comment", "auto_subscribe": True} + ) + request.user = self.student + request.view_name = "create_comment" + views.create_comment( + request, course_id=str(self.course.id), thread_id="test_thread_id" + ) + + event_name, event = mock_emit.call_args[0] + assert event_name == "edx.forum.response.created" + assert event["body"] == "Test comment" + assert event["commentable_id"] == "test_commentable_id" + assert event["user_forums_roles"] == ["Student"] + assert event["user_course_roles"] == ["Wizard"] + assert event["discussion"]["id"] == "test_thread_id" + assert event["options"]["followed"] is True + + event_receiver.assert_called_once() + + self.assertDictContainsSubset( + { + "signal": FORUM_THREAD_RESPONSE_CREATED, + "sender": None, + }, + event_receiver.call_args.kwargs, + ) + + self.assertIn("thread", event_receiver.call_args.kwargs) + + @patch("eventtracking.tracker.emit") + def test_comment_event(self, mock_emit): + """ + Ensure an event is fired when someone comments on a response. + """ + event_receiver = Mock() + FORUM_RESPONSE_COMMENT_CREATED.connect(event_receiver) + mocked_data = { + "closed": False, + "depth": 1, + "thread_id": "test_thread_id", + "commentable_id": "test_commentable_id", + "parent_id": "test_response_id", + } + self.set_mock_return_value("get_thread", mocked_data) + self.set_mock_return_value("get_parent_comment", mocked_data) + self.set_mock_return_value("create_child_comment", mocked_data) + request = RequestFactory().post("dummy_url", {"body": "Another comment"}) + request.user = self.student + request.view_name = "create_sub_comment" + views.create_sub_comment( + request, course_id=str(self.course.id), comment_id="dummy_comment_id" + ) + + event_name, event = mock_emit.call_args[0] + assert event_name == "edx.forum.comment.created" + assert event["body"] == "Another comment" + assert event["discussion"]["id"] == "test_thread_id" + assert event["response"]["id"] == "test_response_id" + assert event["user_forums_roles"] == ["Student"] + assert event["user_course_roles"] == ["Wizard"] + assert event["options"]["followed"] is False + + self.assertDictContainsSubset( + { + "signal": FORUM_RESPONSE_COMMENT_CREATED, + "sender": None, + }, + event_receiver.call_args.kwargs, + ) + + self.assertIn("thread", event_receiver.call_args.kwargs) + + +@disable_signal(views, "thread_edited") +class UpdateThreadUnicodeTestCase( + SharedModuleStoreTestCase, + UnicodeTestMixin, + MockForumApiMixin, +): + def setUp(self): + super().setUp() + + @classmethod + def setUpClass(cls): + super().setUpClassAndForumMock() + # pylint: disable=super-method-not-called + 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() + CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) + + @patch( + "lms.djangoapps.discussion.django_comment_client.utils.get_discussion_categories_ids", + return_value=["test_commentable"], + ) + def _test_unicode_data(self, text, mock_get_discussion_id_map): + mocked_data = { + "user_id": str(self.student.id), + "closed": False, + } + self.set_mock_return_value("get_thread", mocked_data) + self.set_mock_return_value("update_thread", mocked_data) + request = RequestFactory().post( + "dummy_url", + { + "body": text, + "title": text, + "thread_type": "question", + "commentable_id": "test_commentable", + }, + ) + request.user = self.student + request.view_name = "update_thread" + response = views.update_thread( + request, course_id=str(self.course.id), thread_id="dummy_thread_id" + ) + + assert response.status_code == 200 + + self.check_mock_called("update_thread") + mock_params = self.get_mock_func_calls("update_thread")[-1][1] + assert mock_params["body"] == text + assert mock_params["title"] == text + assert mock_params["thread_type"] == "question" + assert mock_params["commentable_id"] == "test_commentable" + + +class CreateThreadUnicodeTestCase( + SharedModuleStoreTestCase, UnicodeTestMixin, 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() + CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) + + def _test_unicode_data(self, text): + """ + Test to make sure unicode data in a thread doesn't break it. + """ + # self.set_mock_return_value('get_thread', {}) + self.set_mock_return_value("create_thread", {}) + + request = RequestFactory().post( + "dummy_url", {"thread_type": "discussion", "body": text, "title": text} + ) + request.user = self.student + request.view_name = "create_thread" + response = views.create_thread( + # The commentable ID contains a username, the Unicode char below ensures it works fine + request, + course_id=str(self.course.id), + commentable_id="non_tåem_dummy_id", + ) + + assert response.status_code == 200 + self.check_mock_called("create_thread") + create_call_params = self.get_mock_func_calls("create_thread")[-1][1] + assert create_call_params["body"] == text + assert create_call_params["title"] == text + + +@disable_signal(views, "comment_created") +class CreateCommentUnicodeTestCase( + SharedModuleStoreTestCase, UnicodeTestMixin, 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() + CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) + + def _test_unicode_data(self, text): + commentable_id = "non_team_dummy_id" + mocked_data = {"closed": False, "commentable_id": commentable_id} + self.set_mock_return_value("get_thread", mocked_data) + self.set_mock_return_value("create_parent_comment", mocked_data) + + # We have to get clever here due to Thread's setters and getters. + # Patch won't work with it. + try: + Thread.commentable_id = commentable_id + request = RequestFactory().post("dummy_url", {"body": text}) + request.user = self.student + request.view_name = "create_comment" + response = views.create_comment( + request, course_id=str(self.course.id), thread_id="dummy_thread_id" + ) + + assert response.status_code == 200 + self.check_mock_called("create_parent_comment") + create_call_params = self.get_mock_func_calls("create_parent_comment")[-1][ + 1 + ] + assert create_call_params["body"] == text + finally: + del Thread.commentable_id + + +@disable_signal(views, "comment_edited") +class UpdateCommentUnicodeTestCase( + SharedModuleStoreTestCase, UnicodeTestMixin, 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() + CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) + + def _test_unicode_data(self, text): + mocked_data = { + "user_id": str(self.student.id), + "closed": False, + } + self.set_mock_return_value("get_thread", mocked_data) + self.set_mock_return_value("get_parent_comment", mocked_data) + self.set_mock_return_value("update_comment", mocked_data) + + request = RequestFactory().post("dummy_url", {"body": text}) + request.user = self.student + request.view_name = "update_comment" + response = views.update_comment( + request, course_id=str(self.course.id), comment_id="dummy_comment_id" + ) + + assert response.status_code == 200 + self.check_mock_called("update_comment") + update_call_params = self.get_mock_func_calls("update_comment")[-1][1] + assert update_call_params["body"] == text + + +@disable_signal(views, "comment_created") +class CreateSubCommentUnicodeTestCase( + SharedModuleStoreTestCase, UnicodeTestMixin, MockForumApiMixin +): + """ + Make sure comments under a response can handle unicode. + """ + + @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() + CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) + + def _test_unicode_data(self, text): + """ + Create a comment with unicode in it. + """ + mocked_data = { + "closed": False, + "depth": 1, + "thread_id": "test_thread", + "commentable_id": "non_team_dummy_id", + } + self.set_mock_return_value("get_thread", mocked_data) + self.set_mock_return_value("get_parent_comment", mocked_data) + self.set_mock_return_value("create_child_comment", mocked_data) + + request = RequestFactory().post("dummy_url", {"body": text}) + request.user = self.student + request.view_name = "create_sub_comment" + Thread.commentable_id = "test_commentable" + try: + response = views.create_sub_comment( + request, course_id=str(self.course.id), comment_id="dummy_comment_id" + ) + + assert response.status_code == 200 + self.check_mock_called("create_child_comment") + create_call_params = self.get_mock_func_calls("create_child_comment")[-1][1] + assert create_call_params["body"] == text + finally: + del Thread.commentable_id + + +class UsersEndpointTestCase(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(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/views.py b/lms/djangoapps/discussion/django_comment_client/base/views.py index 458b0a02857e..7578492d572e 100644 --- a/lms/djangoapps/discussion/django_comment_client/base/views.py +++ b/lms/djangoapps/discussion/django_comment_client/base/views.py @@ -161,7 +161,7 @@ def add_truncated_title_to_event_data(event_data, full_title): event_data['title'] = full_title[:TRACKING_MAX_FORUM_TITLE] -def track_thread_created_event(request, course, thread, followed, from_mfe_sidebar=False): +def track_thread_created_event(request, course, thread, followed, from_mfe_sidebar=False, notify_all_learners=False): """ Send analytics event for a newly created thread. """ @@ -172,7 +172,7 @@ def track_thread_created_event(request, course, thread, followed, from_mfe_sideb 'thread_type': thread.thread_type, 'anonymous': thread.anonymous, 'anonymous_to_peers': thread.anonymous_to_peers, - 'options': {'followed': followed}, + 'options': {'followed': followed, 'notify_all_learners': notify_all_learners}, 'from_mfe_sidebar': from_mfe_sidebar, # There is a stated desire for an 'origin' property that will state # whether this thread was created via courseware or the forum. @@ -367,6 +367,7 @@ def track_comment_reported_event(request, course, comment): obj_type = 'comment' if comment.get('parent_id') else 'response' event_name = _EVENT_NAME_TEMPLATE.format(obj_type=obj_type, action_name='reported') event_data = { + 'discussion': {'id': comment.thread_id}, 'body': comment.body[:TRACKING_MAX_FORUM_BODY], 'truncated': len(comment.body) > TRACKING_MAX_FORUM_BODY, 'commentable_id': comment.get('commentable_id', ''), @@ -405,6 +406,7 @@ def track_comment_unreported_event(request, course, comment): obj_type = 'comment' if comment.get('parent_id') else 'response' event_name = _EVENT_NAME_TEMPLATE.format(obj_type=obj_type, action_name='unreported') event_data = { + 'discussion': {'id': comment.thread_id}, 'body': comment.body[:TRACKING_MAX_FORUM_BODY], 'truncated': len(comment.body) > TRACKING_MAX_FORUM_BODY, 'commentable_id': comment.get('commentable_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 0a5fbe491930..1de062d2b210 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 @@ def test_cohorted_topic_student_with_other_group_id(self, mock_is_forum_v2_enabl ) 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 @@ def test_cohorted_topic_moderator_without_group_id(self, mock_is_forum_v2_enable ) 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 @@ def test_cohorted_topic_moderator_with_own_group_id(self, mock_is_forum_v2_enabl ) 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 @@ def test_cohorted_topic_moderator_with_other_group_id(self, mock_is_forum_v2_ena ) 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 @@ def test_cohorted_topic_enrollment_track_invalid_group_id(self, mock_is_forum_v2 }) 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 @@ def test_non_cohorted_topic_student_without_group_id(self, mock_is_forum_v2_enab ) 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 @@ def test_non_cohorted_topic_student_with_own_group_id(self, mock_is_forum_v2_ena ) 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 @@ def test_non_cohorted_topic_student_with_other_group_id(self, mock_is_forum_v2_e ) 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 @@ def test_non_cohorted_topic_moderator_without_group_id(self, mock_is_forum_v2_en ) 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 @@ def test_non_cohorted_topic_moderator_with_own_group_id(self, mock_is_forum_v2_e ) 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,18 +216,236 @@ def test_non_cohorted_topic_moderator_with_other_group_id(self, mock_is_forum_v2 ) 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) + + +class GroupIdAssertionMixinV2: + """ + Provides assertion methods for testing group_id functionality in forum v2. + + This mixin contains helper methods to verify that the comments service is called + with the correct group_id parameters and that responses contain the expected + group information. + """ + def _get_params_last_call(self, function_name): + """ + Returns the data or params dict that `mock_request` was called with. + """ + return self.get_mock_func_calls(function_name)[-1][1] + + def _assert_comments_service_called_with_group_id(self, group_id): + # self.function_name should be set by the test class that inherits from this mixin + # to specify which forum_api method is being tested (e.g., 'get_thread', 'get_comment', etc.) + assert self.check_mock_called(self.function_name) + assert self._get_params_last_call(self.function_name)['group_id'] == group_id + + def _assert_comments_service_called_without_group_id(self): + assert self.check_mock_called(self.function_name) + assert 'group_id' not in self._get_params_last_call(self.function_name) + + def _assert_html_response_contains_group_info(self, response): + group_info = {"group_id": None, "group_name": None} + match = re.search(r'"group_id": (\d*),', response.content.decode('utf-8')) + if match and match.group(1) != '': + group_info["group_id"] = int(match.group(1)) + match = re.search(r'"group_name": "(\w*)"', response.content.decode('utf-8')) + if match: + group_info["group_name"] = match.group(1) + self._assert_thread_contains_group_info(group_info) + + def _assert_json_response_contains_group_info(self, response, extract_thread=None): + """ + :param extract_thread: a function which accepts a dictionary (complete + json response payload) and returns another dictionary (first + occurrence of a thread model within that payload). if None is + passed, the identity function is assumed. + """ + payload = json.loads(response.content.decode('utf-8')) + thread = extract_thread(payload) if extract_thread else payload + self._assert_thread_contains_group_info(thread) + + def _assert_thread_contains_group_info(self, thread): + assert thread['group_id'] == self.student_cohort.id + assert thread['group_name'] == self.student_cohort.name + + +class CohortedTopicGroupIdTestMixinV2(GroupIdAssertionMixinV2): + """ + Provides test cases to verify that views pass the correct `group_id` to + the comments service when requesting content in cohorted discussions for forum v2. + """ + def call_view(self, commentable_id, user, group_id, pass_group_id=True): + """ + Call the view for the implementing test class, constructing a request + from the parameters. + """ + pass # lint-amnesty, pylint: disable=unnecessary-pass + + def test_cohorted_topic_student_without_group_id(self): + self.call_view("cohorted_topic", self.student, '', pass_group_id=False) + self._assert_comments_service_called_with_group_id(self.student_cohort.id) + + def test_cohorted_topic_student_none_group_id(self): + self.call_view("cohorted_topic", self.student, "") + self._assert_comments_service_called_with_group_id(self.student_cohort.id) + + def test_cohorted_topic_student_with_own_group_id(self): + self.call_view("cohorted_topic", self.student, self.student_cohort.id) + self._assert_comments_service_called_with_group_id(self.student_cohort.id) + + def test_cohorted_topic_student_with_other_group_id(self): + self.call_view( + "cohorted_topic", + self.student, + self.moderator_cohort.id + ) + self._assert_comments_service_called_with_group_id(self.student_cohort.id) + + def test_cohorted_topic_moderator_without_group_id(self): + self.call_view( + "cohorted_topic", + self.moderator, + '', + pass_group_id=False + ) + self._assert_comments_service_called_without_group_id() + + def test_cohorted_topic_moderator_none_group_id(self): + self.call_view("cohorted_topic", self.moderator, "") + self._assert_comments_service_called_without_group_id() + + def test_cohorted_topic_moderator_with_own_group_id(self): + self.call_view( + "cohorted_topic", + self.moderator, + self.moderator_cohort.id + ) + self._assert_comments_service_called_with_group_id(self.moderator_cohort.id) + + def test_cohorted_topic_moderator_with_other_group_id(self): + self.call_view( + "cohorted_topic", + self.moderator, + self.student_cohort.id + ) + self._assert_comments_service_called_with_group_id(self.student_cohort.id) + + def test_cohorted_topic_moderator_with_invalid_group_id(self): + invalid_id = self.student_cohort.id + self.moderator_cohort.id + response = self.call_view("cohorted_topic", self.moderator, invalid_id) # lint-amnesty, pylint: disable=assignment-from-no-return + assert response.status_code == 500 + + def test_cohorted_topic_enrollment_track_invalid_group_id(self): + CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.AUDIT) + CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.VERIFIED) + discussion_settings = CourseDiscussionSettings.get(self.course.id) + discussion_settings.update({ + 'divided_discussions': ['cohorted_topic'], + 'division_scheme': CourseDiscussionSettings.ENROLLMENT_TRACK, + 'always_divide_inline_discussions': True, + }) + + invalid_id = -1000 + response = self.call_view("cohorted_topic", self.moderator, invalid_id) # lint-amnesty, pylint: disable=assignment-from-no-return + assert response.status_code == 500 + + +class NonCohortedTopicGroupIdTestMixinV2(GroupIdAssertionMixinV2): + """ + Provides test cases to verify that views pass the correct `group_id` to + the comments service when requesting content in non-cohorted discussions for forum v2. + """ + def call_view(self, commentable_id, user, group_id, pass_group_id=True): + """ + Call the view for the implementing test class, constructing a request + from the parameters. + """ + pass # lint-amnesty, pylint: disable=unnecessary-pass + + def test_non_cohorted_topic_student_without_group_id(self): + self.call_view( + "non_cohorted_topic", + self.student, + '', + pass_group_id=False + ) + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_student_none_group_id(self): + self.call_view("non_cohorted_topic", self.student, '') + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_student_with_own_group_id(self): + self.call_view( + "non_cohorted_topic", + self.student, + self.student_cohort.id + ) + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_student_with_other_group_id(self): + self.call_view( + "non_cohorted_topic", + self.student, + self.moderator_cohort.id + ) + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_moderator_without_group_id(self): + self.call_view( + "non_cohorted_topic", + self.moderator, + "", + pass_group_id=False, + ) + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_moderator_none_group_id(self): + self.call_view("non_cohorted_topic", self.moderator, '') + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_moderator_with_own_group_id(self): + self.call_view( + "non_cohorted_topic", + self.moderator, + self.moderator_cohort.id, + ) + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_moderator_with_other_group_id(self): + self.call_view( + "non_cohorted_topic", + self.moderator, + self.student_cohort.id, + ) + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_moderator_with_invalid_group_id(self): + invalid_id = self.student_cohort.id + self.moderator_cohort.id + self.call_view("non_cohorted_topic", self.moderator, invalid_id) + self._assert_comments_service_called_without_group_id() + + def test_team_discussion_id_not_cohorted(self): + team = CourseTeamFactory( + course_id=self.course.id, + topic_id='topic-id' + ) + + team.add_user(self.student) + self.call_view(team.discussion_topic_id, self.student, '') + + self._assert_comments_service_called_without_group_id() diff --git a/lms/djangoapps/discussion/django_comment_client/tests/mixins.py b/lms/djangoapps/discussion/django_comment_client/tests/mixins.py new file mode 100644 index 000000000000..d811bcadefee --- /dev/null +++ b/lms/djangoapps/discussion/django_comment_client/tests/mixins.py @@ -0,0 +1,97 @@ +""" +Mixin for django_comment_client tests. +""" + +from unittest import mock + + +class MockForumApiMixin: + """Mixin to mock forum_api across different test cases with a single mock instance.""" + + users_map = {} + + @classmethod + def setUpClassAndForumMock(cls): + """ + Set up the class and apply the forum_api mock. + """ + cls.mock_forum_api = mock.Mock() + + patch_targets = [ + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api", + "openedx.core.djangoapps.django_comment_common.comment_client.comment.forum_api", + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api", + "openedx.core.djangoapps.django_comment_common.comment_client.course.forum_api", + "openedx.core.djangoapps.django_comment_common.comment_client.subscriptions.forum_api", + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api", + ] + cls.forum_api_patchers = [ + mock.patch(target, cls.mock_forum_api) for target in patch_targets + ] + for patcher in cls.forum_api_patchers: + patcher.start() + + @classmethod + def disposeForumMocks(cls): + """Stop patches after tests complete.""" + for patcher in cls.forum_api_patchers: + patcher.stop() + + def set_mock_return_value(self, function_name, return_value): + """ + Set a return value for a specific method in forum_api mock. + + Args: + function_name (str): The method name in the mock to set a return value for. + return_value (Any): The return value for the method. + """ + setattr( + self.mock_forum_api, function_name, mock.Mock(return_value=return_value) + ) + + def set_mock_side_effect(self, function_name, side_effect_fn): + """ + Set a side effect for a specific method in forum_api mock. + + Args: + function_name (str): The method name in the mock to set a side effect for. + side_effect_fn (Callable): A function to be called when the mock is called. + """ + setattr( + self.mock_forum_api, function_name, mock.Mock(side_effect=side_effect_fn) + ) + + def check_mock_called_with(self, function_name, index, *params, **kwargs): + """ + Check if a specific method in forum_api mock was called with the given parameters. + + Args: + function_name (str): The method name in the mock to check. + params (tuple): The parameters to check the method was called with. + """ + call_args = getattr(self.mock_forum_api, function_name).call_args_list[index] + assert call_args == mock.call(*params, **kwargs) + + def check_mock_called(self, function_name): + """ + Check if a specific method in the forum_api mock was called. + + Args: + function_name (str): The method name in the mock to check. + + Returns: + bool: True if the method was called, False otherwise. + """ + return getattr(self.mock_forum_api, function_name).called + + def get_mock_func_calls(self, function_name): + """ + Returns a list of call arguments for a specific method in the mock_forum_api. + + Args: + function_name (str): The name of the method in the mock_forum_api to retrieve call arguments for. + + Returns: + list: A list of call arguments for the specified method. + """ + return getattr(self.mock_forum_api, function_name).call_args_list diff --git a/lms/djangoapps/discussion/django_comment_client/tests/test_utils.py b/lms/djangoapps/discussion/django_comment_client/tests/test_utils.py index 8800504c86d5..742eb23bf5dc 100644 --- a/lms/djangoapps/discussion/django_comment_client/tests/test_utils.py +++ b/lms/djangoapps/discussion/django_comment_client/tests/test_utils.py @@ -39,12 +39,10 @@ ) from openedx.core.djangoapps.django_comment_common.comment_client.utils import ( CommentClientMaintenanceError, - perform_request, ) from openedx.core.djangoapps.django_comment_common.models import ( CourseDiscussionSettings, DiscussionsIdMapping, - ForumsConfig, assign_role ) from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles @@ -1650,35 +1648,6 @@ def test_divided_outside_group(self, check_condition_function): 'can_report': True} -class ClientConfigurationTestCase(TestCase): - """Simple test cases to ensure enabling/disabling the use of the comment service works as intended.""" - - def test_disabled(self): - """Ensures that an exception is raised when forums are disabled.""" - config = ForumsConfig.current() - config.enabled = False - config.save() - - with pytest.raises(CommentClientMaintenanceError): - perform_request('GET', 'http://www.google.com') - - @patch('requests.request') - def test_enabled(self, mock_request): - """Ensures that requests proceed normally when forums are enabled.""" - config = ForumsConfig.current() - config.enabled = True - config.save() - - response = Mock() - response.status_code = 200 - response.json = lambda: {} - - mock_request.return_value = response - - result = perform_request('GET', 'http://www.google.com') - assert result == {} - - def set_discussion_division_settings( course_key, enable_cohorts=False, always_divide_inline_discussions=False, divided_discussions=[], division_scheme=CourseDiscussionSettings.COHORT diff --git a/lms/djangoapps/discussion/django_comment_client/tests/utils.py b/lms/djangoapps/discussion/django_comment_client/tests/utils.py index 4f5fa72ef383..bc3fdffa11d0 100644 --- a/lms/djangoapps/discussion/django_comment_client/tests/utils.py +++ b/lms/djangoapps/discussion/django_comment_client/tests/utils.py @@ -13,24 +13,12 @@ from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory from common.djangoapps.util.testing import UrlResetMixin from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory -from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings, ForumsConfig, Role +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.lib.teams_config import TeamsConfig -class ForumsEnableMixin: - """ - Ensures that the forums are enabled for a given test class. - """ - def setUp(self): - super().setUp() - - config = ForumsConfig.current() - config.enabled = True - config.save() - - -class CohortedTestCase(ForumsEnableMixin, UrlResetMixin, SharedModuleStoreTestCase): +class CohortedTestCase(UrlResetMixin, SharedModuleStoreTestCase): """ Sets up a course with a student, a moderator and their cohorts. """ diff --git a/lms/djangoapps/discussion/plugins.py b/lms/djangoapps/discussion/plugins.py index 54898d51c315..e7edbf6f4a53 100644 --- a/lms/djangoapps/discussion/plugins.py +++ b/lms/djangoapps/discussion/plugins.py @@ -20,7 +20,7 @@ class DiscussionTab(TabFragmentViewMixin, EnrolledTab): """ - A tab for the cs_comments_service forums. + A tab for the forums. """ type = 'discussion' diff --git a/lms/djangoapps/discussion/rest_api/api.py b/lms/djangoapps/discussion/rest_api/api.py index 7123ca9e3fb4..8d64251dc6bd 100644 --- a/lms/djangoapps/discussion/rest_api/api.py +++ b/lms/djangoapps/discussion/rest_api/api.py @@ -372,6 +372,18 @@ def _format_datetime(dt): for (reason_code, label) in CLOSE_REASON_CODES.items() ], 'show_discussions': bool(discussion_tab and discussion_tab.is_enabled(course, request.user)), + 'has_bulk_delete_privileges': bool(user_roles & { + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + }), + 'is_notify_all_learners_enabled': getattr(settings, 'ENABLE_NOTIFY_ALL_LEARNERS', False), + 'captcha_settings': { + 'enabled': getattr(settings, 'ENABLE_DISCUSSION_CAPTCHA', False), + 'site_key': getattr(settings, 'RECAPTCHA_PUBLIC_KEY', None), + }, + 'is_email_verified': True, # Default to True for authenticated users + 'only_verified_users_can_post': getattr(settings, 'DISCUSSION_ONLY_VERIFIED_USERS_CAN_POST', False), + 'content_creation_rate_limited': getattr(settings, 'DISCUSSION_CONTENT_CREATION_RATE_LIMITED', False), } @@ -990,7 +1002,10 @@ def get_thread_list( except ValueError: pass - if (group_id is None) and not context["has_moderation_privilege"]: + if (group_id is None) and ( + not context["has_moderation_privilege"] + or request.user.id in context["ta_user_ids"] + ): group_id = get_group_id_for_user(request.user, CourseDiscussionSettings.get(course.id)) query_params = { @@ -1454,6 +1469,7 @@ def create_thread(request, thread_data): """ course_id = thread_data.get("course_id") from_mfe_sidebar = thread_data.pop("enable_in_context_sidebar", False) + notify_all_learners = thread_data.pop("notify_all_learners", False) user = request.user if not course_id: raise ValidationError({"course_id": ["This field is required."]}) @@ -1481,12 +1497,12 @@ def create_thread(request, thread_data): raise ValidationError(dict(list(serializer.errors.items()) + list(actions_form.errors.items()))) serializer.save() cc_thread = serializer.instance - thread_created.send(sender=None, user=user, post=cc_thread) + thread_created.send(sender=None, user=user, post=cc_thread, notify_all_learners=notify_all_learners) api_thread = serializer.data _do_extra_actions(api_thread, cc_thread, list(thread_data.keys()), actions_form, context, request) track_thread_created_event(request, course, cc_thread, actions_form.cleaned_data["following"], - from_mfe_sidebar) + from_mfe_sidebar, notify_all_learners) return api_thread @@ -1526,12 +1542,12 @@ def create_comment(request, comment_data): actions_form = CommentActionsForm(comment_data) if not (serializer.is_valid() and actions_form.is_valid()): raise ValidationError(dict(list(serializer.errors.items()) + list(actions_form.errors.items()))) + context["cc_requester"].follow(cc_thread) serializer.save() cc_comment = serializer.instance comment_created.send(sender=None, user=request.user, post=cc_comment) api_comment = serializer.data _do_extra_actions(api_comment, cc_comment, list(comment_data.keys()), actions_form, context, request) - track_comment_created_event(request, course, cc_comment, cc_thread["commentable_id"], followed=False, from_mfe_sidebar=from_mfe_sidebar) return api_comment diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api.py b/lms/djangoapps/discussion/rest_api/tests/test_api.py index 62725cc47466..480568d115ec 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_api.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_api.py @@ -2,1823 +2,138 @@ Tests for Discussion API internal interface """ - -import itertools -import random -from datetime import datetime, timedelta from unittest import mock -from urllib.parse import parse_qs, urlencode, urlparse, urlunparse import ddt -import httpretty import pytest -from django.test import override_settings from django.contrib.auth import get_user_model -from django.core.exceptions import ValidationError 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 rest_framework.exceptions import PermissionDenied -from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.django import modulestore -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory -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 ( - AdminFactory, - BetaTesterFactory, - CourseEnrollmentFactory, - StaffFactory, UserFactory ) -from common.djangoapps.util.testing import UrlResetMixin -from common.test.utils import MockSignalHandlerMixin, disable_signal -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.api import ( - create_comment, - create_thread, - delete_comment, - delete_thread, - get_comment_list, - get_course, - get_course_topics, - get_course_topics_v2, - get_thread, - get_thread_list, - get_user_comments, - update_comment, - update_thread -) -from lms.djangoapps.discussion.rest_api.exceptions import ( - CommentNotFoundError, - DiscussionBlackOutException, - DiscussionDisabledError, - ThreadNotFoundError -) -from lms.djangoapps.discussion.rest_api.serializers import TopicOrdering +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, - make_minimal_cs_thread, - make_paginated_api_response, - parsed_body, -) -from openedx.core.djangoapps.course_groups.models import CourseUserGroupPartitionGroup -from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory -from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, DiscussionTopicLink, Provider, \ - PostingRestriction -from openedx.core.djangoapps.discussions.tasks import update_discussions_settings_from_course_task -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]) - - -def _create_course_and_cohort_with_user_role(course_is_cohorted, user, role_name): - """ - Creates a course with the value of `course_is_cohorted`, plus `always_cohort_inline_discussions` - set to True (which is no longer the default value). Then 1) enrolls the user in that course, - 2) creates a cohort that the user is placed in, and 3) adds the user to the given role. - - Returns: a tuple of the created course and the created cohort - """ - cohort_course = CourseFactory.create( - cohort_config={"cohorted": course_is_cohorted, "always_cohort_inline_discussions": True} - ) - CourseEnrollmentFactory.create(user=user, course_id=cohort_course.id) - cohort = CohortFactory.create(course_id=cohort_course.id, users=[user]) - _assign_role_to_user(user=user, course_id=cohort_course.id, role=role_name) - - return [cohort_course, cohort] - - -def _set_course_discussion_blackout(course, user_id): +@ddt.ddt +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class GetUserCommentsTest(ForumMockUtilsMixin, SharedModuleStoreTestCase): """ - Set the blackout period for course discussions. - - Arguments: - course: Course for which blackout period is set - user_id: User id of user enrolled in the course + Tests for get_user_comments. """ - course.discussion_blackouts = [ - datetime.now(UTC) - timedelta(days=3), - datetime.now(UTC) + timedelta(days=3) - ] - configuration = DiscussionsConfiguration.get(course.id) - configuration.posting_restrictions = PostingRestriction.SCHEDULED - configuration.save() - modulestore().update_item(course, user_id) - -@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, - } - - @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} + super().setUpClassAndForumMock() + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() -@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'] == [] + self.course = CourseFactory.create() + # create staff user so that we don't need to worry about + # permissions here + self.user = UserFactory.create(is_staff=True) + self.register_get_user_response(self.user) -@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 = RequestFactory().get(f'/api/discussion/v1/users/{self.user.username}/{self.course.id}') 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): + def test_call_with_single_results_page(self): """ - Tests discussion topic details against a requested topic id + Assert that a minimal call with valid inputs, and single result, + returns the expected response structure. """ - 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 GetThreadListTest(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMixin, SharedModuleStoreTestCase): - """Test for get_thread_list""" - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.course = CourseFactory.create() - - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - super().setUp() - httpretty.reset() - httpretty.enable() - self.addCleanup(httpretty.reset) - self.addCleanup(httpretty.disable) - self.maxDiff = None # pylint: disable=invalid-name - self.user = UserFactory.create() - self.register_get_user_response(self.user) - self.request = RequestFactory().get("/test_path") - self.request.user = self.user - CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) - self.author = UserFactory.create() - self.course.cohort_config = {"cohorted": False} - modulestore().update_item(self.course, ModuleStoreEnum.UserID.test) - self.cohort = CohortFactory.create(course_id=self.course.id) - - def get_thread_list( - self, - threads, + self.register_get_comments_response( + [make_minimal_cs_comment()], page=1, - page_size=1, num_pages=1, - course=None, - topic_id_list=None, - ): - """ - Register the appropriate comments service response, then call - get_thread_list and return the result. - """ - course = course or self.course - self.register_get_threads_response(threads, page, num_pages) - ret = get_thread_list(self.request, course.id, page, page_size, topic_id_list) - return ret - - def test_nonexistent_course(self): - with pytest.raises(CourseNotFoundError): - get_thread_list(self.request, CourseLocator.from_string("course-v1:non+existent+course"), 1, 1) - - def test_not_enrolled(self): - self.request.user = UserFactory.create() - with pytest.raises(CourseNotFoundError): - self.get_thread_list([]) - - def test_discussions_disabled(self): - with pytest.raises(DiscussionDisabledError): - self.get_thread_list([], course=_discussion_disabled_course_for(self.user)) - - def test_empty(self): - assert self.get_thread_list( - [], num_pages=0 - ).data == { - 'pagination': { - 'next': None, - 'previous': None, - 'num_pages': 0, - 'count': 0 - }, - 'results': [], - 'text_search_rewrite': None - } - - def test_get_threads_by_topic_id(self): - self.get_thread_list([], topic_id_list=["topic_x", "topic_meow"]) - assert urlparse(httpretty.last_request().path).path == '/api/v1/threads' # lint-amnesty, pylint: disable=no-member - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["1"], - "commentable_ids": ["topic_x,topic_meow"] - }) - - def test_basic_query_params(self): - self.get_thread_list([], page=6, page_size=14) - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["6"], - "per_page": ["14"], - }) - - def test_thread_content(self): - self.course.cohort_config = {"cohorted": True} - modulestore().update_item(self.course, ModuleStoreEnum.UserID.test) - source_threads = [ - make_minimal_cs_thread({ - "id": "test_thread_id_0", - "course_id": str(self.course.id), - "commentable_id": "topic_x", - "username": self.author.username, - "user_id": str(self.author.id), - "title": "Test Title", - "body": "Test body", - "votes": {"up_count": 4}, - "comments_count": 5, - "unread_comments_count": 3, - "endorsed": True, - "read": True, - "created_at": "2015-04-28T00:00:00Z", - "updated_at": "2015-04-28T11:11:11Z", - }), - make_minimal_cs_thread({ - "id": "test_thread_id_1", - "course_id": str(self.course.id), - "commentable_id": "topic_y", - "group_id": self.cohort.id, - "username": self.author.username, - "user_id": str(self.author.id), - "thread_type": "question", - "title": "Another Test Title", - "body": "More content", - "votes": {"up_count": 9}, - "comments_count": 18, - "created_at": "2015-04-28T22:22:22Z", - "updated_at": "2015-04-28T00:33:33Z", - }) - ] - expected_threads = [ - self.expected_thread_data({ - "id": "test_thread_id_0", - "author": self.author.username, - "topic_id": "topic_x", - "vote_count": 4, - "comment_count": 6, - "unread_comment_count": 3, - "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_0", - "editable_fields": ["abuse_flagged", "copy_link", "following", "read", "voted"], - "has_endorsed": True, - "read": True, - "created_at": "2015-04-28T00:00:00Z", - "updated_at": "2015-04-28T11:11:11Z", - "abuse_flagged_count": None, - "can_delete": False, - }), - self.expected_thread_data({ - "id": "test_thread_id_1", - "author": self.author.username, - "topic_id": "topic_y", - "group_id": self.cohort.id, - "group_name": self.cohort.name, - "type": "question", - "title": "Another Test Title", - "raw_body": "More content", - "preview_body": "More content", - "rendered_body": "

More content

", - "vote_count": 9, - "comment_count": 19, - "created_at": "2015-04-28T22:22:22Z", - "updated_at": "2015-04-28T00:33:33Z", - "comment_list_url": None, - "endorsed_comment_list_url": ( - "http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_1&endorsed=True" - ), - "non_endorsed_comment_list_url": ( - "http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_1&endorsed=False" - ), - "editable_fields": ["abuse_flagged", "copy_link", "following", "read", "voted"], - "abuse_flagged_count": None, - "can_delete": False, - }), - ] - - expected_result = make_paginated_api_response( - results=expected_threads, count=2, num_pages=1, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - assert self.get_thread_list(source_threads).data == expected_result - - @ddt.data( - *itertools.product( - [ - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - FORUM_ROLE_STUDENT, - ], - [True, False] ) - ) - @ddt.unpack - def test_request_group(self, role_name, course_is_cohorted): - cohort_course = CourseFactory.create(cohort_config={"cohorted": course_is_cohorted}) - CourseEnrollmentFactory.create(user=self.user, course_id=cohort_course.id) - CohortFactory.create(course_id=cohort_course.id, users=[self.user]) - _assign_role_to_user(user=self.user, course_id=cohort_course.id, role=role_name) - self.get_thread_list([], course=cohort_course) - actual_has_group = "group_id" in httpretty.last_request().querystring # lint-amnesty, pylint: disable=no-member - expected_has_group = (course_is_cohorted and role_name == FORUM_ROLE_STUDENT) - assert actual_has_group == expected_has_group - - def test_pagination(self): - # N.B. Empty thread list is not realistic but convenient for this test - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=3, next_link="http://testserver/test_path?page=2", previous_link=None + response = get_user_comments( + request=self.request, + author=self.user, + course_key=self.course.id, ) - expected_result.update({"text_search_rewrite": None}) - assert self.get_thread_list([], page=1, num_pages=3).data == expected_result + assert "results" in response.data + assert "pagination" in response.data + assert response.data["pagination"]["count"] == 1 + assert response.data["pagination"]["num_pages"] == 1 + assert response.data["pagination"]["next"] is None + assert response.data["pagination"]["previous"] is None - expected_result = make_paginated_api_response( - results=[], - count=0, + @ddt.data(1, 2, 3) + def test_call_with_paginated_results(self, page): + """ + Assert that paginated results return the correct pagination + information at the pagination boundaries. + """ + self.register_get_comments_response( + [make_minimal_cs_comment() for _ in range(30)], + page=page, num_pages=3, - next_link="http://testserver/test_path?page=3", - previous_link="http://testserver/test_path?page=1" - ) - expected_result.update({"text_search_rewrite": None}) - assert self.get_thread_list([], page=2, num_pages=3).data == expected_result - - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=3, next_link=None, previous_link="http://testserver/test_path?page=2" - ) - expected_result.update({"text_search_rewrite": None}) - assert self.get_thread_list([], page=3, num_pages=3).data == expected_result - - # Test page past the last one - self.register_get_threads_response([], page=3, num_pages=3) - with pytest.raises(PageNotFoundError): - get_thread_list(self.request, self.course.id, page=4, page_size=10) - - @ddt.data(None, "rewritten search string") - def test_text_search(self, text_search_rewrite): - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": text_search_rewrite}) - self.register_get_threads_search_response([], text_search_rewrite, num_pages=0) - assert get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - text_search='test search string' - ).data == expected_result - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["10"], - "text": ["test search string"], - }) - - def test_filter_threads_by_author(self): - thread = make_minimal_cs_thread() - self.register_get_threads_response([thread], page=1, num_pages=10) - thread_results = get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - author=self.user.username, - ).data.get('results') - assert len(thread_results) == 1 - - expected_last_query_params = { - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["10"], - "author_id": [str(self.user.id)], - } - - self.assert_last_query_params(expected_last_query_params) - - def test_filter_threads_by_missing_author(self): - self.register_get_threads_response([make_minimal_cs_thread()], page=1, num_pages=10) - results = get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - author="a fake and missing username", - ).data.get('results') - assert len(results) == 0 - - @ddt.data('question', 'discussion', None) - def test_thread_type(self, thread_type): - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - - self.register_get_threads_response([], page=1, num_pages=0) - assert get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - thread_type=thread_type, - ).data == expected_result - - expected_last_query_params = { - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["10"], - "thread_type": [thread_type], - } - - if thread_type is None: - del expected_last_query_params["thread_type"] - - self.assert_last_query_params(expected_last_query_params) - - @ddt.data(True, False, None) - def test_flagged(self, flagged_boolean): - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - - self.register_get_threads_response([], page=1, num_pages=0) - assert get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - flagged=flagged_boolean, - ).data == expected_result - - expected_last_query_params = { - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["10"], - "flagged": [str(flagged_boolean)], - } - - if flagged_boolean is None: - del expected_last_query_params["flagged"] - - self.assert_last_query_params(expected_last_query_params) - - @ddt.data( - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - ) - def test_flagged_count(self, role): - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - - _assign_role_to_user(self.user, self.course.id, role=role) - - self.register_get_threads_response([], page=1, num_pages=0) - get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - count_flagged=True, - ) - - expected_last_query_params = { - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "count_flagged": ["True"], - "page": ["1"], - "per_page": ["10"], - } - - self.assert_last_query_params(expected_last_query_params) - - def test_flagged_count_denied(self): - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None ) - expected_result.update({"text_search_rewrite": None}) - - _assign_role_to_user(self.user, self.course.id, role=FORUM_ROLE_STUDENT) - - self.register_get_threads_response([], page=1, num_pages=0) - - with pytest.raises(PermissionDenied): - get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - count_flagged=True, - ) - - def test_following(self): - self.register_subscribed_threads_response(self.user, [], page=1, num_pages=0) - result = get_thread_list( - self.request, - self.course.id, - page=1, - page_size=11, - following=True, - ).data - - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None + response = get_user_comments( + request=self.request, + author=self.user, + course_key=self.course.id, + page=page, ) - expected_result.update({"text_search_rewrite": None}) - assert result == expected_result - assert urlparse( - httpretty.last_request().path # lint-amnesty, pylint: disable=no-member - ).path == f"/api/v1/users/{self.user.id}/subscribed_threads" - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["11"], - }) - - @ddt.data("unanswered", "unread") - def test_view_query(self, query): - self.register_get_threads_response([], page=1, num_pages=0) - result = get_thread_list( - self.request, - self.course.id, - page=1, - page_size=11, - view=query, - ).data + assert "pagination" in response.data + assert response.data["pagination"]["count"] == 30 + assert response.data["pagination"]["num_pages"] == 3 - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - assert result == expected_result - assert urlparse(httpretty.last_request().path).path == '/api/v1/threads' # lint-amnesty, pylint: disable=no-member - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["11"], - query: ["true"], - }) + if page in (1, 2): + assert response.data["pagination"]["next"] is not None + 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"] + if page == 1: + assert response.data["pagination"]["previous"] is None + if page == 3: + assert response.data["pagination"]["next"] is None - @ddt.data( - ("last_activity_at", "activity"), - ("comment_count", "comments"), - ("vote_count", "votes") - ) - @ddt.unpack - def test_order_by_query(self, http_query, cc_query): + def test_call_with_invalid_page(self): """ - Tests the order_by parameter - - Arguments: - http_query (str): Query string sent in the http request - cc_query (str): Query string used for the comments client service + Assert that calls for pages that exceed the existing number of + results pages raise PageNotFoundError. """ - self.register_get_threads_response([], page=1, num_pages=0) - result = get_thread_list( - self.request, - self.course.id, - page=1, - page_size=11, - order_by=http_query, - ).data - - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - assert result == expected_result - assert urlparse(httpretty.last_request().path).path == '/api/v1/threads' # lint-amnesty, pylint: disable=no-member - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": [cc_query], - "page": ["1"], - "per_page": ["11"], - }) + self.register_get_comments_response([], page=2, num_pages=1) + with pytest.raises(PageNotFoundError): + get_user_comments( + request=self.request, + author=self.user, + course_key=self.course.id, + page=2, + ) - def test_order_direction(self): + def test_call_with_non_existent_course(self): """ - Only "desc" is supported for order. Also, since it is simply swallowed, - it isn't included in the params. + Assert that calls for comments in a course that doesn't exist + result in a CourseNotFoundError error. """ - self.register_get_threads_response([], page=1, num_pages=0) - result = get_thread_list( - self.request, - self.course.id, + self.register_get_comments_response( + [make_minimal_cs_comment()], page=1, - page_size=11, - order_direction="desc", - ).data - - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - assert result == expected_result - assert urlparse(httpretty.last_request().path).path == '/api/v1/threads' # lint-amnesty, pylint: disable=no-member - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["11"], - }) - - def test_invalid_order_direction(self): - """ - Test with invalid order_direction (e.g. "asc") - """ - with pytest.raises(ValidationError) as assertion: - self.register_get_threads_response([], page=1, num_pages=0) - get_thread_list( # pylint: disable=expression-not-assigned - self.request, - self.course.id, - page=1, - page_size=11, - order_direction="asc", - ).data - assert 'order_direction' in assertion.value.message_dict - - -@ddt.ddt -@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class GetCommentListTest(ForumsEnableMixin, CommentsServiceMockMixin, SharedModuleStoreTestCase): - """Test for get_comment_list""" - - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.course = CourseFactory.create() - - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - super().setUp() - httpretty.reset() - httpretty.enable() - self.addCleanup(httpretty.reset) - self.addCleanup(httpretty.disable) - 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.maxDiff = None # pylint: disable=invalid-name - self.user = UserFactory.create() - self.register_get_user_response(self.user) - self.request = RequestFactory().get("/test_path") - self.request.user = self.user - CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) - self.author = UserFactory.create() - - def make_minimal_cs_thread(self, overrides=None): - """ - Create a thread with the given overrides, plus the course_id if not - already in overrides. - """ - overrides = overrides.copy() if overrides else {} - overrides.setdefault("course_id", str(self.course.id)) - return make_minimal_cs_thread(overrides) - - def get_comment_list(self, thread, endorsed=None, page=1, page_size=1, - merge_question_type_responses=False): - """ - Register the appropriate comments service response, then call - get_comment_list and return the result. - """ - self.register_get_thread_response(thread) - return get_comment_list(self.request, thread["id"], endorsed, page, page_size, - merge_question_type_responses=merge_question_type_responses) - - def test_nonexistent_thread(self): - thread_id = "nonexistent_thread" - self.register_get_thread_error_response(thread_id, 404) - with pytest.raises(ThreadNotFoundError): - get_comment_list(self.request, thread_id, endorsed=False, page=1, page_size=1) - - def test_nonexistent_course(self): - with pytest.raises(CourseNotFoundError): - self.get_comment_list(self.make_minimal_cs_thread({"course_id": "course-v1:non+existent+course"})) - - def test_not_enrolled(self): - self.request.user = UserFactory.create() - with pytest.raises(CourseNotFoundError): - self.get_comment_list(self.make_minimal_cs_thread()) - - def test_discussions_disabled(self): - disabled_course = _discussion_disabled_course_for(self.user) - with pytest.raises(DiscussionDisabledError): - self.get_comment_list( - self.make_minimal_cs_thread( - overrides={"course_id": str(disabled_course.id)} - ) - ) - - @ddt.data( - *itertools.product( - [ - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - FORUM_ROLE_STUDENT, - ], - [True, False], - [True, False], - ["no_group", "match_group", "different_group"], - ) - ) - @ddt.unpack - def test_group_access( - self, - role_name, - course_is_cohorted, - topic_is_cohorted, - thread_group_state - ): - cohort_course = CourseFactory.create( - discussion_topics={"Test Topic": {"id": "test_topic"}}, - cohort_config={ - "cohorted": course_is_cohorted, - "cohorted_discussions": ["test_topic"] if topic_is_cohorted else [], - } - ) - CourseEnrollmentFactory.create(user=self.user, course_id=cohort_course.id) - cohort = CohortFactory.create(course_id=cohort_course.id, users=[self.user]) - _assign_role_to_user(user=self.user, course_id=cohort_course.id, role=role_name) - thread = self.make_minimal_cs_thread({ - "course_id": str(cohort_course.id), - "commentable_id": "test_topic", - "group_id": ( - None if thread_group_state == "no_group" else - cohort.id if thread_group_state == "match_group" else - cohort.id + 1 - ), - }) - expected_error = ( - role_name == FORUM_ROLE_STUDENT and - course_is_cohorted and - topic_is_cohorted and - thread_group_state == "different_group" - ) - try: - self.get_comment_list(thread) - assert not expected_error - except ThreadNotFoundError: - assert expected_error - - @ddt.data(True, False) - def test_discussion_endorsed(self, endorsed_value): - with pytest.raises(ValidationError) as assertion: - self.get_comment_list( - self.make_minimal_cs_thread({"thread_type": "discussion"}), - endorsed=endorsed_value - ) - assert assertion.value.message_dict == {'endorsed': ['This field may not be specified for discussion threads.']} - - def test_question_without_endorsed(self): - with pytest.raises(ValidationError) as assertion: - self.get_comment_list( - self.make_minimal_cs_thread({"thread_type": "question"}), - endorsed=None - ) - assert assertion.value.message_dict == {'endorsed': ['This field is required for question threads.']} - - def test_empty(self): - discussion_thread = self.make_minimal_cs_thread( - {"thread_type": "discussion", "children": [], "resp_total": 0} - ) - assert self.get_comment_list(discussion_thread).data == make_paginated_api_response( - results=[], count=0, num_pages=1, next_link=None, previous_link=None) - - question_thread = self.make_minimal_cs_thread({ - "thread_type": "question", - "endorsed_responses": [], - "non_endorsed_responses": [], - "non_endorsed_resp_total": 0 - }) - assert self.get_comment_list(question_thread, endorsed=False).data == make_paginated_api_response( - results=[], count=0, num_pages=1, next_link=None, previous_link=None) - assert self.get_comment_list(question_thread, endorsed=True).data == make_paginated_api_response( - results=[], count=0, num_pages=1, next_link=None, previous_link=None) - - def test_basic_query_params(self): - self.get_comment_list( - self.make_minimal_cs_thread({ - "children": [make_minimal_cs_comment({"username": self.user.username})], - "resp_total": 71 - }), - page=6, - page_size=14 - ) - self.assert_query_params_equal( - httpretty.httpretty.latest_requests[-2], - { - "user_id": [str(self.user.id)], - "mark_as_read": ["False"], - "recursive": ["False"], - "resp_skip": ["70"], - "resp_limit": ["14"], - "with_responses": ["True"], - "reverse_order": ["False"], - "merge_question_type_responses": ["False"], - } - ) - - def get_source_and_expected_comments(self): - """ - Returns the source comments and expected comments for testing purposes. - """ - source_comments = [ - { - "type": "comment", - "id": "test_comment_1", - "thread_id": "test_thread", - "user_id": str(self.author.id), - "username": self.author.username, - "anonymous": False, - "anonymous_to_peers": False, - "created_at": "2015-05-11T00:00:00Z", - "updated_at": "2015-05-11T11:11:11Z", - "body": "Test body", - "endorsed": True, - "abuse_flaggers": [], - "votes": {"up_count": 4}, - "child_count": 0, - "children": [], - }, - { - "type": "comment", - "id": "test_comment_2", - "thread_id": "test_thread", - "user_id": str(self.author.id), - "username": self.author.username, - "anonymous": True, - "anonymous_to_peers": False, - "created_at": "2015-05-11T22:22:22Z", - "updated_at": "2015-05-11T33:33:33Z", - "body": "More content", - "endorsed": False, - "abuse_flaggers": [str(self.user.id)], - "votes": {"up_count": 7}, - "child_count": 0, - "children": [], - } - ] - expected_comments = [ - { - "id": "test_comment_1", - "thread_id": "test_thread", - "parent_id": None, - "author": self.author.username, - "author_label": None, - "created_at": "2015-05-11T00:00:00Z", - "updated_at": "2015-05-11T11:11:11Z", - "raw_body": "Test body", - "rendered_body": "

Test body

", - "endorsed": True, - "endorsed_by": None, - "endorsed_by_label": None, - "endorsed_at": None, - "abuse_flagged": False, - "abuse_flagged_any_user": None, - "voted": False, - "vote_count": 4, - "editable_fields": ["abuse_flagged", "voted"], - "child_count": 0, - "children": [], - "can_delete": False, - "anonymous": False, - "anonymous_to_peers": False, - "last_edit": None, - "edit_by_label": None, - "profile_image": { - "has_image": False, - "image_url_full": "http://testserver/static/default_500.png", - "image_url_large": "http://testserver/static/default_120.png", - "image_url_medium": "http://testserver/static/default_50.png", - "image_url_small": "http://testserver/static/default_30.png", - }, - }, - { - "id": "test_comment_2", - "thread_id": "test_thread", - "parent_id": None, - "author": None, - "author_label": None, - "created_at": "2015-05-11T22:22:22Z", - "updated_at": "2015-05-11T33:33:33Z", - "raw_body": "More content", - "rendered_body": "

More content

", - "endorsed": False, - "endorsed_by": None, - "endorsed_by_label": None, - "endorsed_at": None, - "abuse_flagged": True, - "abuse_flagged_any_user": None, - "voted": False, - "vote_count": 7, - "editable_fields": ["abuse_flagged", "voted"], - "child_count": 0, - "children": [], - "can_delete": False, - "anonymous": True, - "anonymous_to_peers": False, - "last_edit": None, - "edit_by_label": None, - "profile_image": { - "has_image": False, - "image_url_full": "http://testserver/static/default_500.png", - "image_url_large": "http://testserver/static/default_120.png", - "image_url_medium": "http://testserver/static/default_50.png", - "image_url_small": "http://testserver/static/default_30.png", - }, - }, - ] - return source_comments, expected_comments - - def test_discussion_content(self): - source_comments, expected_comments = self.get_source_and_expected_comments() - actual_comments = self.get_comment_list( - self.make_minimal_cs_thread({"children": source_comments}) - ).data["results"] - assert actual_comments == expected_comments - - def test_question_content_with_merge_question_type_responses(self): - source_comments, expected_comments = self.get_source_and_expected_comments() - actual_comments = self.get_comment_list( - self.make_minimal_cs_thread({ - "thread_type": "question", - "children": source_comments, - "resp_total": len(source_comments) - }), merge_question_type_responses=True).data["results"] - assert actual_comments == expected_comments - - def test_question_content_(self): - thread = self.make_minimal_cs_thread({ - "thread_type": "question", - "endorsed_responses": [make_minimal_cs_comment({"id": "endorsed_comment", "username": self.user.username})], - "non_endorsed_responses": [make_minimal_cs_comment({ - "id": "non_endorsed_comment", "username": self.user.username - })], - "non_endorsed_resp_total": 1, - }) - - endorsed_actual = self.get_comment_list(thread, endorsed=True).data - assert endorsed_actual['results'][0]['id'] == 'endorsed_comment' - - non_endorsed_actual = self.get_comment_list(thread, endorsed=False).data - assert non_endorsed_actual['results'][0]['id'] == 'non_endorsed_comment' - - def test_endorsed_by_anonymity(self): - """ - Ensure thread anonymity is properly considered in serializing - endorsed_by. - """ - thread = self.make_minimal_cs_thread({ - "anonymous": True, - "children": [ - make_minimal_cs_comment({ - "username": self.user.username, - "endorsement": {"user_id": str(self.author.id), "time": "2015-05-18T12:34:56Z"}, - }) - ] - }) - actual_comments = self.get_comment_list(thread).data["results"] - assert actual_comments[0]['endorsed_by'] is None - - @ddt.data( - ("discussion", None, "children", "resp_total", False), - ("question", False, "non_endorsed_responses", "non_endorsed_resp_total", False), - ("question", None, "children", "resp_total", True), - ) - @ddt.unpack - def test_cs_pagination(self, thread_type, endorsed_arg, response_field, - response_total_field, merge_question_type_responses): - """ - Test cases in which pagination is done by the comments service. - - thread_type is the type of thread (question or discussion). - endorsed_arg is the value of the endorsed argument. - repsonse_field is the field in which responses are returned for the - given thread type. - response_total_field is the field in which the total number of responses - is returned for the given thread type. - """ - # N.B. The mismatch between the number of children and the listed total - # number of responses is unrealistic but convenient for this test - thread = self.make_minimal_cs_thread({ - "thread_type": thread_type, - response_field: [make_minimal_cs_comment({"username": self.user.username})], - response_total_field: 5, - }) - - # Only page - actual = self.get_comment_list(thread, endorsed=endorsed_arg, page=1, page_size=5, - merge_question_type_responses=merge_question_type_responses).data - assert actual['pagination']['next'] is None - assert actual['pagination']['previous'] is None - - # First page of many - actual = self.get_comment_list(thread, endorsed=endorsed_arg, page=1, page_size=2, - merge_question_type_responses=merge_question_type_responses).data - assert actual['pagination']['next'] == 'http://testserver/test_path?page=2' - assert actual['pagination']['previous'] is None - - # Middle page of many - actual = self.get_comment_list(thread, endorsed=endorsed_arg, page=2, page_size=2, - merge_question_type_responses=merge_question_type_responses).data - assert actual['pagination']['next'] == 'http://testserver/test_path?page=3' - assert actual['pagination']['previous'] == 'http://testserver/test_path?page=1' - - # Last page of many - actual = self.get_comment_list(thread, endorsed=endorsed_arg, page=3, page_size=2, - merge_question_type_responses=merge_question_type_responses).data - assert actual['pagination']['next'] is None - assert actual['pagination']['previous'] == 'http://testserver/test_path?page=2' - - # Page past the end - thread = self.make_minimal_cs_thread({ - "thread_type": thread_type, - response_field: [], - response_total_field: 5 - }) - with pytest.raises(PageNotFoundError): - self.get_comment_list(thread, endorsed=endorsed_arg, page=2, page_size=5, - merge_question_type_responses=merge_question_type_responses) - - def test_question_endorsed_pagination(self): - thread = self.make_minimal_cs_thread({ - "thread_type": "question", - "endorsed_responses": [make_minimal_cs_comment({ - "id": f"comment_{i}", - "username": self.user.username - }) for i in range(10)] - }) - - def assert_page_correct(page, page_size, expected_start, expected_stop, expected_next, expected_prev): - """ - Check that requesting the given page/page_size returns the expected - output - """ - actual = self.get_comment_list(thread, endorsed=True, page=page, page_size=page_size).data - result_ids = [result["id"] for result in actual["results"]] - assert result_ids == [f"comment_{i}" for i in range(expected_start, expected_stop)] - assert actual['pagination']['next'] == ( - f"http://testserver/test_path?page={expected_next}" if expected_next else None - ) - assert actual['pagination']['previous'] == ( - f"http://testserver/test_path?page={expected_prev}" if expected_prev else None - ) - - # Only page - assert_page_correct( - page=1, - page_size=10, - expected_start=0, - expected_stop=10, - expected_next=None, - expected_prev=None - ) - - # First page of many - assert_page_correct( - page=1, - page_size=4, - expected_start=0, - expected_stop=4, - expected_next=2, - expected_prev=None - ) - - # Middle page of many - assert_page_correct( - page=2, - page_size=4, - expected_start=4, - expected_stop=8, - expected_next=3, - expected_prev=1 - ) - - # Last page of many - assert_page_correct( - page=3, - page_size=4, - expected_start=8, - expected_stop=10, - expected_next=None, - expected_prev=2 - ) - - # Page past the end - with pytest.raises(PageNotFoundError): - self.get_comment_list(thread, endorsed=True, page=2, page_size=10) - - -@ddt.ddt -@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class GetUserCommentsTest(ForumsEnableMixin, CommentsServiceMockMixin, SharedModuleStoreTestCase): - """ - Tests for get_user_comments. - """ - - @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 - # permissions here - self.user = UserFactory.create(is_staff=True) - self.register_get_user_response(self.user) - - self.request = RequestFactory().get(f'/api/discussion/v1/users/{self.user.username}/{self.course.id}') - self.request.user = self.user - - def test_call_with_single_results_page(self): - """ - Assert that a minimal call with valid inputs, and single result, - returns the expected response structure. - """ - self.register_get_comments_response( - [make_minimal_cs_comment()], - page=1, - num_pages=1, - ) - response = get_user_comments( - request=self.request, - author=self.user, - course_key=self.course.id, - ) - assert "results" in response.data - assert "pagination" in response.data - assert response.data["pagination"]["count"] == 1 - assert response.data["pagination"]["num_pages"] == 1 - assert response.data["pagination"]["next"] is None - assert response.data["pagination"]["previous"] is None - - @ddt.data(1, 2, 3) - def test_call_with_paginated_results(self, page): - """ - Assert that paginated results return the correct pagination - information at the pagination boundaries. - """ - self.register_get_comments_response( - [make_minimal_cs_comment() for _ in range(30)], - page=page, - num_pages=3, - ) - response = get_user_comments( - request=self.request, - author=self.user, - course_key=self.course.id, - page=page, - ) - assert "pagination" in response.data - assert response.data["pagination"]["count"] == 30 - assert response.data["pagination"]["num_pages"] == 3 - - if page in (1, 2): - assert response.data["pagination"]["next"] is not None - 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"] - if page == 1: - assert response.data["pagination"]["previous"] is None - if page == 3: - assert response.data["pagination"]["next"] is None - - def test_call_with_invalid_page(self): - """ - Assert that calls for pages that exceed the existing number of - results pages raise PageNotFoundError. - """ - self.register_get_comments_response([], page=2, num_pages=1) - with pytest.raises(PageNotFoundError): - get_user_comments( - request=self.request, - author=self.user, - course_key=self.course.id, - page=2, - ) - - def test_call_with_non_existent_course(self): - """ - Assert that calls for comments in a course that doesn't exist - result in a CourseNotFoundError error. - """ - self.register_get_comments_response( - [make_minimal_cs_comment()], - page=1, - num_pages=1, + num_pages=1, ) with pytest.raises(CourseNotFoundError): get_user_comments( @@ -1827,2547 +142,3 @@ def test_call_with_non_existent_course(self): course_key=CourseKey.from_string("course-v1:x+y+z"), page=2, ) - - -@ddt.ddt -@disable_signal(api, 'thread_created') -@disable_signal(api, 'thread_voted') -@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class CreateThreadTest( - ForumsEnableMixin, - CommentsServiceMockMixin, - UrlResetMixin, - SharedModuleStoreTestCase, - MockSignalHandlerMixin -): - """Tests for create_thread""" - LONG_TITLE = ( - 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. ' - 'Aenean commodo ligula eget dolor. Aenean massa. Cum sociis ' - 'natoque penatibus et magnis dis parturient montes, nascetur ' - 'ridiculus mus. Donec quam felis, ultricies nec, ' - 'pellentesque eu, pretium quis, sem. Nulla consequat massa ' - 'quis enim. Donec pede justo, fringilla vel, aliquet nec, ' - 'vulputate eget, arcu. In enim justo, rhoncus ut, imperdiet ' - 'a, venenatis vitae, justo. Nullam dictum felis eu pede ' - 'mollis pretium. Integer tincidunt. Cras dapibus. Vivamus ' - 'elementum semper nisi. Aenean vulputate eleifend tellus. ' - 'Aenean leo ligula, porttitor eu, consequat vitae, eleifend ' - 'ac, enim. Aliquam lorem ante, dapibus in, viverra quis, ' - 'feugiat a, tellus. Phasellus viverra nulla ut metus varius ' - 'laoreet. Quisque rutrum. Aenean imperdiet. Etiam ultricies ' - 'nisi vel augue. Curabitur ullamcorper ultricies nisi. Nam ' - 'eget dui. Etiam rhoncus. Maecenas tempus, tellus eget ' - 'condimentum rhoncus, sem quam semper libero, sit amet ' - 'adipiscing sem neque sed ipsum. Nam quam nunc, blandit vel, ' - 'luctus pulvinar, hendrerit id, lorem. Maecenas nec odio et ' - 'ante tincidunt tempus. Donec vitae sapien ut libero ' - 'venenatis faucibus. Nullam quis ante. Etiam sit amet orci ' - 'eget eros faucibus tincidunt. Duis leo. Sed fringilla ' - 'mauris sit amet nibh. Donec sodales sagittis magna. Sed ' - 'consequat, leo eget bibendum sodales, augue velit cursus ' - 'nunc, quis gravida magna mi a libero. Fusce vulputate ' - 'eleifend sapien. Vestibulum purus quam, scelerisque ut, ' - 'mollis sed, nonummy id, metus. Nullam accumsan lorem in ' - 'dui. Cras ultricies mi eu turpis hendrerit fringilla. ' - 'Vestibulum ante ipsum primis in faucibus orci luctus et ' - 'ultrices posuere cubilia Curae; In ac dui quis mi ' - 'consectetuer lacinia. Nam pretium turpis et arcu. Duis arcu ' - 'tortor, suscipit eget, imperdiet nec, imperdiet iaculis, ' - 'ipsum. Sed aliquam ultrices mauris. Integer ante arcu, ' - 'accumsan a, consectetuer eget, posuere ut, mauris. Praesent ' - 'adipiscing. Phasellus ullamcorper ipsum rutrum nunc. Nunc ' - 'nonummy metus.' - ) - - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - super().setUp() - self.course = CourseFactory.create() - 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("/test_path") - self.request.user = self.user - CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) - self.minimal_data = { - "course_id": str(self.course.id), - "topic_id": "test_topic", - "type": "discussion", - "title": "Test Title", - "raw_body": "Test body", - } - - @mock.patch("eventtracking.tracker.emit") - def test_basic(self, mock_emit): - cs_thread = make_minimal_cs_thread({ - "id": "test_id", - "username": self.user.username, - "read": True, - }) - self.register_post_thread_response(cs_thread) - with self.assert_signal_sent(api, 'thread_created', sender=None, user=self.user, exclude_args=('post',)): - actual = create_thread(self.request, self.minimal_data) - expected = self.expected_thread_data({ - "id": "test_id", - "course_id": str(self.course.id), - "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_id", - "read": True, - }) - assert actual == expected - assert parsed_body(httpretty.last_request()) == { - 'course_id': [str(self.course.id)], - 'commentable_id': ['test_topic'], - 'thread_type': ['discussion'], - 'title': ['Test Title'], - 'body': ['Test body'], - 'user_id': [str(self.user.id)], - 'anonymous': ['False'], - 'anonymous_to_peers': ['False'], - } - event_name, event_data = mock_emit.call_args[0] - assert event_name == 'edx.forum.thread.created' - assert event_data == { - 'commentable_id': 'test_topic', - 'group_id': None, - 'thread_type': 'discussion', - 'title': 'Test Title', - 'title_truncated': False, - 'anonymous': False, - 'anonymous_to_peers': False, - 'options': {'followed': False}, - 'id': 'test_id', - 'truncated': False, - 'body': 'Test body', - 'url': '', - 'user_forums_roles': [FORUM_ROLE_STUDENT], - 'user_course_roles': [], - 'from_mfe_sidebar': False, - } - - def test_basic_in_blackout_period(self): - """ - Test case when course is in blackout period and user does not have special privileges. - """ - _set_course_discussion_blackout(course=self.course, user_id=self.user.id) - - with self.assertRaises(DiscussionBlackOutException) as assertion: - create_thread(self.request, self.minimal_data) - self.assertEqual(assertion.exception.status_code, 403) - self.assertEqual(assertion.exception.detail, "Discussions are in blackout period.") - - @mock.patch("eventtracking.tracker.emit") - def test_basic_in_blackout_period_with_user_access(self, mock_emit): - """ - Test case when course is in blackout period and user has special privileges. - """ - cs_thread = make_minimal_cs_thread({ - "id": "test_id", - "username": self.user.username, - "read": True, - }) - self.register_post_thread_response(cs_thread) - - _set_course_discussion_blackout(course=self.course, user_id=self.user.id) - - _assign_role_to_user(user=self.user, course_id=self.course.id, role=FORUM_ROLE_MODERATOR) - - with self.assert_signal_sent(api, 'thread_created', sender=None, user=self.user, exclude_args=('post',)): - actual = create_thread(self.request, self.minimal_data) - expected = self.expected_thread_data({ - "author_label": "Moderator", - "id": "test_id", - "course_id": str(self.course.id), - "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_id", - "read": True, - "editable_fields": [ - "abuse_flagged", - "anonymous", - "close_reason_code", - "closed", - "copy_link", - "following", - "pinned", - "raw_body", - "read", - "title", - "topic_id", - "type", - "voted", - ], - }) - assert actual == expected - self.assertEqual( - parsed_body(httpretty.last_request()), - { - "course_id": [str(self.course.id)], - "commentable_id": ["test_topic"], - "thread_type": ["discussion"], - "title": ["Test Title"], - "body": ["Test body"], - "user_id": [str(self.user.id)], - "anonymous": ["False"], - "anonymous_to_peers": ["False"], - } - ) - event_name, event_data = mock_emit.call_args[0] - self.assertEqual(event_name, "edx.forum.thread.created") - self.assertEqual( - event_data, - { - "commentable_id": "test_topic", - "group_id": None, - "thread_type": "discussion", - "title": "Test Title", - "title_truncated": False, - "anonymous": False, - "anonymous_to_peers": False, - "options": {"followed": False}, - "id": "test_id", - "truncated": False, - "body": "Test body", - "url": "", - "user_forums_roles": [FORUM_ROLE_STUDENT, FORUM_ROLE_MODERATOR], - "user_course_roles": [], - "from_mfe_sidebar": False, - } - ) - - @mock.patch("eventtracking.tracker.emit") - def test_title_truncation(self, mock_emit): - data = self.minimal_data.copy() - data['title'] = self.LONG_TITLE - - cs_thread = make_minimal_cs_thread({ - "id": "test_id", - "username": self.user.username, - "read": True, - }) - self.register_post_thread_response(cs_thread) - with self.assert_signal_sent(api, 'thread_created', sender=None, user=self.user, exclude_args=('post',)): - create_thread(self.request, data) - event_name, event_data = mock_emit.call_args[0] - assert event_name == 'edx.forum.thread.created' - assert event_data == { - 'commentable_id': 'test_topic', - 'group_id': None, - 'thread_type': 'discussion', - 'title': self.LONG_TITLE[:1000], - 'title_truncated': True, - 'anonymous': False, - 'anonymous_to_peers': False, - 'options': {'followed': False}, - 'id': 'test_id', - 'truncated': False, - 'body': 'Test body', - 'url': '', - 'user_forums_roles': [FORUM_ROLE_STUDENT], - 'user_course_roles': [], - 'from_mfe_sidebar': False, - } - - @ddt.data( - *itertools.product( - [ - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - FORUM_ROLE_STUDENT, - ], - [True, False], - [True, False], - ["no_group_set", "group_is_none", "group_is_set"], - ) - ) - @ddt.unpack - def test_group_id(self, role_name, course_is_cohorted, topic_is_cohorted, data_group_state): - """ - Tests whether the user has permission to create a thread with certain - group_id values. - - If there is no group, user cannot create a thread. - Else if group is None or set, and the course is not cohorted and/or the - role is a student, user can create a thread. - """ - - cohort_course = CourseFactory.create( - discussion_topics={"Test Topic": {"id": "test_topic"}}, - cohort_config={ - "cohorted": course_is_cohorted, - "cohorted_discussions": ["test_topic"] if topic_is_cohorted else [], - } - ) - CourseEnrollmentFactory.create(user=self.user, course_id=cohort_course.id) - if course_is_cohorted: - cohort = CohortFactory.create(course_id=cohort_course.id, users=[self.user]) - _assign_role_to_user(user=self.user, course_id=cohort_course.id, role=role_name) - self.register_post_thread_response({"username": self.user.username}) - data = self.minimal_data.copy() - data["course_id"] = str(cohort_course.id) - if data_group_state == "group_is_none": - data["group_id"] = None - elif data_group_state == "group_is_set": - if course_is_cohorted: - data["group_id"] = cohort.id + 1 - else: - data["group_id"] = 1 # Set to any value since there is no cohort - expected_error = ( - data_group_state in ["group_is_none", "group_is_set"] and - (not course_is_cohorted or role_name == FORUM_ROLE_STUDENT) - ) - try: - create_thread(self.request, data) - assert not expected_error - actual_post_data = parsed_body(httpretty.last_request()) - if data_group_state == "group_is_set": - assert actual_post_data['group_id'] == [str(data['group_id'])] - elif data_group_state == "no_group_set" and course_is_cohorted and topic_is_cohorted: - assert actual_post_data['group_id'] == [str(cohort.id)] - else: - assert 'group_id' not in actual_post_data - except ValidationError as ex: - if not expected_error: - self.fail(f"Unexpected validation error: {ex}") - - def test_following(self): - self.register_post_thread_response({"id": "test_id", "username": self.user.username}) - self.register_subscription_response(self.user) - data = self.minimal_data.copy() - data["following"] = "True" - result = create_thread(self.request, data) - assert result['following'] is True - cs_request = httpretty.last_request() - assert urlparse(cs_request.path).path == f"/api/v1/users/{self.user.id}/subscriptions" # lint-amnesty, pylint: disable=no-member - assert cs_request.method == 'POST' - assert parsed_body(cs_request) == {'source_type': ['thread'], 'source_id': ['test_id']} - - def test_abuse_flagged(self): - self.register_post_thread_response({"id": "test_id", "username": self.user.username}) - self.register_thread_flag_response("test_id") - data = self.minimal_data.copy() - data["abuse_flagged"] = "True" - result = create_thread(self.request, data) - assert result['abuse_flagged'] is True - cs_request = httpretty.last_request() - assert urlparse(cs_request.path).path == '/api/v1/threads/test_id/abuse_flag' # lint-amnesty, pylint: disable=no-member - assert cs_request.method == 'PUT' - assert parsed_body(cs_request) == {'user_id': [str(self.user.id)]} - - def test_course_id_missing(self): - with pytest.raises(ValidationError) as assertion: - create_thread(self.request, {}) - assert assertion.value.message_dict == {'course_id': ['This field is required.']} - - def test_course_id_invalid(self): - with pytest.raises(ValidationError) as assertion: - create_thread(self.request, {"course_id": "invalid!"}) - assert assertion.value.message_dict == {'course_id': ['Invalid value.']} - - def test_nonexistent_course(self): - with pytest.raises(CourseNotFoundError): - create_thread(self.request, {"course_id": "course-v1:non+existent+course"}) - - def test_not_enrolled(self): - self.request.user = UserFactory.create() - with pytest.raises(CourseNotFoundError): - create_thread(self.request, self.minimal_data) - - def test_discussions_disabled(self): - disabled_course = _discussion_disabled_course_for(self.user) - self.minimal_data["course_id"] = str(disabled_course.id) - with pytest.raises(DiscussionDisabledError): - create_thread(self.request, self.minimal_data) - - def test_invalid_field(self): - data = self.minimal_data.copy() - data["type"] = "invalid_type" - with pytest.raises(ValidationError): - create_thread(self.request, data) - - -@ddt.ddt -@disable_signal(api, 'comment_created') -@disable_signal(api, 'comment_voted') -@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -@mock.patch("lms.djangoapps.discussion.signals.handlers.send_response_notifications", new=mock.Mock()) -class CreateCommentTest( - ForumsEnableMixin, - CommentsServiceMockMixin, - UrlResetMixin, - SharedModuleStoreTestCase, - MockSignalHandlerMixin -): - """Tests for create_comment""" - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.course = CourseFactory.create() - - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - super().setUp() - httpretty.reset() - httpretty.enable() - self.course = CourseFactory.create() - 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("/test_path") - self.request.user = self.user - CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) - self.register_get_thread_response( - make_minimal_cs_thread({ - "id": "test_thread", - "course_id": str(self.course.id), - "commentable_id": "test_topic", - }) - ) - self.minimal_data = { - "thread_id": "test_thread", - "raw_body": "Test body", - } - - mock_response = { - 'collection': [], - 'page': 1, - 'num_pages': 1, - 'subscriptions_count': 1, - 'corrected_text': None - - } - self.register_get_subscriptions('cohort_thread', mock_response) - self.register_get_subscriptions('test_thread', mock_response) - - @ddt.data(None, "test_parent") - @mock.patch("eventtracking.tracker.emit") - def test_success(self, parent_id, mock_emit): - if parent_id: - self.register_get_comment_response({"id": parent_id, "thread_id": "test_thread"}) - self.register_post_comment_response( - { - "id": "test_comment", - "username": self.user.username, - "created_at": "2015-05-27T00:00:00Z", - "updated_at": "2015-05-27T00:00:00Z", - }, - thread_id="test_thread", - parent_id=parent_id - ) - data = self.minimal_data.copy() - if parent_id: - data["parent_id"] = parent_id - with self.assert_signal_sent(api, 'comment_created', sender=None, user=self.user, exclude_args=('post',)): - actual = create_comment(self.request, data) - expected = { - "id": "test_comment", - "thread_id": "test_thread", - "parent_id": parent_id, - "author": self.user.username, - "author_label": None, - "created_at": "2015-05-27T00:00:00Z", - "updated_at": "2015-05-27T00:00:00Z", - "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": 0, - "children": [], - "editable_fields": ["abuse_flagged", "anonymous", "raw_body"], - "child_count": 0, - "can_delete": True, - "anonymous": False, - "anonymous_to_peers": False, - "last_edit": None, - "edit_by_label": None, - "profile_image": { - "has_image": False, - "image_url_full": "http://testserver/static/default_500.png", - "image_url_large": "http://testserver/static/default_120.png", - "image_url_medium": "http://testserver/static/default_50.png", - "image_url_small": "http://testserver/static/default_30.png", - }, - } - assert actual == expected - expected_url = ( - f"/api/v1/comments/{parent_id}" if parent_id else - "/api/v1/threads/test_thread/comments" - ) - assert urlparse(httpretty.last_request().path).path == expected_url # lint-amnesty, pylint: disable=no-member - - data = httpretty.latest_requests() - assert parsed_body(data[len(data) - 2]) == { - 'course_id': [str(self.course.id)], - 'body': ['Test body'], - 'user_id': [str(self.user.id)], - 'anonymous': ['False'], - 'anonymous_to_peers': ['False'], - } - - expected_event_name = ( - "edx.forum.comment.created" if parent_id else - "edx.forum.response.created" - ) - expected_event_data = { - "discussion": {"id": "test_thread"}, - "commentable_id": "test_topic", - "options": {"followed": False}, - "id": "test_comment", - "truncated": False, - "body": "Test body", - "url": "", - "user_forums_roles": [FORUM_ROLE_STUDENT], - "user_course_roles": [], - "from_mfe_sidebar": False, - } - if parent_id: - expected_event_data["response"] = {"id": parent_id} - actual_event_name, actual_event_data = mock_emit.call_args[0] - assert actual_event_name == expected_event_name - assert actual_event_data == expected_event_data - - @ddt.data(None, "test_parent") - @mock.patch("eventtracking.tracker.emit") - def test_success_in_black_out_with_user_access(self, parent_id, mock_emit): - """ - Test case when course is in blackout period and user has special privileges. - """ - if parent_id: - self.register_get_comment_response({"id": parent_id, "thread_id": "test_thread"}) - self.register_post_comment_response( - { - "id": "test_comment", - "username": self.user.username, - "created_at": "2015-05-27T00:00:00Z", - "updated_at": "2015-05-27T00:00:00Z", - }, - thread_id="test_thread", - parent_id=parent_id - ) - data = self.minimal_data.copy() - editable_fields = [ - "abuse_flagged", - "anonymous", - "raw_body", - "voted" - ] - if parent_id: - data["parent_id"] = parent_id - else: - editable_fields.insert(2, "endorsed") - - _set_course_discussion_blackout(course=self.course, user_id=self.user.id) - _assign_role_to_user(user=self.user, course_id=self.course.id, role=FORUM_ROLE_MODERATOR) - - with self.assert_signal_sent(api, 'comment_created', sender=None, user=self.user, exclude_args=('post',)): - actual = create_comment(self.request, data) - expected = { - "id": "test_comment", - "thread_id": "test_thread", - "parent_id": parent_id, - "author": self.user.username, - "author_label": "Moderator", - "created_at": "2015-05-27T00:00:00Z", - "updated_at": "2015-05-27T00:00:00Z", - "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": False, - "voted": False, - "vote_count": 0, - "children": [], - "editable_fields": editable_fields, - "child_count": 0, - "can_delete": True, - "anonymous": False, - "anonymous_to_peers": False, - "last_edit": None, - "edit_by_label": None, - "profile_image": { - "has_image": False, - "image_url_full": "http://testserver/static/default_500.png", - "image_url_large": "http://testserver/static/default_120.png", - "image_url_medium": "http://testserver/static/default_50.png", - "image_url_small": "http://testserver/static/default_30.png", - }, - } - assert actual == expected - expected_url = ( - f"/api/v1/comments/{parent_id}" if parent_id else - "/api/v1/threads/test_thread/comments" - ) - assert urlparse(httpretty.last_request().path).path == expected_url # pylint: disable=no-member - data = httpretty.latest_requests() - assert parsed_body(data[len(data) - 2]) == { - "course_id": [str(self.course.id)], - "body": ["Test body"], - "user_id": [str(self.user.id)], - "anonymous": ['False'], - "anonymous_to_peers": ['False'], - } - - expected_event_name = ( - "edx.forum.comment.created" if parent_id else - "edx.forum.response.created" - ) - expected_event_data = { - "discussion": {"id": "test_thread"}, - "commentable_id": "test_topic", - "options": {"followed": False}, - "id": "test_comment", - "truncated": False, - "body": "Test body", - "url": "", - "user_forums_roles": [FORUM_ROLE_STUDENT, FORUM_ROLE_MODERATOR], - "user_course_roles": [], - "from_mfe_sidebar": False, - } - if parent_id: - expected_event_data["response"] = {"id": parent_id} - actual_event_name, actual_event_data = mock_emit.call_args[0] - self.assertEqual(actual_event_name, expected_event_name) - self.assertEqual(actual_event_data, expected_event_data) - - def test_error_in_black_out(self): - """ - Test case when course is in blackout period and user does not have special privileges. - """ - _set_course_discussion_blackout(course=self.course, user_id=self.user.id) - - with self.assertRaises(DiscussionBlackOutException) as assertion: - create_comment(self.request, self.minimal_data) - self.assertEqual(assertion.exception.status_code, 403) - self.assertEqual(assertion.exception.detail, "Discussions are in blackout period.") - - @ddt.data( - *itertools.product( - [ - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - FORUM_ROLE_STUDENT, - ], - [True, False], - ["question", "discussion"], - ) - ) - @ddt.unpack - def test_endorsed(self, role_name, is_thread_author, thread_type): - _assign_role_to_user(user=self.user, course_id=self.course.id, role=role_name) - self.register_get_thread_response( - make_minimal_cs_thread({ - "id": "test_thread", - "course_id": str(self.course.id), - "thread_type": thread_type, - "user_id": str(self.user.id) if is_thread_author else str(self.user.id + 1), - }) - ) - self.register_post_comment_response({"username": self.user.username}, "test_thread") - data = self.minimal_data.copy() - data["endorsed"] = True - expected_error = ( - role_name == FORUM_ROLE_STUDENT and - (not is_thread_author or thread_type == "discussion") - ) - try: - create_comment(self.request, data) - assert parsed_body(httpretty.last_request())['endorsed'] == ['True'] - assert not expected_error - except ValidationError: - assert expected_error - - def test_abuse_flagged(self): - self.register_post_comment_response({"id": "test_comment", "username": self.user.username}, "test_thread") - self.register_comment_flag_response("test_comment") - data = self.minimal_data.copy() - data["abuse_flagged"] = "True" - result = create_comment(self.request, data) - assert result['abuse_flagged'] is True - cs_request = httpretty.last_request() - assert urlparse(cs_request.path).path == '/api/v1/comments/test_comment/abuse_flag' # lint-amnesty, pylint: disable=no-member - assert cs_request.method == 'PUT' - assert parsed_body(cs_request) == {'user_id': [str(self.user.id)]} - - def test_thread_id_missing(self): - with pytest.raises(ValidationError) as assertion: - create_comment(self.request, {}) - assert assertion.value.message_dict == {'thread_id': ['This field is required.']} - - def test_thread_id_not_found(self): - self.register_get_thread_error_response("test_thread", 404) - with pytest.raises(ThreadNotFoundError): - create_comment(self.request, self.minimal_data) - - def test_nonexistent_course(self): - self.register_get_thread_response( - make_minimal_cs_thread({"id": "test_thread", "course_id": "course-v1:non+existent+course"}) - ) - with pytest.raises(CourseNotFoundError): - create_comment(self.request, self.minimal_data) - - def test_not_enrolled(self): - self.request.user = UserFactory.create() - with pytest.raises(CourseNotFoundError): - create_comment(self.request, self.minimal_data) - - def test_discussions_disabled(self): - disabled_course = _discussion_disabled_course_for(self.user) - self.register_get_thread_response( - make_minimal_cs_thread({ - "id": "test_thread", - "course_id": str(disabled_course.id), - "commentable_id": "test_topic", - }) - ) - with pytest.raises(DiscussionDisabledError): - create_comment(self.request, self.minimal_data) - - @ddt.data( - *itertools.product( - [ - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - FORUM_ROLE_STUDENT, - ], - [True, False], - ["no_group", "match_group", "different_group"], - ) - ) - @ddt.unpack - def test_group_access(self, role_name, course_is_cohorted, thread_group_state): - cohort_course, cohort = _create_course_and_cohort_with_user_role(course_is_cohorted, self.user, role_name) - self.register_get_thread_response(make_minimal_cs_thread({ - "id": "cohort_thread", - "course_id": str(cohort_course.id), - "group_id": ( - None if thread_group_state == "no_group" else - cohort.id if thread_group_state == "match_group" else - cohort.id + 1 - ), - })) - self.register_post_comment_response({"username": self.user.username}, thread_id="cohort_thread") - data = self.minimal_data.copy() - data["thread_id"] = "cohort_thread" - expected_error = ( - role_name == FORUM_ROLE_STUDENT and - course_is_cohorted and - thread_group_state == "different_group" - ) - try: - create_comment(self.request, data) - assert not expected_error - except ThreadNotFoundError: - assert expected_error - - def test_invalid_field(self): - data = self.minimal_data.copy() - del data["raw_body"] - with pytest.raises(ValidationError): - create_comment(self.request, data) - - -@ddt.ddt -@disable_signal(api, 'thread_edited') -@disable_signal(api, 'thread_voted') -@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class UpdateThreadTest( - ForumsEnableMixin, - CommentsServiceMockMixin, - UrlResetMixin, - SharedModuleStoreTestCase, - MockSignalHandlerMixin -): - """Tests for update_thread""" - - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.course = CourseFactory.create() - - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - super().setUp() - - httpretty.reset() - httpretty.enable() - self.addCleanup(httpretty.reset) - self.addCleanup(httpretty.disable) - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" - ) - self.mock_get_course_id_by_thread = patcher.start() - self.addCleanup(patcher.stop) - - self.user = UserFactory.create() - self.register_get_user_response(self.user) - self.request = RequestFactory().get("/test_path") - self.request.user = self.user - CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) - - def register_thread(self, overrides=None): - """ - Make a thread with appropriate data overridden by the overrides - parameter and register mock responses for both GET and PUT on its - endpoint. - """ - cs_data = make_minimal_cs_thread({ - "id": "test_thread", - "course_id": str(self.course.id), - "commentable_id": "original_topic", - "username": self.user.username, - "user_id": str(self.user.id), - "thread_type": "discussion", - "title": "Original Title", - "body": "Original body", - }) - cs_data.update(overrides or {}) - self.register_get_thread_response(cs_data) - self.register_put_thread_response(cs_data) - - def create_user_with_request(self): - """ - Create a user and an associated request for a specific course enrollment. - """ - user = UserFactory.create() - self.register_get_user_response(user) - request = RequestFactory().get("/test_path") - request.user = user - CourseEnrollmentFactory.create(user=user, course_id=self.course.id) - return user, request - - def test_empty(self): - """Check that an empty update does not make any modifying requests.""" - # Ensure that the default following value of False is not applied implicitly - self.register_get_user_response(self.user, subscribed_thread_ids=["test_thread"]) - self.register_thread() - update_thread(self.request, "test_thread", {}) - for request in httpretty.httpretty.latest_requests: - assert request.method == 'GET' - - def test_basic(self): - self.register_thread() - with self.assert_signal_sent(api, 'thread_edited', sender=None, user=self.user, exclude_args=('post',)): - actual = update_thread(self.request, "test_thread", {"raw_body": "Edited body"}) - - assert actual == self.expected_thread_data({ - 'raw_body': 'Edited body', - 'rendered_body': '

Edited body

', - 'preview_body': 'Edited body', - 'topic_id': 'original_topic', - 'read': True, - 'title': 'Original Title', - }) - assert parsed_body(httpretty.last_request()) == { - 'course_id': [str(self.course.id)], - 'commentable_id': ['original_topic'], - 'thread_type': ['discussion'], - 'title': ['Original Title'], - 'body': ['Edited body'], - 'user_id': [str(self.user.id)], - 'anonymous': ['False'], - 'anonymous_to_peers': ['False'], - 'closed': ['False'], - 'pinned': ['False'], - 'read': ['False'], - 'editing_user_id': [str(self.user.id)], - } - - def test_nonexistent_thread(self): - self.register_get_thread_error_response("test_thread", 404) - with pytest.raises(ThreadNotFoundError): - update_thread(self.request, "test_thread", {}) - - def test_nonexistent_course(self): - self.register_thread({"course_id": "course-v1:non+existent+course"}) - with pytest.raises(CourseNotFoundError): - update_thread(self.request, "test_thread", {}) - - def test_not_enrolled(self): - self.register_thread() - self.request.user = UserFactory.create() - with pytest.raises(CourseNotFoundError): - update_thread(self.request, "test_thread", {}) - - def test_discussions_disabled(self): - disabled_course = _discussion_disabled_course_for(self.user) - self.register_thread(overrides={"course_id": str(disabled_course.id)}) - with pytest.raises(DiscussionDisabledError): - update_thread(self.request, "test_thread", {}) - - @ddt.data( - *itertools.product( - [ - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - FORUM_ROLE_STUDENT, - ], - [True, False], - ["no_group", "match_group", "different_group"], - ) - ) - @ddt.unpack - def test_group_access(self, role_name, course_is_cohorted, thread_group_state): - cohort_course, cohort = _create_course_and_cohort_with_user_role(course_is_cohorted, self.user, role_name) - self.register_thread({ - "course_id": str(cohort_course.id), - "group_id": ( - None if thread_group_state == "no_group" else - cohort.id if thread_group_state == "match_group" else - cohort.id + 1 - ), - }) - expected_error = ( - role_name == FORUM_ROLE_STUDENT and - course_is_cohorted and - thread_group_state == "different_group" - ) - try: - update_thread(self.request, "test_thread", {}) - assert not expected_error - except ThreadNotFoundError: - assert expected_error - - @ddt.data( - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - FORUM_ROLE_STUDENT, - ) - def test_author_only_fields(self, role_name): - _assign_role_to_user(user=self.user, course_id=self.course.id, role=role_name) - self.register_thread({"user_id": str(self.user.id + 1)}) - data = {field: "edited" for field in ["topic_id", "title", "raw_body"]} - data["type"] = "question" - expected_error = role_name == FORUM_ROLE_STUDENT - try: - update_thread(self.request, "test_thread", data) - assert not expected_error - except ValidationError as err: - assert expected_error - assert err.message_dict == {field: ['This field is not editable.'] for field in data.keys()} - - @ddt.data(*itertools.product([True, False], [True, False])) - @ddt.unpack - @mock.patch("eventtracking.tracker.emit") - def test_following(self, old_following, new_following, mock_emit): - """ - Test attempts to edit the "following" field. - - old_following indicates whether the thread should be followed at the - start of the test. new_following indicates the value for the "following" - field in the update. If old_following and new_following are the same, no - update should be made. Otherwise, a subscription should be POSTed or - DELETEd according to the new_following value. - """ - if old_following: - self.register_get_user_response(self.user, subscribed_thread_ids=["test_thread"]) - self.register_subscription_response(self.user) - self.register_thread() - data = {"following": new_following} - signal_name = "thread_followed" if new_following else "thread_unfollowed" - mock_path = f"openedx.core.djangoapps.django_comment_common.signals.{signal_name}.send" - with mock.patch(mock_path) as signal_patch: - result = update_thread(self.request, "test_thread", data) - if old_following != new_following: - self.assertEqual(signal_patch.call_count, 1) - assert result['following'] == new_following - last_request_path = urlparse(httpretty.last_request().path).path # lint-amnesty, pylint: disable=no-member - subscription_url = f"/api/v1/users/{self.user.id}/subscriptions" - if old_following == new_following: - assert last_request_path != subscription_url - else: - assert last_request_path == subscription_url - assert httpretty.last_request().method == ('POST' if new_following else 'DELETE') - request_data = ( - parsed_body(httpretty.last_request()) if new_following else - parse_qs(urlparse(httpretty.last_request().path).query) # lint-amnesty, pylint: disable=no-member - ) - request_data.pop("request_id", None) - assert request_data == {'source_type': ['thread'], 'source_id': ['test_thread']} - event_name, event_data = mock_emit.call_args[0] - expected_event_action = 'followed' if new_following else 'unfollowed' - assert event_name == f'edx.forum.thread.{expected_event_action}' - assert event_data['commentable_id'] == 'original_topic' - assert event_data['id'] == 'test_thread' - assert event_data['followed'] == new_following - assert event_data['user_forums_roles'] == ['Student'] - - @ddt.data(*itertools.product([True, False], [True, False])) - @ddt.unpack - @mock.patch("eventtracking.tracker.emit") - def test_voted(self, current_vote_status, new_vote_status, mock_emit): - """ - Test attempts to edit the "voted" field. - - current_vote_status indicates whether the thread should be upvoted at - the start of the test. new_vote_status indicates the value for the - "voted" field in the update. If current_vote_status and new_vote_status - are the same, no update should be made. Otherwise, a vote should be PUT - or DELETEd according to the new_vote_status value. - """ - #setup - user1, request1 = self.create_user_with_request() - - if current_vote_status: - self.register_get_user_response(user1, upvoted_ids=["test_thread"]) - self.register_thread_votes_response("test_thread") - self.register_thread() - data = {"voted": new_vote_status} - result = update_thread(request1, "test_thread", data) - assert result['voted'] == new_vote_status - last_request_path = urlparse(httpretty.last_request().path).path # lint-amnesty, pylint: disable=no-member - votes_url = "/api/v1/threads/test_thread/votes" - if current_vote_status == new_vote_status: - assert last_request_path != votes_url - else: - assert last_request_path == votes_url - assert httpretty.last_request().method == ('PUT' if new_vote_status else 'DELETE') - actual_request_data = ( - parsed_body(httpretty.last_request()) if new_vote_status else - parse_qs(urlparse(httpretty.last_request().path).query) # lint-amnesty, pylint: disable=no-member - ) - actual_request_data.pop("request_id", None) - expected_request_data = {"user_id": [str(user1.id)]} - if new_vote_status: - expected_request_data["value"] = ["up"] - assert actual_request_data == expected_request_data - - event_name, event_data = mock_emit.call_args[0] - assert event_name == 'edx.forum.thread.voted' - assert event_data == { - 'undo_vote': (not new_vote_status), - 'url': '', - 'target_username': self.user.username, - 'vote_value': 'up', - 'user_forums_roles': [FORUM_ROLE_STUDENT], - 'user_course_roles': [], - 'commentable_id': 'original_topic', - 'id': 'test_thread' - } - - @ddt.data(*itertools.product([True, False], [True, False], [True, False])) - @ddt.unpack - def test_vote_count(self, current_vote_status, first_vote, second_vote): - """ - Tests vote_count increases and decreases correctly from the same user - """ - #setup - starting_vote_count = 0 - user, request = self.create_user_with_request() - if current_vote_status: - self.register_get_user_response(user, upvoted_ids=["test_thread"]) - starting_vote_count = 1 - self.register_thread_votes_response("test_thread") - self.register_thread(overrides={"votes": {"up_count": starting_vote_count}}) - - #first vote - data = {"voted": first_vote} - result = update_thread(request, "test_thread", data) - self.register_thread(overrides={"voted": first_vote}) - assert result['vote_count'] == (1 if first_vote else 0) - - #second vote - data = {"voted": second_vote} - result = update_thread(request, "test_thread", data) - assert result['vote_count'] == (1 if second_vote else 0) - - @ddt.data(*itertools.product([True, False], [True, False], [True, False], [True, False])) - @ddt.unpack - def test_vote_count_two_users( - self, - current_user1_vote, - current_user2_vote, - user1_vote, - user2_vote - ): - """ - Tests vote_count increases and decreases correctly from different users - """ - #setup - user1, request1 = self.create_user_with_request() - user2, request2 = self.create_user_with_request() - - vote_count = 0 - if current_user1_vote: - self.register_get_user_response(user1, upvoted_ids=["test_thread"]) - vote_count += 1 - if current_user2_vote: - self.register_get_user_response(user2, upvoted_ids=["test_thread"]) - vote_count += 1 - - for (current_vote, user_vote, request) in \ - [(current_user1_vote, user1_vote, request1), - (current_user2_vote, user2_vote, request2)]: - - self.register_thread_votes_response("test_thread") - self.register_thread(overrides={"votes": {"up_count": vote_count}}) - - data = {"voted": user_vote} - result = update_thread(request, "test_thread", data) - if current_vote == user_vote: - assert result['vote_count'] == vote_count - elif user_vote: - vote_count += 1 - assert result['vote_count'] == vote_count - self.register_get_user_response(self.user, upvoted_ids=["test_thread"]) - else: - vote_count -= 1 - assert result['vote_count'] == vote_count - self.register_get_user_response(self.user, upvoted_ids=[]) - - @ddt.data(*itertools.product([True, False], [True, False])) - @ddt.unpack - @mock.patch("eventtracking.tracker.emit") - def test_abuse_flagged(self, old_flagged, new_flagged, mock_emit): - """ - Test attempts to edit the "abuse_flagged" field. - - old_flagged indicates whether the thread should be flagged at the start - of the test. new_flagged indicates the value for the "abuse_flagged" - field in the update. If old_flagged and new_flagged are the same, no - update should be made. Otherwise, a PUT should be made to the flag or - or unflag endpoint according to the new_flagged value. - """ - self.register_get_user_response(self.user) - self.register_thread_flag_response("test_thread") - self.register_thread({"abuse_flaggers": [str(self.user.id)] if old_flagged else []}) - data = {"abuse_flagged": new_flagged} - result = update_thread(self.request, "test_thread", data) - assert result['abuse_flagged'] == new_flagged - last_request_path = urlparse(httpretty.last_request().path).path # lint-amnesty, pylint: disable=no-member - flag_url = "/api/v1/threads/test_thread/abuse_flag" - unflag_url = "/api/v1/threads/test_thread/abuse_unflag" - if old_flagged == new_flagged: - assert last_request_path != flag_url - assert last_request_path != unflag_url - else: - assert last_request_path == (flag_url if new_flagged else unflag_url) - assert httpretty.last_request().method == 'PUT' - assert parsed_body(httpretty.last_request()) == {'user_id': [str(self.user.id)]} - - expected_event_name = 'edx.forum.thread.reported' if new_flagged else 'edx.forum.thread.unreported' - expected_event_data = { - 'body': 'Original body', - 'id': 'test_thread', - 'content_type': 'Post', - 'commentable_id': 'original_topic', - 'url': '', - 'user_course_roles': [], - 'user_forums_roles': [FORUM_ROLE_STUDENT], - 'target_username': self.user.username, - 'title_truncated': False, - 'title': 'Original Title', - 'thread_type': 'discussion', - 'group_id': None, - 'truncated': False, - } - if not new_flagged: - expected_event_data['reported_status_cleared'] = False - - actual_event_name, actual_event_data = mock_emit.call_args[0] - self.assertEqual(actual_event_name, expected_event_name) - self.assertEqual(actual_event_data, expected_event_data) - - @ddt.data( - (False, True), - (True, True), - ) - @ddt.unpack - @mock.patch("eventtracking.tracker.emit") - def test_thread_un_abuse_flag_for_moderator_role(self, is_author, remove_all, mock_emit): - """ - Test un-abuse flag for moderator role. - - When moderator unflags a reported thread, it should - pass the "all" flag to the api. This will indicate - to the api to clear all abuse_flaggers, and mark the - thread as unreported. - """ - _assign_role_to_user(user=self.user, course_id=self.course.id, role=FORUM_ROLE_ADMINISTRATOR) - self.register_get_user_response(self.user) - self.register_thread_flag_response("test_thread") - self.register_thread({"abuse_flaggers": ["11"], "user_id": str(self.user.id) if is_author else "12"}) - data = {"abuse_flagged": False} - update_thread(self.request, "test_thread", data) - assert httpretty.last_request().method == 'PUT' - query_params = {'user_id': [str(self.user.id)]} - if remove_all: - query_params.update({'all': ['True']}) - assert parsed_body(httpretty.last_request()) == query_params - - expected_event_name = 'edx.forum.thread.unreported' - expected_event_data = { - 'body': 'Original body', - 'id': 'test_thread', - 'content_type': 'Post', - 'commentable_id': 'original_topic', - 'url': '', - 'user_course_roles': [], - 'user_forums_roles': [FORUM_ROLE_STUDENT, FORUM_ROLE_ADMINISTRATOR], - 'target_username': self.user.username, - 'title_truncated': False, - 'title': 'Original Title', - 'reported_status_cleared': False, - 'thread_type': 'discussion', - 'group_id': None, - 'truncated': False, - } - - actual_event_name, actual_event_data = mock_emit.call_args[0] - self.assertEqual(actual_event_name, expected_event_name) - self.assertEqual(actual_event_data, expected_event_data) - - def test_invalid_field(self): - self.register_thread() - with pytest.raises(ValidationError) as assertion: - update_thread(self.request, "test_thread", {"raw_body": ""}) - assert assertion.value.message_dict == {'raw_body': ['This field may not be blank.']} - - @ddt.data( - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - FORUM_ROLE_STUDENT, - ) - @mock.patch("lms.djangoapps.discussion.rest_api.serializers.EDIT_REASON_CODES", { - "test-edit-reason": "Test Edit Reason", - }) - @mock.patch("eventtracking.tracker.emit") - def test_update_thread_with_edit_reason_code(self, role_name, mock_emit): - """ - Test editing comments, specifying and retrieving edit reason codes. - """ - _assign_role_to_user(user=self.user, course_id=self.course.id, role=role_name) - self.register_thread({"user_id": str(self.user.id + 1)}) - try: - result = update_thread(self.request, "test_thread", { - "raw_body": "Edited body", - "edit_reason_code": "test-edit-reason", - }) - assert role_name != FORUM_ROLE_STUDENT - assert result["last_edit"] == { - "original_body": "Original body", - "reason": "Test Edit Reason", - "reason_code": "test-edit-reason", - "author": self.user.username, - } - request_body = httpretty.last_request().parsed_body # pylint: disable=no-member - assert request_body["edit_reason_code"] == ["test-edit-reason"] - - expected_event_name = 'edx.forum.thread.edited' - expected_event_data = { - 'id': 'test_thread', - 'content_type': 'Post', - 'own_content': False, - 'url': '', - 'user_course_roles': [], - 'user_forums_roles': ['Student', role_name], - 'target_username': self.user.username, - 'edit_reason': 'test-edit-reason', - 'commentable_id': 'original_topic' - } - - actual_event_name, actual_event_data = mock_emit.call_args[0] - self.assertEqual(actual_event_name, expected_event_name) - self.assertEqual(actual_event_data, expected_event_data) - - except ValidationError as error: - assert role_name == FORUM_ROLE_STUDENT - assert error.message_dict == {"edit_reason_code": ["This field is not editable."], - "raw_body": ["This field is not editable."]} - - @ddt.data( - *itertools.product( - [ - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - FORUM_ROLE_STUDENT, - ], - [True, False] - ) - ) - @ddt.unpack - @mock.patch("lms.djangoapps.discussion.rest_api.serializers.CLOSE_REASON_CODES", { - "test-close-reason": "Test Close Reason", - }) - @mock.patch("eventtracking.tracker.emit") - def test_update_thread_with_close_reason_code(self, role_name, closed, mock_emit): - """ - Test editing comments, specifying and retrieving edit reason codes. - """ - _assign_role_to_user(user=self.user, course_id=self.course.id, role=role_name) - self.register_thread() - try: - self.request.META['HTTP_REFERER'] = 'https://example.com' - result = update_thread(self.request, "test_thread", { - "closed": closed, - "close_reason_code": "test-close-reason", - }) - - assert role_name != FORUM_ROLE_STUDENT - assert result["closed"] == closed - request_body = httpretty.last_request().parsed_body # pylint: disable=no-member - assert request_body["close_reason_code"] == ["test-close-reason"] - assert request_body["closing_user_id"] == [str(self.user.id)] - - expected_event_name = f'edx.forum.thread.{"locked" if closed else "unlocked"}' - expected_event_data = { - 'id': 'test_thread', - 'team_id': None, - 'url': self.request.META['HTTP_REFERER'], - 'user_course_roles': [], - 'user_forums_roles': ['Student', role_name], - 'target_username': self.user.username, - 'lock_reason': 'test-close-reason', - 'commentable_id': 'original_topic' - } - - actual_event_name, actual_event_data = mock_emit.call_args[0] - self.assertEqual(actual_event_name, expected_event_name) - self.assertEqual(actual_event_data, expected_event_data) - except ValidationError as error: - assert role_name == FORUM_ROLE_STUDENT - assert error.message_dict == { - "closed": ["This field is not editable."], - "close_reason_code": ["This field is not editable."], - } - - -@ddt.ddt -@disable_signal(api, 'comment_edited') -@disable_signal(api, 'comment_voted') -@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class UpdateCommentTest( - ForumsEnableMixin, - CommentsServiceMockMixin, - UrlResetMixin, - SharedModuleStoreTestCase, - MockSignalHandlerMixin -): - """Tests for update_comment""" - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.course = CourseFactory.create() - - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - super().setUp() - - httpretty.reset() - httpretty.enable() - self.addCleanup(httpretty.reset) - self.addCleanup(httpretty.disable) - - 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("/test_path") - self.request.user = self.user - CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) - - def register_comment(self, overrides=None, thread_overrides=None, course=None): - """ - Make a comment with appropriate data overridden by the overrides - parameter and register mock responses for both GET and PUT on its - endpoint. Also mock GET for the related thread with thread_overrides. - """ - if course is None: - course = self.course - - cs_thread_data = make_minimal_cs_thread({ - "id": "test_thread", - "course_id": str(course.id) - }) - cs_thread_data.update(thread_overrides or {}) - self.register_get_thread_response(cs_thread_data) - cs_comment_data = make_minimal_cs_comment({ - "id": "test_comment", - "course_id": cs_thread_data["course_id"], - "thread_id": cs_thread_data["id"], - "username": self.user.username, - "user_id": str(self.user.id), - "created_at": "2015-06-03T00:00:00Z", - "updated_at": "2015-06-03T00:00:00Z", - "body": "Original body", - }) - cs_comment_data.update(overrides or {}) - self.register_get_comment_response(cs_comment_data) - self.register_put_comment_response(cs_comment_data) - - def create_user_with_request(self): - """ - Create a user and an associated request for a specific course enrollment. - """ - user = UserFactory.create() - self.register_get_user_response(user) - request = RequestFactory().get("/test_path") - request.user = user - CourseEnrollmentFactory.create(user=user, course_id=self.course.id) - return user, request - - def test_empty(self): - """Check that an empty update does not make any modifying requests.""" - self.register_comment() - update_comment(self.request, "test_comment", {}) - for request in httpretty.httpretty.latest_requests: - assert request.method == 'GET' - - @ddt.data(None, "test_parent") - def test_basic(self, parent_id): - self.register_comment({"parent_id": parent_id}) - with self.assert_signal_sent(api, 'comment_edited', sender=None, user=self.user, exclude_args=('post',)): - actual = update_comment(self.request, "test_comment", {"raw_body": "Edited body"}) - expected = { - "anonymous": False, - "anonymous_to_peers": False, - "id": "test_comment", - "thread_id": "test_thread", - "parent_id": parent_id, - "author": self.user.username, - "author_label": None, - "created_at": "2015-06-03T00:00:00Z", - "updated_at": "2015-06-03T00:00:00Z", - "raw_body": "Edited body", - "rendered_body": "

Edited body

", - "endorsed": False, - "endorsed_by": None, - "endorsed_by_label": None, - "endorsed_at": None, - "abuse_flagged": False, - "abuse_flagged_any_user": None, - "voted": False, - "vote_count": 0, - "children": [], - "editable_fields": ["abuse_flagged", "anonymous", "raw_body"], - "child_count": 0, - "can_delete": True, - "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 actual == expected - assert parsed_body(httpretty.last_request()) == { - 'body': ['Edited body'], - 'course_id': [str(self.course.id)], - 'user_id': [str(self.user.id)], - 'anonymous': ['False'], - 'anonymous_to_peers': ['False'], - 'endorsed': ['False'], - 'editing_user_id': [str(self.user.id)], - } - - def test_nonexistent_comment(self): - self.register_get_comment_error_response("test_comment", 404) - with pytest.raises(CommentNotFoundError): - update_comment(self.request, "test_comment", {}) - - def test_nonexistent_course(self): - self.register_comment(thread_overrides={"course_id": "course-v1:non+existent+course"}) - with pytest.raises(CourseNotFoundError): - update_comment(self.request, "test_comment", {}) - - def test_unenrolled(self): - self.register_comment() - self.request.user = UserFactory.create() - with pytest.raises(CourseNotFoundError): - update_comment(self.request, "test_comment", {}) - - def test_discussions_disabled(self): - self.register_comment(course=_discussion_disabled_course_for(self.user)) - with pytest.raises(DiscussionDisabledError): - update_comment(self.request, "test_comment", {}) - - @ddt.data( - *itertools.product( - [ - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - FORUM_ROLE_STUDENT, - ], - [True, False], - ["no_group", "match_group", "different_group"], - ) - ) - @ddt.unpack - def test_group_access(self, role_name, course_is_cohorted, thread_group_state): - cohort_course, cohort = _create_course_and_cohort_with_user_role(course_is_cohorted, self.user, role_name) - self.register_get_thread_response(make_minimal_cs_thread()) - self.register_comment( - {"thread_id": "test_thread"}, - thread_overrides={ - "id": "test_thread", - "course_id": str(cohort_course.id), - "group_id": ( - None if thread_group_state == "no_group" else - cohort.id if thread_group_state == "match_group" else - cohort.id + 1 - ), - } - ) - expected_error = ( - role_name == FORUM_ROLE_STUDENT and - course_is_cohorted and - thread_group_state == "different_group" - ) - try: - update_comment(self.request, "test_comment", {}) - assert not expected_error - except ThreadNotFoundError: - assert expected_error - - @ddt.data(*itertools.product( - [ - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - FORUM_ROLE_STUDENT, - ], - [True, False], - [True, False], - )) - @ddt.unpack - def test_raw_body_access(self, role_name, is_thread_author, is_comment_author): - _assign_role_to_user(user=self.user, course_id=self.course.id, role=role_name) - self.register_comment( - {"user_id": str(self.user.id if is_comment_author else (self.user.id + 1))}, - thread_overrides={ - "user_id": str(self.user.id if is_thread_author else (self.user.id + 1)) - } - ) - expected_error = role_name == FORUM_ROLE_STUDENT and not is_comment_author - try: - update_comment(self.request, "test_comment", {"raw_body": "edited"}) - assert not expected_error - except ValidationError as err: - assert expected_error - assert err.message_dict == {'raw_body': ['This field is not editable.']} - - @ddt.data(*itertools.product( - [ - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - FORUM_ROLE_STUDENT, - ], - [True, False], - ["question", "discussion"], - [True, False], - )) - @ddt.unpack - @mock.patch('openedx.core.djangoapps.django_comment_common.signals.comment_endorsed.send') - def test_endorsed_access(self, role_name, is_thread_author, thread_type, is_comment_author, endorsed_mock): - _assign_role_to_user(user=self.user, course_id=self.course.id, role=role_name) - self.register_comment( - {"user_id": str(self.user.id if is_comment_author else (self.user.id + 1))}, - thread_overrides={ - "thread_type": thread_type, - "user_id": str(self.user.id if is_thread_author else (self.user.id + 1)), - } - ) - expected_error = ( - role_name == FORUM_ROLE_STUDENT and - (thread_type == "discussion" or not is_thread_author) - ) - try: - update_comment(self.request, "test_comment", {"endorsed": True}) - self.assertEqual(endorsed_mock.call_count, 1) - assert not expected_error - except ValidationError as err: - assert expected_error - assert err.message_dict == {'endorsed': ['This field is not editable.']} - - @ddt.data(*itertools.product([True, False], [True, False])) - @ddt.unpack - @mock.patch("eventtracking.tracker.emit") - def test_voted(self, current_vote_status, new_vote_status, mock_emit): - """ - Test attempts to edit the "voted" field. - - current_vote_status indicates whether the comment should be upvoted at - the start of the test. new_vote_status indicates the value for the - "voted" field in the update. If current_vote_status and new_vote_status - are the same, no update should be made. Otherwise, a vote should be PUT - or DELETEd according to the new_vote_status value. - """ - vote_count = 0 - user1, request1 = self.create_user_with_request() - if current_vote_status: - self.register_get_user_response(user1, upvoted_ids=["test_comment"]) - vote_count = 1 - self.register_comment_votes_response("test_comment") - self.register_comment(overrides={"votes": {"up_count": vote_count}}) - data = {"voted": new_vote_status} - result = update_comment(request1, "test_comment", data) - assert result['vote_count'] == (1 if new_vote_status else 0) - assert result['voted'] == new_vote_status - last_request_path = urlparse(httpretty.last_request().path).path # lint-amnesty, pylint: disable=no-member - votes_url = "/api/v1/comments/test_comment/votes" - if current_vote_status == new_vote_status: - assert last_request_path != votes_url - else: - assert last_request_path == votes_url - assert httpretty.last_request().method == ('PUT' if new_vote_status else 'DELETE') - actual_request_data = ( - parsed_body(httpretty.last_request()) if new_vote_status else - parse_qs(urlparse(httpretty.last_request().path).query) # lint-amnesty, pylint: disable=no-member - ) - actual_request_data.pop("request_id", None) - expected_request_data = {"user_id": [str(user1.id)]} - if new_vote_status: - expected_request_data["value"] = ["up"] - assert actual_request_data == expected_request_data - - event_name, event_data = mock_emit.call_args[0] - assert event_name == 'edx.forum.response.voted' - - assert event_data == { - 'undo_vote': (not new_vote_status), - 'url': '', - 'target_username': self.user.username, - 'vote_value': 'up', - 'user_forums_roles': [FORUM_ROLE_STUDENT], - 'user_course_roles': [], - 'commentable_id': 'dummy', - 'id': 'test_comment' - } - - @ddt.data(*itertools.product([True, False], [True, False], [True, False])) - @ddt.unpack - def test_vote_count(self, current_vote_status, first_vote, second_vote): - """ - Tests vote_count increases and decreases correctly from the same user - """ - #setup - starting_vote_count = 0 - user1, request1 = self.create_user_with_request() - if current_vote_status: - self.register_get_user_response(user1, upvoted_ids=["test_comment"]) - starting_vote_count = 1 - self.register_comment_votes_response("test_comment") - self.register_comment(overrides={"votes": {"up_count": starting_vote_count}}) - - #first vote - data = {"voted": first_vote} - result = update_comment(request1, "test_comment", data) - self.register_comment(overrides={"voted": first_vote}) - assert result['vote_count'] == (1 if first_vote else 0) - - #second vote - data = {"voted": second_vote} - result = update_comment(request1, "test_comment", data) - assert result['vote_count'] == (1 if second_vote else 0) - - @ddt.data(*itertools.product([True, False], [True, False], [True, False], [True, False])) - @ddt.unpack - def test_vote_count_two_users( - self, - current_user1_vote, - current_user2_vote, - user1_vote, - user2_vote - ): - """ - Tests vote_count increases and decreases correctly from different users - """ - user1, request1 = self.create_user_with_request() - user2, request2 = self.create_user_with_request() - - vote_count = 0 - if current_user1_vote: - self.register_get_user_response(user1, upvoted_ids=["test_comment"]) - vote_count += 1 - if current_user2_vote: - self.register_get_user_response(user2, upvoted_ids=["test_comment"]) - vote_count += 1 - - for (current_vote, user_vote, request) in \ - [(current_user1_vote, user1_vote, request1), - (current_user2_vote, user2_vote, request2)]: - - self.register_comment_votes_response("test_comment") - self.register_comment(overrides={"votes": {"up_count": vote_count}}) - - data = {"voted": user_vote} - result = update_comment(request, "test_comment", data) - if current_vote == user_vote: - assert result['vote_count'] == vote_count - elif user_vote: - vote_count += 1 - assert result['vote_count'] == vote_count - self.register_get_user_response(self.user, upvoted_ids=["test_comment"]) - else: - vote_count -= 1 - assert result['vote_count'] == vote_count - self.register_get_user_response(self.user, upvoted_ids=[]) - - @ddt.data(*itertools.product([True, False], [True, False])) - @ddt.unpack - @mock.patch("eventtracking.tracker.emit") - def test_abuse_flagged(self, old_flagged, new_flagged, mock_emit): - """ - Test attempts to edit the "abuse_flagged" field. - - old_flagged indicates whether the comment should be flagged at the start - of the test. new_flagged indicates the value for the "abuse_flagged" - field in the update. If old_flagged and new_flagged are the same, no - update should be made. Otherwise, a PUT should be made to the flag or - or unflag endpoint according to the new_flagged value. - """ - self.register_get_user_response(self.user) - self.register_comment_flag_response("test_comment") - self.register_comment({"abuse_flaggers": [str(self.user.id)] if old_flagged else []}) - data = {"abuse_flagged": new_flagged} - result = update_comment(self.request, "test_comment", data) - assert result['abuse_flagged'] == new_flagged - last_request_path = urlparse(httpretty.last_request().path).path # lint-amnesty, pylint: disable=no-member - flag_url = "/api/v1/comments/test_comment/abuse_flag" - unflag_url = "/api/v1/comments/test_comment/abuse_unflag" - if old_flagged == new_flagged: - assert last_request_path != flag_url - assert last_request_path != unflag_url - else: - assert last_request_path == (flag_url if new_flagged else unflag_url) - assert httpretty.last_request().method == 'PUT' - assert parsed_body(httpretty.last_request()) == {'user_id': [str(self.user.id)]} - - expected_event_name = 'edx.forum.response.reported' if new_flagged else 'edx.forum.response.unreported' - expected_event_data = { - 'body': 'Original body', - 'id': 'test_comment', - 'content_type': 'Response', - 'commentable_id': 'dummy', - 'url': '', - 'truncated': False, - 'user_course_roles': [], - 'user_forums_roles': [FORUM_ROLE_STUDENT], - 'target_username': self.user.username, - } - if not new_flagged: - expected_event_data['reported_status_cleared'] = False - - actual_event_name, actual_event_data = mock_emit.call_args[0] - self.assertEqual(actual_event_name, expected_event_name) - self.assertEqual(actual_event_data, expected_event_data) - - @ddt.data( - (False, True), - (True, True), - ) - @ddt.unpack - @mock.patch("eventtracking.tracker.emit") - def test_comment_un_abuse_flag_for_moderator_role(self, is_author, remove_all, mock_emit): - """ - Test un-abuse flag for moderator role. - - When moderator unflags a reported comment, it should - pass the "all" flag to the api. This will indicate - to the api to clear all abuse_flaggers, and mark the - comment as unreported. - """ - _assign_role_to_user(user=self.user, course_id=self.course.id, role=FORUM_ROLE_ADMINISTRATOR) - self.register_get_user_response(self.user) - self.register_comment_flag_response("test_comment") - self.register_comment({"abuse_flaggers": ["11"], "user_id": str(self.user.id) if is_author else "12"}) - data = {"abuse_flagged": False} - update_comment(self.request, "test_comment", data) - assert httpretty.last_request().method == 'PUT' - query_params = {'user_id': [str(self.user.id)]} - if remove_all: - query_params.update({'all': ['True']}) - assert parsed_body(httpretty.last_request()) == query_params - - expected_event_name = 'edx.forum.response.unreported' - expected_event_data = { - 'body': 'Original body', - 'id': 'test_comment', - 'content_type': 'Response', - 'commentable_id': 'dummy', - 'truncated': False, - 'url': '', - 'user_course_roles': [], - 'user_forums_roles': [FORUM_ROLE_STUDENT, FORUM_ROLE_ADMINISTRATOR], - 'target_username': self.user.username, - 'reported_status_cleared': False, - } - - actual_event_name, actual_event_data = mock_emit.call_args[0] - self.assertEqual(actual_event_name, expected_event_name) - self.assertEqual(actual_event_data, expected_event_data) - - @ddt.data( - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - FORUM_ROLE_STUDENT, - ) - @mock.patch("lms.djangoapps.discussion.rest_api.serializers.EDIT_REASON_CODES", { - "test-edit-reason": "Test Edit Reason", - }) - @mock.patch("eventtracking.tracker.emit") - def test_update_comment_with_edit_reason_code(self, role_name, mock_emit): - """ - Test editing comments, specifying and retrieving edit reason codes. - """ - _assign_role_to_user(user=self.user, course_id=self.course.id, role=role_name) - self.register_comment({"user_id": str(self.user.id + 1)}) - try: - result = update_comment(self.request, "test_comment", { - "raw_body": "Edited body", - "edit_reason_code": "test-edit-reason", - }) - assert role_name != FORUM_ROLE_STUDENT - assert result["last_edit"] == { - "original_body": "Original body", - "reason": "Test Edit Reason", - "reason_code": "test-edit-reason", - "author": self.user.username, - } - request_body = httpretty.last_request().parsed_body # pylint: disable=no-member - assert request_body["edit_reason_code"] == ["test-edit-reason"] - - expected_event_name = 'edx.forum.response.edited' - expected_event_data = { - 'id': 'test_comment', - 'content_type': 'Response', - 'own_content': False, - 'url': '', - 'user_course_roles': [], - 'user_forums_roles': ['Student', role_name], - 'target_username': self.user.username, - 'edit_reason': 'test-edit-reason', - 'commentable_id': 'dummy' - } - - actual_event_name, actual_event_data = mock_emit.call_args[0] - self.assertEqual(actual_event_name, expected_event_name) - self.assertEqual(actual_event_data, expected_event_data) - - except ValidationError: - assert role_name == FORUM_ROLE_STUDENT - - -@ddt.ddt -@disable_signal(api, 'thread_deleted') -@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class DeleteThreadTest( - ForumsEnableMixin, - CommentsServiceMockMixin, - UrlResetMixin, - SharedModuleStoreTestCase, - MockSignalHandlerMixin -): - """Tests for delete_thread""" - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.course = CourseFactory.create() - - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - super().setUp() - httpretty.reset() - httpretty.enable() - self.addCleanup(httpretty.reset) - self.addCleanup(httpretty.disable) - 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("/test_path") - self.request.user = self.user - self.thread_id = "test_thread" - CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) - - def register_thread(self, overrides=None): - """ - Make a thread with appropriate data overridden by the overrides - parameter and register mock responses for both GET and DELETE on its - endpoint. - """ - cs_data = make_minimal_cs_thread({ - "id": self.thread_id, - "course_id": str(self.course.id), - "user_id": str(self.user.id), - }) - cs_data.update(overrides or {}) - self.register_get_thread_response(cs_data) - self.register_delete_thread_response(cs_data["id"]) - - @mock.patch("eventtracking.tracker.emit") - def test_basic(self, mock_emit): - self.register_thread() - with self.assert_signal_sent(api, 'thread_deleted', sender=None, user=self.user, exclude_args=('post',)): - assert delete_thread(self.request, self.thread_id) is None - assert urlparse(httpretty.last_request().path).path == f"/api/v1/threads/{self.thread_id}" # lint-amnesty, pylint: disable=no-member - assert httpretty.last_request().method == 'DELETE' - - expected_event_name = 'edx.forum.thread.deleted' - expected_event_data = { - 'body': 'dummy', - 'content_type': 'Post', - 'own_content': True, - 'commentable_id': 'dummy', - 'target_username': 'dummy', - 'title_truncated': False, - 'title': 'dummy', - 'id': 'test_thread', - 'url': '', - 'user_forums_roles': ['Student'], - 'user_course_roles': [] - } - - actual_event_name, actual_event_data = mock_emit.call_args[0] - self.assertEqual(actual_event_name, expected_event_name) - self.assertEqual(actual_event_data, expected_event_data) - - def test_thread_id_not_found(self): - self.register_get_thread_error_response("missing_thread", 404) - with pytest.raises(ThreadNotFoundError): - delete_thread(self.request, "missing_thread") - - def test_nonexistent_course(self): - self.register_thread({"course_id": "course-v1:non+existent+course"}) - with pytest.raises(CourseNotFoundError): - delete_thread(self.request, self.thread_id) - - def test_not_enrolled(self): - self.register_thread() - self.request.user = UserFactory.create() - with pytest.raises(CourseNotFoundError): - delete_thread(self.request, self.thread_id) - - def test_discussions_disabled(self): - disabled_course = _discussion_disabled_course_for(self.user) - self.register_thread(overrides={"course_id": str(disabled_course.id)}) - with pytest.raises(DiscussionDisabledError): - delete_thread(self.request, self.thread_id) - - @ddt.data( - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - FORUM_ROLE_STUDENT, - ) - def test_non_author_delete_allowed(self, role_name): - _assign_role_to_user(user=self.user, course_id=self.course.id, role=role_name) - self.register_thread({"user_id": str(self.user.id + 1)}) - expected_error = role_name == FORUM_ROLE_STUDENT - try: - delete_thread(self.request, self.thread_id) - assert not expected_error - except PermissionDenied: - assert expected_error - - @ddt.data( - *itertools.product( - [ - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - FORUM_ROLE_STUDENT, - ], - [True, False], - ["no_group", "match_group", "different_group"], - ) - ) - @ddt.unpack - def test_group_access(self, role_name, course_is_cohorted, thread_group_state): - """ - Tests group access for deleting a thread - - All privileged roles are able to delete a thread. A student role can - only delete a thread if, - the student role is the author and the thread is not in a cohort, - the student role is the author and the thread is in the author's cohort. - """ - cohort_course, cohort = _create_course_and_cohort_with_user_role(course_is_cohorted, self.user, role_name) - self.register_thread({ - "course_id": str(cohort_course.id), - "group_id": ( - None if thread_group_state == "no_group" else - cohort.id if thread_group_state == "match_group" else - cohort.id + 1 - ), - }) - expected_error = ( - role_name == FORUM_ROLE_STUDENT and - course_is_cohorted and - thread_group_state == "different_group" - ) - try: - delete_thread(self.request, self.thread_id) - assert not expected_error - except ThreadNotFoundError: - assert expected_error - - -@ddt.ddt -@disable_signal(api, 'comment_deleted') -@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class DeleteCommentTest( - ForumsEnableMixin, - CommentsServiceMockMixin, - UrlResetMixin, - SharedModuleStoreTestCase, - MockSignalHandlerMixin -): - """Tests for delete_comment""" - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.course = CourseFactory.create() - - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - super().setUp() - httpretty.reset() - httpretty.enable() - self.addCleanup(httpretty.reset) - self.addCleanup(httpretty.disable) - 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("/test_path") - self.request.user = self.user - self.thread_id = "test_thread" - self.comment_id = "test_comment" - CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) - - def register_comment_and_thread(self, overrides=None, thread_overrides=None): - """ - Make a comment with appropriate data overridden by the override - parameters and register mock responses for both GET and DELETE on its - endpoint. Also mock GET for the related thread with thread_overrides. - """ - cs_thread_data = make_minimal_cs_thread({ - "id": self.thread_id, - "course_id": str(self.course.id) - }) - cs_thread_data.update(thread_overrides or {}) - self.register_get_thread_response(cs_thread_data) - cs_comment_data = make_minimal_cs_comment({ - "id": self.comment_id, - "course_id": cs_thread_data["course_id"], - "thread_id": cs_thread_data["id"], - "username": self.user.username, - "user_id": str(self.user.id), - }) - cs_comment_data.update(overrides or {}) - self.register_get_comment_response(cs_comment_data) - self.register_delete_comment_response(self.comment_id) - - @mock.patch("eventtracking.tracker.emit") - def test_basic(self, mock_emit): - self.register_comment_and_thread() - with self.assert_signal_sent(api, 'comment_deleted', sender=None, user=self.user, exclude_args=('post',)): - assert delete_comment(self.request, self.comment_id) is None - assert urlparse(httpretty.last_request().path).path == f"/api/v1/comments/{self.comment_id}" # lint-amnesty, pylint: disable=no-member - assert httpretty.last_request().method == 'DELETE' - - expected_event_name = 'edx.forum.response.deleted' - expected_event_data = { - 'body': 'dummy', - 'content_type': 'Response', - 'own_content': True, - 'commentable_id': 'dummy', - 'target_username': self.user.username, - 'id': 'test_comment', - 'url': '', - 'user_forums_roles': ['Student'], - 'user_course_roles': [] - } - - actual_event_name, actual_event_data = mock_emit.call_args[0] - self.assertEqual(actual_event_name, expected_event_name) - self.assertEqual(actual_event_data, expected_event_data) - - def test_comment_id_not_found(self): - self.register_get_comment_error_response("missing_comment", 404) - with pytest.raises(CommentNotFoundError): - delete_comment(self.request, "missing_comment") - - def test_nonexistent_course(self): - self.register_comment_and_thread( - thread_overrides={"course_id": "course-v1:non+existent+course"} - ) - with pytest.raises(CourseNotFoundError): - delete_comment(self.request, self.comment_id) - - def test_not_enrolled(self): - self.register_comment_and_thread() - self.request.user = UserFactory.create() - with pytest.raises(CourseNotFoundError): - delete_comment(self.request, self.comment_id) - - def test_discussions_disabled(self): - disabled_course = _discussion_disabled_course_for(self.user) - self.register_comment_and_thread( - thread_overrides={"course_id": str(disabled_course.id)}, - overrides={"course_id": str(disabled_course.id)} - ) - with pytest.raises(DiscussionDisabledError): - delete_comment(self.request, self.comment_id) - - @ddt.data( - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - FORUM_ROLE_STUDENT, - ) - def test_non_author_delete_allowed(self, role_name): - _assign_role_to_user(user=self.user, course_id=self.course.id, role=role_name) - self.register_comment_and_thread( - overrides={"user_id": str(self.user.id + 1)} - ) - expected_error = role_name == FORUM_ROLE_STUDENT - try: - delete_comment(self.request, self.comment_id) - assert not expected_error - except PermissionDenied: - assert expected_error - - @ddt.data( - *itertools.product( - [ - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - FORUM_ROLE_STUDENT, - ], - [True, False], - ["no_group", "match_group", "different_group"], - ) - ) - @ddt.unpack - def test_group_access(self, role_name, course_is_cohorted, thread_group_state): - """ - Tests group access for deleting a comment - - All privileged roles are able to delete a comment. A student role can - only delete a comment if, - the student role is the author and the comment is not in a cohort, - the student role is the author and the comment is in the author's cohort. - """ - cohort_course, cohort = _create_course_and_cohort_with_user_role(course_is_cohorted, self.user, role_name) - self.register_comment_and_thread( - overrides={"thread_id": "test_thread"}, - thread_overrides={ - "course_id": str(cohort_course.id), - "group_id": ( - None if thread_group_state == "no_group" else - cohort.id if thread_group_state == "match_group" else - cohort.id + 1 - ), - } - ) - expected_error = ( - role_name == FORUM_ROLE_STUDENT and - course_is_cohorted and - thread_group_state == "different_group" - ) - try: - delete_comment(self.request, self.comment_id) - assert not expected_error - except ThreadNotFoundError: - assert expected_error - - -@ddt.ddt -@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class RetrieveThreadTest( - ForumsEnableMixin, - CommentsServiceMockMixin, - UrlResetMixin, - SharedModuleStoreTestCase -): - """Tests for get_thread""" - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.course = CourseFactory.create() - - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - super().setUp() - httpretty.reset() - httpretty.enable() - self.addCleanup(httpretty.reset) - self.addCleanup(httpretty.disable) - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" - ) - self.mock_get_course_id_by_thread = patcher.start() - self.addCleanup(patcher.stop) - self.user = UserFactory.create() - self.register_get_user_response(self.user) - self.request = RequestFactory().get("/test_path") - self.request.user = self.user - self.thread_id = "test_thread" - CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) - - def register_thread(self, overrides=None): - """ - Make a thread with appropriate data overridden by the overrides - parameter and register mock responses for GET on its - endpoint. - """ - cs_data = make_minimal_cs_thread({ - "id": self.thread_id, - "course_id": str(self.course.id), - "commentable_id": "test_topic", - "username": self.user.username, - "user_id": str(self.user.id), - "title": "Test Title", - "body": "Test body", - "resp_total": 0, - - }) - cs_data.update(overrides or {}) - self.register_get_thread_response(cs_data) - - def test_basic(self): - self.register_thread({"resp_total": 2}) - assert get_thread(self.request, self.thread_id) == self.expected_thread_data({ - 'response_count': 2, - 'unread_comment_count': 1 - }) - assert httpretty.last_request().method == 'GET' - - def test_thread_id_not_found(self): - self.register_get_thread_error_response("missing_thread", 404) - with pytest.raises(ThreadNotFoundError): - get_thread(self.request, "missing_thread") - - def test_nonauthor_enrolled_in_course(self): - non_author_user = UserFactory.create() - self.register_get_user_response(non_author_user) - CourseEnrollmentFactory.create(user=non_author_user, course_id=self.course.id) - self.register_thread() - self.request.user = non_author_user - assert get_thread(self.request, self.thread_id) == self.expected_thread_data({ - 'can_delete': False, - 'editable_fields': ['abuse_flagged', 'copy_link', 'following', 'read', 'voted'], - 'unread_comment_count': 1 - }) - assert httpretty.last_request().method == 'GET' - - def test_not_enrolled_in_course(self): - self.register_thread() - self.request.user = UserFactory.create() - with pytest.raises(CourseNotFoundError): - get_thread(self.request, self.thread_id) - - @ddt.data( - *itertools.product( - [ - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - FORUM_ROLE_STUDENT, - ], - [True, False], - ["no_group", "match_group", "different_group"], - ) - ) - @ddt.unpack - def test_group_access(self, role_name, course_is_cohorted, thread_group_state): - """ - Tests group access for retrieving a thread - - All privileged roles are able to retrieve a thread. A student role can - only retrieve a thread if, - the student role is the author and the thread is not in a cohort, - the student role is the author and the thread is in the author's cohort. - """ - cohort_course, cohort = _create_course_and_cohort_with_user_role(course_is_cohorted, self.user, role_name) - self.register_thread({ - "course_id": str(cohort_course.id), - "group_id": ( - None if thread_group_state == "no_group" else - cohort.id if thread_group_state == "match_group" else - cohort.id + 1 - ), - }) - expected_error = ( - role_name == FORUM_ROLE_STUDENT and - course_is_cohorted and - thread_group_state == "different_group" - ) - try: - get_thread(self.request, self.thread_id) - assert not expected_error - except ThreadNotFoundError: - assert expected_error - - def test_course_id_mismatch(self): - """ - Test if the api throws not found exception if course_id from params mismatches course_id in thread - """ - self.register_thread() - get_thread(self.request, self.thread_id, 'different_course_id') - assert ThreadNotFoundError - - -@mock.patch('lms.djangoapps.discussion.rest_api.api._get_course', mock.Mock()) -class CourseTopicsV2Test(ModuleStoreTestCase): - """ - Tests for discussions topic API v2 code. - """ - - def setUp(self) -> None: - super().setUp() - self.course = CourseFactory.create( - discussion_topics={f"Course Wide Topic {idx}": {"id": f'course-wide-topic-{idx}'} for idx in range(10)} - ) - 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=f'vertical-{idx}', - start=datetime(2015, 4, 1, tzinfo=UTC), - ) - for idx in range(10) - ] - staff_only_unit = BlockFactory.create( - parent_location=self.sequential.location, - category='vertical', - display_name='staff-vertical-1', - metadata=dict(visible_to_staff_only=True), - ) - self.course_key = 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(self.course_key)) - self.staff_only_id = DiscussionTopicLink.objects.filter( - usage_key__in=[staff_only_unit.location] - ).values_list( - 'external_id', flat=True, - ).get() - 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')) - topic_ids.remove(self.staff_only_id) - topic_ids_by_name = list(topic_id_query.order_by('title')) - topic_ids_by_name.remove(self.staff_only_id) - self.deleted_topic_ids = deleted_topic_ids = [f'disabled-topic-{idx}' for idx in range(10)] - for idx, topic_id in enumerate(deleted_topic_ids): - usage_key = course_key.make_usage_key('vertical', topic_id) - topic_links.append( - DiscussionTopicLink( - context_key=course_key, - usage_key=usage_key, - title=f"Discussion on {topic_id}", - external_id=topic_id, - provider_id=Provider.OPEN_EDX, - ordering=idx, - enabled_in_context=False, - ) - ) - DiscussionTopicLink.objects.bulk_create(topic_links) - self.topic_ids = topic_ids - self.topic_ids_by_name = topic_ids_by_name - self.user = UserFactory.create() - self.staff = AdminFactory.create() - self.all_topic_ids = set(topic_ids) | set(deleted_topic_ids) | {self.staff_only_id} - # Set up topic stats for all topics, but have one deleted topic - # and one active topic return zero stats for testing. - self.topic_stats = { - **{topic_id: dict(discussion=random.randint(0, 10), question=random.randint(0, 10)) - for topic_id in self.all_topic_ids}, - deleted_topic_ids[0]: dict(discussion=0, question=0), - self.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) - - def test_default_response(self): - """ - Test that the standard response contains the correct number of items - """ - topics_list = get_course_topics_v2(course_key=self.course_key, user=self.user) - assert {t['id'] for t in topics_list} == set(self.topic_ids) - - def test_filtering(self): - """ - Tests that filtering by topic id works - """ - filter_ids = set(random.sample(self.topic_ids, 4)) - topics_list = get_course_topics_v2(course_key=self.course_key, user=self.user, topic_ids=filter_ids) - assert len(topics_list) == 4 - # All the filtered ids should be returned - assert filter_ids == set(topic_data.get('id') for topic_data in topics_list) - - def test_sort_by_name(self): - """ - Test sorting by name - """ - topics_list = get_course_topics_v2( - course_key=self.course_key, - user=self.user, - order_by=TopicOrdering.NAME, - ) - returned_topic_ids = [topic_data.get('id') for topic_data in topics_list] - assert returned_topic_ids == self.topic_ids_by_name - - def test_sort_by_structure(self): - """ - Test sorting by course structure - """ - topics_list = get_course_topics_v2( - course_key=self.course_key, - user=self.user, - order_by=TopicOrdering.COURSE_STRUCTURE, - ) - returned_topic_ids = [topic_data.get('id') for topic_data in topics_list] - # The topics are already sorted in their simulated course order - sorted_topic_ids = self.topic_ids - assert returned_topic_ids == sorted_topic_ids - - def test_sort_by_activity(self): - """ - Test sorting by activity - """ - topics_list = get_course_topics_v2( - course_key=self.course_key, - user=self.user, - order_by=TopicOrdering.ACTIVITY, - ) - returned_topic_ids = [topic_data.get('id') for topic_data in topics_list] - # The topics are already sorted in their simulated course order - sorted_topic_ids = sorted( - self.topic_ids, - key=lambda tid: sum(self.topic_stats.get(tid, {}).values()), - reverse=True, - ) - assert returned_topic_ids == sorted_topic_ids - - def test_other_providers_ordering_error(self): - """ - Test that activity sorting raises an error for other providers - """ - self.config.provider_type = 'other' - self.config.save() - with pytest.raises(ValidationError): - get_course_topics_v2( - course_key=self.course_key, - user=self.user, - order_by=TopicOrdering.ACTIVITY, - ) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py new file mode 100644 index 000000000000..900d52017c5e --- /dev/null +++ b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py @@ -0,0 +1,4781 @@ +# 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. +""" + +import itertools +import random +from datetime import datetime, timedelta +from unittest import mock +from urllib.parse import parse_qs, urlencode, urlparse, urlunparse + +import ddt +import httpretty +import pytest +from django.test import override_settings +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +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 rest_framework.exceptions import PermissionDenied + +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, + SharedModuleStoreTestCase, +) +from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory +from xmodule.partitions.partitions import Group, UserPartition + +from common.djangoapps.student.tests.factories import ( + AdminFactory, + BetaTesterFactory, + CourseEnrollmentFactory, + StaffFactory, + UserFactory, +) +from common.djangoapps.util.testing import UrlResetMixin +from common.test.utils import MockSignalHandlerMixin, disable_signal +from lms.djangoapps.discussion.tests.utils import ( + make_minimal_cs_comment, + make_minimal_cs_thread, +) +from lms.djangoapps.discussion.rest_api import api +from lms.djangoapps.discussion.rest_api.api import ( + create_comment, + create_thread, + delete_comment, + delete_thread, + get_comment_list, + get_course, + get_course_topics, + get_course_topics_v2, + get_thread, + get_thread_list, + get_user_comments, + update_comment, + update_thread, +) +from lms.djangoapps.discussion.rest_api.exceptions import ( + CommentNotFoundError, + DiscussionBlackOutException, + DiscussionDisabledError, + ThreadNotFoundError, +) +from lms.djangoapps.discussion.rest_api.serializers import TopicOrdering +from lms.djangoapps.discussion.rest_api.tests.utils import ( + CommentsServiceMockMixin, + ForumMockUtilsMixin, + make_paginated_api_response, + parsed_body, +) +from openedx.core.djangoapps.course_groups.models import CourseUserGroupPartitionGroup +from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory +from openedx.core.djangoapps.discussions.models import ( + DiscussionsConfiguration, + DiscussionTopicLink, + Provider, + PostingRestriction, +) +from openedx.core.djangoapps.discussions.tasks import ( + update_discussions_settings_from_course_task, +) +from openedx.core.djangoapps.django_comment_common.models import ( + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_GROUP_MODERATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_STUDENT, + Role, +) +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): + """ + Assign a discussion role to a user for a given course. + + 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]) + + +def _create_course_and_cohort_with_user_role(course_is_cohorted, user, role_name): + """ + Creates a course with the value of `course_is_cohorted`, plus `always_cohort_inline_discussions` + set to True (which is no longer the default value). Then 1) enrolls the user in that course, + 2) creates a cohort that the user is placed in, and 3) adds the user to the given role. + + Returns: a tuple of the created course and the created cohort + """ + cohort_course = CourseFactory.create( + cohort_config={ + "cohorted": course_is_cohorted, + "always_cohort_inline_discussions": True, + } + ) + CourseEnrollmentFactory.create(user=user, course_id=cohort_course.id) + cohort = CohortFactory.create(course_id=cohort_course.id, users=[user]) + _assign_role_to_user(user=user, course_id=cohort_course.id, role=role_name) + + return [cohort_course, cohort] + + +def _set_course_discussion_blackout(course, user_id): + """ + Set the blackout period for course discussions. + + Arguments: + course: Course for which blackout period is set + user_id: User id of user enrolled in the course + """ + course.discussion_blackouts = [ + datetime.now(UTC) - timedelta(days=3), + datetime.now(UTC) + timedelta(days=3), + ] + configuration = DiscussionsConfiguration.get(course.id) + configuration.posting_restrictions = PostingRestriction.SCHEDULED + configuration.save() + modulestore().update_item(course, user_id) + + +@ddt.ddt +@disable_signal(api, "thread_created") +@disable_signal(api, "thread_voted") +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class CreateThreadTest( + UrlResetMixin, + SharedModuleStoreTestCase, + MockSignalHandlerMixin, + ForumMockUtilsMixin, +): + """Tests for create_thread""" + + LONG_TITLE = ( + "Lorem ipsum dolor sit amet, consectetuer adipiscing elit. " + "Aenean commodo ligula eget dolor. Aenean massa. Cum sociis " + "natoque penatibus et magnis dis parturient montes, nascetur " + "ridiculus mus. Donec quam felis, ultricies nec, " + "pellentesque eu, pretium quis, sem. Nulla consequat massa " + "quis enim. Donec pede justo, fringilla vel, aliquet nec, " + "vulputate eget, arcu. In enim justo, rhoncus ut, imperdiet " + "a, venenatis vitae, justo. Nullam dictum felis eu pede " + "mollis pretium. Integer tincidunt. Cras dapibus. Vivamus " + "elementum semper nisi. Aenean vulputate eleifend tellus. " + "Aenean leo ligula, porttitor eu, consequat vitae, eleifend " + "ac, enim. Aliquam lorem ante, dapibus in, viverra quis, " + "feugiat a, tellus. Phasellus viverra nulla ut metus varius " + "laoreet. Quisque rutrum. Aenean imperdiet. Etiam ultricies " + "nisi vel augue. Curabitur ullamcorper ultricies nisi. Nam " + "eget dui. Etiam rhoncus. Maecenas tempus, tellus eget " + "condimentum rhoncus, sem quam semper libero, sit amet " + "adipiscing sem neque sed ipsum. Nam quam nunc, blandit vel, " + "luctus pulvinar, hendrerit id, lorem. Maecenas nec odio et " + "ante tincidunt tempus. Donec vitae sapien ut libero " + "venenatis faucibus. Nullam quis ante. Etiam sit amet orci " + "eget eros faucibus tincidunt. Duis leo. Sed fringilla " + "mauris sit amet nibh. Donec sodales sagittis magna. Sed " + "consequat, leo eget bibendum sodales, augue velit cursus " + "nunc, quis gravida magna mi a libero. Fusce vulputate " + "eleifend sapien. Vestibulum purus quam, scelerisque ut, " + "mollis sed, nonummy id, metus. Nullam accumsan lorem in " + "dui. Cras ultricies mi eu turpis hendrerit fringilla. " + "Vestibulum ante ipsum primis in faucibus orci luctus et " + "ultrices posuere cubilia Curae; In ac dui quis mi " + "consectetuer lacinia. Nam pretium turpis et arcu. Duis arcu " + "tortor, suscipit eget, imperdiet nec, imperdiet iaculis, " + "ipsum. Sed aliquam ultrices mauris. Integer ante arcu, " + "accumsan a, consectetuer eget, posuere ut, mauris. Praesent " + "adipiscing. Phasellus ullamcorper ipsum rutrum nunc. Nunc " + "nonummy metus." + ) + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def setUp(self): + super().setUp() + self.course = CourseFactory.create() + httpretty.reset() + httpretty.enable() + self.addCleanup(httpretty.reset) + self.addCleanup(httpretty.disable) + self.user = UserFactory.create() + self.register_get_user_response(self.user) + self.request = RequestFactory().get("/test_path") + self.request.user = self.user + CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) + self.minimal_data = { + "course_id": str(self.course.id), + "topic_id": "test_topic", + "type": "discussion", + "title": "Test Title", + "raw_body": "Test body", + } + + @mock.patch("eventtracking.tracker.emit") + def test_basic(self, mock_emit): + cs_thread = make_minimal_cs_thread( + { + "id": "test_id", + "username": self.user.username, + "read": True, + } + ) + self.register_post_thread_response(cs_thread) + with self.assert_signal_sent( + api, "thread_created", sender=None, user=self.user, exclude_args=("post", "notify_all_learners") + ): + actual = create_thread(self.request, self.minimal_data) + expected = self.expected_thread_data( + { + "id": "test_id", + "course_id": str(self.course.id), + "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_id", + "read": True, + } + ) + assert actual == expected + params = { + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "thread_type": "discussion", + "title": "Test Title", + "body": "Test body", + "user_id": str(self.user.id), + "anonymous": False, + "anonymous_to_peers": False, + } + + self.check_mock_called_with("create_thread", -1, **params) + + event_name, event_data = mock_emit.call_args[0] + assert event_name == "edx.forum.thread.created" + assert event_data == { + "commentable_id": "test_topic", + "group_id": None, + "thread_type": "discussion", + "title": "Test Title", + "title_truncated": False, + "anonymous": False, + "anonymous_to_peers": False, + "options": {"followed": False, "notify_all_learners": False}, + "id": "test_id", + "truncated": False, + "body": "Test body", + "url": "", + "user_forums_roles": [FORUM_ROLE_STUDENT], + "user_course_roles": [], + "from_mfe_sidebar": False, + } + + def test_basic_in_blackout_period(self): + """ + Test case when course is in blackout period and user does not have special privileges. + """ + _set_course_discussion_blackout(course=self.course, user_id=self.user.id) + + with self.assertRaises(DiscussionBlackOutException) as assertion: + create_thread(self.request, self.minimal_data) + self.assertEqual(assertion.exception.status_code, 403) + self.assertEqual( + assertion.exception.detail, "Discussions are in blackout period." + ) + + @mock.patch("eventtracking.tracker.emit") + def test_basic_in_blackout_period_with_user_access(self, mock_emit): + """ + Test case when course is in blackout period and user has special privileges. + """ + cs_thread = make_minimal_cs_thread( + { + "id": "test_id", + "username": self.user.username, + "read": True, + } + ) + self.register_post_thread_response(cs_thread) + + _set_course_discussion_blackout(course=self.course, user_id=self.user.id) + + _assign_role_to_user( + user=self.user, course_id=self.course.id, role=FORUM_ROLE_MODERATOR + ) + + with self.assert_signal_sent( + api, "thread_created", sender=None, user=self.user, exclude_args=("post", "notify_all_learners") + ): + actual = create_thread(self.request, self.minimal_data) + expected = self.expected_thread_data( + { + "author_label": "Moderator", + "id": "test_id", + "course_id": str(self.course.id), + "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_id", + "read": True, + "editable_fields": [ + "abuse_flagged", + "anonymous", + "close_reason_code", + "closed", + "copy_link", + "following", + "pinned", + "raw_body", + "read", + "title", + "topic_id", + "type", + "voted", + ], + } + ) + assert actual == expected + params = { + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "thread_type": "discussion", + "title": "Test Title", + "body": "Test body", + "user_id": str(self.user.id), + "anonymous": False, + "anonymous_to_peers": False, + } + self.check_mock_called_with("create_thread", -1, **params) + event_name, event_data = mock_emit.call_args[0] + self.assertEqual(event_name, "edx.forum.thread.created") + self.assertEqual( + event_data, + { + "commentable_id": "test_topic", + "group_id": None, + "thread_type": "discussion", + "title": "Test Title", + "title_truncated": False, + "anonymous": False, + "anonymous_to_peers": False, + "options": {"followed": False, "notify_all_learners": False}, + "id": "test_id", + "truncated": False, + "body": "Test body", + "url": "", + "user_forums_roles": [FORUM_ROLE_STUDENT, FORUM_ROLE_MODERATOR], + "user_course_roles": [], + "from_mfe_sidebar": False, + }, + ) + + @mock.patch("eventtracking.tracker.emit") + def test_title_truncation(self, mock_emit): + data = self.minimal_data.copy() + data["title"] = self.LONG_TITLE + + cs_thread = make_minimal_cs_thread( + { + "id": "test_id", + "username": self.user.username, + "read": True, + } + ) + self.register_post_thread_response(cs_thread) + with self.assert_signal_sent( + api, "thread_created", sender=None, user=self.user, exclude_args=("post", "notify_all_learners") + ): + create_thread(self.request, data) + event_name, event_data = mock_emit.call_args[0] + assert event_name == "edx.forum.thread.created" + assert event_data == { + "commentable_id": "test_topic", + "group_id": None, + "thread_type": "discussion", + "title": self.LONG_TITLE[:1000], + "title_truncated": True, + "anonymous": False, + "anonymous_to_peers": False, + "options": {"followed": False, "notify_all_learners": False}, + "id": "test_id", + "truncated": False, + "body": "Test body", + "url": "", + "user_forums_roles": [FORUM_ROLE_STUDENT], + "user_course_roles": [], + "from_mfe_sidebar": False, + } + + @ddt.data( + *itertools.product( + [ + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_STUDENT, + ], + [True, False], + [True, False], + ["no_group_set", "group_is_none", "group_is_set"], + ) + ) + @ddt.unpack + def test_group_id( + self, role_name, course_is_cohorted, topic_is_cohorted, data_group_state + ): + """ + Tests whether the user has permission to create a thread with certain + group_id values. + + If there is no group, user cannot create a thread. + Else if group is None or set, and the course is not cohorted and/or the + role is a student, user can create a thread. + """ + + cohort_course = CourseFactory.create( + discussion_topics={"Test Topic": {"id": "test_topic"}}, + cohort_config={ + "cohorted": course_is_cohorted, + "cohorted_discussions": ["test_topic"] if topic_is_cohorted else [], + }, + ) + CourseEnrollmentFactory.create(user=self.user, course_id=cohort_course.id) + if course_is_cohorted: + cohort = CohortFactory.create(course_id=cohort_course.id, users=[self.user]) + _assign_role_to_user(user=self.user, course_id=cohort_course.id, role=role_name) + self.register_post_thread_response({"username": self.user.username}) + data = self.minimal_data.copy() + data["course_id"] = str(cohort_course.id) + if data_group_state == "group_is_none": + data["group_id"] = None + elif data_group_state == "group_is_set": + if course_is_cohorted: + data["group_id"] = cohort.id + 1 + else: + data["group_id"] = 1 # Set to any value since there is no cohort + expected_error = data_group_state in ["group_is_none", "group_is_set"] and ( + not course_is_cohorted or role_name == FORUM_ROLE_STUDENT + ) + try: + create_thread(self.request, data) + assert not expected_error + actual_post_data = self.get_mock_func_calls("create_thread")[-1][1] + if data_group_state == "group_is_set": + assert actual_post_data["group_id"] == data["group_id"] + elif ( + data_group_state == "no_group_set" + and course_is_cohorted + and topic_is_cohorted + ): + assert actual_post_data["group_id"] == cohort.id + else: + assert "group_id" not in actual_post_data + except ValidationError as ex: + if not expected_error: + self.fail(f"Unexpected validation error: {ex}") + + def test_course_id_missing(self): + with pytest.raises(ValidationError) as assertion: + create_thread(self.request, {}) + assert assertion.value.message_dict == { + "course_id": ["This field is required."] + } + + def test_course_id_invalid(self): + with pytest.raises(ValidationError) as assertion: + create_thread(self.request, {"course_id": "invalid!"}) + assert assertion.value.message_dict == {"course_id": ["Invalid value."]} + + def test_nonexistent_course(self): + with pytest.raises(CourseNotFoundError): + create_thread(self.request, {"course_id": "course-v1:non+existent+course"}) + + def test_not_enrolled(self): + self.request.user = UserFactory.create() + with pytest.raises(CourseNotFoundError): + create_thread(self.request, self.minimal_data) + + def test_discussions_disabled(self): + disabled_course = _discussion_disabled_course_for(self.user) + self.minimal_data["course_id"] = str(disabled_course.id) + with pytest.raises(DiscussionDisabledError): + create_thread(self.request, self.minimal_data) + + def test_invalid_field(self): + data = self.minimal_data.copy() + data["type"] = "invalid_type" + with pytest.raises(ValidationError): + create_thread(self.request, data) + + def test_abuse_flagged(self): + self.register_post_thread_response( + {"id": "test_id", "username": self.user.username} + ) + self.register_thread_flag_response("test_id") + data = self.minimal_data.copy() + data["abuse_flagged"] = "True" + result = create_thread(self.request, data) + assert result["abuse_flagged"] is True + + self.check_mock_called("update_thread_flag") + params = { + "thread_id": "test_id", + "action": "flag", + "user_id": "1", + "course_id": str(self.course.id), + } + self.check_mock_called_with("update_thread_flag", -1, **params) + + def test_following(self): + self.register_post_thread_response( + {"id": "test_id", "username": self.user.username} + ) + self.register_subscription_response(self.user) + data = self.minimal_data.copy() + data["following"] = "True" + result = create_thread(self.request, data) + assert result["following"] is True + self.check_mock_called("create_subscription") + + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "source_id": "test_id", + } + self.check_mock_called_with("create_subscription", 0, **params) + + +@ddt.ddt +@disable_signal(api, "comment_created") +@disable_signal(api, "comment_voted") +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +@mock.patch( + "lms.djangoapps.discussion.signals.handlers.send_response_notifications", + new=mock.Mock(), +) +class CreateCommentTest( + UrlResetMixin, + SharedModuleStoreTestCase, + MockSignalHandlerMixin, + ForumMockUtilsMixin, +): + """Tests for create_comment""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + cls.course = CourseFactory.create() + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def setUp(self): + super().setUp() + httpretty.reset() + httpretty.enable() + self.addCleanup(httpretty.reset) + self.addCleanup(httpretty.disable) + self.course = CourseFactory.create() + self.user = UserFactory.create() + self.register_get_user_response(self.user) + self.request = RequestFactory().get("/test_path") + self.request.user = self.user + CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) + self.register_get_thread_response( + make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + } + ) + ) + self.minimal_data = { + "thread_id": "test_thread", + "raw_body": "Test body", + } + + mock_response = { + "collection": [], + "page": 1, + "num_pages": 1, + "subscriptions_count": 1, + "corrected_text": None, + } + self.register_get_subscriptions("cohort_thread", mock_response) + self.register_get_subscriptions("test_thread", mock_response) + + @ddt.data(None, "test_parent") + @mock.patch("eventtracking.tracker.emit") + def test_success(self, parent_id, mock_emit): + if parent_id: + self.register_get_comment_response( + {"id": parent_id, "thread_id": "test_thread"} + ) + self.register_post_comment_response( + { + "id": "test_comment", + "username": self.user.username, + "created_at": "2015-05-27T00:00:00Z", + "updated_at": "2015-05-27T00:00:00Z", + }, + thread_id="test_thread", + parent_id=parent_id, + ) + data = self.minimal_data.copy() + if parent_id: + data["parent_id"] = parent_id + with self.assert_signal_sent( + api, "comment_created", sender=None, user=self.user, exclude_args=("post",) + ): + actual = create_comment(self.request, data) + expected = { + "id": "test_comment", + "thread_id": "test_thread", + "parent_id": parent_id, + "author": self.user.username, + "author_label": None, + "created_at": "2015-05-27T00:00:00Z", + "updated_at": "2015-05-27T00:00:00Z", + "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": 0, + "children": [], + "editable_fields": ["abuse_flagged", "anonymous", "raw_body"], + "child_count": 0, + "can_delete": True, + "anonymous": False, + "anonymous_to_peers": False, + "last_edit": None, + "edit_by_label": None, + "profile_image": { + "has_image": False, + "image_url_full": "http://testserver/static/default_500.png", + "image_url_large": "http://testserver/static/default_120.png", + "image_url_medium": "http://testserver/static/default_50.png", + "image_url_small": "http://testserver/static/default_30.png", + }, + } + assert actual == expected + + params = { + "course_id": str(self.course.id), + "body": "Test body", + "user_id": str(self.user.id), + "anonymous": False, + "anonymous_to_peers": False, + } + if parent_id: + params["parent_comment_id"] = parent_id + self.check_mock_called_with("create_child_comment", -1, **params) + else: + params["thread_id"] = "test_thread" + self.check_mock_called_with("create_parent_comment", -1, **params) + + expected_event_name = ( + "edx.forum.comment.created" if parent_id else "edx.forum.response.created" + ) + expected_event_data = { + "discussion": {"id": "test_thread"}, + "commentable_id": "test_topic", + "options": {"followed": False}, + "id": "test_comment", + "truncated": False, + "body": "Test body", + "url": "", + "user_forums_roles": [FORUM_ROLE_STUDENT], + "user_course_roles": [], + "from_mfe_sidebar": False, + } + if parent_id: + expected_event_data["response"] = {"id": parent_id} + actual_event_name, actual_event_data = mock_emit.call_args[0] + assert actual_event_name == expected_event_name + assert actual_event_data == expected_event_data + + @ddt.data(None, "test_parent") + @mock.patch("eventtracking.tracker.emit") + def test_success_in_black_out_with_user_access(self, parent_id, mock_emit): + """ + Test case when course is in blackout period and user has special privileges. + """ + if parent_id: + self.register_get_comment_response( + {"id": parent_id, "thread_id": "test_thread"} + ) + self.register_post_comment_response( + { + "id": "test_comment", + "username": self.user.username, + "created_at": "2015-05-27T00:00:00Z", + "updated_at": "2015-05-27T00:00:00Z", + }, + thread_id="test_thread", + parent_id=parent_id, + ) + data = self.minimal_data.copy() + editable_fields = ["abuse_flagged", "anonymous", "raw_body", "voted"] + if parent_id: + data["parent_id"] = parent_id + else: + editable_fields.insert(2, "endorsed") + + _set_course_discussion_blackout(course=self.course, user_id=self.user.id) + _assign_role_to_user( + user=self.user, course_id=self.course.id, role=FORUM_ROLE_MODERATOR + ) + + with self.assert_signal_sent( + api, "comment_created", sender=None, user=self.user, exclude_args=("post",) + ): + actual = create_comment(self.request, data) + expected = { + "id": "test_comment", + "thread_id": "test_thread", + "parent_id": parent_id, + "author": self.user.username, + "author_label": "Moderator", + "created_at": "2015-05-27T00:00:00Z", + "updated_at": "2015-05-27T00:00:00Z", + "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": False, + "voted": False, + "vote_count": 0, + "children": [], + "editable_fields": editable_fields, + "child_count": 0, + "can_delete": True, + "anonymous": False, + "anonymous_to_peers": False, + "last_edit": None, + "edit_by_label": None, + "profile_image": { + "has_image": False, + "image_url_full": "http://testserver/static/default_500.png", + "image_url_large": "http://testserver/static/default_120.png", + "image_url_medium": "http://testserver/static/default_50.png", + "image_url_small": "http://testserver/static/default_30.png", + }, + } + assert actual == expected + + params = { + "course_id": str(self.course.id), + "body": "Test body", + "user_id": str(self.user.id), + "anonymous": False, + "anonymous_to_peers": False, + } + if parent_id: + params["parent_comment_id"] = parent_id + self.check_mock_called_with("create_child_comment", -1, **params) + else: + params["thread_id"] = "test_thread" + self.check_mock_called_with("create_parent_comment", -1, **params) + + expected_event_name = ( + "edx.forum.comment.created" if parent_id else "edx.forum.response.created" + ) + expected_event_data = { + "discussion": {"id": "test_thread"}, + "commentable_id": "test_topic", + "options": {"followed": False}, + "id": "test_comment", + "truncated": False, + "body": "Test body", + "url": "", + "user_forums_roles": [FORUM_ROLE_STUDENT, FORUM_ROLE_MODERATOR], + "user_course_roles": [], + "from_mfe_sidebar": False, + } + if parent_id: + expected_event_data["response"] = {"id": parent_id} + actual_event_name, actual_event_data = mock_emit.call_args[0] + self.assertEqual(actual_event_name, expected_event_name) + self.assertEqual(actual_event_data, expected_event_data) + + def test_error_in_black_out(self): + """ + Test case when course is in blackout period and user does not have special privileges. + """ + _set_course_discussion_blackout(course=self.course, user_id=self.user.id) + + with self.assertRaises(DiscussionBlackOutException) as assertion: + create_comment(self.request, self.minimal_data) + self.assertEqual(assertion.exception.status_code, 403) + self.assertEqual( + assertion.exception.detail, "Discussions are in blackout period." + ) + + @ddt.data( + *itertools.product( + [ + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_STUDENT, + ], + [True, False], + ["question", "discussion"], + ) + ) + @ddt.unpack + def test_endorsed(self, role_name, is_thread_author, thread_type): + _assign_role_to_user(user=self.user, course_id=self.course.id, role=role_name) + self.register_get_thread_response( + make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + "thread_type": thread_type, + "user_id": ( + str(self.user.id) if is_thread_author else str(self.user.id + 1) + ), + } + ) + ) + self.register_post_comment_response( + {"username": self.user.username}, "test_thread" + ) + data = self.minimal_data.copy() + data["endorsed"] = True + expected_error = role_name == FORUM_ROLE_STUDENT and ( + not is_thread_author or thread_type == "discussion" + ) + try: + create_comment(self.request, data) + last_commemt_params = self.get_mock_func_calls("create_parent_comment")[-1][1] + assert last_commemt_params["endorsed"] + assert not expected_error + except ValidationError: + assert expected_error + + def test_abuse_flagged(self): + self.register_post_comment_response( + {"id": "test_comment", "username": self.user.username}, "test_thread" + ) + self.register_comment_flag_response("test_comment") + data = self.minimal_data.copy() + data["abuse_flagged"] = "True" + result = create_comment(self.request, data) + assert result["abuse_flagged"] is True + + self.check_mock_called("update_comment_flag") + params = { + "comment_id": "test_comment", + "action": "flag", + "user_id": "1", + "course_id": str(self.course.id), + } + self.check_mock_called_with("update_comment_flag", -1, **params) + + def test_thread_id_missing(self): + with pytest.raises(ValidationError) as assertion: + create_comment(self.request, {}) + assert assertion.value.message_dict == { + "thread_id": ["This field is required."] + } + + def test_thread_id_not_found(self): + self.register_get_thread_error_response("test_thread", 404) + with pytest.raises(ThreadNotFoundError): + create_comment(self.request, self.minimal_data) + + def test_nonexistent_course(self): + self.register_get_thread_response( + make_minimal_cs_thread( + {"id": "test_thread", "course_id": "course-v1:non+existent+course"} + ) + ) + with pytest.raises(CourseNotFoundError): + create_comment(self.request, self.minimal_data) + + def test_not_enrolled(self): + self.request.user = UserFactory.create() + with pytest.raises(CourseNotFoundError): + create_comment(self.request, self.minimal_data) + + def test_discussions_disabled(self): + disabled_course = _discussion_disabled_course_for(self.user) + self.register_get_thread_response( + make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(disabled_course.id), + "commentable_id": "test_topic", + } + ) + ) + with pytest.raises(DiscussionDisabledError): + create_comment(self.request, self.minimal_data) + + @ddt.data( + *itertools.product( + [ + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_STUDENT, + ], + [True, False], + ["no_group", "match_group", "different_group"], + ) + ) + @ddt.unpack + def test_group_access(self, role_name, course_is_cohorted, thread_group_state): + cohort_course, cohort = _create_course_and_cohort_with_user_role( + course_is_cohorted, self.user, role_name + ) + self.register_get_thread_response( + make_minimal_cs_thread( + { + "id": "cohort_thread", + "course_id": str(cohort_course.id), + "group_id": ( + None + if thread_group_state == "no_group" + else ( + cohort.id + if thread_group_state == "match_group" + else cohort.id + 1 + ) + ), + } + ) + ) + self.register_post_comment_response( + {"username": self.user.username}, thread_id="cohort_thread" + ) + data = self.minimal_data.copy() + data["thread_id"] = "cohort_thread" + expected_error = ( + role_name == FORUM_ROLE_STUDENT + and course_is_cohorted + and thread_group_state == "different_group" + ) + try: + create_comment(self.request, data) + assert not expected_error + except ThreadNotFoundError: + assert expected_error + + def test_invalid_field(self): + data = self.minimal_data.copy() + del data["raw_body"] + with pytest.raises(ValidationError): + create_comment(self.request, data) + + +@ddt.ddt +@disable_signal(api, "thread_edited") +@disable_signal(api, "thread_voted") +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class UpdateThreadTest( + UrlResetMixin, + SharedModuleStoreTestCase, + MockSignalHandlerMixin, + ForumMockUtilsMixin, +): + """Tests for update_thread""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + cls.course = CourseFactory.create() + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def setUp(self): + super().setUp() + + httpretty.reset() + httpretty.enable() + self.addCleanup(httpretty.reset) + self.addCleanup(httpretty.disable) + self.user = UserFactory.create() + self.register_get_user_response(self.user) + self.request = RequestFactory().get("/test_path") + self.request.user = self.user + CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) + + def register_thread(self, overrides=None): + """ + Make a thread with appropriate data overridden by the overrides + parameter and register mock responses for both GET and PUT on its + endpoint. + """ + cs_data = make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "original_topic", + "username": self.user.username, + "user_id": str(self.user.id), + "thread_type": "discussion", + "title": "Original Title", + "body": "Original body", + } + ) + cs_data.update(overrides or {}) + self.register_get_thread_response(cs_data) + self.register_put_thread_response(cs_data) + + def create_user_with_request(self): + """ + Create a user and an associated request for a specific course enrollment. + """ + user = UserFactory.create() + self.register_get_user_response(user) + request = RequestFactory().get("/test_path") + request.user = user + CourseEnrollmentFactory.create(user=user, course_id=self.course.id) + return user, request + + def test_empty(self): + """Check that an empty update does not make any modifying requests.""" + # Ensure that the default following value of False is not applied implicitly + self.register_get_user_response( + self.user, subscribed_thread_ids=["test_thread"] + ) + self.register_thread() + update_thread(self.request, "test_thread", {}) + for request in httpretty.httpretty.latest_requests: + assert request.method == "GET" + + def test_basic(self): + self.register_thread() + with self.assert_signal_sent( + api, "thread_edited", sender=None, user=self.user, exclude_args=("post",) + ): + actual = update_thread( + self.request, "test_thread", {"raw_body": "Edited body"} + ) + + assert actual == self.expected_thread_data( + { + "raw_body": "Edited body", + "rendered_body": "

Edited body

", + "preview_body": "Edited body", + "topic_id": "original_topic", + "read": True, + "title": "Original Title", + } + ) + params = { + "thread_id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "original_topic", + "thread_type": "discussion", + "title": "Original Title", + "body": "Edited body", + "user_id": str(self.user.id), + "anonymous": False, + "anonymous_to_peers": False, + "closed": False, + "pinned": False, + "editing_user_id": str(self.user.id), + } + self.check_mock_called_with("update_thread", -1, **params) + + @ddt.data(*itertools.product([True, False], [True, False])) + @ddt.unpack + @mock.patch("eventtracking.tracker.emit") + def test_abuse_flagged(self, old_flagged, new_flagged, mock_emit): + """ + Test attempts to edit the "abuse_flagged" field. + + old_flagged indicates whether the thread should be flagged at the start + of the test. new_flagged indicates the value for the "abuse_flagged" + field in the update. If old_flagged and new_flagged are the same, no + update should be made. Otherwise, a PUT should be made to the flag or + or unflag endpoint according to the new_flagged value. + """ + self.register_get_user_response(self.user) + self.register_thread_flag_response("test_thread") + self.register_thread( + {"abuse_flaggers": [str(self.user.id)] if old_flagged else []} + ) + data = {"abuse_flagged": new_flagged} + result = update_thread(self.request, "test_thread", data) + assert result["abuse_flagged"] == new_flagged + + flag_func_calls = self.get_mock_func_calls("update_thread_flag") + last_function_args = flag_func_calls[-1] if flag_func_calls else None + + if old_flagged == new_flagged: + assert last_function_args is None + else: + assert last_function_args[1]["action"] == ( + "flag" if new_flagged else "unflag" + ) + params = { + "thread_id": "test_thread", + "action": "flag" if new_flagged else "unflag", + "user_id": "1", + "course_id": str(self.course.id), + } + if not new_flagged: + params["update_all"] = False + self.check_mock_called_with("update_thread_flag", -1, **params) + + expected_event_name = ( + "edx.forum.thread.reported" + if new_flagged + else "edx.forum.thread.unreported" + ) + expected_event_data = { + "body": "Original body", + "id": "test_thread", + "content_type": "Post", + "commentable_id": "original_topic", + "url": "", + "user_course_roles": [], + "user_forums_roles": [FORUM_ROLE_STUDENT], + "target_username": self.user.username, + "title_truncated": False, + "title": "Original Title", + "thread_type": "discussion", + "group_id": None, + "truncated": False, + } + if not new_flagged: + expected_event_data["reported_status_cleared"] = False + + actual_event_name, actual_event_data = mock_emit.call_args[0] + self.assertEqual(actual_event_name, expected_event_name) + self.assertEqual(actual_event_data, expected_event_data) + + @ddt.data( + (False, True), + (True, True), + ) + @ddt.unpack + @mock.patch("eventtracking.tracker.emit") + def test_thread_un_abuse_flag_for_moderator_role( + self, is_author, remove_all, mock_emit + ): + """ + Test un-abuse flag for moderator role. + + When moderator unflags a reported thread, it should + pass the "all" flag to the api. This will indicate + to the api to clear all abuse_flaggers, and mark the + thread as unreported. + """ + _assign_role_to_user( + user=self.user, course_id=self.course.id, role=FORUM_ROLE_ADMINISTRATOR + ) + self.register_get_user_response(self.user) + self.register_thread_flag_response("test_thread") + self.register_thread( + { + "abuse_flaggers": ["11"], + "user_id": str(self.user.id) if is_author else "12", + } + ) + data = {"abuse_flagged": False} + update_thread(self.request, "test_thread", data) + + params = { + "thread_id": "test_thread", + "action": "unflag", + "user_id": "1", + "update_all": bool(remove_all), + "course_id": str(self.course.id), + } + + self.check_mock_called_with("update_thread_flag", -1, **params) + + expected_event_name = "edx.forum.thread.unreported" + expected_event_data = { + "body": "Original body", + "id": "test_thread", + "content_type": "Post", + "commentable_id": "original_topic", + "url": "", + "user_course_roles": [], + "user_forums_roles": [FORUM_ROLE_STUDENT, FORUM_ROLE_ADMINISTRATOR], + "target_username": self.user.username, + "title_truncated": False, + "title": "Original Title", + "reported_status_cleared": False, + "thread_type": "discussion", + "group_id": None, + "truncated": False, + } + + actual_event_name, actual_event_data = mock_emit.call_args[0] + self.assertEqual(actual_event_name, expected_event_name) + self.assertEqual(actual_event_data, expected_event_data) + + @ddt.data(*itertools.product([True, False], [True, False])) + @ddt.unpack + @mock.patch("eventtracking.tracker.emit") + def test_voted(self, current_vote_status, new_vote_status, mock_emit): + """ + Test attempts to edit the "voted" field. + + current_vote_status indicates whether the thread should be upvoted at + the start of the test. new_vote_status indicates the value for the + "voted" field in the update. If current_vote_status and new_vote_status + are the same, no update should be made. Otherwise, a vote should be PUT + or DELETEd according to the new_vote_status value. + """ + # setup + user1, request1 = self.create_user_with_request() + if current_vote_status: + self.register_get_user_response(user1, upvoted_ids=["test_thread"]) + self.register_thread_votes_response("test_thread") + self.register_thread() + data = {"voted": new_vote_status} + result = update_thread(request1, "test_thread", data) + assert result["voted"] == new_vote_status + + vote_update_func_calls = self.get_mock_func_calls("update_thread_votes") + last_function_args = ( + vote_update_func_calls[-1] if vote_update_func_calls else None + ) + + if current_vote_status == new_vote_status: + assert last_function_args is None + else: + if vote_update_func_calls: + assert last_function_args[1]["value"] == ( + "up" if new_vote_status else "down" + ) + params = { + "thread_id": "test_thread", + "value": "up" if new_vote_status else "down", + "user_id": str(user1.id), + "course_id": str(self.course.id), + } + self.check_mock_called_with("update_thread_votes", -1, **params) + else: + params = { + "thread_id": "test_thread", + "user_id": str(user1.id), + "course_id": str(self.course.id), + } + self.check_mock_called_with("delete_thread_vote", -1, **params) + event_name, event_data = mock_emit.call_args[0] + assert event_name == "edx.forum.thread.voted" + assert event_data == { + "undo_vote": (not new_vote_status), + "url": "", + "target_username": self.user.username, + "vote_value": "up", + "user_forums_roles": [FORUM_ROLE_STUDENT], + "user_course_roles": [], + "commentable_id": "original_topic", + "id": "test_thread", + } + + @ddt.data(*itertools.product([True, False], [True, False], [True, False])) + @ddt.unpack + def test_vote_count(self, current_vote_status, first_vote, second_vote): + """ + Tests vote_count increases and decreases correctly from the same user + """ + # setup + starting_vote_count = 0 + user, request = self.create_user_with_request() + if current_vote_status: + self.register_get_user_response(user, upvoted_ids=["test_thread"]) + starting_vote_count = 1 + self.register_thread_votes_response("test_thread") + self.register_thread(overrides={"votes": {"up_count": starting_vote_count}}) + + # first vote + data = {"voted": first_vote} + result = update_thread(request, "test_thread", data) + self.register_thread(overrides={"voted": first_vote}) + assert result["vote_count"] == (1 if first_vote else 0) + + # second vote + # In the previous tests, where we mocked request objects, + # the mocked user API returned a user with upvoted_ids=[]. In our case, + # we have used register_get_user_response again to set upvoted_ids to None. + data = {"voted": second_vote} + self.register_get_user_response(user) + self.register_thread(overrides={"voted": False}) + result = update_thread(request, "test_thread", data) + assert result["vote_count"] == (1 if second_vote else 0) + + @ddt.data( + *itertools.product([True, False], [True, False], [True, False], [True, False]) + ) + @ddt.unpack + def test_vote_count_two_users( + self, current_user1_vote, current_user2_vote, user1_vote, user2_vote + ): + """ + Tests vote_count increases and decreases correctly from different users + """ + # setup + user1, request1 = self.create_user_with_request() + user2, request2 = self.create_user_with_request() + + vote_count = 0 + if current_user1_vote: + self.register_get_user_response(user1, upvoted_ids=["test_thread"]) + vote_count += 1 + if current_user2_vote: + self.register_get_user_response(user2, upvoted_ids=["test_thread"]) + vote_count += 1 + + for current_vote, user_vote, request in [ + (current_user1_vote, user1_vote, request1), + (current_user2_vote, user2_vote, request2), + ]: + + self.register_thread_votes_response("test_thread") + self.register_thread(overrides={"votes": {"up_count": vote_count}}) + + data = {"voted": user_vote} + result = update_thread(request, "test_thread", data) + if current_vote == user_vote: + assert result["vote_count"] == vote_count + elif user_vote: + vote_count += 1 + assert result["vote_count"] == vote_count + self.register_get_user_response(self.user, upvoted_ids=["test_thread"]) + else: + vote_count -= 1 + assert result["vote_count"] == vote_count + self.register_get_user_response(self.user, upvoted_ids=[]) + + @ddt.data(*itertools.product([True, False], [True, False])) + @ddt.unpack + @mock.patch("eventtracking.tracker.emit") + def test_following(self, old_following, new_following, mock_emit): + """ + Test attempts to edit the "following" field. + + old_following indicates whether the thread should be followed at the + start of the test. new_following indicates the value for the "following" + field in the update. If old_following and new_following are the same, no + update should be made. Otherwise, a subscription should be POSTed or + DELETEd according to the new_following value. + """ + if old_following: + self.register_get_user_response( + self.user, subscribed_thread_ids=["test_thread"] + ) + self.register_subscription_response(self.user) + self.register_thread() + data = {"following": new_following} + signal_name = "thread_followed" if new_following else "thread_unfollowed" + mock_path = ( + f"openedx.core.djangoapps.django_comment_common.signals.{signal_name}.send" + ) + with mock.patch(mock_path) as signal_patch: + result = update_thread(self.request, "test_thread", data) + if old_following != new_following: + self.assertEqual(signal_patch.call_count, 1) + assert result["following"] == new_following + + if old_following == new_following: + assert not self.check_mock_called("create_subscription") + else: + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "source_id": "test_thread", + } + if new_following: + assert self.check_mock_called("create_subscription") + else: + assert self.check_mock_called("delete_subscription") + + event_name, event_data = mock_emit.call_args[0] + expected_event_action = "followed" if new_following else "unfollowed" + assert event_name == f"edx.forum.thread.{expected_event_action}" + assert event_data["commentable_id"] == "original_topic" + assert event_data["id"] == "test_thread" + assert event_data["followed"] == new_following + assert event_data["user_forums_roles"] == ["Student"] + + def test_nonexistent_thread(self): + self.register_get_thread_error_response("test_thread", 404) + with pytest.raises(ThreadNotFoundError): + update_thread(self.request, "test_thread", {}) + + def test_nonexistent_course(self): + self.register_thread({"course_id": "course-v1:non+existent+course"}) + with pytest.raises(CourseNotFoundError): + update_thread(self.request, "test_thread", {}) + + def test_not_enrolled(self): + self.register_thread() + self.request.user = UserFactory.create() + with pytest.raises(CourseNotFoundError): + update_thread(self.request, "test_thread", {}) + + def test_discussions_disabled(self): + disabled_course = _discussion_disabled_course_for(self.user) + self.register_thread(overrides={"course_id": str(disabled_course.id)}) + with pytest.raises(DiscussionDisabledError): + update_thread(self.request, "test_thread", {}) + + @ddt.data( + *itertools.product( + [ + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_STUDENT, + ], + [True, False], + ["no_group", "match_group", "different_group"], + ) + ) + @ddt.unpack + def test_group_access(self, role_name, course_is_cohorted, thread_group_state): + cohort_course, cohort = _create_course_and_cohort_with_user_role( + course_is_cohorted, self.user, role_name + ) + self.register_thread( + { + "course_id": str(cohort_course.id), + "group_id": ( + None + if thread_group_state == "no_group" + else ( + cohort.id + if thread_group_state == "match_group" + else cohort.id + 1 + ) + ), + } + ) + expected_error = ( + role_name == FORUM_ROLE_STUDENT + and course_is_cohorted + and thread_group_state == "different_group" + ) + try: + update_thread(self.request, "test_thread", {}) + assert not expected_error + except ThreadNotFoundError: + assert expected_error + + @ddt.data( + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_STUDENT, + ) + def test_author_only_fields(self, role_name): + _assign_role_to_user(user=self.user, course_id=self.course.id, role=role_name) + self.register_thread({"user_id": str(self.user.id + 1)}) + data = {field: "edited" for field in ["topic_id", "title", "raw_body"]} + data["type"] = "question" + expected_error = role_name == FORUM_ROLE_STUDENT + try: + update_thread(self.request, "test_thread", data) + assert not expected_error + except ValidationError as err: + assert expected_error + assert err.message_dict == { + field: ["This field is not editable."] for field in data.keys() + } + + def test_invalid_field(self): + self.register_thread() + with pytest.raises(ValidationError) as assertion: + update_thread(self.request, "test_thread", {"raw_body": ""}) + assert assertion.value.message_dict == { + "raw_body": ["This field may not be blank."] + } + + @ddt.data( + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_STUDENT, + ) + @mock.patch( + "lms.djangoapps.discussion.rest_api.serializers.EDIT_REASON_CODES", + { + "test-edit-reason": "Test Edit Reason", + }, + ) + @mock.patch("eventtracking.tracker.emit") + def test_update_thread_with_edit_reason_code(self, role_name, mock_emit): + """ + Test editing comments, specifying and retrieving edit reason codes. + """ + _assign_role_to_user(user=self.user, course_id=self.course.id, role=role_name) + self.register_thread({"user_id": str(self.user.id + 1)}) + try: + result = update_thread( + self.request, + "test_thread", + { + "raw_body": "Edited body", + "edit_reason_code": "test-edit-reason", + }, + ) + assert role_name != FORUM_ROLE_STUDENT + assert result["last_edit"] == { + "original_body": "Original body", + "reason": "Test Edit Reason", + "reason_code": "test-edit-reason", + "author": self.user.username, + } + thread_call_args = self.get_mock_func_calls("update_thread")[0][1] + assert thread_call_args["edit_reason_code"] == "test-edit-reason" + + expected_event_name = "edx.forum.thread.edited" + expected_event_data = { + "id": "test_thread", + "content_type": "Post", + "own_content": False, + "url": "", + "user_course_roles": [], + "user_forums_roles": ["Student", role_name], + "target_username": self.user.username, + "edit_reason": "test-edit-reason", + "commentable_id": "original_topic", + } + + actual_event_name, actual_event_data = mock_emit.call_args[0] + self.assertEqual(actual_event_name, expected_event_name) + self.assertEqual(actual_event_data, expected_event_data) + + except ValidationError as error: + assert role_name == FORUM_ROLE_STUDENT + assert error.message_dict == { + "edit_reason_code": ["This field is not editable."], + "raw_body": ["This field is not editable."], + } + + @ddt.data( + *itertools.product( + [ + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_STUDENT, + ], + [True, False], + ) + ) + @ddt.unpack + @mock.patch( + "lms.djangoapps.discussion.rest_api.serializers.CLOSE_REASON_CODES", + { + "test-close-reason": "Test Close Reason", + }, + ) + @mock.patch("eventtracking.tracker.emit") + def test_update_thread_with_close_reason_code(self, role_name, closed, mock_emit): + """ + Test editing comments, specifying and retrieving edit reason codes. + """ + _assign_role_to_user(user=self.user, course_id=self.course.id, role=role_name) + self.register_thread() + try: + self.request.META["HTTP_REFERER"] = "https://example.com" + result = update_thread( + self.request, + "test_thread", + { + "closed": closed, + "close_reason_code": "test-close-reason", + }, + ) + + assert role_name != FORUM_ROLE_STUDENT + assert result["closed"] == closed + thread_call_args = self.get_mock_func_calls("update_thread")[0][1] + assert thread_call_args["close_reason_code"] == "test-close-reason" + assert thread_call_args["closing_user_id"] == str(self.user.id) + + expected_event_name = ( + f'edx.forum.thread.{"locked" if closed else "unlocked"}' + ) + expected_event_data = { + "id": "test_thread", + "team_id": None, + "url": self.request.META["HTTP_REFERER"], + "user_course_roles": [], + "user_forums_roles": ["Student", role_name], + "target_username": self.user.username, + "lock_reason": "test-close-reason", + "commentable_id": "original_topic", + } + + actual_event_name, actual_event_data = mock_emit.call_args[0] + self.assertEqual(actual_event_name, expected_event_name) + self.assertEqual(actual_event_data, expected_event_data) + except ValidationError as error: + assert role_name == FORUM_ROLE_STUDENT + assert error.message_dict == { + "closed": ["This field is not editable."], + "close_reason_code": ["This field is not editable."], + } + + +@ddt.ddt +@disable_signal(api, "comment_edited") +@disable_signal(api, "comment_voted") +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class UpdateCommentTest( + UrlResetMixin, + SharedModuleStoreTestCase, + MockSignalHandlerMixin, + ForumMockUtilsMixin, +): + """Tests for update_comment""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + cls.course = CourseFactory.create() + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def setUp(self): + super().setUp() + + httpretty.reset() + httpretty.enable() + self.addCleanup(httpretty.reset) + self.addCleanup(httpretty.disable) + self.user = UserFactory.create() + self.register_get_user_response(self.user) + self.request = RequestFactory().get("/test_path") + self.request.user = self.user + CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) + + def register_comment(self, overrides=None, thread_overrides=None, course=None): + """ + Make a comment with appropriate data overridden by the overrides + parameter and register mock responses for both GET and PUT on its + endpoint. Also mock GET for the related thread with thread_overrides. + """ + if course is None: + course = self.course + + cs_thread_data = make_minimal_cs_thread( + {"id": "test_thread", "course_id": str(course.id)} + ) + cs_thread_data.update(thread_overrides or {}) + self.register_get_thread_response(cs_thread_data) + cs_comment_data = make_minimal_cs_comment( + { + "id": "test_comment", + "course_id": cs_thread_data["course_id"], + "thread_id": cs_thread_data["id"], + "username": self.user.username, + "user_id": str(self.user.id), + "created_at": "2015-06-03T00:00:00Z", + "updated_at": "2015-06-03T00:00:00Z", + "body": "Original body", + } + ) + cs_comment_data.update(overrides or {}) + self.register_get_comment_response(cs_comment_data) + self.register_put_comment_response(cs_comment_data) + + def create_user_with_request(self): + """ + Create a user and an associated request for a specific course enrollment. + """ + user = UserFactory.create() + self.register_get_user_response(user) + request = RequestFactory().get("/test_path") + request.user = user + CourseEnrollmentFactory.create(user=user, course_id=self.course.id) + return user, request + + def test_empty(self): + """Check that an empty update does not make any modifying requests.""" + self.register_comment() + update_comment(self.request, "test_comment", {}) + for request in httpretty.httpretty.latest_requests: + assert request.method == "GET" + + @ddt.data(None, "test_parent") + def test_basic(self, parent_id): + self.register_comment({"parent_id": parent_id}) + with self.assert_signal_sent( + api, "comment_edited", sender=None, user=self.user, exclude_args=("post",) + ): + actual = update_comment( + self.request, "test_comment", {"raw_body": "Edited body"} + ) + expected = { + "anonymous": False, + "anonymous_to_peers": False, + "id": "test_comment", + "thread_id": "test_thread", + "parent_id": parent_id, + "author": self.user.username, + "author_label": None, + "created_at": "2015-06-03T00:00:00Z", + "updated_at": "2015-06-03T00:00:00Z", + "raw_body": "Edited body", + "rendered_body": "

Edited body

", + "endorsed": False, + "endorsed_by": None, + "endorsed_by_label": None, + "endorsed_at": None, + "abuse_flagged": False, + "abuse_flagged_any_user": None, + "voted": False, + "vote_count": 0, + "children": [], + "editable_fields": ["abuse_flagged", "anonymous", "raw_body"], + "child_count": 0, + "can_delete": True, + "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 actual == expected + params = { + "comment_id": "test_comment", + "body": "Edited body", + "course_id": str(self.course.id), + "user_id": str(self.user.id), + "anonymous": False, + "anonymous_to_peers": False, + "endorsed": False, + "editing_user_id": str(self.user.id), + } + self.check_mock_called_with("update_comment", -1, **params) + + @ddt.data(*itertools.product([True, False], [True, False])) + @ddt.unpack + @mock.patch("eventtracking.tracker.emit") + def test_abuse_flagged(self, old_flagged, new_flagged, mock_emit): + """ + Test attempts to edit the "abuse_flagged" field. + + old_flagged indicates whether the comment should be flagged at the start + of the test. new_flagged indicates the value for the "abuse_flagged" + field in the update. If old_flagged and new_flagged are the same, no + update should be made. Otherwise, a PUT should be made to the flag or + or unflag endpoint according to the new_flagged value. + """ + self.register_get_user_response(self.user) + self.register_comment_flag_response("test_comment") + self.register_comment( + {"abuse_flaggers": [str(self.user.id)] if old_flagged else []} + ) + data = {"abuse_flagged": new_flagged} + result = update_comment(self.request, "test_comment", data) + assert result["abuse_flagged"] == new_flagged + flag_func_calls = self.get_mock_func_calls("update_comment_flag") + last_function_args = flag_func_calls[-1] if flag_func_calls else None + + if old_flagged == new_flagged: + assert last_function_args is None + else: + assert last_function_args[1]["action"] == ( + "flag" if new_flagged else "unflag" + ) + params = { + "comment_id": "test_comment", + "action": "flag" if new_flagged else "unflag", + "user_id": "1", + "course_id": str(self.course.id), + } + if not new_flagged: + params["update_all"] = False + self.check_mock_called_with("update_comment_flag", -1, **params) + + expected_event_name = ( + "edx.forum.response.reported" + if new_flagged + else "edx.forum.response.unreported" + ) + expected_event_data = { + "discussion": {'id': 'test_thread'}, + "body": "Original body", + "id": "test_comment", + "content_type": "Response", + "commentable_id": "dummy", + "url": "", + "truncated": False, + "user_course_roles": [], + "user_forums_roles": [FORUM_ROLE_STUDENT], + "target_username": self.user.username, + } + if not new_flagged: + expected_event_data["reported_status_cleared"] = False + + actual_event_name, actual_event_data = mock_emit.call_args[0] + self.assertEqual(actual_event_name, expected_event_name) + self.assertEqual(actual_event_data, expected_event_data) + + @ddt.data( + (False, True), + (True, True), + ) + @ddt.unpack + @mock.patch("eventtracking.tracker.emit") + def test_comment_un_abuse_flag_for_moderator_role( + self, is_author, remove_all, mock_emit + ): + """ + Test un-abuse flag for moderator role. + + When moderator unflags a reported comment, it should + pass the "all" flag to the api. This will indicate + to the api to clear all abuse_flaggers, and mark the + comment as unreported. + """ + _assign_role_to_user( + user=self.user, course_id=self.course.id, role=FORUM_ROLE_ADMINISTRATOR + ) + self.register_get_user_response(self.user) + self.register_comment_flag_response("test_comment") + self.register_comment( + { + "abuse_flaggers": ["11"], + "user_id": str(self.user.id) if is_author else "12", + } + ) + data = {"abuse_flagged": False} + update_comment(self.request, "test_comment", data) + + params = { + "comment_id": "test_comment", + "action": "unflag", + "user_id": "1", + "update_all": bool(remove_all), + "course_id": str(self.course.id), + } + self.check_mock_called_with("update_comment_flag", -1, **params) + + expected_event_name = "edx.forum.response.unreported" + expected_event_data = { + "body": "Original body", + "id": "test_comment", + "content_type": "Response", + "discussion": {'id': 'test_thread'}, + "commentable_id": "dummy", + "truncated": False, + "url": "", + "user_course_roles": [], + "user_forums_roles": [FORUM_ROLE_STUDENT, FORUM_ROLE_ADMINISTRATOR], + "target_username": self.user.username, + "reported_status_cleared": False, + } + + actual_event_name, actual_event_data = mock_emit.call_args[0] + self.assertEqual(actual_event_name, expected_event_name) + self.assertEqual(actual_event_data, expected_event_data) + + @ddt.data(*itertools.product([True, False], [True, False])) + @ddt.unpack + @mock.patch("eventtracking.tracker.emit") + def test_voted(self, current_vote_status, new_vote_status, mock_emit): + """ + Test attempts to edit the "voted" field. + + current_vote_status indicates whether the comment should be upvoted at + the start of the test. new_vote_status indicates the value for the + "voted" field in the update. If current_vote_status and new_vote_status + are the same, no update should be made. Otherwise, a vote should be PUT + or DELETEd according to the new_vote_status value. + """ + vote_count = 0 + user1, request1 = self.create_user_with_request() + if current_vote_status: + self.register_get_user_response(user1, upvoted_ids=["test_comment"]) + vote_count = 1 + self.register_comment_votes_response("test_comment") + self.register_comment(overrides={"votes": {"up_count": vote_count}}) + data = {"voted": new_vote_status} + result = update_comment(request1, "test_comment", data) + assert result["vote_count"] == (1 if new_vote_status else 0) + assert result["voted"] == new_vote_status + vote_update_func_calls = self.get_mock_func_calls("update_comment_votes") + last_function_args = ( + vote_update_func_calls[-1] if vote_update_func_calls else None + ) + if current_vote_status == new_vote_status: + assert last_function_args is None + else: + + if vote_update_func_calls: + assert last_function_args[1]["value"] == ( + "up" if new_vote_status else "down" + ) + params = { + "comment_id": "test_comment", + "value": "up" if new_vote_status else "down", + "user_id": str(user1.id), + "course_id": str(self.course.id), + } + self.check_mock_called_with("update_comment_votes", -1, **params) + else: + params = { + "comment_id": "test_comment", + "user_id": str(user1.id), + "course_id": str(self.course.id), + } + self.check_mock_called_with("delete_comment_vote", -1, **params) + + event_name, event_data = mock_emit.call_args[0] + assert event_name == "edx.forum.response.voted" + + assert event_data == { + "undo_vote": (not new_vote_status), + "url": "", + "target_username": self.user.username, + "vote_value": "up", + "user_forums_roles": [FORUM_ROLE_STUDENT], + "user_course_roles": [], + "commentable_id": "dummy", + "id": "test_comment", + } + + @ddt.data(*itertools.product([True, False], [True, False], [True, False])) + @ddt.unpack + def test_vote_count(self, current_vote_status, first_vote, second_vote): + """ + Tests vote_count increases and decreases correctly from the same user + """ + # setup + starting_vote_count = 0 + user1, request1 = self.create_user_with_request() + if current_vote_status: + self.register_get_user_response(user1, upvoted_ids=["test_comment"]) + starting_vote_count = 1 + self.register_comment_votes_response("test_comment") + self.register_comment(overrides={"votes": {"up_count": starting_vote_count}}) + + # first vote + data = {"voted": first_vote} + result = update_comment(request1, "test_comment", data) + self.register_comment(overrides={"voted": first_vote}) + assert result["vote_count"] == (1 if first_vote else 0) + + # second vote + # In the previous tests, where we mocked request objects, + # the mocked user API returned a user with upvoted_ids=[]. In our case, + # we have used register_get_user_response again to set upvoted_ids to None. + data = {"voted": second_vote} + self.register_get_user_response(user1) + result = update_comment(request1, "test_comment", data) + assert result["vote_count"] == (1 if second_vote else 0) + + # TODO: Refactor test logic to avoid complex conditionals and in-test logic. + # Aim for simpler, more explicit test cases, even if it means more code, + # to reduce the risk of introducing logic bugs within the tests themselves. + @ddt.data( + *itertools.product([True, False], [True, False], [True, False], [True, False]) + ) + @ddt.unpack + def test_vote_count_two_users( + self, current_user1_vote, current_user2_vote, user1_vote, user2_vote + ): + """ + Tests vote_count increases and decreases correctly from different users + """ + user1, request1 = self.create_user_with_request() + user2, request2 = self.create_user_with_request() + + vote_count = 0 + if current_user1_vote: + self.register_get_user_response(user1, upvoted_ids=["test_comment"]) + vote_count += 1 + if current_user2_vote: + self.register_get_user_response(user2, upvoted_ids=["test_comment"]) + vote_count += 1 + + for current_vote, user_vote, request in [ + (current_user1_vote, user1_vote, request1), + (current_user2_vote, user2_vote, request2), + ]: + + self.register_comment_votes_response("test_comment") + self.register_comment(overrides={"votes": {"up_count": vote_count}}) + + data = {"voted": user_vote} + result = update_comment(request, "test_comment", data) + if current_vote == user_vote: + assert result["vote_count"] == vote_count + elif user_vote: + vote_count += 1 + assert result["vote_count"] == vote_count + self.register_get_user_response(self.user, upvoted_ids=["test_comment"]) + else: + vote_count -= 1 + assert result["vote_count"] == vote_count + self.register_get_user_response(self.user, upvoted_ids=[]) + + def test_nonexistent_comment(self): + self.register_get_comment_error_response("test_comment", 404) + with pytest.raises(CommentNotFoundError): + update_comment(self.request, "test_comment", {}) + + def test_nonexistent_course(self): + self.register_comment( + thread_overrides={"course_id": "course-v1:non+existent+course"} + ) + with pytest.raises(CourseNotFoundError): + update_comment(self.request, "test_comment", {}) + + def test_unenrolled(self): + self.register_comment() + self.request.user = UserFactory.create() + with pytest.raises(CourseNotFoundError): + update_comment(self.request, "test_comment", {}) + + def test_discussions_disabled(self): + self.register_comment(course=_discussion_disabled_course_for(self.user)) + with pytest.raises(DiscussionDisabledError): + update_comment(self.request, "test_comment", {}) + + @ddt.data( + *itertools.product( + [ + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_STUDENT, + ], + [True, False], + ["no_group", "match_group", "different_group"], + ) + ) + @ddt.unpack + def test_group_access(self, role_name, course_is_cohorted, thread_group_state): + cohort_course, cohort = _create_course_and_cohort_with_user_role( + course_is_cohorted, self.user, role_name + ) + self.register_get_thread_response(make_minimal_cs_thread()) + self.register_comment( + {"thread_id": "test_thread"}, + thread_overrides={ + "id": "test_thread", + "course_id": str(cohort_course.id), + "group_id": ( + None + if thread_group_state == "no_group" + else ( + cohort.id + if thread_group_state == "match_group" + else cohort.id + 1 + ) + ), + }, + ) + expected_error = ( + role_name == FORUM_ROLE_STUDENT + and course_is_cohorted + and thread_group_state == "different_group" + ) + try: + update_comment(self.request, "test_comment", {}) + assert not expected_error + except ThreadNotFoundError: + assert expected_error + + @ddt.data( + *itertools.product( + [ + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_STUDENT, + ], + [True, False], + [True, False], + ) + ) + @ddt.unpack + def test_raw_body_access(self, role_name, is_thread_author, is_comment_author): + _assign_role_to_user(user=self.user, course_id=self.course.id, role=role_name) + self.register_comment( + {"user_id": str(self.user.id if is_comment_author else (self.user.id + 1))}, + thread_overrides={ + "user_id": str(self.user.id if is_thread_author else (self.user.id + 1)) + }, + ) + expected_error = role_name == FORUM_ROLE_STUDENT and not is_comment_author + try: + update_comment(self.request, "test_comment", {"raw_body": "edited"}) + assert not expected_error + except ValidationError as err: + assert expected_error + assert err.message_dict == {"raw_body": ["This field is not editable."]} + + @ddt.data( + *itertools.product( + [ + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_STUDENT, + ], + [True, False], + ["question", "discussion"], + [True, False], + ) + ) + @ddt.unpack + @mock.patch( + "openedx.core.djangoapps.django_comment_common.signals.comment_endorsed.send" + ) + def test_endorsed_access( + self, role_name, is_thread_author, thread_type, is_comment_author, endorsed_mock + ): + _assign_role_to_user(user=self.user, course_id=self.course.id, role=role_name) + self.register_comment( + {"user_id": str(self.user.id if is_comment_author else (self.user.id + 1))}, + thread_overrides={ + "thread_type": thread_type, + "user_id": str( + self.user.id if is_thread_author else (self.user.id + 1) + ), + }, + ) + expected_error = role_name == FORUM_ROLE_STUDENT and ( + thread_type == "discussion" or not is_thread_author + ) + try: + update_comment(self.request, "test_comment", {"endorsed": True}) + self.assertEqual(endorsed_mock.call_count, 1) + assert not expected_error + except ValidationError as err: + assert expected_error + assert err.message_dict == {"endorsed": ["This field is not editable."]} + + @ddt.data( + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_STUDENT, + ) + @mock.patch( + "lms.djangoapps.discussion.rest_api.serializers.EDIT_REASON_CODES", + { + "test-edit-reason": "Test Edit Reason", + }, + ) + @mock.patch("eventtracking.tracker.emit") + def test_update_comment_with_edit_reason_code(self, role_name, mock_emit): + """ + Test editing comments, specifying and retrieving edit reason codes. + """ + _assign_role_to_user(user=self.user, course_id=self.course.id, role=role_name) + self.register_comment({"user_id": str(self.user.id + 1)}) + try: + result = update_comment( + self.request, + "test_comment", + { + "raw_body": "Edited body", + "edit_reason_code": "test-edit-reason", + }, + ) + assert role_name != FORUM_ROLE_STUDENT + assert result["last_edit"] == { + "original_body": "Original body", + "reason": "Test Edit Reason", + "reason_code": "test-edit-reason", + "author": self.user.username, + } + comment_call_args = self.get_mock_func_calls("update_comment")[0][1] + assert comment_call_args["edit_reason_code"] == "test-edit-reason" + + expected_event_name = "edx.forum.response.edited" + expected_event_data = { + "id": "test_comment", + "content_type": "Response", + "own_content": False, + "url": "", + "user_course_roles": [], + "user_forums_roles": ["Student", role_name], + "target_username": self.user.username, + "edit_reason": "test-edit-reason", + "commentable_id": "dummy", + } + + actual_event_name, actual_event_data = mock_emit.call_args[0] + self.assertEqual(actual_event_name, expected_event_name) + self.assertEqual(actual_event_data, expected_event_data) + + except ValidationError: + assert role_name == FORUM_ROLE_STUDENT + + +@ddt.ddt +@disable_signal(api, "thread_deleted") +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class DeleteThreadTest( + UrlResetMixin, + SharedModuleStoreTestCase, + MockSignalHandlerMixin, + ForumMockUtilsMixin, +): + """Tests for delete_thread""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + cls.course = CourseFactory.create() + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def setUp(self): + super().setUp() + httpretty.reset() + httpretty.enable() + self.addCleanup(httpretty.reset) + self.addCleanup(httpretty.disable) + self.user = UserFactory.create() + self.register_get_user_response(self.user) + self.request = RequestFactory().get("/test_path") + self.request.user = self.user + self.thread_id = "test_thread" + CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) + + def register_thread(self, overrides=None): + """ + Make a thread with appropriate data overridden by the overrides + parameter and register mock responses for both GET and DELETE on its + endpoint. + """ + cs_data = make_minimal_cs_thread( + { + "id": self.thread_id, + "course_id": str(self.course.id), + "user_id": str(self.user.id), + } + ) + cs_data.update(overrides or {}) + self.register_get_thread_response(cs_data) + self.register_delete_thread_response(cs_data["id"]) + + @mock.patch("eventtracking.tracker.emit") + def test_basic(self, mock_emit): + self.register_thread() + with self.assert_signal_sent( + api, "thread_deleted", sender=None, user=self.user, exclude_args=("post",) + ): + assert delete_thread(self.request, self.thread_id) is None + self.check_mock_called("delete_thread") + params = { + "thread_id": self.thread_id, + "course_id": str(self.course.id), + } + self.check_mock_called_with("delete_thread", -1, **params) + + expected_event_name = "edx.forum.thread.deleted" + expected_event_data = { + "body": "dummy", + "content_type": "Post", + "own_content": True, + "commentable_id": "dummy", + "target_username": "dummy", + "title_truncated": False, + "title": "dummy", + "id": "test_thread", + "url": "", + "user_forums_roles": ["Student"], + "user_course_roles": [], + } + + actual_event_name, actual_event_data = mock_emit.call_args[0] + self.assertEqual(actual_event_name, expected_event_name) + self.assertEqual(actual_event_data, expected_event_data) + + def test_thread_id_not_found(self): + self.register_get_thread_error_response("missing_thread", 404) + with pytest.raises(ThreadNotFoundError): + delete_thread(self.request, "missing_thread") + + def test_nonexistent_course(self): + self.register_thread({"course_id": "course-v1:non+existent+course"}) + with pytest.raises(CourseNotFoundError): + delete_thread(self.request, self.thread_id) + + def test_not_enrolled(self): + self.register_thread() + self.request.user = UserFactory.create() + with pytest.raises(CourseNotFoundError): + delete_thread(self.request, self.thread_id) + + def test_discussions_disabled(self): + disabled_course = _discussion_disabled_course_for(self.user) + self.register_thread(overrides={"course_id": str(disabled_course.id)}) + with pytest.raises(DiscussionDisabledError): + delete_thread(self.request, self.thread_id) + + @ddt.data( + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_STUDENT, + ) + def test_non_author_delete_allowed(self, role_name): + _assign_role_to_user(user=self.user, course_id=self.course.id, role=role_name) + self.register_thread({"user_id": str(self.user.id + 1)}) + expected_error = role_name == FORUM_ROLE_STUDENT + try: + delete_thread(self.request, self.thread_id) + assert not expected_error + except PermissionDenied: + assert expected_error + + @ddt.data( + *itertools.product( + [ + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_STUDENT, + ], + [True, False], + ["no_group", "match_group", "different_group"], + ) + ) + @ddt.unpack + def test_group_access(self, role_name, course_is_cohorted, thread_group_state): + """ + Tests group access for deleting a thread + + All privileged roles are able to delete a thread. A student role can + only delete a thread if, + the student role is the author and the thread is not in a cohort, + the student role is the author and the thread is in the author's cohort. + """ + cohort_course, cohort = _create_course_and_cohort_with_user_role( + course_is_cohorted, self.user, role_name + ) + self.register_thread( + { + "course_id": str(cohort_course.id), + "group_id": ( + None + if thread_group_state == "no_group" + else ( + cohort.id + if thread_group_state == "match_group" + else cohort.id + 1 + ) + ), + } + ) + expected_error = ( + role_name == FORUM_ROLE_STUDENT + and course_is_cohorted + and thread_group_state == "different_group" + ) + try: + delete_thread(self.request, self.thread_id) + assert not expected_error + except ThreadNotFoundError: + assert expected_error + + +@ddt.ddt +@disable_signal(api, "comment_deleted") +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class DeleteCommentTest( + UrlResetMixin, + SharedModuleStoreTestCase, + MockSignalHandlerMixin, + ForumMockUtilsMixin, +): + """Tests for delete_comment""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + cls.course = CourseFactory.create() + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def setUp(self): + super().setUp() + httpretty.reset() + httpretty.enable() + self.addCleanup(httpretty.reset) + self.addCleanup(httpretty.disable) + self.user = UserFactory.create() + self.register_get_user_response(self.user) + self.request = RequestFactory().get("/test_path") + self.request.user = self.user + self.thread_id = "test_thread" + self.comment_id = "test_comment" + CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) + + def register_comment_and_thread(self, overrides=None, thread_overrides=None): + """ + Make a comment with appropriate data overridden by the override + parameters and register mock responses for both GET and DELETE on its + endpoint. Also mock GET for the related thread with thread_overrides. + """ + cs_thread_data = make_minimal_cs_thread( + {"id": self.thread_id, "course_id": str(self.course.id)} + ) + cs_thread_data.update(thread_overrides or {}) + self.register_get_thread_response(cs_thread_data) + cs_comment_data = make_minimal_cs_comment( + { + "id": self.comment_id, + "course_id": cs_thread_data["course_id"], + "thread_id": cs_thread_data["id"], + "username": self.user.username, + "user_id": str(self.user.id), + } + ) + cs_comment_data.update(overrides or {}) + self.register_get_comment_response(cs_comment_data) + self.register_delete_comment_response(self.comment_id) + + @mock.patch("eventtracking.tracker.emit") + def test_basic(self, mock_emit): + self.register_comment_and_thread() + with self.assert_signal_sent( + api, "comment_deleted", sender=None, user=self.user, exclude_args=("post",) + ): + assert delete_comment(self.request, self.comment_id) is None + self.check_mock_called("delete_comment") + params = { + "comment_id": self.comment_id, + "course_id": str(self.course.id), + } + self.check_mock_called_with("delete_comment", -1, **params) + + expected_event_name = "edx.forum.response.deleted" + expected_event_data = { + "body": "dummy", + "content_type": "Response", + "own_content": True, + "commentable_id": "dummy", + "target_username": self.user.username, + "id": "test_comment", + "url": "", + "user_forums_roles": ["Student"], + "user_course_roles": [], + } + + actual_event_name, actual_event_data = mock_emit.call_args[0] + self.assertEqual(actual_event_name, expected_event_name) + self.assertEqual(actual_event_data, expected_event_data) + + def test_comment_id_not_found(self): + self.register_get_comment_error_response("missing_comment", 404) + with pytest.raises(CommentNotFoundError): + delete_comment(self.request, "missing_comment") + + def test_nonexistent_course(self): + self.register_comment_and_thread( + thread_overrides={"course_id": "course-v1:non+existent+course"} + ) + with pytest.raises(CourseNotFoundError): + delete_comment(self.request, self.comment_id) + + def test_not_enrolled(self): + self.register_comment_and_thread() + self.request.user = UserFactory.create() + with pytest.raises(CourseNotFoundError): + delete_comment(self.request, self.comment_id) + + def test_discussions_disabled(self): + disabled_course = _discussion_disabled_course_for(self.user) + self.register_comment_and_thread( + thread_overrides={"course_id": str(disabled_course.id)}, + overrides={"course_id": str(disabled_course.id)}, + ) + with pytest.raises(DiscussionDisabledError): + delete_comment(self.request, self.comment_id) + + @ddt.data( + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_STUDENT, + ) + def test_non_author_delete_allowed(self, role_name): + _assign_role_to_user(user=self.user, course_id=self.course.id, role=role_name) + self.register_comment_and_thread(overrides={"user_id": str(self.user.id + 1)}) + expected_error = role_name == FORUM_ROLE_STUDENT + try: + delete_comment(self.request, self.comment_id) + assert not expected_error + except PermissionDenied: + assert expected_error + + @ddt.data( + *itertools.product( + [ + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_STUDENT, + ], + [True, False], + ["no_group", "match_group", "different_group"], + ) + ) + @ddt.unpack + def test_group_access(self, role_name, course_is_cohorted, thread_group_state): + """ + Tests group access for deleting a comment + + All privileged roles are able to delete a comment. A student role can + only delete a comment if, + the student role is the author and the comment is not in a cohort, + the student role is the author and the comment is in the author's cohort. + """ + cohort_course, cohort = _create_course_and_cohort_with_user_role( + course_is_cohorted, self.user, role_name + ) + self.register_comment_and_thread( + overrides={"thread_id": "test_thread"}, + thread_overrides={ + "course_id": str(cohort_course.id), + "group_id": ( + None + if thread_group_state == "no_group" + else ( + cohort.id + if thread_group_state == "match_group" + else cohort.id + 1 + ) + ), + }, + ) + expected_error = ( + role_name == FORUM_ROLE_STUDENT + and course_is_cohorted + and thread_group_state == "different_group" + ) + try: + delete_comment(self.request, self.comment_id) + assert not expected_error + except ThreadNotFoundError: + assert expected_error + + +@ddt.ddt +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class RetrieveThreadTest( + UrlResetMixin, + SharedModuleStoreTestCase, + ForumMockUtilsMixin, +): + """Tests for get_thread""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + cls.course = CourseFactory.create() + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def setUp(self): + super().setUp() + httpretty.reset() + httpretty.enable() + self.addCleanup(httpretty.reset) + self.addCleanup(httpretty.disable) + self.user = UserFactory.create() + self.register_get_user_response(self.user) + self.request = RequestFactory().get("/test_path") + self.request.user = self.user + self.thread_id = "test_thread" + CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) + + def register_thread(self, overrides=None): + """ + Make a thread with appropriate data overridden by the overrides + parameter and register mock responses for GET on its + endpoint. + """ + cs_data = make_minimal_cs_thread( + { + "id": self.thread_id, + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "username": self.user.username, + "user_id": str(self.user.id), + "title": "Test Title", + "body": "Test body", + "resp_total": 0, + } + ) + cs_data.update(overrides or {}) + self.register_get_thread_response(cs_data) + + def test_basic(self): + self.register_thread({"resp_total": 2}) + assert get_thread(self.request, self.thread_id) == self.expected_thread_data( + {"response_count": 2, "unread_comment_count": 1} + ) + self.check_mock_called("get_thread") + + def test_thread_id_not_found(self): + self.register_get_thread_error_response("missing_thread", 404) + with pytest.raises(ThreadNotFoundError): + get_thread(self.request, "missing_thread") + + def test_nonauthor_enrolled_in_course(self): + non_author_user = UserFactory.create() + self.register_get_user_response(non_author_user) + CourseEnrollmentFactory.create(user=non_author_user, course_id=self.course.id) + self.register_thread() + self.request.user = non_author_user + assert get_thread(self.request, self.thread_id) == self.expected_thread_data( + { + "can_delete": False, + "editable_fields": [ + "abuse_flagged", + "copy_link", + "following", + "read", + "voted", + ], + "unread_comment_count": 1, + } + ) + self.check_mock_called("get_thread") + + def test_not_enrolled_in_course(self): + self.register_thread() + self.request.user = UserFactory.create() + with pytest.raises(CourseNotFoundError): + get_thread(self.request, self.thread_id) + + @ddt.data( + *itertools.product( + [ + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_STUDENT, + ], + [True, False], + ["no_group", "match_group", "different_group"], + ) + ) + @ddt.unpack + def test_group_access(self, role_name, course_is_cohorted, thread_group_state): + """ + Tests group access for retrieving a thread + + All privileged roles are able to retrieve a thread. A student role can + only retrieve a thread if, + the student role is the author and the thread is not in a cohort, + the student role is the author and the thread is in the author's cohort. + """ + cohort_course, cohort = _create_course_and_cohort_with_user_role( + course_is_cohorted, self.user, role_name + ) + self.register_thread( + { + "course_id": str(cohort_course.id), + "group_id": ( + None + if thread_group_state == "no_group" + else ( + cohort.id + if thread_group_state == "match_group" + else cohort.id + 1 + ) + ), + } + ) + expected_error = ( + role_name == FORUM_ROLE_STUDENT + and course_is_cohorted + and thread_group_state == "different_group" + ) + try: + get_thread(self.request, self.thread_id) + assert not expected_error + except ThreadNotFoundError: + assert expected_error + + def test_course_id_mismatch(self): + """ + Test if the api throws not found exception if course_id from params mismatches course_id in thread + """ + self.register_thread() + get_thread(self.request, self.thread_id, "different_course_id") + assert ThreadNotFoundError + + +@ddt.ddt +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class GetThreadListTest( + ForumMockUtilsMixin, UrlResetMixin, SharedModuleStoreTestCase +): + """Test for get_thread_list""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + cls.course = CourseFactory.create() + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def setUp(self): + super().setUp() + httpretty.reset() + httpretty.enable() + self.addCleanup(httpretty.reset) + self.addCleanup(httpretty.disable) + self.maxDiff = None # pylint: disable=invalid-name + self.user = UserFactory.create() + self.register_get_user_response(self.user) + self.request = RequestFactory().get("/test_path") + self.request.user = self.user + CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) + self.author = UserFactory.create() + self.course.cohort_config = {"cohorted": False} + modulestore().update_item(self.course, ModuleStoreEnum.UserID.test) + self.cohort = CohortFactory.create(course_id=self.course.id) + + def get_thread_list( + self, + threads, + page=1, + page_size=1, + num_pages=1, + course=None, + topic_id_list=None, + ): + """ + Register the appropriate comments service response, then call + get_thread_list and return the result. + """ + course = course or self.course + self.register_get_threads_response(threads, page, num_pages) + ret = get_thread_list(self.request, course.id, page, page_size, topic_id_list) + return ret + + def test_nonexistent_course(self): + with pytest.raises(CourseNotFoundError): + get_thread_list( + self.request, + CourseLocator.from_string("course-v1:non+existent+course"), + 1, + 1, + ) + + def test_not_enrolled(self): + self.request.user = UserFactory.create() + with pytest.raises(CourseNotFoundError): + self.get_thread_list([]) + + def test_discussions_disabled(self): + with pytest.raises(DiscussionDisabledError): + self.get_thread_list([], course=_discussion_disabled_course_for(self.user)) + + def test_empty(self): + assert self.get_thread_list([], num_pages=0).data == { + "pagination": {"next": None, "previous": None, "num_pages": 0, "count": 0}, + "results": [], + "text_search_rewrite": None, + } + + def test_get_threads_by_topic_id(self): + self.get_thread_list([], topic_id_list=["topic_x", "topic_meow"]) + self.check_mock_called("get_user_threads") + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 1, + "commentable_ids": ["topic_x", "topic_meow"], + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_basic_query_params(self): + self.get_thread_list([], page=6, page_size=14) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 6, + "per_page": 14, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_thread_content(self): + self.course.cohort_config = {"cohorted": True} + modulestore().update_item(self.course, ModuleStoreEnum.UserID.test) + source_threads = [ + make_minimal_cs_thread( + { + "id": "test_thread_id_0", + "course_id": str(self.course.id), + "commentable_id": "topic_x", + "username": self.author.username, + "user_id": str(self.author.id), + "title": "Test Title", + "body": "Test body", + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + "endorsed": True, + "read": True, + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + } + ), + make_minimal_cs_thread( + { + "id": "test_thread_id_1", + "course_id": str(self.course.id), + "commentable_id": "topic_y", + "group_id": self.cohort.id, + "username": self.author.username, + "user_id": str(self.author.id), + "thread_type": "question", + "title": "Another Test Title", + "body": "More content", + "votes": {"up_count": 9}, + "comments_count": 18, + "created_at": "2015-04-28T22:22:22Z", + "updated_at": "2015-04-28T00:33:33Z", + } + ), + ] + expected_threads = [ + self.expected_thread_data( + { + "id": "test_thread_id_0", + "author": self.author.username, + "topic_id": "topic_x", + "vote_count": 4, + "comment_count": 6, + "unread_comment_count": 3, + "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_0", + "editable_fields": [ + "abuse_flagged", + "copy_link", + "following", + "read", + "voted", + ], + "has_endorsed": True, + "read": True, + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + "abuse_flagged_count": None, + "can_delete": False, + } + ), + self.expected_thread_data( + { + "id": "test_thread_id_1", + "author": self.author.username, + "topic_id": "topic_y", + "group_id": self.cohort.id, + "group_name": self.cohort.name, + "type": "question", + "title": "Another Test Title", + "raw_body": "More content", + "preview_body": "More content", + "rendered_body": "

More content

", + "vote_count": 9, + "comment_count": 19, + "created_at": "2015-04-28T22:22:22Z", + "updated_at": "2015-04-28T00:33:33Z", + "comment_list_url": None, + "endorsed_comment_list_url": ( + "http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_1&endorsed=True" + ), + "non_endorsed_comment_list_url": ( + "http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_1&endorsed=False" + ), + "editable_fields": [ + "abuse_flagged", + "copy_link", + "following", + "read", + "voted", + ], + "abuse_flagged_count": None, + "can_delete": False, + } + ), + ] + + expected_result = make_paginated_api_response( + results=expected_threads, + count=2, + num_pages=1, + next_link=None, + previous_link=None, + ) + expected_result.update({"text_search_rewrite": None}) + assert self.get_thread_list(source_threads).data == expected_result + + @ddt.data( + *itertools.product( + [ + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_STUDENT, + ], + [True, False], + ) + ) + @ddt.unpack + def test_request_group(self, role_name, course_is_cohorted): + cohort_course = CourseFactory.create( + cohort_config={"cohorted": course_is_cohorted} + ) + CourseEnrollmentFactory.create(user=self.user, course_id=cohort_course.id) + CohortFactory.create(course_id=cohort_course.id, users=[self.user]) + _assign_role_to_user(user=self.user, course_id=cohort_course.id, role=role_name) + self.get_thread_list([], course=cohort_course) + thread_func_params = self.get_mock_func_calls("get_user_threads")[-1][1] + actual_has_group = "group_id" in thread_func_params + expected_has_group = ( + course_is_cohorted and role_name in ( + FORUM_ROLE_STUDENT, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_GROUP_MODERATOR + ) + ) + assert actual_has_group == expected_has_group + + def test_pagination(self): + # N.B. Empty thread list is not realistic but convenient for this test + expected_result = make_paginated_api_response( + results=[], + count=0, + num_pages=3, + next_link="http://testserver/test_path?page=2", + previous_link=None, + ) + expected_result.update({"text_search_rewrite": None}) + assert self.get_thread_list([], page=1, num_pages=3).data == expected_result + + expected_result = make_paginated_api_response( + results=[], + count=0, + num_pages=3, + next_link="http://testserver/test_path?page=3", + previous_link="http://testserver/test_path?page=1", + ) + expected_result.update({"text_search_rewrite": None}) + assert self.get_thread_list([], page=2, num_pages=3).data == expected_result + + expected_result = make_paginated_api_response( + results=[], + count=0, + num_pages=3, + next_link=None, + previous_link="http://testserver/test_path?page=2", + ) + expected_result.update({"text_search_rewrite": None}) + assert self.get_thread_list([], page=3, num_pages=3).data == expected_result + + # Test page past the last one + self.register_get_threads_response([], page=3, num_pages=3) + with pytest.raises(PageNotFoundError): + get_thread_list(self.request, self.course.id, page=4, page_size=10) + + @ddt.data(None, "rewritten search string") + def test_text_search(self, text_search_rewrite): + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": text_search_rewrite}) + self.register_get_threads_search_response([], text_search_rewrite, num_pages=0) + assert ( + get_thread_list( + self.request, + self.course.id, + page=1, + page_size=10, + text_search="test search string", + ).data + == expected_result + ) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + "text": "test search string", + } + self.check_mock_called_with( + "search_threads", + -1, + **params, + ) + + def test_filter_threads_by_author(self): + thread = make_minimal_cs_thread() + self.register_get_threads_response([thread], page=1, num_pages=10) + thread_results = get_thread_list( + self.request, + self.course.id, + page=1, + page_size=10, + author=self.user.username, + ).data.get("results") + assert len(thread_results) == 1 + + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + "author_id": str(self.user.id), + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_filter_threads_by_missing_author(self): + self.register_get_threads_response( + [make_minimal_cs_thread()], page=1, num_pages=10 + ) + results = get_thread_list( + self.request, + self.course.id, + page=1, + page_size=10, + author="a fake and missing username", + ).data.get("results") + assert len(results) == 0 + + @ddt.data("question", "discussion", None) + def test_thread_type(self, thread_type): + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + + self.register_get_threads_response([], page=1, num_pages=0) + assert ( + get_thread_list( + self.request, + self.course.id, + page=1, + page_size=10, + thread_type=thread_type, + ).data + == expected_result + ) + + expected_last_query_params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + "thread_type": thread_type, + } + + if thread_type is None: + del expected_last_query_params["thread_type"] + + self.check_mock_called_with( + "get_user_threads", + -1, + **expected_last_query_params, + ) + + @ddt.data(True, False, None) + def test_flagged(self, flagged_boolean): + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + + self.register_get_threads_response([], page=1, num_pages=0) + assert ( + get_thread_list( + self.request, + self.course.id, + page=1, + page_size=10, + flagged=flagged_boolean, + ).data + == expected_result + ) + + expected_last_query_params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + "flagged": flagged_boolean, + } + + if flagged_boolean is None: + del expected_last_query_params["flagged"] + + self.check_mock_called_with( + "get_user_threads", + -1, + **expected_last_query_params, + ) + + @ddt.data( + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + ) + def test_flagged_count(self, role): + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + + _assign_role_to_user(self.user, self.course.id, role=role) + + self.register_get_threads_response([], page=1, num_pages=0) + get_thread_list( + self.request, + self.course.id, + page=1, + page_size=10, + count_flagged=True, + ) + + expected_last_query_params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "count_flagged": True, + "page": 1, + "per_page": 10, + } + + self.check_mock_called_with( + "get_user_threads", -1, **expected_last_query_params + ) + + def test_flagged_count_denied(self): + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + + _assign_role_to_user(self.user, self.course.id, role=FORUM_ROLE_STUDENT) + + self.register_get_threads_response([], page=1, num_pages=0) + + with pytest.raises(PermissionDenied): + get_thread_list( + self.request, + self.course.id, + page=1, + page_size=10, + count_flagged=True, + ) + + def test_following(self): + self.register_subscribed_threads_response(self.user, [], page=1, num_pages=1) + result = get_thread_list( + self.request, + self.course.id, + page=1, + page_size=11, + following=True, + ).data + + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=1, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + assert result == expected_result + self.check_mock_called("get_user_subscriptions") + + params = { + "course_id": str(self.course.id), + "user_id": str(self.user.id), + "sort_key": "activity", + "page": 1, + "per_page": 11, + } + self.check_mock_called_with("get_user_subscriptions", -1, **params) + + @ddt.data("unanswered", "unread") + def test_view_query(self, query): + self.register_get_threads_response([], page=1, num_pages=0) + result = get_thread_list( + self.request, + self.course.id, + page=1, + page_size=11, + view=query, + ).data + + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + assert result == expected_result + self.check_mock_called("get_user_threads") + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 11, + query: True, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + @ddt.data( + ("last_activity_at", "activity"), + ("comment_count", "comments"), + ("vote_count", "votes"), + ) + @ddt.unpack + def test_order_by_query(self, http_query, cc_query): + """ + Tests the order_by parameter + + Arguments: + http_query (str): Query string sent in the http request + cc_query (str): Query string used for the comments client service + """ + self.register_get_threads_response([], page=1, num_pages=0) + result = get_thread_list( + self.request, + self.course.id, + page=1, + page_size=11, + order_by=http_query, + ).data + + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + assert result == expected_result + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": cc_query, + "page": 1, + "per_page": 11, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_order_direction(self): + """ + Only "desc" is supported for order. Also, since it is simply swallowed, + it isn't included in the params. + """ + self.register_get_threads_response([], page=1, num_pages=0) + result = get_thread_list( + self.request, + self.course.id, + page=1, + page_size=11, + order_direction="desc", + ).data + + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + assert result == expected_result + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 11, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_invalid_order_direction(self): + """ + Test with invalid order_direction (e.g. "asc") + """ + with pytest.raises(ValidationError) as assertion: + self.register_get_threads_response([], page=1, num_pages=0) + get_thread_list( # pylint: disable=expression-not-assigned + self.request, + self.course.id, + page=1, + page_size=11, + order_direction="asc", + ).data + assert "order_direction" in assertion.value.message_dict + + +@ddt.ddt +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class GetCommentListTest( + SharedModuleStoreTestCase, ForumMockUtilsMixin +): + """Test for get_comment_list""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + cls.course = CourseFactory.create() + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def setUp(self): + super().setUp() + httpretty.reset() + httpretty.enable() + self.addCleanup(httpretty.reset) + self.addCleanup(httpretty.disable) + self.maxDiff = None # pylint: disable=invalid-name + self.user = UserFactory.create() + self.register_get_user_response(self.user) + self.request = RequestFactory().get("/test_path") + self.request.user = self.user + CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) + self.author = UserFactory.create() + self.set_mock_return_value("get_course_id_by_thread", str(self.course.id)) + + def make_minimal_cs_thread(self, overrides=None): + """ + Create a thread with the given overrides, plus the course_id if not + already in overrides. + """ + overrides = overrides.copy() if overrides else {} + overrides.setdefault("course_id", str(self.course.id)) + return make_minimal_cs_thread(overrides) + + def get_comment_list( + self, + thread, + endorsed=None, + page=1, + page_size=1, + merge_question_type_responses=False, + ): + """ + Register the appropriate comments service response, then call + get_comment_list and return the result. + """ + self.register_get_thread_response(thread) + return get_comment_list( + self.request, + thread["id"], + endorsed, + page, + page_size, + merge_question_type_responses=merge_question_type_responses, + ) + + def test_nonexistent_thread(self): + thread_id = "nonexistent_thread" + self.register_get_thread_error_response(thread_id, 404) + with pytest.raises(ThreadNotFoundError): + get_comment_list( + self.request, thread_id, endorsed=False, page=1, page_size=1 + ) + + def test_nonexistent_course(self): + with pytest.raises(CourseNotFoundError): + self.get_comment_list( + self.make_minimal_cs_thread( + {"course_id": "course-v1:non+existent+course"} + ) + ) + + def test_not_enrolled(self): + self.request.user = UserFactory.create() + with pytest.raises(CourseNotFoundError): + self.get_comment_list(self.make_minimal_cs_thread()) + + def test_discussions_disabled(self): + disabled_course = _discussion_disabled_course_for(self.user) + with pytest.raises(DiscussionDisabledError): + self.get_comment_list( + self.make_minimal_cs_thread( + overrides={"course_id": str(disabled_course.id)} + ) + ) + + @ddt.data( + *itertools.product( + [ + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_STUDENT, + ], + [True, False], + [True, False], + ["no_group", "match_group", "different_group"], + ) + ) + @ddt.unpack + def test_group_access( + self, role_name, course_is_cohorted, topic_is_cohorted, thread_group_state + ): + cohort_course = CourseFactory.create( + discussion_topics={"Test Topic": {"id": "test_topic"}}, + cohort_config={ + "cohorted": course_is_cohorted, + "cohorted_discussions": ["test_topic"] if topic_is_cohorted else [], + }, + ) + CourseEnrollmentFactory.create(user=self.user, course_id=cohort_course.id) + cohort = CohortFactory.create(course_id=cohort_course.id, users=[self.user]) + _assign_role_to_user(user=self.user, course_id=cohort_course.id, role=role_name) + thread = self.make_minimal_cs_thread( + { + "course_id": str(cohort_course.id), + "commentable_id": "test_topic", + "group_id": ( + None + if thread_group_state == "no_group" + else ( + cohort.id + if thread_group_state == "match_group" + else cohort.id + 1 + ) + ), + } + ) + expected_error = ( + role_name == FORUM_ROLE_STUDENT + and course_is_cohorted + and topic_is_cohorted + and thread_group_state == "different_group" + ) + try: + self.get_comment_list(thread) + assert not expected_error + except ThreadNotFoundError: + assert expected_error + + @ddt.data(True, False) + def test_discussion_endorsed(self, endorsed_value): + with pytest.raises(ValidationError) as assertion: + self.get_comment_list( + self.make_minimal_cs_thread({"thread_type": "discussion"}), + endorsed=endorsed_value, + ) + assert assertion.value.message_dict == { + "endorsed": ["This field may not be specified for discussion threads."] + } + + def test_question_without_endorsed(self): + with pytest.raises(ValidationError) as assertion: + self.get_comment_list( + self.make_minimal_cs_thread({"thread_type": "question"}), endorsed=None + ) + assert assertion.value.message_dict == { + "endorsed": ["This field is required for question threads."] + } + + def test_empty(self): + discussion_thread = self.make_minimal_cs_thread( + {"thread_type": "discussion", "children": [], "resp_total": 0} + ) + assert self.get_comment_list( + discussion_thread + ).data == make_paginated_api_response( + results=[], count=0, num_pages=1, next_link=None, previous_link=None + ) + + question_thread = self.make_minimal_cs_thread( + { + "thread_type": "question", + "endorsed_responses": [], + "non_endorsed_responses": [], + "non_endorsed_resp_total": 0, + } + ) + assert self.get_comment_list( + question_thread, endorsed=False + ).data == make_paginated_api_response( + results=[], count=0, num_pages=1, next_link=None, previous_link=None + ) + assert self.get_comment_list( + question_thread, endorsed=True + ).data == make_paginated_api_response( + results=[], count=0, num_pages=1, next_link=None, previous_link=None + ) + + def test_basic_query_params(self): + self.get_comment_list( + self.make_minimal_cs_thread( + { + "children": [ + make_minimal_cs_comment({"username": self.user.username}) + ], + "resp_total": 71, + } + ), + page=6, + page_size=14, + ) + params = { + "thread_id": "dummy", + "params": { + "user_id": str(self.user.id), + "mark_as_read": False, + "recursive": False, + "resp_skip": 70, + "resp_limit": 14, + "with_responses": True, + "reverse_order": False, + "merge_question_type_responses": False, + }, + "course_id": str(self.course.id), + } + self.check_mock_called_with("get_thread", -1, **params) + + def get_source_and_expected_comments(self): + """ + Returns the source comments and expected comments for testing purposes. + """ + source_comments = [ + { + "type": "comment", + "id": "test_comment_1", + "thread_id": "test_thread", + "user_id": str(self.author.id), + "username": self.author.username, + "anonymous": False, + "anonymous_to_peers": False, + "created_at": "2015-05-11T00:00:00Z", + "updated_at": "2015-05-11T11:11:11Z", + "body": "Test body", + "endorsed": True, + "abuse_flaggers": [], + "votes": {"up_count": 4}, + "child_count": 0, + "children": [], + }, + { + "type": "comment", + "id": "test_comment_2", + "thread_id": "test_thread", + "user_id": str(self.author.id), + "username": self.author.username, + "anonymous": True, + "anonymous_to_peers": False, + "created_at": "2015-05-11T22:22:22Z", + "updated_at": "2015-05-11T33:33:33Z", + "body": "More content", + "endorsed": False, + "abuse_flaggers": [str(self.user.id)], + "votes": {"up_count": 7}, + "child_count": 0, + "children": [], + }, + ] + expected_comments = [ + { + "id": "test_comment_1", + "thread_id": "test_thread", + "parent_id": None, + "author": self.author.username, + "author_label": None, + "created_at": "2015-05-11T00:00:00Z", + "updated_at": "2015-05-11T11:11:11Z", + "raw_body": "Test body", + "rendered_body": "

Test body

", + "endorsed": True, + "endorsed_by": None, + "endorsed_by_label": None, + "endorsed_at": None, + "abuse_flagged": False, + "abuse_flagged_any_user": None, + "voted": False, + "vote_count": 4, + "editable_fields": ["abuse_flagged", "voted"], + "child_count": 0, + "children": [], + "can_delete": False, + "anonymous": False, + "anonymous_to_peers": False, + "last_edit": None, + "edit_by_label": None, + "profile_image": { + "has_image": False, + "image_url_full": "http://testserver/static/default_500.png", + "image_url_large": "http://testserver/static/default_120.png", + "image_url_medium": "http://testserver/static/default_50.png", + "image_url_small": "http://testserver/static/default_30.png", + }, + }, + { + "id": "test_comment_2", + "thread_id": "test_thread", + "parent_id": None, + "author": None, + "author_label": None, + "created_at": "2015-05-11T22:22:22Z", + "updated_at": "2015-05-11T33:33:33Z", + "raw_body": "More content", + "rendered_body": "

More content

", + "endorsed": False, + "endorsed_by": None, + "endorsed_by_label": None, + "endorsed_at": None, + "abuse_flagged": True, + "abuse_flagged_any_user": None, + "voted": False, + "vote_count": 7, + "editable_fields": ["abuse_flagged", "voted"], + "child_count": 0, + "children": [], + "can_delete": False, + "anonymous": True, + "anonymous_to_peers": False, + "last_edit": None, + "edit_by_label": None, + "profile_image": { + "has_image": False, + "image_url_full": "http://testserver/static/default_500.png", + "image_url_large": "http://testserver/static/default_120.png", + "image_url_medium": "http://testserver/static/default_50.png", + "image_url_small": "http://testserver/static/default_30.png", + }, + }, + ] + return source_comments, expected_comments + + def test_discussion_content(self): + source_comments, expected_comments = self.get_source_and_expected_comments() + actual_comments = self.get_comment_list( + self.make_minimal_cs_thread({"children": source_comments}) + ).data["results"] + assert actual_comments == expected_comments + + def test_question_content_with_merge_question_type_responses(self): + source_comments, expected_comments = self.get_source_and_expected_comments() + actual_comments = self.get_comment_list( + self.make_minimal_cs_thread( + { + "thread_type": "question", + "children": source_comments, + "resp_total": len(source_comments), + } + ), + merge_question_type_responses=True, + ).data["results"] + assert actual_comments == expected_comments + + def test_question_content_(self): + thread = self.make_minimal_cs_thread( + { + "thread_type": "question", + "endorsed_responses": [ + make_minimal_cs_comment( + {"id": "endorsed_comment", "username": self.user.username} + ) + ], + "non_endorsed_responses": [ + make_minimal_cs_comment( + {"id": "non_endorsed_comment", "username": self.user.username} + ) + ], + "non_endorsed_resp_total": 1, + } + ) + + endorsed_actual = self.get_comment_list(thread, endorsed=True).data + assert endorsed_actual["results"][0]["id"] == "endorsed_comment" + + non_endorsed_actual = self.get_comment_list(thread, endorsed=False).data + assert non_endorsed_actual["results"][0]["id"] == "non_endorsed_comment" + + def test_endorsed_by_anonymity(self): + """ + Ensure thread anonymity is properly considered in serializing + endorsed_by. + """ + thread = self.make_minimal_cs_thread( + { + "anonymous": True, + "children": [ + make_minimal_cs_comment( + { + "username": self.user.username, + "endorsement": { + "user_id": str(self.author.id), + "time": "2015-05-18T12:34:56Z", + }, + } + ) + ], + } + ) + actual_comments = self.get_comment_list(thread).data["results"] + assert actual_comments[0]["endorsed_by"] is None + + @ddt.data( + ("discussion", None, "children", "resp_total", False), + ("question", False, "non_endorsed_responses", "non_endorsed_resp_total", False), + ("question", None, "children", "resp_total", True), + ) + @ddt.unpack + def test_cs_pagination( + self, + thread_type, + endorsed_arg, + response_field, + response_total_field, + merge_question_type_responses, + ): + """ + Test cases in which pagination is done by the comments service. + + thread_type is the type of thread (question or discussion). + endorsed_arg is the value of the endorsed argument. + repsonse_field is the field in which responses are returned for the + given thread type. + response_total_field is the field in which the total number of responses + is returned for the given thread type. + """ + # N.B. The mismatch between the number of children and the listed total + # number of responses is unrealistic but convenient for this test + thread = self.make_minimal_cs_thread( + { + "thread_type": thread_type, + response_field: [ + make_minimal_cs_comment({"username": self.user.username}) + ], + response_total_field: 5, + } + ) + + # Only page + actual = self.get_comment_list( + thread, + endorsed=endorsed_arg, + page=1, + page_size=5, + merge_question_type_responses=merge_question_type_responses, + ).data + assert actual["pagination"]["next"] is None + assert actual["pagination"]["previous"] is None + + # First page of many + actual = self.get_comment_list( + thread, + endorsed=endorsed_arg, + page=1, + page_size=2, + merge_question_type_responses=merge_question_type_responses, + ).data + assert actual["pagination"]["next"] == "http://testserver/test_path?page=2" + assert actual["pagination"]["previous"] is None + + # Middle page of many + actual = self.get_comment_list( + thread, + endorsed=endorsed_arg, + page=2, + page_size=2, + merge_question_type_responses=merge_question_type_responses, + ).data + assert actual["pagination"]["next"] == "http://testserver/test_path?page=3" + assert actual["pagination"]["previous"] == "http://testserver/test_path?page=1" + + # Last page of many + actual = self.get_comment_list( + thread, + endorsed=endorsed_arg, + page=3, + page_size=2, + merge_question_type_responses=merge_question_type_responses, + ).data + assert actual["pagination"]["next"] is None + assert actual["pagination"]["previous"] == "http://testserver/test_path?page=2" + + # Page past the end + thread = self.make_minimal_cs_thread( + {"thread_type": thread_type, response_field: [], response_total_field: 5} + ) + with pytest.raises(PageNotFoundError): + self.get_comment_list( + thread, + endorsed=endorsed_arg, + page=2, + page_size=5, + merge_question_type_responses=merge_question_type_responses, + ) + + def test_question_endorsed_pagination(self): + thread = self.make_minimal_cs_thread( + { + "thread_type": "question", + "endorsed_responses": [ + make_minimal_cs_comment( + {"id": f"comment_{i}", "username": self.user.username} + ) + for i in range(10) + ], + } + ) + + def assert_page_correct( + page, page_size, expected_start, expected_stop, expected_next, expected_prev + ): + """ + Check that requesting the given page/page_size returns the expected + output + """ + actual = self.get_comment_list( + thread, endorsed=True, page=page, page_size=page_size + ).data + result_ids = [result["id"] for result in actual["results"]] + assert result_ids == [ + f"comment_{i}" for i in range(expected_start, expected_stop) + ] + assert actual["pagination"]["next"] == ( + f"http://testserver/test_path?page={expected_next}" + if expected_next + else None + ) + assert actual["pagination"]["previous"] == ( + f"http://testserver/test_path?page={expected_prev}" + if expected_prev + else None + ) + + # Only page + assert_page_correct( + page=1, + page_size=10, + expected_start=0, + expected_stop=10, + expected_next=None, + expected_prev=None, + ) + + # First page of many + assert_page_correct( + page=1, + page_size=4, + expected_start=0, + expected_stop=4, + expected_next=2, + expected_prev=None, + ) + + # Middle page of many + assert_page_correct( + page=2, + page_size=4, + expected_start=4, + expected_stop=8, + expected_next=3, + expected_prev=1, + ) + + # Last page of many + assert_page_correct( + page=3, + page_size=4, + expected_start=8, + expected_stop=10, + expected_next=None, + expected_prev=2, + ) + + # Page past the end + with pytest.raises(PageNotFoundError): + self.get_comment_list(thread, endorsed=True, page=2, page_size=10) + + +@mock.patch("lms.djangoapps.discussion.rest_api.api._get_course", mock.Mock()) +class CourseTopicsV2Test(ModuleStoreTestCase): + """ + Tests for discussions topic API v2 code. + """ + + def setUp(self) -> None: + super().setUp() + self.course = CourseFactory.create( + discussion_topics={ + f"Course Wide Topic {idx}": {"id": f"course-wide-topic-{idx}"} + for idx in range(10) + } + ) + 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=f"vertical-{idx}", + start=datetime(2015, 4, 1, tzinfo=UTC), + ) + for idx in range(10) + ] + staff_only_unit = BlockFactory.create( + parent_location=self.sequential.location, + category="vertical", + display_name="staff-vertical-1", + metadata=dict(visible_to_staff_only=True), + ) + self.course_key = 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(self.course_key)) + self.staff_only_id = ( + DiscussionTopicLink.objects.filter(usage_key__in=[staff_only_unit.location]) + .values_list( + "external_id", + flat=True, + ) + .get() + ) + 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")) + topic_ids.remove(self.staff_only_id) + topic_ids_by_name = list(topic_id_query.order_by("title")) + topic_ids_by_name.remove(self.staff_only_id) + self.deleted_topic_ids = deleted_topic_ids = [ + f"disabled-topic-{idx}" for idx in range(10) + ] + for idx, topic_id in enumerate(deleted_topic_ids): + usage_key = course_key.make_usage_key("vertical", topic_id) + topic_links.append( + DiscussionTopicLink( + context_key=course_key, + usage_key=usage_key, + title=f"Discussion on {topic_id}", + external_id=topic_id, + provider_id=Provider.OPEN_EDX, + ordering=idx, + enabled_in_context=False, + ) + ) + DiscussionTopicLink.objects.bulk_create(topic_links) + self.topic_ids = topic_ids + self.topic_ids_by_name = topic_ids_by_name + self.user = UserFactory.create() + self.staff = AdminFactory.create() + self.all_topic_ids = ( + set(topic_ids) | set(deleted_topic_ids) | {self.staff_only_id} + ) + # Set up topic stats for all topics, but have one deleted topic + # and one active topic return zero stats for testing. + self.topic_stats = { + **{ + topic_id: dict( + discussion=random.randint(0, 10), question=random.randint(0, 10) + ) + for topic_id in self.all_topic_ids + }, + deleted_topic_ids[0]: dict(discussion=0, question=0), + self.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) + + def test_default_response(self): + """ + Test that the standard response contains the correct number of items + """ + topics_list = get_course_topics_v2(course_key=self.course_key, user=self.user) + assert {t["id"] for t in topics_list} == set(self.topic_ids) + + def test_filtering(self): + """ + Tests that filtering by topic id works + """ + filter_ids = set(random.sample(self.topic_ids, 4)) + topics_list = get_course_topics_v2( + course_key=self.course_key, user=self.user, topic_ids=filter_ids + ) + assert len(topics_list) == 4 + # All the filtered ids should be returned + assert filter_ids == set(topic_data.get("id") for topic_data in topics_list) + + def test_sort_by_name(self): + """ + Test sorting by name + """ + topics_list = get_course_topics_v2( + course_key=self.course_key, + user=self.user, + order_by=TopicOrdering.NAME, + ) + returned_topic_ids = [topic_data.get("id") for topic_data in topics_list] + assert returned_topic_ids == self.topic_ids_by_name + + def test_sort_by_structure(self): + """ + Test sorting by course structure + """ + topics_list = get_course_topics_v2( + course_key=self.course_key, + user=self.user, + order_by=TopicOrdering.COURSE_STRUCTURE, + ) + returned_topic_ids = [topic_data.get("id") for topic_data in topics_list] + # The topics are already sorted in their simulated course order + sorted_topic_ids = self.topic_ids + assert returned_topic_ids == sorted_topic_ids + + def test_sort_by_activity(self): + """ + Test sorting by activity + """ + topics_list = get_course_topics_v2( + course_key=self.course_key, + user=self.user, + order_by=TopicOrdering.ACTIVITY, + ) + returned_topic_ids = [topic_data.get("id") for topic_data in topics_list] + # The topics are already sorted in their simulated course order + sorted_topic_ids = sorted( + self.topic_ids, + key=lambda tid: sum(self.topic_stats.get(tid, {}).values()), + reverse=True, + ) + assert returned_topic_ids == sorted_topic_ids + + def test_other_providers_ordering_error(self): + """ + Test that activity sorting raises an error for other providers + """ + self.config.provider_type = "other" + self.config.save() + with pytest.raises(ValidationError): + get_course_topics_v2( + course_key=self.course_key, + 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, 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(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(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_v2.py similarity index 80% rename from lms/djangoapps/discussion/rest_api/tests/test_serializers.py rename to lms/djangoapps/discussion/rest_api/tests/test_serializers_v2.py index 73b195e02fa6..e45e66280ce2 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_serializers.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_serializers_v2.py @@ -1,10 +1,10 @@ +# pylint: disable=unused-import """ Tests for Discussion API serializers """ import itertools from unittest import mock -from urllib.parse import urlparse import ddt import httpretty @@ -16,13 +16,11 @@ from common.djangoapps.student.tests.factories import UserFactory from common.djangoapps.util.testing import UrlResetMixin -from lms.djangoapps.discussion.django_comment_client.tests.utils import ForumsEnableMixin from lms.djangoapps.discussion.rest_api.serializers import CommentSerializer, ThreadSerializer, get_context from lms.djangoapps.discussion.rest_api.tests.utils import ( - CommentsServiceMockMixin, + ForumMockUtilsMixin, 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 @@ -37,17 +35,20 @@ @ddt.ddt -class SerializerTestMixin(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMixin): - """ - Test Mixin for Serializer tests - """ +class CommentSerializerDeserializationTest(ForumMockUtilsMixin, 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() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() httpretty.reset() @@ -55,341 +56,622 @@ def setUp(self): 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 + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" ) - patcher.start() + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() self.addCleanup(patcher.stop) - self.maxDiff = None # pylint: disable=invalid-name self.user = UserFactory.create() self.register_get_user_response(self.user) 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) + 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), + })) - @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): + def save_and_reserialize(self, data, instance=None): """ - 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. + Create a serializer with the given data, ensure that it is valid, save + the result, and return the full comment data from the serializer. """ - self.create_role(role_name, [self.user]) - serialized = self.serialize( - self.make_cs_content({"anonymous": anonymous, "anonymous_to_peers": anonymous_to_peers}) + context = get_context( + self.course, + self.request, + make_minimal_cs_thread({"course_id": str(self.course.id)}) ) - actual_serialized_anonymous = serialized["author"] is None - assert actual_serialized_anonymous == expected_serialized_anonymous + serializer = CommentSerializer( + instance, + data=data, + partial=(instance is not None), + context=context + ) + assert serializer.is_valid() + serializer.save() + return serializer.data - @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. + @ddt.data(None, "test_parent") + def test_create_success(self, parent_id): + data = self.minimal_data.copy() + if parent_id: + data["parent_id"] = parent_id + self.register_get_comment_response({"thread_id": "test_thread", "id": parent_id}) + self.register_post_comment_response( + {"id": "test_comment", "username": self.user.username}, + thread_id="test_thread", + parent_id=parent_id + ) + saved = self.save_and_reserialize(data) + expected_url = ( + f"/api/v1/comments/{parent_id}" if parent_id else + "/api/v1/threads/test_thread/comments" + ) + self.check_mock_called("create_parent_comment") + params = { + 'course_id': str(self.course.id), + 'body': 'Test body', + 'user_id': str(self.user.id), + 'anonymous': False, + 'anonymous_to_peers': False, + 'thread_id': 'test_thread', + } + if not data: + self.check_mock_called_with( + "create_parent_comment", + 0, + **params, + ) + assert saved['id'] == 'test_comment' + assert saved['parent_id'] == parent_id - 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. + def test_create_all_fields(self): + data = self.minimal_data.copy() + data["parent_id"] = "test_parent" + data["endorsed"] = True + self.register_get_comment_response({"thread_id": "test_thread", "id": "test_parent"}) + self.register_post_comment_response( + {"id": "test_comment", "username": self.user.username}, + thread_id="test_thread", + parent_id="test_parent" + ) + self.save_and_reserialize(data) + params = { + 'course_id': str(self.course.id), + 'body': 'Test body', + 'user_id': str(self.user.id), + 'endorsed': True, + 'anonymous': False, + 'anonymous_to_peers': False, + 'parent_comment_id': 'test_parent', + } + self.check_mock_called("create_parent_comment") + self.check_mock_called_with( + "create_child_comment", + 0, + **params + ) - 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_update_all(self): + cs_response_data = self.existing_comment.attributes.copy() + cs_response_data["endorsement"] = { + "user_id": str(self.user.id), + "time": "2015-06-05T00:00:00Z", + } + self.register_put_comment_response(cs_response_data) + data = {"raw_body": "Edited body", "endorsed": True} + self.register_get_thread_response( + make_minimal_cs_thread({ + "id": "dummy", + "course_id": str(self.course.id), + }) + ) + saved = self.save_and_reserialize(data, instance=self.existing_comment) - def test_abuse_flagged(self): - serialized = self.serialize(self.make_cs_content({"abuse_flaggers": [str(self.user.id)]})) - assert serialized['abuse_flagged'] is True + params = { + 'body': 'Edited body', + 'course_id': str(self.course.id), + 'user_id': str(self.user.id), + 'anonymous': False, + 'anonymous_to_peers': 'False', + 'endorsed': 'True', + 'endorsement_user_id': str(self.user.id), + 'editing_user_id': str(self.user.id), + } + self.check_mock_called("update_comment") + for key in data: + assert saved[key] == data[key] + assert saved['endorsed_by'] == self.user.username + assert saved['endorsed_at'] == '2015-06-05T00:00:00Z' - 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.data("", " ") + def test_update_empty_raw_body(self, value): + serializer = CommentSerializer( + self.existing_comment, + data={"raw_body": value}, + partial=True, + context=get_context(self.course, self.request) + ) + assert not serializer.is_valid() + assert serializer.errors == {'raw_body': ['This field may not be blank.']} + def test_create_parent_id_nonexistent(self): + self.register_get_comment_error_response("bad_parent", 404) + data = self.minimal_data.copy() + data["parent_id"] = "bad_parent" + context = get_context(self.course, self.request, make_minimal_cs_thread()) + serializer = CommentSerializer(data=data, context=context) + assert not serializer.is_valid() + assert serializer.errors == { + 'non_field_errors': ['parent_id does not identify a comment in the thread identified by thread_id.'] + } -@ddt.ddt -class ThreadSerializerSerializationTest(SerializerTestMixin, SharedModuleStoreTestCase): - """Tests for ThreadSerializer serialization.""" + def test_create_parent_id_wrong_thread(self): + self.register_get_comment_response({"thread_id": "different_thread", "id": "test_parent"}) + data = self.minimal_data.copy() + data["parent_id"] = "test_parent" + context = get_context(self.course, self.request, make_minimal_cs_thread()) + serializer = CommentSerializer(data=data, context=context) + assert not serializer.is_valid() + assert serializer.errors == { + 'non_field_errors': ['parent_id does not identify a comment in the thread identified by thread_id.'] + } - def make_cs_content(self, overrides): + def test_create_anonymous(self): """ - Create a thread with the given overrides, plus some useful test data. + Test that serializer correctly deserializes the anonymous field when + creating a new comment. """ - 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) + self.register_post_comment_response({"username": self.user.username}, thread_id="test_thread") + data = self.minimal_data.copy() + data["anonymous"] = True + self.save_and_reserialize(data) + call_args = self.get_mock_func_calls("create_parent_comment")[0] + args, kwargs = call_args + assert kwargs['anonymous'] - def serialize(self, thread): + def test_create_anonymous_to_peers(self): """ - Create a serializer with an appropriate context and use it to serialize - the given thread, returning the result. + Test that serializer correctly deserializes the anonymous_to_peers + field when creating a new comment. """ - return ThreadSerializer(thread, context=get_context(self.course, self.request)).data + self.register_post_comment_response({"username": self.user.username}, thread_id="test_thread") + data = self.minimal_data.copy() + data["anonymous_to_peers"] = True + self.save_and_reserialize(data) + call_args = self.get_mock_func_calls("create_parent_comment")[-1] + args, kwargs = call_args + assert kwargs['anonymous_to_peers'] - 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 + @ddt.data(None, -1, 0, 2, 5) + def test_create_parent_id_too_deep(self, max_depth): + with mock.patch("lms.djangoapps.discussion.django_comment_client.utils.MAX_COMMENT_DEPTH", max_depth): + data = self.minimal_data.copy() + context = get_context(self.course, self.request, make_minimal_cs_thread()) + if max_depth is None or max_depth >= 0: + if max_depth != 0: + self.register_get_comment_response({ + "id": "not_too_deep", + "thread_id": "test_thread", + "depth": max_depth - 1 if max_depth else 100 + }) + data["parent_id"] = "not_too_deep" + else: + data["parent_id"] = None + serializer = CommentSerializer(data=data, context=context) + assert serializer.is_valid(), serializer.errors + if max_depth is not None: + if max_depth >= 0: + self.register_get_comment_response({ + "id": "too_deep", + "thread_id": "test_thread", + "depth": max_depth + }) + data["parent_id"] = "too_deep" + else: + data["parent_id"] = None + serializer = CommentSerializer(data=data, context=context) + assert not serializer.is_valid() + assert serializer.errors == {'non_field_errors': ['Comment level is too deep.']} - def test_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_create_endorsed(self): + # TODO: The comments service doesn't populate the endorsement field on + # comment creation, so this is sadly realistic + self.register_post_comment_response({"username": self.user.username}, thread_id="test_thread") + data = self.minimal_data.copy() + data["endorsed"] = True + saved = self.save_and_reserialize(data) - 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 + params = { + 'course_id': str(self.course.id), + 'body': 'Test body', + 'user_id': str(self.user.id), + 'endorsed': True, + 'anonymous': False, + 'anonymous_to_peers': False, + 'thread_id': 'test_thread', + } + self.check_mock_called("create_parent_comment") + self.check_mock_called_with( + "create_parent_comment", + -1, + **params + ) + assert saved['endorsed'] + assert saved['endorsed_by'] is None + assert saved['endorsed_by_label'] is None + assert saved['endorsed_at'] is None - 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_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_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 + def test_update_empty(self): + self.register_put_comment_response(self.existing_comment.attributes) + self.save_and_reserialize({}, instance=self.existing_comment) + parsed_body = { + 'body': 'Original body', + 'course_id': str(self.course.id), + 'user_id': str(self.user.id), + 'anonymous': False, + 'anonymous_to_peers': False, + 'endorsed': False, + 'comment_id': 'existing_comment', + } + self.check_mock_called("update_comment") + self.check_mock_called_with( + "update_comment", + -1, + **parsed_body + ) - @ddt.data( - (FORUM_ROLE_MODERATOR, True), - (FORUM_ROLE_STUDENT, False), - ("author", True), - ) - @ddt.unpack - def test_closed_by_label_field(self, role, visible): + def test_update_anonymous(self): """ - Tests if closed by field is visible to author and priviledged users + Test that serializer correctly deserializes the anonymous field when + updating an existing comment. """ - 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 + self.register_put_comment_response(self.existing_comment.attributes) + data = { + "anonymous": True, + } + self.save_and_reserialize(data, self.existing_comment) + call_args = self.get_mock_func_calls("update_comment")[0] + args, kwargs = call_args + assert kwargs['anonymous'] - @ddt.data( - (FORUM_ROLE_MODERATOR, True), - (FORUM_ROLE_STUDENT, False), - ("author", True), - ) - @ddt.unpack - def test_edit_by_label_field(self, role, visible): + def test_update_anonymous_to_peers(self): """ - Tests if closed by field is visible to author and priviledged users + Test that serializer correctly deserializes the anonymous_to_peers + field when updating an existing comment. """ - 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 + self.register_put_comment_response(self.existing_comment.attributes) + data = { + "anonymous_to_peers": True, + } + self.save_and_reserialize(data, self.existing_comment) - def test_get_preview_body(self): - """ - Test for the 'get_preview_body' method. + call_args = self.get_mock_func_calls("update_comment")[0] + args, kwargs = call_args + assert kwargs['anonymous_to_peers'] - 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.

"} + @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) ) - serialized = self.serialize(thread_data) - assert serialized['preview_body'] == "This is a test thread body with some text." + assert not serializer.is_valid() + assert serializer.errors == {field: ['This field is not allowed in an update.']} @ddt.ddt -class CommentSerializerTest(SerializerTestMixin, SharedModuleStoreTestCase): - """Tests for CommentSerializer.""" - - def setUp(self): - super().setUp() +class ThreadSerializerDeserializationTest( + ForumMockUtilsMixin, + 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() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() + + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + super().setUp() + httpretty.reset() + httpretty.enable() + self.addCleanup(httpretty.reset) + self.addCleanup(httpretty.disable) + self.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_minimal(self): + self.register_post_thread_response({"id": "test_id", "username": self.user.username}) + saved = self.save_and_reserialize(self.minimal_data) + params = { + 'course_id': str(self.course.id), + 'commentable_id': 'test_topic', + 'thread_type': 'discussion', + 'title': 'Test Title', + 'body': 'Test body', + 'user_id': str(self.user.id), + 'anonymous': False, + 'anonymous_to_peers': False, + } + self.check_mock_called("create_thread") + self.check_mock_called_with( + "create_thread", + -1, + **params + ) + assert saved['id'] == 'test_id' + + def test_create_type(self): + self.register_post_thread_response({"id": "test_id", "username": self.user.username}) + data = self.minimal_data.copy() + data["type"] = "question" + self.save_and_reserialize(data) + + data["type"] = "invalid_type" + serializer = ThreadSerializer(data=data) + assert not serializer.is_valid() + + def test_create_all_fields(self): + self.register_post_thread_response({"id": "test_id", "username": self.user.username}) + data = self.minimal_data.copy() + data["group_id"] = 42 + self.save_and_reserialize(data) + params = { + 'course_id': str(self.course.id), + 'commentable_id': 'test_topic', + 'thread_type': 'discussion', + 'title': 'Test Title', + 'body': 'Test body', + 'user_id': str(self.user.id), + 'group_id': 42, + 'anonymous': False, + 'anonymous_to_peers': False, + } + self.check_mock_called("create_thread") + self.check_mock_called_with( + "create_thread", + -1, + **params + ) + + def test_create_anonymous(self): + """ + Test that serializer correctly deserializes the anonymous field when + creating a new thread. + """ + self.register_post_thread_response({"id": "test_id", "username": self.user.username}) + data = self.minimal_data.copy() + data["anonymous"] = True + self.save_and_reserialize(data) + call_args = self.get_mock_func_calls("create_thread")[0] + args, kwargs = call_args + assert kwargs['anonymous'] + + def test_create_anonymous_to_peers(self): + """ + Test that serializer correctly deserializes the anonymous_to_peers field + when creating a new thread. + """ + self.register_post_thread_response({"id": "test_id", "username": self.user.username}) + data = self.minimal_data.copy() + data["anonymous_to_peers"] = True + self.save_and_reserialize(data) + call_args = self.get_mock_func_calls("create_thread")[0] + args, kwargs = call_args + assert kwargs['anonymous_to_peers'] + + +@ddt.ddt +class SerializerTestMixin(UrlResetMixin, ForumMockUtilsMixin): + """ + 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() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() + + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + super().setUp() + httpretty.reset() + httpretty.enable() + self.addCleanup(httpretty.reset) + self.addCleanup(httpretty.disable) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + self.maxDiff = None # pylint: disable=invalid-name + self.user = UserFactory.create() + self.register_get_user_response(self.user) + 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.register_get_user_response(self.user) + 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.register_get_user_response(self.user) + 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): + self.register_get_user_response(self.user) + 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 CommentSerializerTest(SerializerTestMixin, SharedModuleStoreTestCase): + """Tests for CommentSerializer.""" + + def setUp(self): + super().setUp() self.endorser = UserFactory.create() self.endorsed_at = "2015-05-18T12:34:56Z" + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() def make_cs_content(self, overrides=None, with_endorsement=False): """ @@ -416,6 +698,7 @@ def serialize(self, comment, thread_data=None): return CommentSerializer(comment, context=context).data def test_basic(self): + self.register_get_user_response(self.user) comment = { "type": "comment", "id": "test_comment", @@ -492,6 +775,7 @@ def test_endorsed_by(self, endorser_role_name, thread_anonymous): 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), @@ -519,15 +803,18 @@ def test_endorsed_by_labels(self, role_name, expected_label): 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": [ @@ -557,554 +844,236 @@ def test_children(self): @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() +class ThreadSerializerSerializationTest(SerializerTestMixin, SharedModuleStoreTestCase, ForumMockUtilsMixin): + """Tests for ThreadSerializer serialization.""" - @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_minimal(self): - self.register_post_thread_response({"id": "test_id", "username": self.user.username}) - saved = self.save_and_reserialize(self.minimal_data) - assert urlparse(httpretty.last_request().path).path ==\ - '/api/v1/test_topic/threads' # lint-amnesty, pylint: disable=no-member - assert parsed_body(httpretty.last_request()) == { - 'course_id': [str(self.course.id)], - 'commentable_id': ['test_topic'], - 'thread_type': ['discussion'], - 'title': ['Test Title'], - 'body': ['Test body'], - 'user_id': [str(self.user.id)], - 'anonymous': ['False'], - 'anonymous_to_peers': ['False'], - } - assert saved['id'] == 'test_id' - - def test_create_all_fields(self): - self.register_post_thread_response({"id": "test_id", "username": self.user.username}) - data = self.minimal_data.copy() - data["group_id"] = 42 - self.save_and_reserialize(data) - assert parsed_body(httpretty.last_request()) == { - 'course_id': [str(self.course.id)], - 'commentable_id': ['test_topic'], - 'thread_type': ['discussion'], - 'title': ['Test Title'], - 'body': ['Test body'], - 'user_id': [str(self.user.id)], - 'group_id': ['42'], - 'anonymous': ['False'], - 'anonymous_to_peers': ['False'], - } - - def test_create_missing_field(self): - for field in self.minimal_data: - data = self.minimal_data.copy() - data.pop(field) - serializer = ThreadSerializer(data=data) - assert not serializer.is_valid() - assert serializer.errors == {field: ['This field is required.']} - - @ddt.data("", " ") - def test_create_empty_string(self, value): - data = self.minimal_data.copy() - data.update({field: value for field in ["topic_id", "title", "raw_body"]}) - serializer = ThreadSerializer(data=data, context=get_context(self.course, self.request)) - assert not serializer.is_valid() - assert serializer.errors == { - field: ['This field may not be blank.'] for field in ['topic_id', 'title', 'raw_body'] - } - - def test_create_type(self): - self.register_post_thread_response({"id": "test_id", "username": self.user.username}) - data = self.minimal_data.copy() - data["type"] = "question" - self.save_and_reserialize(data) - - data["type"] = "invalid_type" - serializer = ThreadSerializer(data=data) - assert not serializer.is_valid() - - def test_create_anonymous(self): - """ - Test that serializer correctly deserializes the anonymous field when - creating a new thread. - """ - self.register_post_thread_response({"id": "test_id", "username": self.user.username}) - data = self.minimal_data.copy() - data["anonymous"] = True - self.save_and_reserialize(data) - assert parsed_body(httpretty.last_request())["anonymous"] == ['True'] - - def test_create_anonymous_to_peers(self): - """ - Test that serializer correctly deserializes the anonymous_to_peers field - when creating a new thread. - """ - self.register_post_thread_response({"id": "test_id", "username": self.user.username}) - data = self.minimal_data.copy() - data["anonymous_to_peers"] = True - self.save_and_reserialize(data) - assert parsed_body(httpretty.last_request())["anonymous_to_peers"] == ['True'] - - 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): + def make_cs_content(self, overrides): """ - Test that serializer correctly deserializes the anonymous field when - updating an existing thread. + Create a thread with the given overrides, plus some useful test data. """ - self.register_put_thread_response(self.existing_thread.attributes) - data = { - "anonymous": True, + 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, } - self.save_and_reserialize(data, self.existing_thread) - assert parsed_body(httpretty.last_request())["anonymous"] == ['True'] + merged_overrides.update(overrides) + return make_minimal_cs_thread(merged_overrides) - def test_update_anonymous_to_peers(self): + def serialize(self, thread): """ - Test that serializer correctly deserializes the anonymous_to_peers - field when updating an existing thread. + Create a serializer with an appropriate context and use it to serialize + the given thread, returning the result. """ - 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() + return ThreadSerializer(thread, context=get_context(self.course, self.request)).data - 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, + def test_basic(self): + thread = make_minimal_cs_thread({ + "id": "test_thread", "course_id": str(self.course.id), - })) - - def save_and_reserialize(self, data, instance=None): - """ - Create a serializer with the given data, ensure that it is valid, save - the result, and return the full comment data from the serializer. - """ - context = get_context( - self.course, - self.request, - make_minimal_cs_thread({"course_id": str(self.course.id)}) - ) - serializer = CommentSerializer( - instance, - data=data, - partial=(instance is not None), - context=context - ) - assert serializer.is_valid() - serializer.save() - return serializer.data - - @ddt.data(None, "test_parent") - def test_create_success(self, parent_id): - data = self.minimal_data.copy() - if parent_id: - data["parent_id"] = parent_id - self.register_get_comment_response({"thread_id": "test_thread", "id": parent_id}) - self.register_post_comment_response( - {"id": "test_comment", "username": self.user.username}, - thread_id="test_thread", - parent_id=parent_id - ) - saved = self.save_and_reserialize(data) - expected_url = ( - f"/api/v1/comments/{parent_id}" if parent_id else - "/api/v1/threads/test_thread/comments" - ) - assert urlparse(httpretty.last_request().path).path == expected_url # lint-amnesty, pylint: disable=no-member - assert parsed_body(httpretty.last_request()) == { - 'course_id': [str(self.course.id)], - 'body': ['Test body'], - 'user_id': [str(self.user.id)], - 'anonymous': ['False'], - 'anonymous_to_peers': ['False'], - } - assert saved['id'] == 'test_comment' - assert saved['parent_id'] == parent_id - - def test_create_all_fields(self): - data = self.minimal_data.copy() - data["parent_id"] = "test_parent" - data["endorsed"] = True - self.register_get_comment_response({"thread_id": "test_thread", "id": "test_parent"}) - self.register_post_comment_response( - {"id": "test_comment", "username": self.user.username}, - thread_id="test_thread", - parent_id="test_parent" - ) - self.save_and_reserialize(data) - assert parsed_body(httpretty.last_request()) == { - 'course_id': [str(self.course.id)], - 'body': ['Test body'], - 'user_id': [str(self.user.id)], - 'endorsed': ['True'], - 'anonymous': ['False'], - 'anonymous_to_peers': ['False'], - } - - def test_create_parent_id_nonexistent(self): - self.register_get_comment_error_response("bad_parent", 404) - data = self.minimal_data.copy() - data["parent_id"] = "bad_parent" - context = get_context(self.course, self.request, make_minimal_cs_thread()) - serializer = CommentSerializer(data=data, context=context) - assert not serializer.is_valid() - assert serializer.errors == { - 'non_field_errors': ['parent_id does not identify a comment in the thread identified by thread_id.'] - } - - def test_create_parent_id_wrong_thread(self): - self.register_get_comment_response({"thread_id": "different_thread", "id": "test_parent"}) - data = self.minimal_data.copy() - data["parent_id"] = "test_parent" - context = get_context(self.course, self.request, make_minimal_cs_thread()) - serializer = CommentSerializer(data=data, context=context) - assert not serializer.is_valid() - assert serializer.errors == { - 'non_field_errors': ['parent_id does not identify a comment in the thread identified by thread_id.'] - } + "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 - @ddt.data(None, -1, 0, 2, 5) - def test_create_parent_id_too_deep(self, max_depth): - with mock.patch("lms.djangoapps.discussion.django_comment_client.utils.MAX_COMMENT_DEPTH", max_depth): - data = self.minimal_data.copy() - context = get_context(self.course, self.request, make_minimal_cs_thread()) - if max_depth is None or max_depth >= 0: - if max_depth != 0: - self.register_get_comment_response({ - "id": "not_too_deep", - "thread_id": "test_thread", - "depth": max_depth - 1 if max_depth else 100 - }) - data["parent_id"] = "not_too_deep" - else: - data["parent_id"] = None - serializer = CommentSerializer(data=data, context=context) - assert serializer.is_valid(), serializer.errors - if max_depth is not None: - if max_depth >= 0: - self.register_get_comment_response({ - "id": "too_deep", - "thread_id": "test_thread", - "depth": max_depth - }) - data["parent_id"] = "too_deep" - else: - data["parent_id"] = None - serializer = CommentSerializer(data=data, context=context) - assert not serializer.is_valid() - assert serializer.errors == {'non_field_errors': ['Comment level is too deep.']} + 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_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_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_create_endorsed(self): - # TODO: The comments service doesn't populate the endorsement field on - # comment creation, so this is sadly realistic - self.register_post_comment_response({"username": self.user.username}, thread_id="test_thread") - data = self.minimal_data.copy() - data["endorsed"] = True - saved = self.save_and_reserialize(data) - assert parsed_body(httpretty.last_request()) == { - 'course_id': [str(self.course.id)], - 'body': ['Test body'], - 'user_id': [str(self.user.id)], - 'endorsed': ['True'], - 'anonymous': ['False'], - 'anonymous_to_peers': ['False'], - } - assert saved['endorsed'] - assert saved['endorsed_by'] is None - assert saved['endorsed_by_label'] is None - assert saved['endorsed_at'] is None + 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_create_anonymous(self): + 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): """ - Test that serializer correctly deserializes the anonymous field when - creating a new comment. + Tests if closed by field is visible to author and priviledged users """ - self.register_post_comment_response({"username": self.user.username}, thread_id="test_thread") - data = self.minimal_data.copy() - data["anonymous"] = True - self.save_and_reserialize(data) - assert parsed_body(httpretty.last_request())["anonymous"] == ['True'] + 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]) - def test_create_anonymous_to_peers(self): + 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): """ - Test that serializer correctly deserializes the anonymous_to_peers - field when creating a new comment. + Tests if closed by field is visible to author and priviledged users """ - self.register_post_comment_response({"username": self.user.username}, thread_id="test_thread") - data = self.minimal_data.copy() - data["anonymous_to_peers"] = True - self.save_and_reserialize(data) - assert parsed_body(httpretty.last_request())["anonymous_to_peers"] == ['True'] + 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]) - 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'] - } + 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"] - def test_update_all(self): - cs_response_data = self.existing_comment.attributes.copy() - cs_response_data["endorsement"] = { - "user_id": str(self.user.id), - "time": "2015-06-05T00:00:00Z", - } - self.register_put_comment_response(cs_response_data) - data = {"raw_body": "Edited body", "endorsed": True} - self.register_get_thread_response( - make_minimal_cs_thread({ - "id": "dummy", - "course_id": str(self.course.id), - }) - ) - saved = self.save_and_reserialize(data, instance=self.existing_comment) + if role == "author": + editable_fields.remove("voted") + editable_fields.extend(['anonymous', 'raw_body', 'title', 'topic_id', 'type']) - assert parsed_body(httpretty.last_request()) == { - 'body': ['Edited body'], - 'course_id': [str(self.course.id)], - 'user_id': [str(self.user.id)], - 'anonymous': ['False'], - 'anonymous_to_peers': ['False'], - 'endorsed': ['True'], - 'endorsement_user_id': [str(self.user.id)], - 'editing_user_id': [str(self.user.id)], - } - for key in data: - assert saved[key] == data[key] - assert saved['endorsed_by'] == self.user.username - assert saved['endorsed_at'] == '2015-06-05T00:00:00Z' + elif role == FORUM_ROLE_MODERATOR: + editable_fields.extend(['close_reason_code', 'closed', 'edit_reason_code', 'pinned', + 'raw_body', 'title', 'topic_id', 'type']) - @ddt.data("", " ") - def test_update_empty_raw_body(self, value): - serializer = CommentSerializer( - self.existing_comment, - data={"raw_body": value}, - partial=True, - context=get_context(self.course, self.request) - ) - assert not serializer.is_valid() - assert serializer.errors == {'raw_body': ['This field may not be blank.']} + 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_update_anonymous(self): - """ - Test that serializer correctly deserializes the anonymous field when - updating an existing comment. + def test_get_preview_body(self): """ - 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'] + Test for the 'get_preview_body' method. - def test_update_anonymous_to_peers(self): - """ - Test that serializer correctly deserializes the anonymous_to_peers - field when updating an existing comment. + 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. """ - 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) + thread_data = self.make_cs_content( + {"body": "

This is a test thread body with some text.

"} ) - assert not serializer.is_valid() - assert serializer.errors == {field: ['This field is not allowed in an update.']} + 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.py b/lms/djangoapps/discussion/rest_api/tests/test_tasks.py index a6eb4948ce03..1a5e8d552ed6 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_tasks.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_tasks.py @@ -1,8 +1,10 @@ """ Test cases for tasks.py """ +# This file uses Django unittest framework (ModuleStoreTestCase), not pytest. +# pylint-pytest F6401 is a false positive since this is not a pytest test file. +# pylint: disable=fixme # F6401: pylint-pytest cannot enumerate fixtures in unittest files from unittest import mock -from unittest.mock import Mock import ddt import httpretty @@ -15,7 +17,6 @@ from lms.djangoapps.discussion.django_comment_client.tests.factories import RoleFactory from lms.djangoapps.discussion.rest_api.tasks import ( send_response_endorsed_notifications, - send_response_notifications, send_thread_created_notification ) from lms.djangoapps.discussion.rest_api.tests.utils import ThreadMock, make_minimal_cs_thread @@ -33,8 +34,7 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory -from ..discussions_notifications import DiscussionNotificationSender -from .test_views import DiscussionAPIViewTestMixin +from .test_views_v2 import DiscussionAPIViewTestMixin def _get_mfe_url(course_id, post_id): @@ -58,12 +58,6 @@ def setUp(self): Setup test case """ 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) # Creating a course self.course = CourseFactory.create() @@ -194,25 +188,31 @@ def test_not_authenticated(self): ('new_discussion_post',), ) @ddt.unpack - def test_notification_is_send_to_all_enrollments(self, notification_type): + def test_notification_is_send_to_all_enrollments( + self, notification_type + ): """ Tests notification is sent to all users if course is not cohorted """ self._assign_enrollments() thread_type = ( - "discussion" - if notification_type == "new_discussion_post" - else ("question" if notification_type == "new_question_post" else "") + "discussion" if notification_type == "new_discussion_post" else "question" ) + thread = self._create_thread(thread_type=thread_type) handler = mock.Mock() COURSE_NOTIFICATION_REQUESTED.connect(handler) - send_thread_created_notification(thread['id'], str(self.course.id), self.author.id) + + send_thread_created_notification( + thread['id'], + str(self.course.id), + self.author.id + ) self.assertEqual(handler.call_count, 1) + course_notification_data = handler.call_args[1]['course_notification_data'] - assert notification_type == course_notification_data.notification_type - notification_audience_filters = {} - assert notification_audience_filters == course_notification_data.audience_filters + self.assertEqual(course_notification_data.notification_type, notification_type) + self.assertEqual(course_notification_data.audience_filters, {}) @ddt.data( ('cohort_1', 'new_question_post'), @@ -256,420 +256,6 @@ def test_notification_is_send_to_cohort_ids(self, cohort_text, notification_type self.assertEqual(handler.call_count, 1) -@ddt.ddt -@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) -class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestCase): - """ - Test for the send_response_notifications function - """ - - def setUp(self): - 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) - - self.course = CourseFactory.create() - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread", - return_value=self.course.id - ) - self.mock_get_course_id_by_thread = patcher.start() - self.addCleanup(patcher.stop) - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment", - return_value=self.course.id - ) - self.mock_get_course_id_by_comment = patcher.start() - self.addCleanup(patcher.stop) - self.user_1 = UserFactory.create() - CourseEnrollment.enroll(self.user_1, self.course.id) - self.user_2 = UserFactory.create() - CourseEnrollment.enroll(self.user_2, self.course.id) - self.user_3 = UserFactory.create() - CourseEnrollment.enroll(self.user_3, self.course.id) - self.thread = ThreadMock(thread_id=1, creator=self.user_1, title='test thread') - self.thread_2 = ThreadMock(thread_id=2, creator=self.user_2, title='test thread 2') - self.thread_3 = ThreadMock(thread_id=2, creator=self.user_1, title='test thread 3') - for thread in [self.thread, self.thread_2, self.thread_3]: - self.register_get_thread_response({ - 'id': thread.id, - 'course_id': str(self.course.id), - 'topic_id': 'abc', - "user_id": thread.user_id, - "username": thread.username, - "thread_type": 'discussion', - "title": thread.title, - "commentable_id": thread.commentable_id, - - }) - self._register_subscriptions_endpoint() - - self.comment = ThreadMock(thread_id=4, creator=self.user_2, title='test comment', body='comment body') - self.register_get_comment_response( - { - 'id': self.comment.id, - 'thread_id': self.thread.id, - 'parent_id': None, - 'user_id': self.comment.user_id, - 'body': self.comment.body, - } - ) - - def test_basic(self): - """ - Left empty intentionally. This test case is inherited from DiscussionAPIViewTestMixin - """ - - def test_not_authenticated(self): - """ - Left empty intentionally. This test case is inherited from DiscussionAPIViewTestMixin - """ - - def test_send_notification_to_thread_creator(self): - """ - Test that the notification is sent to the thread creator - """ - handler = mock.Mock() - USER_NOTIFICATION_REQUESTED.connect(handler) - - # Post the form or do what it takes to send the signal - - send_response_notifications( - self.thread.id, - str(self.course.id), - self.user_2.id, - self.comment.id, - parent_id=None - ) - self.assertEqual(handler.call_count, 2) - args = handler.call_args_list[0][1]['notification_data'] - self.assertEqual([int(user_id) for user_id in args.user_ids], [self.user_1.id]) - self.assertEqual(args.notification_type, 'new_response') - expected_context = { - 'replier_name': self.user_2.username, - 'post_title': 'test thread', - 'email_content': self.comment.body, - 'course_name': self.course.display_name, - 'sender_id': self.user_2.id, - 'response_id': 4, - 'topic_id': None, - 'thread_id': 1, - 'comment_id': None, - } - self.assertDictEqual(args.context, expected_context) - self.assertEqual( - args.content_url, - _get_mfe_url(self.course.id, self.thread.id) - ) - self.assertEqual(args.app_name, 'discussion') - - def test_send_notification_to_parent_threads(self): - """ - Test that the notification signal is sent to the parent response creator and - parent thread creator, it checks signal is sent with correct arguments for both - types of notifications. - """ - handler = mock.Mock() - USER_NOTIFICATION_REQUESTED.connect(handler) - - self.register_get_comment_response({ - 'id': self.thread_2.id, - 'thread_id': self.thread.id, - 'user_id': self.thread_2.user_id - }) - - send_response_notifications( - self.thread.id, - str(self.course.id), - self.user_3.id, - self.comment.id, - parent_id=self.thread_2.id - ) - # check if 2 call are made to the handler i.e. one for the response creator and one for the thread creator - self.assertEqual(handler.call_count, 2) - - # check if the notification is sent to the thread creator - args_comment = handler.call_args_list[0][1]['notification_data'] - args_comment_on_response = handler.call_args_list[1][1]['notification_data'] - self.assertEqual([int(user_id) for user_id in args_comment.user_ids], [self.user_1.id]) - self.assertEqual(args_comment.notification_type, 'new_comment') - expected_context = { - 'replier_name': self.user_3.username, - 'post_title': self.thread.title, - 'email_content': self.comment.body, - 'author_name': 'dummy\'s', - 'author_pronoun': 'dummy\'s', - 'course_name': self.course.display_name, - 'sender_id': self.user_3.id, - 'response_id': 2, - 'topic_id': None, - 'thread_id': 1, - 'comment_id': 4, - } - self.assertDictEqual(args_comment.context, expected_context) - self.assertEqual( - args_comment.content_url, - _get_mfe_url(self.course.id, self.thread.id) - ) - self.assertEqual(args_comment.app_name, 'discussion') - - # check if the notification is sent to the parent response creator - self.assertEqual([int(user_id) for user_id in args_comment_on_response.user_ids], [self.user_2.id]) - self.assertEqual(args_comment_on_response.notification_type, 'new_comment_on_response') - expected_context = { - 'replier_name': self.user_3.username, - 'post_title': self.thread.title, - 'email_content': self.comment.body, - 'course_name': self.course.display_name, - 'sender_id': self.user_3.id, - 'response_id': 2, - 'topic_id': None, - 'thread_id': 1, - 'comment_id': 4, - } - self.assertDictEqual(args_comment_on_response.context, expected_context) - self.assertEqual( - args_comment_on_response.content_url, - _get_mfe_url(self.course.id, self.thread.id) - ) - self.assertEqual(args_comment_on_response.app_name, 'discussion') - - def test_no_signal_on_creators_own_thread(self): - """ - Makes sure that 1 signal is emitted if user creates response on - their own thread. - """ - handler = mock.Mock() - USER_NOTIFICATION_REQUESTED.connect(handler) - - send_response_notifications( - self.thread.id, - str(self.course.id), - self.user_1.id, - self.comment.id, parent_id=None - ) - self.assertEqual(handler.call_count, 1) - - def test_comment_creators_own_response(self): - """ - Check incase post author and response auther is same only send - new comment signal , with your as author_name. - """ - handler = mock.Mock() - USER_NOTIFICATION_REQUESTED.connect(handler) - - self.register_get_comment_response({ - 'id': self.thread_3.id, - 'thread_id': self.thread.id, - 'user_id': self.thread_3.user_id - }) - - send_response_notifications( - self.thread.id, - str(self.course.id), - self.user_3.id, - parent_id=self.thread_2.id, - comment_id=self.comment.id - ) - # check if 1 call is made to the handler i.e. for the thread creator - self.assertEqual(handler.call_count, 2) - - # check if the notification is sent to the thread creator - args_comment = handler.call_args_list[0][1]['notification_data'] - self.assertEqual(args_comment.user_ids, [self.user_1.id]) - self.assertEqual(args_comment.notification_type, 'new_comment') - expected_context = { - 'replier_name': self.user_3.username, - 'post_title': self.thread.title, - 'author_name': 'dummy\'s', - 'author_pronoun': 'your', - 'course_name': self.course.display_name, - 'sender_id': self.user_3.id, - 'email_content': self.comment.body, - 'response_id': 2, - 'topic_id': None, - 'thread_id': 1, - 'comment_id': 4, - } - self.assertDictEqual(args_comment.context, expected_context) - self.assertEqual( - args_comment.content_url, - _get_mfe_url(self.course.id, self.thread.id) - ) - self.assertEqual(args_comment.app_name, 'discussion') - - @ddt.data( - (None, 'response_on_followed_post'), (1, 'comment_on_followed_post') - ) - @ddt.unpack - def test_send_notification_to_followers(self, parent_id, notification_type): - """ - Test that the notification is sent to the followers of the thread - """ - self.register_get_comment_response({ - 'id': self.thread.id, - 'thread_id': self.thread.id, - 'user_id': self.thread.user_id - }) - handler = Mock() - USER_NOTIFICATION_REQUESTED.connect(handler) - - # Post the form or do what it takes to send the signal - notification_sender = DiscussionNotificationSender( - self.thread, - self.course, - self.user_2, - parent_id=parent_id, - comment_id=self.comment.id - ) - notification_sender.send_response_on_followed_post_notification() - self.assertEqual(handler.call_count, 1) - args = handler.call_args[1]['notification_data'] - # only sent to user_3 because user_2 is the one who created the response - self.assertEqual([self.user_3.id], args.user_ids) - self.assertEqual(args.notification_type, notification_type) - expected_context = { - 'replier_name': self.user_2.username, - 'post_title': 'test thread', - 'email_content': self.comment.body, - 'course_name': self.course.display_name, - 'sender_id': self.user_2.id, - 'response_id': 4 if notification_type == 'response_on_followed_post' else parent_id, - 'topic_id': None, - 'thread_id': 1, - 'comment_id': 4 if not notification_type == 'response_on_followed_post' else None, - } - if parent_id: - expected_context['author_name'] = 'dummy\'s' - expected_context['author_pronoun'] = 'dummy\'s' - self.assertDictEqual(args.context, expected_context) - self.assertEqual( - args.content_url, - _get_mfe_url(self.course.id, self.thread.id) - ) - self.assertEqual(args.app_name, 'discussion') - - def _register_subscriptions_endpoint(self): - """ - Registers the endpoint for the subscriptions API - """ - mock_response = { - 'collection': [ - { - '_id': 1, - 'subscriber_id': str(self.user_2.id), - "source_id": self.thread.id, - "source_type": "thread", - }, - { - '_id': 2, - 'subscriber_id': str(self.user_3.id), - "source_id": self.thread.id, - "source_type": "thread", - }, - ], - 'page': 1, - 'num_pages': 1, - 'subscriptions_count': 2, - 'corrected_text': None - - } - self.register_get_subscriptions(self.thread.id, mock_response) - - -@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) -class TestSendCommentNotification(DiscussionAPIViewTestMixin, ModuleStoreTestCase): - """ - Test case to send new_comment notification - """ - - def setUp(self): - 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) - - self.course = CourseFactory.create() - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread", - return_value=self.course.id - ) - self.mock_get_course_id_by_thread = patcher.start() - self.addCleanup(patcher.stop) - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment", - return_value=self.course.id - ) - self.mock_get_course_id_by_comment = patcher.start() - self.addCleanup(patcher.stop) - self.user_1 = UserFactory.create() - CourseEnrollment.enroll(self.user_1, self.course.id) - self.user_2 = UserFactory.create() - CourseEnrollment.enroll(self.user_2, self.course.id) - - def test_basic(self): - """ - Left empty intentionally. This test case is inherited from DiscussionAPIViewTestMixin - """ - - def test_not_authenticated(self): - """ - Left empty intentionally. This test case is inherited from DiscussionAPIViewTestMixin - """ - - def test_new_comment_notification(self): - """ - Tests new comment notification generation - """ - handler = mock.Mock() - USER_NOTIFICATION_REQUESTED.connect(handler) - - thread = ThreadMock(thread_id=1, creator=self.user_1, title='test thread') - response = ThreadMock(thread_id=2, creator=self.user_2, title='test response') - comment = ThreadMock(thread_id=3, creator=self.user_2, title='test comment', body='comment body') - self.register_get_thread_response({ - 'id': thread.id, - 'course_id': str(self.course.id), - 'topic_id': 'abc', - "user_id": thread.user_id, - "username": thread.username, - "thread_type": 'discussion', - "title": thread.title, - "commentable_id": thread.commentable_id, - - }) - self.register_get_comment_response({ - 'id': response.id, - 'thread_id': thread.id, - 'user_id': response.user_id - }) - self.register_get_comment_response({ - 'id': comment.id, - 'parent_id': response.id, - 'user_id': comment.user_id, - 'body': comment.body - }) - self.register_get_subscriptions(1, {}) - send_response_notifications(thread.id, str(self.course.id), self.user_2.id, parent_id=response.id, - comment_id=comment.id) - handler.assert_called_once() - context = handler.call_args[1]['notification_data'].context - self.assertEqual(context['author_name'], 'dummy\'s') - self.assertEqual(context['author_pronoun'], 'their') - self.assertEqual(context['email_content'], comment.body) - - @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) class TestResponseEndorsedNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestCase): """ @@ -680,12 +266,6 @@ def setUp(self): 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) self.course = CourseFactory.create() patcher = mock.patch( diff --git a/lms/djangoapps/discussion/rest_api/tests/test_tasks_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_tasks_v2.py new file mode 100644 index 000000000000..09339558c415 --- /dev/null +++ b/lms/djangoapps/discussion/rest_api/tests/test_tasks_v2.py @@ -0,0 +1,756 @@ +""" +Test cases for forum v2 based tasks.py +""" +from unittest import mock +from unittest.mock import Mock + +import ddt +import httpretty +from django.conf import settings +from edx_toggles.toggles.testutils import override_waffle_flag +from openedx_events.learning.signals import COURSE_NOTIFICATION_REQUESTED, USER_NOTIFICATION_REQUESTED + +from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.student.tests.factories import StaffFactory, UserFactory +from lms.djangoapps.discussion.django_comment_client.tests.factories import RoleFactory +from lms.djangoapps.discussion.rest_api.tasks import ( + send_response_endorsed_notifications, + send_response_notifications, + send_thread_created_notification +) +from lms.djangoapps.discussion.rest_api.tests.utils import ThreadMock, make_minimal_cs_thread +from openedx.core.djangoapps.course_groups.models import CohortMembership, CourseCohortsSettings +from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory +from openedx.core.djangoapps.discussions.models import DiscussionTopicLink +from openedx.core.djangoapps.django_comment_common.models import ( + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_GROUP_MODERATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_STUDENT, + CourseDiscussionSettings +) +from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + +from ..discussions_notifications import DiscussionNotificationSender +from .test_views_v2 import DiscussionAPIViewTestMixin + + +def _get_mfe_url(course_id, post_id): + """ + get discussions mfe url to specific post. + """ + return f"{settings.DISCUSSIONS_MICROFRONTEND_URL}/{str(course_id)}/posts/{post_id}" + + +@ddt.ddt +@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) +class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestCase): + """ + Test for the send_response_notifications function + """ + + def setUp(self): + super().setUp() + httpretty.reset() + httpretty.enable() + self.addCleanup(httpretty.disable) + self.addCleanup(httpretty.reset) + + self.course = CourseFactory.create() + + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread", + return_value=self.course.id + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment", + return_value=self.course.id + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + + self.user_1 = UserFactory.create() + CourseEnrollment.enroll(self.user_1, self.course.id) + self.user_2 = UserFactory.create() + CourseEnrollment.enroll(self.user_2, self.course.id) + self.user_3 = UserFactory.create() + CourseEnrollment.enroll(self.user_3, self.course.id) + self.thread = ThreadMock(thread_id=1, creator=self.user_1, title='test thread') + self.thread_2 = ThreadMock(thread_id=2, creator=self.user_2, title='test thread 2') + self.thread_3 = ThreadMock(thread_id=2, creator=self.user_1, title='test thread 3') + for thread in [self.thread_3, self.thread_2, self.thread]: + self.register_get_thread_response({ + 'id': thread.id, + 'course_id': str(self.course.id), + 'topic_id': 'abc', + "user_id": thread.user_id, + "username": thread.username, + "thread_type": 'discussion', + "title": thread.title, + "commentable_id": thread.commentable_id, + + }) + + self._register_subscriptions_endpoint() + + self.comment = ThreadMock(thread_id=4, creator=self.user_2, title='test comment', body='comment body') + self.register_get_comment_response( + { + 'id': self.comment.id, + 'thread_id': self.thread.id, + 'parent_id': None, + 'user_id': self.comment.user_id, + 'body': self.comment.body, + } + ) + + def test_basic(self): + """ + Left empty intentionally. This test case is inherited from DiscussionAPIViewTestMixin + """ + + def test_not_authenticated(self): + """ + Left empty intentionally. This test case is inherited from DiscussionAPIViewTestMixin + """ + + def test_send_notification_to_thread_creator(self): + """ + Test that the notification is sent to the thread creator + """ + handler = mock.Mock() + USER_NOTIFICATION_REQUESTED.connect(handler) + + # Post the form or do what it takes to send the signal + send_response_notifications( + self.thread.id, + str(self.course.id), + self.user_2.id, + self.comment.id, + parent_id=None + ) + self.assertEqual(handler.call_count, 2) + args = handler.call_args_list[0][1]['notification_data'] + self.assertEqual([int(user_id) for user_id in args.user_ids], [self.user_1.id]) + self.assertEqual(args.notification_type, 'new_response') + expected_context = { + 'replier_name': self.user_2.username, + 'post_title': 'test thread', + 'email_content': self.comment.body, + 'course_name': self.course.display_name, + 'sender_id': self.user_2.id, + 'response_id': 4, + 'topic_id': None, + 'thread_id': 1, + 'comment_id': None, + } + self.assertDictEqual(args.context, expected_context) + self.assertEqual( + args.content_url, + _get_mfe_url(self.course.id, self.thread.id) + ) + self.assertEqual(args.app_name, 'discussion') + + def test_no_signal_on_creators_own_thread(self): + """ + Makes sure that 1 signal is emitted if user creates response on + their own thread. + """ + handler = mock.Mock() + USER_NOTIFICATION_REQUESTED.connect(handler) + + send_response_notifications( + self.thread.id, + str(self.course.id), + self.user_1.id, + self.comment.id, parent_id=None + ) + self.assertEqual(handler.call_count, 1) + + @ddt.data( + (None, 'response_on_followed_post'), (1, 'comment_on_followed_post') + ) + @ddt.unpack + def test_send_notification_to_followers(self, parent_id, notification_type): + """ + Test that the notification is sent to the followers of the thread + """ + self.register_get_comment_response({ + 'id': self.thread.id, + 'thread_id': self.thread.id, + 'user_id': self.thread.user_id, + "body": "comment body" + }) + handler = Mock() + USER_NOTIFICATION_REQUESTED.connect(handler) + + # Post the form or do what it takes to send the signal + notification_sender = DiscussionNotificationSender( + self.thread, + self.course, + self.user_2, + parent_id=parent_id, + comment_id=self.comment.id + ) + notification_sender.send_response_on_followed_post_notification() + self.assertEqual(handler.call_count, 1) + args = handler.call_args[1]['notification_data'] + # only sent to user_3 because user_2 is the one who created the response + self.assertEqual([self.user_3.id], args.user_ids) + self.assertEqual(args.notification_type, notification_type) + expected_context = { + 'replier_name': self.user_2.username, + 'post_title': 'test thread', + 'email_content': self.comment.body, + 'course_name': self.course.display_name, + 'sender_id': self.user_2.id, + 'response_id': 4 if notification_type == 'response_on_followed_post' else parent_id, + 'topic_id': None, + 'thread_id': 1, + 'comment_id': 4 if not notification_type == 'response_on_followed_post' else None, + } + if parent_id: + expected_context['author_name'] = 'dummy\'s' + expected_context['author_pronoun'] = 'dummy\'s' + self.assertDictEqual(args.context, expected_context) + self.assertEqual( + args.content_url, + _get_mfe_url(self.course.id, self.thread.id) + ) + self.assertEqual(args.app_name, 'discussion') + + def test_comment_creators_own_response(self): + """ + Check incase post author and response auther is same only send + new comment signal , with your as author_name. + """ + handler = mock.Mock() + USER_NOTIFICATION_REQUESTED.connect(handler) + + self.register_get_comment_response({ + 'id': self.thread_3.id, + 'thread_id': self.thread.id, + 'user_id': self.thread_3.user_id, + 'body': 'comment body', + }) + + send_response_notifications( + self.thread.id, + str(self.course.id), + self.user_3.id, + parent_id=self.thread_2.id, + comment_id=self.comment.id + ) + # check if 1 call is made to the handler i.e. for the thread creator + self.assertEqual(handler.call_count, 2) + + # check if the notification is sent to the thread creator + args_comment = handler.call_args_list[0][1]['notification_data'] + self.assertEqual(args_comment.user_ids, [self.user_1.id]) + self.assertEqual(args_comment.notification_type, 'new_comment') + expected_context = { + 'replier_name': self.user_3.username, + 'post_title': self.thread.title, + 'author_name': 'dummy\'s', + 'author_pronoun': 'your', + 'course_name': self.course.display_name, + 'sender_id': self.user_3.id, + 'email_content': self.comment.body, + 'response_id': 2, + 'topic_id': None, + 'thread_id': 1, + 'comment_id': 4, + } + self.assertDictEqual(args_comment.context, expected_context) + self.assertEqual( + args_comment.content_url, + _get_mfe_url(self.course.id, self.thread.id) + ) + self.assertEqual(args_comment.app_name, 'discussion') + + def test_send_notification_to_parent_threads(self): + """ + Test that the notification signal is sent to the parent response creator and + parent thread creator, it checks signal is sent with correct arguments for both + types of notifications. + """ + handler = mock.Mock() + USER_NOTIFICATION_REQUESTED.connect(handler) + + self.register_get_comment_response({ + 'id': self.thread_2.id, + 'thread_id': self.thread.id, + 'user_id': self.thread_2.user_id, + 'body': 'comment body' + }) + + send_response_notifications( + self.thread.id, + str(self.course.id), + self.user_3.id, + self.comment.id, + parent_id=self.thread_2.id + ) + # check if 2 call are made to the handler i.e. one for the response creator and one for the thread creator + self.assertEqual(handler.call_count, 2) + + # check if the notification is sent to the thread creator + args_comment = handler.call_args_list[0][1]['notification_data'] + args_comment_on_response = handler.call_args_list[1][1]['notification_data'] + self.assertEqual([int(user_id) for user_id in args_comment.user_ids], [self.user_1.id]) + self.assertEqual(args_comment.notification_type, 'new_comment') + expected_context = { + 'replier_name': self.user_3.username, + 'post_title': self.thread.title, + 'email_content': self.comment.body, + 'author_name': 'dummy\'s', + 'author_pronoun': 'dummy\'s', + 'course_name': self.course.display_name, + 'sender_id': self.user_3.id, + 'response_id': 2, + 'topic_id': None, + 'thread_id': 1, + 'comment_id': 4, + } + self.assertDictEqual(args_comment.context, expected_context) + self.assertEqual( + args_comment.content_url, + _get_mfe_url(self.course.id, self.thread.id) + ) + self.assertEqual(args_comment.app_name, 'discussion') + + # check if the notification is sent to the parent response creator + self.assertEqual([int(user_id) for user_id in args_comment_on_response.user_ids], [self.user_2.id]) + self.assertEqual(args_comment_on_response.notification_type, 'new_comment_on_response') + expected_context = { + 'replier_name': self.user_3.username, + 'post_title': self.thread.title, + 'email_content': self.comment.body, + 'course_name': self.course.display_name, + 'sender_id': self.user_3.id, + 'response_id': 2, + 'topic_id': None, + 'thread_id': 1, + 'comment_id': 4, + } + self.assertDictEqual(args_comment_on_response.context, expected_context) + self.assertEqual( + args_comment_on_response.content_url, + _get_mfe_url(self.course.id, self.thread.id) + ) + self.assertEqual(args_comment_on_response.app_name, 'discussion') + + def _register_subscriptions_endpoint(self): + """ + Registers the endpoint for the subscriptions API + """ + mock_response = { + 'collection': [ + { + '_id': 1, + 'subscriber_id': str(self.user_2.id), + "source_id": self.thread.id, + "source_type": "thread", + }, + { + '_id': 2, + 'subscriber_id': str(self.user_3.id), + "source_id": self.thread.id, + "source_type": "thread", + }, + ], + 'page': 1, + 'num_pages': 1, + 'subscriptions_count': 2, + 'corrected_text': None + + } + self.register_get_subscriptions(self.thread.id, mock_response) + + +@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) +class TestSendCommentNotification(DiscussionAPIViewTestMixin, ModuleStoreTestCase): + """ + Test case to send new_comment notification + """ + + def setUp(self): + super().setUp() + httpretty.reset() + httpretty.enable() + + self.course = CourseFactory.create() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread", + return_value=self.course.id + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment", + return_value=self.course.id + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + + self.user_1 = UserFactory.create() + CourseEnrollment.enroll(self.user_1, self.course.id) + self.user_2 = UserFactory.create() + CourseEnrollment.enroll(self.user_2, self.course.id) + + def test_basic(self): + """ + Left empty intentionally. This test case is inherited from DiscussionAPIViewTestMixin + """ + + def test_not_authenticated(self): + """ + Left empty intentionally. This test case is inherited from DiscussionAPIViewTestMixin + """ + + def test_new_comment_notification(self): + """ + Tests new comment notification generation + """ + handler = mock.Mock() + USER_NOTIFICATION_REQUESTED.connect(handler) + + thread = ThreadMock(thread_id=1, creator=self.user_1, title='test thread') + response = ThreadMock(thread_id=2, creator=self.user_2, title='test response') + comment = ThreadMock(thread_id=3, creator=self.user_2, title='test comment', body='comment body') + self.register_get_thread_response({ + 'id': thread.id, + 'course_id': str(self.course.id), + 'topic_id': 'abc', + "user_id": thread.user_id, + "username": thread.username, + "thread_type": 'discussion', + "title": thread.title, + "commentable_id": thread.commentable_id, + + }) + self.register_get_comment_response({ + 'id': response.id, + 'thread_id': thread.id, + 'user_id': response.user_id + }) + self.register_get_comment_response({ + 'id': comment.id, + 'parent_id': response.id, + 'user_id': comment.user_id, + 'body': comment.body + }) + self.register_get_subscriptions(1, {}) + send_response_notifications(thread.id, str(self.course.id), self.user_2.id, parent_id=response.id, + comment_id=comment.id) + handler.assert_called_once() + context = handler.call_args[1]['notification_data'].context + self.assertEqual(context['author_name'], 'dummy\'s') + self.assertEqual(context['author_pronoun'], 'their') + self.assertEqual(context['email_content'], comment.body) + + +@ddt.ddt +@httpretty.activate +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) +class TestNewThreadCreatedNotification(DiscussionAPIViewTestMixin, ModuleStoreTestCase): + """ + Test cases related to new_discussion_post and new_question_post notification types + """ + + def setUp(self): + """ + Setup test case + """ + super().setUp() + # Creating a course + self.course = CourseFactory.create() + self.set_mock_return_value('get_course_id_by_thread', str(self.course.id)) + self.set_mock_return_value('get_course_id_by_comment', str(self.course.id)) + + # Creating relative discussion and cohort settings + CourseCohortsSettings.objects.create(course_id=str(self.course.id)) + CourseDiscussionSettings.objects.create(course_id=str(self.course.id), _divided_discussions='[]') + self.first_cohort = self.second_cohort = None + + # Duplicating roles + self.student_role = RoleFactory(name=FORUM_ROLE_STUDENT, course_id=self.course.id) + self.moderator_role = RoleFactory(name=FORUM_ROLE_MODERATOR, course_id=self.course.id) + self.ta_role = RoleFactory(name=FORUM_ROLE_COMMUNITY_TA, course_id=self.course.id) + self.group_community_ta_role = RoleFactory(name=FORUM_ROLE_GROUP_MODERATOR, course_id=self.course.id) + + # Creating users for with roles + self.author = StaffFactory(course_key=self.course.id, username='Author') + self.staff = StaffFactory(course_key=self.course.id, username='Staff') + + self.moderator = UserFactory(username='Moderator') + self.moderator_role.users.add(self.moderator) + + self.ta = UserFactory(username='TA') + self.ta_role.users.add(self.ta) + + self.group_ta_cohort_1 = UserFactory(username='Group TA 1') + self.group_ta_cohort_2 = UserFactory(username='Group TA 2') + self.group_community_ta_role.users.add(self.group_ta_cohort_1) + self.group_community_ta_role.users.add(self.group_ta_cohort_2) + + self.learner_cohort_1 = UserFactory(username='Learner 1') + self.learner_cohort_2 = UserFactory(username='Learner 2') + self.student_role.users.add(self.learner_cohort_1) + self.student_role.users.add(self.learner_cohort_2) + + # Creating a topic + self.topic_id = 'test_topic' + usage_key = self.course.id.make_usage_key('vertical', self.topic_id) + self.topic = DiscussionTopicLink( + context_key=self.course.id, + usage_key=usage_key, + title=f"Discussion on {self.topic_id}", + external_id=self.topic_id, + provider_id="openedx", + ordering=1, + enabled_in_context=True, + ) + self.notification_to_all_users = [ + self.learner_cohort_1, self.learner_cohort_2, self.staff, + self.moderator, self.ta, self.group_ta_cohort_1, self.group_ta_cohort_2 + ] + self.privileged_users = [ + self.staff, self.moderator, self.ta + ] + self.cohort_1_users = [self.learner_cohort_1, self.group_ta_cohort_1] + self.privileged_users + self.cohort_2_users = [self.learner_cohort_2, self.group_ta_cohort_2] + self.privileged_users + self.thread = self._create_thread() + + def _configure_cohorts(self): + """ + Configure cohort for course and assign membership to users + """ + course_key_str = str(self.course.id) + cohort_settings = CourseCohortsSettings.objects.get(course_id=course_key_str) + cohort_settings.is_cohorted = True + cohort_settings.save() + + discussion_settings = CourseDiscussionSettings.objects.get(course_id=course_key_str) + discussion_settings.always_divide_inline_discussions = True + discussion_settings.save() + + self.first_cohort = CohortFactory(course_id=self.course.id, name="FirstCohort") + self.second_cohort = CohortFactory(course_id=self.course.id, name="SecondCohort") + + CohortMembership.assign(cohort=self.first_cohort, user=self.learner_cohort_1) + CohortMembership.assign(cohort=self.first_cohort, user=self.group_ta_cohort_1) + CohortMembership.assign(cohort=self.second_cohort, user=self.learner_cohort_2) + CohortMembership.assign(cohort=self.second_cohort, user=self.group_ta_cohort_2) + + def _assign_enrollments(self): + """ + Enrolls all the user in the course + """ + user_list = [self.author] + self.notification_to_all_users + for user in user_list: + CourseEnrollment.enroll(user, self.course.id) + + def _create_thread(self, thread_type="discussion", group_id=None): + """ + Create a thread + """ + thread = make_minimal_cs_thread({ + 'id': 1, + 'course_id': str(self.course.id), + "commentable_id": self.topic_id, + "username": self.author.username, + "user_id": str(self.author.id), + "thread_type": thread_type, + "group_id": group_id, + "title": "Test Title", + }) + self.register_get_thread_response(thread) + return thread + + def test_basic(self): + """ + Left empty intentionally. This test case is inherited from DiscussionAPIViewTestMixin + """ + + def test_not_authenticated(self): + """ + Left empty intentionally. This test case is inherited from DiscussionAPIViewTestMixin + """ + + @ddt.data( + ('new_question_post',), + ('new_discussion_post',), + ) + @ddt.unpack + def test_notification_is_send_to_all_enrollments(self, notification_type): + """ + Tests notification is sent to all users if course is not cohorted + """ + self._assign_enrollments() + thread_type = ( + "discussion" + if notification_type == "new_discussion_post" + else ("question" if notification_type == "new_question_post" else "") + ) + thread = self._create_thread(thread_type=thread_type) + handler = mock.Mock() + COURSE_NOTIFICATION_REQUESTED.connect(handler) + send_thread_created_notification(thread['id'], str(self.course.id), self.author.id) + self.assertEqual(handler.call_count, 1) + course_notification_data = handler.call_args[1]['course_notification_data'] + assert notification_type == course_notification_data.notification_type + notification_audience_filters = {} + assert notification_audience_filters == course_notification_data.audience_filters + + @ddt.data( + ('cohort_1', 'new_question_post'), + ('cohort_1', 'new_discussion_post'), + ('cohort_2', 'new_question_post'), + ('cohort_2', 'new_discussion_post'), + ) + @ddt.unpack + def test_notification_is_send_to_cohort_ids(self, cohort_text, notification_type): + """ + Tests if notification is sent only to privileged users and cohort members if the + course is cohorted + """ + self._assign_enrollments() + self._configure_cohorts() + cohort, audience = ( + (self.first_cohort, self.cohort_1_users) + if cohort_text == "cohort_1" + else ((self.second_cohort, self.cohort_2_users) if cohort_text == "cohort_2" else None) + ) + + thread_type = ( + "discussion" + if notification_type == "new_discussion_post" + else ("question" if notification_type == "new_question_post" else "") + ) + + cohort_id = cohort.id + thread = self._create_thread(group_id=cohort_id, thread_type=thread_type) + handler = mock.Mock() + COURSE_NOTIFICATION_REQUESTED.connect(handler) + send_thread_created_notification(thread['id'], str(self.course.id), self.author.id) + course_notification_data = handler.call_args[1]['course_notification_data'] + assert notification_type == course_notification_data.notification_type + notification_audience_filters = { + 'cohorts': [cohort_id], + 'course_roles': ['staff', 'instructor'], + 'discussion_roles': ['Administrator', 'Moderator', 'Community TA'], + } + assert notification_audience_filters == handler.call_args[1]['course_notification_data'].audience_filters + self.assertEqual(handler.call_count, 1) + + +@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) +class TestResponseEndorsedNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestCase): + """ + Test case to send response endorsed notifications + """ + + def setUp(self): + super().setUp() + httpretty.reset() + httpretty.enable() + self.set_mock_return_value('get_course_id_by_thread', str(self.course.id)) + self.set_mock_return_value('get_course_id_by_comment', str(self.course.id)) + self.user_1 = UserFactory.create() + CourseEnrollment.enroll(self.user_1, self.course.id) + self.user_2 = UserFactory.create() + self.user_3 = UserFactory.create() + CourseEnrollment.enroll(self.user_2, self.course.id) + CourseEnrollment.enroll(self.user_3, self.course.id) + + def test_basic(self): + """ + Left empty intentionally. This test case is inherited from DiscussionAPIViewTestMixin + """ + + def test_not_authenticated(self): + """ + Left empty intentionally. This test case is inherited from DiscussionAPIViewTestMixin + """ + + def test_response_endorsed_notifications(self): + """ + Tests response endorsed notifications + """ + thread = ThreadMock(thread_id=1, creator=self.user_1, title='test thread') + response = ThreadMock(thread_id=2, creator=self.user_2, title='test response') + self.register_get_thread_response({ + 'id': thread.id, + 'course_id': str(self.course.id), + 'topic_id': 'abc', + "user_id": thread.user_id, + "username": thread.username, + "thread_type": 'discussion', + "title": thread.title, + "commentable_id": thread.commentable_id, + }) + self.register_get_comment_response({ + 'id': 1, + 'thread_id': thread.id, + 'user_id': response.user_id + }) + self.register_get_comment_response({ + 'id': 2, + 'thread_id': thread.id, + 'user_id': response.user_id + }) + handler = mock.Mock() + USER_NOTIFICATION_REQUESTED.connect(handler) + send_response_endorsed_notifications(thread.id, response.id, str(self.course.id), self.user_3.id) + self.assertEqual(handler.call_count, 2) + + # Test response endorsed on thread notification + notification_data = handler.call_args_list[0][1]['notification_data'] + # Target only the thread author + self.assertEqual([int(user_id) for user_id in notification_data.user_ids], [int(thread.user_id)]) + self.assertEqual(notification_data.notification_type, 'response_endorsed_on_thread') + + expected_context = { + 'replier_name': self.user_2.username, + 'post_title': 'test thread', + 'course_name': self.course.display_name, + 'sender_id': int(self.user_2.id), + 'email_content': 'dummy', + 'response_id': None, + 'topic_id': None, + 'thread_id': 1, + 'comment_id': 2, + } + self.assertDictEqual(notification_data.context, expected_context) + self.assertEqual(notification_data.content_url, _get_mfe_url(self.course.id, thread.id)) + self.assertEqual(notification_data.app_name, 'discussion') + self.assertEqual('response_endorsed_on_thread', notification_data.notification_type) + + # Test response endorsed notification + notification_data = handler.call_args_list[1][1]['notification_data'] + # Target only the response author + self.assertEqual([int(user_id) for user_id in notification_data.user_ids], [int(response.user_id)]) + self.assertEqual(notification_data.notification_type, 'response_endorsed') + + expected_context = { + 'replier_name': response.username, + 'post_title': 'test thread', + 'course_name': self.course.display_name, + 'sender_id': int(response.user_id), + 'email_content': 'dummy', + 'response_id': None, + 'topic_id': None, + 'thread_id': 1, + 'comment_id': 2, + } + self.assertDictEqual(notification_data.context, expected_context) + self.assertEqual(notification_data.content_url, _get_mfe_url(self.course.id, thread.id)) + self.assertEqual(notification_data.app_name, 'discussion') + self.assertEqual('response_endorsed', notification_data.notification_type) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_utils.py b/lms/djangoapps/discussion/rest_api/tests/test_utils.py index db24847a82f3..c341b9de49c1 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_utils.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_utils.py @@ -11,7 +11,6 @@ from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory from lms.djangoapps.discussion.django_comment_client.tests.factories import RoleFactory -from lms.djangoapps.discussion.django_comment_client.tests.utils import ForumsEnableMixin from lms.djangoapps.discussion.rest_api.tests.utils import CommentsServiceMockMixin from lms.djangoapps.discussion.rest_api.utils import ( discussion_open_for_user, @@ -162,7 +161,7 @@ def test_remove_empty_sequentials(self): @ddt.ddt -class TestBlackoutDates(ForumsEnableMixin, CommentsServiceMockMixin, ModuleStoreTestCase): +class TestBlackoutDates(CommentsServiceMockMixin, ModuleStoreTestCase): """ Test for the is_posting_allowed function """ diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views.py b/lms/djangoapps/discussion/rest_api/tests/test_views.py index 9ae03986bb93..619f5c2e7d7b 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views.py @@ -2,315 +2,36 @@ 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.parsers import JSONParser -from rest_framework.test import APIClient, APITestCase -from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE -from lms.djangoapps.discussion.rest_api.utils import get_usernames_from_search_string -from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.django import modulestore -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory, check_mongo_calls +from 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 PatchMediaTypeMixin, UrlResetMixin -from common.test.utils import disable_signal -from lms.djangoapps.discussion.django_comment_client.tests.utils import ( - ForumsEnableMixin, - config_course_discussions, - topic_name_to_id, -) -from lms.djangoapps.discussion.rest_api import api +from common.djangoapps.util.testing import UrlResetMixin from lms.djangoapps.discussion.rest_api.tests.utils import ( - CommentsServiceMockMixin, - ProfileImageTestMixin, + ForumMockUtilsMixin, make_minimal_cs_comment, make_minimal_cs_thread, - make_paginated_api_response, - parsed_body, ) -from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts -from openedx.core.djangoapps.discussions.config.waffle import ENABLE_NEW_STRUCTURE_DISCUSSIONS -from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, DiscussionTopicLink, Provider -from openedx.core.djangoapps.discussions.tasks import update_discussions_settings_from_course_task -from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings, Role -from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles -from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user -from openedx.core.djangoapps.oauth_dispatch.tests.factories import AccessTokenFactory, ApplicationFactory -from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_storage -from openedx.core.djangoapps.user_api.models import RetirementState, UserRetirementStatus - - -class DiscussionAPIViewTestMixin(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMixin): - """ - Mixin for common code in tests of Discussion API views. This includes - creation of common structures (e.g. a course, user, and enrollment), logging - in the test client, utility functions, and a test case for unauthenticated - requests. Subclasses must set self.url in their setUp methods. - """ - - client_class = APIClient - - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - super().setUp() - self.maxDiff = None # pylint: disable=invalid-name - self.course = CourseFactory.create( - org="x", - course="y", - run="z", - start=datetime.now(UTC), - discussion_topics={"Test Topic": {"id": "test_topic"}} - ) - self.password = "Password1234" - self.user = UserFactory.create(password=self.password) - # Ensure that parental controls don't apply to this user - self.user.profile.year_of_birth = 1970 - self.user.profile.save() - CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) - self.client.login(username=self.user.username, password=self.password) - - 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, ): @@ -318,21 +39,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) @@ -346,7 +66,7 @@ def setUp(self): def register_mock_endpoints(self): """ - Register cs_comments_service mocks for sample threads and comments. + Register forum service mocks for sample threads and comments. """ self.register_get_threads_response( threads=[ @@ -502,3146 +222,3 @@ def test_request_with_empty_results_page(self): 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_moderation_privileges": False, - 'is_course_admin': False, - 'is_course_staff': False, - "is_group_ta": False, - 'is_user_admin': False, - "user_roles": ["Student"], - "edit_reasons": [{"code": "test-edit-reason", "label": "Test Edit Reason"}], - "post_close_reasons": [{"code": "test-close-reason", "label": "Test Close Reason"}], - 'show_discussions': True, - } - ) - - -@httpretty.activate -@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class RetireViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): - """Tests for CourseView""" - - def setUp(self): - super().setUp() - RetirementState.objects.create(state_name='PENDING', state_execution_order=1) - self.retire_forums_state = RetirementState.objects.create(state_name='RETIRE_FORUMS', state_execution_order=11) - - self.retirement = UserRetirementStatus.create_retirement(self.user) - self.retirement.current_state = self.retire_forums_state - self.retirement.save() - - self.superuser = SuperuserFactory() - self.superuser_client = APIClient() - self.retired_username = get_retired_username_by_username(self.user.username) - self.url = reverse("retire_discussion_user") - 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 ThreadViewSetListTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase, ProfileImageTestMixin): - """Tests for ThreadViewSet list""" - - def setUp(self): - super().setUp() - self.author = UserFactory.create() - self.url = reverse("thread-list") - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - - def create_source_thread(self, overrides=None): - """ - Create a sample source cs_thread - """ - thread = make_minimal_cs_thread({ - "id": "test_thread", - "course_id": str(self.course.id), - "commentable_id": "test_topic", - "user_id": str(self.user.id), - "username": self.user.username, - "created_at": "2015-04-28T00:00:00Z", - "updated_at": "2015-04-28T11:11:11Z", - "title": "Test Title", - "body": "Test body", - "votes": {"up_count": 4}, - "comments_count": 5, - "unread_comments_count": 3, - }) - - thread.update(overrides or {}) - return thread - - def test_course_id_missing(self): - response = self.client.get(self.url) - self.assert_response_correct( - response, - 400, - {"field_errors": {"course_id": {"developer_message": "This field is required."}}} - ) - - def test_404(self): - response = self.client.get(self.url, {"course_id": "non/existent/course"}) - self.assert_response_correct( - response, - 404, - {"developer_message": "Course not found."} - ) - - def test_basic(self): - self.register_get_user_response(self.user, upvoted_ids=["test_thread"]) - source_threads = [ - self.create_source_thread({"user_id": str(self.author.id), "username": self.author.username}) - ] - expected_threads = [self.expected_thread_data({ - "created_at": "2015-04-28T00:00:00Z", - "updated_at": "2015-04-28T11:11:11Z", - "vote_count": 4, - "comment_count": 6, - "can_delete": False, - "unread_comment_count": 3, - "voted": True, - "author": self.author.username, - "editable_fields": ["abuse_flagged", "copy_link", "following", "read", "voted"], - "abuse_flagged_count": None, - })] - self.register_get_threads_response(source_threads, page=1, num_pages=2) - response = self.client.get(self.url, {"course_id": str(self.course.id), "following": ""}) - expected_response = make_paginated_api_response( - results=expected_threads, - count=1, - num_pages=2, - next_link="http://testserver/api/discussion/v1/threads/?course_id=course-v1%3Ax%2By%2Bz&following=&page=2", - previous_link=None - ) - expected_response.update({"text_search_rewrite": None}) - self.assert_response_correct( - response, - 200, - expected_response - ) - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["10"], - }) - - @ddt.data("unread", "unanswered", "unresponded") - def test_view_query(self, query): - threads = [make_minimal_cs_thread()] - self.register_get_user_response(self.user) - self.register_get_threads_response(threads, page=1, num_pages=1) - self.client.get( - self.url, - { - "course_id": str(self.course.id), - "view": query, - } - ) - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["10"], - query: ["true"], - }) - - def test_pagination(self): - self.register_get_user_response(self.user) - self.register_get_threads_response([], page=1, num_pages=1) - response = self.client.get( - self.url, - {"course_id": str(self.course.id), "page": "18", "page_size": "4"} - ) - self.assert_response_correct( - response, - 404, - {"developer_message": "Page not found (No results on this page)."} - ) - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["18"], - "per_page": ["4"], - }) - - def test_text_search(self): - self.register_get_user_response(self.user) - self.register_get_threads_search_response([], None, num_pages=0) - response = self.client.get( - self.url, - {"course_id": str(self.course.id), "text_search": "test search string"} - ) - - expected_response = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_response.update({"text_search_rewrite": None}) - self.assert_response_correct( - response, - 200, - expected_response - ) - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["10"], - "text": ["test search string"], - }) - - @ddt.data(True, "true", "1") - def test_following_true(self, following): - self.register_get_user_response(self.user) - self.register_subscribed_threads_response(self.user, [], page=1, num_pages=0) - response = self.client.get( - self.url, - { - "course_id": str(self.course.id), - "following": following, - } - ) - - expected_response = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_response.update({"text_search_rewrite": None}) - self.assert_response_correct( - response, - 200, - expected_response - ) - assert urlparse( - httpretty.last_request().path # lint-amnesty, pylint: disable=no-member - ).path == f"/api/v1/users/{self.user.id}/subscribed_threads" - - @ddt.data(False, "false", "0") - def test_following_false(self, following): - response = self.client.get( - self.url, - { - "course_id": str(self.course.id), - "following": following, - } - ) - self.assert_response_correct( - response, - 400, - {"field_errors": { - "following": {"developer_message": "The value of the 'following' parameter must be true."} - }} - ) - - def test_following_error(self): - response = self.client.get( - self.url, - { - "course_id": str(self.course.id), - "following": "invalid-boolean", - } - ) - self.assert_response_correct( - response, - 400, - {"field_errors": { - "following": {"developer_message": "Invalid Boolean Value."} - }} - ) - - @ddt.data( - ("last_activity_at", "activity"), - ("comment_count", "comments"), - ("vote_count", "votes") - ) - @ddt.unpack - def test_order_by(self, http_query, cc_query): - """ - Tests the order_by parameter - - Arguments: - http_query (str): Query string sent in the http request - cc_query (str): Query string used for the comments client service - """ - threads = [make_minimal_cs_thread()] - self.register_get_user_response(self.user) - self.register_get_threads_response(threads, page=1, num_pages=1) - self.client.get( - self.url, - { - "course_id": str(self.course.id), - "order_by": http_query, - } - ) - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "page": ["1"], - "per_page": ["10"], - "sort_key": [cc_query], - }) - - def test_order_direction(self): - """ - Test order direction, of which "desc" is the only valid option. The - option actually just gets swallowed, so it doesn't affect the params. - """ - threads = [make_minimal_cs_thread()] - self.register_get_user_response(self.user) - self.register_get_threads_response(threads, page=1, num_pages=1) - self.client.get( - self.url, - { - "course_id": str(self.course.id), - "order_direction": "desc", - } - ) - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["10"], - }) - - def test_mutually_exclusive(self): - """ - Tests GET thread_list api does not allow filtering on mutually exclusive parameters - """ - self.register_get_user_response(self.user) - self.register_get_threads_search_response([], None, num_pages=0) - response = self.client.get(self.url, { - "course_id": str(self.course.id), - "text_search": "test search string", - "topic_id": "topic1, topic2", - }) - self.assert_response_correct( - response, - 400, - { - "developer_message": "The following query parameters are mutually exclusive: topic_id, " - "text_search, following" - } - ) - - def test_profile_image_requested_field(self): - """ - Tests thread has user profile image details if called in requested_fields - """ - user_2 = UserFactory.create(password=self.password) - # Ensure that parental controls don't apply to this user - user_2.profile.year_of_birth = 1970 - user_2.profile.save() - source_threads = [ - self.create_source_thread(), - self.create_source_thread({"user_id": str(user_2.id), "username": user_2.username}), - ] - - self.register_get_user_response(self.user, upvoted_ids=["test_thread"]) - self.register_get_threads_response(source_threads, page=1, num_pages=1) - self.create_profile_image(self.user, get_profile_image_storage()) - self.create_profile_image(user_2, get_profile_image_storage()) - - response = self.client.get( - self.url, - {"course_id": str(self.course.id), "requested_fields": "profile_image"}, - ) - assert response.status_code == 200 - response_threads = json.loads(response.content.decode('utf-8'))['results'] - - for response_thread in response_threads: - expected_profile_data = self.get_expected_user_profile(response_thread['author']) - response_users = response_thread['users'] - assert expected_profile_data == response_users[response_thread['author']] - - def test_profile_image_requested_field_anonymous_user(self): - """ - Tests profile_image in requested_fields for thread created with anonymous user - """ - source_threads = [ - self.create_source_thread( - {"user_id": None, "username": None, "anonymous": True, "anonymous_to_peers": True} - ), - ] - - self.register_get_user_response(self.user, upvoted_ids=["test_thread"]) - self.register_get_threads_response(source_threads, page=1, num_pages=1) - - response = self.client.get( - self.url, - {"course_id": str(self.course.id), "requested_fields": "profile_image"}, - ) - assert response.status_code == 200 - response_thread = json.loads(response.content.decode('utf-8'))['results'][0] - assert response_thread['author'] is None - assert {} == response_thread['users'] - - -@httpretty.activate -@disable_signal(api, 'thread_created') -@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class ThreadViewSetCreateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): - """Tests for ThreadViewSet create""" - - def setUp(self): - super().setUp() - self.url = reverse("thread-list") - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - - def test_basic(self): - self.register_get_user_response(self.user) - cs_thread = make_minimal_cs_thread({ - "id": "test_thread", - "username": self.user.username, - "read": True, - }) - self.register_post_thread_response(cs_thread) - request_data = { - "course_id": str(self.course.id), - "topic_id": "test_topic", - "type": "discussion", - "title": "Test Title", - "raw_body": "# Test \n This is a very long body but will not be truncated for the preview.", - } - response = self.client.post( - self.url, - json.dumps(request_data), - content_type="application/json" - ) - assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data == self.expected_thread_data({ - "read": True, - "raw_body": "# Test \n This is a very long body but will not be truncated for the preview.", - "preview_body": "Test This is a very long body but will not be truncated for the preview.", - "rendered_body": "

Test

\n

This is a very long body but will not be truncated for" - " the preview.

", - }) - assert parsed_body(httpretty.last_request()) == { - 'course_id': [str(self.course.id)], - 'commentable_id': ['test_topic'], - 'thread_type': ['discussion'], - 'title': ['Test Title'], - 'body': ['# Test \n This is a very long body but will not be truncated for the preview.'], - 'user_id': [str(self.user.id)], - 'anonymous': ['False'], - 'anonymous_to_peers': ['False'], - } - - def test_error(self): - request_data = { - "topic_id": "dummy", - "type": "discussion", - "title": "dummy", - "raw_body": "dummy", - } - response = self.client.post( - self.url, - json.dumps(request_data), - content_type="application/json" - ) - expected_response_data = { - "field_errors": {"course_id": {"developer_message": "This field is required."}} - } - assert response.status_code == 400 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data == expected_response_data - - -@ddt.ddt -@httpretty.activate -@disable_signal(api, 'thread_edited') -@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class ThreadViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin): - """Tests for ThreadViewSet partial_update""" - - def setUp(self): - self.unsupported_media_type = JSONParser.media_type - super().setUp() - self.url = reverse("thread-detail", kwargs={"thread_id": "test_thread"}) - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" - ) - self.mock_get_course_id_by_thread = patcher.start() - self.addCleanup(patcher.stop) - - def test_basic(self): - self.register_get_user_response(self.user) - self.register_thread({ - "created_at": "Test Created Date", - "updated_at": "Test Updated Date", - "read": True, - "resp_total": 2, - }) - request_data = {"raw_body": "Edited body"} - response = self.request_patch(request_data) - assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data == self.expected_thread_data({ - 'raw_body': 'Edited body', - 'rendered_body': '

Edited body

', - 'preview_body': 'Edited body', - 'editable_fields': [ - 'abuse_flagged', 'anonymous', 'copy_link', 'following', 'raw_body', 'read', - 'title', 'topic_id', 'type' - ], - 'created_at': 'Test Created Date', - 'updated_at': 'Test Updated Date', - 'comment_count': 1, - 'read': True, - 'response_count': 2, - }) - assert parsed_body(httpretty.last_request()) == { - 'course_id': [str(self.course.id)], - 'commentable_id': ['test_topic'], - 'thread_type': ['discussion'], - 'title': ['Test Title'], - 'body': ['Edited body'], - 'user_id': [str(self.user.id)], - 'anonymous': ['False'], - 'anonymous_to_peers': ['False'], - 'closed': ['False'], - 'pinned': ['False'], - 'read': ['True'], - 'editing_user_id': [str(self.user.id)], - } - - def test_error(self): - self.register_get_user_response(self.user) - self.register_thread() - request_data = {"title": ""} - response = self.request_patch(request_data) - expected_response_data = { - "field_errors": {"title": {"developer_message": "This field may not be blank."}} - } - assert response.status_code == 400 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data == expected_response_data - - @ddt.data( - ("abuse_flagged", True), - ("abuse_flagged", False), - ) - @ddt.unpack - def test_closed_thread(self, field, value): - self.register_get_user_response(self.user) - self.register_thread({"closed": True, "read": True}) - self.register_flag_response("thread", "test_thread") - request_data = {field: value} - response = self.request_patch(request_data) - assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data == self.expected_thread_data({ - 'read': True, - 'closed': True, - 'abuse_flagged': value, - 'editable_fields': ['abuse_flagged', 'copy_link', 'read'], - 'comment_count': 1, 'unread_comment_count': 0 - }) - - @ddt.data( - ("raw_body", "Edited body"), - ("voted", True), - ("following", True), - ) - @ddt.unpack - def test_closed_thread_error(self, field, value): - self.register_get_user_response(self.user) - self.register_thread({"closed": True}) - self.register_flag_response("thread", "test_thread") - request_data = {field: value} - response = self.request_patch(request_data) - assert response.status_code == 400 - - def test_patch_read_owner_user(self): - self.register_get_user_response(self.user) - self.register_thread({"resp_total": 2}) - self.register_read_response(self.user, "thread", "test_thread") - request_data = {"read": True} - - response = self.request_patch(request_data) - assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data == self.expected_thread_data({ - 'comment_count': 1, - 'read': True, - 'editable_fields': [ - 'abuse_flagged', 'anonymous', 'copy_link', 'following', 'raw_body', 'read', - 'title', 'topic_id', 'type' - ], - 'response_count': 2 - }) - - def test_patch_read_non_owner_user(self): - self.register_get_user_response(self.user) - thread_owner_user = UserFactory.create(password=self.password) - CourseEnrollmentFactory.create(user=thread_owner_user, course_id=self.course.id) - self.register_get_user_response(thread_owner_user) - self.register_thread({ - "username": thread_owner_user.username, - "user_id": str(thread_owner_user.id), - "resp_total": 2, - }) - self.register_read_response(self.user, "thread", "test_thread") - - request_data = {"read": True} - response = self.request_patch(request_data) - assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data == self.expected_thread_data({ - 'author': str(thread_owner_user.username), - 'comment_count': 1, - 'can_delete': False, - 'read': True, - 'editable_fields': ['abuse_flagged', 'copy_link', 'following', 'read', 'voted'], - 'response_count': 2 - }) - - -@httpretty.activate -@disable_signal(api, 'thread_deleted') -@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class ThreadViewSetDeleteTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): - """Tests for ThreadViewSet delete""" - - def setUp(self): - super().setUp() - self.url = reverse("thread-detail", kwargs={"thread_id": "test_thread"}) - self.thread_id = "test_thread" - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" - ) - self.mock_get_course_id_by_thread = patcher.start() - self.addCleanup(patcher.stop) - - def test_basic(self): - self.register_get_user_response(self.user) - cs_thread = make_minimal_cs_thread({ - "id": self.thread_id, - "course_id": str(self.course.id), - "username": self.user.username, - "user_id": str(self.user.id), - }) - self.register_get_thread_response(cs_thread) - self.register_delete_thread_response(self.thread_id) - response = self.client.delete(self.url) - assert response.status_code == 204 - assert response.content == b'' - assert urlparse(httpretty.last_request().path).path == f"/api/v1/threads/{self.thread_id}" # lint-amnesty, pylint: disable=no-member - assert httpretty.last_request().method == 'DELETE' - - def test_delete_nonexistent_thread(self): - self.register_get_thread_error_response(self.thread_id, 404) - response = self.client.delete(self.url) - assert response.status_code == 404 - - -@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 -@httpretty.activate -@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class CommentViewSetListTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase, ProfileImageTestMixin): - """Tests for CommentViewSet list""" - - def setUp(self): - super().setUp() - self.author = UserFactory.create() - self.url = reverse("comment-list") - self.thread_id = "test_thread" - self.storage = get_profile_image_storage() - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" - ) - self.mock_get_course_id_by_thread = patcher.start() - self.addCleanup(patcher.stop) - - def create_source_comment(self, overrides=None): - """ - Create a sample source cs_comment - """ - comment = make_minimal_cs_comment({ - "id": "test_comment", - "thread_id": self.thread_id, - "user_id": str(self.user.id), - "username": self.user.username, - "created_at": "2015-05-11T00:00:00Z", - "updated_at": "2015-05-11T11:11:11Z", - "body": "Test body", - "votes": {"up_count": 4}, - }) - - comment.update(overrides or {}) - return comment - - def make_minimal_cs_thread(self, overrides=None): - """ - Create a thread with the given overrides, plus the course_id if not - already in overrides. - """ - overrides = overrides.copy() if overrides else {} - overrides.setdefault("course_id", str(self.course.id)) - return make_minimal_cs_thread(overrides) - - def expected_response_comment(self, overrides=None): - """ - create expected response data - """ - response_data = { - "id": "test_comment", - "thread_id": self.thread_id, - "parent_id": None, - "author": self.author.username, - "author_label": None, - "created_at": "1970-01-01T00:00:00Z", - "updated_at": "1970-01-01T00:00:00Z", - "raw_body": "dummy", - "rendered_body": "

dummy

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

Test body

", - "created_at": "2015-05-11T00:00:00Z", - "updated_at": "2015-05-11T11:11:11Z", - })] - self.register_get_thread_response({ - "id": self.thread_id, - "course_id": str(self.course.id), - "thread_type": "discussion", - "children": source_comments, - "resp_total": 100, - }) - response = self.client.get(self.url, {"thread_id": self.thread_id}) - next_link = "http://testserver/api/discussion/v1/comments/?page=2&thread_id={}".format( - self.thread_id - ) - self.assert_response_correct( - response, - 200, - make_paginated_api_response( - results=expected_comments, count=100, num_pages=10, next_link=next_link, previous_link=None - ) - ) - self.assert_query_params_equal( - httpretty.httpretty.latest_requests[-2], - { - "resp_skip": ["0"], - "resp_limit": ["10"], - "user_id": [str(self.user.id)], - "mark_as_read": ["False"], - "recursive": ["False"], - "with_responses": ["True"], - "reverse_order": ["False"], - "merge_question_type_responses": ["False"], - } - ) - - def test_pagination(self): - """ - Test that pagination parameters are correctly plumbed through to the - comments service and that a 404 is correctly returned if a page past the - end is requested - """ - self.register_get_user_response(self.user) - self.register_get_thread_response(make_minimal_cs_thread({ - "id": self.thread_id, - "course_id": str(self.course.id), - "thread_type": "discussion", - "resp_total": 10, - })) - response = self.client.get( - self.url, - {"thread_id": self.thread_id, "page": "18", "page_size": "4"} - ) - self.assert_response_correct( - response, - 404, - {"developer_message": "Page not found (No results on this page)."} - ) - self.assert_query_params_equal( - httpretty.httpretty.latest_requests[-2], - { - "resp_skip": ["68"], - "resp_limit": ["4"], - "user_id": [str(self.user.id)], - "mark_as_read": ["False"], - "recursive": ["False"], - "with_responses": ["True"], - "reverse_order": ["False"], - "merge_question_type_responses": ["False"], - } - ) - - def test_question_content_with_merge_question_type_responses(self): - self.register_get_user_response(self.user) - thread = self.make_minimal_cs_thread({ - "thread_type": "question", - "children": [make_minimal_cs_comment({ - "id": "endorsed_comment", - "user_id": self.user.id, - "username": self.user.username, - "endorsed": True, - }), - make_minimal_cs_comment({ - "id": "non_endorsed_comment", - "user_id": self.user.id, - "username": self.user.username, - "endorsed": False, - })], - "resp_total": 2, - }) - self.register_get_thread_response(thread) - response = self.client.get(self.url, { - "thread_id": thread["id"], - "merge_question_type_responses": True - }) - parsed_content = json.loads(response.content.decode('utf-8')) - assert parsed_content['results'][0]['id'] == "endorsed_comment" - assert parsed_content['results'][1]['id'] == "non_endorsed_comment" - - @ddt.data( - (True, "endorsed_comment"), - ("true", "endorsed_comment"), - ("1", "endorsed_comment"), - (False, "non_endorsed_comment"), - ("false", "non_endorsed_comment"), - ("0", "non_endorsed_comment"), - ) - @ddt.unpack - def test_question_content(self, endorsed, comment_id): - self.register_get_user_response(self.user) - thread = self.make_minimal_cs_thread({ - "thread_type": "question", - "endorsed_responses": [make_minimal_cs_comment({ - "id": "endorsed_comment", - "user_id": self.user.id, - "username": self.user.username, - })], - "non_endorsed_responses": [make_minimal_cs_comment({ - "id": "non_endorsed_comment", - "user_id": self.user.id, - "username": self.user.username, - })], - "non_endorsed_resp_total": 1, - }) - self.register_get_thread_response(thread) - response = self.client.get(self.url, { - "thread_id": thread["id"], - "endorsed": endorsed, - }) - parsed_content = json.loads(response.content.decode('utf-8')) - assert parsed_content['results'][0]['id'] == comment_id - - def test_question_invalid_endorsed(self): - response = self.client.get(self.url, { - "thread_id": self.thread_id, - "endorsed": "invalid-boolean" - }) - self.assert_response_correct( - response, - 400, - {"field_errors": { - "endorsed": {"developer_message": "Invalid Boolean Value."} - }} - ) - - def test_question_missing_endorsed(self): - self.register_get_user_response(self.user) - thread = self.make_minimal_cs_thread({ - "thread_type": "question", - "endorsed_responses": [make_minimal_cs_comment({"id": "endorsed_comment"})], - "non_endorsed_responses": [make_minimal_cs_comment({"id": "non_endorsed_comment"})], - "non_endorsed_resp_total": 1, - }) - self.register_get_thread_response(thread) - response = self.client.get(self.url, { - "thread_id": thread["id"] - }) - self.assert_response_correct( - response, - 400, - {"field_errors": { - "endorsed": {"developer_message": "This field is required for question threads."} - }} - ) - - @ddt.data( - ("discussion", False), - ("question", True) - ) - @ddt.unpack - def test_child_comments_count(self, thread_type, merge_question_type_responses): - self.register_get_user_response(self.user) - response_1 = make_minimal_cs_comment({ - "id": "test_response_1", - "thread_id": self.thread_id, - "user_id": str(self.author.id), - "username": self.author.username, - "child_count": 2, - }) - response_2 = make_minimal_cs_comment({ - "id": "test_response_2", - "thread_id": self.thread_id, - "user_id": str(self.author.id), - "username": self.author.username, - "child_count": 3, - }) - thread = self.make_minimal_cs_thread({ - "id": self.thread_id, - "course_id": str(self.course.id), - "thread_type": thread_type, - "children": [response_1, response_2], - "resp_total": 2, - "comments_count": 8, - "unread_comments_count": 0, - }) - self.register_get_thread_response(thread) - response = self.client.get(self.url, { - "thread_id": self.thread_id, - "merge_question_type_responses": merge_question_type_responses}) - expected_comments = [ - self.expected_response_comment(overrides={"id": "test_response_1", "child_count": 2, "can_delete": False}), - self.expected_response_comment(overrides={"id": "test_response_2", "child_count": 3, "can_delete": False}), - ] - self.assert_response_correct( - response, - 200, - { - "results": expected_comments, - "pagination": { - "count": 2, - "next": None, - "num_pages": 1, - "previous": None, - } - } - ) - - def test_profile_image_requested_field(self): - """ - Tests all comments retrieved have user profile image details if called in requested_fields - """ - source_comments = [self.create_source_comment()] - self.register_get_thread_response({ - "id": self.thread_id, - "course_id": str(self.course.id), - "thread_type": "discussion", - "children": source_comments, - "resp_total": 100, - }) - self.register_get_user_response(self.user, upvoted_ids=["test_comment"]) - self.create_profile_image(self.user, get_profile_image_storage()) - - response = self.client.get(self.url, {"thread_id": self.thread_id, "requested_fields": "profile_image"}) - assert response.status_code == 200 - response_comments = json.loads(response.content.decode('utf-8'))['results'] - for response_comment in response_comments: - expected_profile_data = self.get_expected_user_profile(response_comment['author']) - response_users = response_comment['users'] - assert expected_profile_data == response_users[response_comment['author']] - - def test_profile_image_requested_field_endorsed_comments(self): - """ - Tests all comments have user profile image details for both author and endorser - if called in requested_fields for endorsed threads - """ - endorser_user = UserFactory.create(password=self.password) - # Ensure that parental controls don't apply to this user - endorser_user.profile.year_of_birth = 1970 - endorser_user.profile.save() - - self.register_get_user_response(self.user) - thread = self.make_minimal_cs_thread({ - "thread_type": "question", - "endorsed_responses": [make_minimal_cs_comment({ - "id": "endorsed_comment", - "user_id": self.user.id, - "username": self.user.username, - "endorsed": True, - "endorsement": {"user_id": endorser_user.id, "time": "2016-05-10T08:51:28Z"}, - })], - "non_endorsed_responses": [make_minimal_cs_comment({ - "id": "non_endorsed_comment", - "user_id": self.user.id, - "username": self.user.username, - })], - "non_endorsed_resp_total": 1, - }) - self.register_get_thread_response(thread) - self.create_profile_image(self.user, get_profile_image_storage()) - self.create_profile_image(endorser_user, get_profile_image_storage()) - - response = self.client.get(self.url, { - "thread_id": thread["id"], - "endorsed": True, - "requested_fields": "profile_image", - }) - assert response.status_code == 200 - response_comments = json.loads(response.content.decode('utf-8'))['results'] - for response_comment in response_comments: - expected_author_profile_data = self.get_expected_user_profile(response_comment['author']) - expected_endorser_profile_data = self.get_expected_user_profile(response_comment['endorsed_by']) - response_users = response_comment['users'] - assert expected_author_profile_data == response_users[response_comment['author']] - assert expected_endorser_profile_data == response_users[response_comment['endorsed_by']] - - def test_profile_image_request_for_null_endorsed_by(self): - """ - Tests if 'endorsed' is True but 'endorsed_by' is null, the api does not crash. - This is the case for some old/stale data in prod/stage environments. - """ - self.register_get_user_response(self.user) - thread = self.make_minimal_cs_thread({ - "thread_type": "question", - "endorsed_responses": [make_minimal_cs_comment({ - "id": "endorsed_comment", - "user_id": self.user.id, - "username": self.user.username, - "endorsed": True, - })], - "non_endorsed_resp_total": 0, - }) - self.register_get_thread_response(thread) - self.create_profile_image(self.user, get_profile_image_storage()) - - response = self.client.get(self.url, { - "thread_id": thread["id"], - "endorsed": True, - "requested_fields": "profile_image", - }) - assert response.status_code == 200 - response_comments = json.loads(response.content.decode('utf-8'))['results'] - for response_comment in response_comments: - expected_author_profile_data = self.get_expected_user_profile(response_comment['author']) - response_users = response_comment['users'] - assert expected_author_profile_data == response_users[response_comment['author']] - assert response_comment['endorsed_by'] not in response_users - - def test_reverse_order_sort(self): - """ - Tests if reverse_order param is passed to cs comments service - """ - self.register_get_user_response(self.user, upvoted_ids=["test_comment"]) - source_comments = [ - self.create_source_comment({"user_id": str(self.author.id), "username": self.author.username}) - ] - self.register_get_thread_response({ - "id": self.thread_id, - "course_id": str(self.course.id), - "thread_type": "discussion", - "children": source_comments, - "resp_total": 100, - }) - self.client.get(self.url, {"thread_id": self.thread_id, "reverse_order": True}) - self.assert_query_params_equal( - httpretty.httpretty.latest_requests[-2], - { - "resp_skip": ["0"], - "resp_limit": ["10"], - "user_id": [str(self.user.id)], - "mark_as_read": ["False"], - "recursive": ["False"], - "with_responses": ["True"], - "reverse_order": ["True"], - "merge_question_type_responses": ["False"], - } - ) - - -@httpretty.activate -@disable_signal(api, 'comment_deleted') -@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class CommentViewSetDeleteTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): - """Tests for ThreadViewSet delete""" - - def setUp(self): - super().setUp() - self.url = reverse("comment-detail", kwargs={"comment_id": "test_comment"}) - self.comment_id = "test_comment" - 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) - - def test_basic(self): - self.register_get_user_response(self.user) - cs_thread = make_minimal_cs_thread({ - "id": "test_thread", - "course_id": str(self.course.id), - }) - self.register_get_thread_response(cs_thread) - cs_comment = make_minimal_cs_comment({ - "id": self.comment_id, - "course_id": cs_thread["course_id"], - "thread_id": cs_thread["id"], - "username": self.user.username, - "user_id": str(self.user.id), - }) - self.register_get_comment_response(cs_comment) - self.register_delete_comment_response(self.comment_id) - response = self.client.delete(self.url) - assert response.status_code == 204 - assert response.content == b'' - assert urlparse(httpretty.last_request().path).path == f"/api/v1/comments/{self.comment_id}" # lint-amnesty, pylint: disable=no-member - assert httpretty.last_request().method == 'DELETE' - - def test_delete_nonexistent_comment(self): - self.register_get_comment_error_response(self.comment_id, 404) - response = self.client.delete(self.url) - assert response.status_code == 404 - - -@httpretty.activate -@disable_signal(api, 'comment_created') -@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -@mock.patch("lms.djangoapps.discussion.signals.handlers.send_response_notifications", new=mock.Mock()) -class CommentViewSetCreateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): - """Tests for CommentViewSet create""" - - def setUp(self): - super().setUp() - self.url = reverse("comment-list") - 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) - - def test_basic(self): - self.register_get_user_response(self.user) - self.register_thread() - self.register_comment() - request_data = { - "thread_id": "test_thread", - "raw_body": "Test body", - } - expected_response_data = { - "id": "test_comment", - "thread_id": "test_thread", - "parent_id": None, - "author": self.user.username, - "author_label": None, - "created_at": "1970-01-01T00:00:00Z", - "updated_at": "1970-01-01T00:00:00Z", - "raw_body": "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": 0, - "children": [], - "editable_fields": ["abuse_flagged", "anonymous", "raw_body"], - "child_count": 0, - "can_delete": True, - "anonymous": False, - "anonymous_to_peers": False, - "last_edit": None, - "edit_by_label": None, - "profile_image": { - "has_image": False, - "image_url_full": "http://testserver/static/default_500.png", - "image_url_large": "http://testserver/static/default_120.png", - "image_url_medium": "http://testserver/static/default_50.png", - "image_url_small": "http://testserver/static/default_30.png", - }, - } - response = self.client.post( - self.url, - json.dumps(request_data), - content_type="application/json" - ) - assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data == expected_response_data - assert urlparse(httpretty.last_request().path).path == '/api/v1/threads/test_thread/comments' # lint-amnesty, pylint: disable=no-member - assert parsed_body(httpretty.last_request()) == { - 'course_id': [str(self.course.id)], - 'body': ['Test body'], - 'user_id': [str(self.user.id)], - 'anonymous': ['False'], - 'anonymous_to_peers': ['False'], - } - - def test_error(self): - response = self.client.post( - self.url, - json.dumps({}), - content_type="application/json" - ) - expected_response_data = { - "field_errors": {"thread_id": {"developer_message": "This field is required."}} - } - assert response.status_code == 400 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data == expected_response_data - - def test_closed_thread(self): - self.register_get_user_response(self.user) - self.register_thread({"closed": True}) - self.register_comment() - request_data = { - "thread_id": "test_thread", - "raw_body": "Test body" - } - response = self.client.post( - self.url, - json.dumps(request_data), - content_type="application/json" - ) - assert response.status_code == 403 - - -@ddt.ddt -@disable_signal(api, 'comment_edited') -@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class CommentViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin): - """Tests for CommentViewSet partial_update""" - - def setUp(self): - self.unsupported_media_type = JSONParser.media_type - super().setUp() - httpretty.reset() - httpretty.enable() - self.addCleanup(httpretty.reset) - self.addCleanup(httpretty.disable) - 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.register_get_user_response(self.user) - self.url = reverse("comment-detail", kwargs={"comment_id": "test_comment"}) - - def expected_response_data(self, overrides=None): - """ - create expected response data from comment update endpoint - """ - response_data = { - "id": "test_comment", - "thread_id": "test_thread", - "parent_id": None, - "author": self.user.username, - "author_label": None, - "created_at": "1970-01-01T00:00:00Z", - "updated_at": "1970-01-01T00:00:00Z", - "raw_body": "Original body", - "rendered_body": "

Original body

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

Edited body

', - 'editable_fields': ['abuse_flagged', 'anonymous', 'raw_body'], - 'created_at': 'Test Created Date', - 'updated_at': 'Test Updated Date' - }) - assert parsed_body(httpretty.last_request()) == { - 'body': ['Edited body'], - 'course_id': [str(self.course.id)], - 'user_id': [str(self.user.id)], - 'anonymous': ['False'], - 'anonymous_to_peers': ['False'], - 'endorsed': ['False'], - 'editing_user_id': [str(self.user.id)], - } - - def test_error(self): - self.register_thread() - self.register_comment() - request_data = {"raw_body": ""} - response = self.request_patch(request_data) - expected_response_data = { - "field_errors": {"raw_body": {"developer_message": "This field may not be blank."}} - } - assert response.status_code == 400 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data == expected_response_data - - @ddt.data( - ("abuse_flagged", True), - ("abuse_flagged", False), - ) - @ddt.unpack - def test_closed_thread(self, field, value): - self.register_thread({"closed": True}) - self.register_comment() - self.register_flag_response("comment", "test_comment") - request_data = {field: value} - response = self.request_patch(request_data) - assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data == self.expected_response_data({ - 'abuse_flagged': value, - "abuse_flagged_any_user": None, - 'editable_fields': ['abuse_flagged'] - }) - - @ddt.data( - ("raw_body", "Edited body"), - ("voted", True), - ("following", True), - ) - @ddt.unpack - def test_closed_thread_error(self, field, value): - self.register_thread({"closed": True}) - self.register_comment() - request_data = {field: value} - response = self.request_patch(request_data) - assert response.status_code == 400 - - -@httpretty.activate -@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class ThreadViewSetRetrieveTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase, ProfileImageTestMixin): - """Tests for ThreadViewSet Retrieve""" - - def setUp(self): - super().setUp() - self.url = reverse("thread-detail", kwargs={"thread_id": "test_thread"}) - self.thread_id = "test_thread" - 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) - - def test_basic(self): - self.register_get_user_response(self.user) - cs_thread = make_minimal_cs_thread({ - "id": self.thread_id, - "course_id": str(self.course.id), - "commentable_id": "test_topic", - "username": self.user.username, - "user_id": str(self.user.id), - "title": "Test Title", - "body": "Test body", - }) - self.register_get_thread_response(cs_thread) - response = self.client.get(self.url) - assert response.status_code == 200 - assert json.loads(response.content.decode('utf-8')) == self.expected_thread_data({'unread_comment_count': 1}) - assert httpretty.last_request().method == 'GET' - - def test_retrieve_nonexistent_thread(self): - self.register_get_thread_error_response(self.thread_id, 404) - response = self.client.get(self.url) - assert response.status_code == 404 - - def test_profile_image_requested_field(self): - """ - Tests thread has user profile image details if called in requested_fields - """ - self.register_get_user_response(self.user) - cs_thread = make_minimal_cs_thread({ - "id": self.thread_id, - "course_id": str(self.course.id), - "username": self.user.username, - "user_id": str(self.user.id), - }) - self.register_get_thread_response(cs_thread) - self.create_profile_image(self.user, get_profile_image_storage()) - response = self.client.get(self.url, {"requested_fields": "profile_image"}) - assert response.status_code == 200 - expected_profile_data = self.get_expected_user_profile(self.user.username) - response_users = json.loads(response.content.decode('utf-8'))['users'] - assert expected_profile_data == response_users[self.user.username] - - -@httpretty.activate -@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class CommentViewSetRetrieveTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase, ProfileImageTestMixin): - """Tests for CommentViewSet Retrieve""" - - def setUp(self): - super().setUp() - self.url = reverse("comment-detail", kwargs={"comment_id": "test_comment"}) - self.thread_id = "test_thread" - self.comment_id = "test_comment" - 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) - - def make_comment_data(self, comment_id, parent_id=None, children=[]): # pylint: disable=W0102 - """ - Returns comment dict object as returned by comments service - """ - return make_minimal_cs_comment({ - "id": comment_id, - "parent_id": parent_id, - "course_id": str(self.course.id), - "thread_id": self.thread_id, - "thread_type": "discussion", - "username": self.user.username, - "user_id": str(self.user.id), - "created_at": "2015-06-03T00:00:00Z", - "updated_at": "2015-06-03T00:00:00Z", - "body": "Original body", - "children": children, - }) - - def test_basic(self): - self.register_get_user_response(self.user) - cs_comment_child = self.make_comment_data("test_child_comment", self.comment_id, children=[]) - cs_comment = self.make_comment_data(self.comment_id, None, [cs_comment_child]) - cs_thread = make_minimal_cs_thread({ - "id": self.thread_id, - "course_id": str(self.course.id), - "children": [cs_comment], - }) - self.register_get_thread_response(cs_thread) - self.register_get_comment_response(cs_comment) - - expected_response_data = { - "id": "test_child_comment", - "parent_id": self.comment_id, - "thread_id": self.thread_id, - "author": self.user.username, - "author_label": None, - "raw_body": "Original body", - "rendered_body": "

Original body

", - "created_at": "2015-06-03T00:00:00Z", - "updated_at": "2015-06-03T00:00:00Z", - "children": [], - "endorsed_at": None, - "endorsed": False, - "endorsed_by": None, - "endorsed_by_label": None, - "voted": False, - "vote_count": 0, - "abuse_flagged": False, - "abuse_flagged_any_user": None, - "editable_fields": ["abuse_flagged", "anonymous", "raw_body"], - "child_count": 0, - "can_delete": True, - "anonymous": False, - "anonymous_to_peers": False, - "last_edit": None, - "edit_by_label": None, - "profile_image": { - "has_image": False, - "image_url_full": "http://testserver/static/default_500.png", - "image_url_large": "http://testserver/static/default_120.png", - "image_url_medium": "http://testserver/static/default_50.png", - "image_url_small": "http://testserver/static/default_30.png", - }, - } - - response = self.client.get(self.url) - assert response.status_code == 200 - assert json.loads(response.content.decode('utf-8'))['results'][0] == expected_response_data - - def test_retrieve_nonexistent_comment(self): - self.register_get_comment_error_response(self.comment_id, 404) - response = self.client.get(self.url) - assert response.status_code == 404 - - def test_pagination(self): - """ - Test that pagination parameters are correctly plumbed through to the - comments service and that a 404 is correctly returned if a page past the - end is requested - """ - self.register_get_user_response(self.user) - cs_comment_child = self.make_comment_data("test_child_comment", self.comment_id, children=[]) - cs_comment = self.make_comment_data(self.comment_id, None, [cs_comment_child]) - cs_thread = make_minimal_cs_thread({ - "id": self.thread_id, - "course_id": str(self.course.id), - "children": [cs_comment], - }) - self.register_get_thread_response(cs_thread) - self.register_get_comment_response(cs_comment) - response = self.client.get( - self.url, - {"comment_id": self.comment_id, "page": "18", "page_size": "4"} - ) - self.assert_response_correct( - response, - 404, - {"developer_message": "Page not found (No results on this page)."} - ) - - def test_profile_image_requested_field(self): - """ - Tests all comments retrieved have user profile image details if called in requested_fields - """ - self.register_get_user_response(self.user) - cs_comment_child = self.make_comment_data('test_child_comment', self.comment_id, children=[]) - cs_comment = self.make_comment_data(self.comment_id, None, [cs_comment_child]) - cs_thread = make_minimal_cs_thread({ - 'id': self.thread_id, - 'course_id': str(self.course.id), - 'children': [cs_comment], - }) - self.register_get_thread_response(cs_thread) - self.register_get_comment_response(cs_comment) - self.create_profile_image(self.user, get_profile_image_storage()) - - response = self.client.get(self.url, {'requested_fields': 'profile_image'}) - assert response.status_code == 200 - response_comments = json.loads(response.content.decode('utf-8'))['results'] - - for response_comment in response_comments: - expected_profile_data = self.get_expected_user_profile(response_comment['author']) - response_users = response_comment['users'] - assert expected_profile_data == response_users[response_comment['author']] - - -@ddt.ddt -class CourseDiscussionSettingsAPIViewTest(APITestCase, UrlResetMixin, ModuleStoreTestCase): - """ - Test the course discussion settings handler API endpoint. - """ - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - super().setUp() - self.course = CourseFactory.create( - org="x", - course="y", - run="z", - start=datetime.now(UTC), - discussion_topics={"Test Topic": {"id": "test_topic"}} - ) - self.path = reverse('discussion_course_settings', kwargs={'course_id': str(self.course.id)}) - self.password = self.TEST_PASSWORD - self.user = UserFactory(username='staff', password=self.password, is_staff=True) - 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 new file mode 100644 index 000000000000..10251224ad7d --- /dev/null +++ b/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py @@ -0,0 +1,2669 @@ +""" +Tests for the external REST API endpoints of the Discussion API (views_v2.py). + +This module focuses on integration tests for the Django REST Framework views that expose the Discussion API. +It verifies the correct behavior of the API endpoints, including authentication, permissions, request/response formats, +and integration with the underlying discussion service. These tests ensure that the endpoints correctly handle +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 +import httpretty +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import override_settings +from django.urls import reverse +from edx_toggles.toggles.testutils import override_waffle_flag +from opaque_keys.edx.keys import CourseKey +from pytz import UTC +from rest_framework import status +from rest_framework.parsers import JSONParser +from rest_framework.test import APIClient, APITestCase + +from lms.djangoapps.discussion.django_comment_client.tests.utils import ( + config_course_discussions, + topic_name_to_id, +) +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 ( + 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 + + +class DiscussionAPIViewTestMixin(ForumMockUtilsMixin, 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) + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + 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() + + +@ddt.ddt +@httpretty.activate +@disable_signal(api, "thread_edited") +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class ThreadViewSetPartialUpdateTest( + DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin +): + """Tests for ThreadViewSet partial_update""" + + def setUp(self): + self.unsupported_media_type = JSONParser.media_type + super().setUp() + self.url = reverse("thread-detail", kwargs={"thread_id": "test_thread"}) + + def test_basic(self): + self.register_get_user_response(self.user) + self.register_thread( + { + "created_at": "Test Created Date", + "updated_at": "Test Updated Date", + "read": True, + "resp_total": 2, + } + ) + request_data = {"raw_body": "Edited body"} + response = self.request_patch(request_data) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == self.expected_thread_data( + { + "raw_body": "Edited body", + "rendered_body": "

Edited body

", + "preview_body": "Edited body", + "editable_fields": [ + "abuse_flagged", + "anonymous", + "copy_link", + "following", + "raw_body", + "read", + "title", + "topic_id", + "type", + ], + "created_at": "Test Created Date", + "updated_at": "Test Updated Date", + "comment_count": 1, + "read": True, + "response_count": 2, + } + ) + + params = { + "thread_id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "thread_type": "discussion", + "title": "Test Title", + "body": "Edited body", + "user_id": str(self.user.id), + "anonymous": False, + "anonymous_to_peers": False, + "closed": False, + "pinned": False, + "editing_user_id": str(self.user.id), + } + self.check_mock_called_with("update_thread", -1, **params) + + def test_error(self): + self.register_get_user_response(self.user) + self.register_thread() + request_data = {"title": ""} + response = self.request_patch(request_data) + expected_response_data = { + "field_errors": { + "title": {"developer_message": "This field may not be blank."} + } + } + assert response.status_code == 400 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == expected_response_data + + @ddt.data( + ("abuse_flagged", True), + ("abuse_flagged", False), + ) + @ddt.unpack + def test_closed_thread(self, field, value): + self.register_get_user_response(self.user) + self.register_thread({"closed": True, "read": True}) + self.register_flag_response("thread", "test_thread") + request_data = {field: value} + response = self.request_patch(request_data) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == self.expected_thread_data( + { + "read": True, + "closed": True, + "abuse_flagged": value, + "editable_fields": ["abuse_flagged", "copy_link", "read"], + "comment_count": 1, + "unread_comment_count": 0, + } + ) + + @ddt.data( + ("raw_body", "Edited body"), + ("voted", True), + ("following", True), + ) + @ddt.unpack + def test_closed_thread_error(self, field, value): + self.register_get_user_response(self.user) + self.register_thread({"closed": True}) + self.register_flag_response("thread", "test_thread") + request_data = {field: value} + response = self.request_patch(request_data) + assert response.status_code == 400 + + def test_patch_read_owner_user(self): + self.register_get_user_response(self.user) + self.register_thread({"resp_total": 2}) + self.register_read_response(self.user, "thread", "test_thread") + request_data = {"read": True} + + response = self.request_patch(request_data) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == self.expected_thread_data( + { + "comment_count": 1, + "read": True, + "editable_fields": [ + "abuse_flagged", + "anonymous", + "copy_link", + "following", + "raw_body", + "read", + "title", + "topic_id", + "type", + ], + "response_count": 2, + } + ) + + def test_patch_read_non_owner_user(self): + self.register_get_user_response(self.user) + thread_owner_user = UserFactory.create(password=self.password) + CourseEnrollmentFactory.create(user=thread_owner_user, course_id=self.course.id) + self.register_thread( + { + "username": thread_owner_user.username, + "user_id": str(thread_owner_user.id), + "resp_total": 2, + } + ) + self.register_read_response(self.user, "thread", "test_thread") + + request_data = {"read": True} + response = self.request_patch(request_data) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + expected_data = self.expected_thread_data( + { + "author": str(thread_owner_user.username), + "comment_count": 1, + "can_delete": False, + "read": True, + "editable_fields": [ + "abuse_flagged", + "copy_link", + "following", + "read", + "voted", + ], + "response_count": 2, + } + ) + assert response_data == expected_data + + +@ddt.ddt +@disable_signal(api, "comment_edited") +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class CommentViewSetPartialUpdateTest( + DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin +): + """Tests for CommentViewSet partial_update""" + + def setUp(self): + self.unsupported_media_type = JSONParser.media_type + super().setUp() + self.register_get_user_response(self.user) + self.url = reverse("comment-detail", kwargs={"comment_id": "test_comment"}) + + def expected_response_data(self, overrides=None): + """ + create expected response data from comment update endpoint + """ + response_data = { + "id": "test_comment", + "thread_id": "test_thread", + "parent_id": None, + "author": self.user.username, + "author_label": None, + "created_at": "1970-01-01T00:00:00Z", + "updated_at": "1970-01-01T00:00:00Z", + "raw_body": "Original body", + "rendered_body": "

Original body

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

Edited body

", + "editable_fields": ["abuse_flagged", "anonymous", "raw_body"], + "created_at": "Test Created Date", + "updated_at": "Test Updated Date", + } + ) + params = { + "comment_id": "test_comment", + "body": "Edited body", + "course_id": str(self.course.id), + "user_id": str(self.user.id), + "anonymous": False, + "anonymous_to_peers": False, + "endorsed": False, + "editing_user_id": str(self.user.id), + } + self.check_mock_called_with("update_comment", -1, **params) + + def test_error(self): + self.register_thread() + self.register_comment() + request_data = {"raw_body": ""} + response = self.request_patch(request_data) + expected_response_data = { + "field_errors": { + "raw_body": {"developer_message": "This field may not be blank."} + } + } + assert response.status_code == 400 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == expected_response_data + + @ddt.data( + ("abuse_flagged", True), + ("abuse_flagged", False), + ) + @ddt.unpack + def test_closed_thread(self, field, value): + self.register_thread({"closed": True}) + self.register_comment() + self.register_flag_response("comment", "test_comment") + request_data = {field: value} + response = self.request_patch(request_data) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == self.expected_response_data( + { + "abuse_flagged": value, + "abuse_flagged_any_user": None, + "editable_fields": ["abuse_flagged"], + } + ) + + @ddt.data( + ("raw_body", "Edited body"), + ("voted", True), + ("following", True), + ) + @ddt.unpack + def test_closed_thread_error(self, field, value): + self.register_thread({"closed": True}) + self.register_comment() + request_data = {field: value} + response = self.request_patch(request_data) + assert response.status_code == 400 + + +@ddt.ddt +@httpretty.activate +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class ThreadViewSetListTest( + DiscussionAPIViewTestMixin, ModuleStoreTestCase, ProfileImageTestMixin +): + """Tests for ThreadViewSet list""" + + def setUp(self): + super().setUp() + self.author = UserFactory.create() + self.url = reverse("thread-list") + + def create_source_thread(self, overrides=None): + """ + Create a sample source cs_thread + """ + thread = make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(self.user.id), + "username": self.user.username, + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + "title": "Test Title", + "body": "Test body", + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + } + ) + + thread.update(overrides or {}) + return thread + + def test_course_id_missing(self): + response = self.client.get(self.url) + self.assert_response_correct( + response, + 400, + {"field_errors": {"course_id": {"developer_message": "This field is required."}}} + ) + + def test_404(self): + response = self.client.get(self.url, {"course_id": "non/existent/course"}) + self.assert_response_correct( + response, + 404, + {"developer_message": "Course not found."} + ) + + def test_basic(self): + self.register_get_user_response(self.user, upvoted_ids=["test_thread"]) + source_threads = [ + self.create_source_thread( + {"user_id": str(self.author.id), "username": self.author.username} + ) + ] + expected_threads = [ + self.expected_thread_data( + { + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + "vote_count": 4, + "comment_count": 6, + "can_delete": False, + "unread_comment_count": 3, + "voted": True, + "author": self.author.username, + "editable_fields": [ + "abuse_flagged", + "copy_link", + "following", + "read", + "voted", + ], + "abuse_flagged_count": None, + } + ) + ] + self.register_get_threads_response(source_threads, page=1, num_pages=2) + response = self.client.get( + self.url, {"course_id": str(self.course.id), "following": ""} + ) + expected_response = make_paginated_api_response( + results=expected_threads, + count=1, + num_pages=2, + next_link="http://testserver/api/discussion/v1/threads/?course_id=course-v1%3Ax%2By%2Bz&following=&page=2", + previous_link=None, + ) + expected_response.update({"text_search_rewrite": None}) + self.assert_response_correct(response, 200, expected_response) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + @ddt.data("unread", "unanswered", "unresponded") + def test_view_query(self, query): + threads = [make_minimal_cs_thread()] + self.register_get_user_response(self.user) + self.register_get_threads_response(threads, page=1, num_pages=1) + self.client.get( + self.url, + { + "course_id": str(self.course.id), + "view": query, + }, + ) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + query: True, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_pagination(self): + self.register_get_user_response(self.user) + self.register_get_threads_response([], page=1, num_pages=1) + response = self.client.get( + self.url, {"course_id": str(self.course.id), "page": "18", "page_size": "4"} + ) + self.assert_response_correct( + response, + 404, + {"developer_message": "Page not found (No results on this page)."}, + ) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 18, + "per_page": 4, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_text_search(self): + self.register_get_user_response(self.user) + self.register_get_threads_search_response([], None, num_pages=0) + response = self.client.get( + self.url, + {"course_id": str(self.course.id), "text_search": "test search string"}, + ) + + expected_response = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_response.update({"text_search_rewrite": None}) + self.assert_response_correct(response, 200, expected_response) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + "text": "test search string", + } + self.check_mock_called_with( + "search_threads", + -1, + **params, + ) + + @ddt.data(True, "true", "1") + def test_following_true(self, following): + self.register_get_user_response(self.user) + self.register_subscribed_threads_response(self.user, [], page=1, num_pages=1) + response = self.client.get( + self.url, + { + "course_id": str(self.course.id), + "following": following, + }, + ) + + expected_response = make_paginated_api_response( + results=[], count=0, num_pages=1, next_link=None, previous_link=None + ) + expected_response.update({"text_search_rewrite": None}) + self.assert_response_correct(response, 200, expected_response) + self.check_mock_called("get_user_subscriptions") + + @ddt.data(False, "false", "0") + def test_following_false(self, following): + response = self.client.get( + self.url, + { + "course_id": str(self.course.id), + "following": following, + }, + ) + self.assert_response_correct( + response, + 400, + { + "field_errors": { + "following": { + "developer_message": "The value of the 'following' parameter must be true." + } + } + }, + ) + + def test_following_error(self): + response = self.client.get( + self.url, + { + "course_id": str(self.course.id), + "following": "invalid-boolean", + }, + ) + self.assert_response_correct( + response, + 400, + { + "field_errors": { + "following": {"developer_message": "Invalid Boolean Value."} + } + }, + ) + + @ddt.data( + ("last_activity_at", "activity"), + ("comment_count", "comments"), + ("vote_count", "votes"), + ) + @ddt.unpack + def test_order_by(self, http_query, cc_query): + """ + Tests the order_by parameter + + Arguments: + http_query (str): Query string sent in the http request + cc_query (str): Query string used for the comments client service + """ + threads = [make_minimal_cs_thread()] + self.register_get_user_response(self.user) + self.register_get_threads_response(threads, page=1, num_pages=1) + self.client.get( + self.url, + { + "course_id": str(self.course.id), + "order_by": http_query, + }, + ) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "page": 1, + "per_page": 10, + "sort_key": cc_query, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_order_direction(self): + """ + Test order direction, of which "desc" is the only valid option. The + option actually just gets swallowed, so it doesn't affect the params. + """ + threads = [make_minimal_cs_thread()] + self.register_get_user_response(self.user) + self.register_get_threads_response(threads, page=1, num_pages=1) + self.client.get( + self.url, + { + "course_id": str(self.course.id), + "order_direction": "desc", + }, + ) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_mutually_exclusive(self): + """ + Tests GET thread_list api does not allow filtering on mutually exclusive parameters + """ + self.register_get_user_response(self.user) + self.register_get_threads_search_response([], None, num_pages=0) + response = self.client.get( + self.url, + { + "course_id": str(self.course.id), + "text_search": "test search string", + "topic_id": "topic1, topic2", + }, + ) + self.assert_response_correct( + response, + 400, + { + "developer_message": "The following query parameters are mutually exclusive: topic_id, " + "text_search, following" + }, + ) + + def test_profile_image_requested_field(self): + """ + Tests thread has user profile image details if called in requested_fields + """ + user_2 = UserFactory.create(password=self.password) + # Ensure that parental controls don't apply to this user + user_2.profile.year_of_birth = 1970 + user_2.profile.save() + source_threads = [ + self.create_source_thread(), + self.create_source_thread( + {"user_id": str(user_2.id), "username": user_2.username} + ), + ] + + self.register_get_user_response(self.user, upvoted_ids=["test_thread"]) + self.register_get_threads_response(source_threads, page=1, num_pages=1) + self.create_profile_image(self.user, get_profile_image_storage()) + self.create_profile_image(user_2, get_profile_image_storage()) + + response = self.client.get( + self.url, + {"course_id": str(self.course.id), "requested_fields": "profile_image"}, + ) + assert response.status_code == 200 + response_threads = json.loads(response.content.decode("utf-8"))["results"] + + for response_thread in response_threads: + expected_profile_data = self.get_expected_user_profile( + response_thread["author"] + ) + response_users = response_thread["users"] + assert expected_profile_data == response_users[response_thread["author"]] + + def test_profile_image_requested_field_anonymous_user(self): + """ + Tests profile_image in requested_fields for thread created with anonymous user + """ + source_threads = [ + self.create_source_thread( + { + "user_id": None, + "username": None, + "anonymous": True, + "anonymous_to_peers": True, + } + ), + ] + + self.register_get_user_response(self.user, upvoted_ids=["test_thread"]) + self.register_get_threads_response(source_threads, page=1, num_pages=1) + + response = self.client.get( + self.url, + {"course_id": str(self.course.id), "requested_fields": "profile_image"}, + ) + assert response.status_code == 200 + response_thread = json.loads(response.content.decode("utf-8"))["results"][0] + assert response_thread["author"] is None + assert {} == response_thread["users"] + + +@ddt.ddt +class BulkDeleteUserPostsTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): + """ + Tests for the BulkDeleteUserPostsViewSet + """ + + @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) + + def test_basic(self): + """ + Basic test method required by DiscussionAPIViewTestMixin + """ + + def user_login(self): + """ + Authenticates the test client with the example user. + """ + 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."} + ) + + 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, + } + ) + + +@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") + + 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) + + 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(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(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 + + 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 +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) + + 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 496f8723acfb..37512c3573ee 100644 --- a/lms/djangoapps/discussion/rest_api/tests/utils.py +++ b/lms/djangoapps/discussion/rest_api/tests/utils.py @@ -14,6 +14,8 @@ from PIL import Image from pytz import UTC +from lms.djangoapps.discussion.django_comment_client.tests.mixins import MockForumApiMixin +from openedx.core.djangoapps.django_comment_common.comment_client.utils import CommentClientRequestError from openedx.core.djangoapps.profile_images.images import create_profile_images from openedx.core.djangoapps.profile_images.tests.helpers import make_image_file from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_names, set_has_profile_image @@ -51,6 +53,34 @@ def callback(request, _uri, headers): return callback +def make_thread_callback(thread_data): + """ + Returns a function that simulates thread creation/update behavior, + applying overrides based on keyword arguments (e.g., mock request body). + """ + + def callback(*args, **kwargs): + # Simulate default thread response + response_data = make_minimal_cs_thread(thread_data) + original_data = response_data.copy() + + for key, val in kwargs.items(): + if key in ["anonymous", "anonymous_to_peers", "closed", "pinned"]: + response_data[key] = val is True or val == "True" + elif key == "edit_reason_code": + response_data["edit_history"] = [{ + "original_body": original_data["body"], + "author": thread_data.get("username"), + "reason_code": val, + }] + else: + response_data[key] = val + + return response_data + + return callback + + def _get_comment_callback(comment_data, thread_id, parent_id): """ Get a callback function that will return a comment containing the given data @@ -86,6 +116,48 @@ def callback(request, _uri, headers): return callback +def make_comment_callback(comment_data, thread_id, parent_id): + """ + Returns a callable that mimics comment creation or update behavior, + applying overrides based on keyword arguments like a parsed request body. + """ + + def callback(*args, **kwargs): + response_data = make_minimal_cs_comment(comment_data) + original_data = response_data.copy() + + # Inject thread_id and parent_id + response_data["thread_id"] = thread_id + response_data["parent_id"] = parent_id + + # Override fields based on "incoming request" + for key, val in kwargs.items(): + if key in ["anonymous", "anonymous_to_peers", "endorsed"]: + response_data[key] = val is True or val == "True" + elif key == "edit_reason_code": + response_data["edit_history"] = [{ + "original_body": original_data["body"], + "author": comment_data.get("username"), + "reason_code": val, + }] + else: + response_data[key] = val + + return response_data + + return callback + + +def make_user_callbacks(user_map): + """ + Returns a callable that mimics user creation. + """ + def callback(*args, **kwargs): + user_id = args[0] if args else kwargs.get('user_id') + return user_map[str(user_id)] + return callback + + class CommentsServiceMockMixin: """Mixin with utility methods for mocking the comments service""" @@ -521,6 +593,243 @@ def expected_thread_data(self, overrides=None): return response_data +class ForumMockUtilsMixin(MockForumApiMixin): + """Mixin with utility methods for mocking the comments service""" + + def register_get_threads_response(self, threads, page, num_pages): + """Register a mock response for GET on the CS thread list endpoint""" + self.set_mock_return_value('get_user_threads', { + "collection": threads, + "page": page, + "num_pages": num_pages, + "thread_count": len(threads), + }) + + def register_get_course_commentable_counts_response(self, course_id, thread_counts): + """Register a mock response for GET on the CS thread list endpoint""" + self.set_mock_return_value('get_commentables_stats', thread_counts) + + def register_get_threads_search_response(self, threads, rewrite, num_pages=1): + """Register a mock response for GET on the CS thread search endpoint""" + self.set_mock_return_value('search_threads', { + "collection": threads, + "page": 1, + "num_pages": num_pages, + "corrected_text": rewrite, + "thread_count": len(threads), + }) + + def register_post_thread_response(self, thread_data): + self.set_mock_side_effect('create_thread', make_thread_callback(thread_data)) + + def register_put_thread_response(self, thread_data): + self.set_mock_side_effect('update_thread', make_thread_callback(thread_data)) + + def register_get_thread_error_response(self, thread_id, status_code): + self.set_mock_side_effect( + 'get_thread', + CommentClientRequestError(f"Thread does not exist with Id: {thread_id}") + ) + + def register_get_thread_response(self, thread): + self.set_mock_return_value('get_thread', thread) + + def register_get_comments_response(self, comments, page, num_pages): + """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, + "comment_count": len(comments), + }) + + def register_post_comment_response(self, comment_data, thread_id, parent_id=None): + self.set_mock_side_effect( + 'create_child_comment' if parent_id else 'create_parent_comment', + make_comment_callback(comment_data, thread_id, parent_id) + ) + + def register_put_comment_response(self, comment_data): + thread_id = comment_data["thread_id"] + parent_id = comment_data.get("parent_id") + self.set_mock_side_effect( + 'update_comment', + make_comment_callback(comment_data, thread_id, parent_id) + ) + + def register_get_comment_error_response(self, comment_id, status_code): + self.set_mock_side_effect( + 'get_parent_comment', + CommentClientRequestError(f"Comment does not exist with Id: {comment_id}") + ) + + def register_get_comment_response(self, response_overrides): + comment = make_minimal_cs_comment(response_overrides) + self.set_mock_return_value('get_parent_comment', comment) + + def register_get_user_response(self, user, subscribed_thread_ids=None, upvoted_ids=None): + """Register a mock response for GET on the CS user endpoint""" + self.users_map[str(user.id)] = { + "id": str(user.id), + "subscribed_thread_ids": subscribed_thread_ids or [], + "upvoted_ids": upvoted_ids or [], + } + self.set_mock_side_effect('get_user', make_user_callbacks(self.users_map)) + + 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, + "num_pages": num_pages, + "thread_count": len(threads), + }) + + def register_course_stats_response(self, course_key, stats, page, num_pages): + self.set_mock_return_value('get_user_course_stats', { + "user_stats": stats, + "page": page, + "num_pages": num_pages, + "count": len(stats), + }) + + def register_subscription_response(self, user): + self.set_mock_return_value('create_subscription', {}) + self.set_mock_return_value('delete_subscription', {}) + + def register_thread_votes_response(self, thread_id): + self.set_mock_return_value('update_thread_votes', {}) + self.set_mock_return_value('delete_thread_vote', {}) + + def register_comment_votes_response(self, comment_id): + self.set_mock_return_value('update_comment_votes', {}) + self.set_mock_return_value('delete_comment_vote', {}) + + def register_flag_response(self, content_type, content_id): + if content_type == 'thread': + self.set_mock_return_value('update_thread_flag', {}) + elif content_type == 'comment': + self.set_mock_return_value('update_comment_flag', {}) + + def register_read_response(self, user, content_type, content_id): + self.set_mock_return_value('mark_thread_as_read', {}) + + def register_delete_thread_response(self, thread_id): + self.set_mock_return_value('delete_thread', {}) + + def register_delete_comment_response(self, comment_id): + self.set_mock_return_value('delete_comment', {}) + + def register_user_active_threads(self, user_id, response): + self.set_mock_return_value('get_user_active_threads', response) + + def register_get_subscriptions(self, thread_id, response): + self.set_mock_return_value('get_thread_subscriptions', response) + + def register_thread_flag_response(self, thread_id): + """Register a mock response for PUT on the CS thread flag endpoints""" + self.register_flag_response("thread", thread_id) + + def register_comment_flag_response(self, comment_id): + """Register a mock response for PUT on the CS comment flag endpoints""" + self.register_flag_response("comment", comment_id) + + def assert_query_params_equal(self, httpretty_request, expected_params): + """ + Assert that the given mock request had the expected query parameters + """ + actual_params = dict(querystring(httpretty_request)) + actual_params.pop("request_id") # request_id is random + assert actual_params == expected_params + + def assert_last_query_params(self, expected_params): + """ + Assert that the last mock request had the expected query parameters + """ + self.assert_query_params_equal(httpretty.last_request(), expected_params) + + def request_patch(self, request_data): + """ + make a request to PATCH endpoint and return response + """ + return self.client.patch( + self.url, + json.dumps(request_data), + content_type="application/merge-patch+json" + ) + + def expected_thread_data(self, overrides=None): + """ + Returns expected thread data in API response + """ + response_data = { + "anonymous": False, + "anonymous_to_peers": False, + "author": self.user.username, + "author_label": None, + "created_at": "1970-01-01T00:00:00Z", + "updated_at": "1970-01-01T00:00:00Z", + "raw_body": "Test body", + "rendered_body": "

Test body

", + "preview_body": "Test body", + "abuse_flagged": False, + "abuse_flagged_count": None, + "voted": False, + "vote_count": 0, + "editable_fields": [ + "abuse_flagged", + "anonymous", + "copy_link", + "following", + "raw_body", + "read", + "title", + "topic_id", + "type", + ], + "course_id": str(self.course.id), + "topic_id": "test_topic", + "group_id": None, + "group_name": None, + "title": "Test Title", + "pinned": False, + "closed": False, + "can_delete": True, + "following": False, + "comment_count": 1, + "unread_comment_count": 0, + "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread", + "endorsed_comment_list_url": None, + "non_endorsed_comment_list_url": None, + "read": False, + "has_endorsed": False, + "id": "test_thread", + "type": "discussion", + "response_count": 0, + "last_edit": None, + "edit_by_label": None, + "closed_by": None, + "closed_by_label": None, + "close_reason": None, + "close_reason_code": None, + } + response_data.update(overrides or {}) + return response_data + + def make_minimal_cs_thread(overrides=None): """ Create a dictionary containing all needed thread fields as returned by the diff --git a/lms/djangoapps/discussion/tests/test_tasks.py b/lms/djangoapps/discussion/tests/test_tasks.py index 952a6c567a52..1617cb6daf53 100644 --- a/lms/djangoapps/discussion/tests/test_tasks.py +++ b/lms/djangoapps/discussion/tests/test_tasks.py @@ -14,6 +14,9 @@ from edx_ace.recipient import Recipient from edx_ace.renderers import EmailRenderer from edx_ace.utils import date +from lms.djangoapps.discussion.django_comment_client.tests.mixins import ( + MockForumApiMixin, +) from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase import openedx.core.djangoapps.django_comment_common.comment_client as cc @@ -69,13 +72,39 @@ def mock_request(method, url, **kwargs): return mock_request +def make_subscribed_threads_callback(subscribed_thread_ids, per_page=1): + """ + Creates a callback function for simulating user data. + """ + + def callback(*args, **kwargs): # lint-amnesty, pylint: disable=unused-argument + subscribed_thread_collection = [ + {"id": thread_id} for thread_id in subscribed_thread_ids + ] + page = kwargs.get("page", 1) + start_index = per_page * (page - 1) + end_index = per_page * page + data = { + "collection": subscribed_thread_collection[start_index:end_index], + "page": page, + "num_pages": int( + math.ceil(len(subscribed_thread_collection) / float(per_page)) + ), + "thread_count": len(subscribed_thread_collection), + } + return data + + return callback + + @ddt.ddt -class TaskTestCase(ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring +class TaskTestCase(ModuleStoreTestCase, MockForumApiMixin): # lint-amnesty, pylint: disable=missing-class-docstring @classmethod @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUpClass(cls): super().setUpClass() + super().setUpClassAndForumMock() cls.discussion_id = 'dummy_discussion_id' cls.question_id = 'dummy_question_id' cls.course = CourseOverviewFactory.create(language='fr') @@ -109,6 +138,29 @@ def setUpClass(cls): cls.create_threads_and_comments() + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def _set_forum_mocks( + self, + subscribed_thread_ids=None, + thread_data=None, + comment_data=None, + per_page=1, + ): + """mock threads and comments""" + if subscribed_thread_ids is not None: + self.set_mock_side_effect( + "get_user_subscriptions", + make_subscribed_threads_callback(subscribed_thread_ids, per_page), + ) + if thread_data: + self.set_mock_return_value("get_thread", thread_data) + if comment_data: + self.set_mock_return_value("get_parent_comment", comment_data) + @classmethod def create_threads_and_comments(cls): # lint-amnesty, pylint: disable=missing-function-docstring # Regular discussion threads and comments. @@ -221,9 +273,6 @@ def create_threads_and_comments(cls): # lint-amnesty, pylint: disable=missing-f def setUp(self): super().setUp() - self.request_patcher = mock.patch('requests.request') - self.mock_request = self.request_patcher.start() - self.ace_send_patcher = mock.patch('edx_ace.ace.send') self.mock_ace_send = self.ace_send_patcher.start() self.mock_message_patcher = mock.patch('lms.djangoapps.discussion.tasks.ResponseNotification') @@ -232,26 +281,11 @@ def setUp(self): thread_permalink = '/courses/discussion/dummy_discussion_id' self.permalink_patcher = mock.patch('lms.djangoapps.discussion.tasks.permalink', return_value=thread_permalink) self.mock_permalink = self.permalink_patcher.start() - patcher = mock.patch( - '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.set_mock_return_value("get_course_id_by_thread", str(self.course.id)) + self.set_mock_return_value("get_course_id_by_comment", str(self.course.id)) def tearDown(self): super().tearDown() - self.request_patcher.stop() self.ace_send_patcher.stop() self.mock_message_patcher.stop() self.permalink_patcher.stop() @@ -279,7 +313,7 @@ def test_send_discussion_email_notification(self, user_subscribed): ] for thread, comment in examples: self.mock_ace_send.reset_mock() - self.mock_request.side_effect = make_mock_responder( + self._set_forum_mocks( subscribed_thread_ids=subscribed_thread_ids, comment_data=comment, thread_data=thread, @@ -344,7 +378,7 @@ def run_should_not_send_email_test(self, thread, comment_dict): """ assert email is not sent """ - self.mock_request.side_effect = make_mock_responder( + self._set_forum_mocks( subscribed_thread_ids=[self.discussion_id, self.question_id], comment_data=comment_dict, thread_data=thread, diff --git a/lms/djangoapps/discussion/tests/test_tasks_v2.py b/lms/djangoapps/discussion/tests/test_tasks_v2.py new file mode 100644 index 000000000000..eed2c36f3d26 --- /dev/null +++ b/lms/djangoapps/discussion/tests/test_tasks_v2.py @@ -0,0 +1,489 @@ +""" +Tests the execution of forum notification tasks. +""" + +import math +from datetime import datetime, timedelta +from unittest import mock + +import ddt +from django.contrib.sites.models import Site +from edx_ace.channel import ChannelType, get_channel_for_message +from edx_ace.recipient import Recipient +from edx_ace.renderers import EmailRenderer +from edx_ace.utils import date +from lms.djangoapps.discussion.django_comment_client.tests.mixins import ( + MockForumApiMixin, +) +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase + +import openedx.core.djangoapps.django_comment_common.comment_client as cc +from common.djangoapps.student.tests.factories import ( + CourseEnrollmentFactory, + UserFactory, +) +from lms.djangoapps.discussion.signals.handlers import ( + ENABLE_FORUM_NOTIFICATIONS_FOR_SITE_KEY, +) +from lms.djangoapps.discussion.tasks import ( + _is_first_comment, + _should_send_message, + _track_notification_sent, +) +from openedx.core.djangoapps.ace_common.template_context import ( + get_base_template_context, +) +from openedx.core.djangoapps.content.course_overviews.tests.factories import ( + CourseOverviewFactory, +) +from openedx.core.djangoapps.django_comment_common.models import ForumsConfig +from openedx.core.djangoapps.django_comment_common.signals import comment_created +from openedx.core.djangoapps.site_configuration.tests.factories import ( + SiteConfigurationFactory, +) +from openedx.core.lib.celery.task_utils import emulate_http_request + +NOW = datetime.utcnow() +ONE_HOUR_AGO = NOW - timedelta(hours=1) +TWO_HOURS_AGO = NOW - timedelta(hours=2) + + +def make_subscribed_threads_callback(subscribed_thread_ids, per_page=1): + """ + Creates a callback function for simulating user data. + """ + + def callback(*args, **kwargs): # lint-amnesty, pylint: disable=unused-argument + subscribed_thread_collection = [ + {"id": thread_id} for thread_id in subscribed_thread_ids + ] + page = kwargs.get("page", 1) + start_index = per_page * (page - 1) + end_index = per_page * page + data = { + "collection": subscribed_thread_collection[start_index:end_index], + "page": page, + "num_pages": int( + math.ceil(len(subscribed_thread_collection) / float(per_page)) + ), + "thread_count": len(subscribed_thread_collection), + } + return data + + return callback + + +@ddt.ddt +class TaskTestCase( + ModuleStoreTestCase, MockForumApiMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + + @classmethod + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + cls.discussion_id = "dummy_discussion_id" + cls.question_id = "dummy_question_id" + cls.course = CourseOverviewFactory.create(language="fr") + + # Patch the comment client user save method so it does not try + # to create a new cc user when creating a django user + with mock.patch("common.djangoapps.student.models.user.cc.User.save"): + cls.thread_author = UserFactory( + username="thread_author", password="password", email="email" + ) + cls.comment_author = UserFactory( + username="comment_author", password="password", email="email" + ) + + CourseEnrollmentFactory(user=cls.thread_author, course_id=cls.course.id) + CourseEnrollmentFactory(user=cls.comment_author, course_id=cls.course.id) + + config = ForumsConfig.current() + config.enabled = True + config.save() + + cls.create_threads_and_comments() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def _set_forum_mocks( + self, + subscribed_thread_ids=None, + thread_data=None, + comment_data=None, + per_page=1, + ): + """mock threads and comments""" + if subscribed_thread_ids: + self.set_mock_side_effect( + "get_user_subscriptions", + make_subscribed_threads_callback(subscribed_thread_ids, per_page), + ) + if thread_data: + self.set_mock_return_value("get_thread", thread_data) + if comment_data: + self.set_mock_return_value("get_parent_comment", comment_data) + + @classmethod + def create_threads_and_comments( + cls, + ): # lint-amnesty, pylint: disable=missing-function-docstring + # Regular discussion threads and comments. + cls.discussion_thread = { + "id": cls.discussion_id, + "course_id": str(cls.course.id), + "created_at": date.serialize(TWO_HOURS_AGO), + "title": "discussion-thread-title", + "user_id": cls.thread_author.id, + "username": cls.thread_author.username, + "commentable_id": "discussion-thread-commentable-id", + "thread_type": "discussion", + } + cls.discussion_comment = { + "id": "discussion-comment", + "body": "discussion-comment-body", + "created_at": date.serialize(ONE_HOUR_AGO), + "thread_id": cls.discussion_thread["id"], + "parent_id": None, + "user_id": cls.comment_author.id, + "username": cls.comment_author.username, + "course_id": str(cls.course.id), + } + cls.discussion_comment2 = { + "id": "discussion-comment2", + "body": "discussion-comment2-body", + "created_at": date.serialize(NOW), + "thread_id": cls.discussion_thread["id"], + "parent_id": None, + "user_id": cls.comment_author.id, + "username": cls.comment_author.username, + "course_id": str(cls.course.id), + } + cls.discussion_subcomment = { + "id": "discussion-subcomment", + "body": "discussion-subcomment-body", + "created_at": date.serialize(NOW), + "thread_id": cls.discussion_thread["id"], + "parent_id": cls.discussion_comment["id"], + "user_id": cls.comment_author.id, + "username": cls.comment_author.username, + "course_id": str(cls.course.id), + } + cls.discussion_thread["children"] = [ + cls.discussion_comment, + cls.discussion_comment2, + ] + cls.discussion_comment["child_count"] = 1 + cls.discussion_thread2 = { + "id": cls.discussion_id, + "course_id": str(cls.course.id), + "created_at": date.serialize(TWO_HOURS_AGO), + "title": "discussion-thread-2-title", + "user_id": cls.thread_author.id, + "username": cls.thread_author.username, + "commentable_id": "discussion-thread-commentable-id-2", + "thread_type": "discussion", + } + # Qeustion threads and comments. + cls.question_thread = { + "id": cls.question_id, + "course_id": str(cls.course.id), + "created_at": date.serialize(TWO_HOURS_AGO), + "title": "question-thread-title", + "user_id": cls.thread_author.id, + "username": cls.thread_author.username, + "commentable_id": "question-thread-commentable-id-3", + "thread_type": "question", + } + cls.question_comment = { + "id": "question-comment", + "body": "question-comment-body", + "created_at": date.serialize(ONE_HOUR_AGO), + "thread_id": cls.question_thread["id"], + "parent_id": None, + "user_id": cls.comment_author.id, + "username": cls.comment_author.username, + "course_id": str(cls.course.id), + } + cls.question_comment2 = { + "id": "question-comment2", + "body": "question-comment2-body", + "created_at": date.serialize(NOW), + "thread_id": cls.question_thread["id"], + "parent_id": None, + "user_id": cls.comment_author.id, + "username": cls.comment_author.username, + "course_id": str(cls.course.id), + } + cls.question_subcomment = { + "id": "question-subcomment", + "body": "question-subcomment-body", + "created_at": date.serialize(NOW), + "thread_id": cls.question_thread["id"], + "parent_id": cls.question_comment["id"], + "user_id": cls.comment_author.id, + "username": cls.comment_author.username, + "course_id": str(cls.course.id), + } + cls.question_thread["endorsed_responses"] = [cls.question_comment] + cls.question_thread["non_endorsed_responses"] = [cls.question_comment2] + cls.question_comment["child_count"] = 1 + cls.question_thread2 = { + "id": cls.question_id, + "course_id": str(cls.course.id), + "created_at": date.serialize(TWO_HOURS_AGO), + "title": "question-thread-2-title", + "user_id": cls.thread_author.id, + "username": cls.thread_author.username, + "commentable_id": "question-thread-commentable-id-2", + "thread_type": "question", + } + + def setUp(self): + super().setUp() + + self.ace_send_patcher = mock.patch("edx_ace.ace.send") + self.mock_ace_send = self.ace_send_patcher.start() + self.mock_message_patcher = mock.patch( + "lms.djangoapps.discussion.tasks.ResponseNotification" + ) + self.mock_message = self.mock_message_patcher.start() + + thread_permalink = "/courses/discussion/dummy_discussion_id" + self.permalink_patcher = mock.patch( + "lms.djangoapps.discussion.tasks.permalink", return_value=thread_permalink + ) + self.mock_permalink = self.permalink_patcher.start() + self.set_mock_return_value("get_course_id_by_thread", str(self.course.id)) + self.set_mock_return_value("get_course_id_by_comment", str(self.course.id)) + + def tearDown(self): + super().tearDown() + self.ace_send_patcher.stop() + self.mock_message_patcher.stop() + self.permalink_patcher.stop() + + @ddt.data(True) + def test_send_discussion_email_notification(self, user_subscribed): + self.mock_message_patcher.stop() + if user_subscribed: + non_matching_id = "not-a-match" + # with per_page left with a default value of 1, this ensures + # that we test a multiple page result when calling + # comment_client.User.subscribed_threads() + subscribed_thread_ids = [ + non_matching_id, + self.discussion_id, + self.question_id, + ] + else: + subscribed_thread_ids = [] + + site = Site.objects.get_current() + site_config = SiteConfigurationFactory.create(site=site) + site_config.site_values[ENABLE_FORUM_NOTIFICATIONS_FOR_SITE_KEY] = True + site_config.save() + examples = [ + (self.discussion_thread, self.discussion_comment), + (self.question_thread, self.question_comment), + ] + for thread, comment in examples: + self.mock_ace_send.reset_mock() + self._set_forum_mocks( + subscribed_thread_ids=subscribed_thread_ids, + comment_data=comment, + thread_data=thread, + ) + user = mock.Mock() + comment = cc.Comment.find(id=comment["id"]).retrieve() + with mock.patch( + "lms.djangoapps.discussion.rest_api.tasks.send_response_notifications.apply_async" + ): + with mock.patch( + "lms.djangoapps.discussion.signals.handlers.get_current_site", + return_value=site, + ): + comment_created.send(sender=None, user=user, post=comment) + if user_subscribed: + expected_message_context = get_base_template_context(site) + expected_message_context.update( + { + "comment_author_id": self.comment_author.id, + "comment_body": comment["body"], + "comment_body_text": comment.body_text, + "comment_created_at": ONE_HOUR_AGO, + "comment_id": comment["id"], + "comment_parent_id": comment["parent_id"], + "comment_username": self.comment_author.username, + "course_id": self.course.id, + "thread_author_id": self.thread_author.id, + "thread_created_at": TWO_HOURS_AGO, + "thread_id": thread["id"], + "thread_title": thread["title"], + "thread_username": self.thread_author.username, + "thread_commentable_id": thread["commentable_id"], + "post_link": f"https://{site.domain}{self.mock_permalink.return_value}", + "site": site, + "site_id": site.id, + "push_notification_extra_context": { + "notification_type": "forum_response", + "topic_id": thread["commentable_id"], + "course_id": comment["course_id"], + "parent_id": str(comment["parent_id"]), + "thread_id": thread["id"], + "comment_id": comment["id"], + }, + } + ) + expected_recipient = Recipient( + self.thread_author.id, self.thread_author.email + ) + actual_message = self.mock_ace_send.call_args_list[0][0][0] + assert expected_message_context == actual_message.context + assert expected_recipient == actual_message.recipient + assert self.course.language == actual_message.language + self._assert_rendered_email(actual_message, comment) + + else: + assert not self.mock_ace_send.called + + def _assert_rendered_email( + self, message, comment + ): # lint-amnesty, pylint: disable=missing-function-docstring + # check that we can actually render the message + with emulate_http_request( + site=message.context["site"], user=self.thread_author + ): + rendered_email = EmailRenderer().render( + get_channel_for_message(ChannelType.EMAIL, message), message + ) + assert comment["body"] in rendered_email.body_html + assert self.comment_author.username in rendered_email.body_html + assert self.mock_permalink.return_value in rendered_email.body_html + assert message.context["site"].domain in rendered_email.body_html + + def run_should_not_send_email_test(self, thread, comment_dict): + """ + assert email is not sent + """ + self._set_forum_mocks( + subscribed_thread_ids=[self.discussion_id, self.question_id], + comment_data=comment_dict, + thread_data=thread, + ) + user = mock.Mock() + comment = cc.Comment.find(id=comment_dict["id"]).retrieve() + with mock.patch( + "lms.djangoapps.discussion.rest_api.tasks.send_response_notifications.apply_async" + ): + comment_created.send(sender=None, user=user, post=comment) + + actual_result = _should_send_message( + { + "thread_author_id": self.thread_author.id, + "course_id": self.course.id, + "comment_id": comment_dict["id"], + "thread_id": thread["id"], + } + ) + + should_email_send = _is_first_comment(comment_dict["id"], thread["id"]) + assert not should_email_send + assert not self.mock_ace_send.called + + def test_subcomment_should_not_send_email(self): + self.run_should_not_send_email_test( + self.discussion_thread, self.discussion_subcomment + ) + self.run_should_not_send_email_test( + self.question_subcomment, self.question_subcomment + ) + + def test_second_comment_should_not_send_email(self): + self.run_should_not_send_email_test( + self.discussion_thread, self.discussion_comment2 + ) + self.run_should_not_send_email_test( + self.question_thread, self.question_comment2 + ) + + def test_thread_without_children_should_not_send_email(self): + """ + test that email notification will not be sent for the thread + that doesn't have attribute 'children' + """ + self.run_should_not_send_email_test( + self.discussion_thread2, self.discussion_comment + ) + self.run_should_not_send_email_test( + self.question_thread2, self.question_comment + ) + + @ddt.data( + ( + { + "thread_id": "dummy_discussion_id", + "thread_title": "thread-title", + "thread_created_at": date.serialize(datetime(2000, 1, 1, 0, 0, 0)), + "course_id": "fake_course_edx", + "thread_author_id": "a_fake_dude", + }, + { + "app_label": "discussion", + "name": "responsenotification", + "language": "en", + "uuid": "uuid1", + "send_uuid": "uuid2", + "thread_id": "dummy_discussion_id", + "course_id": "fake_course_edx", + "thread_created_at": datetime(2000, 1, 1, 0, 0, 0), + }, + ), + ( + { + "thread_id": "dummy_discussion_id2", + "thread_title": "thread-title2", + "thread_created_at": date.serialize(datetime(2000, 1, 1, 0, 0, 0)), + "course_id": "fake_course_edx2", + "thread_author_id": "a_fake_dude2", + }, + { + "app_label": "discussion", + "name": "responsenotification", + "language": "en", + "uuid": "uuid3", + "send_uuid": "uuid4", + "thread_id": "dummy_discussion_id2", + "course_id": "fake_course_edx2", + "thread_created_at": datetime(2000, 1, 1, 0, 0, 0), + }, + ), + ) + @ddt.unpack + def test_track_notification_sent(self, context, test_props): + with mock.patch("edx_ace.ace.send").start() as message: + # Populate mock message ( + # There are some cruft attrs, but they're harmless. + for key, entry in test_props.items(): + setattr(message, key, entry) + + test_props["nonInteraction"] = True + # Also augment context with site object, for setting segment context. + site = Site.objects.get_current() + context["site"] = site + with mock.patch( + "lms.djangoapps.discussion.tasks.segment.track" + ) as mock_segment_track: + _track_notification_sent(message, context) + mock_segment_track.assert_called_once_with( + user_id=context["thread_author_id"], + event_name="edx.bi.email.sent", + properties=test_props, + ) diff --git a/lms/djangoapps/discussion/tests/test_views.py b/lms/djangoapps/discussion/tests/test_views.py index facdb368f14f..25d149b3005e 100644 --- a/lms/djangoapps/discussion/tests/test_views.py +++ b/lms/djangoapps/discussion/tests/test_views.py @@ -7,69 +7,43 @@ from unittest import mock from unittest.mock import ANY, Mock, call, patch -import ddt import pytest -from django.conf import settings -from django.http import Http404 from django.test.client import Client, RequestFactory from django.test.utils import override_settings from django.urls import reverse from django.utils import translation from edx_django_utils.cache import RequestCache -from edx_toggles.toggles.testutils import override_waffle_flag -from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import ( TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase, - SharedModuleStoreTestCase ) from xmodule.modulestore.tests.factories import ( CourseFactory, BlockFactory, - check_mongo_calls ) from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.course_modes.tests.factories import CourseModeFactory -from common.djangoapps.student.roles import CourseStaffRole, UserBasedRole -from common.djangoapps.student.tests.factories import AdminFactory, CourseEnrollmentFactory, UserFactory -from common.djangoapps.util.testing import EventTestMixin, UrlResetMixin +from 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.group_id import ( - CohortedTopicGroupIdTestMixin, - GroupIdAssertionMixin, - NonCohortedTopicGroupIdTestMixin -) -from lms.djangoapps.discussion.django_comment_client.tests.unicode import UnicodeTestMixin from lms.djangoapps.discussion.django_comment_client.tests.utils import ( - CohortedTestCase, - ForumsEnableMixin, config_course_discussions, topic_name_to_id ) -from lms.djangoapps.discussion.django_comment_client.utils import strip_none -from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE from lms.djangoapps.discussion.views import _get_discussion_default_topic_id, course_discussions_settings_handler -from lms.djangoapps.teams.tests.factories import CourseTeamFactory, CourseTeamMembershipFactory -from openedx.core.djangoapps.course_groups.models import CourseUserGroup from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts from openedx.core.djangoapps.course_groups.tests.test_views import CohortViewsTestCase from openedx.core.djangoapps.django_comment_common.comment_client.utils import CommentClientPaginatedResult from openedx.core.djangoapps.django_comment_common.models import ( - FORUM_ROLE_STUDENT, CourseDiscussionSettings, ForumsConfig ) -from openedx.core.djangoapps.django_comment_common.utils import ThreadContext, seed_permissions_roles -from openedx.core.djangoapps.util.testing import ContentGroupTestCase +from openedx.core.djangoapps.django_comment_common.utils import ThreadContext from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES -from openedx.core.lib.teams_config import TeamsConfig -from openedx.features.content_type_gating.models import ContentTypeGatingConfig -from openedx.features.enterprise_support.tests.mixins.enterprise import EnterpriseTestConsentRequired log = logging.getLogger(__name__) @@ -110,12 +84,6 @@ def setUp(self): 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" ) @@ -298,1452 +266,63 @@ def make_mock_request_impl( # lint-amnesty, pylint: disable=missing-function-do thread_list=thread_list, anonymous=anonymous, anonymous_to_peers=anonymous_to_peers, - ) - - def mock_request_impl(*args, **kwargs): - data = impl(*args, **kwargs) - if data: - return Mock(status_code=200, text=json.dumps(data), json=Mock(return_value=data)) - else: - return Mock(status_code=404) - return mock_request_impl - - -class StringEndsWithMatcher: # lint-amnesty, pylint: disable=missing-class-docstring - def __init__(self, suffix): - self.suffix = suffix - - def __eq__(self, other): - return other.endswith(self.suffix) - - -class PartialDictMatcher: # lint-amnesty, pylint: disable=missing-class-docstring - def __init__(self, expected_values): - self.expected_values = expected_values - - def __eq__(self, other): - return all( - key in other and other[key] == value - for key, value in self.expected_values.items() - ) - - -@patch('requests.request', autospec=True) -class SingleThreadTestCase(ForumsEnableMixin, ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring - - CREATE_USER = False - - def setUp(self): - super().setUp() - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" - ) - self.mock_get_course_id_by_thread = patcher.start() - self.addCleanup(patcher.stop) - - self.course = CourseFactory.create(discussion_topics={'dummy discussion': {'id': 'dummy_discussion_id'}}) - self.student = UserFactory.create() - CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) - - def test_ajax(self, mock_request): - text = "dummy content" - thread_id = "test_thread_id" - mock_request.side_effect = make_mock_request_impl(course=self.course, text=text, thread_id=thread_id) - - request = RequestFactory().get( - "dummy_url", - HTTP_X_REQUESTED_WITH="XMLHttpRequest" - ) - request.user = self.student - response = views.single_thread( - request, - str(self.course.id), - "dummy_discussion_id", - "test_thread_id" - ) - - assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - # strip_none is being used to perform the same transform that the - # django view performs prior to writing thread data to the response - assert response_data['content'] == strip_none(make_mock_thread_data( - course=self.course, - text=text, - thread_id=thread_id, - num_children=1 - )) - mock_request.assert_called_with( - "get", - StringEndsWithMatcher(thread_id), # url - data=None, - params=PartialDictMatcher({"mark_as_read": True, "user_id": 1, "recursive": True}), - headers=ANY, - timeout=ANY - ) - - def test_skip_limit(self, mock_request): - text = "dummy content" - thread_id = "test_thread_id" - response_skip = "45" - response_limit = "15" - mock_request.side_effect = make_mock_request_impl(course=self.course, text=text, thread_id=thread_id) - - request = RequestFactory().get( - "dummy_url", - {"resp_skip": response_skip, "resp_limit": response_limit}, - HTTP_X_REQUESTED_WITH="XMLHttpRequest" - ) - request.user = self.student - response = views.single_thread( - request, - str(self.course.id), - "dummy_discussion_id", - "test_thread_id" - ) - assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - # strip_none is being used to perform the same transform that the - # django view performs prior to writing thread data to the response - assert response_data['content'] == strip_none(make_mock_thread_data( - course=self.course, - text=text, - thread_id=thread_id, - num_children=1 - )) - mock_request.assert_called_with( - "get", - StringEndsWithMatcher(thread_id), # url - data=None, - params=PartialDictMatcher({ - "mark_as_read": True, - "user_id": 1, - "recursive": True, - "resp_skip": response_skip, - "resp_limit": response_limit, - }), - headers=ANY, - timeout=ANY - ) - - def test_post(self, _mock_request): - request = RequestFactory().post("dummy_url") - response = views.single_thread( - request, - str(self.course.id), - "dummy_discussion_id", - "dummy_thread_id" - ) - assert response.status_code == 405 - - def test_post_anonymous_to_ta(self, mock_request): - text = "dummy content" - thread_id = "test_thread_id" - mock_request.side_effect = make_mock_request_impl(course=self.course, text=text, thread_id=thread_id, - anonymous_to_peers=True) - - request = RequestFactory().get( - "dummy_url", - HTTP_X_REQUESTED_WITH="XMLHttpRequest" - ) - request.user = self.student - request.user.is_community_ta = True - response = views.single_thread( - request, - str(self.course.id), - "dummy_discussion_id", - "test_thread_id" - ) - - assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - # user is community ta, so response must not have username and user_id fields - assert response_data['content'].get('username') is None - assert response_data['content'].get('user_id') is None - - def test_not_found(self, mock_request): - request = RequestFactory().get("dummy_url") - request.user = self.student - # Mock request to return 404 for thread request - mock_request.side_effect = make_mock_request_impl(course=self.course, text="dummy", thread_id=None) - self.assertRaises( - Http404, - views.single_thread, - request, - str(self.course.id), - "test_discussion_id", - "test_thread_id" - ) - - def test_private_team_thread_html(self, mock_request): - discussion_topic_id = 'dummy_discussion_id' - thread_id = 'test_thread_id' - CourseTeamFactory.create(discussion_topic_id=discussion_topic_id) - user_not_in_team = UserFactory.create() - CourseEnrollmentFactory.create(user=user_not_in_team, course_id=self.course.id) - self.client.login(username=user_not_in_team.username, password=self.TEST_PASSWORD) - - mock_request.side_effect = make_mock_request_impl( - course=self.course, - text="dummy", - thread_id=thread_id, - commentable_id=discussion_topic_id - ) - with patch('lms.djangoapps.teams.api.is_team_discussion_private', autospec=True) as mocked: - mocked.return_value = True - response = self.client.get( - reverse('single_thread', kwargs={ - 'course_id': str(self.course.id), - 'discussion_id': discussion_topic_id, - 'thread_id': thread_id, - }) - ) - assert response.status_code == 200 - assert response['Content-Type'] == 'text/html; charset=utf-8' - html = response.content.decode('utf-8') - # Verify that the access denied error message is in the HTML - assert 'This is a private discussion. You do not have permissions to view this discussion' in html - - -class AllowPlusOrMinusOneInt(int): - """ - A workaround for the fact that assertNumQueries doesn't let you - specify a range or any tolerance. An 'int' that is 'equal to' its value, - but also its value +/- 1 - """ - - def __init__(self, value): - super().__init__() - self.value = value - self.values = (value, value - 1, value + 1) - - def __eq__(self, other): - return other in self.values - - def __repr__(self): - return f"({self.value} +/- 1)" - - -@ddt.ddt -@patch('requests.request', autospec=True) -class SingleThreadQueryCountTestCase(ForumsEnableMixin, ModuleStoreTestCase): - """ - Ensures the number of modulestore queries and number of sql queries are - independent of the number of responses retrieved for a given discussion thread. - """ - def setUp(self): - super().setUp() - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" - ) - self.mock_get_course_id_by_thread = patcher.start() - self.addCleanup(patcher.stop) - - @ddt.data( - # split mongo: 3 queries, regardless of thread response size. - (False, 1, 2, 2, 21, 8), - (False, 50, 2, 2, 21, 8), - - # Enabling Enterprise integration should have no effect on the number of mongo queries made. - # split mongo: 3 queries, regardless of thread response size. - (True, 1, 2, 2, 21, 8), - (True, 50, 2, 2, 21, 8), - ) - @ddt.unpack - def test_number_of_mongo_queries( - self, - enterprise_enabled, - num_thread_responses, - num_uncached_mongo_calls, - num_cached_mongo_calls, - num_uncached_sql_queries, - num_cached_sql_queries, - mock_request - ): - ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1)) - with modulestore().default_store(ModuleStoreEnum.Type.split): - course = CourseFactory.create(discussion_topics={'dummy discussion': {'id': 'dummy_discussion_id'}}) - - student = UserFactory.create() - CourseEnrollmentFactory.create(user=student, course_id=course.id) - - test_thread_id = "test_thread_id" - mock_request.side_effect = make_mock_request_impl( - course=course, text="dummy content", thread_id=test_thread_id, num_thread_responses=num_thread_responses - ) - request = RequestFactory().get( - "dummy_url", - HTTP_X_REQUESTED_WITH="XMLHttpRequest" - ) - request.user = student - - def call_single_thread(): - """ - Call single_thread and assert that it returns what we expect. - """ - with patch.dict("django.conf.settings.FEATURES", dict(ENABLE_ENTERPRISE_INTEGRATION=enterprise_enabled)): - response = views.single_thread( - request, - str(course.id), - "dummy_discussion_id", - test_thread_id - ) - assert response.status_code == 200 - assert len(json.loads(response.content.decode('utf-8'))['content']['children']) == num_thread_responses - - # Test uncached first, then cached now that the cache is warm. - cached_calls = [ - [num_uncached_mongo_calls, num_uncached_sql_queries], - # Sometimes there will be one more or fewer sql call than expected, because the call to - # CourseMode.modes_for_course sometimes does / doesn't get cached and does / doesn't hit the DB. - # EDUCATOR-5167 - [num_cached_mongo_calls, AllowPlusOrMinusOneInt(num_cached_sql_queries)], - ] - for expected_mongo_calls, expected_sql_queries in cached_calls: - with self.assertNumQueries(expected_sql_queries, table_ignorelist=QUERY_COUNT_TABLE_IGNORELIST): - with check_mongo_calls(expected_mongo_calls): - call_single_thread() - - -@patch('requests.request', autospec=True) -class SingleCohortedThreadTestCase(CohortedTestCase): # lint-amnesty, pylint: disable=missing-class-docstring - - def setUp(self): - super().setUp() - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" - ) - self.mock_get_course_id_by_thread = patcher.start() - self.addCleanup(patcher.stop) - - def _create_mock_cohorted_thread(self, mock_request): # lint-amnesty, pylint: disable=missing-function-docstring - mock_text = "dummy content" - mock_thread_id = "test_thread_id" - mock_request.side_effect = make_mock_request_impl( - course=self.course, text=mock_text, - thread_id=mock_thread_id, - group_id=self.student_cohort.id, - commentable_id="cohorted_topic", - ) - return mock_text, mock_thread_id - - def test_ajax(self, mock_request): - mock_text, mock_thread_id = self._create_mock_cohorted_thread(mock_request) - - request = RequestFactory().get( - "dummy_url", - HTTP_X_REQUESTED_WITH="XMLHttpRequest" - ) - request.user = self.student - response = views.single_thread( - request, - str(self.course.id), - "cohorted_topic", - mock_thread_id - ) - - assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data['content'] == make_mock_thread_data( - course=self.course, - commentable_id='cohorted_topic', - text=mock_text, - thread_id=mock_thread_id, - num_children=1, - group_id=self.student_cohort.id, - group_name=self.student_cohort.name, - is_commentable_divided=True - ) - - def test_html(self, mock_request): - _mock_text, mock_thread_id = self._create_mock_cohorted_thread(mock_request) - - self.client.login(username=self.student.username, password=self.TEST_PASSWORD) - response = self.client.get( - reverse('single_thread', kwargs={ - 'course_id': str(self.course.id), - 'discussion_id': "cohorted_topic", - 'thread_id': mock_thread_id, - }) - ) - - assert response.status_code == 200 - assert response['Content-Type'] == 'text/html; charset=utf-8' - html = response.content.decode('utf-8') - - # Verify that the group name is correctly included in the HTML - self.assertRegex(html, r'"group_name": "student_cohort"') - - -@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) -class SingleThreadAccessTestCase(CohortedTestCase): # lint-amnesty, pylint: disable=missing-class-docstring - - def setUp(self): - super().setUp() - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" - ) - self.mock_get_course_id_by_thread = patcher.start() - self.addCleanup(patcher.stop) - - def call_view(self, mock_request, commentable_id, user, group_id, thread_group_id=None, pass_group_id=True): # lint-amnesty, pylint: disable=missing-function-docstring - thread_id = "test_thread_id" - mock_request.side_effect = make_mock_request_impl( - course=self.course, text="dummy context", thread_id=thread_id, group_id=thread_group_id - ) - - request_data = {} - if pass_group_id: - request_data["group_id"] = group_id - request = RequestFactory().get( - "dummy_url", - data=request_data, - HTTP_X_REQUESTED_WITH="XMLHttpRequest" - ) - request.user = user - return views.single_thread( - request, - str(self.course.id), - commentable_id, - thread_id - ) - - def test_student_non_cohorted(self, mock_request): - resp = self.call_view(mock_request, "non_cohorted_topic", self.student, self.student_cohort.id) - assert resp.status_code == 200 - - def test_student_same_cohort(self, mock_request): - resp = self.call_view( - mock_request, - "cohorted_topic", - self.student, - self.student_cohort.id, - thread_group_id=self.student_cohort.id - ) - assert resp.status_code == 200 - - # this test ensures that a thread response from the cs with group_id: null - # behaves the same as a thread response without a group_id (see: TNL-444) - def test_student_global_thread_in_cohorted_topic(self, mock_request): - resp = self.call_view( - mock_request, - "cohorted_topic", - self.student, - self.student_cohort.id, - thread_group_id=None - ) - assert resp.status_code == 200 - - def test_student_different_cohort(self, mock_request): - pytest.raises(Http404, (lambda: self.call_view( - mock_request, - 'cohorted_topic', - self.student, - self.student_cohort.id, - thread_group_id=self.moderator_cohort.id - ))) - - def test_moderator_non_cohorted(self, mock_request): - resp = self.call_view(mock_request, "non_cohorted_topic", self.moderator, self.moderator_cohort.id) - assert resp.status_code == 200 - - def test_moderator_same_cohort(self, mock_request): - resp = self.call_view( - mock_request, - "cohorted_topic", - self.moderator, - self.moderator_cohort.id, - thread_group_id=self.moderator_cohort.id - ) - assert resp.status_code == 200 - - def test_moderator_different_cohort(self, mock_request): - resp = self.call_view( - mock_request, - "cohorted_topic", - self.moderator, - self.moderator_cohort.id, - thread_group_id=self.student_cohort.id - ) - assert resp.status_code == 200 - - def test_private_team_thread(self, mock_request): - CourseTeamFactory.create(discussion_topic_id='dummy_discussion_id') - user_not_in_team = UserFactory.create() - CourseEnrollmentFactory(user=user_not_in_team, course_id=self.course.id) - - with patch('lms.djangoapps.teams.api.is_team_discussion_private', autospec=True) as mocked: - mocked.return_value = True - response = self.call_view( - mock_request, - 'non_cohorted_topic', - user_not_in_team, - '' - ) - assert 403 == response.status_code - assert views.TEAM_PERMISSION_MESSAGE == response.content.decode('utf-8') - - -@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) -class SingleThreadGroupIdTestCase(CohortedTestCase, GroupIdAssertionMixin): # lint-amnesty, pylint: disable=missing-class-docstring - cs_endpoint = "/threads/dummy_thread_id" - - def setUp(self): - super().setUp() - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" - ) - self.mock_get_course_id_by_thread = patcher.start() - self.addCleanup(patcher.stop) - - def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id=True, is_ajax=False): # lint-amnesty, pylint: disable=missing-function-docstring - mock_request.side_effect = make_mock_request_impl( - course=self.course, text="dummy context", group_id=self.student_cohort.id - ) - - request_data = {} - if pass_group_id: - request_data["group_id"] = group_id - headers = {} - if is_ajax: - headers['HTTP_X_REQUESTED_WITH'] = "XMLHttpRequest" - - self.client.login(username=user.username, password=self.TEST_PASSWORD) - - return self.client.get( - reverse('single_thread', args=[str(self.course.id), commentable_id, "dummy_thread_id"]), - data=request_data, - **headers - ) - - def test_group_info_in_html_response(self, mock_request): - response = self.call_view( - mock_request, - "cohorted_topic", - self.student, - self.student_cohort.id, - is_ajax=False - ) - self._assert_html_response_contains_group_info(response) - - def test_group_info_in_ajax_response(self, mock_request): - response = self.call_view( - mock_request, - "cohorted_topic", - self.student, - self.student_cohort.id, - is_ajax=True - ) - self._assert_json_response_contains_group_info( - response, lambda d: d['content'] - ) - - -@patch('requests.request', autospec=True) -class ForumFormDiscussionContentGroupTestCase(ForumsEnableMixin, ContentGroupTestCase): - """ - Tests `forum_form_discussion api` works with different content groups. - Discussion blocks are setup in ContentGroupTestCase class i.e - alpha_block => alpha_group_discussion => alpha_cohort => alpha_user/community_ta - beta_block => beta_group_discussion => beta_cohort => beta_user - """ - - @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - super().setUp() - self.thread_list = [ - {"thread_id": "test_general_thread_id"}, - {"thread_id": "test_global_group_thread_id", "commentable_id": self.global_block.discussion_id}, - {"thread_id": "test_alpha_group_thread_id", "group_id": self.alpha_block.group_access[0][0], - "commentable_id": self.alpha_block.discussion_id}, - {"thread_id": "test_beta_group_thread_id", "group_id": self.beta_block.group_access[0][0], - "commentable_id": self.beta_block.discussion_id} - ] - - def assert_has_access(self, response, expected_discussion_threads): - """ - Verify that a users have access to the threads in their assigned - cohorts and non-cohorted blocks. - """ - discussion_data = json.loads(response.content.decode('utf-8'))['discussion_data'] - assert len(discussion_data) == expected_discussion_threads - - def call_view(self, mock_request, user): # lint-amnesty, pylint: disable=missing-function-docstring - mock_request.side_effect = make_mock_request_impl( - course=self.course, - text="dummy content", - thread_list=self.thread_list - ) - self.client.login(username=user.username, password=self.TEST_PASSWORD) - return self.client.get( - reverse("forum_form_discussion", args=[str(self.course.id)]), - HTTP_X_REQUESTED_WITH="XMLHttpRequest" - ) - - def test_community_ta_user(self, mock_request): - """ - Verify that community_ta user has access to all threads regardless - of cohort. - """ - response = self.call_view( - mock_request, - self.community_ta - ) - self.assert_has_access(response, 4) - - def test_alpha_cohort_user(self, mock_request): - """ - Verify that alpha_user has access to alpha_cohort and non-cohorted - threads. - """ - response = self.call_view( - mock_request, - self.alpha_user - ) - self.assert_has_access(response, 3) - - def test_beta_cohort_user(self, mock_request): - """ - Verify that beta_user has access to beta_cohort and non-cohorted - threads. - """ - response = self.call_view( - mock_request, - self.beta_user - ) - self.assert_has_access(response, 3) - - def test_global_staff_user(self, mock_request): - """ - Verify that global staff user has access to all threads regardless - of cohort. - """ - response = self.call_view( - mock_request, - self.staff_user - ) - self.assert_has_access(response, 4) - - -@patch('requests.request', autospec=True) -class SingleThreadContentGroupTestCase(ForumsEnableMixin, UrlResetMixin, ContentGroupTestCase): # lint-amnesty, pylint: disable=missing-class-docstring - - @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" - ) - self.mock_get_course_id_by_comment = patcher.start() - self.addCleanup(patcher.stop) - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" - ) - self.mock_get_course_id_by_thread = patcher.start() - self.addCleanup(patcher.stop) - - def assert_can_access(self, user, discussion_id, thread_id, should_have_access): - """ - Verify that a user has access to a thread within a given - discussion_id when should_have_access is True, otherwise - verify that the user does not have access to that thread. - """ - def call_single_thread(): - self.client.login(username=user.username, password=self.TEST_PASSWORD) - return self.client.get( - reverse('single_thread', args=[str(self.course.id), discussion_id, thread_id]) - ) - - if should_have_access: - assert call_single_thread().status_code == 200 - else: - assert call_single_thread().status_code == 404 - - def test_staff_user(self, mock_request): - """ - Verify that the staff user can access threads in the alpha, - beta, and global discussion blocks. - """ - thread_id = "test_thread_id" - mock_request.side_effect = make_mock_request_impl(course=self.course, text="dummy content", thread_id=thread_id) - - for discussion_xblock in [self.alpha_block, self.beta_block, self.global_block]: - self.assert_can_access(self.staff_user, discussion_xblock.discussion_id, thread_id, True) - - def test_alpha_user(self, mock_request): - """ - Verify that the alpha user can access threads in the alpha and - global discussion blocks. - """ - thread_id = "test_thread_id" - mock_request.side_effect = make_mock_request_impl(course=self.course, text="dummy content", thread_id=thread_id) - - for discussion_xblock in [self.alpha_block, self.global_block]: - self.assert_can_access(self.alpha_user, discussion_xblock.discussion_id, thread_id, True) - - self.assert_can_access(self.alpha_user, self.beta_block.discussion_id, thread_id, False) - - def test_beta_user(self, mock_request): - """ - Verify that the beta user can access threads in the beta and - global discussion blocks. - """ - thread_id = "test_thread_id" - mock_request.side_effect = make_mock_request_impl(course=self.course, text="dummy content", thread_id=thread_id) - - for discussion_xblock in [self.beta_block, self.global_block]: - self.assert_can_access(self.beta_user, discussion_xblock.discussion_id, thread_id, True) - - self.assert_can_access(self.beta_user, self.alpha_block.discussion_id, thread_id, False) - - def test_non_cohorted_user(self, mock_request): - """ - Verify that the non-cohorted user can access threads in just the - global discussion blocks. - """ - thread_id = "test_thread_id" - mock_request.side_effect = make_mock_request_impl(course=self.course, text="dummy content", thread_id=thread_id) - - self.assert_can_access(self.non_cohorted_user, self.global_block.discussion_id, thread_id, True) - - self.assert_can_access(self.non_cohorted_user, self.alpha_block.discussion_id, thread_id, False) - - self.assert_can_access(self.non_cohorted_user, self.beta_block.discussion_id, thread_id, False) - - def test_course_context_respected(self, mock_request): - """ - Verify that course threads go through discussion_category_id_access method. - """ - thread_id = "test_thread_id" - mock_request.side_effect = make_mock_request_impl( - course=self.course, text="dummy content", thread_id=thread_id - ) - - # Beta user does not have access to alpha_block. - self.assert_can_access(self.beta_user, self.alpha_block.discussion_id, thread_id, False) - - def test_standalone_context_respected(self, mock_request): - """ - Verify that standalone threads don't go through discussion_category_id_access method. - """ - # For this rather pathological test, we are assigning the alpha block discussion_id (commentable_id) - # to a team so that we can verify that standalone threads don't go through discussion_category_id_access. - thread_id = "test_thread_id" - CourseTeamFactory( - name="A team", - course_id=self.course.id, - topic_id='topic_id', - discussion_topic_id=self.alpha_block.discussion_id - ) - mock_request.side_effect = make_mock_request_impl( - course=self.course, text="dummy content", thread_id=thread_id, - commentable_id=self.alpha_block.discussion_id - ) - - # If a thread returns context other than "course", the access check is not done, and the beta user - # can see the alpha discussion block. - self.assert_can_access(self.beta_user, self.alpha_block.discussion_id, thread_id, True) - - -@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) -class InlineDiscussionContextTestCase(ForumsEnableMixin, ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring - - def setUp(self): - super().setUp() - self.course = CourseFactory.create() - CourseEnrollmentFactory(user=self.user, course_id=self.course.id) - self.discussion_topic_id = "dummy_topic" - self.team = CourseTeamFactory( - name="A team", - course_id=self.course.id, - topic_id='topic_id', - discussion_topic_id=self.discussion_topic_id - ) - - self.team.add_user(self.user) - self.user_not_in_team = UserFactory.create() - - def test_context_can_be_standalone(self, mock_request): - mock_request.side_effect = make_mock_request_impl( - course=self.course, - text="dummy text", - commentable_id=self.discussion_topic_id - ) - - request = RequestFactory().get("dummy_url") - request.user = self.user - - response = views.inline_discussion( - request, - str(self.course.id), - self.discussion_topic_id, - ) - - json_response = json.loads(response.content.decode('utf-8')) - assert json_response['discussion_data'][0]['context'] == ThreadContext.STANDALONE - - def test_private_team_discussion(self, mock_request): - # First set the team discussion to be private - CourseEnrollmentFactory(user=self.user_not_in_team, course_id=self.course.id) - request = RequestFactory().get("dummy_url") - request.user = self.user_not_in_team - - mock_request.side_effect = make_mock_request_impl( - course=self.course, - text="dummy text", - commentable_id=self.discussion_topic_id - ) - - with patch('lms.djangoapps.teams.api.is_team_discussion_private', autospec=True) as mocked: - mocked.return_value = True - response = views.inline_discussion( - request, - str(self.course.id), - self.discussion_topic_id, - ) - assert response.status_code == 403 - assert response.content.decode('utf-8') == views.TEAM_PERMISSION_MESSAGE - - -@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) -@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) -class InlineDiscussionGroupIdTestCase( # lint-amnesty, pylint: disable=missing-class-docstring - CohortedTestCase, - CohortedTopicGroupIdTestMixin, - NonCohortedTopicGroupIdTestMixin -): - cs_endpoint = "/threads" - - def setUp(self): - super().setUp() - self.cohorted_commentable_id = 'cohorted_topic' - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" - ) - self.mock_get_course_id_by_thread = patcher.start() - self.addCleanup(patcher.stop) - - def call_view( - self, - mock_is_forum_v2_enabled, - mock_request, - commentable_id, - user, - group_id, - pass_group_id=True - ): # pylint: disable=arguments-differ - mock_is_forum_v2_enabled.return_value = False - kwargs = {'commentable_id': self.cohorted_commentable_id} - if group_id: - # avoid causing a server error when the LMS chokes attempting - # to find a group name for the group_id, when we're testing with - # an invalid one. - try: - CourseUserGroup.objects.get(id=group_id) - kwargs['group_id'] = group_id - except CourseUserGroup.DoesNotExist: - pass - mock_request.side_effect = make_mock_request_impl(self.course, "dummy content", **kwargs) - - request_data = {} - if pass_group_id: - request_data["group_id"] = group_id - request = RequestFactory().get( - "dummy_url", - data=request_data - ) - request.user = user - return views.inline_discussion( - request, - str(self.course.id), - commentable_id - ) - - def test_group_info_in_ajax_response(self, mock_is_forum_v2_enabled, mock_request): - response = self.call_view( - mock_is_forum_v2_enabled, - mock_request, - self.cohorted_commentable_id, - self.student, - self.student_cohort.id - ) - self._assert_json_response_contains_group_info( - response, lambda d: d['discussion_data'][0] - ) - - -@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) -@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) -class ForumFormDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGroupIdTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring - cs_endpoint = "/threads" - - def setUp(self): - super().setUp() - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" - ) - self.mock_get_course_id_by_thread = patcher.start() - self.addCleanup(patcher.stop) - - def call_view( - self, - mock_is_forum_v2_enabled, - mock_request, - commentable_id, - user, - group_id, - pass_group_id=True, - is_ajax=False - ): # pylint: disable=arguments-differ - mock_is_forum_v2_enabled.return_value = False - kwargs = {} - if group_id: - kwargs['group_id'] = group_id - mock_request.side_effect = make_mock_request_impl(self.course, "dummy content", **kwargs) - - request_data = {} - if pass_group_id: - request_data["group_id"] = group_id - headers = {} - if is_ajax: - headers['HTTP_X_REQUESTED_WITH'] = "XMLHttpRequest" - - self.client.login(username=user.username, password=self.TEST_PASSWORD) - return self.client.get( - reverse("forum_form_discussion", args=[str(self.course.id)]), - data=request_data, - **headers - ) - - def test_group_info_in_html_response(self, mock_is_forum_v2_enabled, mock_request): - response = self.call_view( - mock_is_forum_v2_enabled, - mock_request, - "cohorted_topic", - self.student, - self.student_cohort.id - ) - self._assert_html_response_contains_group_info(response) - - def test_group_info_in_ajax_response(self, mock_is_forum_v2_enabled, mock_request): - response = self.call_view( - mock_is_forum_v2_enabled, - mock_request, - "cohorted_topic", - self.student, - self.student_cohort.id, - is_ajax=True - ) - self._assert_json_response_contains_group_info( - response, lambda d: d['discussion_data'][0] - ) - - -@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) -@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) -class UserProfileDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGroupIdTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring - cs_endpoint = "/active_threads" - - def setUp(self): - super().setUp() - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" - ) - self.mock_get_course_id_by_comment = patcher.start() - self.addCleanup(patcher.stop) - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" - ) - self.mock_get_course_id_by_thread = patcher.start() - self.addCleanup(patcher.stop) - - def call_view_for_profiled_user( - self, - mock_is_forum_v2_enabled, - mock_request, - requesting_user, - profiled_user, - group_id, - pass_group_id, - is_ajax=False - ): - """ - Calls "user_profile" view method on behalf of "requesting_user" to get information about - the user "profiled_user". - """ - mock_is_forum_v2_enabled.return_value = False - kwargs = {} - if group_id: - kwargs['group_id'] = group_id - mock_request.side_effect = make_mock_request_impl(self.course, "dummy content", **kwargs) - - request_data = {} - if pass_group_id: - request_data["group_id"] = group_id - headers = {} - if is_ajax: - headers['HTTP_X_REQUESTED_WITH'] = "XMLHttpRequest" - - self.client.login(username=requesting_user.username, password=self.TEST_PASSWORD) - return self.client.get( - reverse('user_profile', args=[str(self.course.id), profiled_user.id]), - data=request_data, - **headers - ) - - def call_view( - self, - mock_is_forum_v2_enabled, - mock_request, - _commentable_id, - user, - group_id, - pass_group_id=True, - is_ajax=False - ): # pylint: disable=arguments-differ - return self.call_view_for_profiled_user( - mock_is_forum_v2_enabled, mock_request, user, user, group_id, pass_group_id=pass_group_id, is_ajax=is_ajax - ) - - def test_group_info_in_html_response(self, mock_is_forum_v2_enabled, mock_request): - response = self.call_view( - mock_is_forum_v2_enabled, - mock_request, - "cohorted_topic", - self.student, - self.student_cohort.id, - is_ajax=False - ) - self._assert_html_response_contains_group_info(response) - - def test_group_info_in_ajax_response(self, mock_is_forum_v2_enabled, mock_request): - response = self.call_view( - mock_is_forum_v2_enabled, - mock_request, - "cohorted_topic", - self.student, - self.student_cohort.id, - is_ajax=True - ) - self._assert_json_response_contains_group_info( - response, lambda d: d['discussion_data'][0] - ) - - def _test_group_id_passed_to_user_profile( - self, - mock_is_forum_v2_enabled, - mock_request, - expect_group_id_in_request, - requesting_user, - profiled_user, - group_id, - pass_group_id - ): - """ - Helper method for testing whether or not group_id was passed to the user_profile request. - """ - - def get_params_from_user_info_call(for_specific_course): - """ - Returns the request parameters for the user info call with either course_id specified or not, - depending on value of 'for_specific_course'. - """ - # There will be 3 calls from user_profile. One has the cs_endpoint "active_threads", and it is already - # tested. The other 2 calls are for user info; one of those calls is for general information about the user, - # and it does not specify a course_id. The other call does specify a course_id, and if the caller did not - # have discussion moderator privileges, it should also contain a group_id. - for r_call in mock_request.call_args_list: - if not r_call[0][1].endswith(self.cs_endpoint): - params = r_call[1]["params"] - has_course_id = "course_id" in params - if (for_specific_course and has_course_id) or (not for_specific_course and not has_course_id): - return params - pytest.fail(f"Did not find appropriate user_profile call for 'for_specific_course'={for_specific_course}") - - mock_request.reset_mock() - self.call_view_for_profiled_user( - mock_is_forum_v2_enabled, - mock_request, - requesting_user, - profiled_user, - group_id, - pass_group_id=pass_group_id, - is_ajax=False - ) - # Should never have a group_id if course_id was not included in the request. - params_without_course_id = get_params_from_user_info_call(False) - assert 'group_id' not in params_without_course_id - - params_with_course_id = get_params_from_user_info_call(True) - if expect_group_id_in_request: - assert 'group_id' in params_with_course_id - assert group_id == params_with_course_id['group_id'] - else: - assert 'group_id' not in params_with_course_id - - def test_group_id_passed_to_user_profile_student(self, mock_is_forum_v2_enabled, mock_request): - """ - Test that the group id is always included when requesting user profile information for a particular - course if the requester does not have discussion moderation privileges. - """ - def verify_group_id_always_present(profiled_user, pass_group_id): - """ - Helper method to verify that group_id is always present for student in course - (non-privileged user). - """ - self._test_group_id_passed_to_user_profile( - mock_is_forum_v2_enabled, - mock_request, - True, - self.student, - profiled_user, - self.student_cohort.id, - pass_group_id - ) - - # In all these test cases, the requesting_user is the student (non-privileged user). - # The profile returned on behalf of the student is for the profiled_user. - verify_group_id_always_present(profiled_user=self.student, pass_group_id=True) - verify_group_id_always_present(profiled_user=self.student, pass_group_id=False) - verify_group_id_always_present(profiled_user=self.moderator, pass_group_id=True) - verify_group_id_always_present(profiled_user=self.moderator, pass_group_id=False) - - def test_group_id_user_profile_moderator(self, mock_is_forum_v2_enabled, mock_request): - """ - Test that the group id is only included when a privileged user requests user profile information for a - particular course and user if the group_id is explicitly passed in. - """ - def verify_group_id_present(profiled_user, pass_group_id, requested_cohort=self.moderator_cohort): - """ - Helper method to verify that group_id is present. - """ - self._test_group_id_passed_to_user_profile( - mock_is_forum_v2_enabled, - mock_request, - True, - self.moderator, - profiled_user, - requested_cohort.id, - pass_group_id - ) - - def verify_group_id_not_present(profiled_user, pass_group_id, requested_cohort=self.moderator_cohort): - """ - Helper method to verify that group_id is not present. - """ - self._test_group_id_passed_to_user_profile( - mock_is_forum_v2_enabled, - mock_request, - False, - self.moderator, - profiled_user, - requested_cohort.id, - pass_group_id - ) - - # In all these test cases, the requesting_user is the moderator (privileged user). - - # If the group_id is explicitly passed, it will be present in the request. - verify_group_id_present(profiled_user=self.student, pass_group_id=True) - verify_group_id_present(profiled_user=self.moderator, pass_group_id=True) - verify_group_id_present( - profiled_user=self.student, pass_group_id=True, requested_cohort=self.student_cohort - ) - - # If the group_id is not explicitly passed, it will not be present because the requesting_user - # has discussion moderator privileges. - verify_group_id_not_present(profiled_user=self.student, pass_group_id=False) - verify_group_id_not_present(profiled_user=self.moderator, pass_group_id=False) - - -@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) -@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) -class FollowedThreadsDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGroupIdTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring - cs_endpoint = "/subscribed_threads" - - def setUp(self): - super().setUp() - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" - ) - self.mock_get_course_id_by_thread = patcher.start() - self.addCleanup(patcher.stop) - - def call_view( - self, - mock_is_forum_v2_enabled, - mock_request, - commentable_id, - user, - group_id, - pass_group_id=True - ): # pylint: disable=arguments-differ - mock_is_forum_v2_enabled.return_value = False - kwargs = {} - if group_id: - kwargs['group_id'] = group_id - mock_request.side_effect = make_mock_request_impl(self.course, "dummy content", **kwargs) - - request_data = {} - if pass_group_id: - request_data["group_id"] = group_id - request = RequestFactory().get( - "dummy_url", - data=request_data, - HTTP_X_REQUESTED_WITH="XMLHttpRequest" - ) - request.user = user - return views.followed_threads( - request, - str(self.course.id), - user.id - ) + ) - def test_group_info_in_ajax_response(self, mock_is_forum_v2_enabled, mock_request): - response = self.call_view( - mock_is_forum_v2_enabled, - mock_request, - "cohorted_topic", - self.student, - self.student_cohort.id - ) - self._assert_json_response_contains_group_info( - response, lambda d: d['discussion_data'][0] - ) + def mock_request_impl(*args, **kwargs): + data = impl(*args, **kwargs) + if data: + return Mock(status_code=200, text=json.dumps(data), json=Mock(return_value=data)) + else: + return Mock(status_code=404) + return mock_request_impl -@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) -class InlineDiscussionTestCase(ForumsEnableMixin, ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring +class StringEndsWithMatcher: # lint-amnesty, pylint: disable=missing-class-docstring + def __init__(self, suffix): + self.suffix = suffix - def setUp(self): - super().setUp() + def __eq__(self, other): + return other.endswith(self.suffix) - self.course = CourseFactory.create( - org="TestX", - number="101", - display_name="Test Course", - teams_configuration=TeamsConfig({ - 'topics': [{ - 'id': 'topic_id', - 'name': 'A topic', - 'description': 'A topic', - }] - }) - ) - self.student = UserFactory.create() - CourseEnrollmentFactory(user=self.student, course_id=self.course.id) - self.discussion1 = BlockFactory.create( - parent_location=self.course.location, - category="discussion", - discussion_id="discussion1", - display_name='Discussion1', - discussion_category="Chapter", - discussion_target="Discussion1" - ) - def send_request(self, mock_request, params=None): - """ - Creates and returns a request with params set, and configures - mock_request to return appropriate values. - """ - request = RequestFactory().get("dummy_url", params if params else {}) - request.user = self.student - mock_request.side_effect = make_mock_request_impl( - course=self.course, text="dummy content", commentable_id=self.discussion1.discussion_id - ) - return views.inline_discussion( - request, str(self.course.id), self.discussion1.discussion_id - ) +class PartialDictMatcher: # lint-amnesty, pylint: disable=missing-class-docstring + def __init__(self, expected_values): + self.expected_values = expected_values - def test_context(self, mock_request): - team = CourseTeamFactory( - name='Team Name', - topic_id='topic_id', - course_id=self.course.id, - discussion_topic_id=self.discussion1.discussion_id + def __eq__(self, other): + return all( + key in other and other[key] == value + for key, value in self.expected_values.items() ) - team.add_user(self.student) - - self.send_request(mock_request) - assert mock_request.call_args[1]['params']['context'] == ThreadContext.STANDALONE - - -@patch('requests.request', autospec=True) -class UserProfileTestCase(ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring - - TEST_THREAD_TEXT = 'userprofile-test-text' - TEST_THREAD_ID = 'userprofile-test-thread-id' - - @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - super().setUp() - - self.course = CourseFactory.create() - self.student = UserFactory.create() - self.profiled_user = UserFactory.create() - CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) - CourseEnrollmentFactory.create(user=self.profiled_user, course_id=self.course.id) - def get_response(self, mock_request, params, **headers): # lint-amnesty, pylint: disable=missing-function-docstring - mock_request.side_effect = make_mock_request_impl( - course=self.course, text=self.TEST_THREAD_TEXT, thread_id=self.TEST_THREAD_ID - ) - self.client.login(username=self.student.username, password=self.TEST_PASSWORD) - - response = self.client.get( - reverse('user_profile', kwargs={ - 'course_id': str(self.course.id), - 'user_id': self.profiled_user.id, - }), - data=params, - **headers - ) - mock_request.assert_any_call( - "get", - StringEndsWithMatcher(f'/users/{self.profiled_user.id}/active_threads'), - data=None, - params=PartialDictMatcher({ - "course_id": str(self.course.id), - "page": params.get("page", 1), - "per_page": views.THREADS_PER_PAGE - }), - headers=ANY, - timeout=ANY - ) - return response - - def check_html(self, mock_request, **params): # lint-amnesty, pylint: disable=missing-function-docstring - response = self.get_response(mock_request, params) - assert response.status_code == 200 - assert response['Content-Type'] == 'text/html; charset=utf-8' - html = response.content.decode('utf-8') - self.assertRegex(html, r'data-page="1"') - self.assertRegex(html, r'data-num-pages="1"') - self.assertRegex(html, r'1 discussion started') - self.assertRegex(html, r'2 comments') - self.assertRegex(html, f''id': '{self.TEST_THREAD_ID}'') - self.assertRegex(html, f''title': '{self.TEST_THREAD_TEXT}'') - self.assertRegex(html, f''body': '{self.TEST_THREAD_TEXT}'') - self.assertRegex(html, f''username': '{self.student.username}'') - - def check_ajax(self, mock_request, **params): # lint-amnesty, pylint: disable=missing-function-docstring - response = self.get_response(mock_request, params, HTTP_X_REQUESTED_WITH="XMLHttpRequest") - assert response.status_code == 200 - assert response['Content-Type'] == 'application/json; charset=utf-8' - response_data = json.loads(response.content.decode('utf-8')) - assert sorted(response_data.keys()) == ['annotated_content_info', 'discussion_data', 'num_pages', 'page'] - assert len(response_data['discussion_data']) == 1 - assert response_data['page'] == 1 - assert response_data['num_pages'] == 1 - assert response_data['discussion_data'][0]['id'] == self.TEST_THREAD_ID - assert response_data['discussion_data'][0]['title'] == self.TEST_THREAD_TEXT - assert response_data['discussion_data'][0]['body'] == self.TEST_THREAD_TEXT - - def test_html(self, mock_request): - self.check_html(mock_request) - - def test_ajax(self, mock_request): - self.check_ajax(mock_request) - - def test_404_non_enrolled_user(self, __): - """ - Test that when student try to visit un-enrolled students' discussion profile, - the system raises Http404. - """ - unenrolled_user = UserFactory.create() - request = RequestFactory().get("dummy_url") - request.user = self.student - with pytest.raises(Http404): - views.user_profile( - request, - str(self.course.id), - unenrolled_user.id - ) +class AllowPlusOrMinusOneInt(int): + """ + A workaround for the fact that assertNumQueries doesn't let you + specify a range or any tolerance. An 'int' that is 'equal to' its value, + but also its value +/- 1 + """ - def test_404_profiled_user(self, _mock_request): - request = RequestFactory().get("dummy_url") - request.user = self.student - with pytest.raises(Http404): - views.user_profile( - request, - str(self.course.id), - -999 - ) + def __init__(self, value): + super().__init__() + self.value = value + self.values = (value, value - 1, value + 1) - def test_404_course(self, _mock_request): - request = RequestFactory().get("dummy_url") - request.user = self.student - with pytest.raises(Http404): - views.user_profile( - request, - "non/existent/course", - self.profiled_user.id - ) + def __eq__(self, other): + return other in self.values - def test_post(self, mock_request): - mock_request.side_effect = make_mock_request_impl( - course=self.course, text=self.TEST_THREAD_TEXT, thread_id=self.TEST_THREAD_ID - ) - request = RequestFactory().post("dummy_url") - request.user = self.student - response = views.user_profile( - request, - str(self.course.id), - self.profiled_user.id - ) - assert response.status_code == 405 + def __repr__(self): + return f"({self.value} +/- 1)" @patch('requests.request', autospec=True) -class CommentsServiceRequestHeadersTestCase(ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring +class CommentsServiceRequestHeadersTestCase(UrlResetMixin, ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring CREATE_USER = False @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" ) @@ -1811,261 +390,7 @@ def test_api_key(self, mock_request): self.assert_all_calls_have_header(mock_request, "X-Edx-Api-Key", "test_api_key") -class InlineDiscussionUnicodeTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring - - @classmethod - def setUpClass(cls): - # pylint: disable=super-method-not-called - with super().setUpClassAndTestData(): - cls.course = CourseFactory.create() - - @classmethod - def setUpTestData(cls): - super().setUpTestData() - - cls.student = UserFactory.create() - CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) - - @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def _test_unicode_data(self, text, mock_request): # lint-amnesty, pylint: disable=missing-function-docstring - mock_request.side_effect = make_mock_request_impl(course=self.course, text=text) - request = RequestFactory().get("dummy_url") - request.user = self.student - - response = views.inline_discussion( - request, str(self.course.id), self.course.discussion_topics['General']['id'] - ) - assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data['discussion_data'][0]['title'] == text - assert response_data['discussion_data'][0]['body'] == text - - -class ForumFormDiscussionUnicodeTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring - - @classmethod - def setUpClass(cls): - # pylint: disable=super-method-not-called - with super().setUpClassAndTestData(): - cls.course = CourseFactory.create() - - @classmethod - def setUpTestData(cls): - super().setUpTestData() - - cls.student = UserFactory.create() - CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) - - @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def _test_unicode_data(self, text, mock_request): # lint-amnesty, pylint: disable=missing-function-docstring - mock_request.side_effect = make_mock_request_impl(course=self.course, text=text) - request = RequestFactory().get("dummy_url") - request.user = self.student - # so (request.headers.get('x-requested-with') == 'XMLHttpRequest') == True - request.META["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest" - - response = views.forum_form_discussion(request, str(self.course.id)) - assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data['discussion_data'][0]['title'] == text - assert response_data['discussion_data'][0]['body'] == text - - -@ddt.ddt -@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) -class ForumDiscussionXSSTestCase(ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring - - @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - super().setUp() - - username = "foo" - password = "bar" - - self.course = CourseFactory.create() - self.student = UserFactory.create(username=username, password=password) - CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) - assert self.client.login(username=username, password=password) - - @ddt.data('">', '', '') - @patch('common.djangoapps.student.models.user.cc.User.from_django_user') - def test_forum_discussion_xss_prevent(self, malicious_code, mock_user, mock_req): - """ - Test that XSS attack is prevented - """ - mock_user.return_value.to_dict.return_value = {} - mock_req.return_value.status_code = 200 - reverse_url = "{}{}".format(reverse( - "forum_form_discussion", - kwargs={"course_id": str(self.course.id)}), '/forum_form_discussion') - # Test that malicious code does not appear in html - url = "{}?{}={}".format(reverse_url, 'sort_key', malicious_code) - resp = self.client.get(url) - self.assertNotContains(resp, malicious_code) - - @ddt.data('">', '', '') - @patch('common.djangoapps.student.models.user.cc.User.from_django_user') - @patch('common.djangoapps.student.models.user.cc.User.active_threads') - def test_forum_user_profile_xss_prevent(self, malicious_code, mock_threads, mock_from_django_user, mock_request): - """ - Test that XSS attack is prevented - """ - mock_threads.return_value = [], 1, 1 - mock_from_django_user.return_value.to_dict.return_value = { - 'upvoted_ids': [], - 'downvoted_ids': [], - 'subscribed_thread_ids': [] - } - mock_request.side_effect = make_mock_request_impl(course=self.course, text='dummy') - - url = reverse('user_profile', - kwargs={'course_id': str(self.course.id), 'user_id': str(self.student.id)}) - # Test that malicious code does not appear in html - url_string = "{}?{}={}".format(url, 'page', malicious_code) - resp = self.client.get(url_string) - self.assertNotContains(resp, malicious_code) - - -class ForumDiscussionSearchUnicodeTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring - - @classmethod - def setUpClass(cls): - # pylint: disable=super-method-not-called - with super().setUpClassAndTestData(): - cls.course = CourseFactory.create() - - @classmethod - def setUpTestData(cls): - super().setUpTestData() - - cls.student = UserFactory.create() - CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) - - @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def _test_unicode_data(self, text, mock_request): # lint-amnesty, pylint: disable=missing-function-docstring - mock_request.side_effect = make_mock_request_impl(course=self.course, text=text) - data = { - "ajax": 1, - "text": text, - } - request = RequestFactory().get("dummy_url", data) - request.user = self.student - # so (request.headers.get('x-requested-with') == 'XMLHttpRequest') == True - request.META["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest" - - response = views.forum_form_discussion(request, str(self.course.id)) - assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data['discussion_data'][0]['title'] == text - assert response_data['discussion_data'][0]['body'] == text - - -class SingleThreadUnicodeTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring - - @classmethod - def setUpClass(cls): - # pylint: disable=super-method-not-called - with super().setUpClassAndTestData(): - cls.course = CourseFactory.create(discussion_topics={'dummy_discussion_id': {'id': 'dummy_discussion_id'}}) - - def setUp(self): - super().setUp() - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" - ) - self.mock_get_course_id_by_thread = patcher.start() - self.addCleanup(patcher.stop) - - @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 - thread_id = "test_thread_id" - mock_request.side_effect = make_mock_request_impl(course=self.course, text=text, thread_id=thread_id) - 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.single_thread(request, str(self.course.id), "dummy_discussion_id", thread_id) - assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data['content']['title'] == text - assert response_data['content']['body'] == text - - -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 FollowedThreadsUnicodeTestCase(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.followed_threads(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): +class EnrollmentTestCase(ModuleStoreTestCase): """ Tests for the behavior of views depending on if the student is enrolled in the course @@ -2087,60 +412,6 @@ def test_unenrolled(self, mock_request): views.forum_form_discussion(request, course_id=str(self.course.id)) # pylint: disable=no-value-for-parameter, unexpected-keyword-arg -@patch('requests.request', autospec=True) -class EnterpriseConsentTestCase(EnterpriseTestConsentRequired, ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase): - """ - Ensure that the Enterprise Data Consent redirects are in place only when consent is required. - """ - CREATE_USER = False - - @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - # Invoke UrlResetMixin setUp - super().setUp() - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" - ) - self.mock_get_course_id_by_thread = patcher.start() - self.addCleanup(patcher.stop) - username = "foo" - password = "bar" - - self.discussion_id = 'dummy_discussion_id' - self.course = CourseFactory.create(discussion_topics={'dummy discussion': {'id': self.discussion_id}}) - self.student = UserFactory.create(username=username, password=password) - CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) - assert self.client.login(username=username, password=password) - - self.addCleanup(translation.deactivate) - - @patch('openedx.features.enterprise_support.api.enterprise_customer_for_request') - def test_consent_required(self, mock_enterprise_customer_for_request, mock_request): - """ - Test that enterprise data sharing consent is required when enabled for the various discussion views. - """ - # ENT-924: Temporary solution to replace sensitive SSO usernames. - mock_enterprise_customer_for_request.return_value = None - - thread_id = 'dummy' - course_id = str(self.course.id) - mock_request.side_effect = make_mock_request_impl(course=self.course, text='dummy', thread_id=thread_id) - - for url in ( - reverse('forum_form_discussion', - kwargs=dict(course_id=course_id)), - reverse('single_thread', - kwargs=dict(course_id=course_id, discussion_id=self.discussion_id, thread_id=thread_id)), - ): - self.verify_consent_required(self.client, url) # pylint: disable=no-value-for-parameter - - class DividedDiscussionsTestCase(CohortViewsTestCase): # lint-amnesty, pylint: disable=missing-class-docstring def create_divided_discussions(self): @@ -2426,174 +697,3 @@ def test_default_topic_id(self): expected_id = 'another_discussion_id' result = _get_discussion_default_topic_id(course) assert expected_id == result - - -class ThreadViewedEventTestCase(EventTestMixin, ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase): - """ - Forum thread views are expected to launch analytics events. Test these here. - """ - - CATEGORY_ID = 'i4x-edx-discussion-id' - CATEGORY_NAME = 'Discussion 1' - PARENT_CATEGORY_NAME = 'Chapter 1' - - DUMMY_THREAD_ID = 'dummythreadids' - DUMMY_TITLE = 'Dummy title' - DUMMY_URL = 'https://example.com/dummy/url/' - - @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): # pylint: disable=arguments-differ - super().setUp('lms.djangoapps.discussion.django_comment_client.base.views.tracker') - - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" - ) - self.mock_get_course_id_by_thread = patcher.start() - self.addCleanup(patcher.stop) - self.course = CourseFactory.create( - teams_configuration=TeamsConfig({ - 'topics': [{ - 'id': 'arbitrary-topic-id', - 'name': 'arbitrary-topic-name', - 'description': 'arbitrary-topic-desc' - }] - }) - ) - seed_permissions_roles(self.course.id) - - PASSWORD = 'test' - self.student = UserFactory.create(password=PASSWORD) - CourseEnrollmentFactory(user=self.student, course_id=self.course.id) - - self.staff = UserFactory.create(is_staff=True) - UserBasedRole(user=self.staff, role=CourseStaffRole.ROLE).add_course(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 = CourseTeamFactory.create( - name='Team 1', - course_id=self.course.id, - topic_id='arbitrary-topic-id', - discussion_topic_id=self.category.discussion_id, - ) - CourseTeamMembershipFactory.create(team=self.team, user=self.student) - self.client.login(username=self.student.username, password=PASSWORD) - - @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.perform_request') - def test_thread_viewed_event(self, mock_perform_request): - mock_perform_request.side_effect = make_mock_perform_request_impl( - course=self.course, - text=self.DUMMY_TITLE, - thread_id=self.DUMMY_THREAD_ID, - commentable_id=self.category.discussion_id, - ) - url = '/courses/{}/discussion/forum/{}/threads/{}'.format( - str(self.course.id), - self.category.discussion_id, - self.DUMMY_THREAD_ID - ) - self.client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - - expected_event = { - 'id': self.DUMMY_THREAD_ID, - 'title': self.DUMMY_TITLE, - 'commentable_id': self.category.discussion_id, - 'category_id': self.category.discussion_id, - 'category_name': self.category.discussion_target, - 'user_forums_roles': [FORUM_ROLE_STUDENT], - 'user_course_roles': [], - 'target_username': self.student.username, - 'team_id': self.team.id, - 'url': self.DUMMY_URL, - } - expected_event_items = list(expected_event.items()) - - self.assert_event_emission_count('edx.forum.thread.viewed', 1) - _, event = self.get_latest_call_args() - event_items = list(event.items()) - assert ((kv_pair in event_items) for kv_pair in expected_event_items) - - -@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 new file mode 100644 index 000000000000..aabdb9d74c21 --- /dev/null +++ b/lms/djangoapps/discussion/tests/test_views_v2.py @@ -0,0 +1,2132 @@ +# pylint: disable=unused-import +""" +Tests the forum notification views. +""" + +import json +import logging +from datetime import datetime +from unittest import mock +from unittest.mock import ANY, Mock, call, patch + +import ddt +import pytest +from django.conf import settings +from django.http import Http404 +from django.test.client import Client, RequestFactory +from django.test.utils import override_settings +from django.urls import reverse +from django.utils import translation +from edx_django_utils.cache import RequestCache +from edx_toggles.toggles.testutils import override_waffle_flag +from lms.djangoapps.discussion.django_comment_client.tests.mixins import ( + MockForumApiMixin, +) +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import ( + TEST_DATA_SPLIT_MODULESTORE, + ModuleStoreTestCase, + SharedModuleStoreTestCase, +) +from xmodule.modulestore.tests.factories import ( + CourseFactory, + BlockFactory, + check_mongo_calls, +) + +from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.course_modes.tests.factories import CourseModeFactory +from common.djangoapps.student.roles import CourseStaffRole, UserBasedRole +from common.djangoapps.student.tests.factories import ( + AdminFactory, + CourseEnrollmentFactory, + UserFactory, +) +from common.djangoapps.util.testing import EventTestMixin, UrlResetMixin +from lms.djangoapps.courseware.exceptions import CourseAccessRedirect +from lms.djangoapps.discussion import views +from lms.djangoapps.discussion.django_comment_client.constants import ( + TYPE_ENTRY, + TYPE_SUBCATEGORY, +) +from lms.djangoapps.discussion.django_comment_client.permissions import get_team +from lms.djangoapps.discussion.django_comment_client.tests.group_id import ( + CohortedTopicGroupIdTestMixinV2, + GroupIdAssertionMixinV2, + NonCohortedTopicGroupIdTestMixinV2, +) +from lms.djangoapps.discussion.django_comment_client.tests.unicode import ( + UnicodeTestMixin, +) +from lms.djangoapps.discussion.django_comment_client.tests.utils import ( + CohortedTestCase, + config_course_discussions, + topic_name_to_id, +) +from lms.djangoapps.discussion.django_comment_client.utils import strip_none +from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE +from lms.djangoapps.discussion.views import ( + _get_discussion_default_topic_id, + course_discussions_settings_handler, +) +from lms.djangoapps.teams.tests.factories import ( + CourseTeamFactory, + CourseTeamMembershipFactory, +) +from openedx.core.djangoapps.course_groups.models import CourseUserGroup +from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts +from openedx.core.djangoapps.course_groups.tests.test_views import CohortViewsTestCase +from openedx.core.djangoapps.django_comment_common.comment_client.utils import ( + CommentClientPaginatedResult, +) +from openedx.core.djangoapps.django_comment_common.models import ( + FORUM_ROLE_STUDENT, + CourseDiscussionSettings, + ForumsConfig, +) +from openedx.core.djangoapps.django_comment_common.utils import ( + ThreadContext, + seed_permissions_roles, +) +from openedx.core.djangoapps.util.testing import ContentGroupTestCase +from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES +from openedx.core.lib.teams_config import TeamsConfig +from openedx.features.content_type_gating.models import ContentTypeGatingConfig +from openedx.features.enterprise_support.tests.mixins.enterprise import ( + EnterpriseTestConsentRequired, +) + +log = logging.getLogger(__name__) + +QUERY_COUNT_TABLE_IGNORELIST = WAFFLE_TABLES + + +def make_mock_thread_data( + course, + text, + thread_id, + num_children, + group_id=None, + group_name=None, + commentable_id=None, + is_commentable_divided=None, + anonymous=False, + anonymous_to_peers=False, +): + """ + Creates mock thread data for testing purposes. + """ + data_commentable_id = ( + commentable_id + or course.discussion_topics.get("General", {}).get("id") + or "dummy_commentable_id" + ) + thread_data = { + "id": thread_id, + "type": "thread", + "title": text, + "body": text, + "commentable_id": data_commentable_id, + "resp_total": 42, + "resp_skip": 25, + "resp_limit": 5, + "group_id": group_id, + "anonymous": anonymous, + "anonymous_to_peers": anonymous_to_peers, + "context": ( + ThreadContext.COURSE + if get_team(data_commentable_id) is None + else ThreadContext.STANDALONE + ), + } + if group_id is not None: + thread_data["group_name"] = group_name + if is_commentable_divided is not None: + thread_data["is_commentable_divided"] = is_commentable_divided + if num_children is not None: + thread_data["children"] = [ + { + "id": f"dummy_comment_id_{i}", + "type": "comment", + "body": text, + } + for i in range(num_children) + ] + return thread_data + + +def make_mock_collection_data( + course, + text, + thread_id, + num_children=None, + group_id=None, + commentable_id=None, + thread_list=None, +): + """ + Creates mock collection data for testing purposes. + """ + if thread_list: + return [ + make_mock_thread_data( + course=course, text=text, num_children=num_children, **thread + ) + for thread in thread_list + ] + else: + return [ + make_mock_thread_data( + course=course, + text=text, + thread_id=thread_id, + num_children=num_children, + group_id=group_id, + commentable_id=commentable_id, + ) + ] + + +def make_collection_callback( + course, + text, + thread_id="dummy_thread_id", + group_id=None, + commentable_id=None, + thread_list=None, +): + """ + Creates a callback function for simulating collection data. + """ + + def callback(*args, **kwargs): + # Simulate default user thread response + return { + "collection": make_mock_collection_data( + course, text, thread_id, None, group_id, commentable_id, thread_list + ) + } + + return callback + + +def make_thread_callback( + course, + text, + thread_id="dummy_thread_id", + group_id=None, + commentable_id=None, + num_thread_responses=1, + anonymous=False, + anonymous_to_peers=False, +): + """ + Creates a callback function for simulating thread data. + """ + + def callback(*args, **kwargs): + # Simulate default user thread response + return make_mock_thread_data( + course=course, + text=text, + thread_id=thread_id, + num_children=num_thread_responses, + group_id=group_id, + commentable_id=commentable_id, + anonymous=anonymous, + anonymous_to_peers=anonymous_to_peers, + ) + + return callback + + +def make_user_callback(): + """ + Creates a callback function for simulating user data. + """ + + def callback(*args, **kwargs): + res = { + "default_sort_key": "date", + "upvoted_ids": [], + "downvoted_ids": [], + "subscribed_thread_ids": [], + } + # comments service adds these attributes when course_id param is present + if kwargs.get("course_id"): + res.update({"threads_count": 1, "comments_count": 2}) + return res + + return callback + + +class ForumViewsUtilsMixin(MockForumApiMixin): + """ + Utils for the Forum Views. + """ + + def _configure_mock_responses( + self, + course, + text, + thread_id="dummy_thread_id", + group_id=None, + commentable_id=None, + num_thread_responses=1, + thread_list=None, + anonymous=False, + anonymous_to_peers=False, + ): + """ + Configure mock responses for the Forum Views. + """ + for func_name in [ + "search_threads", + "get_user_active_threads", + "get_user_threads", + "get_user_subscriptions", + ]: + self.set_mock_side_effect( + func_name, + make_collection_callback( + course, + text, + thread_id, + group_id, + commentable_id, + thread_list, + ), + ) + + self.set_mock_side_effect( + "get_thread", + make_thread_callback( + course, + text, + thread_id, + group_id, + commentable_id, + num_thread_responses, + anonymous, + anonymous_to_peers, + ), + ) + + self.set_mock_side_effect("get_user", make_user_callback()) + + +class SingleThreadTestCase(ModuleStoreTestCase, ForumViewsUtilsMixin): # lint-amnesty, pylint: disable=missing-class-docstring + + CREATE_USER = False + + def setUp(self): + super().setUp() + self.course = CourseFactory.create(discussion_topics={'dummy discussion': {'id': 'dummy_discussion_id'}}) + self.student = UserFactory.create() + CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) + self.set_mock_return_value('get_course_id_by_thread', str(self.course.id)) + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def test_ajax(self): + text = "dummy content" + thread_id = "test_thread_id" + self._configure_mock_responses(course=self.course, text=text, thread_id=thread_id) + + request = RequestFactory().get( + "dummy_url", + HTTP_X_REQUESTED_WITH="XMLHttpRequest" + ) + request.user = self.student + response = views.single_thread( + request, + str(self.course.id), + "dummy_discussion_id", + "test_thread_id" + ) + + assert response.status_code == 200 + response_data = json.loads(response.content.decode('utf-8')) + # strip_none is being used to perform the same transform that the + # django view performs prior to writing thread data to the response + assert response_data['content'] == strip_none(make_mock_thread_data( + course=self.course, + text=text, + thread_id=thread_id, + num_children=1 + )) + get_thread_args = self.get_mock_func_calls('get_thread')[0][1] + get_thread_args['params']['mark_as_read'] = True + get_thread_args['params']['user_id'] = '1' + get_thread_args['params']['recursive'] = True + + def test_skip_limit(self): + text = "dummy content" + thread_id = "test_thread_id" + response_skip = "45" + response_limit = "15" + self._configure_mock_responses(course=self.course, text=text, thread_id=thread_id) + + request = RequestFactory().get( + "dummy_url", + {"resp_skip": response_skip, "resp_limit": response_limit}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest" + ) + request.user = self.student + response = views.single_thread( + request, + str(self.course.id), + "dummy_discussion_id", + "test_thread_id" + ) + assert response.status_code == 200 + response_data = json.loads(response.content.decode('utf-8')) + # strip_none is being used to perform the same transform that the + # django view performs prior to writing thread data to the response + assert response_data['content'] == strip_none(make_mock_thread_data( + course=self.course, + text=text, + thread_id=thread_id, + num_children=1 + )) + + params = { + "thread_id": thread_id, + 'params': { + 'recursive': True, + 'with_responses': True, + 'user_id': '1', + 'mark_as_read': True, + 'resp_skip': response_skip, + 'resp_limit': response_limit, + 'reverse_order': False, + 'merge_question_type_responses': False + }, + 'course_id': str(self.course.id) + } + self.check_mock_called_with('get_thread', -1, **params) + + def test_post(self): + request = RequestFactory().post("dummy_url") + response = views.single_thread( + request, + str(self.course.id), + "dummy_discussion_id", + "dummy_thread_id" + ) + assert response.status_code == 405 + + def test_post_anonymous_to_ta(self): + text = "dummy content" + thread_id = "test_thread_id" + self._configure_mock_responses( + course=self.course, text=text, + thread_id=thread_id, + anonymous_to_peers=True, + ) + + request = RequestFactory().get( + "dummy_url", + HTTP_X_REQUESTED_WITH="XMLHttpRequest" + ) + request.user = self.student + request.user.is_community_ta = True + response = views.single_thread( + request, + str(self.course.id), + "dummy_discussion_id", + "test_thread_id" + ) + + assert response.status_code == 200 + response_data = json.loads(response.content.decode('utf-8')) + # user is community ta, so response must not have username and user_id fields + assert response_data['content'].get('username') is None + assert response_data['content'].get('user_id') is None + + def test_not_found(self): + request = RequestFactory().get("dummy_url") + request.user = self.student + # Mock request to return 404 for thread request + self._configure_mock_responses(course=self.course, text="dummy", thread_id=None) + self.assertRaises( + Http404, + views.single_thread, + request, + str(self.course.id), + "test_discussion_id", + "test_thread_id" + ) + + def test_private_team_thread_html(self): + discussion_topic_id = 'dummy_discussion_id' + thread_id = 'test_thread_id' + CourseTeamFactory.create(discussion_topic_id=discussion_topic_id) + user_not_in_team = UserFactory.create() + CourseEnrollmentFactory.create(user=user_not_in_team, course_id=self.course.id) + self.client.login(username=user_not_in_team.username, password=self.TEST_PASSWORD) + + self._configure_mock_responses( + course=self.course, + text="dummy", + thread_id=thread_id, + commentable_id=discussion_topic_id + ) + with patch('lms.djangoapps.teams.api.is_team_discussion_private', autospec=True) as mocked: + mocked.return_value = True + response = self.client.get( + reverse('single_thread', kwargs={ + 'course_id': str(self.course.id), + 'discussion_id': discussion_topic_id, + 'thread_id': thread_id, + }) + ) + assert response.status_code == 200 + assert response['Content-Type'] == 'text/html; charset=utf-8' + html = response.content.decode('utf-8') + # Verify that the access denied error message is in the HTML + assert 'This is a private discussion. You do not have permissions to view this discussion' in html + + +class SingleCohortedThreadTestCase(CohortedTestCase, ForumViewsUtilsMixin): # lint-amnesty, pylint: disable=missing-class-docstring + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def _create_mock_cohorted_thread(self): # lint-amnesty, pylint: disable=missing-function-docstring + mock_text = "dummy content" + mock_thread_id = "test_thread_id" + self._configure_mock_responses( + course=self.course, text=mock_text, + thread_id=mock_thread_id, + group_id=self.student_cohort.id, + commentable_id="cohorted_topic", + ) + return mock_text, mock_thread_id + + def test_ajax(self): + mock_text, mock_thread_id = self._create_mock_cohorted_thread() + + request = RequestFactory().get( + "dummy_url", + HTTP_X_REQUESTED_WITH="XMLHttpRequest" + ) + request.user = self.student + response = views.single_thread( + request, + str(self.course.id), + "cohorted_topic", + mock_thread_id + ) + + assert response.status_code == 200 + response_data = json.loads(response.content.decode('utf-8')) + assert response_data['content'] == make_mock_thread_data( + course=self.course, + commentable_id='cohorted_topic', + text=mock_text, + thread_id=mock_thread_id, + num_children=1, + group_id=self.student_cohort.id, + group_name=self.student_cohort.name, + is_commentable_divided=True + ) + + def test_html(self): + _mock_text, mock_thread_id = self._create_mock_cohorted_thread() + + self.client.login(username=self.student.username, password=self.TEST_PASSWORD) + response = self.client.get( + reverse('single_thread', kwargs={ + 'course_id': str(self.course.id), + 'discussion_id': "cohorted_topic", + 'thread_id': mock_thread_id, + }) + ) + + assert response.status_code == 200 + assert response['Content-Type'] == 'text/html; charset=utf-8' + html = response.content.decode('utf-8') + + # Verify that the group name is correctly included in the HTML + self.assertRegex(html, r'"group_name": "student_cohort"') + + +class SingleThreadAccessTestCase(CohortedTestCase, ForumViewsUtilsMixin): # lint-amnesty, pylint: disable=missing-class-docstring + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def call_view(self, commentable_id, user, group_id, thread_group_id=None, pass_group_id=True): # lint-amnesty, pylint: disable=missing-function-docstring + thread_id = "test_thread_id" + self._configure_mock_responses( + course=self.course, text="dummy context", thread_id=thread_id, group_id=thread_group_id + ) + + request_data = {} + if pass_group_id: + request_data["group_id"] = group_id + request = RequestFactory().get( + "dummy_url", + data=request_data, + HTTP_X_REQUESTED_WITH="XMLHttpRequest" + ) + request.user = user + return views.single_thread( + request, + str(self.course.id), + commentable_id, + thread_id + ) + + def test_student_non_cohorted(self): + resp = self.call_view("non_cohorted_topic", self.student, self.student_cohort.id) + assert resp.status_code == 200 + + def test_student_same_cohort(self): + resp = self.call_view( + "cohorted_topic", + self.student, + self.student_cohort.id, + thread_group_id=self.student_cohort.id + ) + assert resp.status_code == 200 + + # this test ensures that a thread response from the cs with group_id: null + # behaves the same as a thread response without a group_id (see: TNL-444) + def test_student_global_thread_in_cohorted_topic(self): + resp = self.call_view( + "cohorted_topic", + self.student, + self.student_cohort.id, + thread_group_id=None + ) + assert resp.status_code == 200 + + def test_student_different_cohort(self): + pytest.raises(Http404, (lambda: self.call_view( + 'cohorted_topic', + self.student, + self.student_cohort.id, + thread_group_id=self.moderator_cohort.id + ))) + + def test_moderator_non_cohorted(self): + resp = self.call_view("non_cohorted_topic", self.moderator, self.moderator_cohort.id) + assert resp.status_code == 200 + + def test_moderator_same_cohort(self): + resp = self.call_view( + "cohorted_topic", + self.moderator, + self.moderator_cohort.id, + thread_group_id=self.moderator_cohort.id + ) + assert resp.status_code == 200 + + def test_moderator_different_cohort(self): + resp = self.call_view( + "cohorted_topic", + self.moderator, + self.moderator_cohort.id, + thread_group_id=self.student_cohort.id + ) + assert resp.status_code == 200 + + def test_private_team_thread(self): + CourseTeamFactory.create(discussion_topic_id='dummy_discussion_id') + user_not_in_team = UserFactory.create() + CourseEnrollmentFactory(user=user_not_in_team, course_id=self.course.id) + + with patch('lms.djangoapps.teams.api.is_team_discussion_private', autospec=True) as mocked: + mocked.return_value = True + response = self.call_view( + 'non_cohorted_topic', + user_not_in_team, + '' + ) + assert 403 == response.status_code + assert views.TEAM_PERMISSION_MESSAGE == response.content.decode('utf-8') + + +class SingleThreadGroupIdTestCase(CohortedTestCase, GroupIdAssertionMixinV2, ForumViewsUtilsMixin): # lint-amnesty, pylint: disable=missing-class-docstring + function_name = "get_thread" + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def call_view(self, commentable_id, user, group_id, pass_group_id=True, is_ajax=False): # lint-amnesty, pylint: disable=missing-function-docstring + self._configure_mock_responses( + course=self.course, text="dummy context", group_id=self.student_cohort.id + ) + + request_data = {} + if pass_group_id: + request_data["group_id"] = group_id + headers = {} + if is_ajax: + headers['HTTP_X_REQUESTED_WITH'] = "XMLHttpRequest" + + self.client.login(username=user.username, password=self.TEST_PASSWORD) + + return self.client.get( + reverse('single_thread', args=[str(self.course.id), commentable_id, "dummy_thread_id"]), + data=request_data, + **headers + ) + + def test_group_info_in_html_response(self): + response = self.call_view( + "cohorted_topic", + self.student, + self.student_cohort.id, + is_ajax=False + ) + self._assert_html_response_contains_group_info(response) + + def test_group_info_in_ajax_response(self): + response = self.call_view( + "cohorted_topic", + self.student, + self.student_cohort.id, + is_ajax=True + ) + self._assert_json_response_contains_group_info( + response, lambda d: d['content'] + ) + + +class SingleThreadContentGroupTestCase(UrlResetMixin, ContentGroupTestCase, ForumViewsUtilsMixin): # lint-amnesty, pylint: disable=missing-class-docstring + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + super().setUp() + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def assert_can_access(self, user, discussion_id, thread_id, should_have_access): + """ + Verify that a user has access to a thread within a given + discussion_id when should_have_access is True, otherwise + verify that the user does not have access to that thread. + """ + def call_single_thread(): + self.client.login(username=user.username, password=self.TEST_PASSWORD) + return self.client.get( + reverse('single_thread', args=[str(self.course.id), discussion_id, thread_id]) + ) + + if should_have_access: + assert call_single_thread().status_code == 200 + else: + assert call_single_thread().status_code == 404 + + def test_staff_user(self): + """ + Verify that the staff user can access threads in the alpha, + beta, and global discussion blocks. + """ + thread_id = "test_thread_id" + self._configure_mock_responses(course=self.course, text="dummy content", thread_id=thread_id) + + for discussion_xblock in [self.alpha_block, self.beta_block, self.global_block]: + self.assert_can_access(self.staff_user, discussion_xblock.discussion_id, thread_id, True) + + def test_alpha_user(self): + """ + Verify that the alpha user can access threads in the alpha and + global discussion blocks. + """ + thread_id = "test_thread_id" + self._configure_mock_responses(course=self.course, text="dummy content", thread_id=thread_id) + + for discussion_xblock in [self.alpha_block, self.global_block]: + self.assert_can_access(self.alpha_user, discussion_xblock.discussion_id, thread_id, True) + + self.assert_can_access(self.alpha_user, self.beta_block.discussion_id, thread_id, False) + + def test_beta_user(self): + """ + Verify that the beta user can access threads in the beta and + global discussion blocks. + """ + thread_id = "test_thread_id" + self._configure_mock_responses(course=self.course, text="dummy content", thread_id=thread_id) + + for discussion_xblock in [self.beta_block, self.global_block]: + self.assert_can_access(self.beta_user, discussion_xblock.discussion_id, thread_id, True) + + self.assert_can_access(self.beta_user, self.alpha_block.discussion_id, thread_id, False) + + def test_non_cohorted_user(self): + """ + Verify that the non-cohorted user can access threads in just the + global discussion blocks. + """ + thread_id = "test_thread_id" + self._configure_mock_responses(course=self.course, text="dummy content", thread_id=thread_id) + + self.assert_can_access(self.non_cohorted_user, self.global_block.discussion_id, thread_id, True) + + self.assert_can_access(self.non_cohorted_user, self.alpha_block.discussion_id, thread_id, False) + + self.assert_can_access(self.non_cohorted_user, self.beta_block.discussion_id, thread_id, False) + + def test_course_context_respected(self): + """ + Verify that course threads go through discussion_category_id_access method. + """ + thread_id = "test_thread_id" + self._configure_mock_responses( + course=self.course, text="dummy content", thread_id=thread_id + ) + + # Beta user does not have access to alpha_block. + self.assert_can_access(self.beta_user, self.alpha_block.discussion_id, thread_id, False) + + def test_standalone_context_respected(self): + """ + Verify that standalone threads don't go through discussion_category_id_access method. + """ + # For this rather pathological test, we are assigning the alpha block discussion_id (commentable_id) + # to a team so that we can verify that standalone threads don't go through discussion_category_id_access. + thread_id = "test_thread_id" + CourseTeamFactory( + name="A team", + course_id=self.course.id, + topic_id='topic_id', + discussion_topic_id=self.alpha_block.discussion_id + ) + self._configure_mock_responses( + course=self.course, text="dummy content", thread_id=thread_id, + commentable_id=self.alpha_block.discussion_id + ) + + # If a thread returns context other than "course", the access check is not done, and the beta user + # can see the alpha discussion block. + self.assert_can_access(self.beta_user, self.alpha_block.discussion_id, thread_id, True) + + +class FollowedThreadsDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGroupIdTestMixinV2, ForumViewsUtilsMixin): # lint-amnesty, pylint: disable=missing-class-docstring + function_name = "get_user_subscriptions" + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def call_view( + self, + commentable_id, + user, + group_id, + pass_group_id=True + ): # pylint: disable=arguments-differ + kwargs = {} + if group_id: + kwargs['group_id'] = group_id + self._configure_mock_responses(self.course, "dummy content", **kwargs) + + request_data = {} + if pass_group_id: + request_data["group_id"] = group_id + request = RequestFactory().get( + "dummy_url", + data=request_data, + HTTP_X_REQUESTED_WITH="XMLHttpRequest" + ) + request.user = user + return views.followed_threads( + request, + str(self.course.id), + user.id + ) + + def test_group_info_in_ajax_response(self): + response = self.call_view( + "cohorted_topic", + self.student, + self.student_cohort.id + ) + self._assert_json_response_contains_group_info( + response, lambda d: d['discussion_data'][0] + ) + + +class SingleThreadUnicodeTestCase(SharedModuleStoreTestCase, UnicodeTestMixin, ForumViewsUtilsMixin): # lint-amnesty, pylint: disable=missing-class-docstring + + @classmethod + def setUpClass(cls): # pylint: disable=super-method-not-called + super().setUpClassAndForumMock() + with super().setUpClassAndTestData(): + cls.course = CourseFactory.create(discussion_topics={'dummy_discussion_id': {'id': 'dummy_discussion_id'}}) + + @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) + + @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 + thread_id = "test_thread_id" + self._configure_mock_responses(course=self.course, text=text, thread_id=thread_id) + 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.single_thread(request, str(self.course.id), "dummy_discussion_id", thread_id) + assert response.status_code == 200 + response_data = json.loads(response.content.decode('utf-8')) + assert response_data['content']['title'] == text + assert response_data['content']['body'] == text + + +class ForumFormDiscussionContentGroupTestCase( + ContentGroupTestCase, ForumViewsUtilsMixin +): + """ + Tests `forum_form_discussion api` works with different content groups. + Discussion blocks are setup in ContentGroupTestCase class i.e + alpha_block => alpha_group_discussion => alpha_cohort => alpha_user/community_ta + beta_block => beta_group_discussion => beta_cohort => beta_user + """ + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + super().setUp() + self.thread_list = [ + {"thread_id": "test_general_thread_id"}, + { + "thread_id": "test_global_group_thread_id", + "commentable_id": self.global_block.discussion_id, + }, + { + "thread_id": "test_alpha_group_thread_id", + "group_id": self.alpha_block.group_access[0][0], + "commentable_id": self.alpha_block.discussion_id, + }, + { + "thread_id": "test_beta_group_thread_id", + "group_id": self.beta_block.group_access[0][0], + "commentable_id": self.beta_block.discussion_id, + }, + ] + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def assert_has_access(self, response, expected_discussion_threads): + """ + Verify that a users have access to the threads in their assigned + cohorts and non-cohorted blocks. + """ + discussion_data = json.loads(response.content.decode("utf-8"))[ + "discussion_data" + ] + assert len(discussion_data) == expected_discussion_threads + + def call_view( + self, user + ): # lint-amnesty, pylint: disable=missing-function-docstring + self._configure_mock_responses( + course=self.course, text="dummy content", thread_list=self.thread_list + ) + self.client.login(username=user.username, password=self.TEST_PASSWORD) + return self.client.get( + reverse("forum_form_discussion", args=[str(self.course.id)]), + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + + def test_community_ta_user(self): + """ + Verify that community_ta user has access to all threads regardless + of cohort. + """ + response = self.call_view(self.community_ta) + self.assert_has_access(response, 4) + + def test_alpha_cohort_user(self): + """ + Verify that alpha_user has access to alpha_cohort and non-cohorted + threads. + """ + response = self.call_view(self.alpha_user) + self.assert_has_access(response, 3) + + def test_beta_cohort_user(self): + """ + Verify that beta_user has access to beta_cohort and non-cohorted + threads. + """ + response = self.call_view(self.beta_user) + self.assert_has_access(response, 3) + + def test_global_staff_user(self): + """ + Verify that global staff user has access to all threads regardless + of cohort. + """ + response = self.call_view(self.staff_user) + self.assert_has_access(response, 4) + + +class ForumFormDiscussionUnicodeTestCase( + SharedModuleStoreTestCase, + UnicodeTestMixin, + ForumViewsUtilsMixin, +): + """ + Discussiin Unicode Tests. + """ + + @classmethod + def setUpClass(cls): # pylint: disable=super-method-not-called + super().setUpClassAndForumMock() + + with super().setUpClassAndTestData(): + cls.course = CourseFactory.create() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + cls.student = UserFactory.create() + CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) + + def _test_unicode_data( + self, text + ): # lint-amnesty, pylint: disable=missing-function-docstring + self._configure_mock_responses(course=self.course, text=text) + request = RequestFactory().get("dummy_url") + request.user = self.student + # so (request.headers.get('x-requested-with') == 'XMLHttpRequest') == True + request.META["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest" + + response = views.forum_form_discussion(request, str(self.course.id)) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data["discussion_data"][0]["title"] == text + assert response_data["discussion_data"][0]["body"] == text + + +class EnterpriseConsentTestCase( + EnterpriseTestConsentRequired, + UrlResetMixin, + ModuleStoreTestCase, + ForumViewsUtilsMixin, +): + """ + Ensure that the Enterprise Data Consent redirects are in place only when consent is required. + """ + + CREATE_USER = False + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + # Invoke UrlResetMixin setUp + super().setUp() + username = "foo" + password = "bar" + + self.discussion_id = "dummy_discussion_id" + self.course = CourseFactory.create( + discussion_topics={"dummy discussion": {"id": self.discussion_id}} + ) + self.student = UserFactory.create(username=username, password=password) + CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) + assert self.client.login(username=username, password=password) + + self.addCleanup(translation.deactivate) + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + @patch("openedx.features.enterprise_support.api.enterprise_customer_for_request") + def test_consent_required(self, mock_enterprise_customer_for_request): + """ + Test that enterprise data sharing consent is required when enabled for the various discussion views. + """ + # ENT-924: Temporary solution to replace sensitive SSO usernames. + mock_enterprise_customer_for_request.return_value = None + + thread_id = "dummy" + course_id = str(self.course.id) + self._configure_mock_responses( + course=self.course, text="dummy", thread_id=thread_id + ) + + for url in ( + reverse("forum_form_discussion", kwargs=dict(course_id=course_id)), + reverse( + "single_thread", + kwargs=dict( + course_id=course_id, + discussion_id=self.discussion_id, + thread_id=thread_id, + ), + ), + ): + self.verify_consent_required( # pylint: disable=no-value-for-parameter + self.client, url + ) + + +class InlineDiscussionGroupIdTestCase( # lint-amnesty, pylint: disable=missing-class-docstring + CohortedTestCase, + CohortedTopicGroupIdTestMixinV2, + NonCohortedTopicGroupIdTestMixinV2, + ForumViewsUtilsMixin, +): + function_name = "get_user_threads" + + def setUp(self): + super().setUp() + self.cohorted_commentable_id = "cohorted_topic" + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def call_view( + self, commentable_id, user, group_id, pass_group_id=True + ): # pylint: disable=arguments-differ + kwargs = {"commentable_id": self.cohorted_commentable_id} + if group_id: + # avoid causing a server error when the LMS chokes attempting + # to find a group name for the group_id, when we're testing with + # an invalid one. + try: + CourseUserGroup.objects.get(id=group_id) + kwargs["group_id"] = group_id + except CourseUserGroup.DoesNotExist: + pass + self._configure_mock_responses(self.course, "dummy content", **kwargs) + + request_data = {} + if pass_group_id: + request_data["group_id"] = group_id + request = RequestFactory().get("dummy_url", data=request_data) + request.user = user + return views.inline_discussion(request, str(self.course.id), commentable_id) + + def test_group_info_in_ajax_response(self): + response = self.call_view( + self.cohorted_commentable_id, self.student, self.student_cohort.id + ) + self._assert_json_response_contains_group_info( + response, lambda d: d["discussion_data"][0] + ) + + +class InlineDiscussionContextTestCase( + ModuleStoreTestCase, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + + def setUp(self): + super().setUp() + self.course = CourseFactory.create() + CourseEnrollmentFactory(user=self.user, course_id=self.course.id) + self.discussion_topic_id = "dummy_topic" + self.team = CourseTeamFactory( + name="A team", + course_id=self.course.id, + topic_id="topic_id", + discussion_topic_id=self.discussion_topic_id, + ) + + self.team.add_user(self.user) + self.user_not_in_team = UserFactory.create() + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def test_context_can_be_standalone(self): + self._configure_mock_responses( + course=self.course, + text="dummy text", + commentable_id=self.discussion_topic_id, + ) + + request = RequestFactory().get("dummy_url") + request.user = self.user + + response = views.inline_discussion( + request, + str(self.course.id), + self.discussion_topic_id, + ) + + json_response = json.loads(response.content.decode("utf-8")) + assert ( + json_response["discussion_data"][0]["context"] == ThreadContext.STANDALONE + ) + + def test_private_team_discussion(self): + # First set the team discussion to be private + CourseEnrollmentFactory(user=self.user_not_in_team, course_id=self.course.id) + request = RequestFactory().get("dummy_url") + request.user = self.user_not_in_team + + self._configure_mock_responses( + course=self.course, + text="dummy text", + commentable_id=self.discussion_topic_id, + ) + + with patch( + "lms.djangoapps.teams.api.is_team_discussion_private", autospec=True + ) as mocked: + mocked.return_value = True + response = views.inline_discussion( + request, + str(self.course.id), + self.discussion_topic_id, + ) + assert response.status_code == 403 + assert response.content.decode("utf-8") == views.TEAM_PERMISSION_MESSAGE + + +class UserProfileDiscussionGroupIdTestCase( + CohortedTestCase, CohortedTopicGroupIdTestMixinV2, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + function_name = "get_user_active_threads" + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def call_view_for_profiled_user( + self, requesting_user, profiled_user, group_id, pass_group_id, is_ajax=False + ): + """ + Calls "user_profile" view method on behalf of "requesting_user" to get information about + the user "profiled_user". + """ + kwargs = {} + if group_id: + kwargs["group_id"] = group_id + self._configure_mock_responses(self.course, "dummy content", **kwargs) + + request_data = {} + if pass_group_id: + request_data["group_id"] = group_id + headers = {} + if is_ajax: + headers["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest" + + self.client.login( + username=requesting_user.username, password=self.TEST_PASSWORD + ) + return self.client.get( + reverse("user_profile", args=[str(self.course.id), profiled_user.id]), + data=request_data, + **headers, + ) + + def call_view( + self, _commentable_id, user, group_id, pass_group_id=True, is_ajax=False + ): # pylint: disable=arguments-differ + return self.call_view_for_profiled_user( + user, user, group_id, pass_group_id=pass_group_id, is_ajax=is_ajax + ) + + def test_group_info_in_html_response(self): + response = self.call_view( + "cohorted_topic", self.student, self.student_cohort.id, is_ajax=False + ) + self._assert_html_response_contains_group_info(response) + + def test_group_info_in_ajax_response(self): + response = self.call_view( + "cohorted_topic", self.student, self.student_cohort.id, is_ajax=True + ) + self._assert_json_response_contains_group_info( + response, lambda d: d["discussion_data"][0] + ) + + def _test_group_id_passed_to_user_profile( + self, + expect_group_id_in_request, + requesting_user, + profiled_user, + group_id, + pass_group_id, + ): + """ + Helper method for testing whether or not group_id was passed to the user_profile request. + """ + + def get_params_from_user_info_call(for_specific_course): + """ + Returns the request parameters for the user info call with either course_id specified or not, + depending on value of 'for_specific_course'. + """ + # There will be 3 calls from user_profile. One has the cs_endpoint "active_threads", and it is already + # tested. The other 2 calls are for user info; one of those calls is for general information about the user, + # and it does not specify a course_id. The other call does specify a course_id, and if the caller did not + # have discussion moderator privileges, it should also contain a group_id. + user_func_calls = self.get_mock_func_calls("get_user") + for r_call in user_func_calls: + has_course_id = "course_id" in r_call[1] + if (for_specific_course and has_course_id) or ( + not for_specific_course and not has_course_id + ): + return r_call[1] + pytest.fail( + f"Did not find appropriate user_profile call for 'for_specific_course'={for_specific_course}" + ) + + self.call_view_for_profiled_user( + requesting_user, + profiled_user, + group_id, + pass_group_id=pass_group_id, + is_ajax=False, + ) + # Should never have a group_id if course_id was not included in the request. + params_without_course_id = get_params_from_user_info_call(False) + assert "group_ids" not in params_without_course_id + + params_with_course_id = get_params_from_user_info_call(True) + if expect_group_id_in_request: + assert "group_ids" in params_with_course_id + assert [group_id] == params_with_course_id["group_ids"] + else: + assert "group_ids" not in params_with_course_id + + def test_group_id_passed_to_user_profile_student(self): + """ + Test that the group id is always included when requesting user profile information for a particular + course if the requester does not have discussion moderation privileges. + """ + + def verify_group_id_always_present(profiled_user, pass_group_id): + """ + Helper method to verify that group_id is always present for student in course + (non-privileged user). + """ + self._test_group_id_passed_to_user_profile( + True, self.student, profiled_user, self.student_cohort.id, pass_group_id + ) + + # In all these test cases, the requesting_user is the student (non-privileged user). + # The profile returned on behalf of the student is for the profiled_user. + verify_group_id_always_present(profiled_user=self.student, pass_group_id=True) + verify_group_id_always_present(profiled_user=self.student, pass_group_id=False) + verify_group_id_always_present(profiled_user=self.moderator, pass_group_id=True) + verify_group_id_always_present( + profiled_user=self.moderator, pass_group_id=False + ) + + def test_group_id_user_profile_moderator(self): + """ + Test that the group id is only included when a privileged user requests user profile information for a + particular course and user if the group_id is explicitly passed in. + """ + + def verify_group_id_present( + profiled_user, pass_group_id, requested_cohort=self.moderator_cohort + ): + """ + Helper method to verify that group_id is present. + """ + self._test_group_id_passed_to_user_profile( + True, self.moderator, profiled_user, requested_cohort.id, pass_group_id + ) + + def verify_group_id_not_present( + profiled_user, pass_group_id, requested_cohort=self.moderator_cohort + ): + """ + Helper method to verify that group_id is not present. + """ + self._test_group_id_passed_to_user_profile( + False, self.moderator, profiled_user, requested_cohort.id, pass_group_id + ) + + # In all these test cases, the requesting_user is the moderator (privileged user). + + # If the group_id is explicitly passed, it will be present in the request. + verify_group_id_present(profiled_user=self.student, pass_group_id=True) + verify_group_id_present(profiled_user=self.moderator, pass_group_id=True) + verify_group_id_present( + profiled_user=self.student, + pass_group_id=True, + requested_cohort=self.student_cohort, + ) + + # If the group_id is not explicitly passed, it will not be present because the requesting_user + # has discussion moderator privileges. + verify_group_id_not_present(profiled_user=self.student, pass_group_id=False) + verify_group_id_not_present(profiled_user=self.moderator, pass_group_id=False) + + +@ddt.ddt +class ForumDiscussionXSSTestCase( + UrlResetMixin, ModuleStoreTestCase, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + super().setUp() + + username = "foo" + password = "bar" + + self.course = CourseFactory.create() + self.student = UserFactory.create(username=username, password=password) + CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) + assert self.client.login(username=username, password=password) + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + @ddt.data( + '">', + "", + "", + ) + @patch("common.djangoapps.student.models.user.cc.User.from_django_user") + def test_forum_discussion_xss_prevent(self, malicious_code, mock_user): + """ + Test that XSS attack is prevented + """ + self.set_mock_return_value("get_user", {}) + self.set_mock_return_value("get_user_threads", {}) + self.set_mock_return_value("get_user_active_threads", {}) + mock_user.return_value.to_dict.return_value = {} + reverse_url = "{}{}".format( + reverse("forum_form_discussion", kwargs={"course_id": str(self.course.id)}), + "/forum_form_discussion", + ) + # Test that malicious code does not appear in html + url = "{}?{}={}".format(reverse_url, "sort_key", malicious_code) + resp = self.client.get(url) + self.assertNotContains(resp, malicious_code) + + @ddt.data( + '">', + "", + "", + ) + @patch("common.djangoapps.student.models.user.cc.User.from_django_user") + @patch("common.djangoapps.student.models.user.cc.User.active_threads") + def test_forum_user_profile_xss_prevent( + self, malicious_code, mock_threads, mock_from_django_user + ): + """ + Test that XSS attack is prevented + """ + mock_threads.return_value = [], 1, 1 + mock_from_django_user.return_value.to_dict.return_value = { + "upvoted_ids": [], + "downvoted_ids": [], + "subscribed_thread_ids": [], + } + self._configure_mock_responses(course=self.course, text="dummy") + + url = reverse( + "user_profile", + kwargs={"course_id": str(self.course.id), "user_id": str(self.student.id)}, + ) + # Test that malicious code does not appear in html + url_string = "{}?{}={}".format(url, "page", malicious_code) + resp = self.client.get(url_string) + self.assertNotContains(resp, malicious_code) + + +class InlineDiscussionTestCase( + ModuleStoreTestCase, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + + def setUp(self): + super().setUp() + + self.course = CourseFactory.create( + org="TestX", + number="101", + display_name="Test Course", + teams_configuration=TeamsConfig( + { + "topics": [ + { + "id": "topic_id", + "name": "A topic", + "description": "A topic", + } + ] + } + ), + ) + self.student = UserFactory.create() + CourseEnrollmentFactory(user=self.student, course_id=self.course.id) + self.discussion1 = BlockFactory.create( + parent_location=self.course.location, + category="discussion", + discussion_id="discussion1", + display_name="Discussion1", + discussion_category="Chapter", + discussion_target="Discussion1", + ) + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def send_request(self, params=None): + """ + Creates and returns a request with params set, and configures + mock_request to return appropriate values. + """ + request = RequestFactory().get("dummy_url", params if params else {}) + request.user = self.student + self._configure_mock_responses( + course=self.course, + text="dummy content", + commentable_id=self.discussion1.discussion_id, + ) + return views.inline_discussion( + request, str(self.course.id), self.discussion1.discussion_id + ) + + def test_context(self): + team = CourseTeamFactory( + name="Team Name", + topic_id="topic_id", + course_id=self.course.id, + discussion_topic_id=self.discussion1.discussion_id, + ) + + team.add_user(self.student) + + self.send_request() + last_call = self.get_mock_func_calls("get_user_threads")[-1][1] + assert last_call["context"] == ThreadContext.STANDALONE + + +class ForumDiscussionSearchUnicodeTestCase( + SharedModuleStoreTestCase, UnicodeTestMixin, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + + @classmethod + def setUpClass(cls): # pylint: disable=super-method-not-called + super().setUpClassAndForumMock() + with super().setUpClassAndTestData(): + cls.course = CourseFactory.create() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + cls.student = UserFactory.create() + CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) + + def _test_unicode_data( + self, text + ): # lint-amnesty, pylint: disable=missing-function-docstring + self._configure_mock_responses(course=self.course, text=text) + data = { + "ajax": 1, + "text": text, + } + request = RequestFactory().get("dummy_url", data) + request.user = self.student + # so (request.headers.get('x-requested-with') == 'XMLHttpRequest') == True + request.META["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest" + + response = views.forum_form_discussion(request, str(self.course.id)) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data["discussion_data"][0]["title"] == text + assert response_data["discussion_data"][0]["body"] == text + + +class InlineDiscussionUnicodeTestCase( + SharedModuleStoreTestCase, UnicodeTestMixin, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + + @classmethod + def setUpClass(cls): # pylint: disable=super-method-not-called + super().setUpClassAndForumMock() + + with super().setUpClassAndTestData(): + cls.course = CourseFactory.create() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + cls.student = UserFactory.create() + CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) + + def _test_unicode_data( + self, text + ): # lint-amnesty, pylint: disable=missing-function-docstring + self._configure_mock_responses(course=self.course, text=text) + request = RequestFactory().get("dummy_url") + request.user = self.student + + response = views.inline_discussion( + request, str(self.course.id), self.course.discussion_topics["General"]["id"] + ) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data["discussion_data"][0]["title"] == text + assert response_data["discussion_data"][0]["body"] == text + + +class ForumFormDiscussionGroupIdTestCase( + CohortedTestCase, CohortedTopicGroupIdTestMixinV2, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + function_name = "get_user_threads" + + def call_view( + self, commentable_id, user, group_id, pass_group_id=True, is_ajax=False + ): # pylint: disable=arguments-differ + kwargs = {} + if group_id: + kwargs["group_id"] = group_id + self._configure_mock_responses(self.course, "dummy content", **kwargs) + + request_data = {} + if pass_group_id: + request_data["group_id"] = group_id + headers = {} + if is_ajax: + headers["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest" + + self.client.login(username=user.username, password=self.TEST_PASSWORD) + return self.client.get( + reverse("forum_form_discussion", args=[str(self.course.id)]), + data=request_data, + **headers, + ) + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def test_group_info_in_html_response(self): + response = self.call_view( + "cohorted_topic", self.student, self.student_cohort.id + ) + self._assert_html_response_contains_group_info(response) + + def test_group_info_in_ajax_response(self): + response = self.call_view( + "cohorted_topic", self.student, self.student_cohort.id, is_ajax=True + ) + self._assert_json_response_contains_group_info( + response, lambda d: d["discussion_data"][0] + ) + + +class UserProfileTestCase( + UrlResetMixin, ModuleStoreTestCase, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + + TEST_THREAD_TEXT = "userprofile-test-text" + TEST_THREAD_ID = "userprofile-test-thread-id" + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + super().setUp() + + self.course = CourseFactory.create() + self.student = UserFactory.create() + self.profiled_user = UserFactory.create() + CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) + CourseEnrollmentFactory.create( + user=self.profiled_user, course_id=self.course.id + ) + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def get_response( + self, params, **headers + ): # lint-amnesty, pylint: disable=missing-function-docstring + self._configure_mock_responses( + course=self.course, + text=self.TEST_THREAD_TEXT, + thread_id=self.TEST_THREAD_ID, + ) + self.client.login(username=self.student.username, password=self.TEST_PASSWORD) + + response = self.client.get( + reverse( + "user_profile", + kwargs={ + "course_id": str(self.course.id), + "user_id": self.profiled_user.id, + }, + ), + data=params, + **headers, + ) + params = { + "course_id": str(self.course.id), + "page": params.get("page", 1), + "per_page": views.THREADS_PER_PAGE, + } + self.check_mock_called_with("get_user_active_threads", -1, **params) + return response + + def check_html( + self, **params + ): # lint-amnesty, pylint: disable=missing-function-docstring + response = self.get_response(params) + assert response.status_code == 200 + assert response["Content-Type"] == "text/html; charset=utf-8" + html = response.content.decode("utf-8") + self.assertRegex(html, r'data-page="1"') + self.assertRegex(html, r'data-num-pages="1"') + self.assertRegex( + html, r'1 discussion started' + ) + self.assertRegex(html, r'2 comments') + self.assertRegex(html, f"'id': '{self.TEST_THREAD_ID}'") + self.assertRegex(html, f"'title': '{self.TEST_THREAD_TEXT}'") + self.assertRegex(html, f"'body': '{self.TEST_THREAD_TEXT}'") + self.assertRegex(html, f"'username': '{self.student.username}'") + + def check_ajax( + self, **params + ): # lint-amnesty, pylint: disable=missing-function-docstring + response = self.get_response(params, HTTP_X_REQUESTED_WITH="XMLHttpRequest") + assert response.status_code == 200 + assert response["Content-Type"] == "application/json; charset=utf-8" + response_data = json.loads(response.content.decode("utf-8")) + assert sorted(response_data.keys()) == [ + "annotated_content_info", + "discussion_data", + "num_pages", + "page", + ] + assert len(response_data["discussion_data"]) == 1 + assert response_data["page"] == 1 + assert response_data["num_pages"] == 1 + assert response_data["discussion_data"][0]["id"] == self.TEST_THREAD_ID + assert response_data["discussion_data"][0]["title"] == self.TEST_THREAD_TEXT + assert response_data["discussion_data"][0]["body"] == self.TEST_THREAD_TEXT + + def test_html(self): + self.check_html() + + def test_ajax(self): + self.check_ajax() + + def test_404_non_enrolled_user(self): + """ + Test that when student try to visit un-enrolled students' discussion profile, + the system raises Http404. + """ + unenrolled_user = UserFactory.create() + request = RequestFactory().get("dummy_url") + request.user = self.student + with pytest.raises(Http404): + views.user_profile(request, str(self.course.id), unenrolled_user.id) + + def test_404_profiled_user(self): + request = RequestFactory().get("dummy_url") + request.user = self.student + with pytest.raises(Http404): + views.user_profile(request, str(self.course.id), -999) + + def test_404_course(self): + request = RequestFactory().get("dummy_url") + request.user = self.student + with pytest.raises(Http404): + views.user_profile(request, "non/existent/course", self.profiled_user.id) + + def test_post(self): + self._configure_mock_responses( + course=self.course, + text=self.TEST_THREAD_TEXT, + thread_id=self.TEST_THREAD_ID, + ) + request = RequestFactory().post("dummy_url") + request.user = self.student + response = views.user_profile( + request, str(self.course.id), self.profiled_user.id + ) + assert response.status_code == 405 + + +class ThreadViewedEventTestCase( + EventTestMixin, UrlResetMixin, ModuleStoreTestCase, ForumViewsUtilsMixin, +): + """ + Forum thread views are expected to launch analytics events. Test these here. + """ + + CATEGORY_ID = 'i4x-edx-discussion-id' + CATEGORY_NAME = 'Discussion 1' + PARENT_CATEGORY_NAME = 'Chapter 1' + + DUMMY_THREAD_ID = 'dummythreadids' + DUMMY_TITLE = 'Dummy title' + DUMMY_URL = 'https://example.com/dummy/url/' + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): # pylint: disable=arguments-differ + super().setUp('lms.djangoapps.discussion.django_comment_client.base.views.tracker') + self.course = CourseFactory.create( + teams_configuration=TeamsConfig({ + 'topics': [{ + 'id': 'arbitrary-topic-id', + 'name': 'arbitrary-topic-name', + 'description': 'arbitrary-topic-desc' + }] + }) + ) + seed_permissions_roles(self.course.id) + + PASSWORD = 'test' + self.student = UserFactory.create(password=PASSWORD) + CourseEnrollmentFactory(user=self.student, course_id=self.course.id) + + self.staff = UserFactory.create(is_staff=True) + UserBasedRole(user=self.staff, role=CourseStaffRole.ROLE).add_course(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 = CourseTeamFactory.create( + name='Team 1', + course_id=self.course.id, + topic_id='arbitrary-topic-id', + discussion_topic_id=self.category.discussion_id, + ) + CourseTeamMembershipFactory.create(team=self.team, user=self.student) + self.client.login(username=self.student.username, password=PASSWORD) + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def test_thread_viewed_event(self): + self._configure_mock_responses( + course=self.course, + text=self.DUMMY_TITLE, + thread_id=self.DUMMY_THREAD_ID, + commentable_id=self.category.discussion_id, + ) + url = '/courses/{}/discussion/forum/{}/threads/{}'.format( + str(self.course.id), + self.category.discussion_id, + self.DUMMY_THREAD_ID + ) + self.client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + expected_event = { + 'id': self.DUMMY_THREAD_ID, + 'title': self.DUMMY_TITLE, + 'commentable_id': self.category.discussion_id, + 'category_id': self.category.discussion_id, + 'category_name': self.category.discussion_target, + 'user_forums_roles': [FORUM_ROLE_STUDENT], + 'user_course_roles': [], + 'target_username': self.student.username, + 'team_id': self.team.id, + 'url': self.DUMMY_URL, + } + expected_event_items = list(expected_event.items()) + + self.assert_event_emission_count('edx.forum.thread.viewed', 1) + _, event = self.get_latest_call_args() + event_items = list(event.items()) + assert ((kv_pair in event_items) for kv_pair in expected_event_items) + + +class FollowedThreadsUnicodeTestCase( + 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, + thread_id="dummy_thread_id", + commentable_id="dummy_commentable_id", + ) + # 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.followed_threads(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 UserProfileUnicodeTestCase(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(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/lms/djangoapps/discussion/tests/utils.py b/lms/djangoapps/discussion/tests/utils.py new file mode 100644 index 000000000000..822034fb39a1 --- /dev/null +++ b/lms/djangoapps/discussion/tests/utils.py @@ -0,0 +1,70 @@ +""" +Utils for the discussion app. +""" + + +def make_minimal_cs_thread(overrides=None): + """ + Create a dictionary containing all needed thread fields as returned by the + comments service with dummy data and optional overrides + """ + ret = { + "type": "thread", + "id": "dummy", + "course_id": "course-v1:dummy+dummy+dummy", + "commentable_id": "dummy", + "group_id": None, + "user_id": "0", + "username": "dummy", + "anonymous": False, + "anonymous_to_peers": False, + "created_at": "1970-01-01T00:00:00Z", + "updated_at": "1970-01-01T00:00:00Z", + "last_activity_at": "1970-01-01T00:00:00Z", + "thread_type": "discussion", + "title": "dummy", + "body": "dummy", + "pinned": False, + "closed": False, + "abuse_flaggers": [], + "abuse_flagged_count": None, + "votes": {"up_count": 0}, + "comments_count": 0, + "unread_comments_count": 0, + "children": [], + "read": False, + "endorsed": False, + "resp_total": 0, + "closed_by": None, + "close_reason_code": None, + } + ret.update(overrides or {}) + return ret + + +def make_minimal_cs_comment(overrides=None): + """ + Create a dictionary containing all needed comment fields as returned by the + comments service with dummy data and optional overrides + """ + ret = { + "type": "comment", + "id": "dummy", + "commentable_id": "dummy", + "thread_id": "dummy", + "parent_id": None, + "user_id": "0", + "username": "dummy", + "anonymous": False, + "anonymous_to_peers": False, + "created_at": "1970-01-01T00:00:00Z", + "updated_at": "1970-01-01T00:00:00Z", + "body": "dummy", + "abuse_flaggers": [], + "votes": {"up_count": 0}, + "endorsed": False, + "child_count": 0, + "children": [], + } + ret.update(overrides or {}) + return ret diff --git a/lms/urls.py b/lms/urls.py index 1ae425c9a9be..d08fdb285dda 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -41,7 +41,6 @@ from openedx.core.djangoapps.cors_csrf import views as cors_csrf_views from openedx.core.djangoapps.course_groups import views as course_groups_views from openedx.core.djangoapps.debug import views as openedx_debug_views -from openedx.core.djangoapps.django_comment_common.models import ForumsConfig from openedx.core.djangoapps.lang_pref import views as lang_pref_views from openedx.core.djangoapps.password_policy import compliance as password_policy_compliance from openedx.core.djangoapps.password_policy.forms import PasswordPolicyAwareAdminAuthForm @@ -908,7 +907,6 @@ urlpatterns += [ path('config/programs', ConfigurationModelCurrentAPIView.as_view(model=ProgramsApiConfig)), path('config/catalog', ConfigurationModelCurrentAPIView.as_view(model=CatalogIntegration)), - path('config/forums', ConfigurationModelCurrentAPIView.as_view(model=ForumsConfig)), ] if settings.DEBUG: diff --git a/openedx/core/djangoapps/discussions/README.rst b/openedx/core/djangoapps/discussions/README.rst index 5c1127d324cc..d8a937a07ecc 100644 --- a/openedx/core/djangoapps/discussions/README.rst +++ b/openedx/core/djangoapps/discussions/README.rst @@ -3,7 +3,7 @@ Discussions This Discussions app is responsible for providing support for configuring discussion tools in the Open edX platform. This includes the in-built forum -tool that uses the `cs_comments_service`, but also other LTI-based tools. +tool, but also other LTI-based tools. Technical Overview @@ -44,10 +44,9 @@ discussion configuration information such as the course key, the provider type, whether in-context discussions are enabled, whether graded units are enabled, when unit level visibility is enabled. Other plugin configuration and a list of discussion contexts for which discussions are enabled. Each discussion -context has a usage key, a title (the units name) an external id -(the cs_comments_service id), it's ordering in the course, and additional -context. It then sends its own signal that has the discussion configuration -object attached. +context has a usage key, a title (the units name) an external id, +its ordering in the course, and additional context. It then sends its own +signal that has the discussion configuration object attached. Finally, the handler for this discussion change signal, takes the information from the discussion change signal and compares it to the topics in the diff --git a/openedx/core/djangoapps/discussions/config/waffle.py b/openedx/core/djangoapps/discussions/config/waffle.py index eca6fc970856..1d4c67e9e17b 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_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 5f6348547efb..2d1c53f62b4a 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/comment.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/comment.py @@ -1,12 +1,18 @@ # pylint: disable=missing-docstring,protected-access +import logging +import time + from bs4 import BeautifulSoup from openedx.core.djangoapps.django_comment_common.comment_client import models, settings -from .thread import Thread, _url_for_flag_abuse_thread, _url_for_unflag_abuse_thread -from .utils import CommentClientRequestError, get_course_key, perform_request +from .thread import Thread +from .utils import CommentClientRequestError, get_course_key from forum import api as forum_api -from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled +from forum.backends.mongodb.comments import Comment as ForumComment + + +log = logging.getLogger(__name__) class Comment(models.Model): @@ -64,71 +70,30 @@ def url(cls, action, params=None): return super().url(action, params) def flagAbuse(self, user, voteable, course_id=None): - if voteable.type == 'thread': - url = _url_for_flag_abuse_thread(voteable.id) - elif voteable.type == 'comment': - url = _url_for_flag_abuse_comment(voteable.id) - else: - raise CommentClientRequestError("Can only flag/unflag threads or comments") + if voteable.type != 'comment': + raise CommentClientRequestError("Can only flag comments") + course_key = get_course_key(self.attributes.get("course_id") or course_id) - if is_forum_v2_enabled(course_key): - if voteable.type == 'thread': - response = forum_api.update_thread_flag( - voteable.id, "flag", user_id=user.id, course_id=str(course_key) - ) - else: - response = forum_api.update_comment_flag( - voteable.id, "flag", user_id=user.id, course_id=str(course_key) - ) - else: - params = {'user_id': user.id} - response = perform_request( - 'put', - url, - params, - metric_tags=self._metric_tags, - metric_action='comment.abuse.flagged' - ) + response = forum_api.update_comment_flag( + comment_id=voteable.id, + action="flag", + user_id=str(user.id), + course_id=str(course_key), + ) voteable._update_from_response(response) def unFlagAbuse(self, user, voteable, removeAll, course_id=None): - if voteable.type == 'thread': - url = _url_for_unflag_abuse_thread(voteable.id) - elif voteable.type == 'comment': - url = _url_for_unflag_abuse_comment(voteable.id) - else: - raise CommentClientRequestError("Can flag/unflag for threads or comments") + if voteable.type != 'comment': + raise CommentClientRequestError("Can only unflag comments") + course_key = get_course_key(self.attributes.get("course_id") or course_id) - if is_forum_v2_enabled(course_key): - if voteable.type == "thread": - response = forum_api.update_thread_flag( - thread_id=voteable.id, - action="unflag", - user_id=user.id, - update_all=bool(removeAll), - course_id=str(course_key) - ) - else: - response = forum_api.update_comment_flag( - comment_id=voteable.id, - action="unflag", - user_id=user.id, - update_all=bool(removeAll), - course_id=str(course_key) - ) - else: - params = {'user_id': user.id} - - if removeAll: - params['all'] = True - - response = perform_request( - 'put', - url, - params, - metric_tags=self._metric_tags, - metric_action='comment.abuse.unflagged' - ) + response = forum_api.update_comment_flag( + comment_id=voteable.id, + action="unflag", + user_id=str(user.id), + update_all=bool(removeAll), + course_id=str(course_key), + ) voteable._update_from_response(response) @property @@ -139,6 +104,70 @@ def body_text(self): 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): + """ + Returns comments and responses count of user in the given course_ids. + TODO: Add support for MySQL backend as well + """ + query_params = { + "course_id": {"$in": course_ids}, + "author_id": str(user_id), + "_type": "Comment" + } + return ForumComment()._collection.count_documents(query_params) # pylint: disable=protected-access + + @classmethod + def delete_user_comments(cls, user_id, course_ids): + """ + Deletes comments and responses of user in the given course_ids. + TODO: Add support for MySQL backend as well + """ + start_time = time.time() + query_params = { + "course_id": {"$in": course_ids}, + "author_id": str(user_id), + } + comments_deleted = 0 + comments = ForumComment().get_list(**query_params) + log.info(f"<> Fetched comments for user {user_id} in {time.time() - start_time} seconds") + for comment in comments: + start_time = time.time() + comment_id = comment.get("_id") + course_id = comment.get("course_id") + if comment_id: + forum_api.delete_comment(comment_id, course_id=course_id) + comments_deleted += 1 + log.info(f"<> Deleted comment {comment_id} in {time.time() - start_time} seconds." + f" Comment Found: {comment_id is not None}") + return comments_deleted + def _url_for_thread_comments(thread_id): return f"{settings.PREFIX}/threads/{thread_id}/comments" @@ -146,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 8cbb580e7831..1dad0ca159aa 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 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 1812d24dec0a..e788ac5e9ea0 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 .utils import CommentClientRequestError, extract, 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__) @@ -73,26 +71,13 @@ def retrieve(self, *args, **kwargs): def _retrieve(self, *args, **kwargs): course_id = self.attributes.get("course_id") or kwargs.get("course_key") - if course_id: - course_key = get_course_key(course_id) - use_forumv2 = is_forum_v2_enabled(course_key) - else: - use_forumv2, course_id = is_forum_v2_enabled_for_comment(self.id) + if not course_id: + course_id = forum_api.get_course_id_by_comment(self.id) response = None - if use_forumv2: - if self.type == "comment": - response = forum_api.get_parent_comment(comment_id=self.attributes["id"], course_id=course_id) - if response is None: - raise CommentClientRequestError("Forum v2 API call is missing") - else: - url = self.url(action='get', params=self.attributes) - response = perform_request( - 'get', - url, - self.default_retrieve_params, - metric_tags=self._metric_tags, - metric_action='model.retrieve' - ) + if self.type == "comment": + response = forum_api.get_parent_comment(comment_id=self.attributes["id"], course_id=course_id) + if response is None: + raise CommentClientRequestError("Forum v2 API call is missing") self._update_from_response(response) @property @@ -116,25 +101,6 @@ def _metric_tags(self): def find(cls, id): # pylint: disable=redefined-builtin return cls(id=id) - @classmethod - def retrieve_all(cls, params=None): - """ - Performs a GET request against the resource's listing endpoint. - - Arguments: - params: A dictionary of parameters to be passed as the request's query string. - - Returns: - The parsed JSON response from the backend. - """ - return perform_request( - 'get', - cls.url(action='get_all'), - params, - metric_tags=[f'model_class:{cls.__name__}'], - metric_action='model.retrieve_all', - ) - def _update_from_response(self, response_data): for k, v in response_data.items(): if k in self.accessible_fields: @@ -170,24 +136,19 @@ def save(self, params=None): response = self.handle_update(params) else: # otherwise, treat this as an insert response = self.handle_create(params) - self.retrieved = True self._update_from_response(response) self.after_save(self) def delete(self, course_id=None): course_key = get_course_key(self.attributes.get("course_id") or course_id) - if is_forum_v2_enabled(course_key): - response = None - if self.type == "comment": - response = forum_api.delete_comment(comment_id=self.attributes["id"], course_id=str(course_key)) - elif self.type == "thread": - response = forum_api.delete_thread(thread_id=self.attributes["id"], course_id=str(course_key)) - if response is None: - raise CommentClientRequestError("Forum v2 API call is missing") - else: - url = self.url(action='delete', params=self.attributes) - response = perform_request('delete', url, metric_tags=self._metric_tags, metric_action='model.delete') + response = None + if self.type == "comment": + response = forum_api.delete_comment(comment_id=self.attributes["id"], course_id=str(course_key)) + elif self.type == "thread": + response = forum_api.delete_thread(thread_id=self.attributes["id"], course_id=str(course_key)) + if response is None: + raise CommentClientRequestError("Forum v2 API call is missing") self.retrieved = True self._update_from_response(response) @@ -225,18 +186,15 @@ def handle_update(self, params=None): 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): @@ -256,7 +214,7 @@ def handle_update_comment(self, request_params, course_id): request_data = { "comment_id": self.attributes["id"], "body": request_params.get("body"), - "course_id": request_params.get("course_id"), + "course_id": request_params.get("course_id") or course_id, "user_id": request_params.get("user_id"), "anonymous": request_params.get("anonymous"), "anonymous_to_peers": request_params.get("anonymous_to_peers"), @@ -265,7 +223,6 @@ def handle_update_comment(self, request_params, course_id): "editing_user_id": request_params.get("editing_user_id"), "edit_reason_code": request_params.get("edit_reason_code"), "endorsement_user_id": request_params.get("endorsement_user_id"), - "course_key": course_id } request_data = {k: v for k, v in request_data.items() if v is not None} response = forum_api.update_comment(**request_data) @@ -276,7 +233,7 @@ def handle_update_thread(self, request_params, course_id): "thread_id": self.attributes["id"], "title": request_params.get("title"), "body": request_params.get("body"), - "course_id": request_params.get("course_id"), + "course_id": request_params.get("course_id") or course_id, "anonymous": request_params.get("anonymous"), "anonymous_to_peers": request_params.get("anonymous_to_peers"), "closed": request_params.get("closed"), @@ -289,105 +246,60 @@ def handle_update_thread(self, request_params, course_id): "close_reason_code": request_params.get("close_reason_code"), "closing_user_id": request_params.get("closing_user_id"), "endorsed": request_params.get("endorsed"), - "course_key": course_id } request_data = {k: v for k, v in request_data.items() if v is not None} response = forum_api.update_thread(**request_data) return response - def perform_http_put_request(self, request_params): - url = self.url(action="put", params=self.attributes) - response = perform_request( - "put", - url, - request_params, - metric_tags=self._metric_tags, - metric_action="model.update", - ) - return response - - def perform_http_post_request(self): - url = self.url(action="post", params=self.attributes) - response = perform_request( - "post", - url, - self.initializable_attributes(), - metric_tags=self._metric_tags, - metric_action="model.insert", - ) - return response - def handle_create(self, params=None): course_id = self.attributes.get("course_id") or params.get("course_id") - course_key = get_course_key(course_id) - if is_forum_v2_enabled(course_key): - response = None - if self.type == "comment": - response = self.handle_create_comment(str(course_key)) - elif self.type == "thread": - response = self.handle_create_thread(str(course_key)) - if response is None: - raise CommentClientRequestError("Forum v2 API call is missing") - else: - response = self.perform_http_post_request() - return response + course_key = str(get_course_key(course_id)) + handlers = { + "comment": self.handle_create_comment, + "thread": self.handle_create_thread, + } + + try: + return handlers[self.type](course_key) + except KeyError as exc: + raise CommentClientRequestError(f"Unsupported type: {self.type}") from exc def handle_create_comment(self, course_id): request_data = self.initializable_attributes() - body = request_data["body"] - user_id = request_data["user_id"] course_id = course_id or str(request_data["course_id"]) + params = { + "body": request_data["body"], + "user_id": str(request_data["user_id"]), + "course_id": course_id, + "anonymous": request_data.get("anonymous", False), + "anonymous_to_peers": request_data.get("anonymous_to_peers", False), + } + if 'endorsed' in request_data: + params['endorsed'] = request_data['endorsed'] if parent_id := self.attributes.get("parent_id"): - response = forum_api.create_child_comment( - parent_id, - body, - user_id, - course_id, - request_data.get("anonymous", False), - request_data.get("anonymous_to_peers", False), - ) + params["parent_comment_id"] = parent_id + response = forum_api.create_child_comment(**params) else: - response = forum_api.create_parent_comment( - self.attributes["thread_id"], - body, - user_id, - course_id, - request_data.get("anonymous", False), - request_data.get("anonymous_to_peers", False), - ) + params["thread_id"] = self.attributes["thread_id"] + response = forum_api.create_parent_comment(**params) return response def handle_create_thread(self, course_id): request_data = self.initializable_attributes() - response = forum_api.create_thread( - title=request_data["title"], - body=request_data["body"], - course_id=course_id or str(request_data["course_id"]), - user_id=str(request_data["user_id"]), - anonymous=request_data.get("anonymous", False), - anonymous_to_peers=request_data.get("anonymous_to_peers", False), - commentable_id=request_data.get("commentable_id", "course"), - thread_type=request_data.get("thread_type", "discussion"), - group_id=request_data.get("group_id", None), - context=request_data.get("context", None), - ) - return response - - -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 + params = { + "title": request_data["title"], + "body": request_data["body"], + "course_id": course_id or str(request_data["course_id"]), + "user_id": str(request_data["user_id"]), + "anonymous": request_data.get("anonymous", False), + "anonymous_to_peers": request_data.get("anonymous_to_peers", False), + "commentable_id": request_data.get("commentable_id", "course"), + "thread_type": request_data.get("thread_type", "discussion"), + } + if group_id := request_data.get("group_id"): + params["group_id"] = group_id + if context := request_data.get("context"): + params["context"] = context - 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 + response = forum_api.create_thread(**params) + return response diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/subscriptions.py b/openedx/core/djangoapps/django_comment_common/comment_client/subscriptions.py index 2130dfc56be6..34814f64904c 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/subscriptions.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/subscriptions.py @@ -5,7 +5,6 @@ from . import models, settings, utils from forum import api as forum_api -from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled log = logging.getLogger(__name__) @@ -36,22 +35,12 @@ def fetch(cls, thread_id, course_id, query_params): utils.strip_blank(utils.strip_none(query_params)) ) course_key = utils.get_course_key(course_id) - if is_forum_v2_enabled(course_key): - response = forum_api.get_thread_subscriptions( - thread_id=thread_id, - page=params["page"], - per_page=params["per_page"], - course_id=str(course_key) - ) - else: - response = utils.perform_request( - 'get', - cls.url(action='get', params=params) + "/subscriptions", - params, - metric_tags=[], - metric_action='subscription.get', - paged_results=True - ) + response = forum_api.get_thread_subscriptions( + thread_id=thread_id, + page=params["page"], + per_page=params["per_page"], + course_id=str(course_key) + ) return utils.SubscriptionsPaginatedResult( collection=response.get('collection', []), page=response.get('page', 1), diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py index e5739515f9b3..ffb9147acab4 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py @@ -2,13 +2,15 @@ import logging -import typing as t +import time from eventtracking import tracker -from . import models, settings, utils from forum import api as forum_api -from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled, is_forum_v2_disabled_globally +from forum.backends.mongodb.threads import CommentThread as ForumThread + +from . import models, settings, utils + log = logging.getLogger(__name__) @@ -56,42 +58,28 @@ def search(cls, query_params): utils.strip_blank(utils.strip_none(query_params)) ) - if query_params.get('text'): - url = cls.url(action='search') - else: - url = cls.url(action='get_all', params=utils.extract(params, 'commentable_id')) - if params.get('commentable_id'): - del params['commentable_id'] - - if is_forum_v2_enabled(utils.get_course_key(query_params['course_id'])): - if query_params.get('text'): - search_params = utils.strip_none(params) - if user_id := search_params.get('user_id'): - search_params['user_id'] = str(user_id) - if group_ids := search_params.get('group_ids'): - search_params['group_ids'] = [int(group_id) for group_id in group_ids.split(',')] - elif group_id := search_params.get('group_id'): - search_params['group_ids'] = [int(group_id)] - search_params.pop('group_id', None) - if commentable_ids := search_params.get('commentable_ids'): - search_params['commentable_ids'] = commentable_ids.split(',') - elif commentable_id := search_params.get('commentable_id'): - search_params['commentable_ids'] = [commentable_id] - search_params.pop('commentable_id', None) - response = forum_api.search_threads(**search_params) - else: - if user_id := params.get('user_id'): - params['user_id'] = str(user_id) - response = forum_api.get_user_threads(**params) + # Convert user_id and author_id to strings if present + for field in ['user_id', 'author_id']: + if value := params.get(field): + params[field] = str(value) + + # Handle commentable_ids/commentable_id conversion + if commentable_ids := params.get('commentable_ids'): + params['commentable_ids'] = commentable_ids.split(',') + elif commentable_id := params.get('commentable_id'): + params['commentable_ids'] = [commentable_id] + params.pop('commentable_id', None) + + params = utils.clean_forum_params(params) + if query_params.get('text'): # Handle group_ids/group_id conversion + if group_ids := params.get('group_ids'): + params['group_ids'] = [int(group_id) for group_id in group_ids.split(',')] + elif group_id := params.get('group_id'): + params['group_ids'] = [int(group_id)] + params.pop('group_id', None) + response = forum_api.search_threads(**params) else: - response = utils.perform_request( - 'get', - url, - params, - metric_tags=['course_id:{}'.format(query_params['course_id'])], - metric_action='thread.search', - paged_results=True - ) + response = forum_api.get_user_threads(**params) if query_params.get('text'): search_query = query_params['text'] @@ -124,7 +112,6 @@ def search(cls, query_params): total_results=total_results ) ) - return utils.CommentClientPaginatedResult( collection=response.get('collection', []), page=response.get('page', 1), @@ -162,7 +149,6 @@ def url(cls, action, params=None): # for the request. Model._retrieve should be modified to handle this such # that subclasses don't need to override for this. def _retrieve(self, *args, **kwargs): - url = self.url(action='get', params=self.attributes) request_params = { 'recursive': kwargs.get('recursive'), 'with_responses': kwargs.get('with_responses', False), @@ -173,156 +159,116 @@ def _retrieve(self, *args, **kwargs): 'reverse_order': kwargs.get('reverse_order', False), 'merge_question_type_responses': kwargs.get('merge_question_type_responses', False) } - request_params = utils.strip_none(request_params) + request_params = utils.clean_forum_params(request_params) course_id = kwargs.get("course_id") - if course_id: - course_key = utils.get_course_key(course_id) - use_forumv2 = is_forum_v2_enabled(course_key) - else: - use_forumv2, course_id = is_forum_v2_enabled_for_thread(self.id) - if use_forumv2: - if user_id := request_params.get('user_id'): - request_params['user_id'] = str(user_id) - response = forum_api.get_thread( - thread_id=self.id, - params=request_params, - course_id=course_id, - ) - else: - response = utils.perform_request( - 'get', - url, - request_params, - metric_action='model.retrieve', - metric_tags=self._metric_tags - ) + if not course_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( + thread_id=self.id, + params=request_params, + course_id=course_id, + ) self._update_from_response(response) def flagAbuse(self, user, voteable, course_id=None): - if voteable.type == 'thread': - url = _url_for_flag_abuse_thread(voteable.id) - else: - raise utils.CommentClientRequestError("Can only flag/unflag threads or comments") + if voteable.type != 'thread': + raise utils.CommentClientRequestError("Can only flag threads") + course_key = utils.get_course_key(self.attributes.get("course_id") or course_id) - if is_forum_v2_enabled(course_key): - response = forum_api.update_thread_flag(voteable.id, "flag", user_id=user.id, course_id=str(course_key)) - else: - params = {'user_id': user.id} - response = utils.perform_request( - 'put', - url, - params, - metric_action='thread.abuse.flagged', - metric_tags=self._metric_tags - ) + response = forum_api.update_thread_flag( + thread_id=voteable.id, + action="flag", + user_id=str(user.id), + course_id=str(course_key) + ) voteable._update_from_response(response) def unFlagAbuse(self, user, voteable, removeAll, course_id=None): - if voteable.type == 'thread': - url = _url_for_unflag_abuse_thread(voteable.id) - else: - raise utils.CommentClientRequestError("Can only flag/unflag for threads or comments") + if voteable.type != 'thread': + raise utils.CommentClientRequestError("Can only unflag threads") + course_key = utils.get_course_key(self.attributes.get("course_id") or course_id) - if is_forum_v2_enabled(course_key): - response = forum_api.update_thread_flag( - thread_id=voteable.id, - action="unflag", - user_id=user.id, - update_all=bool(removeAll), - course_id=str(course_key) - ) - else: - params = {'user_id': user.id} - #if you're an admin, when you unflag, remove ALL flags - if removeAll: - params['all'] = True - - response = utils.perform_request( - 'put', - url, - params, - metric_tags=self._metric_tags, - metric_action='thread.abuse.unflagged' - ) + response = forum_api.update_thread_flag( + thread_id=voteable.id, + action="unflag", + user_id=user.id, + update_all=bool(removeAll), + course_id=str(course_key) + ) + voteable._update_from_response(response) def pin(self, user, thread_id, course_id=None): course_key = utils.get_course_key(self.attributes.get("course_id") or course_id) - if is_forum_v2_enabled(course_key): - response = forum_api.pin_thread( - user_id=user.id, - thread_id=thread_id, - course_id=str(course_key) - ) - else: - url = _url_for_pin_thread(thread_id) - params = {'user_id': user.id} - response = utils.perform_request( - 'put', - url, - params, - metric_tags=self._metric_tags, - metric_action='thread.pin' - ) + response = forum_api.pin_thread( + user_id=user.id, + thread_id=thread_id, + course_id=str(course_key) + ) self._update_from_response(response) def un_pin(self, user, thread_id, course_id=None): course_key = utils.get_course_key(self.attributes.get("course_id") or course_id) - if is_forum_v2_enabled(course_key): - response = forum_api.unpin_thread( - user_id=user.id, - thread_id=thread_id, - course_id=str(course_key) - ) - else: - url = _url_for_un_pin_thread(thread_id) - params = {'user_id': user.id} - response = utils.perform_request( - 'put', - url, - params, - metric_tags=self._metric_tags, - metric_action='thread.unpin' - ) + response = forum_api.unpin_thread( + user_id=user.id, + thread_id=thread_id, + course_id=str(course_key) + ) self._update_from_response(response) + @classmethod + def get_user_threads_count(cls, user_id, course_ids): + """ + Returns threads count of user in the given course_ids. + TODO: Add support for MySQL backend as well + """ + query_params = { + "course_id": {"$in": course_ids}, + "author_id": str(user_id), + "_type": "CommentThread" + } + return ForumThread()._collection.count_documents(query_params) # pylint: disable=protected-access -def _url_for_flag_abuse_thread(thread_id): - return f"{settings.PREFIX}/threads/{thread_id}/abuse_flag" - - -def _url_for_unflag_abuse_thread(thread_id): - return f"{settings.PREFIX}/threads/{thread_id}/abuse_unflag" - - -def _url_for_pin_thread(thread_id): - return f"{settings.PREFIX}/threads/{thread_id}/pin" - - -def _url_for_un_pin_thread(thread_id): - return f"{settings.PREFIX}/threads/{thread_id}/unpin" - - -def is_forum_v2_enabled_for_thread(thread_id: str) -> tuple[bool, t.Optional[str]]: - """ - Figure out whether we use forum v2 for a given thread. - - This is a complex affair... First, we check the value of the DISABLE_FORUM_V2 - setting, which overrides everything. If this setting does not exist, then we need to - find the course ID that corresponds to the thread ID. Then, we return the value of - the course waffle flag for this course ID. - - Note that to fetch the course ID associated to a thread ID, we need to connect both - to mongodb and mysql. As a consequence, when forum v2 needs adequate connection - strings for both backends. - - 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_thread(thread_id) - course_key = utils.get_course_key(course_id) - return is_forum_v2_enabled(course_key), course_id + @classmethod + def delete_user_threads(cls, user_id, course_ids): + """ + Deletes threads of user in the given course_ids. + TODO: Add support for MySQL backend as well + """ + start_time = time.time() + query_params = { + "course_id": {"$in": course_ids}, + "author_id": str(user_id), + } + threads_deleted = 0 + threads = ForumThread().get_list(**query_params) + log.info(f"<> Fetched threads for user {user_id} in {time.time() - start_time} seconds") + for thread in threads: + start_time = time.time() + thread_id = thread.get("_id") + course_id = thread.get("course_id") + if thread_id: + forum_api.delete_thread(thread_id, course_id=course_id) + threads_deleted += 1 + log.info(f"<> Deleted thread {thread_id} in {time.time() - start_time} seconds." + f" Thread Found: {thread_id is not None}") + return threads_deleted + + +def _clean_forum_params(params): + """Convert string booleans to actual booleans and remove None values from forum parameters.""" + result = {} + for k, v in params.items(): + if v is not None: + if isinstance(v, str): + if v.lower() == 'true': + result[k] = True + elif v.lower() == 'false': + result[k] = False + else: + result[k] = v + else: + result[k] = v + return result diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/user.py b/openedx/core/djangoapps/django_comment_common/comment_client/user.py index eaac6b408659..bd208545ce56 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/user.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/user.py @@ -4,7 +4,6 @@ from . import models, settings, utils from forum import api as forum_api from forum.utils import ForumV2RequestError, str_to_bool -from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled class User(models.Model): @@ -34,122 +33,65 @@ def from_django_user(cls, user): def read(self, source): """ - Calls cs_comments_service to mark thread as read for the user + Calls forum service to mark thread as read for the user """ course_id = self.attributes.get("course_id") course_key = utils.get_course_key(course_id) - if is_forum_v2_enabled(course_key): - forum_api.mark_thread_as_read(self.id, source.id, course_id=str(course_id)) - else: - params = {'source_type': source.type, 'source_id': source.id} - utils.perform_request( - 'post', - _url_for_read(self.id), - params, - metric_action='user.read', - metric_tags=self._metric_tags + [f'target.type:{source.type}'], - ) + forum_api.mark_thread_as_read(self.id, source.id, course_id=str(course_id)) def follow(self, source, course_id=None): course_key = utils.get_course_key(self.attributes.get("course_id") or course_id) - if is_forum_v2_enabled(course_key): - forum_api.create_subscription( - user_id=self.id, - source_id=source.id, - course_id=str(course_key) - ) - else: - params = {'source_type': source.type, 'source_id': source.id} - utils.perform_request( - 'post', - _url_for_subscription(self.id), - params, - metric_action='user.follow', - metric_tags=self._metric_tags + [f'target.type:{source.type}'], - ) + forum_api.create_subscription( + user_id=self.id, + source_id=source.id, + course_id=str(course_key) + ) def unfollow(self, source, course_id=None): course_key = utils.get_course_key(self.attributes.get("course_id") or course_id) - if is_forum_v2_enabled(course_key): - forum_api.delete_subscription( - user_id=self.id, - source_id=source.id, - course_id=str(course_key) - ) - else: - params = {'source_type': source.type, 'source_id': source.id} - utils.perform_request( - 'delete', - _url_for_subscription(self.id), - params, - metric_action='user.unfollow', - metric_tags=self._metric_tags + [f'target.type:{source.type}'], - ) + forum_api.delete_subscription( + user_id=self.id, + source_id=source.id, + course_id=str(course_key) + ) def vote(self, voteable, value, course_id=None): + course_key = utils.get_course_key(self.attributes.get("course_id") or course_id) if voteable.type == 'thread': - url = _url_for_vote_thread(voteable.id) + response = forum_api.update_thread_votes( + thread_id=voteable.id, + user_id=self.id, + value=value, + course_id=str(course_key) + ) elif voteable.type == 'comment': - url = _url_for_vote_comment(voteable.id) + response = forum_api.update_comment_votes( + comment_id=voteable.id, + user_id=self.id, + value=value, + course_id=str(course_key) + ) else: raise utils.CommentClientRequestError("Can only vote / unvote for threads or comments") - course_key = utils.get_course_key(self.attributes.get("course_id") or course_id) - if is_forum_v2_enabled(course_key): - if voteable.type == 'thread': - response = forum_api.update_thread_votes( - thread_id=voteable.id, - user_id=self.id, - value=value, - course_id=str(course_key) - ) - else: - response = forum_api.update_comment_votes( - comment_id=voteable.id, - user_id=self.id, - value=value, - course_id=str(course_key) - ) - else: - params = {'user_id': self.id, 'value': value} - response = utils.perform_request( - 'put', - url, - params, - metric_action='user.vote', - metric_tags=self._metric_tags + [f'target.type:{voteable.type}'], - ) voteable._update_from_response(response) def unvote(self, voteable, course_id=None): + course_key = utils.get_course_key(self.attributes.get("course_id") or course_id) if voteable.type == 'thread': - url = _url_for_vote_thread(voteable.id) + response = forum_api.delete_thread_vote( + thread_id=voteable.id, + user_id=self.id, + course_id=str(course_key) + ) elif voteable.type == 'comment': - url = _url_for_vote_comment(voteable.id) + response = forum_api.delete_comment_vote( + comment_id=voteable.id, + user_id=self.id, + course_id=str(course_key) + ) else: raise utils.CommentClientRequestError("Can only vote / unvote for threads or comments") - course_key = utils.get_course_key(self.attributes.get("course_id") or course_id) - if is_forum_v2_enabled(course_key): - if voteable.type == 'thread': - response = forum_api.delete_thread_vote( - thread_id=voteable.id, - user_id=self.id, - course_id=str(course_key) - ) - else: - response = forum_api.delete_comment_vote( - comment_id=voteable.id, - user_id=self.id, - course_id=str(course_key) - ) - else: - params = {'user_id': self.id} - response = utils.perform_request( - 'delete', - url, - params, - metric_action='user.unvote', - metric_tags=self._metric_tags + [f'target.type:{voteable.type}'], - ) + voteable._update_from_response(response) def active_threads(self, query_params=None): @@ -157,31 +99,21 @@ def active_threads(self, query_params=None): query_params = {} if not self.course_id: raise utils.CommentClientRequestError("Must provide course_id when retrieving active threads for the user") - url = _url_for_user_active_threads(self.id) params = {'course_id': str(self.course_id)} params.update(query_params) course_key = utils.get_course_key(self.attributes.get("course_id")) - if is_forum_v2_enabled(course_key): - if user_id := params.get("user_id"): - params["user_id"] = str(user_id) - if page := params.get("page"): - params["page"] = int(page) - if per_page := params.get("per_page"): - params["per_page"] = int(per_page) - if count_flagged := params.get("count_flagged", False): - params["count_flagged"] = str_to_bool(count_flagged) - if not params.get("course_id"): - params["course_id"] = str(course_key) - response = forum_api.get_user_active_threads(**params) - else: - response = utils.perform_request( - 'get', - url, - params, - metric_action='user.active_threads', - metric_tags=self._metric_tags, - paged_results=True, - ) + if user_id := params.get("user_id"): + params["user_id"] = str(user_id) + if page := params.get("page"): + params["page"] = int(page) + if per_page := params.get("per_page"): + params["per_page"] = int(per_page) + if count_flagged := params.get("count_flagged", False): + params["count_flagged"] = str_to_bool(count_flagged) + if not params.get("course_id"): + params["course_id"] = str(course_key) + params = _clean_forum_params(params) + response = forum_api.get_user_active_threads(**params) return response.get('collection', []), response.get('page', 1), response.get('num_pages', 1) def subscribed_threads(self, query_params=None): @@ -191,31 +123,21 @@ def subscribed_threads(self, query_params=None): raise utils.CommentClientRequestError( "Must provide course_id when retrieving subscribed threads for the user", ) - url = _url_for_user_subscribed_threads(self.id) params = {'course_id': str(self.course_id)} params.update(query_params) course_key = utils.get_course_key(self.attributes.get("course_id")) - if is_forum_v2_enabled(course_key): - if user_id := params.get("user_id"): - params["user_id"] = str(user_id) - if page := params.get("page"): - params["page"] = int(page) - if per_page := params.get("per_page"): - params["per_page"] = int(per_page) - if count_flagged := params.get("count_flagged", False): - params["count_flagged"] = str_to_bool(count_flagged) - if not params.get("course_id"): - params["course_id"] = str(course_key) - response = forum_api.get_user_threads(**params) - else: - response = utils.perform_request( - 'get', - url, - params, - metric_action='user.subscribed_threads', - metric_tags=self._metric_tags, - paged_results=True - ) + if user_id := params.get("user_id"): + params["user_id"] = str(user_id) + if page := params.get("page"): + params["page"] = int(page) + if per_page := params.get("per_page"): + params["per_page"] = int(per_page) + if count_flagged := params.get("count_flagged", False): + params["count_flagged"] = str_to_bool(count_flagged) + if not params.get("course_id"): + params["course_id"] = str(course_key) + params = _clean_forum_params(params) + response = forum_api.get_user_subscriptions(**params) return utils.CommentClientPaginatedResult( collection=response.get('collection', []), page=response.get('page', 1), @@ -224,7 +146,6 @@ def subscribed_threads(self, query_params=None): ) def _retrieve(self, *args, **kwargs): - url = self.url(action='get', params=self.attributes) retrieve_params = self.default_retrieve_params.copy() retrieve_params.update(kwargs) @@ -238,119 +159,43 @@ def _retrieve(self, *args, **kwargs): if course_id: course_id = str(course_id) retrieve_params['course_id'] = course_id - course_key = utils.get_course_key(course_id) - if is_forum_v2_enabled(course_key): - group_ids = [retrieve_params['group_id']] if 'group_id' in retrieve_params else [] - is_complete = retrieve_params['complete'] - try: - response = forum_api.get_user( - self.attributes["id"], - group_ids=group_ids, - course_id=course_id, - complete=is_complete - ) - except ForumV2RequestError as e: - self.save({"course_id": course_id}) - response = forum_api.get_user( - self.attributes["id"], - group_ids=group_ids, - course_id=course_id, - complete=is_complete - ) - else: - try: - response = utils.perform_request( - 'get', - url, - retrieve_params, - metric_action='model.retrieve', - metric_tags=self._metric_tags, - ) - except utils.CommentClientRequestError as e: - if e.status_code == 404: - # attempt to gracefully recover from a previous failure - # to sync this user to the comments service. - self.save() - response = utils.perform_request( - 'get', - url, - retrieve_params, - metric_action='model.retrieve', - metric_tags=self._metric_tags, - ) - else: - raise + group_ids = [retrieve_params['group_id']] if 'group_id' in retrieve_params else None + is_complete = retrieve_params['complete'] + params = _clean_forum_params({ + "user_id": self.attributes["id"], + "group_ids": group_ids, + "course_id": course_id, + "complete": is_complete + }) + try: + response = forum_api.get_user(**params) + except ForumV2RequestError as e: + self.save({"course_id": course_id}) + response = forum_api.get_user(**params) self._update_from_response(response) def retire(self, retired_username): course_key = utils.get_course_key(self.attributes.get("course_id")) - if is_forum_v2_enabled(course_key): - forum_api.retire_user(user_id=self.id, retired_username=retired_username, course_id=str(course_key)) - else: - url = _url_for_retire(self.id) - params = {'retired_username': retired_username} - utils.perform_request( - 'post', - url, - params, - raw=True, - metric_action='user.retire', - metric_tags=self._metric_tags - ) + forum_api.retire_user(user_id=self.id, retired_username=retired_username, course_id=str(course_key)) def replace_username(self, new_username): course_key = utils.get_course_key(self.attributes.get("course_id")) - if is_forum_v2_enabled(course_key): - forum_api.update_username(user_id=self.id, new_username=new_username, course_id=str(course_key)) - else: - url = _url_for_username_replacement(self.id) - params = {"new_username": new_username} - - utils.perform_request( - 'post', - url, - params, - raw=True, - ) - - -def _url_for_vote_comment(comment_id): - return f"{settings.PREFIX}/comments/{comment_id}/votes" - - -def _url_for_vote_thread(thread_id): - return f"{settings.PREFIX}/threads/{thread_id}/votes" - - -def _url_for_subscription(user_id): - return f"{settings.PREFIX}/users/{user_id}/subscriptions" - - -def _url_for_user_active_threads(user_id): - return f"{settings.PREFIX}/users/{user_id}/active_threads" - - -def _url_for_user_subscribed_threads(user_id): - return f"{settings.PREFIX}/users/{user_id}/subscribed_threads" - - -def _url_for_read(user_id): - """ - Returns cs_comments_service url endpoint to mark thread as read for given user_id - """ - return f"{settings.PREFIX}/users/{user_id}/read" - - -def _url_for_retire(user_id): - """ - Returns cs_comments_service url endpoint to retire a user (remove all post content, etc.) - """ - return f"{settings.PREFIX}/users/{user_id}/retire" - - -def _url_for_username_replacement(user_id): - """ - Returns cs_comments_servuce url endpoint to replace the username of a user - """ - return f"{settings.PREFIX}/users/{user_id}/replace_username" + forum_api.update_username(user_id=self.id, new_username=new_username, course_id=str(course_key)) + + +def _clean_forum_params(params): + """Convert string booleans to actual booleans and remove None values from forum parameters.""" + result = {} + for k, v in params.items(): + if v is not None: + if isinstance(v, str): + if v.lower() == 'true': + result[k] = True + elif v.lower() == 'false': + result[k] = False + else: + result[k] = v + else: + result[k] = v + return result diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/utils.py b/openedx/core/djangoapps/django_comment_common/comment_client/utils.py index e77f39e6277d..ccdced767e00 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/utils.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/utils.py @@ -3,14 +3,10 @@ import logging -from uuid import uuid4 -import requests -from django.utils.translation import get_language +import requests # pylint: disable=unused-import from opaque_keys.edx.keys import CourseKey -from .settings import SERVICE_HOST as COMMENTS_SERVICE - log = logging.getLogger(__name__) @@ -31,76 +27,21 @@ def extract(dic, keys): return strip_none({k: dic.get(k) for k in keys}) -def perform_request(method, url, data_or_params=None, raw=False, - metric_action=None, metric_tags=None, paged_results=False): - # To avoid dependency conflict - from openedx.core.djangoapps.django_comment_common.models import ForumsConfig - config = ForumsConfig.current() - - if not config.enabled: - raise CommentClientMaintenanceError('service disabled') - - if metric_tags is None: - metric_tags = [] - - metric_tags.append(f'method:{method}') - if metric_action: - metric_tags.append(f'action:{metric_action}') - - if data_or_params is None: - data_or_params = {} - headers = { - 'X-Edx-Api-Key': config.api_key, - 'Accept-Language': get_language(), - } - request_id = uuid4() - request_id_dict = {'request_id': request_id} - - if method in ['post', 'put', 'patch']: - data = data_or_params - params = request_id_dict - else: - data = None - params = data_or_params.copy() - params.update(request_id_dict) - response = requests.request( - method, - url, - data=data, - params=params, - headers=headers, - timeout=config.connection_timeout - ) - - metric_tags.append(f'status_code:{response.status_code}') - status_code = int(response.status_code) - if status_code > 200: - metric_tags.append('result:failure') - else: - metric_tags.append('result:success') - - if 200 < status_code < 500: # lint-amnesty, pylint: disable=no-else-raise - log.info(f'Investigation Log: CommentClientRequestError for request with {method} and params {params}') - raise CommentClientRequestError(response.text, response.status_code) - # Heroku returns a 503 when an application is in maintenance mode - elif status_code == 503: - raise CommentClientMaintenanceError(response.text) - elif status_code == 500: - raise CommentClient500Error(response.text) - else: - if raw: - return response.text - else: - try: - data = response.json() - except ValueError: - raise CommentClientError( # lint-amnesty, pylint: disable=raise-missing-from - "Invalid JSON response for request {request_id}; first 100 characters: '{content}'".format( - request_id=request_id, - content=response.text[:100] - ) - ) - return data +def clean_forum_params(params): + """Convert string booleans to actual booleans and remove None values and empty lists from forum parameters.""" + result = {} + for k, v in params.items(): + if v is not None and v != []: + if isinstance(v, str): + if v.lower() == 'true': + result[k] = True + elif v.lower() == 'false': + result[k] = False + else: + result[k] = v + else: + result[k] = v + return result class CommentClientError(Exception): @@ -143,33 +84,6 @@ def __init__(self, collection, page, num_pages, subscriptions_count=0, corrected self.corrected_text = corrected_text -def check_forum_heartbeat(): - """ - Check the forum connection via its built-in heartbeat service and create an answer which can be used in the LMS - heartbeat django application. - This function can be connected to the LMS heartbeat checker through the HEARTBEAT_CHECKS variable. - """ - # To avoid dependency conflict - from openedx.core.djangoapps.django_comment_common.models import ForumsConfig - config = ForumsConfig.current() - - if not config.enabled: - # If this check is enabled but forums disabled, don't connect, just report no error - return 'forum', True, 'OK' - - try: - res = requests.get( - '%s/heartbeat' % COMMENTS_SERVICE, - timeout=config.connection_timeout - ).json() - if res['OK']: - return 'forum', True, 'OK' - else: - return 'forum', False, res.get('check', 'Forum heartbeat failed') - except Exception as fail: - return 'forum', False, str(fail) - - def get_course_key(course_id: CourseKey | str | None) -> CourseKey | None: """ Returns a CourseKey if the provided course_id is a valid string representation of a CourseKey. diff --git a/requirements/common_constraints.txt b/requirements/common_constraints.txt index fc92ee019407..748858b7015a 100644 --- a/requirements/common_constraints.txt +++ b/requirements/common_constraints.txt @@ -16,13 +16,9 @@ # this file from Github directly. It does not require packaging in edx-lint. # using LTS django version -Django<5.0 +Django<6.0 # elasticsearch>=7.14.0 includes breaking changes in it which caused issues in discovery upgrade process. # elastic search changelog: https://www.elastic.co/guide/en/enterprise-search/master/release-notes-7.14.0.html # See https://github.com/openedx/edx-platform/issues/35126 for more info elasticsearch<7.14.0 - -# Cause: https://github.com/openedx/edx-lint/issues/458 -# This can be unpinned once https://github.com/openedx/edx-lint/issues/459 has been resolved. -pip<24.3 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 492d3c193c69..5e7beb804df1 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -815,7 +815,7 @@ openedx-filters==2.0.1 # -r requirements/edx/kernel.in # lti-consumer-xblock # ora2 -openedx-forum==0.3.6 +openedx-forum==0.4.0 # via -r requirements/edx/kernel.in openedx-learning==0.26.0 # via diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index cea879b897d1..1eb8e59d1d60 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1374,7 +1374,7 @@ openedx-filters==2.0.1 # -r requirements/edx/testing.txt # lti-consumer-xblock # ora2 -openedx-forum==0.3.6 +openedx-forum==0.4.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index c9929446bdd8..b8cbcd960681 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -986,7 +986,7 @@ openedx-filters==2.0.1 # -r requirements/edx/base.txt # lti-consumer-xblock # ora2 -openedx-forum==0.3.6 +openedx-forum==0.4.0 # via -r requirements/edx/base.txt openedx-learning==0.26.0 # via diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 6eb1248b07ee..7995a7122ada 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1044,7 +1044,7 @@ openedx-filters==2.0.1 # -r requirements/edx/base.txt # lti-consumer-xblock # ora2 -openedx-forum==0.3.6 +openedx-forum==0.4.0 # via -r requirements/edx/base.txt openedx-learning==0.26.0 # via diff --git a/requirements/pip.txt b/requirements/pip.txt index 6bb638bff471..d52d3d7fdf3b 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -9,8 +9,6 @@ wheel==0.45.1 # The following packages are considered to be unsafe in a requirements file: pip==24.2 - # via - # -c requirements/common_constraints.txt - # -r requirements/pip.in + # via -r requirements/pip.in setuptools==79.0.0 # via -r requirements/pip.in