From edd8320899e4ac3f8de0752a85a4630da58d968b Mon Sep 17 00:00:00 2001 From: ayesha waris <73840786+ayesha-waris@users.noreply.github.com> Date: Thu, 22 May 2025 18:55:22 +0500 Subject: [PATCH 1/7] fix: stoped group TA to see the posts of other cohorts (#36765) * fix: stoped group TA to see the posts of other cohorts * test: updated test cases --------- Co-authored-by: Ayesha Waris --- lms/djangoapps/discussion/rest_api/api.py | 5 ++++- lms/djangoapps/discussion/rest_api/tests/test_api.py | 9 ++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/lms/djangoapps/discussion/rest_api/api.py b/lms/djangoapps/discussion/rest_api/api.py index 7123ca9e3fb4..100482ea56d1 100644 --- a/lms/djangoapps/discussion/rest_api/api.py +++ b/lms/djangoapps/discussion/rest_api/api.py @@ -990,7 +990,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 = { diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api.py b/lms/djangoapps/discussion/rest_api/tests/test_api.py index 62725cc47466..b26f594d98bd 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_api.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_api.py @@ -77,6 +77,7 @@ FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_MODERATOR, FORUM_ROLE_STUDENT, + FORUM_ROLE_GROUP_MODERATOR, Role ) from openedx.core.lib.exceptions import CourseNotFoundError, PageNotFoundError @@ -885,6 +886,7 @@ def test_thread_content(self): FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_STUDENT, + FORUM_ROLE_GROUP_MODERATOR, ], [True, False] ) @@ -897,7 +899,8 @@ def test_request_group(self, role_name, course_is_cohorted): _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) + 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): @@ -1787,10 +1790,10 @@ def test_call_with_paginated_results(self, page): if page in (1, 2): assert response.data["pagination"]["next"] is not None - assert f"page={page+1}" in response.data["pagination"]["next"] + assert f"page={page + 1}" in response.data["pagination"]["next"] if page in (2, 3): assert response.data["pagination"]["previous"] is not None - assert f"page={page-1}" in response.data["pagination"]["previous"] + assert f"page={page - 1}" in response.data["pagination"]["previous"] if page == 1: assert response.data["pagination"]["previous"] is None if page == 3: From cf63612900c8d4123445a67149005d39b3299e1c Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed <68893403+taimoor-ahmed-1@users.noreply.github.com> Date: Wed, 7 May 2025 19:41:37 +0500 Subject: [PATCH 2/7] feat!: remove cs_comments_service support for forums pin API This will force the use of the new v2 forums API for pinning/unpinning. --- .../django_comment_client/base/tests.py | 52 ---- .../django_comment_client/base/tests_v2.py | 244 ++++++++++++++++++ .../django_comment_client/tests/mixins.py | 67 +++++ .../comment_client/thread.py | 42 +-- 4 files changed, 321 insertions(+), 84 deletions(-) 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 diff --git a/lms/djangoapps/discussion/django_comment_client/base/tests.py b/lms/djangoapps/discussion/django_comment_client/base/tests.py index d2a4f921f28b..340f853866a6 100644 --- a/lms/djangoapps/discussion/django_comment_client/base/tests.py +++ b/lms/djangoapps/discussion/django_comment_client/base/tests.py @@ -210,22 +210,6 @@ def test_flag(self, mock_is_forum_v2_enabled, mock_request): response = self.call_view("un_flag_abuse_for_thread", mock_is_forum_v2_enabled, mock_request) self._assert_json_response_contains_group_info(response) - def test_pin(self, mock_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", @@ -1191,42 +1175,6 @@ def setUp(self): 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] 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..cabe441039a7 --- /dev/null +++ b/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py @@ -0,0 +1,244 @@ +# 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 +from lms.djangoapps.discussion.django_comment_client.tests.mixins import ( + MockForumApiMixin, +) + + +@disable_signal(views, "thread_edited") +@disable_signal(views, "thread_voted") +@disable_signal(views, "thread_deleted") +class ThreadActionGroupIdTestCase( + CohortedTestCase, GroupIdAssertionMixin, 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 = { + "user_id": str(self.student.id), + "group_id": self.student_cohort.id, + "closed": False, + "type": "thread", + "commentable_id": "non_team_dummy_id", + "body": "test body", + } + + self.set_mock_return_value("get_course_id_by_thread", 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_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) + + +@disable_signal(views, "comment_endorsed") +class ViewPermissionsTestCase( + ForumsEnableMixin, + 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 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 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..28f1dc0714df --- /dev/null +++ b/lms/djangoapps/discussion/django_comment_client/tests/mixins.py @@ -0,0 +1,67 @@ +""" +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.""" + + @classmethod + def setUpClass(cls): + """Apply a single forum_api mock at the class level.""" + cls.setUpClassAndForumMock() + + @classmethod + def setUpClassAndForumMock(cls): + """ + Set up the class and apply the forum_api mock. + """ + cls.mock_forum_api = mock.Mock() + + # TODO: Remove this after moving all APIs + cls.flag_v2_patcher = mock.patch( + "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled" + ) + cls.mock_enable_forum_v2 = cls.flag_v2_patcher.start() + cls.mock_enable_forum_v2.return_value = True + + patch_targets = [ + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api", + "openedx.core.djangoapps.django_comment_common.comment_client.comment.forum_api", + "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.""" + cls.flag_v2_patcher.stop() + + for patcher in cls.forum_api_patchers: + patcher.stop() + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + cls.disposeForumMocks() + + 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) + ) 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..e37c09b851df 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py @@ -248,42 +248,20 @@ def unFlagAbuse(self, user, voteable, removeAll, course_id=None): 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) From 46a09047146daae24f71fbe53f768ff98edcad8d Mon Sep 17 00:00:00 2001 From: Ali-Salman29 Date: Thu, 22 May 2025 09:00:51 +0200 Subject: [PATCH 3/7] feat!: remove cs_comments_service support for forum's flag APIs This will force the use of the new v2 forum's APIs for flaging/unflaging. --- .../django_comment_client/base/tests.py | 366 +-------- .../django_comment_client/base/tests_v2.py | 671 +++++++++++++++- .../django_comment_client/tests/mixins.py | 57 +- .../discussion/rest_api/tests/test_api.py | 220 ----- .../discussion/rest_api/tests/test_api_v2.py | 758 ++++++++++++++++++ .../discussion/rest_api/tests/test_views.py | 301 +------ .../rest_api/tests/test_views_v2.py | 444 ++++++++++ .../discussion/rest_api/tests/utils.py | 281 +++++++ lms/djangoapps/discussion/tests/utils.py | 70 ++ .../comment_client/comment.py | 84 +- .../comment_client/models.py | 8 +- .../comment_client/thread.py | 58 +- 12 files changed, 2307 insertions(+), 1011 deletions(-) create mode 100644 lms/djangoapps/discussion/rest_api/tests/test_api_v2.py create mode 100644 lms/djangoapps/discussion/rest_api/tests/test_views_v2.py create mode 100644 lms/djangoapps/discussion/tests/utils.py diff --git a/lms/djangoapps/discussion/django_comment_client/base/tests.py b/lms/djangoapps/discussion/django_comment_client/base/tests.py index 340f853866a6..7047ba3995a5 100644 --- a/lms/djangoapps/discussion/django_comment_client/base/tests.py +++ b/lms/djangoapps/discussion/django_comment_client/base/tests.py @@ -202,14 +202,6 @@ def test_vote(self, mock_is_forum_v2_enabled, mock_request): response = self.call_view("undo_vote_for_thread", mock_is_forum_v2_enabled, mock_request) self._assert_json_response_contains_group_info(response) - def test_flag(self, mock_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_openclose(self, mock_is_forum_v2_enabled, mock_request): response = self.call_view( "openclose_thread", @@ -799,309 +791,6 @@ def test_update_comment_basic(self, mock_is_forum_v2_enabled, mock_request): 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'), @@ -1427,56 +1116,6 @@ def _test_unicode_data(self, text, mock_is_forum_v2_enabled, mock_request): 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, @@ -1830,7 +1469,7 @@ def test_comment_actions(self, user, commentable_id, status_code, mock_is_forum_ "course_id": str(self.course.id) }, ) - for action in ["upvote_comment", "downvote_comment", "un_flag_abuse_for_comment", "flag_abuse_for_comment"]: + for action in ["upvote_comment", "downvote_comment"]: response = self.client.post( reverse( action, @@ -1851,8 +1490,7 @@ def test_threads_actions(self, user, commentable_id, status_code, mock_is_forum_ 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"]: + for action in ["upvote_thread", "downvote_thread", "follow_thread", "unfollow_thread"]: response = self.client.post( reverse( action, diff --git a/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py b/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py index cabe441039a7..243959612a8d 100644 --- a/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py +++ b/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py @@ -86,6 +86,11 @@ MockForumApiMixin, ) +from lms.djangoapps.discussion.tests.utils import ( + make_minimal_cs_thread, + make_minimal_cs_comment, +) + @disable_signal(views, "thread_edited") @disable_signal(views, "thread_voted") @@ -111,16 +116,11 @@ 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 = { - "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", - } + 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", self.course.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) @@ -132,9 +132,19 @@ def call_view( request, course_id=str(self.course.id), thread_id="dummy", - **(view_args or {}) + **(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) @@ -146,6 +156,400 @@ def test_pin_thread(self): self._assert_json_response_contains_group_info(response) +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) + + +@ddt.ddt +@disable_signal(views, "comment_flagged") +@disable_signal(views, "thread_flagged") +class ViewsTestCase( + ForumsEnableMixin, + 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_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 + + @disable_signal(views, "comment_endorsed") class ViewPermissionsTestCase( ForumsEnableMixin, @@ -242,3 +646,250 @@ def test_un_pin_thread_as_moderator(self): ) ) 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"], + make_minimal_cs_comment( + { + "commentable_id": commentable_id, + "course_id": str(self.course.id), + } + ), + ) + for action in ["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", + }, + ) + ) + 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"], + 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"]: + response = self.client.post( + reverse( + action, + kwargs={ + "course_id": str(self.course.id), + "thread_id": "dummy", + }, + ) + ) + assert response.status_code == status_code diff --git a/lms/djangoapps/discussion/django_comment_client/tests/mixins.py b/lms/djangoapps/discussion/django_comment_client/tests/mixins.py index 28f1dc0714df..f0955fb2b9cb 100644 --- a/lms/djangoapps/discussion/django_comment_client/tests/mixins.py +++ b/lms/djangoapps/discussion/django_comment_client/tests/mixins.py @@ -8,11 +8,6 @@ class MockForumApiMixin: """Mixin to mock forum_api across different test cases with a single mock instance.""" - @classmethod - def setUpClass(cls): - """Apply a single forum_api mock at the class level.""" - cls.setUpClassAndForumMock() - @classmethod def setUpClassAndForumMock(cls): """ @@ -49,11 +44,6 @@ def disposeForumMocks(cls): for patcher in cls.forum_api_patchers: patcher.stop() - @classmethod - def tearDownClass(cls): - """Stop patches after tests complete.""" - cls.disposeForumMocks() - def set_mock_return_value(self, function_name, return_value): """ Set a return value for a specific method in forum_api mock. @@ -65,3 +55,50 @@ def set_mock_return_value(self, function_name, return_value): 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, *parms, **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. + parms (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(*parms, **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/rest_api/tests/test_api.py b/lms/djangoapps/discussion/rest_api/tests/test_api.py index b26f594d98bd..c89359152a7b 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_api.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_api.py @@ -2153,18 +2153,6 @@ def test_following(self): 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, {}) @@ -2513,18 +2501,6 @@ def test_endorsed(self, role_name, is_thread_author, thread_type): 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, {}) @@ -2960,108 +2936,6 @@ def test_vote_count_two_users( 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: @@ -3569,100 +3443,6 @@ def test_vote_count_two_users( 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, 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..53e029217611 --- /dev/null +++ b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py @@ -0,0 +1,758 @@ +# pylint: skip-file +""" +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.django_comment_client.tests.utils import ( + ForumsEnableMixin, +) +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_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( + ForumsEnableMixin, + 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", + } + + 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) + + +@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, + 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) + + 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) + + +@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, + 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) + + @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": True if remove_all else False, + "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.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, + 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) + + @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 = { + "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": True if remove_all else False, + "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", + "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) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views.py b/lms/djangoapps/discussion/rest_api/tests/test_views.py index 9ae03986bb93..84efa95378d4 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views.py @@ -18,7 +18,6 @@ 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 @@ -38,7 +37,7 @@ SuperuserFactory, UserFactory ) -from common.djangoapps.util.testing import PatchMediaTypeMixin, UrlResetMixin +from common.djangoapps.util.testing import UrlResetMixin from common.test.utils import disable_signal from lms.djangoapps.discussion.django_comment_client.tests.utils import ( ForumsEnableMixin, @@ -1481,162 +1480,6 @@ def test_error(self): 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}) @@ -2632,148 +2475,6 @@ def test_closed_thread(self): 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): 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..29e469c9a9a2 --- /dev/null +++ b/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py @@ -0,0 +1,444 @@ +# pylint: skip-file +""" +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 +from urllib.parse import parse_qs, urlencode, urlparse + +import ddt +import httpretty +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import override_settings +from django.urls import reverse +from edx_toggles.toggles.testutils import override_waffle_flag +from lms.djangoapps.discussion.django_comment_client.tests.mixins import MockForumApiMixin +from opaque_keys.edx.keys import CourseKey +from pytz import UTC +from rest_framework import status +from rest_framework.parsers import JSONParser +from rest_framework.test import APIClient, APITestCase + +from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE +from lms.djangoapps.discussion.rest_api.utils import get_usernames_from_search_string +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory, check_mongo_calls + +from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.course_modes.tests.factories import CourseModeFactory +from common.djangoapps.student.models import get_retired_username_by_username, CourseEnrollment +from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole, GlobalStaff +from common.djangoapps.student.tests.factories import ( + AdminFactory, + CourseEnrollmentFactory, + SuperuserFactory, + UserFactory +) +from common.djangoapps.util.testing import PatchMediaTypeMixin, UrlResetMixin +from common.test.utils import disable_signal +from lms.djangoapps.discussion.tests.utils import ( + make_minimal_cs_comment, + make_minimal_cs_thread, +) +from lms.djangoapps.discussion.django_comment_client.tests.utils import ( + ForumsEnableMixin, + config_course_discussions, + topic_name_to_id, +) +from lms.djangoapps.discussion.rest_api import api +from lms.djangoapps.discussion.rest_api.tests.utils import ( + CommentsServiceMockMixin, + ForumMockUtilsMixin, + ProfileImageTestMixin, + 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, 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, + 'read': True, + '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 diff --git a/lms/djangoapps/discussion/rest_api/tests/utils.py b/lms/djangoapps/discussion/rest_api/tests/utils.py index 496f8723acfb..2c8b869d1569 100644 --- a/lms/djangoapps/discussion/rest_api/tests/utils.py +++ b/lms/djangoapps/discussion/rest_api/tests/utils.py @@ -14,6 +14,7 @@ from PIL import Image from pytz import UTC +from lms.djangoapps.discussion.django_comment_client.tests.mixins import MockForumApiMixin 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 +52,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 +115,38 @@ 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 + + class CommentsServiceMockMixin: """Mixin with utility methods for mocking the comments service""" @@ -521,6 +582,226 @@ 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_return_value('get_thread', Exception(f"Error {status_code}")) + + 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): + self.set_mock_return_value('get_parent_comment', { + "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_return_value('get_parent_comment', Exception(f"Error {status_code}")) + + 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): + self.set_mock_return_value('get_user', { + "id": str(user.id), + "subscribed_thread_ids": subscribed_thread_ids or [], + "upvoted_ids": upvoted_ids or [], + }) + + def register_get_user_retire_response(self, user, 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): + 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/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/openedx/core/djangoapps/django_comment_common/comment_client/comment.py b/openedx/core/djangoapps/django_comment_common/comment_client/comment.py index 5f6348547efb..99c7ae1b8a03 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/comment.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/comment.py @@ -3,10 +3,9 @@ from openedx.core.djangoapps.django_comment_common.comment_client import models, settings -from .thread import Thread, _url_for_flag_abuse_thread, _url_for_unflag_abuse_thread -from .utils import CommentClientRequestError, 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 class Comment(models.Model): @@ -64,71 +63,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 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..88606b999b67 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/models.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/models.py @@ -170,7 +170,6 @@ 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) @@ -256,7 +255,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 +264,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 +274,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,7 +287,7 @@ 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 + "read": request_params.get("read"), } request_data = {k: v for k, v in request_data.items() if v is not None} response = forum_api.update_thread(**request_data) 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 e37c09b851df..49aa8f9bc194 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py @@ -199,51 +199,31 @@ def _retrieve(self, *args, **kwargs): 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 = forum_api.update_thread_flag( + thread_id=voteable.id, + action="unflag", + user_id=user.id, + update_all=bool(removeAll), + course_id=str(course_key) + ) - response = utils.perform_request( - 'put', - url, - params, - metric_tags=self._metric_tags, - metric_action='thread.abuse.unflagged' - ) voteable._update_from_response(response) def pin(self, user, thread_id, course_id=None): From 7af72198e61bb47e33a529a53e126618f19f2fe0 Mon Sep 17 00:00:00 2001 From: Ali-Salman29 Date: Tue, 27 May 2025 14:30:52 +0200 Subject: [PATCH 4/7] feat!: remove cs_comments_service support for forum's vote APIs This will force the use of the new v2 forum's APIs for voting/unvoting. --- .../django_comment_client/base/tests.py | 95 +----- .../django_comment_client/base/tests_v2.py | 153 ++++++++- .../django_comment_client/tests/mixins.py | 2 + .../discussion/rest_api/tests/test_api.py | 247 --------------- .../discussion/rest_api/tests/test_api_v2.py | 298 ++++++++++++++++++ .../discussion/rest_api/tests/utils.py | 16 +- .../comment_client/user.py | 77 ++--- 7 files changed, 489 insertions(+), 399 deletions(-) diff --git a/lms/djangoapps/discussion/django_comment_client/base/tests.py b/lms/djangoapps/discussion/django_comment_client/base/tests.py index 7047ba3995a5..bc2253b14066 100644 --- a/lms/djangoapps/discussion/django_comment_client/base/tests.py +++ b/lms/djangoapps/discussion/django_comment_client/base/tests.py @@ -191,17 +191,6 @@ 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_openclose(self, mock_is_forum_v2_enabled, mock_request): response = self.call_view( "openclose_thread", @@ -791,25 +780,6 @@ def test_update_comment_basic(self, mock_is_forum_v2_enabled, mock_request): data={"body": updated_body, "course_id": str(self.course_id)} ) - @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) @@ -1451,33 +1421,6 @@ def test_create_sub_comment(self, user, commentable_id, status_code, mock_is_for ) 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"]: - 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): @@ -1490,7 +1433,7 @@ def test_threads_actions(self, user, commentable_id, status_code, mock_is_forum_ 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", "follow_thread", "unfollow_thread"]: + for action in ["follow_thread", "unfollow_thread"]: response = self.client.post( reverse( action, @@ -1690,42 +1633,6 @@ def test_team_events(self, view_name, event_name, view_data, view_kwargs, mock_i 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) diff --git a/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py b/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py index 243959612a8d..0d1faa55d7df 100644 --- a/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py +++ b/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py @@ -155,6 +155,14 @@ def test_pin_thread(self): 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) + class ViewsTestCaseMixin: @@ -214,6 +222,22 @@ def set_up_course(self, block_count=0): 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) + @ddt.ddt @disable_signal(views, "comment_flagged") @@ -549,6 +573,26 @@ def un_flag_comment(self, is_closed): 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 + @disable_signal(views, "comment_endorsed") class ViewPermissionsTestCase( @@ -843,15 +887,22 @@ def test_comment_actions(self, user, commentable_id, status_code): commentable_id = getattr(self, commentable_id) self._setup_mock( user, - ["get_parent_comment", "update_comment_flag"], + [ + "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), } ), ) - for action in ["un_flag_abuse_for_comment", "flag_abuse_for_comment"]: + # "un_flag_abuse_for_comment", "flag_abuse_for_comment", + for action in ["upvote_comment", "downvote_comment"]: response = self.client.post( reverse( action, @@ -873,7 +924,12 @@ def test_threads_actions(self, user, commentable_id, status_code): commentable_id = getattr(self, commentable_id) self._setup_mock( user, - ["get_thread", "update_thread_flag"], + [ + "get_thread", + "update_thread_flag", + "update_thread_votes", + "delete_thread_vote", + ], make_minimal_cs_thread( { "commentable_id": commentable_id, @@ -882,7 +938,12 @@ def test_threads_actions(self, user, commentable_id, status_code): ), ) - for action in ["un_flag_abuse_for_thread", "flag_abuse_for_thread"]: + for action in [ + "un_flag_abuse_for_thread", + "flag_abuse_for_thread", + "upvote_thread", + "downvote_thread", + ]: response = self.client.post( reverse( action, @@ -893,3 +954,87 @@ def test_threads_actions(self, user, commentable_id, status_code): ) ) assert response.status_code == status_code + + +@disable_signal(views, "comment_created") +@ddt.ddt +class ForumEventTestCase( + ForumsEnableMixin, 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" diff --git a/lms/djangoapps/discussion/django_comment_client/tests/mixins.py b/lms/djangoapps/discussion/django_comment_client/tests/mixins.py index f0955fb2b9cb..a5ae9ec145b6 100644 --- a/lms/djangoapps/discussion/django_comment_client/tests/mixins.py +++ b/lms/djangoapps/discussion/django_comment_client/tests/mixins.py @@ -8,6 +8,8 @@ class MockForumApiMixin: """Mixin to mock forum_api across different test cases with a single mock instance.""" + users_map = {} + @classmethod def setUpClassAndForumMock(cls): """ diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api.py b/lms/djangoapps/discussion/rest_api/tests/test_api.py index c89359152a7b..aec308bd6235 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_api.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_api.py @@ -2813,129 +2813,6 @@ def test_following(self, old_following, new_following, mock_emit): 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=[]) - def test_invalid_field(self): self.register_thread() with pytest.raises(ValidationError) as assertion: @@ -3319,130 +3196,6 @@ def test_endorsed_access(self, role_name, is_thread_author, thread_type, is_comm 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( FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py index 53e029217611..4804c73a069f 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py @@ -433,6 +433,17 @@ def register_thread(self, overrides=None): 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 + @ddt.data(*itertools.product([True, False], [True, False])) @ddt.unpack @mock.patch("eventtracking.tracker.emit") @@ -564,6 +575,142 @@ def test_thread_un_abuse_flag_for_moderator_role( 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.ddt @disable_signal(api, "comment_edited") @@ -636,6 +783,17 @@ def register_comment(self, overrides=None, thread_overrides=None, course=None): 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 + @ddt.data(*itertools.product([True, False], [True, False])) @ddt.unpack @mock.patch("eventtracking.tracker.emit") @@ -756,3 +914,143 @@ def test_comment_un_abuse_flag_for_moderator_role( 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=[]) diff --git a/lms/djangoapps/discussion/rest_api/tests/utils.py b/lms/djangoapps/discussion/rest_api/tests/utils.py index 2c8b869d1569..2cd6628bf861 100644 --- a/lms/djangoapps/discussion/rest_api/tests/utils.py +++ b/lms/djangoapps/discussion/rest_api/tests/utils.py @@ -147,6 +147,16 @@ def callback(*args, **kwargs): 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""" @@ -650,11 +660,13 @@ def register_get_comment_response(self, 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): - self.set_mock_return_value('get_user', { + """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, body=""): self.set_mock_return_value('retire_user', body) 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..02a24b02b6de 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/user.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/user.py @@ -87,69 +87,42 @@ def unfollow(self, source, course_id=None): ) 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): From cb760a06afb43fb90c1b7242de4a5f53a1efa1be Mon Sep 17 00:00:00 2001 From: Taimoor Ahmed Date: Thu, 19 Jun 2025 12:00:52 +0500 Subject: [PATCH 5/7] fix: Discussion following posts filter This PR fixes following filter on the discussions module by replacing forum api to api since we only need threads that are subscribed/followed by the requesting user. --- .../django_comment_common/comment_client/user.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 02a24b02b6de..731825aa71ad 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/user.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/user.py @@ -169,8 +169,6 @@ def subscribed_threads(self, query_params=None): 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"): @@ -179,7 +177,11 @@ def subscribed_threads(self, query_params=None): 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) + + user_id = params.pop("user_id", None) + if "text" in params: + params.pop("text") + response = forum_api.get_user_subscriptions(user_id, str(course_key), params) else: response = utils.perform_request( 'get', From cf377524b3b9fb8ab7ec87072f52e9dd201efe25 Mon Sep 17 00:00:00 2001 From: Ali-Salman29 Date: Tue, 24 Jun 2025 15:48:12 +0200 Subject: [PATCH 6/7] feat!: remove cs_comments_service support for forum's search APIs This will force the use of the new v2 forum's APIs for searching. --- .../django_comment_client/tests/group_id.py | 216 +++ .../discussion/rest_api/tests/test_api.py | 539 ------- .../discussion/rest_api/tests/test_api_v2.py | 637 +++++++++ .../discussion/rest_api/tests/test_views.py | 348 ----- .../rest_api/tests/test_views_v2.py | 749 ++++++++-- lms/djangoapps/discussion/tests/test_views.py | 1147 ++------------- .../discussion/tests/test_views_v2.py | 1264 +++++++++++++++++ .../comment_client/thread.py | 57 +- .../comment_client/user.py | 22 +- .../comment_client/utils.py | 17 + 10 files changed, 2864 insertions(+), 2132 deletions(-) create mode 100644 lms/djangoapps/discussion/tests/test_views_v2.py 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..9907db95bb2a 100644 --- a/lms/djangoapps/discussion/django_comment_client/tests/group_id.py +++ b/lms/djangoapps/discussion/django_comment_client/tests/group_id.py @@ -241,3 +241,219 @@ def test_team_discussion_id_not_cohorted(self, mock_is_forum_v2_enabled, mock_re self.call_view(mock_is_forum_v2_enabled, mock_request, team.discussion_topic_id, self.student, '') self._assert_comments_service_called_without_group_id(mock_request) + + +class GroupIdAssertionMixinV2: + """ + Provides assertion methods for testing group_id functionality in forum v2. + + This mixin contains helper methods to verify that the comments service is called + with the correct group_id parameters and that responses contain the expected + group information. + """ + def _get_params_last_call(self, function_name): + """ + Returns the data or params dict that `mock_request` was called with. + """ + return self.get_mock_func_calls(function_name)[-1][1] + + def _assert_comments_service_called_with_group_id(self, group_id): + assert self.check_mock_called('get_user_threads') + assert self._get_params_last_call('get_user_threads')['group_id'] == group_id + + def _assert_comments_service_called_without_group_id(self): + assert self.check_mock_called('get_user_threads') + assert 'group_id' not in self._get_params_last_call('get_user_threads') + + def _assert_html_response_contains_group_info(self, response): + group_info = {"group_id": None, "group_name": None} + match = re.search(r'"group_id": (\d*),', response.content.decode('utf-8')) + if match and match.group(1) != '': + group_info["group_id"] = int(match.group(1)) + match = re.search(r'"group_name": "(\w*)"', response.content.decode('utf-8')) + if match: + group_info["group_name"] = match.group(1) + self._assert_thread_contains_group_info(group_info) + + def _assert_json_response_contains_group_info(self, response, extract_thread=None): + """ + :param extract_thread: a function which accepts a dictionary (complete + json response payload) and returns another dictionary (first + occurrence of a thread model within that payload). if None is + passed, the identity function is assumed. + """ + payload = json.loads(response.content.decode('utf-8')) + thread = extract_thread(payload) if extract_thread else payload + self._assert_thread_contains_group_info(thread) + + def _assert_thread_contains_group_info(self, thread): + assert thread['group_id'] == self.student_cohort.id + assert thread['group_name'] == self.student_cohort.name + + +class CohortedTopicGroupIdTestMixinV2(GroupIdAssertionMixinV2): + """ + Provides test cases to verify that views pass the correct `group_id` to + the comments service when requesting content in cohorted discussions for forum v2. + """ + def call_view(self, commentable_id, user, group_id, pass_group_id=True): + """ + Call the view for the implementing test class, constructing a request + from the parameters. + """ + pass # lint-amnesty, pylint: disable=unnecessary-pass + + def test_cohorted_topic_student_without_group_id(self): + self.call_view("cohorted_topic", self.student, '', pass_group_id=False) + self._assert_comments_service_called_with_group_id(self.student_cohort.id) + + def test_cohorted_topic_student_none_group_id(self): + self.call_view("cohorted_topic", self.student, "") + self._assert_comments_service_called_with_group_id(self.student_cohort.id) + + def test_cohorted_topic_student_with_own_group_id(self): + self.call_view("cohorted_topic", self.student, self.student_cohort.id) + self._assert_comments_service_called_with_group_id(self.student_cohort.id) + + def test_cohorted_topic_student_with_other_group_id(self): + self.call_view( + "cohorted_topic", + self.student, + self.moderator_cohort.id + ) + self._assert_comments_service_called_with_group_id(self.student_cohort.id) + + def test_cohorted_topic_moderator_without_group_id(self): + self.call_view( + "cohorted_topic", + self.moderator, + '', + pass_group_id=False + ) + self._assert_comments_service_called_without_group_id() + + def test_cohorted_topic_moderator_none_group_id(self): + self.call_view("cohorted_topic", self.moderator, "") + self._assert_comments_service_called_without_group_id() + + def test_cohorted_topic_moderator_with_own_group_id(self): + self.call_view( + "cohorted_topic", + self.moderator, + self.moderator_cohort.id + ) + self._assert_comments_service_called_with_group_id(self.moderator_cohort.id) + + def test_cohorted_topic_moderator_with_other_group_id(self): + self.call_view( + "cohorted_topic", + self.moderator, + self.student_cohort.id + ) + self._assert_comments_service_called_with_group_id(self.student_cohort.id) + + def test_cohorted_topic_moderator_with_invalid_group_id(self): + invalid_id = self.student_cohort.id + self.moderator_cohort.id + response = self.call_view("cohorted_topic", self.moderator, invalid_id) # lint-amnesty, pylint: disable=assignment-from-no-return + assert response.status_code == 500 + + def test_cohorted_topic_enrollment_track_invalid_group_id(self): + CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.AUDIT) + CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.VERIFIED) + discussion_settings = CourseDiscussionSettings.get(self.course.id) + discussion_settings.update({ + 'divided_discussions': ['cohorted_topic'], + 'division_scheme': CourseDiscussionSettings.ENROLLMENT_TRACK, + 'always_divide_inline_discussions': True, + }) + + invalid_id = -1000 + response = self.call_view("cohorted_topic", self.moderator, invalid_id) # lint-amnesty, pylint: disable=assignment-from-no-return + assert response.status_code == 500 + + +class NonCohortedTopicGroupIdTestMixinV2(GroupIdAssertionMixinV2): + """ + Provides test cases to verify that views pass the correct `group_id` to + the comments service when requesting content in non-cohorted discussions for forum v2. + """ + def call_view(self, commentable_id, user, group_id, pass_group_id=True): + """ + Call the view for the implementing test class, constructing a request + from the parameters. + """ + pass # lint-amnesty, pylint: disable=unnecessary-pass + + def test_non_cohorted_topic_student_without_group_id(self): + self.call_view( + "non_cohorted_topic", + self.student, + '', + pass_group_id=False + ) + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_student_none_group_id(self): + self.call_view("non_cohorted_topic", self.student, '') + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_student_with_own_group_id(self): + self.call_view( + "non_cohorted_topic", + self.student, + self.student_cohort.id + ) + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_student_with_other_group_id(self): + self.call_view( + "non_cohorted_topic", + self.student, + self.moderator_cohort.id + ) + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_moderator_without_group_id(self): + self.call_view( + "non_cohorted_topic", + self.moderator, + "", + pass_group_id=False, + ) + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_moderator_none_group_id(self): + self.call_view("non_cohorted_topic", self.moderator, '') + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_moderator_with_own_group_id(self): + self.call_view( + "non_cohorted_topic", + self.moderator, + self.moderator_cohort.id, + ) + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_moderator_with_other_group_id(self): + self.call_view( + "non_cohorted_topic", + self.moderator, + self.student_cohort.id, + ) + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_moderator_with_invalid_group_id(self): + invalid_id = self.student_cohort.id + self.moderator_cohort.id + self.call_view("non_cohorted_topic", self.moderator, invalid_id) + self._assert_comments_service_called_without_group_id() + + def test_team_discussion_id_not_cohorted(self): + team = CourseTeamFactory( + course_id=self.course.id, + topic_id='topic-id' + ) + + team.add_user(self.student) + self.call_view(team.discussion_topic_id, self.student, '') + + self._assert_comments_service_called_without_group_id() diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api.py b/lms/djangoapps/discussion/rest_api/tests/test_api.py index aec308bd6235..48b943543c91 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_api.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_api.py @@ -21,7 +21,6 @@ from pytz import UTC from rest_framework.exceptions import PermissionDenied -from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory @@ -48,7 +47,6 @@ get_course_topics, get_course_topics_v2, get_thread, - get_thread_list, get_user_comments, update_comment, update_thread @@ -77,7 +75,6 @@ FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_MODERATOR, FORUM_ROLE_STUDENT, - FORUM_ROLE_GROUP_MODERATOR, Role ) from openedx.core.lib.exceptions import CourseNotFoundError, PageNotFoundError @@ -698,542 +695,6 @@ def test_discussion_topic(self): } -@ddt.ddt -@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class GetThreadListTest(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMixin, SharedModuleStoreTestCase): - """Test for get_thread_list""" - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.course = CourseFactory.create() - - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - super().setUp() - httpretty.reset() - httpretty.enable() - self.addCleanup(httpretty.reset) - self.addCleanup(httpretty.disable) - self.maxDiff = None # pylint: disable=invalid-name - self.user = UserFactory.create() - self.register_get_user_response(self.user) - self.request = RequestFactory().get("/test_path") - self.request.user = self.user - CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) - self.author = UserFactory.create() - self.course.cohort_config = {"cohorted": False} - modulestore().update_item(self.course, ModuleStoreEnum.UserID.test) - self.cohort = CohortFactory.create(course_id=self.course.id) - - def get_thread_list( - self, - threads, - page=1, - page_size=1, - num_pages=1, - course=None, - topic_id_list=None, - ): - """ - Register the appropriate comments service response, then call - get_thread_list and return the result. - """ - course = course or self.course - self.register_get_threads_response(threads, page, num_pages) - ret = get_thread_list(self.request, course.id, page, page_size, topic_id_list) - return ret - - def test_nonexistent_course(self): - with pytest.raises(CourseNotFoundError): - get_thread_list(self.request, CourseLocator.from_string("course-v1:non+existent+course"), 1, 1) - - def test_not_enrolled(self): - self.request.user = UserFactory.create() - with pytest.raises(CourseNotFoundError): - self.get_thread_list([]) - - def test_discussions_disabled(self): - with pytest.raises(DiscussionDisabledError): - self.get_thread_list([], course=_discussion_disabled_course_for(self.user)) - - def test_empty(self): - assert self.get_thread_list( - [], num_pages=0 - ).data == { - 'pagination': { - 'next': None, - 'previous': None, - 'num_pages': 0, - 'count': 0 - }, - 'results': [], - 'text_search_rewrite': None - } - - def test_get_threads_by_topic_id(self): - self.get_thread_list([], topic_id_list=["topic_x", "topic_meow"]) - assert urlparse(httpretty.last_request().path).path == '/api/v1/threads' # lint-amnesty, pylint: disable=no-member - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["1"], - "commentable_ids": ["topic_x,topic_meow"] - }) - - def test_basic_query_params(self): - self.get_thread_list([], page=6, page_size=14) - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["6"], - "per_page": ["14"], - }) - - def test_thread_content(self): - self.course.cohort_config = {"cohorted": True} - modulestore().update_item(self.course, ModuleStoreEnum.UserID.test) - source_threads = [ - make_minimal_cs_thread({ - "id": "test_thread_id_0", - "course_id": str(self.course.id), - "commentable_id": "topic_x", - "username": self.author.username, - "user_id": str(self.author.id), - "title": "Test Title", - "body": "Test body", - "votes": {"up_count": 4}, - "comments_count": 5, - "unread_comments_count": 3, - "endorsed": True, - "read": True, - "created_at": "2015-04-28T00:00:00Z", - "updated_at": "2015-04-28T11:11:11Z", - }), - make_minimal_cs_thread({ - "id": "test_thread_id_1", - "course_id": str(self.course.id), - "commentable_id": "topic_y", - "group_id": self.cohort.id, - "username": self.author.username, - "user_id": str(self.author.id), - "thread_type": "question", - "title": "Another Test Title", - "body": "More content", - "votes": {"up_count": 9}, - "comments_count": 18, - "created_at": "2015-04-28T22:22:22Z", - "updated_at": "2015-04-28T00:33:33Z", - }) - ] - expected_threads = [ - self.expected_thread_data({ - "id": "test_thread_id_0", - "author": self.author.username, - "topic_id": "topic_x", - "vote_count": 4, - "comment_count": 6, - "unread_comment_count": 3, - "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_0", - "editable_fields": ["abuse_flagged", "copy_link", "following", "read", "voted"], - "has_endorsed": True, - "read": True, - "created_at": "2015-04-28T00:00:00Z", - "updated_at": "2015-04-28T11:11:11Z", - "abuse_flagged_count": None, - "can_delete": False, - }), - self.expected_thread_data({ - "id": "test_thread_id_1", - "author": self.author.username, - "topic_id": "topic_y", - "group_id": self.cohort.id, - "group_name": self.cohort.name, - "type": "question", - "title": "Another Test Title", - "raw_body": "More content", - "preview_body": "More content", - "rendered_body": "

More content

", - "vote_count": 9, - "comment_count": 19, - "created_at": "2015-04-28T22:22:22Z", - "updated_at": "2015-04-28T00:33:33Z", - "comment_list_url": None, - "endorsed_comment_list_url": ( - "http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_1&endorsed=True" - ), - "non_endorsed_comment_list_url": ( - "http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_1&endorsed=False" - ), - "editable_fields": ["abuse_flagged", "copy_link", "following", "read", "voted"], - "abuse_flagged_count": None, - "can_delete": False, - }), - ] - - expected_result = make_paginated_api_response( - results=expected_threads, count=2, num_pages=1, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - assert self.get_thread_list(source_threads).data == expected_result - - @ddt.data( - *itertools.product( - [ - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - FORUM_ROLE_STUDENT, - FORUM_ROLE_GROUP_MODERATOR, - ], - [True, False] - ) - ) - @ddt.unpack - def test_request_group(self, role_name, course_is_cohorted): - cohort_course = CourseFactory.create(cohort_config={"cohorted": course_is_cohorted}) - CourseEnrollmentFactory.create(user=self.user, course_id=cohort_course.id) - CohortFactory.create(course_id=cohort_course.id, users=[self.user]) - _assign_role_to_user(user=self.user, course_id=cohort_course.id, role=role_name) - self.get_thread_list([], course=cohort_course) - actual_has_group = "group_id" in httpretty.last_request().querystring # lint-amnesty, pylint: disable=no-member - expected_has_group = (course_is_cohorted and - role_name in (FORUM_ROLE_STUDENT, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_GROUP_MODERATOR)) - assert actual_has_group == expected_has_group - - def test_pagination(self): - # N.B. Empty thread list is not realistic but convenient for this test - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=3, next_link="http://testserver/test_path?page=2", previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - assert self.get_thread_list([], page=1, num_pages=3).data == expected_result - - expected_result = make_paginated_api_response( - results=[], - count=0, - num_pages=3, - next_link="http://testserver/test_path?page=3", - previous_link="http://testserver/test_path?page=1" - ) - expected_result.update({"text_search_rewrite": None}) - assert self.get_thread_list([], page=2, num_pages=3).data == expected_result - - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=3, next_link=None, previous_link="http://testserver/test_path?page=2" - ) - expected_result.update({"text_search_rewrite": None}) - assert self.get_thread_list([], page=3, num_pages=3).data == expected_result - - # Test page past the last one - self.register_get_threads_response([], page=3, num_pages=3) - with pytest.raises(PageNotFoundError): - get_thread_list(self.request, self.course.id, page=4, page_size=10) - - @ddt.data(None, "rewritten search string") - def test_text_search(self, text_search_rewrite): - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": text_search_rewrite}) - self.register_get_threads_search_response([], text_search_rewrite, num_pages=0) - assert get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - text_search='test search string' - ).data == expected_result - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["10"], - "text": ["test search string"], - }) - - def test_filter_threads_by_author(self): - thread = make_minimal_cs_thread() - self.register_get_threads_response([thread], page=1, num_pages=10) - thread_results = get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - author=self.user.username, - ).data.get('results') - assert len(thread_results) == 1 - - expected_last_query_params = { - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["10"], - "author_id": [str(self.user.id)], - } - - self.assert_last_query_params(expected_last_query_params) - - def test_filter_threads_by_missing_author(self): - self.register_get_threads_response([make_minimal_cs_thread()], page=1, num_pages=10) - results = get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - author="a fake and missing username", - ).data.get('results') - assert len(results) == 0 - - @ddt.data('question', 'discussion', None) - def test_thread_type(self, thread_type): - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - - self.register_get_threads_response([], page=1, num_pages=0) - assert get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - thread_type=thread_type, - ).data == expected_result - - expected_last_query_params = { - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["10"], - "thread_type": [thread_type], - } - - if thread_type is None: - del expected_last_query_params["thread_type"] - - self.assert_last_query_params(expected_last_query_params) - - @ddt.data(True, False, None) - def test_flagged(self, flagged_boolean): - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - - self.register_get_threads_response([], page=1, num_pages=0) - assert get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - flagged=flagged_boolean, - ).data == expected_result - - expected_last_query_params = { - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["10"], - "flagged": [str(flagged_boolean)], - } - - if flagged_boolean is None: - del expected_last_query_params["flagged"] - - self.assert_last_query_params(expected_last_query_params) - - @ddt.data( - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - ) - def test_flagged_count(self, role): - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - - _assign_role_to_user(self.user, self.course.id, role=role) - - self.register_get_threads_response([], page=1, num_pages=0) - get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - count_flagged=True, - ) - - expected_last_query_params = { - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "count_flagged": ["True"], - "page": ["1"], - "per_page": ["10"], - } - - self.assert_last_query_params(expected_last_query_params) - - def test_flagged_count_denied(self): - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - - _assign_role_to_user(self.user, self.course.id, role=FORUM_ROLE_STUDENT) - - self.register_get_threads_response([], page=1, num_pages=0) - - with pytest.raises(PermissionDenied): - get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - count_flagged=True, - ) - - def test_following(self): - self.register_subscribed_threads_response(self.user, [], page=1, num_pages=0) - result = get_thread_list( - self.request, - self.course.id, - page=1, - page_size=11, - following=True, - ).data - - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - assert result == expected_result - assert urlparse( - httpretty.last_request().path # lint-amnesty, pylint: disable=no-member - ).path == f"/api/v1/users/{self.user.id}/subscribed_threads" - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["11"], - }) - - @ddt.data("unanswered", "unread") - def test_view_query(self, query): - self.register_get_threads_response([], page=1, num_pages=0) - result = get_thread_list( - self.request, - self.course.id, - page=1, - page_size=11, - view=query, - ).data - - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - assert result == expected_result - assert urlparse(httpretty.last_request().path).path == '/api/v1/threads' # lint-amnesty, pylint: disable=no-member - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["11"], - query: ["true"], - }) - - @ddt.data( - ("last_activity_at", "activity"), - ("comment_count", "comments"), - ("vote_count", "votes") - ) - @ddt.unpack - def test_order_by_query(self, http_query, cc_query): - """ - Tests the order_by parameter - - Arguments: - http_query (str): Query string sent in the http request - cc_query (str): Query string used for the comments client service - """ - self.register_get_threads_response([], page=1, num_pages=0) - result = get_thread_list( - self.request, - self.course.id, - page=1, - page_size=11, - order_by=http_query, - ).data - - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - assert result == expected_result - assert urlparse(httpretty.last_request().path).path == '/api/v1/threads' # lint-amnesty, pylint: disable=no-member - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": [cc_query], - "page": ["1"], - "per_page": ["11"], - }) - - def test_order_direction(self): - """ - Only "desc" is supported for order. Also, since it is simply swallowed, - it isn't included in the params. - """ - self.register_get_threads_response([], page=1, num_pages=0) - result = get_thread_list( - self.request, - self.course.id, - page=1, - page_size=11, - order_direction="desc", - ).data - - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - assert result == expected_result - assert urlparse(httpretty.last_request().path).path == '/api/v1/threads' # lint-amnesty, pylint: disable=no-member - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["11"], - }) - - def test_invalid_order_direction(self): - """ - Test with invalid order_direction (e.g. "asc") - """ - with pytest.raises(ValidationError) as assertion: - self.register_get_threads_response([], page=1, num_pages=0) - get_thread_list( # pylint: disable=expression-not-assigned - self.request, - self.course.id, - page=1, - page_size=11, - order_direction="asc", - ).data - assert 'order_direction' in assertion.value.message_dict - - @ddt.ddt @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) class GetCommentListTest(ForumsEnableMixin, CommentsServiceMockMixin, SharedModuleStoreTestCase): diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py index 4804c73a069f..4efadd63858b 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py @@ -92,6 +92,7 @@ from openedx.core.djangoapps.django_comment_common.models import ( FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_GROUP_MODERATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_STUDENT, Role, @@ -1054,3 +1055,639 @@ def test_vote_count_two_users( vote_count -= 1 assert result["vote_count"] == vote_count self.register_get_user_response(self.user, upvoted_ids=[]) + + +@ddt.ddt +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class GetThreadListTest( + ForumsEnableMixin, ForumMockUtilsMixin, UrlResetMixin, SharedModuleStoreTestCase +): + """Test for get_thread_list""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + cls.course = CourseFactory.create() + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def setUp(self): + super().setUp() + httpretty.reset() + httpretty.enable() + self.addCleanup(httpretty.reset) + self.addCleanup(httpretty.disable) + self.maxDiff = None # pylint: disable=invalid-name + self.user = UserFactory.create() + self.register_get_user_response(self.user) + self.request = RequestFactory().get("/test_path") + self.request.user = self.user + CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) + self.author = UserFactory.create() + self.course.cohort_config = {"cohorted": False} + modulestore().update_item(self.course, ModuleStoreEnum.UserID.test) + self.cohort = CohortFactory.create(course_id=self.course.id) + + def get_thread_list( + self, + threads, + page=1, + page_size=1, + num_pages=1, + course=None, + topic_id_list=None, + ): + """ + Register the appropriate comments service response, then call + get_thread_list and return the result. + """ + course = course or self.course + self.register_get_threads_response(threads, page, num_pages) + ret = get_thread_list(self.request, course.id, page, page_size, topic_id_list) + return ret + + def test_nonexistent_course(self): + with pytest.raises(CourseNotFoundError): + get_thread_list( + self.request, + CourseLocator.from_string("course-v1:non+existent+course"), + 1, + 1, + ) + + def test_not_enrolled(self): + self.request.user = UserFactory.create() + with pytest.raises(CourseNotFoundError): + self.get_thread_list([]) + + def test_discussions_disabled(self): + with pytest.raises(DiscussionDisabledError): + self.get_thread_list([], course=_discussion_disabled_course_for(self.user)) + + def test_empty(self): + assert self.get_thread_list([], num_pages=0).data == { + "pagination": {"next": None, "previous": None, "num_pages": 0, "count": 0}, + "results": [], + "text_search_rewrite": None, + } + + def test_get_threads_by_topic_id(self): + self.get_thread_list([], topic_id_list=["topic_x", "topic_meow"]) + self.check_mock_called("get_user_threads") + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 1, + "commentable_ids": ["topic_x", "topic_meow"], + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_basic_query_params(self): + self.get_thread_list([], page=6, page_size=14) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 6, + "per_page": 14, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_thread_content(self): + self.course.cohort_config = {"cohorted": True} + modulestore().update_item(self.course, ModuleStoreEnum.UserID.test) + source_threads = [ + make_minimal_cs_thread( + { + "id": "test_thread_id_0", + "course_id": str(self.course.id), + "commentable_id": "topic_x", + "username": self.author.username, + "user_id": str(self.author.id), + "title": "Test Title", + "body": "Test body", + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + "endorsed": True, + "read": True, + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + } + ), + make_minimal_cs_thread( + { + "id": "test_thread_id_1", + "course_id": str(self.course.id), + "commentable_id": "topic_y", + "group_id": self.cohort.id, + "username": self.author.username, + "user_id": str(self.author.id), + "thread_type": "question", + "title": "Another Test Title", + "body": "More content", + "votes": {"up_count": 9}, + "comments_count": 18, + "created_at": "2015-04-28T22:22:22Z", + "updated_at": "2015-04-28T00:33:33Z", + } + ), + ] + expected_threads = [ + self.expected_thread_data( + { + "id": "test_thread_id_0", + "author": self.author.username, + "topic_id": "topic_x", + "vote_count": 4, + "comment_count": 6, + "unread_comment_count": 3, + "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_0", + "editable_fields": [ + "abuse_flagged", + "copy_link", + "following", + "read", + "voted", + ], + "has_endorsed": True, + "read": True, + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + "abuse_flagged_count": None, + "can_delete": False, + } + ), + self.expected_thread_data( + { + "id": "test_thread_id_1", + "author": self.author.username, + "topic_id": "topic_y", + "group_id": self.cohort.id, + "group_name": self.cohort.name, + "type": "question", + "title": "Another Test Title", + "raw_body": "More content", + "preview_body": "More content", + "rendered_body": "

More content

", + "vote_count": 9, + "comment_count": 19, + "created_at": "2015-04-28T22:22:22Z", + "updated_at": "2015-04-28T00:33:33Z", + "comment_list_url": None, + "endorsed_comment_list_url": ( + "http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_1&endorsed=True" + ), + "non_endorsed_comment_list_url": ( + "http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_1&endorsed=False" + ), + "editable_fields": [ + "abuse_flagged", + "copy_link", + "following", + "read", + "voted", + ], + "abuse_flagged_count": None, + "can_delete": False, + } + ), + ] + + expected_result = make_paginated_api_response( + results=expected_threads, + count=2, + num_pages=1, + next_link=None, + previous_link=None, + ) + expected_result.update({"text_search_rewrite": None}) + assert self.get_thread_list(source_threads).data == expected_result + + @ddt.data( + *itertools.product( + [ + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_STUDENT, + ], + [True, False], + ) + ) + @ddt.unpack + def test_request_group(self, role_name, course_is_cohorted): + cohort_course = CourseFactory.create( + cohort_config={"cohorted": course_is_cohorted} + ) + CourseEnrollmentFactory.create(user=self.user, course_id=cohort_course.id) + CohortFactory.create(course_id=cohort_course.id, users=[self.user]) + _assign_role_to_user(user=self.user, course_id=cohort_course.id, role=role_name) + self.get_thread_list([], course=cohort_course) + thread_func_params = self.get_mock_func_calls("get_user_threads")[-1][1] + actual_has_group = "group_id" in thread_func_params + expected_has_group = ( + course_is_cohorted and role_name in ( + FORUM_ROLE_STUDENT, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_GROUP_MODERATOR + ) + ) + assert actual_has_group == expected_has_group + + def test_pagination(self): + # N.B. Empty thread list is not realistic but convenient for this test + expected_result = make_paginated_api_response( + results=[], + count=0, + num_pages=3, + next_link="http://testserver/test_path?page=2", + previous_link=None, + ) + expected_result.update({"text_search_rewrite": None}) + assert self.get_thread_list([], page=1, num_pages=3).data == expected_result + + expected_result = make_paginated_api_response( + results=[], + count=0, + num_pages=3, + next_link="http://testserver/test_path?page=3", + previous_link="http://testserver/test_path?page=1", + ) + expected_result.update({"text_search_rewrite": None}) + assert self.get_thread_list([], page=2, num_pages=3).data == expected_result + + expected_result = make_paginated_api_response( + results=[], + count=0, + num_pages=3, + next_link=None, + previous_link="http://testserver/test_path?page=2", + ) + expected_result.update({"text_search_rewrite": None}) + assert self.get_thread_list([], page=3, num_pages=3).data == expected_result + + # Test page past the last one + self.register_get_threads_response([], page=3, num_pages=3) + with pytest.raises(PageNotFoundError): + get_thread_list(self.request, self.course.id, page=4, page_size=10) + + @ddt.data(None, "rewritten search string") + def test_text_search(self, text_search_rewrite): + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": text_search_rewrite}) + self.register_get_threads_search_response([], text_search_rewrite, num_pages=0) + assert ( + get_thread_list( + self.request, + self.course.id, + page=1, + page_size=10, + text_search="test search string", + ).data + == expected_result + ) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + "text": "test search string", + } + self.check_mock_called_with( + "search_threads", + -1, + **params, + ) + + def test_filter_threads_by_author(self): + thread = make_minimal_cs_thread() + self.register_get_threads_response([thread], page=1, num_pages=10) + thread_results = get_thread_list( + self.request, + self.course.id, + page=1, + page_size=10, + author=self.user.username, + ).data.get("results") + assert len(thread_results) == 1 + + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + "author_id": str(self.user.id), + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_filter_threads_by_missing_author(self): + self.register_get_threads_response( + [make_minimal_cs_thread()], page=1, num_pages=10 + ) + results = get_thread_list( + self.request, + self.course.id, + page=1, + page_size=10, + author="a fake and missing username", + ).data.get("results") + assert len(results) == 0 + + @ddt.data("question", "discussion", None) + def test_thread_type(self, thread_type): + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + + self.register_get_threads_response([], page=1, num_pages=0) + assert ( + get_thread_list( + self.request, + self.course.id, + page=1, + page_size=10, + thread_type=thread_type, + ).data + == expected_result + ) + + expected_last_query_params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + "thread_type": thread_type, + } + + if thread_type is None: + del expected_last_query_params["thread_type"] + + self.check_mock_called_with( + "get_user_threads", + -1, + **expected_last_query_params, + ) + + @ddt.data(True, False, None) + def test_flagged(self, flagged_boolean): + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + + self.register_get_threads_response([], page=1, num_pages=0) + assert ( + get_thread_list( + self.request, + self.course.id, + page=1, + page_size=10, + flagged=flagged_boolean, + ).data + == expected_result + ) + + expected_last_query_params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + "flagged": flagged_boolean, + } + + if flagged_boolean is None: + del expected_last_query_params["flagged"] + + self.check_mock_called_with( + "get_user_threads", + -1, + **expected_last_query_params, + ) + + @ddt.data( + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + ) + def test_flagged_count(self, role): + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + + _assign_role_to_user(self.user, self.course.id, role=role) + + self.register_get_threads_response([], page=1, num_pages=0) + get_thread_list( + self.request, + self.course.id, + page=1, + page_size=10, + count_flagged=True, + ) + + expected_last_query_params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "count_flagged": True, + "page": 1, + "per_page": 10, + } + + self.check_mock_called_with( + "get_user_threads", -1, **expected_last_query_params + ) + + def test_flagged_count_denied(self): + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + + _assign_role_to_user(self.user, self.course.id, role=FORUM_ROLE_STUDENT) + + self.register_get_threads_response([], page=1, num_pages=0) + + with pytest.raises(PermissionDenied): + get_thread_list( + self.request, + self.course.id, + page=1, + page_size=10, + count_flagged=True, + ) + + def test_following(self): + self.register_subscribed_threads_response(self.user, [], page=1, num_pages=0) + result = get_thread_list( + self.request, + self.course.id, + page=1, + page_size=11, + following=True, + ).data + + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + assert result == expected_result + self.check_mock_called("get_user_subscriptions") + + params = { + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 11, + } + self.check_mock_called_with( + "get_user_subscriptions", -1, str(self.user.id), str(self.course.id), params + ) + + @ddt.data("unanswered", "unread") + def test_view_query(self, query): + self.register_get_threads_response([], page=1, num_pages=0) + result = get_thread_list( + self.request, + self.course.id, + page=1, + page_size=11, + view=query, + ).data + + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + assert result == expected_result + self.check_mock_called("get_user_threads") + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 11, + query: True, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + @ddt.data( + ("last_activity_at", "activity"), + ("comment_count", "comments"), + ("vote_count", "votes"), + ) + @ddt.unpack + def test_order_by_query(self, http_query, cc_query): + """ + Tests the order_by parameter + + Arguments: + http_query (str): Query string sent in the http request + cc_query (str): Query string used for the comments client service + """ + self.register_get_threads_response([], page=1, num_pages=0) + result = get_thread_list( + self.request, + self.course.id, + page=1, + page_size=11, + order_by=http_query, + ).data + + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + assert result == expected_result + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": cc_query, + "page": 1, + "per_page": 11, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_order_direction(self): + """ + Only "desc" is supported for order. Also, since it is simply swallowed, + it isn't included in the params. + """ + self.register_get_threads_response([], page=1, num_pages=0) + result = get_thread_list( + self.request, + self.course.id, + page=1, + page_size=11, + order_direction="desc", + ).data + + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + assert result == expected_result + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 11, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_invalid_order_direction(self): + """ + Test with invalid order_direction (e.g. "asc") + """ + with pytest.raises(ValidationError) as assertion: + self.register_get_threads_response([], page=1, num_pages=0) + get_thread_list( # pylint: disable=expression-not-assigned + self.request, + self.course.id, + page=1, + page_size=11, + order_direction="asc", + ).data + assert "order_direction" in assertion.value.message_dict diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views.py b/lms/djangoapps/discussion/rest_api/tests/test_views.py index 84efa95378d4..1df43ee4f983 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views.py @@ -1056,354 +1056,6 @@ def test_basic(self): assert vertical_keys == expected_non_courseware_keys -@ddt.ddt -@httpretty.activate -@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class ThreadViewSetListTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase, ProfileImageTestMixin): - """Tests for ThreadViewSet list""" - - def setUp(self): - super().setUp() - self.author = UserFactory.create() - self.url = reverse("thread-list") - patcher = mock.patch( - 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', - return_value=False - ) - patcher.start() - self.addCleanup(patcher.stop) - - def create_source_thread(self, overrides=None): - """ - Create a sample source cs_thread - """ - thread = make_minimal_cs_thread({ - "id": "test_thread", - "course_id": str(self.course.id), - "commentable_id": "test_topic", - "user_id": str(self.user.id), - "username": self.user.username, - "created_at": "2015-04-28T00:00:00Z", - "updated_at": "2015-04-28T11:11:11Z", - "title": "Test Title", - "body": "Test body", - "votes": {"up_count": 4}, - "comments_count": 5, - "unread_comments_count": 3, - }) - - thread.update(overrides or {}) - return thread - - def test_course_id_missing(self): - response = self.client.get(self.url) - self.assert_response_correct( - response, - 400, - {"field_errors": {"course_id": {"developer_message": "This field is required."}}} - ) - - def test_404(self): - response = self.client.get(self.url, {"course_id": "non/existent/course"}) - self.assert_response_correct( - response, - 404, - {"developer_message": "Course not found."} - ) - - def test_basic(self): - self.register_get_user_response(self.user, upvoted_ids=["test_thread"]) - source_threads = [ - self.create_source_thread({"user_id": str(self.author.id), "username": self.author.username}) - ] - expected_threads = [self.expected_thread_data({ - "created_at": "2015-04-28T00:00:00Z", - "updated_at": "2015-04-28T11:11:11Z", - "vote_count": 4, - "comment_count": 6, - "can_delete": False, - "unread_comment_count": 3, - "voted": True, - "author": self.author.username, - "editable_fields": ["abuse_flagged", "copy_link", "following", "read", "voted"], - "abuse_flagged_count": None, - })] - self.register_get_threads_response(source_threads, page=1, num_pages=2) - response = self.client.get(self.url, {"course_id": str(self.course.id), "following": ""}) - expected_response = make_paginated_api_response( - results=expected_threads, - count=1, - num_pages=2, - next_link="http://testserver/api/discussion/v1/threads/?course_id=course-v1%3Ax%2By%2Bz&following=&page=2", - previous_link=None - ) - expected_response.update({"text_search_rewrite": None}) - self.assert_response_correct( - response, - 200, - expected_response - ) - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["10"], - }) - - @ddt.data("unread", "unanswered", "unresponded") - def test_view_query(self, query): - threads = [make_minimal_cs_thread()] - self.register_get_user_response(self.user) - self.register_get_threads_response(threads, page=1, num_pages=1) - self.client.get( - self.url, - { - "course_id": str(self.course.id), - "view": query, - } - ) - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["10"], - query: ["true"], - }) - - def test_pagination(self): - self.register_get_user_response(self.user) - self.register_get_threads_response([], page=1, num_pages=1) - response = self.client.get( - self.url, - {"course_id": str(self.course.id), "page": "18", "page_size": "4"} - ) - self.assert_response_correct( - response, - 404, - {"developer_message": "Page not found (No results on this page)."} - ) - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["18"], - "per_page": ["4"], - }) - - def test_text_search(self): - self.register_get_user_response(self.user) - self.register_get_threads_search_response([], None, num_pages=0) - response = self.client.get( - self.url, - {"course_id": str(self.course.id), "text_search": "test search string"} - ) - - expected_response = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_response.update({"text_search_rewrite": None}) - self.assert_response_correct( - response, - 200, - expected_response - ) - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["10"], - "text": ["test search string"], - }) - - @ddt.data(True, "true", "1") - def test_following_true(self, following): - self.register_get_user_response(self.user) - self.register_subscribed_threads_response(self.user, [], page=1, num_pages=0) - response = self.client.get( - self.url, - { - "course_id": str(self.course.id), - "following": following, - } - ) - - expected_response = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_response.update({"text_search_rewrite": None}) - self.assert_response_correct( - response, - 200, - expected_response - ) - assert urlparse( - httpretty.last_request().path # lint-amnesty, pylint: disable=no-member - ).path == f"/api/v1/users/{self.user.id}/subscribed_threads" - - @ddt.data(False, "false", "0") - def test_following_false(self, following): - response = self.client.get( - self.url, - { - "course_id": str(self.course.id), - "following": following, - } - ) - self.assert_response_correct( - response, - 400, - {"field_errors": { - "following": {"developer_message": "The value of the 'following' parameter must be true."} - }} - ) - - def test_following_error(self): - response = self.client.get( - self.url, - { - "course_id": str(self.course.id), - "following": "invalid-boolean", - } - ) - self.assert_response_correct( - response, - 400, - {"field_errors": { - "following": {"developer_message": "Invalid Boolean Value."} - }} - ) - - @ddt.data( - ("last_activity_at", "activity"), - ("comment_count", "comments"), - ("vote_count", "votes") - ) - @ddt.unpack - def test_order_by(self, http_query, cc_query): - """ - Tests the order_by parameter - - Arguments: - http_query (str): Query string sent in the http request - cc_query (str): Query string used for the comments client service - """ - threads = [make_minimal_cs_thread()] - self.register_get_user_response(self.user) - self.register_get_threads_response(threads, page=1, num_pages=1) - self.client.get( - self.url, - { - "course_id": str(self.course.id), - "order_by": http_query, - } - ) - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "page": ["1"], - "per_page": ["10"], - "sort_key": [cc_query], - }) - - def test_order_direction(self): - """ - Test order direction, of which "desc" is the only valid option. The - option actually just gets swallowed, so it doesn't affect the params. - """ - threads = [make_minimal_cs_thread()] - self.register_get_user_response(self.user) - self.register_get_threads_response(threads, page=1, num_pages=1) - self.client.get( - self.url, - { - "course_id": str(self.course.id), - "order_direction": "desc", - } - ) - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["10"], - }) - - def test_mutually_exclusive(self): - """ - Tests GET thread_list api does not allow filtering on mutually exclusive parameters - """ - self.register_get_user_response(self.user) - self.register_get_threads_search_response([], None, num_pages=0) - response = self.client.get(self.url, { - "course_id": str(self.course.id), - "text_search": "test search string", - "topic_id": "topic1, topic2", - }) - self.assert_response_correct( - response, - 400, - { - "developer_message": "The following query parameters are mutually exclusive: topic_id, " - "text_search, following" - } - ) - - def test_profile_image_requested_field(self): - """ - Tests thread has user profile image details if called in requested_fields - """ - user_2 = UserFactory.create(password=self.password) - # Ensure that parental controls don't apply to this user - user_2.profile.year_of_birth = 1970 - user_2.profile.save() - source_threads = [ - self.create_source_thread(), - self.create_source_thread({"user_id": str(user_2.id), "username": user_2.username}), - ] - - self.register_get_user_response(self.user, upvoted_ids=["test_thread"]) - self.register_get_threads_response(source_threads, page=1, num_pages=1) - self.create_profile_image(self.user, get_profile_image_storage()) - self.create_profile_image(user_2, get_profile_image_storage()) - - response = self.client.get( - self.url, - {"course_id": str(self.course.id), "requested_fields": "profile_image"}, - ) - assert response.status_code == 200 - response_threads = json.loads(response.content.decode('utf-8'))['results'] - - for response_thread in response_threads: - expected_profile_data = self.get_expected_user_profile(response_thread['author']) - response_users = response_thread['users'] - assert expected_profile_data == response_users[response_thread['author']] - - def test_profile_image_requested_field_anonymous_user(self): - """ - Tests profile_image in requested_fields for thread created with anonymous user - """ - source_threads = [ - self.create_source_thread( - {"user_id": None, "username": None, "anonymous": True, "anonymous_to_peers": True} - ), - ] - - self.register_get_user_response(self.user, upvoted_ids=["test_thread"]) - self.register_get_threads_response(source_threads, page=1, num_pages=1) - - response = self.client.get( - self.url, - {"course_id": str(self.course.id), "requested_fields": "profile_image"}, - ) - assert response.status_code == 200 - response_thread = json.loads(response.content.decode('utf-8'))['results'][0] - assert response_thread['author'] is None - assert {} == response_thread['users'] - - @httpretty.activate @disable_signal(api, 'thread_created') @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py index 29e469c9a9a2..9c41c11b24f7 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py @@ -21,7 +21,9 @@ from django.test import override_settings from django.urls import reverse from edx_toggles.toggles.testutils import override_waffle_flag -from lms.djangoapps.discussion.django_comment_client.tests.mixins import MockForumApiMixin +from lms.djangoapps.discussion.django_comment_client.tests.mixins import ( + MockForumApiMixin, +) from opaque_keys.edx.keys import CourseKey from pytz import UTC from rest_framework import status @@ -32,18 +34,32 @@ from lms.djangoapps.discussion.rest_api.utils import get_usernames_from_search_string from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory, check_mongo_calls +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, + SharedModuleStoreTestCase, +) +from xmodule.modulestore.tests.factories import ( + CourseFactory, + BlockFactory, + check_mongo_calls, +) from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.course_modes.tests.factories import CourseModeFactory -from common.djangoapps.student.models import get_retired_username_by_username, CourseEnrollment -from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole, GlobalStaff +from common.djangoapps.student.models import ( + get_retired_username_by_username, + CourseEnrollment, +) +from common.djangoapps.student.roles import ( + CourseInstructorRole, + CourseStaffRole, + GlobalStaff, +) from common.djangoapps.student.tests.factories import ( AdminFactory, CourseEnrollmentFactory, SuperuserFactory, - UserFactory + UserFactory, ) from common.djangoapps.util.testing import PatchMediaTypeMixin, UrlResetMixin from common.test.utils import disable_signal @@ -65,15 +81,34 @@ parsed_body, ) from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts -from openedx.core.djangoapps.discussions.config.waffle import ENABLE_NEW_STRUCTURE_DISCUSSIONS -from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, DiscussionTopicLink, Provider -from openedx.core.djangoapps.discussions.tasks import update_discussions_settings_from_course_task -from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings, Role +from openedx.core.djangoapps.discussions.config.waffle import ( + ENABLE_NEW_STRUCTURE_DISCUSSIONS, +) +from openedx.core.djangoapps.discussions.models import ( + DiscussionsConfiguration, + DiscussionTopicLink, + Provider, +) +from openedx.core.djangoapps.discussions.tasks import ( + update_discussions_settings_from_course_task, +) +from openedx.core.djangoapps.django_comment_common.models import ( + CourseDiscussionSettings, + Role, +) from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user -from openedx.core.djangoapps.oauth_dispatch.tests.factories import AccessTokenFactory, ApplicationFactory -from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_storage -from openedx.core.djangoapps.user_api.models import RetirementState, UserRetirementStatus +from openedx.core.djangoapps.oauth_dispatch.tests.factories import ( + AccessTokenFactory, + ApplicationFactory, +) +from openedx.core.djangoapps.user_api.accounts.image_helpers import ( + get_profile_image_storage, +) +from openedx.core.djangoapps.user_api.models import ( + RetirementState, + UserRetirementStatus, +) class DiscussionAPIViewTestMixin(ForumsEnableMixin, ForumMockUtilsMixin, UrlResetMixin): @@ -86,7 +121,9 @@ class DiscussionAPIViewTestMixin(ForumsEnableMixin, ForumMockUtilsMixin, UrlRese client_class = APIClient - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) def setUp(self): super().setUp() self.maxDiff = None # pylint: disable=invalid-name @@ -95,7 +132,7 @@ def setUp(self): course="y", run="z", start=datetime.now(UTC), - discussion_topics={"Test Topic": {"id": "test_topic"}} + discussion_topics={"Test Topic": {"id": "test_topic"}}, ) self.password = "Password1234" self.user = UserFactory.create(password=self.password) @@ -120,23 +157,25 @@ 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')) + parsed_content = json.loads(response.content.decode("utf-8")) assert parsed_content == expected_content def register_thread(self, overrides=None): """ Create cs_thread with minimal fields and register response """ - cs_thread = make_minimal_cs_thread({ - "id": "test_thread", - "course_id": str(self.course.id), - "commentable_id": "test_topic", - "username": self.user.username, - "user_id": str(self.user.id), - "thread_type": "discussion", - "title": "Test Title", - "body": "Test body", - }) + cs_thread = make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "username": self.user.username, + "user_id": str(self.user.id), + "thread_type": "discussion", + "title": "Test Title", + "body": "Test body", + } + ) cs_thread.update(overrides or {}) self.register_get_thread_response(cs_thread) self.register_put_thread_response(cs_thread) @@ -145,14 +184,16 @@ 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 = make_minimal_cs_comment( + { + "id": "test_comment", + "course_id": str(self.course.id), + "thread_id": "test_thread", + "username": self.user.username, + "user_id": str(self.user.id), + "body": "Original body", + } + ) cs_comment.update(overrides or {}) self.register_get_comment_response(cs_comment) self.register_put_comment_response(cs_comment) @@ -164,7 +205,7 @@ def test_not_authenticated(self): self.assert_response_correct( response, 401, - {"developer_message": "Authentication credentials were not provided."} + {"developer_message": "Authentication credentials were not provided."}, ) def test_inactive(self): @@ -174,9 +215,11 @@ def test_inactive(self): @ddt.ddt @httpretty.activate -@disable_signal(api, 'thread_edited') +@disable_signal(api, "thread_edited") @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class ThreadViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin): +class ThreadViewSetPartialUpdateTest( + DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin +): """Tests for ThreadViewSet partial_update""" def setUp(self): @@ -186,47 +229,58 @@ def setUp(self): def test_basic(self): self.register_get_user_response(self.user) - self.register_thread({ - "created_at": "Test Created Date", - "updated_at": "Test Updated Date", - "read": True, - "resp_total": 2, - }) + self.register_thread( + { + "created_at": "Test Created Date", + "updated_at": "Test Updated Date", + "read": True, + "resp_total": 2, + } + ) request_data = {"raw_body": "Edited body"} response = self.request_patch(request_data) assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data == self.expected_thread_data({ - 'raw_body': 'Edited body', - 'rendered_body': '

Edited body

', - 'preview_body': 'Edited body', - 'editable_fields': [ - 'abuse_flagged', 'anonymous', 'copy_link', 'following', 'raw_body', 'read', - 'title', 'topic_id', 'type' - ], - 'created_at': 'Test Created Date', - 'updated_at': 'Test Updated Date', - 'comment_count': 1, - 'read': True, - 'response_count': 2, - }) + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == self.expected_thread_data( + { + "raw_body": "Edited body", + "rendered_body": "

Edited body

", + "preview_body": "Edited body", + "editable_fields": [ + "abuse_flagged", + "anonymous", + "copy_link", + "following", + "raw_body", + "read", + "title", + "topic_id", + "type", + ], + "created_at": "Test Created Date", + "updated_at": "Test Updated Date", + "comment_count": 1, + "read": True, + "response_count": 2, + } + ) params = { - 'thread_id': 'test_thread', - 'course_id': str(self.course.id), - 'commentable_id': 'test_topic', - 'thread_type': 'discussion', - 'title': 'Test Title', - 'body': 'Edited body', - 'user_id': str(self.user.id), - 'anonymous': False, - 'anonymous_to_peers': False, - 'closed': False, - 'pinned': False, - 'read': True, - 'editing_user_id': str(self.user.id), + "thread_id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "thread_type": "discussion", + "title": "Test Title", + "body": "Edited body", + "user_id": str(self.user.id), + "anonymous": False, + "anonymous_to_peers": False, + "closed": False, + "pinned": False, + "read": True, + "editing_user_id": str(self.user.id), } - self.check_mock_called_with('update_thread', -1, **params) + self.check_mock_called_with("update_thread", -1, **params) def test_error(self): self.register_get_user_response(self.user) @@ -234,10 +288,12 @@ def test_error(self): request_data = {"title": ""} response = self.request_patch(request_data) expected_response_data = { - "field_errors": {"title": {"developer_message": "This field may not be blank."}} + "field_errors": { + "title": {"developer_message": "This field may not be blank."} + } } assert response.status_code == 400 - response_data = json.loads(response.content.decode('utf-8')) + response_data = json.loads(response.content.decode("utf-8")) assert response_data == expected_response_data @ddt.data( @@ -252,14 +308,17 @@ def test_closed_thread(self, field, value): request_data = {field: value} response = self.request_patch(request_data) assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data == self.expected_thread_data({ - 'read': True, - 'closed': True, - 'abuse_flagged': value, - 'editable_fields': ['abuse_flagged', 'copy_link', 'read'], - 'comment_count': 1, 'unread_comment_count': 0 - }) + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == self.expected_thread_data( + { + "read": True, + "closed": True, + "abuse_flagged": value, + "editable_fields": ["abuse_flagged", "copy_link", "read"], + "comment_count": 1, + "unread_comment_count": 0, + } + ) @ddt.data( ("raw_body", "Edited body"), @@ -283,47 +342,68 @@ def test_patch_read_owner_user(self): response = self.request_patch(request_data) assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data == self.expected_thread_data({ - 'comment_count': 1, - 'read': True, - 'editable_fields': [ - 'abuse_flagged', 'anonymous', 'copy_link', 'following', 'raw_body', 'read', - 'title', 'topic_id', 'type' - ], - 'response_count': 2 - }) + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == self.expected_thread_data( + { + "comment_count": 1, + "read": True, + "editable_fields": [ + "abuse_flagged", + "anonymous", + "copy_link", + "following", + "raw_body", + "read", + "title", + "topic_id", + "type", + ], + "response_count": 2, + } + ) def test_patch_read_non_owner_user(self): self.register_get_user_response(self.user) thread_owner_user = UserFactory.create(password=self.password) CourseEnrollmentFactory.create(user=thread_owner_user, course_id=self.course.id) - self.register_thread({ - "username": thread_owner_user.username, - "user_id": str(thread_owner_user.id), - "resp_total": 2, - }) + self.register_thread( + { + "username": thread_owner_user.username, + "user_id": str(thread_owner_user.id), + "resp_total": 2, + } + ) self.register_read_response(self.user, "thread", "test_thread") request_data = {"read": True} response = self.request_patch(request_data) assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - expected_data = self.expected_thread_data({ - 'author': str(thread_owner_user.username), - 'comment_count': 1, - 'can_delete': False, - 'read': True, - 'editable_fields': ['abuse_flagged', 'copy_link', 'following', 'read', 'voted'], - 'response_count': 2 - }) + response_data = json.loads(response.content.decode("utf-8")) + expected_data = self.expected_thread_data( + { + "author": str(thread_owner_user.username), + "comment_count": 1, + "can_delete": False, + "read": True, + "editable_fields": [ + "abuse_flagged", + "copy_link", + "following", + "read", + "voted", + ], + "response_count": 2, + } + ) assert response_data == expected_data @ddt.ddt -@disable_signal(api, 'comment_edited') +@disable_signal(api, "comment_edited") @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class CommentViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin): +class CommentViewSetPartialUpdateTest( + DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin +): """Tests for CommentViewSet partial_update""" def setUp(self): @@ -375,29 +455,33 @@ def expected_response_data(self, overrides=None): def test_basic(self): self.register_thread() - self.register_comment({"created_at": "Test Created Date", "updated_at": "Test Updated Date"}) + self.register_comment( + {"created_at": "Test Created Date", "updated_at": "Test Updated Date"} + ) request_data = {"raw_body": "Edited body"} response = self.request_patch(request_data) assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data == self.expected_response_data({ - 'raw_body': 'Edited body', - 'rendered_body': '

Edited body

', - 'editable_fields': ['abuse_flagged', 'anonymous', 'raw_body'], - 'created_at': 'Test Created Date', - 'updated_at': 'Test Updated Date' - }) + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == self.expected_response_data( + { + "raw_body": "Edited body", + "rendered_body": "

Edited body

", + "editable_fields": ["abuse_flagged", "anonymous", "raw_body"], + "created_at": "Test Created Date", + "updated_at": "Test Updated Date", + } + ) params = { - 'comment_id': 'test_comment', - 'body': 'Edited body', - 'course_id': str(self.course.id), - 'user_id': str(self.user.id), - 'anonymous': False, - 'anonymous_to_peers': False, - 'endorsed': False, - 'editing_user_id': str(self.user.id), + "comment_id": "test_comment", + "body": "Edited body", + "course_id": str(self.course.id), + "user_id": str(self.user.id), + "anonymous": False, + "anonymous_to_peers": False, + "endorsed": False, + "editing_user_id": str(self.user.id), } - self.check_mock_called_with('update_comment', -1, **params) + self.check_mock_called_with("update_comment", -1, **params) def test_error(self): self.register_thread() @@ -405,10 +489,12 @@ def test_error(self): request_data = {"raw_body": ""} response = self.request_patch(request_data) expected_response_data = { - "field_errors": {"raw_body": {"developer_message": "This field may not be blank."}} + "field_errors": { + "raw_body": {"developer_message": "This field may not be blank."} + } } assert response.status_code == 400 - response_data = json.loads(response.content.decode('utf-8')) + response_data = json.loads(response.content.decode("utf-8")) assert response_data == expected_response_data @ddt.data( @@ -423,12 +509,14 @@ def test_closed_thread(self, field, value): request_data = {field: value} response = self.request_patch(request_data) assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data == self.expected_response_data({ - 'abuse_flagged': value, - "abuse_flagged_any_user": None, - 'editable_fields': ['abuse_flagged'] - }) + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == self.expected_response_data( + { + "abuse_flagged": value, + "abuse_flagged_any_user": None, + "editable_fields": ["abuse_flagged"], + } + ) @ddt.data( ("raw_body", "Edited body"), @@ -442,3 +530,396 @@ def test_closed_thread_error(self, field, value): request_data = {field: value} response = self.request_patch(request_data) assert response.status_code == 400 + + +@ddt.ddt +@httpretty.activate +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class ThreadViewSetListTest( + DiscussionAPIViewTestMixin, ModuleStoreTestCase, ProfileImageTestMixin +): + """Tests for ThreadViewSet list""" + + def setUp(self): + super().setUp() + self.author = UserFactory.create() + self.url = reverse("thread-list") + + def create_source_thread(self, overrides=None): + """ + Create a sample source cs_thread + """ + thread = make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(self.user.id), + "username": self.user.username, + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + "title": "Test Title", + "body": "Test body", + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + } + ) + + thread.update(overrides or {}) + return thread + + def test_course_id_missing(self): + response = self.client.get(self.url) + self.assert_response_correct( + response, + 400, + {"field_errors": {"course_id": {"developer_message": "This field is required."}}} + ) + + def test_404(self): + response = self.client.get(self.url, {"course_id": "non/existent/course"}) + self.assert_response_correct( + response, + 404, + {"developer_message": "Course not found."} + ) + + def test_basic(self): + self.register_get_user_response(self.user, upvoted_ids=["test_thread"]) + source_threads = [ + self.create_source_thread( + {"user_id": str(self.author.id), "username": self.author.username} + ) + ] + expected_threads = [ + self.expected_thread_data( + { + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + "vote_count": 4, + "comment_count": 6, + "can_delete": False, + "unread_comment_count": 3, + "voted": True, + "author": self.author.username, + "editable_fields": [ + "abuse_flagged", + "copy_link", + "following", + "read", + "voted", + ], + "abuse_flagged_count": None, + } + ) + ] + self.register_get_threads_response(source_threads, page=1, num_pages=2) + response = self.client.get( + self.url, {"course_id": str(self.course.id), "following": ""} + ) + expected_response = make_paginated_api_response( + results=expected_threads, + count=1, + num_pages=2, + next_link="http://testserver/api/discussion/v1/threads/?course_id=course-v1%3Ax%2By%2Bz&following=&page=2", + previous_link=None, + ) + expected_response.update({"text_search_rewrite": None}) + self.assert_response_correct(response, 200, expected_response) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + @ddt.data("unread", "unanswered", "unresponded") + def test_view_query(self, query): + threads = [make_minimal_cs_thread()] + self.register_get_user_response(self.user) + self.register_get_threads_response(threads, page=1, num_pages=1) + self.client.get( + self.url, + { + "course_id": str(self.course.id), + "view": query, + }, + ) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + query: True, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_pagination(self): + self.register_get_user_response(self.user) + self.register_get_threads_response([], page=1, num_pages=1) + response = self.client.get( + self.url, {"course_id": str(self.course.id), "page": "18", "page_size": "4"} + ) + self.assert_response_correct( + response, + 404, + {"developer_message": "Page not found (No results on this page)."}, + ) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 18, + "per_page": 4, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_text_search(self): + self.register_get_user_response(self.user) + self.register_get_threads_search_response([], None, num_pages=0) + response = self.client.get( + self.url, + {"course_id": str(self.course.id), "text_search": "test search string"}, + ) + + expected_response = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_response.update({"text_search_rewrite": None}) + self.assert_response_correct(response, 200, expected_response) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + "text": "test search string", + } + self.check_mock_called_with( + "search_threads", + -1, + **params, + ) + + @ddt.data(True, "true", "1") + def test_following_true(self, following): + self.register_get_user_response(self.user) + self.register_subscribed_threads_response(self.user, [], page=1, num_pages=0) + response = self.client.get( + self.url, + { + "course_id": str(self.course.id), + "following": following, + }, + ) + + expected_response = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_response.update({"text_search_rewrite": None}) + self.assert_response_correct(response, 200, expected_response) + self.check_mock_called("get_user_subscriptions") + + @ddt.data(False, "false", "0") + def test_following_false(self, following): + response = self.client.get( + self.url, + { + "course_id": str(self.course.id), + "following": following, + }, + ) + self.assert_response_correct( + response, + 400, + { + "field_errors": { + "following": { + "developer_message": "The value of the 'following' parameter must be true." + } + } + }, + ) + + def test_following_error(self): + response = self.client.get( + self.url, + { + "course_id": str(self.course.id), + "following": "invalid-boolean", + }, + ) + self.assert_response_correct( + response, + 400, + { + "field_errors": { + "following": {"developer_message": "Invalid Boolean Value."} + } + }, + ) + + @ddt.data( + ("last_activity_at", "activity"), + ("comment_count", "comments"), + ("vote_count", "votes"), + ) + @ddt.unpack + def test_order_by(self, http_query, cc_query): + """ + Tests the order_by parameter + + Arguments: + http_query (str): Query string sent in the http request + cc_query (str): Query string used for the comments client service + """ + threads = [make_minimal_cs_thread()] + self.register_get_user_response(self.user) + self.register_get_threads_response(threads, page=1, num_pages=1) + self.client.get( + self.url, + { + "course_id": str(self.course.id), + "order_by": http_query, + }, + ) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "page": 1, + "per_page": 10, + "sort_key": cc_query, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_order_direction(self): + """ + Test order direction, of which "desc" is the only valid option. The + option actually just gets swallowed, so it doesn't affect the params. + """ + threads = [make_minimal_cs_thread()] + self.register_get_user_response(self.user) + self.register_get_threads_response(threads, page=1, num_pages=1) + self.client.get( + self.url, + { + "course_id": str(self.course.id), + "order_direction": "desc", + }, + ) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_mutually_exclusive(self): + """ + Tests GET thread_list api does not allow filtering on mutually exclusive parameters + """ + self.register_get_user_response(self.user) + self.register_get_threads_search_response([], None, num_pages=0) + response = self.client.get( + self.url, + { + "course_id": str(self.course.id), + "text_search": "test search string", + "topic_id": "topic1, topic2", + }, + ) + self.assert_response_correct( + response, + 400, + { + "developer_message": "The following query parameters are mutually exclusive: topic_id, " + "text_search, following" + }, + ) + + def test_profile_image_requested_field(self): + """ + Tests thread has user profile image details if called in requested_fields + """ + user_2 = UserFactory.create(password=self.password) + # Ensure that parental controls don't apply to this user + user_2.profile.year_of_birth = 1970 + user_2.profile.save() + source_threads = [ + self.create_source_thread(), + self.create_source_thread( + {"user_id": str(user_2.id), "username": user_2.username} + ), + ] + + self.register_get_user_response(self.user, upvoted_ids=["test_thread"]) + self.register_get_threads_response(source_threads, page=1, num_pages=1) + self.create_profile_image(self.user, get_profile_image_storage()) + self.create_profile_image(user_2, get_profile_image_storage()) + + response = self.client.get( + self.url, + {"course_id": str(self.course.id), "requested_fields": "profile_image"}, + ) + assert response.status_code == 200 + response_threads = json.loads(response.content.decode("utf-8"))["results"] + + for response_thread in response_threads: + expected_profile_data = self.get_expected_user_profile( + response_thread["author"] + ) + response_users = response_thread["users"] + assert expected_profile_data == response_users[response_thread["author"]] + + def test_profile_image_requested_field_anonymous_user(self): + """ + Tests profile_image in requested_fields for thread created with anonymous user + """ + source_threads = [ + self.create_source_thread( + { + "user_id": None, + "username": None, + "anonymous": True, + "anonymous_to_peers": True, + } + ), + ] + + self.register_get_user_response(self.user, upvoted_ids=["test_thread"]) + self.register_get_threads_response(source_threads, page=1, num_pages=1) + + response = self.client.get( + self.url, + {"course_id": str(self.course.id), "requested_fields": "profile_image"}, + ) + assert response.status_code == 200 + response_thread = json.loads(response.content.decode("utf-8"))["results"][0] + assert response_thread["author"] is None + assert {} == response_thread["users"] diff --git a/lms/djangoapps/discussion/tests/test_views.py b/lms/djangoapps/discussion/tests/test_views.py index facdb368f14f..8bb0b45500a4 100644 --- a/lms/djangoapps/discussion/tests/test_views.py +++ b/lms/djangoapps/discussion/tests/test_views.py @@ -17,8 +17,6 @@ from django.utils import translation from edx_django_utils.cache import RequestCache from edx_toggles.toggles.testutils import override_waffle_flag -from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import ( TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase, @@ -27,7 +25,6 @@ from xmodule.modulestore.tests.factories import ( CourseFactory, BlockFactory, - check_mongo_calls ) from common.djangoapps.course_modes.models import CourseMode @@ -42,7 +39,6 @@ from lms.djangoapps.discussion.django_comment_client.tests.group_id import ( CohortedTopicGroupIdTestMixin, GroupIdAssertionMixin, - NonCohortedTopicGroupIdTestMixin ) from lms.djangoapps.discussion.django_comment_client.tests.unicode import UnicodeTestMixin from lms.djangoapps.discussion.django_comment_client.tests.utils import ( @@ -55,7 +51,6 @@ from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE from lms.djangoapps.discussion.views import _get_discussion_default_topic_id, course_discussions_settings_handler from lms.djangoapps.teams.tests.factories import CourseTeamFactory, CourseTeamMembershipFactory -from openedx.core.djangoapps.course_groups.models import CourseUserGroup from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts from openedx.core.djangoapps.course_groups.tests.test_views import CohortViewsTestCase from openedx.core.djangoapps.django_comment_common.comment_client.utils import CommentClientPaginatedResult @@ -68,8 +63,6 @@ from openedx.core.djangoapps.util.testing import ContentGroupTestCase from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES from openedx.core.lib.teams_config import TeamsConfig -from openedx.features.content_type_gating.models import ContentTypeGatingConfig -from openedx.features.enterprise_support.tests.mixins.enterprise import EnterpriseTestConsentRequired log = logging.getLogger(__name__) @@ -529,93 +522,6 @@ 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 @@ -868,92 +774,6 @@ def test_group_info_in_ajax_response(self, mock_request): ) -@patch('requests.request', autospec=True) -class ForumFormDiscussionContentGroupTestCase(ForumsEnableMixin, ContentGroupTestCase): - """ - Tests `forum_form_discussion api` works with different content groups. - Discussion blocks are setup in ContentGroupTestCase class i.e - alpha_block => alpha_group_discussion => alpha_cohort => alpha_user/community_ta - beta_block => beta_group_discussion => beta_cohort => beta_user - """ - - @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - super().setUp() - self.thread_list = [ - {"thread_id": "test_general_thread_id"}, - {"thread_id": "test_global_group_thread_id", "commentable_id": self.global_block.discussion_id}, - {"thread_id": "test_alpha_group_thread_id", "group_id": self.alpha_block.group_access[0][0], - "commentable_id": self.alpha_block.discussion_id}, - {"thread_id": "test_beta_group_thread_id", "group_id": self.beta_block.group_access[0][0], - "commentable_id": self.beta_block.discussion_id} - ] - - def assert_has_access(self, response, expected_discussion_threads): - """ - Verify that a users have access to the threads in their assigned - cohorts and non-cohorted blocks. - """ - discussion_data = json.loads(response.content.decode('utf-8'))['discussion_data'] - assert len(discussion_data) == expected_discussion_threads - - def call_view(self, mock_request, user): # lint-amnesty, pylint: disable=missing-function-docstring - mock_request.side_effect = make_mock_request_impl( - course=self.course, - text="dummy content", - thread_list=self.thread_list - ) - self.client.login(username=user.username, password=self.TEST_PASSWORD) - return self.client.get( - reverse("forum_form_discussion", args=[str(self.course.id)]), - HTTP_X_REQUESTED_WITH="XMLHttpRequest" - ) - - def test_community_ta_user(self, mock_request): - """ - Verify that community_ta user has access to all threads regardless - of cohort. - """ - response = self.call_view( - mock_request, - self.community_ta - ) - self.assert_has_access(response, 4) - - def test_alpha_cohort_user(self, mock_request): - """ - Verify that alpha_user has access to alpha_cohort and non-cohorted - threads. - """ - response = self.call_view( - mock_request, - self.alpha_user - ) - self.assert_has_access(response, 3) - - def test_beta_cohort_user(self, mock_request): - """ - Verify that beta_user has access to beta_cohort and non-cohorted - threads. - """ - response = self.call_view( - mock_request, - self.beta_user - ) - self.assert_has_access(response, 3) - - def test_global_staff_user(self, mock_request): - """ - Verify that global staff user has access to all threads regardless - of cohort. - """ - response = self.call_view( - mock_request, - self.staff_user - ) - self.assert_has_access(response, 4) - - @patch('requests.request', autospec=True) class SingleThreadContentGroupTestCase(ForumsEnableMixin, UrlResetMixin, ContentGroupTestCase): # lint-amnesty, pylint: disable=missing-class-docstring @@ -1080,78 +900,13 @@ def test_standalone_context_respected(self, mock_request): 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" +class FollowedThreadsDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGroupIdTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring + cs_endpoint = "/subscribed_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" ) @@ -1168,16 +923,9 @@ def call_view( pass_group_id=True ): # pylint: disable=arguments-differ mock_is_forum_v2_enabled.return_value = False - kwargs = {'commentable_id': self.cohorted_commentable_id} + kwargs = {} 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 + kwargs['group_id'] = group_id mock_request.side_effect = make_mock_request_impl(self.course, "dummy content", **kwargs) request_data = {} @@ -1185,20 +933,21 @@ def call_view( request_data["group_id"] = group_id request = RequestFactory().get( "dummy_url", - data=request_data + data=request_data, + HTTP_X_REQUESTED_WITH="XMLHttpRequest" ) request.user = user - return views.inline_discussion( + return views.followed_threads( request, str(self.course.id), - commentable_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, - self.cohorted_commentable_id, + "cohorted_topic", self.student, self.student_cohort.id ) @@ -1207,84 +956,102 @@ def test_group_info_in_ajax_response(self, mock_is_forum_v2_enabled, mock_reques ) -@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" +@patch('requests.request', autospec=True) +class CommentsServiceRequestHeadersTestCase(ForumsEnableMixin, 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" + ) + 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( - 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) + username = "foo" + password = "bar" - request_data = {} - if pass_group_id: - request_data["group_id"] = group_id - headers = {} - if is_ajax: - headers['HTTP_X_REQUESTED_WITH'] = "XMLHttpRequest" + # Invoke UrlResetMixin + super().setUp() + self.course = CourseFactory.create(discussion_topics={'dummy discussion': {'id': 'dummy_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.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 - ) + self.addCleanup(translation.deactivate) - 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 + def assert_all_calls_have_header(self, mock_request, key, value): # lint-amnesty, pylint: disable=missing-function-docstring + expected = call( + ANY, # method + ANY, # url + data=ANY, + params=ANY, + headers=PartialDictMatcher({key: value}), + timeout=ANY ) - self._assert_html_response_contains_group_info(response) + for actual in mock_request.call_args_list: + assert expected == actual - 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 + def test_accept_language(self, mock_request): + lang = "eo" + 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) + + self.client.get( + reverse( + "single_thread", + kwargs={ + "course_id": str(self.course.id), + "discussion_id": "dummy_discussion_id", + "thread_id": thread_id, + } + ), + HTTP_ACCEPT_LANGUAGE=lang, ) - self._assert_json_response_contains_group_info( - response, lambda d: d['discussion_data'][0] + self.assert_all_calls_have_header(mock_request, "Accept-Language", lang) + + @override_settings(COMMENTS_SERVICE_KEY="test_api_key") + def test_api_key(self, mock_request): + mock_request.side_effect = make_mock_request_impl(course=self.course, text="dummy", thread_id="dummy") + + self.client.get( + reverse( + "forum_form_discussion", + kwargs={"course_id": str(self.course.id)} + ), ) + self.assert_all_calls_have_header(mock_request, "X-Edx-Api-Key", "test_api_key") -@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" +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.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False ) - self.mock_get_course_id_by_comment = patcher.start() + 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" @@ -1292,699 +1059,9 @@ def setUp(self): 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] - ) - - -@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) -class InlineDiscussionTestCase(ForumsEnableMixin, ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring - - def setUp(self): - super().setUp() - - self.course = CourseFactory.create( - org="TestX", - number="101", - display_name="Test Course", - teams_configuration=TeamsConfig({ - 'topics': [{ - 'id': 'topic_id', - 'name': 'A topic', - 'description': 'A topic', - }] - }) - ) - self.student = UserFactory.create() - CourseEnrollmentFactory(user=self.student, course_id=self.course.id) - self.discussion1 = BlockFactory.create( - parent_location=self.course.location, - category="discussion", - discussion_id="discussion1", - display_name='Discussion1', - discussion_category="Chapter", - discussion_target="Discussion1" - ) - - def send_request(self, mock_request, params=None): - """ - Creates and returns a request with params set, and configures - mock_request to return appropriate values. - """ - request = RequestFactory().get("dummy_url", params if params else {}) - request.user = self.student - mock_request.side_effect = make_mock_request_impl( - course=self.course, text="dummy content", commentable_id=self.discussion1.discussion_id - ) - return views.inline_discussion( - request, str(self.course.id), self.discussion1.discussion_id - ) - - def test_context(self, mock_request): - team = CourseTeamFactory( - name='Team Name', - topic_id='topic_id', - course_id=self.course.id, - discussion_topic_id=self.discussion1.discussion_id - ) - - team.add_user(self.student) - - self.send_request(mock_request) - assert mock_request.call_args[1]['params']['context'] == ThreadContext.STANDALONE - - -@patch('requests.request', autospec=True) -class UserProfileTestCase(ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring - - TEST_THREAD_TEXT = 'userprofile-test-text' - TEST_THREAD_ID = 'userprofile-test-thread-id' - - @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - super().setUp() - - self.course = CourseFactory.create() - self.student = UserFactory.create() - self.profiled_user = UserFactory.create() - CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) - CourseEnrollmentFactory.create(user=self.profiled_user, course_id=self.course.id) - - def get_response(self, mock_request, params, **headers): # lint-amnesty, pylint: disable=missing-function-docstring - mock_request.side_effect = make_mock_request_impl( - course=self.course, text=self.TEST_THREAD_TEXT, thread_id=self.TEST_THREAD_ID - ) - self.client.login(username=self.student.username, password=self.TEST_PASSWORD) - - response = self.client.get( - reverse('user_profile', kwargs={ - 'course_id': str(self.course.id), - 'user_id': self.profiled_user.id, - }), - data=params, - **headers - ) - mock_request.assert_any_call( - "get", - StringEndsWithMatcher(f'/users/{self.profiled_user.id}/active_threads'), - data=None, - params=PartialDictMatcher({ - "course_id": str(self.course.id), - "page": params.get("page", 1), - "per_page": views.THREADS_PER_PAGE - }), - headers=ANY, - timeout=ANY - ) - return response - - def check_html(self, mock_request, **params): # lint-amnesty, pylint: disable=missing-function-docstring - response = self.get_response(mock_request, params) - assert response.status_code == 200 - assert response['Content-Type'] == 'text/html; charset=utf-8' - html = response.content.decode('utf-8') - self.assertRegex(html, r'data-page="1"') - self.assertRegex(html, r'data-num-pages="1"') - self.assertRegex(html, r'1 discussion started') - self.assertRegex(html, r'2 comments') - self.assertRegex(html, f''id': '{self.TEST_THREAD_ID}'') - self.assertRegex(html, f''title': '{self.TEST_THREAD_TEXT}'') - self.assertRegex(html, f''body': '{self.TEST_THREAD_TEXT}'') - self.assertRegex(html, f''username': '{self.student.username}'') - - def check_ajax(self, mock_request, **params): # lint-amnesty, pylint: disable=missing-function-docstring - response = self.get_response(mock_request, params, HTTP_X_REQUESTED_WITH="XMLHttpRequest") - assert response.status_code == 200 - assert response['Content-Type'] == 'application/json; charset=utf-8' - response_data = json.loads(response.content.decode('utf-8')) - assert sorted(response_data.keys()) == ['annotated_content_info', 'discussion_data', 'num_pages', 'page'] - assert len(response_data['discussion_data']) == 1 - assert response_data['page'] == 1 - assert response_data['num_pages'] == 1 - assert response_data['discussion_data'][0]['id'] == self.TEST_THREAD_ID - assert response_data['discussion_data'][0]['title'] == self.TEST_THREAD_TEXT - assert response_data['discussion_data'][0]['body'] == self.TEST_THREAD_TEXT - - def test_html(self, mock_request): - self.check_html(mock_request) - - def test_ajax(self, mock_request): - self.check_ajax(mock_request) - - def test_404_non_enrolled_user(self, __): - """ - Test that when student try to visit un-enrolled students' discussion profile, - the system raises Http404. - """ - unenrolled_user = UserFactory.create() - request = RequestFactory().get("dummy_url") - request.user = self.student - with pytest.raises(Http404): - views.user_profile( - request, - str(self.course.id), - unenrolled_user.id - ) - - def test_404_profiled_user(self, _mock_request): - request = RequestFactory().get("dummy_url") - request.user = self.student - with pytest.raises(Http404): - views.user_profile( - request, - str(self.course.id), - -999 - ) - - def test_404_course(self, _mock_request): - request = RequestFactory().get("dummy_url") - request.user = self.student - with pytest.raises(Http404): - views.user_profile( - request, - "non/existent/course", - self.profiled_user.id - ) - - def test_post(self, mock_request): - mock_request.side_effect = make_mock_request_impl( - course=self.course, text=self.TEST_THREAD_TEXT, thread_id=self.TEST_THREAD_ID - ) - request = RequestFactory().post("dummy_url") - request.user = self.student - response = views.user_profile( - request, - str(self.course.id), - self.profiled_user.id - ) - assert response.status_code == 405 - - -@patch('requests.request', autospec=True) -class CommentsServiceRequestHeadersTestCase(ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring - - 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" - ) - self.mock_get_course_id_by_comment = patcher.start() - self.addCleanup(patcher.stop) - patcher = mock.patch( - "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" - ) - self.mock_get_course_id_by_thread = patcher.start() - self.addCleanup(patcher.stop) - - username = "foo" - password = "bar" - - # Invoke UrlResetMixin - super().setUp() - self.course = CourseFactory.create(discussion_topics={'dummy discussion': {'id': 'dummy_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) - - def assert_all_calls_have_header(self, mock_request, key, value): # lint-amnesty, pylint: disable=missing-function-docstring - expected = call( - ANY, # method - ANY, # url - data=ANY, - params=ANY, - headers=PartialDictMatcher({key: value}), - timeout=ANY - ) - for actual in mock_request.call_args_list: - assert expected == actual - - def test_accept_language(self, mock_request): - lang = "eo" - 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) - - self.client.get( - reverse( - "single_thread", - kwargs={ - "course_id": str(self.course.id), - "discussion_id": "dummy_discussion_id", - "thread_id": thread_id, - } - ), - HTTP_ACCEPT_LANGUAGE=lang, - ) - self.assert_all_calls_have_header(mock_request, "Accept-Language", lang) - - @override_settings(COMMENTS_SERVICE_KEY="test_api_key") - def test_api_key(self, mock_request): - mock_request.side_effect = make_mock_request_impl(course=self.course, text="dummy", thread_id="dummy") - - self.client.get( - reverse( - "forum_form_discussion", - kwargs={"course_id": str(self.course.id)} - ), - ) - 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() + @classmethod + def setUpTestData(cls): + super().setUpTestData() cls.student = UserFactory.create() CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) @@ -2087,60 +1164,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): 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..3ac375ed035f --- /dev/null +++ b/lms/djangoapps/discussion/tests/test_views_v2.py @@ -0,0 +1,1264 @@ +# pylint: disable=unused-import +""" +Tests the forum notification views. +""" + +import json +import logging +from datetime import datetime +from unittest import mock +from unittest.mock import ANY, Mock, call, patch + +import ddt +import pytest +from django.conf import settings +from django.http import Http404 +from django.test.client import Client, RequestFactory +from django.test.utils import override_settings +from django.urls import reverse +from django.utils import translation +from edx_django_utils.cache import RequestCache +from edx_toggles.toggles.testutils import override_waffle_flag +from lms.djangoapps.discussion.django_comment_client.tests.mixins import ( + MockForumApiMixin, +) +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import ( + TEST_DATA_SPLIT_MODULESTORE, + ModuleStoreTestCase, + SharedModuleStoreTestCase, +) +from xmodule.modulestore.tests.factories import ( + CourseFactory, + BlockFactory, + check_mongo_calls, +) + +from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.course_modes.tests.factories import CourseModeFactory +from common.djangoapps.student.roles import CourseStaffRole, UserBasedRole +from common.djangoapps.student.tests.factories import ( + AdminFactory, + CourseEnrollmentFactory, + UserFactory, +) +from common.djangoapps.util.testing import EventTestMixin, UrlResetMixin +from lms.djangoapps.courseware.exceptions import CourseAccessRedirect +from lms.djangoapps.discussion import views +from lms.djangoapps.discussion.django_comment_client.constants import ( + TYPE_ENTRY, + TYPE_SUBCATEGORY, +) +from lms.djangoapps.discussion.django_comment_client.permissions import get_team +from lms.djangoapps.discussion.django_comment_client.tests.group_id import ( + CohortedTopicGroupIdTestMixinV2, + GroupIdAssertionMixinV2, + NonCohortedTopicGroupIdTestMixinV2, +) +from lms.djangoapps.discussion.django_comment_client.tests.unicode import ( + UnicodeTestMixin, +) +from lms.djangoapps.discussion.django_comment_client.tests.utils import ( + CohortedTestCase, + ForumsEnableMixin, + config_course_discussions, + topic_name_to_id, +) +from lms.djangoapps.discussion.django_comment_client.utils import strip_none +from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE +from lms.djangoapps.discussion.views import ( + _get_discussion_default_topic_id, + course_discussions_settings_handler, +) +from lms.djangoapps.teams.tests.factories import ( + CourseTeamFactory, + CourseTeamMembershipFactory, +) +from openedx.core.djangoapps.course_groups.models import CourseUserGroup +from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts +from openedx.core.djangoapps.course_groups.tests.test_views import CohortViewsTestCase +from openedx.core.djangoapps.django_comment_common.comment_client.utils import ( + CommentClientPaginatedResult, +) +from openedx.core.djangoapps.django_comment_common.models import ( + FORUM_ROLE_STUDENT, + CourseDiscussionSettings, + ForumsConfig, +) +from openedx.core.djangoapps.django_comment_common.utils import ( + ThreadContext, + seed_permissions_roles, +) +from openedx.core.djangoapps.util.testing import ContentGroupTestCase +from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES +from openedx.core.lib.teams_config import TeamsConfig +from openedx.features.content_type_gating.models import ContentTypeGatingConfig +from openedx.features.enterprise_support.tests.mixins.enterprise import ( + EnterpriseTestConsentRequired, +) + +log = logging.getLogger(__name__) + +QUERY_COUNT_TABLE_IGNORELIST = WAFFLE_TABLES + + +def make_mock_thread_data( + course, + text, + thread_id, + num_children, + group_id=None, + group_name=None, + commentable_id=None, + is_commentable_divided=None, + anonymous=False, + anonymous_to_peers=False, +): + """ + Creates mock thread data for testing purposes. + """ + data_commentable_id = ( + commentable_id + or course.discussion_topics.get("General", {}).get("id") + or "dummy_commentable_id" + ) + thread_data = { + "id": thread_id, + "type": "thread", + "title": text, + "body": text, + "commentable_id": data_commentable_id, + "resp_total": 42, + "resp_skip": 25, + "resp_limit": 5, + "group_id": group_id, + "anonymous": anonymous, + "anonymous_to_peers": anonymous_to_peers, + "context": ( + ThreadContext.COURSE + if get_team(data_commentable_id) is None + else ThreadContext.STANDALONE + ), + } + if group_id is not None: + thread_data["group_name"] = group_name + if is_commentable_divided is not None: + thread_data["is_commentable_divided"] = is_commentable_divided + if num_children is not None: + thread_data["children"] = [ + { + "id": f"dummy_comment_id_{i}", + "type": "comment", + "body": text, + } + for i in range(num_children) + ] + return thread_data + + +def make_mock_collection_data( + course, + text, + thread_id, + num_children=None, + group_id=None, + commentable_id=None, + thread_list=None, +): + """ + Creates mock collection data for testing purposes. + """ + if thread_list: + return [ + make_mock_thread_data( + course=course, text=text, num_children=num_children, **thread + ) + for thread in thread_list + ] + else: + return [ + make_mock_thread_data( + course=course, + text=text, + thread_id=thread_id, + num_children=num_children, + group_id=group_id, + commentable_id=commentable_id, + ) + ] + + +def make_collection_callback( + course, + text, + thread_id="dummy_thread_id", + group_id=None, + commentable_id=None, + thread_list=None, +): + """ + Creates a callback function for simulating collection data. + """ + + def callback(*args, **kwargs): + # Simulate default user thread response + return { + "collection": make_mock_collection_data( + course, text, thread_id, None, group_id, commentable_id, thread_list + ) + } + + return callback + + +def make_thread_callback( + course, + text, + thread_id="dummy_thread_id", + group_id=None, + commentable_id=None, + num_thread_responses=1, + anonymous=False, + anonymous_to_peers=False, +): + """ + Creates a callback function for simulating thread data. + """ + + def callback(*args, **kwargs): + # Simulate default user thread response + return make_mock_thread_data( + course=course, + text=text, + thread_id=thread_id, + num_children=num_thread_responses, + group_id=group_id, + commentable_id=commentable_id, + anonymous=anonymous, + anonymous_to_peers=anonymous_to_peers, + ) + + return callback + + +def make_user_callback(): + """ + Creates a callback function for simulating user data. + """ + + def callback(*args, **kwargs): + res = { + "default_sort_key": "date", + "upvoted_ids": [], + "downvoted_ids": [], + "subscribed_thread_ids": [], + } + # comments service adds these attributes when course_id param is present + if kwargs.get("course_id"): + res.update({"threads_count": 1, "comments_count": 2}) + return res + + return callback + + +class ForumViewsUtilsMixin(MockForumApiMixin): + """ + Utils for the Forum Views. + """ + + def _configure_mock_responses( + self, + course, + text, + thread_id="dummy_thread_id", + group_id=None, + commentable_id=None, + num_thread_responses=1, + thread_list=None, + anonymous=False, + anonymous_to_peers=False, + ): + """ + Configure mock responses for the Forum Views. + """ + for func_name in [ + "search_threads", + "get_user_active_threads", + "get_user_threads", + ]: + self.set_mock_side_effect( + func_name, + make_collection_callback( + course, + text, + thread_id, + group_id, + commentable_id, + thread_list, + ), + ) + + self.set_mock_side_effect( + "get_thread", + make_thread_callback( + course, + text, + thread_id, + group_id, + commentable_id, + num_thread_responses, + anonymous, + anonymous_to_peers, + ), + ) + + self.set_mock_side_effect("get_user", make_user_callback()) + + +class ForumFormDiscussionContentGroupTestCase( + ForumsEnableMixin, ContentGroupTestCase, ForumViewsUtilsMixin +): + """ + Tests `forum_form_discussion api` works with different content groups. + Discussion blocks are setup in ContentGroupTestCase class i.e + alpha_block => alpha_group_discussion => alpha_cohort => alpha_user/community_ta + beta_block => beta_group_discussion => beta_cohort => beta_user + """ + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + super().setUp() + self.thread_list = [ + {"thread_id": "test_general_thread_id"}, + { + "thread_id": "test_global_group_thread_id", + "commentable_id": self.global_block.discussion_id, + }, + { + "thread_id": "test_alpha_group_thread_id", + "group_id": self.alpha_block.group_access[0][0], + "commentable_id": self.alpha_block.discussion_id, + }, + { + "thread_id": "test_beta_group_thread_id", + "group_id": self.beta_block.group_access[0][0], + "commentable_id": self.beta_block.discussion_id, + }, + ] + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def assert_has_access(self, response, expected_discussion_threads): + """ + Verify that a users have access to the threads in their assigned + cohorts and non-cohorted blocks. + """ + discussion_data = json.loads(response.content.decode("utf-8"))[ + "discussion_data" + ] + assert len(discussion_data) == expected_discussion_threads + + def call_view( + self, user + ): # lint-amnesty, pylint: disable=missing-function-docstring + self._configure_mock_responses( + course=self.course, text="dummy content", thread_list=self.thread_list + ) + self.client.login(username=user.username, password=self.TEST_PASSWORD) + return self.client.get( + reverse("forum_form_discussion", args=[str(self.course.id)]), + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + + def test_community_ta_user(self): + """ + Verify that community_ta user has access to all threads regardless + of cohort. + """ + response = self.call_view(self.community_ta) + self.assert_has_access(response, 4) + + def test_alpha_cohort_user(self): + """ + Verify that alpha_user has access to alpha_cohort and non-cohorted + threads. + """ + response = self.call_view(self.alpha_user) + self.assert_has_access(response, 3) + + def test_beta_cohort_user(self): + """ + Verify that beta_user has access to beta_cohort and non-cohorted + threads. + """ + response = self.call_view(self.beta_user) + self.assert_has_access(response, 3) + + def test_global_staff_user(self): + """ + Verify that global staff user has access to all threads regardless + of cohort. + """ + response = self.call_view(self.staff_user) + self.assert_has_access(response, 4) + + +class ForumFormDiscussionUnicodeTestCase( + ForumsEnableMixin, + SharedModuleStoreTestCase, + UnicodeTestMixin, + ForumViewsUtilsMixin, +): + """ + Discussiin Unicode Tests. + """ + + @classmethod + def setUpClass(cls): # pylint: disable=super-method-not-called + super().setUpClassAndForumMock() + + with super().setUpClassAndTestData(): + cls.course = CourseFactory.create() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + cls.student = UserFactory.create() + CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) + + def _test_unicode_data( + self, text + ): # lint-amnesty, pylint: disable=missing-function-docstring + self._configure_mock_responses(course=self.course, text=text) + request = RequestFactory().get("dummy_url") + request.user = self.student + # so (request.headers.get('x-requested-with') == 'XMLHttpRequest') == True + request.META["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest" + + response = views.forum_form_discussion(request, str(self.course.id)) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data["discussion_data"][0]["title"] == text + assert response_data["discussion_data"][0]["body"] == text + + +class EnterpriseConsentTestCase( + EnterpriseTestConsentRequired, + ForumsEnableMixin, + UrlResetMixin, + ModuleStoreTestCase, + ForumViewsUtilsMixin, +): + """ + Ensure that the Enterprise Data Consent redirects are in place only when consent is required. + """ + + CREATE_USER = False + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + # Invoke UrlResetMixin setUp + super().setUp() + username = "foo" + password = "bar" + + self.discussion_id = "dummy_discussion_id" + self.course = CourseFactory.create( + discussion_topics={"dummy discussion": {"id": self.discussion_id}} + ) + self.student = UserFactory.create(username=username, password=password) + CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) + assert self.client.login(username=username, password=password) + + self.addCleanup(translation.deactivate) + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + @patch("openedx.features.enterprise_support.api.enterprise_customer_for_request") + def test_consent_required(self, mock_enterprise_customer_for_request): + """ + Test that enterprise data sharing consent is required when enabled for the various discussion views. + """ + # ENT-924: Temporary solution to replace sensitive SSO usernames. + mock_enterprise_customer_for_request.return_value = None + + thread_id = "dummy" + course_id = str(self.course.id) + self._configure_mock_responses( + course=self.course, text="dummy", thread_id=thread_id + ) + + for url in ( + reverse("forum_form_discussion", kwargs=dict(course_id=course_id)), + reverse( + "single_thread", + kwargs=dict( + course_id=course_id, + discussion_id=self.discussion_id, + thread_id=thread_id, + ), + ), + ): + self.verify_consent_required( # pylint: disable=no-value-for-parameter + self.client, url + ) + + +class InlineDiscussionGroupIdTestCase( # lint-amnesty, pylint: disable=missing-class-docstring + CohortedTestCase, + CohortedTopicGroupIdTestMixinV2, + NonCohortedTopicGroupIdTestMixinV2, + ForumViewsUtilsMixin, +): + function_name = "get_user_threads" + + def setUp(self): + super().setUp() + self.cohorted_commentable_id = "cohorted_topic" + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def call_view( + self, commentable_id, user, group_id, pass_group_id=True + ): # pylint: disable=arguments-differ + kwargs = {"commentable_id": self.cohorted_commentable_id} + if group_id: + # avoid causing a server error when the LMS chokes attempting + # to find a group name for the group_id, when we're testing with + # an invalid one. + try: + CourseUserGroup.objects.get(id=group_id) + kwargs["group_id"] = group_id + except CourseUserGroup.DoesNotExist: + pass + self._configure_mock_responses(self.course, "dummy content", **kwargs) + + request_data = {} + if pass_group_id: + request_data["group_id"] = group_id + request = RequestFactory().get("dummy_url", data=request_data) + request.user = user + return views.inline_discussion(request, str(self.course.id), commentable_id) + + def test_group_info_in_ajax_response(self): + response = self.call_view( + self.cohorted_commentable_id, self.student, self.student_cohort.id + ) + self._assert_json_response_contains_group_info( + response, lambda d: d["discussion_data"][0] + ) + + +class InlineDiscussionContextTestCase( + ForumsEnableMixin, ModuleStoreTestCase, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + + def setUp(self): + super().setUp() + self.course = CourseFactory.create() + CourseEnrollmentFactory(user=self.user, course_id=self.course.id) + self.discussion_topic_id = "dummy_topic" + self.team = CourseTeamFactory( + name="A team", + course_id=self.course.id, + topic_id="topic_id", + discussion_topic_id=self.discussion_topic_id, + ) + + self.team.add_user(self.user) + self.user_not_in_team = UserFactory.create() + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def test_context_can_be_standalone(self): + self._configure_mock_responses( + course=self.course, + text="dummy text", + commentable_id=self.discussion_topic_id, + ) + + request = RequestFactory().get("dummy_url") + request.user = self.user + + response = views.inline_discussion( + request, + str(self.course.id), + self.discussion_topic_id, + ) + + json_response = json.loads(response.content.decode("utf-8")) + assert ( + json_response["discussion_data"][0]["context"] == ThreadContext.STANDALONE + ) + + def test_private_team_discussion(self): + # First set the team discussion to be private + CourseEnrollmentFactory(user=self.user_not_in_team, course_id=self.course.id) + request = RequestFactory().get("dummy_url") + request.user = self.user_not_in_team + + self._configure_mock_responses( + course=self.course, + text="dummy text", + commentable_id=self.discussion_topic_id, + ) + + with patch( + "lms.djangoapps.teams.api.is_team_discussion_private", autospec=True + ) as mocked: + mocked.return_value = True + response = views.inline_discussion( + request, + str(self.course.id), + self.discussion_topic_id, + ) + assert response.status_code == 403 + assert response.content.decode("utf-8") == views.TEAM_PERMISSION_MESSAGE + + +class UserProfileDiscussionGroupIdTestCase( + CohortedTestCase, CohortedTopicGroupIdTestMixinV2, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + function_name = "get_user_active_threads" + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def call_view_for_profiled_user( + self, requesting_user, profiled_user, group_id, pass_group_id, is_ajax=False + ): + """ + Calls "user_profile" view method on behalf of "requesting_user" to get information about + the user "profiled_user". + """ + kwargs = {} + if group_id: + kwargs["group_id"] = group_id + self._configure_mock_responses(self.course, "dummy content", **kwargs) + + request_data = {} + if pass_group_id: + request_data["group_id"] = group_id + headers = {} + if is_ajax: + headers["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest" + + self.client.login( + username=requesting_user.username, password=self.TEST_PASSWORD + ) + return self.client.get( + reverse("user_profile", args=[str(self.course.id), profiled_user.id]), + data=request_data, + **headers, + ) + + def call_view( + self, _commentable_id, user, group_id, pass_group_id=True, is_ajax=False + ): # pylint: disable=arguments-differ + return self.call_view_for_profiled_user( + user, user, group_id, pass_group_id=pass_group_id, is_ajax=is_ajax + ) + + def test_group_info_in_html_response(self): + response = self.call_view( + "cohorted_topic", self.student, self.student_cohort.id, is_ajax=False + ) + self._assert_html_response_contains_group_info(response) + + def test_group_info_in_ajax_response(self): + response = self.call_view( + "cohorted_topic", self.student, self.student_cohort.id, is_ajax=True + ) + self._assert_json_response_contains_group_info( + response, lambda d: d["discussion_data"][0] + ) + + def _test_group_id_passed_to_user_profile( + self, + expect_group_id_in_request, + requesting_user, + profiled_user, + group_id, + pass_group_id, + ): + """ + Helper method for testing whether or not group_id was passed to the user_profile request. + """ + + def get_params_from_user_info_call(for_specific_course): + """ + Returns the request parameters for the user info call with either course_id specified or not, + depending on value of 'for_specific_course'. + """ + # There will be 3 calls from user_profile. One has the cs_endpoint "active_threads", and it is already + # tested. The other 2 calls are for user info; one of those calls is for general information about the user, + # and it does not specify a course_id. The other call does specify a course_id, and if the caller did not + # have discussion moderator privileges, it should also contain a group_id. + user_func_calls = self.get_mock_func_calls("get_user") + for r_call in user_func_calls: + has_course_id = "course_id" in r_call[1] + if (for_specific_course and has_course_id) or ( + not for_specific_course and not has_course_id + ): + return r_call[1] + pytest.fail( + f"Did not find appropriate user_profile call for 'for_specific_course'={for_specific_course}" + ) + + self.call_view_for_profiled_user( + requesting_user, + profiled_user, + group_id, + pass_group_id=pass_group_id, + is_ajax=False, + ) + # Should never have a group_id if course_id was not included in the request. + params_without_course_id = get_params_from_user_info_call(False) + assert "group_ids" not in params_without_course_id + + params_with_course_id = get_params_from_user_info_call(True) + if expect_group_id_in_request: + assert "group_ids" in params_with_course_id + assert [group_id] == params_with_course_id["group_ids"] + else: + assert "group_ids" not in params_with_course_id + + def test_group_id_passed_to_user_profile_student(self): + """ + Test that the group id is always included when requesting user profile information for a particular + course if the requester does not have discussion moderation privileges. + """ + + def verify_group_id_always_present(profiled_user, pass_group_id): + """ + Helper method to verify that group_id is always present for student in course + (non-privileged user). + """ + self._test_group_id_passed_to_user_profile( + True, self.student, profiled_user, self.student_cohort.id, pass_group_id + ) + + # In all these test cases, the requesting_user is the student (non-privileged user). + # The profile returned on behalf of the student is for the profiled_user. + verify_group_id_always_present(profiled_user=self.student, pass_group_id=True) + verify_group_id_always_present(profiled_user=self.student, pass_group_id=False) + verify_group_id_always_present(profiled_user=self.moderator, pass_group_id=True) + verify_group_id_always_present( + profiled_user=self.moderator, pass_group_id=False + ) + + def test_group_id_user_profile_moderator(self): + """ + Test that the group id is only included when a privileged user requests user profile information for a + particular course and user if the group_id is explicitly passed in. + """ + + def verify_group_id_present( + profiled_user, pass_group_id, requested_cohort=self.moderator_cohort + ): + """ + Helper method to verify that group_id is present. + """ + self._test_group_id_passed_to_user_profile( + True, self.moderator, profiled_user, requested_cohort.id, pass_group_id + ) + + def verify_group_id_not_present( + profiled_user, pass_group_id, requested_cohort=self.moderator_cohort + ): + """ + Helper method to verify that group_id is not present. + """ + self._test_group_id_passed_to_user_profile( + False, self.moderator, profiled_user, requested_cohort.id, pass_group_id + ) + + # In all these test cases, the requesting_user is the moderator (privileged user). + + # If the group_id is explicitly passed, it will be present in the request. + verify_group_id_present(profiled_user=self.student, pass_group_id=True) + verify_group_id_present(profiled_user=self.moderator, pass_group_id=True) + verify_group_id_present( + profiled_user=self.student, + pass_group_id=True, + requested_cohort=self.student_cohort, + ) + + # If the group_id is not explicitly passed, it will not be present because the requesting_user + # has discussion moderator privileges. + verify_group_id_not_present(profiled_user=self.student, pass_group_id=False) + verify_group_id_not_present(profiled_user=self.moderator, pass_group_id=False) + + +@ddt.ddt +class ForumDiscussionXSSTestCase( + ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + super().setUp() + + username = "foo" + password = "bar" + + self.course = CourseFactory.create() + self.student = UserFactory.create(username=username, password=password) + CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) + assert self.client.login(username=username, password=password) + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + @ddt.data( + '">', + "", + "", + ) + @patch("common.djangoapps.student.models.user.cc.User.from_django_user") + def test_forum_discussion_xss_prevent(self, malicious_code, mock_user): + """ + Test that XSS attack is prevented + """ + self.set_mock_return_value("get_user", {}) + self.set_mock_return_value("get_user_threads", {}) + self.set_mock_return_value("get_user_active_threads", {}) + mock_user.return_value.to_dict.return_value = {} + reverse_url = "{}{}".format( + reverse("forum_form_discussion", kwargs={"course_id": str(self.course.id)}), + "/forum_form_discussion", + ) + # Test that malicious code does not appear in html + url = "{}?{}={}".format(reverse_url, "sort_key", malicious_code) + resp = self.client.get(url) + self.assertNotContains(resp, malicious_code) + + @ddt.data( + '">', + "", + "", + ) + @patch("common.djangoapps.student.models.user.cc.User.from_django_user") + @patch("common.djangoapps.student.models.user.cc.User.active_threads") + def test_forum_user_profile_xss_prevent( + self, malicious_code, mock_threads, mock_from_django_user + ): + """ + Test that XSS attack is prevented + """ + mock_threads.return_value = [], 1, 1 + mock_from_django_user.return_value.to_dict.return_value = { + "upvoted_ids": [], + "downvoted_ids": [], + "subscribed_thread_ids": [], + } + self._configure_mock_responses(course=self.course, text="dummy") + + url = reverse( + "user_profile", + kwargs={"course_id": str(self.course.id), "user_id": str(self.student.id)}, + ) + # Test that malicious code does not appear in html + url_string = "{}?{}={}".format(url, "page", malicious_code) + resp = self.client.get(url_string) + self.assertNotContains(resp, malicious_code) + + +class InlineDiscussionTestCase( + ForumsEnableMixin, ModuleStoreTestCase, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + + def setUp(self): + super().setUp() + + self.course = CourseFactory.create( + org="TestX", + number="101", + display_name="Test Course", + teams_configuration=TeamsConfig( + { + "topics": [ + { + "id": "topic_id", + "name": "A topic", + "description": "A topic", + } + ] + } + ), + ) + self.student = UserFactory.create() + CourseEnrollmentFactory(user=self.student, course_id=self.course.id) + self.discussion1 = BlockFactory.create( + parent_location=self.course.location, + category="discussion", + discussion_id="discussion1", + display_name="Discussion1", + discussion_category="Chapter", + discussion_target="Discussion1", + ) + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def send_request(self, params=None): + """ + Creates and returns a request with params set, and configures + mock_request to return appropriate values. + """ + request = RequestFactory().get("dummy_url", params if params else {}) + request.user = self.student + self._configure_mock_responses( + course=self.course, + text="dummy content", + commentable_id=self.discussion1.discussion_id, + ) + return views.inline_discussion( + request, str(self.course.id), self.discussion1.discussion_id + ) + + def test_context(self): + team = CourseTeamFactory( + name="Team Name", + topic_id="topic_id", + course_id=self.course.id, + discussion_topic_id=self.discussion1.discussion_id, + ) + + team.add_user(self.student) + + self.send_request() + last_call = self.get_mock_func_calls("get_user_threads")[-1][1] + assert last_call["context"] == ThreadContext.STANDALONE + + +class ForumDiscussionSearchUnicodeTestCase( + ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + + @classmethod + def setUpClass(cls): # pylint: disable=super-method-not-called + super().setUpClassAndForumMock() + with super().setUpClassAndTestData(): + cls.course = CourseFactory.create() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + cls.student = UserFactory.create() + CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) + + def _test_unicode_data( + self, text + ): # lint-amnesty, pylint: disable=missing-function-docstring + self._configure_mock_responses(course=self.course, text=text) + data = { + "ajax": 1, + "text": text, + } + request = RequestFactory().get("dummy_url", data) + request.user = self.student + # so (request.headers.get('x-requested-with') == 'XMLHttpRequest') == True + request.META["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest" + + response = views.forum_form_discussion(request, str(self.course.id)) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data["discussion_data"][0]["title"] == text + assert response_data["discussion_data"][0]["body"] == text + + +class InlineDiscussionUnicodeTestCase( + ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + + @classmethod + def setUpClass(cls): # pylint: disable=super-method-not-called + super().setUpClassAndForumMock() + + with super().setUpClassAndTestData(): + cls.course = CourseFactory.create() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + cls.student = UserFactory.create() + CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) + + def _test_unicode_data( + self, text + ): # lint-amnesty, pylint: disable=missing-function-docstring + self._configure_mock_responses(course=self.course, text=text) + request = RequestFactory().get("dummy_url") + request.user = self.student + + response = views.inline_discussion( + request, str(self.course.id), self.course.discussion_topics["General"]["id"] + ) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data["discussion_data"][0]["title"] == text + assert response_data["discussion_data"][0]["body"] == text + + +class ForumFormDiscussionGroupIdTestCase( + CohortedTestCase, CohortedTopicGroupIdTestMixinV2, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + function_name = "get_user_threads" + + def call_view( + self, commentable_id, user, group_id, pass_group_id=True, is_ajax=False + ): # pylint: disable=arguments-differ + kwargs = {} + if group_id: + kwargs["group_id"] = group_id + self._configure_mock_responses(self.course, "dummy content", **kwargs) + + request_data = {} + if pass_group_id: + request_data["group_id"] = group_id + headers = {} + if is_ajax: + headers["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest" + + self.client.login(username=user.username, password=self.TEST_PASSWORD) + return self.client.get( + reverse("forum_form_discussion", args=[str(self.course.id)]), + data=request_data, + **headers, + ) + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def test_group_info_in_html_response(self): + response = self.call_view( + "cohorted_topic", self.student, self.student_cohort.id + ) + self._assert_html_response_contains_group_info(response) + + def test_group_info_in_ajax_response(self): + response = self.call_view( + "cohorted_topic", self.student, self.student_cohort.id, is_ajax=True + ) + self._assert_json_response_contains_group_info( + response, lambda d: d["discussion_data"][0] + ) + + +class UserProfileTestCase( + ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + + TEST_THREAD_TEXT = "userprofile-test-text" + TEST_THREAD_ID = "userprofile-test-thread-id" + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + super().setUp() + + self.course = CourseFactory.create() + self.student = UserFactory.create() + self.profiled_user = UserFactory.create() + CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) + CourseEnrollmentFactory.create( + user=self.profiled_user, course_id=self.course.id + ) + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def get_response( + self, params, **headers + ): # lint-amnesty, pylint: disable=missing-function-docstring + self._configure_mock_responses( + course=self.course, + text=self.TEST_THREAD_TEXT, + thread_id=self.TEST_THREAD_ID, + ) + self.client.login(username=self.student.username, password=self.TEST_PASSWORD) + + response = self.client.get( + reverse( + "user_profile", + kwargs={ + "course_id": str(self.course.id), + "user_id": self.profiled_user.id, + }, + ), + data=params, + **headers, + ) + params = { + "course_id": str(self.course.id), + "page": params.get("page", 1), + "per_page": views.THREADS_PER_PAGE, + } + self.check_mock_called_with("get_user_active_threads", -1, **params) + return response + + def check_html( + self, **params + ): # lint-amnesty, pylint: disable=missing-function-docstring + response = self.get_response(params) + assert response.status_code == 200 + assert response["Content-Type"] == "text/html; charset=utf-8" + html = response.content.decode("utf-8") + self.assertRegex(html, r'data-page="1"') + self.assertRegex(html, r'data-num-pages="1"') + self.assertRegex( + html, r'1 discussion started' + ) + self.assertRegex(html, r'2 comments') + self.assertRegex(html, f"'id': '{self.TEST_THREAD_ID}'") + self.assertRegex(html, f"'title': '{self.TEST_THREAD_TEXT}'") + self.assertRegex(html, f"'body': '{self.TEST_THREAD_TEXT}'") + self.assertRegex(html, f"'username': '{self.student.username}'") + + def check_ajax( + self, **params + ): # lint-amnesty, pylint: disable=missing-function-docstring + response = self.get_response(params, HTTP_X_REQUESTED_WITH="XMLHttpRequest") + assert response.status_code == 200 + assert response["Content-Type"] == "application/json; charset=utf-8" + response_data = json.loads(response.content.decode("utf-8")) + assert sorted(response_data.keys()) == [ + "annotated_content_info", + "discussion_data", + "num_pages", + "page", + ] + assert len(response_data["discussion_data"]) == 1 + assert response_data["page"] == 1 + assert response_data["num_pages"] == 1 + assert response_data["discussion_data"][0]["id"] == self.TEST_THREAD_ID + assert response_data["discussion_data"][0]["title"] == self.TEST_THREAD_TEXT + assert response_data["discussion_data"][0]["body"] == self.TEST_THREAD_TEXT + + def test_html(self): + self.check_html() + + def test_ajax(self): + self.check_ajax() + + def test_404_non_enrolled_user(self): + """ + Test that when student try to visit un-enrolled students' discussion profile, + the system raises Http404. + """ + unenrolled_user = UserFactory.create() + request = RequestFactory().get("dummy_url") + request.user = self.student + with pytest.raises(Http404): + views.user_profile(request, str(self.course.id), unenrolled_user.id) + + def test_404_profiled_user(self): + request = RequestFactory().get("dummy_url") + request.user = self.student + with pytest.raises(Http404): + views.user_profile(request, str(self.course.id), -999) + + def test_404_course(self): + request = RequestFactory().get("dummy_url") + request.user = self.student + with pytest.raises(Http404): + views.user_profile(request, "non/existent/course", self.profiled_user.id) + + def test_post(self): + self._configure_mock_responses( + course=self.course, + text=self.TEST_THREAD_TEXT, + thread_id=self.TEST_THREAD_ID, + ) + request = RequestFactory().post("dummy_url") + request.user = self.student + response = views.user_profile( + request, str(self.course.id), self.profiled_user.id + ) + assert response.status_code == 405 diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py index 49aa8f9bc194..de994bb13af1 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py @@ -56,42 +56,28 @@ 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 +110,6 @@ def search(cls, query_params): total_results=total_results ) ) - return utils.CommentClientPaginatedResult( collection=response.get('collection', []), page=response.get('page', 1), diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/user.py b/openedx/core/djangoapps/django_comment_common/comment_client/user.py index 731825aa71ad..ee9591e51d82 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/user.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/user.py @@ -181,7 +181,7 @@ def subscribed_threads(self, query_params=None): user_id = params.pop("user_id", None) if "text" in params: params.pop("text") - response = forum_api.get_user_subscriptions(user_id, str(course_key), params) + response = forum_api.get_user_subscriptions(user_id, str(course_key), utils.clean_forum_params(params)) else: response = utils.perform_request( 'get', @@ -218,21 +218,17 @@ def _retrieve(self, *args, **kwargs): if is_forum_v2_enabled(course_key): group_ids = [retrieve_params['group_id']] if 'group_id' in retrieve_params else [] is_complete = retrieve_params['complete'] + params = utils.clean_forum_params({ + "user_id": self.attributes["id"], + "group_ids": group_ids, + "course_id": course_id, + "complete": is_complete + }) try: - response = forum_api.get_user( - self.attributes["id"], - group_ids=group_ids, - course_id=course_id, - complete=is_complete - ) + response = forum_api.get_user(**params) except ForumV2RequestError as e: self.save({"course_id": course_id}) - response = forum_api.get_user( - self.attributes["id"], - group_ids=group_ids, - course_id=course_id, - complete=is_complete - ) + response = forum_api.get_user(**params) else: try: response = utils.perform_request( diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/utils.py b/openedx/core/djangoapps/django_comment_common/comment_client/utils.py index e77f39e6277d..26625ed3a732 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/utils.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/utils.py @@ -103,6 +103,23 @@ def perform_request(method, url, data_or_params=None, raw=False, return data +def clean_forum_params(params): + """Convert string booleans to actual booleans and remove None values and empty lists from forum parameters.""" + result = {} + for k, v in params.items(): + if v is not None and v != []: + if isinstance(v, str): + if v.lower() == 'true': + result[k] = True + elif v.lower() == 'false': + result[k] = False + else: + result[k] = v + else: + result[k] = v + return result + + class CommentClientError(Exception): pass From 7ea187fa2d70d5c8df5cc189063badcbd1dcba6e Mon Sep 17 00:00:00 2001 From: Ali-Salman29 Date: Mon, 28 Jul 2025 12:05:48 +0200 Subject: [PATCH 7/7] feat!: remove cs_comments_service support for forum's subscription APIs - This will force the use of the new v2 forum's APIs for subscriptions. --- .../django_comment_client/base/tests.py | 184 ------- .../django_comment_client/base/tests_v2.py | 181 +++++++ .../discussion/rest_api/tests/test_api.py | 60 +-- .../discussion/rest_api/tests/test_api_v2.py | 55 ++ .../discussion/rest_api/tests/test_tasks.py | 417 --------------- .../rest_api/tests/test_tasks_v2.py | 488 ++++++++++++++++++ .../comment_client/subscriptions.py | 23 +- .../comment_client/user.py | 40 +- 8 files changed, 741 insertions(+), 707 deletions(-) create mode 100644 lms/djangoapps/discussion/rest_api/tests/test_tasks_v2.py diff --git a/lms/djangoapps/discussion/django_comment_client/base/tests.py b/lms/djangoapps/discussion/django_comment_client/base/tests.py index bc2253b14066..dce787ac11ae 100644 --- a/lms/djangoapps/discussion/django_comment_client/base/tests.py +++ b/lms/djangoapps/discussion/django_comment_client/base/tests.py @@ -527,23 +527,6 @@ def test_create_thread_standalone(self, mock_is_forum_v2_enabled, mock_request): # 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, { @@ -1421,27 +1404,6 @@ def test_create_sub_comment(self, user, commentable_id, status_code, mock_is_for ) 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 ["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' @@ -1482,50 +1444,6 @@ def setUpTestData(cls): 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) @@ -1570,108 +1488,6 @@ def test_comment_event(self, mock_is_forum_v2_enabled, mock_request, mock_emit): 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('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): diff --git a/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py b/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py index 0d1faa55d7df..bef31367d575 100644 --- a/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py +++ b/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py @@ -593,6 +593,22 @@ def test_voting(self, view_name, function_name, item_id, signal): ) 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( @@ -956,6 +972,9 @@ def test_threads_actions(self, user, commentable_id, status_code): assert response.status_code == status_code +TEAM_COMMENTABLE_ID = 'test-team-discussion' + + @disable_signal(views, "comment_created") @ddt.ddt class ForumEventTestCase( @@ -1038,3 +1057,165 @@ def test_thread_voted_event( 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) + 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", + } + ) + 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) + mock_request_data = { + "closed": False, + "commentable_id": 'test_commentable_id', + 'thread_id': 'test_thread_id', + } + self.set_mock_return_value("get_thread", mock_request_data) + self.set_mock_return_value("create_parent_comment", mock_request_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 + ) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api.py b/lms/djangoapps/discussion/rest_api/tests/test_api.py index 48b943543c91..2cb9a4860af7 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_api.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_api.py @@ -7,7 +7,7 @@ import random from datetime import datetime, timedelta from unittest import mock -from urllib.parse import parse_qs, urlencode, urlparse, urlunparse +from urllib.parse import urlencode, urlparse, urlunparse import ddt import httpretty @@ -1602,18 +1602,6 @@ def test_group_id(self, role_name, course_is_cohorted, topic_is_cohorted, data_g 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_course_id_missing(self): with pytest.raises(ValidationError) as assertion: create_thread(self.request, {}) @@ -2228,52 +2216,6 @@ def test_author_only_fields(self, role_name): 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'] - def test_invalid_field(self): self.register_thread() with pytest.raises(ValidationError) as assertion: diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py index 4efadd63858b..62284b904c78 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py @@ -282,6 +282,18 @@ def test_abuse_flagged(self): } 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") @@ -712,6 +724,49 @@ def test_vote_count_two_users( 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'] + @ddt.ddt @disable_signal(api, "comment_edited") diff --git a/lms/djangoapps/discussion/rest_api/tests/test_tasks.py b/lms/djangoapps/discussion/rest_api/tests/test_tasks.py index a6eb4948ce03..7fe306fc6c90 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_tasks.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_tasks.py @@ -2,7 +2,6 @@ Test cases for tasks.py """ from unittest import mock -from unittest.mock import Mock import ddt import httpretty @@ -15,7 +14,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,7 +31,6 @@ 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 @@ -256,420 +253,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): """ 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..b6193de2dfd7 --- /dev/null +++ b/lms/djangoapps/discussion/rest_api/tests/test_tasks_v2.py @@ -0,0 +1,488 @@ +""" +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 USER_NOTIFICATION_REQUESTED + +from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.student.tests.factories import UserFactory +from lms.djangoapps.discussion.rest_api.tasks import send_response_notifications +from lms.djangoapps.discussion.rest_api.tests.utils import ThreadMock +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() + + # Patch 1 + patcher1 = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.thread.is_forum_v2_enabled_for_thread', + autospec=True + ) + mock_forum_v2 = patcher1.start() + mock_forum_v2.return_value = (True, str(self.course.id)) + self.addCleanup(patcher1.stop) + + # Patch 2 + patcher2 = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher2.start() + self.addCleanup(patcher2.stop) + + # Patch 3 + patcher3 = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread", + return_value=self.course.id + ) + self.mock_get_course_id_by_thread = patcher3.start() + self.addCleanup(patcher3.stop) + + # Patch 4 + patcher4 = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment", + return_value=self.course.id + ) + self.mock_get_course_id_by_comment = patcher4.start() + self.addCleanup(patcher4.stop) + + # Patch 5 + patcher5 = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.is_forum_v2_enabled_for_comment", + return_value=(True, str(self.course.id)) + ) + self.mock_is_forum_v2_enabled_for_comment = patcher5.start() + self.addCleanup(patcher5.stop) + + self.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() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + + patcher = mock.patch( + 'openedx.core.djangoapps.django_comment_common.comment_client.thread.is_forum_v2_enabled_for_thread', + autospec=True + ) + mock_forum_v2 = patcher.start() + mock_forum_v2.return_value = (True, str(self.course.id)) + self.addCleanup(patcher.stop) + + self.course = CourseFactory.create() + patcher = mock.patch( + "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) + + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.is_forum_v2_enabled_for_comment", + return_value=(True, str(self.course.id)) + ) + self.mock_is_forum_v2_enabled_for_comment = patcher.start() + self.addCleanup(patcher.stop) + + self.user_1 = UserFactory.create() + CourseEnrollment.enroll(self.user_1, self.course.id) + self.user_2 = UserFactory.create() + 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) 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/user.py b/openedx/core/djangoapps/django_comment_common/comment_client/user.py index ee9591e51d82..de676b77a823 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/user.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/user.py @@ -52,39 +52,19 @@ def read(self, source): 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)