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.
This commit is contained in:
committed by
David Ormsbee
parent
3806f9f4f0
commit
e0fbb96ee7
@@ -241,3 +241,219 @@ class NonCohortedTopicGroupIdTestMixin(GroupIdAssertionMixin):
|
||||
self.call_view(mock_is_forum_v2_enabled, mock_request, team.discussion_topic_id, self.student, '')
|
||||
|
||||
self._assert_comments_service_called_without_group_id(mock_request)
|
||||
|
||||
|
||||
class GroupIdAssertionMixinV2:
|
||||
"""
|
||||
Provides assertion methods for testing group_id functionality in forum v2.
|
||||
|
||||
This mixin contains helper methods to verify that the comments service is called
|
||||
with the correct group_id parameters and that responses contain the expected
|
||||
group information.
|
||||
"""
|
||||
def _get_params_last_call(self, function_name):
|
||||
"""
|
||||
Returns the data or params dict that `mock_request` was called with.
|
||||
"""
|
||||
return self.get_mock_func_calls(function_name)[-1][1]
|
||||
|
||||
def _assert_comments_service_called_with_group_id(self, group_id):
|
||||
assert self.check_mock_called('get_user_threads')
|
||||
assert self._get_params_last_call('get_user_threads')['group_id'] == group_id
|
||||
|
||||
def _assert_comments_service_called_without_group_id(self):
|
||||
assert self.check_mock_called('get_user_threads')
|
||||
assert 'group_id' not in self._get_params_last_call('get_user_threads')
|
||||
|
||||
def _assert_html_response_contains_group_info(self, response):
|
||||
group_info = {"group_id": None, "group_name": None}
|
||||
match = re.search(r'"group_id": (\d*),', response.content.decode('utf-8'))
|
||||
if match and match.group(1) != '':
|
||||
group_info["group_id"] = int(match.group(1))
|
||||
match = re.search(r'"group_name": "(\w*)"', response.content.decode('utf-8'))
|
||||
if match:
|
||||
group_info["group_name"] = match.group(1)
|
||||
self._assert_thread_contains_group_info(group_info)
|
||||
|
||||
def _assert_json_response_contains_group_info(self, response, extract_thread=None):
|
||||
"""
|
||||
:param extract_thread: a function which accepts a dictionary (complete
|
||||
json response payload) and returns another dictionary (first
|
||||
occurrence of a thread model within that payload). if None is
|
||||
passed, the identity function is assumed.
|
||||
"""
|
||||
payload = json.loads(response.content.decode('utf-8'))
|
||||
thread = extract_thread(payload) if extract_thread else payload
|
||||
self._assert_thread_contains_group_info(thread)
|
||||
|
||||
def _assert_thread_contains_group_info(self, thread):
|
||||
assert thread['group_id'] == self.student_cohort.id
|
||||
assert thread['group_name'] == self.student_cohort.name
|
||||
|
||||
|
||||
class CohortedTopicGroupIdTestMixinV2(GroupIdAssertionMixinV2):
|
||||
"""
|
||||
Provides test cases to verify that views pass the correct `group_id` to
|
||||
the comments service when requesting content in cohorted discussions for forum v2.
|
||||
"""
|
||||
def call_view(self, commentable_id, user, group_id, pass_group_id=True):
|
||||
"""
|
||||
Call the view for the implementing test class, constructing a request
|
||||
from the parameters.
|
||||
"""
|
||||
pass # lint-amnesty, pylint: disable=unnecessary-pass
|
||||
|
||||
def test_cohorted_topic_student_without_group_id(self):
|
||||
self.call_view("cohorted_topic", self.student, '', pass_group_id=False)
|
||||
self._assert_comments_service_called_with_group_id(self.student_cohort.id)
|
||||
|
||||
def test_cohorted_topic_student_none_group_id(self):
|
||||
self.call_view("cohorted_topic", self.student, "")
|
||||
self._assert_comments_service_called_with_group_id(self.student_cohort.id)
|
||||
|
||||
def test_cohorted_topic_student_with_own_group_id(self):
|
||||
self.call_view("cohorted_topic", self.student, self.student_cohort.id)
|
||||
self._assert_comments_service_called_with_group_id(self.student_cohort.id)
|
||||
|
||||
def test_cohorted_topic_student_with_other_group_id(self):
|
||||
self.call_view(
|
||||
"cohorted_topic",
|
||||
self.student,
|
||||
self.moderator_cohort.id
|
||||
)
|
||||
self._assert_comments_service_called_with_group_id(self.student_cohort.id)
|
||||
|
||||
def test_cohorted_topic_moderator_without_group_id(self):
|
||||
self.call_view(
|
||||
"cohorted_topic",
|
||||
self.moderator,
|
||||
'',
|
||||
pass_group_id=False
|
||||
)
|
||||
self._assert_comments_service_called_without_group_id()
|
||||
|
||||
def test_cohorted_topic_moderator_none_group_id(self):
|
||||
self.call_view("cohorted_topic", self.moderator, "")
|
||||
self._assert_comments_service_called_without_group_id()
|
||||
|
||||
def test_cohorted_topic_moderator_with_own_group_id(self):
|
||||
self.call_view(
|
||||
"cohorted_topic",
|
||||
self.moderator,
|
||||
self.moderator_cohort.id
|
||||
)
|
||||
self._assert_comments_service_called_with_group_id(self.moderator_cohort.id)
|
||||
|
||||
def test_cohorted_topic_moderator_with_other_group_id(self):
|
||||
self.call_view(
|
||||
"cohorted_topic",
|
||||
self.moderator,
|
||||
self.student_cohort.id
|
||||
)
|
||||
self._assert_comments_service_called_with_group_id(self.student_cohort.id)
|
||||
|
||||
def test_cohorted_topic_moderator_with_invalid_group_id(self):
|
||||
invalid_id = self.student_cohort.id + self.moderator_cohort.id
|
||||
response = self.call_view("cohorted_topic", self.moderator, invalid_id) # lint-amnesty, pylint: disable=assignment-from-no-return
|
||||
assert response.status_code == 500
|
||||
|
||||
def test_cohorted_topic_enrollment_track_invalid_group_id(self):
|
||||
CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.AUDIT)
|
||||
CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.VERIFIED)
|
||||
discussion_settings = CourseDiscussionSettings.get(self.course.id)
|
||||
discussion_settings.update({
|
||||
'divided_discussions': ['cohorted_topic'],
|
||||
'division_scheme': CourseDiscussionSettings.ENROLLMENT_TRACK,
|
||||
'always_divide_inline_discussions': True,
|
||||
})
|
||||
|
||||
invalid_id = -1000
|
||||
response = self.call_view("cohorted_topic", self.moderator, invalid_id) # lint-amnesty, pylint: disable=assignment-from-no-return
|
||||
assert response.status_code == 500
|
||||
|
||||
|
||||
class NonCohortedTopicGroupIdTestMixinV2(GroupIdAssertionMixinV2):
|
||||
"""
|
||||
Provides test cases to verify that views pass the correct `group_id` to
|
||||
the comments service when requesting content in non-cohorted discussions for forum v2.
|
||||
"""
|
||||
def call_view(self, commentable_id, user, group_id, pass_group_id=True):
|
||||
"""
|
||||
Call the view for the implementing test class, constructing a request
|
||||
from the parameters.
|
||||
"""
|
||||
pass # lint-amnesty, pylint: disable=unnecessary-pass
|
||||
|
||||
def test_non_cohorted_topic_student_without_group_id(self):
|
||||
self.call_view(
|
||||
"non_cohorted_topic",
|
||||
self.student,
|
||||
'',
|
||||
pass_group_id=False
|
||||
)
|
||||
self._assert_comments_service_called_without_group_id()
|
||||
|
||||
def test_non_cohorted_topic_student_none_group_id(self):
|
||||
self.call_view("non_cohorted_topic", self.student, '')
|
||||
self._assert_comments_service_called_without_group_id()
|
||||
|
||||
def test_non_cohorted_topic_student_with_own_group_id(self):
|
||||
self.call_view(
|
||||
"non_cohorted_topic",
|
||||
self.student,
|
||||
self.student_cohort.id
|
||||
)
|
||||
self._assert_comments_service_called_without_group_id()
|
||||
|
||||
def test_non_cohorted_topic_student_with_other_group_id(self):
|
||||
self.call_view(
|
||||
"non_cohorted_topic",
|
||||
self.student,
|
||||
self.moderator_cohort.id
|
||||
)
|
||||
self._assert_comments_service_called_without_group_id()
|
||||
|
||||
def test_non_cohorted_topic_moderator_without_group_id(self):
|
||||
self.call_view(
|
||||
"non_cohorted_topic",
|
||||
self.moderator,
|
||||
"",
|
||||
pass_group_id=False,
|
||||
)
|
||||
self._assert_comments_service_called_without_group_id()
|
||||
|
||||
def test_non_cohorted_topic_moderator_none_group_id(self):
|
||||
self.call_view("non_cohorted_topic", self.moderator, '')
|
||||
self._assert_comments_service_called_without_group_id()
|
||||
|
||||
def test_non_cohorted_topic_moderator_with_own_group_id(self):
|
||||
self.call_view(
|
||||
"non_cohorted_topic",
|
||||
self.moderator,
|
||||
self.moderator_cohort.id,
|
||||
)
|
||||
self._assert_comments_service_called_without_group_id()
|
||||
|
||||
def test_non_cohorted_topic_moderator_with_other_group_id(self):
|
||||
self.call_view(
|
||||
"non_cohorted_topic",
|
||||
self.moderator,
|
||||
self.student_cohort.id,
|
||||
)
|
||||
self._assert_comments_service_called_without_group_id()
|
||||
|
||||
def test_non_cohorted_topic_moderator_with_invalid_group_id(self):
|
||||
invalid_id = self.student_cohort.id + self.moderator_cohort.id
|
||||
self.call_view("non_cohorted_topic", self.moderator, invalid_id)
|
||||
self._assert_comments_service_called_without_group_id()
|
||||
|
||||
def test_team_discussion_id_not_cohorted(self):
|
||||
team = CourseTeamFactory(
|
||||
course_id=self.course.id,
|
||||
topic_id='topic-id'
|
||||
)
|
||||
|
||||
team.add_user(self.student)
|
||||
self.call_view(team.discussion_topic_id, self.student, '')
|
||||
|
||||
self._assert_comments_service_called_without_group_id()
|
||||
|
||||
@@ -21,7 +21,6 @@ from opaque_keys.edx.locator import CourseLocator
|
||||
from pytz import UTC
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory
|
||||
@@ -48,7 +47,6 @@ from lms.djangoapps.discussion.rest_api.api import (
|
||||
get_course_topics,
|
||||
get_course_topics_v2,
|
||||
get_thread,
|
||||
get_thread_list,
|
||||
get_user_comments,
|
||||
update_comment,
|
||||
update_thread
|
||||
@@ -77,7 +75,6 @@ from openedx.core.djangoapps.django_comment_common.models import (
|
||||
FORUM_ROLE_COMMUNITY_TA,
|
||||
FORUM_ROLE_MODERATOR,
|
||||
FORUM_ROLE_STUDENT,
|
||||
FORUM_ROLE_GROUP_MODERATOR,
|
||||
Role
|
||||
)
|
||||
from openedx.core.lib.exceptions import CourseNotFoundError, PageNotFoundError
|
||||
@@ -698,542 +695,6 @@ class GetCourseTopicsTest(CommentsServiceMockMixin, ForumsEnableMixin, UrlResetM
|
||||
}
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
|
||||
class GetThreadListTest(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMixin, SharedModuleStoreTestCase):
|
||||
"""Test for get_thread_list"""
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.course = CourseFactory.create()
|
||||
|
||||
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
httpretty.reset()
|
||||
httpretty.enable()
|
||||
self.addCleanup(httpretty.reset)
|
||||
self.addCleanup(httpretty.disable)
|
||||
self.maxDiff = None # pylint: disable=invalid-name
|
||||
self.user = UserFactory.create()
|
||||
self.register_get_user_response(self.user)
|
||||
self.request = RequestFactory().get("/test_path")
|
||||
self.request.user = self.user
|
||||
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
|
||||
self.author = UserFactory.create()
|
||||
self.course.cohort_config = {"cohorted": False}
|
||||
modulestore().update_item(self.course, ModuleStoreEnum.UserID.test)
|
||||
self.cohort = CohortFactory.create(course_id=self.course.id)
|
||||
|
||||
def get_thread_list(
|
||||
self,
|
||||
threads,
|
||||
page=1,
|
||||
page_size=1,
|
||||
num_pages=1,
|
||||
course=None,
|
||||
topic_id_list=None,
|
||||
):
|
||||
"""
|
||||
Register the appropriate comments service response, then call
|
||||
get_thread_list and return the result.
|
||||
"""
|
||||
course = course or self.course
|
||||
self.register_get_threads_response(threads, page, num_pages)
|
||||
ret = get_thread_list(self.request, course.id, page, page_size, topic_id_list)
|
||||
return ret
|
||||
|
||||
def test_nonexistent_course(self):
|
||||
with pytest.raises(CourseNotFoundError):
|
||||
get_thread_list(self.request, CourseLocator.from_string("course-v1:non+existent+course"), 1, 1)
|
||||
|
||||
def test_not_enrolled(self):
|
||||
self.request.user = UserFactory.create()
|
||||
with pytest.raises(CourseNotFoundError):
|
||||
self.get_thread_list([])
|
||||
|
||||
def test_discussions_disabled(self):
|
||||
with pytest.raises(DiscussionDisabledError):
|
||||
self.get_thread_list([], course=_discussion_disabled_course_for(self.user))
|
||||
|
||||
def test_empty(self):
|
||||
assert self.get_thread_list(
|
||||
[], num_pages=0
|
||||
).data == {
|
||||
'pagination': {
|
||||
'next': None,
|
||||
'previous': None,
|
||||
'num_pages': 0,
|
||||
'count': 0
|
||||
},
|
||||
'results': [],
|
||||
'text_search_rewrite': None
|
||||
}
|
||||
|
||||
def test_get_threads_by_topic_id(self):
|
||||
self.get_thread_list([], topic_id_list=["topic_x", "topic_meow"])
|
||||
assert urlparse(httpretty.last_request().path).path == '/api/v1/threads' # lint-amnesty, pylint: disable=no-member
|
||||
self.assert_last_query_params({
|
||||
"user_id": [str(self.user.id)],
|
||||
"course_id": [str(self.course.id)],
|
||||
"sort_key": ["activity"],
|
||||
"page": ["1"],
|
||||
"per_page": ["1"],
|
||||
"commentable_ids": ["topic_x,topic_meow"]
|
||||
})
|
||||
|
||||
def test_basic_query_params(self):
|
||||
self.get_thread_list([], page=6, page_size=14)
|
||||
self.assert_last_query_params({
|
||||
"user_id": [str(self.user.id)],
|
||||
"course_id": [str(self.course.id)],
|
||||
"sort_key": ["activity"],
|
||||
"page": ["6"],
|
||||
"per_page": ["14"],
|
||||
})
|
||||
|
||||
def test_thread_content(self):
|
||||
self.course.cohort_config = {"cohorted": True}
|
||||
modulestore().update_item(self.course, ModuleStoreEnum.UserID.test)
|
||||
source_threads = [
|
||||
make_minimal_cs_thread({
|
||||
"id": "test_thread_id_0",
|
||||
"course_id": str(self.course.id),
|
||||
"commentable_id": "topic_x",
|
||||
"username": self.author.username,
|
||||
"user_id": str(self.author.id),
|
||||
"title": "Test Title",
|
||||
"body": "Test body",
|
||||
"votes": {"up_count": 4},
|
||||
"comments_count": 5,
|
||||
"unread_comments_count": 3,
|
||||
"endorsed": True,
|
||||
"read": True,
|
||||
"created_at": "2015-04-28T00:00:00Z",
|
||||
"updated_at": "2015-04-28T11:11:11Z",
|
||||
}),
|
||||
make_minimal_cs_thread({
|
||||
"id": "test_thread_id_1",
|
||||
"course_id": str(self.course.id),
|
||||
"commentable_id": "topic_y",
|
||||
"group_id": self.cohort.id,
|
||||
"username": self.author.username,
|
||||
"user_id": str(self.author.id),
|
||||
"thread_type": "question",
|
||||
"title": "Another Test Title",
|
||||
"body": "More content",
|
||||
"votes": {"up_count": 9},
|
||||
"comments_count": 18,
|
||||
"created_at": "2015-04-28T22:22:22Z",
|
||||
"updated_at": "2015-04-28T00:33:33Z",
|
||||
})
|
||||
]
|
||||
expected_threads = [
|
||||
self.expected_thread_data({
|
||||
"id": "test_thread_id_0",
|
||||
"author": self.author.username,
|
||||
"topic_id": "topic_x",
|
||||
"vote_count": 4,
|
||||
"comment_count": 6,
|
||||
"unread_comment_count": 3,
|
||||
"comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_0",
|
||||
"editable_fields": ["abuse_flagged", "copy_link", "following", "read", "voted"],
|
||||
"has_endorsed": True,
|
||||
"read": True,
|
||||
"created_at": "2015-04-28T00:00:00Z",
|
||||
"updated_at": "2015-04-28T11:11:11Z",
|
||||
"abuse_flagged_count": None,
|
||||
"can_delete": False,
|
||||
}),
|
||||
self.expected_thread_data({
|
||||
"id": "test_thread_id_1",
|
||||
"author": self.author.username,
|
||||
"topic_id": "topic_y",
|
||||
"group_id": self.cohort.id,
|
||||
"group_name": self.cohort.name,
|
||||
"type": "question",
|
||||
"title": "Another Test Title",
|
||||
"raw_body": "More content",
|
||||
"preview_body": "More content",
|
||||
"rendered_body": "<p>More content</p>",
|
||||
"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):
|
||||
|
||||
@@ -92,6 +92,7 @@ from openedx.core.djangoapps.discussions.tasks import (
|
||||
from openedx.core.djangoapps.django_comment_common.models import (
|
||||
FORUM_ROLE_ADMINISTRATOR,
|
||||
FORUM_ROLE_COMMUNITY_TA,
|
||||
FORUM_ROLE_GROUP_MODERATOR,
|
||||
FORUM_ROLE_MODERATOR,
|
||||
FORUM_ROLE_STUDENT,
|
||||
Role,
|
||||
@@ -1054,3 +1055,639 @@ class UpdateCommentTest(
|
||||
vote_count -= 1
|
||||
assert result["vote_count"] == vote_count
|
||||
self.register_get_user_response(self.user, upvoted_ids=[])
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
|
||||
class GetThreadListTest(
|
||||
ForumsEnableMixin, ForumMockUtilsMixin, UrlResetMixin, SharedModuleStoreTestCase
|
||||
):
|
||||
"""Test for get_thread_list"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
super().setUpClassAndForumMock()
|
||||
cls.course = CourseFactory.create()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
"""Stop patches after tests complete."""
|
||||
super().tearDownClass()
|
||||
super().disposeForumMocks()
|
||||
|
||||
@mock.patch.dict(
|
||||
"django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}
|
||||
)
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
httpretty.reset()
|
||||
httpretty.enable()
|
||||
self.addCleanup(httpretty.reset)
|
||||
self.addCleanup(httpretty.disable)
|
||||
self.maxDiff = None # pylint: disable=invalid-name
|
||||
self.user = UserFactory.create()
|
||||
self.register_get_user_response(self.user)
|
||||
self.request = RequestFactory().get("/test_path")
|
||||
self.request.user = self.user
|
||||
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
|
||||
self.author = UserFactory.create()
|
||||
self.course.cohort_config = {"cohorted": False}
|
||||
modulestore().update_item(self.course, ModuleStoreEnum.UserID.test)
|
||||
self.cohort = CohortFactory.create(course_id=self.course.id)
|
||||
|
||||
def get_thread_list(
|
||||
self,
|
||||
threads,
|
||||
page=1,
|
||||
page_size=1,
|
||||
num_pages=1,
|
||||
course=None,
|
||||
topic_id_list=None,
|
||||
):
|
||||
"""
|
||||
Register the appropriate comments service response, then call
|
||||
get_thread_list and return the result.
|
||||
"""
|
||||
course = course or self.course
|
||||
self.register_get_threads_response(threads, page, num_pages)
|
||||
ret = get_thread_list(self.request, course.id, page, page_size, topic_id_list)
|
||||
return ret
|
||||
|
||||
def test_nonexistent_course(self):
|
||||
with pytest.raises(CourseNotFoundError):
|
||||
get_thread_list(
|
||||
self.request,
|
||||
CourseLocator.from_string("course-v1:non+existent+course"),
|
||||
1,
|
||||
1,
|
||||
)
|
||||
|
||||
def test_not_enrolled(self):
|
||||
self.request.user = UserFactory.create()
|
||||
with pytest.raises(CourseNotFoundError):
|
||||
self.get_thread_list([])
|
||||
|
||||
def test_discussions_disabled(self):
|
||||
with pytest.raises(DiscussionDisabledError):
|
||||
self.get_thread_list([], course=_discussion_disabled_course_for(self.user))
|
||||
|
||||
def test_empty(self):
|
||||
assert self.get_thread_list([], num_pages=0).data == {
|
||||
"pagination": {"next": None, "previous": None, "num_pages": 0, "count": 0},
|
||||
"results": [],
|
||||
"text_search_rewrite": None,
|
||||
}
|
||||
|
||||
def test_get_threads_by_topic_id(self):
|
||||
self.get_thread_list([], topic_id_list=["topic_x", "topic_meow"])
|
||||
self.check_mock_called("get_user_threads")
|
||||
params = {
|
||||
"user_id": str(self.user.id),
|
||||
"course_id": str(self.course.id),
|
||||
"sort_key": "activity",
|
||||
"page": 1,
|
||||
"per_page": 1,
|
||||
"commentable_ids": ["topic_x", "topic_meow"],
|
||||
}
|
||||
self.check_mock_called_with(
|
||||
"get_user_threads",
|
||||
-1,
|
||||
**params,
|
||||
)
|
||||
|
||||
def test_basic_query_params(self):
|
||||
self.get_thread_list([], page=6, page_size=14)
|
||||
params = {
|
||||
"user_id": str(self.user.id),
|
||||
"course_id": str(self.course.id),
|
||||
"sort_key": "activity",
|
||||
"page": 6,
|
||||
"per_page": 14,
|
||||
}
|
||||
self.check_mock_called_with(
|
||||
"get_user_threads",
|
||||
-1,
|
||||
**params,
|
||||
)
|
||||
|
||||
def test_thread_content(self):
|
||||
self.course.cohort_config = {"cohorted": True}
|
||||
modulestore().update_item(self.course, ModuleStoreEnum.UserID.test)
|
||||
source_threads = [
|
||||
make_minimal_cs_thread(
|
||||
{
|
||||
"id": "test_thread_id_0",
|
||||
"course_id": str(self.course.id),
|
||||
"commentable_id": "topic_x",
|
||||
"username": self.author.username,
|
||||
"user_id": str(self.author.id),
|
||||
"title": "Test Title",
|
||||
"body": "Test body",
|
||||
"votes": {"up_count": 4},
|
||||
"comments_count": 5,
|
||||
"unread_comments_count": 3,
|
||||
"endorsed": True,
|
||||
"read": True,
|
||||
"created_at": "2015-04-28T00:00:00Z",
|
||||
"updated_at": "2015-04-28T11:11:11Z",
|
||||
}
|
||||
),
|
||||
make_minimal_cs_thread(
|
||||
{
|
||||
"id": "test_thread_id_1",
|
||||
"course_id": str(self.course.id),
|
||||
"commentable_id": "topic_y",
|
||||
"group_id": self.cohort.id,
|
||||
"username": self.author.username,
|
||||
"user_id": str(self.author.id),
|
||||
"thread_type": "question",
|
||||
"title": "Another Test Title",
|
||||
"body": "More content",
|
||||
"votes": {"up_count": 9},
|
||||
"comments_count": 18,
|
||||
"created_at": "2015-04-28T22:22:22Z",
|
||||
"updated_at": "2015-04-28T00:33:33Z",
|
||||
}
|
||||
),
|
||||
]
|
||||
expected_threads = [
|
||||
self.expected_thread_data(
|
||||
{
|
||||
"id": "test_thread_id_0",
|
||||
"author": self.author.username,
|
||||
"topic_id": "topic_x",
|
||||
"vote_count": 4,
|
||||
"comment_count": 6,
|
||||
"unread_comment_count": 3,
|
||||
"comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_0",
|
||||
"editable_fields": [
|
||||
"abuse_flagged",
|
||||
"copy_link",
|
||||
"following",
|
||||
"read",
|
||||
"voted",
|
||||
],
|
||||
"has_endorsed": True,
|
||||
"read": True,
|
||||
"created_at": "2015-04-28T00:00:00Z",
|
||||
"updated_at": "2015-04-28T11:11:11Z",
|
||||
"abuse_flagged_count": None,
|
||||
"can_delete": False,
|
||||
}
|
||||
),
|
||||
self.expected_thread_data(
|
||||
{
|
||||
"id": "test_thread_id_1",
|
||||
"author": self.author.username,
|
||||
"topic_id": "topic_y",
|
||||
"group_id": self.cohort.id,
|
||||
"group_name": self.cohort.name,
|
||||
"type": "question",
|
||||
"title": "Another Test Title",
|
||||
"raw_body": "More content",
|
||||
"preview_body": "More content",
|
||||
"rendered_body": "<p>More content</p>",
|
||||
"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
|
||||
|
||||
@@ -1056,354 +1056,6 @@ class CourseTopicsViewV3Test(DiscussionAPIViewTestMixin, CommentsServiceMockMixi
|
||||
assert vertical_keys == expected_non_courseware_keys
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@httpretty.activate
|
||||
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
|
||||
class ThreadViewSetListTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase, ProfileImageTestMixin):
|
||||
"""Tests for ThreadViewSet list"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.author = UserFactory.create()
|
||||
self.url = reverse("thread-list")
|
||||
patcher = mock.patch(
|
||||
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
|
||||
return_value=False
|
||||
)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def create_source_thread(self, overrides=None):
|
||||
"""
|
||||
Create a sample source cs_thread
|
||||
"""
|
||||
thread = make_minimal_cs_thread({
|
||||
"id": "test_thread",
|
||||
"course_id": str(self.course.id),
|
||||
"commentable_id": "test_topic",
|
||||
"user_id": str(self.user.id),
|
||||
"username": self.user.username,
|
||||
"created_at": "2015-04-28T00:00:00Z",
|
||||
"updated_at": "2015-04-28T11:11:11Z",
|
||||
"title": "Test Title",
|
||||
"body": "Test body",
|
||||
"votes": {"up_count": 4},
|
||||
"comments_count": 5,
|
||||
"unread_comments_count": 3,
|
||||
})
|
||||
|
||||
thread.update(overrides or {})
|
||||
return thread
|
||||
|
||||
def test_course_id_missing(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assert_response_correct(
|
||||
response,
|
||||
400,
|
||||
{"field_errors": {"course_id": {"developer_message": "This field is required."}}}
|
||||
)
|
||||
|
||||
def test_404(self):
|
||||
response = self.client.get(self.url, {"course_id": "non/existent/course"})
|
||||
self.assert_response_correct(
|
||||
response,
|
||||
404,
|
||||
{"developer_message": "Course not found."}
|
||||
)
|
||||
|
||||
def test_basic(self):
|
||||
self.register_get_user_response(self.user, upvoted_ids=["test_thread"])
|
||||
source_threads = [
|
||||
self.create_source_thread({"user_id": str(self.author.id), "username": self.author.username})
|
||||
]
|
||||
expected_threads = [self.expected_thread_data({
|
||||
"created_at": "2015-04-28T00:00:00Z",
|
||||
"updated_at": "2015-04-28T11:11:11Z",
|
||||
"vote_count": 4,
|
||||
"comment_count": 6,
|
||||
"can_delete": False,
|
||||
"unread_comment_count": 3,
|
||||
"voted": True,
|
||||
"author": self.author.username,
|
||||
"editable_fields": ["abuse_flagged", "copy_link", "following", "read", "voted"],
|
||||
"abuse_flagged_count": None,
|
||||
})]
|
||||
self.register_get_threads_response(source_threads, page=1, num_pages=2)
|
||||
response = self.client.get(self.url, {"course_id": str(self.course.id), "following": ""})
|
||||
expected_response = make_paginated_api_response(
|
||||
results=expected_threads,
|
||||
count=1,
|
||||
num_pages=2,
|
||||
next_link="http://testserver/api/discussion/v1/threads/?course_id=course-v1%3Ax%2By%2Bz&following=&page=2",
|
||||
previous_link=None
|
||||
)
|
||||
expected_response.update({"text_search_rewrite": None})
|
||||
self.assert_response_correct(
|
||||
response,
|
||||
200,
|
||||
expected_response
|
||||
)
|
||||
self.assert_last_query_params({
|
||||
"user_id": [str(self.user.id)],
|
||||
"course_id": [str(self.course.id)],
|
||||
"sort_key": ["activity"],
|
||||
"page": ["1"],
|
||||
"per_page": ["10"],
|
||||
})
|
||||
|
||||
@ddt.data("unread", "unanswered", "unresponded")
|
||||
def test_view_query(self, query):
|
||||
threads = [make_minimal_cs_thread()]
|
||||
self.register_get_user_response(self.user)
|
||||
self.register_get_threads_response(threads, page=1, num_pages=1)
|
||||
self.client.get(
|
||||
self.url,
|
||||
{
|
||||
"course_id": str(self.course.id),
|
||||
"view": query,
|
||||
}
|
||||
)
|
||||
self.assert_last_query_params({
|
||||
"user_id": [str(self.user.id)],
|
||||
"course_id": [str(self.course.id)],
|
||||
"sort_key": ["activity"],
|
||||
"page": ["1"],
|
||||
"per_page": ["10"],
|
||||
query: ["true"],
|
||||
})
|
||||
|
||||
def test_pagination(self):
|
||||
self.register_get_user_response(self.user)
|
||||
self.register_get_threads_response([], page=1, num_pages=1)
|
||||
response = self.client.get(
|
||||
self.url,
|
||||
{"course_id": str(self.course.id), "page": "18", "page_size": "4"}
|
||||
)
|
||||
self.assert_response_correct(
|
||||
response,
|
||||
404,
|
||||
{"developer_message": "Page not found (No results on this page)."}
|
||||
)
|
||||
self.assert_last_query_params({
|
||||
"user_id": [str(self.user.id)],
|
||||
"course_id": [str(self.course.id)],
|
||||
"sort_key": ["activity"],
|
||||
"page": ["18"],
|
||||
"per_page": ["4"],
|
||||
})
|
||||
|
||||
def test_text_search(self):
|
||||
self.register_get_user_response(self.user)
|
||||
self.register_get_threads_search_response([], None, num_pages=0)
|
||||
response = self.client.get(
|
||||
self.url,
|
||||
{"course_id": str(self.course.id), "text_search": "test search string"}
|
||||
)
|
||||
|
||||
expected_response = make_paginated_api_response(
|
||||
results=[], count=0, num_pages=0, next_link=None, previous_link=None
|
||||
)
|
||||
expected_response.update({"text_search_rewrite": None})
|
||||
self.assert_response_correct(
|
||||
response,
|
||||
200,
|
||||
expected_response
|
||||
)
|
||||
self.assert_last_query_params({
|
||||
"user_id": [str(self.user.id)],
|
||||
"course_id": [str(self.course.id)],
|
||||
"sort_key": ["activity"],
|
||||
"page": ["1"],
|
||||
"per_page": ["10"],
|
||||
"text": ["test search string"],
|
||||
})
|
||||
|
||||
@ddt.data(True, "true", "1")
|
||||
def test_following_true(self, following):
|
||||
self.register_get_user_response(self.user)
|
||||
self.register_subscribed_threads_response(self.user, [], page=1, num_pages=0)
|
||||
response = self.client.get(
|
||||
self.url,
|
||||
{
|
||||
"course_id": str(self.course.id),
|
||||
"following": following,
|
||||
}
|
||||
)
|
||||
|
||||
expected_response = make_paginated_api_response(
|
||||
results=[], count=0, num_pages=0, next_link=None, previous_link=None
|
||||
)
|
||||
expected_response.update({"text_search_rewrite": None})
|
||||
self.assert_response_correct(
|
||||
response,
|
||||
200,
|
||||
expected_response
|
||||
)
|
||||
assert urlparse(
|
||||
httpretty.last_request().path # lint-amnesty, pylint: disable=no-member
|
||||
).path == f"/api/v1/users/{self.user.id}/subscribed_threads"
|
||||
|
||||
@ddt.data(False, "false", "0")
|
||||
def test_following_false(self, following):
|
||||
response = self.client.get(
|
||||
self.url,
|
||||
{
|
||||
"course_id": str(self.course.id),
|
||||
"following": following,
|
||||
}
|
||||
)
|
||||
self.assert_response_correct(
|
||||
response,
|
||||
400,
|
||||
{"field_errors": {
|
||||
"following": {"developer_message": "The value of the 'following' parameter must be true."}
|
||||
}}
|
||||
)
|
||||
|
||||
def test_following_error(self):
|
||||
response = self.client.get(
|
||||
self.url,
|
||||
{
|
||||
"course_id": str(self.course.id),
|
||||
"following": "invalid-boolean",
|
||||
}
|
||||
)
|
||||
self.assert_response_correct(
|
||||
response,
|
||||
400,
|
||||
{"field_errors": {
|
||||
"following": {"developer_message": "Invalid Boolean Value."}
|
||||
}}
|
||||
)
|
||||
|
||||
@ddt.data(
|
||||
("last_activity_at", "activity"),
|
||||
("comment_count", "comments"),
|
||||
("vote_count", "votes")
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_order_by(self, http_query, cc_query):
|
||||
"""
|
||||
Tests the order_by parameter
|
||||
|
||||
Arguments:
|
||||
http_query (str): Query string sent in the http request
|
||||
cc_query (str): Query string used for the comments client service
|
||||
"""
|
||||
threads = [make_minimal_cs_thread()]
|
||||
self.register_get_user_response(self.user)
|
||||
self.register_get_threads_response(threads, page=1, num_pages=1)
|
||||
self.client.get(
|
||||
self.url,
|
||||
{
|
||||
"course_id": str(self.course.id),
|
||||
"order_by": http_query,
|
||||
}
|
||||
)
|
||||
self.assert_last_query_params({
|
||||
"user_id": [str(self.user.id)],
|
||||
"course_id": [str(self.course.id)],
|
||||
"page": ["1"],
|
||||
"per_page": ["10"],
|
||||
"sort_key": [cc_query],
|
||||
})
|
||||
|
||||
def test_order_direction(self):
|
||||
"""
|
||||
Test order direction, of which "desc" is the only valid option. The
|
||||
option actually just gets swallowed, so it doesn't affect the params.
|
||||
"""
|
||||
threads = [make_minimal_cs_thread()]
|
||||
self.register_get_user_response(self.user)
|
||||
self.register_get_threads_response(threads, page=1, num_pages=1)
|
||||
self.client.get(
|
||||
self.url,
|
||||
{
|
||||
"course_id": str(self.course.id),
|
||||
"order_direction": "desc",
|
||||
}
|
||||
)
|
||||
self.assert_last_query_params({
|
||||
"user_id": [str(self.user.id)],
|
||||
"course_id": [str(self.course.id)],
|
||||
"sort_key": ["activity"],
|
||||
"page": ["1"],
|
||||
"per_page": ["10"],
|
||||
})
|
||||
|
||||
def test_mutually_exclusive(self):
|
||||
"""
|
||||
Tests GET thread_list api does not allow filtering on mutually exclusive parameters
|
||||
"""
|
||||
self.register_get_user_response(self.user)
|
||||
self.register_get_threads_search_response([], None, num_pages=0)
|
||||
response = self.client.get(self.url, {
|
||||
"course_id": str(self.course.id),
|
||||
"text_search": "test search string",
|
||||
"topic_id": "topic1, topic2",
|
||||
})
|
||||
self.assert_response_correct(
|
||||
response,
|
||||
400,
|
||||
{
|
||||
"developer_message": "The following query parameters are mutually exclusive: topic_id, "
|
||||
"text_search, following"
|
||||
}
|
||||
)
|
||||
|
||||
def test_profile_image_requested_field(self):
|
||||
"""
|
||||
Tests thread has user profile image details if called in requested_fields
|
||||
"""
|
||||
user_2 = UserFactory.create(password=self.password)
|
||||
# Ensure that parental controls don't apply to this user
|
||||
user_2.profile.year_of_birth = 1970
|
||||
user_2.profile.save()
|
||||
source_threads = [
|
||||
self.create_source_thread(),
|
||||
self.create_source_thread({"user_id": str(user_2.id), "username": user_2.username}),
|
||||
]
|
||||
|
||||
self.register_get_user_response(self.user, upvoted_ids=["test_thread"])
|
||||
self.register_get_threads_response(source_threads, page=1, num_pages=1)
|
||||
self.create_profile_image(self.user, get_profile_image_storage())
|
||||
self.create_profile_image(user_2, get_profile_image_storage())
|
||||
|
||||
response = self.client.get(
|
||||
self.url,
|
||||
{"course_id": str(self.course.id), "requested_fields": "profile_image"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
response_threads = json.loads(response.content.decode('utf-8'))['results']
|
||||
|
||||
for response_thread in response_threads:
|
||||
expected_profile_data = self.get_expected_user_profile(response_thread['author'])
|
||||
response_users = response_thread['users']
|
||||
assert expected_profile_data == response_users[response_thread['author']]
|
||||
|
||||
def test_profile_image_requested_field_anonymous_user(self):
|
||||
"""
|
||||
Tests profile_image in requested_fields for thread created with anonymous user
|
||||
"""
|
||||
source_threads = [
|
||||
self.create_source_thread(
|
||||
{"user_id": None, "username": None, "anonymous": True, "anonymous_to_peers": True}
|
||||
),
|
||||
]
|
||||
|
||||
self.register_get_user_response(self.user, upvoted_ids=["test_thread"])
|
||||
self.register_get_threads_response(source_threads, page=1, num_pages=1)
|
||||
|
||||
response = self.client.get(
|
||||
self.url,
|
||||
{"course_id": str(self.course.id), "requested_fields": "profile_image"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
response_thread = json.loads(response.content.decode('utf-8'))['results'][0]
|
||||
assert response_thread['author'] is None
|
||||
assert {} == response_thread['users']
|
||||
|
||||
|
||||
@httpretty.activate
|
||||
@disable_signal(api, 'thread_created')
|
||||
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
|
||||
|
||||
@@ -21,7 +21,9 @@ from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from edx_toggles.toggles.testutils import override_waffle_flag
|
||||
from lms.djangoapps.discussion.django_comment_client.tests.mixins import MockForumApiMixin
|
||||
from lms.djangoapps.discussion.django_comment_client.tests.mixins import (
|
||||
MockForumApiMixin,
|
||||
)
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from pytz import UTC
|
||||
from rest_framework import status
|
||||
@@ -32,18 +34,32 @@ from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE
|
||||
from lms.djangoapps.discussion.rest_api.utils import get_usernames_from_search_string
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory, check_mongo_calls
|
||||
from xmodule.modulestore.tests.django_utils import (
|
||||
ModuleStoreTestCase,
|
||||
SharedModuleStoreTestCase,
|
||||
)
|
||||
from xmodule.modulestore.tests.factories import (
|
||||
CourseFactory,
|
||||
BlockFactory,
|
||||
check_mongo_calls,
|
||||
)
|
||||
|
||||
from common.djangoapps.course_modes.models import CourseMode
|
||||
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
|
||||
from common.djangoapps.student.models import get_retired_username_by_username, CourseEnrollment
|
||||
from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole, GlobalStaff
|
||||
from common.djangoapps.student.models import (
|
||||
get_retired_username_by_username,
|
||||
CourseEnrollment,
|
||||
)
|
||||
from common.djangoapps.student.roles import (
|
||||
CourseInstructorRole,
|
||||
CourseStaffRole,
|
||||
GlobalStaff,
|
||||
)
|
||||
from common.djangoapps.student.tests.factories import (
|
||||
AdminFactory,
|
||||
CourseEnrollmentFactory,
|
||||
SuperuserFactory,
|
||||
UserFactory
|
||||
UserFactory,
|
||||
)
|
||||
from common.djangoapps.util.testing import PatchMediaTypeMixin, UrlResetMixin
|
||||
from common.test.utils import disable_signal
|
||||
@@ -65,15 +81,34 @@ from lms.djangoapps.discussion.rest_api.tests.utils import (
|
||||
parsed_body,
|
||||
)
|
||||
from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts
|
||||
from openedx.core.djangoapps.discussions.config.waffle import ENABLE_NEW_STRUCTURE_DISCUSSIONS
|
||||
from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, DiscussionTopicLink, Provider
|
||||
from openedx.core.djangoapps.discussions.tasks import update_discussions_settings_from_course_task
|
||||
from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings, Role
|
||||
from openedx.core.djangoapps.discussions.config.waffle import (
|
||||
ENABLE_NEW_STRUCTURE_DISCUSSIONS,
|
||||
)
|
||||
from openedx.core.djangoapps.discussions.models import (
|
||||
DiscussionsConfiguration,
|
||||
DiscussionTopicLink,
|
||||
Provider,
|
||||
)
|
||||
from openedx.core.djangoapps.discussions.tasks import (
|
||||
update_discussions_settings_from_course_task,
|
||||
)
|
||||
from openedx.core.djangoapps.django_comment_common.models import (
|
||||
CourseDiscussionSettings,
|
||||
Role,
|
||||
)
|
||||
from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles
|
||||
from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user
|
||||
from openedx.core.djangoapps.oauth_dispatch.tests.factories import AccessTokenFactory, ApplicationFactory
|
||||
from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_storage
|
||||
from openedx.core.djangoapps.user_api.models import RetirementState, UserRetirementStatus
|
||||
from openedx.core.djangoapps.oauth_dispatch.tests.factories import (
|
||||
AccessTokenFactory,
|
||||
ApplicationFactory,
|
||||
)
|
||||
from openedx.core.djangoapps.user_api.accounts.image_helpers import (
|
||||
get_profile_image_storage,
|
||||
)
|
||||
from openedx.core.djangoapps.user_api.models import (
|
||||
RetirementState,
|
||||
UserRetirementStatus,
|
||||
)
|
||||
|
||||
|
||||
class DiscussionAPIViewTestMixin(ForumsEnableMixin, ForumMockUtilsMixin, UrlResetMixin):
|
||||
@@ -86,7 +121,9 @@ class DiscussionAPIViewTestMixin(ForumsEnableMixin, ForumMockUtilsMixin, UrlRese
|
||||
|
||||
client_class = APIClient
|
||||
|
||||
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
|
||||
@mock.patch.dict(
|
||||
"django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}
|
||||
)
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.maxDiff = None # pylint: disable=invalid-name
|
||||
@@ -95,7 +132,7 @@ class DiscussionAPIViewTestMixin(ForumsEnableMixin, ForumMockUtilsMixin, UrlRese
|
||||
course="y",
|
||||
run="z",
|
||||
start=datetime.now(UTC),
|
||||
discussion_topics={"Test Topic": {"id": "test_topic"}}
|
||||
discussion_topics={"Test Topic": {"id": "test_topic"}},
|
||||
)
|
||||
self.password = "Password1234"
|
||||
self.user = UserFactory.create(password=self.password)
|
||||
@@ -120,23 +157,25 @@ class DiscussionAPIViewTestMixin(ForumsEnableMixin, ForumMockUtilsMixin, UrlRese
|
||||
Assert that the response has the given status code and parsed content
|
||||
"""
|
||||
assert response.status_code == expected_status
|
||||
parsed_content = json.loads(response.content.decode('utf-8'))
|
||||
parsed_content = json.loads(response.content.decode("utf-8"))
|
||||
assert parsed_content == expected_content
|
||||
|
||||
def register_thread(self, overrides=None):
|
||||
"""
|
||||
Create cs_thread with minimal fields and register response
|
||||
"""
|
||||
cs_thread = make_minimal_cs_thread({
|
||||
"id": "test_thread",
|
||||
"course_id": str(self.course.id),
|
||||
"commentable_id": "test_topic",
|
||||
"username": self.user.username,
|
||||
"user_id": str(self.user.id),
|
||||
"thread_type": "discussion",
|
||||
"title": "Test Title",
|
||||
"body": "Test body",
|
||||
})
|
||||
cs_thread = make_minimal_cs_thread(
|
||||
{
|
||||
"id": "test_thread",
|
||||
"course_id": str(self.course.id),
|
||||
"commentable_id": "test_topic",
|
||||
"username": self.user.username,
|
||||
"user_id": str(self.user.id),
|
||||
"thread_type": "discussion",
|
||||
"title": "Test Title",
|
||||
"body": "Test body",
|
||||
}
|
||||
)
|
||||
cs_thread.update(overrides or {})
|
||||
self.register_get_thread_response(cs_thread)
|
||||
self.register_put_thread_response(cs_thread)
|
||||
@@ -145,14 +184,16 @@ class DiscussionAPIViewTestMixin(ForumsEnableMixin, ForumMockUtilsMixin, UrlRese
|
||||
"""
|
||||
Create cs_comment with minimal fields and register response
|
||||
"""
|
||||
cs_comment = make_minimal_cs_comment({
|
||||
"id": "test_comment",
|
||||
"course_id": str(self.course.id),
|
||||
"thread_id": "test_thread",
|
||||
"username": self.user.username,
|
||||
"user_id": str(self.user.id),
|
||||
"body": "Original body",
|
||||
})
|
||||
cs_comment = make_minimal_cs_comment(
|
||||
{
|
||||
"id": "test_comment",
|
||||
"course_id": str(self.course.id),
|
||||
"thread_id": "test_thread",
|
||||
"username": self.user.username,
|
||||
"user_id": str(self.user.id),
|
||||
"body": "Original body",
|
||||
}
|
||||
)
|
||||
cs_comment.update(overrides or {})
|
||||
self.register_get_comment_response(cs_comment)
|
||||
self.register_put_comment_response(cs_comment)
|
||||
@@ -164,7 +205,7 @@ class DiscussionAPIViewTestMixin(ForumsEnableMixin, ForumMockUtilsMixin, UrlRese
|
||||
self.assert_response_correct(
|
||||
response,
|
||||
401,
|
||||
{"developer_message": "Authentication credentials were not provided."}
|
||||
{"developer_message": "Authentication credentials were not provided."},
|
||||
)
|
||||
|
||||
def test_inactive(self):
|
||||
@@ -174,9 +215,11 @@ class DiscussionAPIViewTestMixin(ForumsEnableMixin, ForumMockUtilsMixin, UrlRese
|
||||
|
||||
@ddt.ddt
|
||||
@httpretty.activate
|
||||
@disable_signal(api, 'thread_edited')
|
||||
@disable_signal(api, "thread_edited")
|
||||
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
|
||||
class ThreadViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin):
|
||||
class ThreadViewSetPartialUpdateTest(
|
||||
DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin
|
||||
):
|
||||
"""Tests for ThreadViewSet partial_update"""
|
||||
|
||||
def setUp(self):
|
||||
@@ -186,47 +229,58 @@ class ThreadViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTest
|
||||
|
||||
def test_basic(self):
|
||||
self.register_get_user_response(self.user)
|
||||
self.register_thread({
|
||||
"created_at": "Test Created Date",
|
||||
"updated_at": "Test Updated Date",
|
||||
"read": True,
|
||||
"resp_total": 2,
|
||||
})
|
||||
self.register_thread(
|
||||
{
|
||||
"created_at": "Test Created Date",
|
||||
"updated_at": "Test Updated Date",
|
||||
"read": True,
|
||||
"resp_total": 2,
|
||||
}
|
||||
)
|
||||
request_data = {"raw_body": "Edited body"}
|
||||
response = self.request_patch(request_data)
|
||||
assert response.status_code == 200
|
||||
response_data = json.loads(response.content.decode('utf-8'))
|
||||
assert response_data == self.expected_thread_data({
|
||||
'raw_body': 'Edited body',
|
||||
'rendered_body': '<p>Edited body</p>',
|
||||
'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": "<p>Edited body</p>",
|
||||
"preview_body": "Edited body",
|
||||
"editable_fields": [
|
||||
"abuse_flagged",
|
||||
"anonymous",
|
||||
"copy_link",
|
||||
"following",
|
||||
"raw_body",
|
||||
"read",
|
||||
"title",
|
||||
"topic_id",
|
||||
"type",
|
||||
],
|
||||
"created_at": "Test Created Date",
|
||||
"updated_at": "Test Updated Date",
|
||||
"comment_count": 1,
|
||||
"read": True,
|
||||
"response_count": 2,
|
||||
}
|
||||
)
|
||||
|
||||
params = {
|
||||
'thread_id': 'test_thread',
|
||||
'course_id': str(self.course.id),
|
||||
'commentable_id': 'test_topic',
|
||||
'thread_type': 'discussion',
|
||||
'title': 'Test Title',
|
||||
'body': 'Edited body',
|
||||
'user_id': str(self.user.id),
|
||||
'anonymous': False,
|
||||
'anonymous_to_peers': False,
|
||||
'closed': False,
|
||||
'pinned': False,
|
||||
'read': True,
|
||||
'editing_user_id': str(self.user.id),
|
||||
"thread_id": "test_thread",
|
||||
"course_id": str(self.course.id),
|
||||
"commentable_id": "test_topic",
|
||||
"thread_type": "discussion",
|
||||
"title": "Test Title",
|
||||
"body": "Edited body",
|
||||
"user_id": str(self.user.id),
|
||||
"anonymous": False,
|
||||
"anonymous_to_peers": False,
|
||||
"closed": False,
|
||||
"pinned": False,
|
||||
"read": True,
|
||||
"editing_user_id": str(self.user.id),
|
||||
}
|
||||
self.check_mock_called_with('update_thread', -1, **params)
|
||||
self.check_mock_called_with("update_thread", -1, **params)
|
||||
|
||||
def test_error(self):
|
||||
self.register_get_user_response(self.user)
|
||||
@@ -234,10 +288,12 @@ class ThreadViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTest
|
||||
request_data = {"title": ""}
|
||||
response = self.request_patch(request_data)
|
||||
expected_response_data = {
|
||||
"field_errors": {"title": {"developer_message": "This field may not be blank."}}
|
||||
"field_errors": {
|
||||
"title": {"developer_message": "This field may not be blank."}
|
||||
}
|
||||
}
|
||||
assert response.status_code == 400
|
||||
response_data = json.loads(response.content.decode('utf-8'))
|
||||
response_data = json.loads(response.content.decode("utf-8"))
|
||||
assert response_data == expected_response_data
|
||||
|
||||
@ddt.data(
|
||||
@@ -252,14 +308,17 @@ class ThreadViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTest
|
||||
request_data = {field: value}
|
||||
response = self.request_patch(request_data)
|
||||
assert response.status_code == 200
|
||||
response_data = json.loads(response.content.decode('utf-8'))
|
||||
assert response_data == self.expected_thread_data({
|
||||
'read': True,
|
||||
'closed': True,
|
||||
'abuse_flagged': value,
|
||||
'editable_fields': ['abuse_flagged', 'copy_link', 'read'],
|
||||
'comment_count': 1, 'unread_comment_count': 0
|
||||
})
|
||||
response_data = json.loads(response.content.decode("utf-8"))
|
||||
assert response_data == self.expected_thread_data(
|
||||
{
|
||||
"read": True,
|
||||
"closed": True,
|
||||
"abuse_flagged": value,
|
||||
"editable_fields": ["abuse_flagged", "copy_link", "read"],
|
||||
"comment_count": 1,
|
||||
"unread_comment_count": 0,
|
||||
}
|
||||
)
|
||||
|
||||
@ddt.data(
|
||||
("raw_body", "Edited body"),
|
||||
@@ -283,47 +342,68 @@ class ThreadViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTest
|
||||
|
||||
response = self.request_patch(request_data)
|
||||
assert response.status_code == 200
|
||||
response_data = json.loads(response.content.decode('utf-8'))
|
||||
assert response_data == self.expected_thread_data({
|
||||
'comment_count': 1,
|
||||
'read': True,
|
||||
'editable_fields': [
|
||||
'abuse_flagged', 'anonymous', 'copy_link', 'following', 'raw_body', 'read',
|
||||
'title', 'topic_id', 'type'
|
||||
],
|
||||
'response_count': 2
|
||||
})
|
||||
response_data = json.loads(response.content.decode("utf-8"))
|
||||
assert response_data == self.expected_thread_data(
|
||||
{
|
||||
"comment_count": 1,
|
||||
"read": True,
|
||||
"editable_fields": [
|
||||
"abuse_flagged",
|
||||
"anonymous",
|
||||
"copy_link",
|
||||
"following",
|
||||
"raw_body",
|
||||
"read",
|
||||
"title",
|
||||
"topic_id",
|
||||
"type",
|
||||
],
|
||||
"response_count": 2,
|
||||
}
|
||||
)
|
||||
|
||||
def test_patch_read_non_owner_user(self):
|
||||
self.register_get_user_response(self.user)
|
||||
thread_owner_user = UserFactory.create(password=self.password)
|
||||
CourseEnrollmentFactory.create(user=thread_owner_user, course_id=self.course.id)
|
||||
self.register_thread({
|
||||
"username": thread_owner_user.username,
|
||||
"user_id": str(thread_owner_user.id),
|
||||
"resp_total": 2,
|
||||
})
|
||||
self.register_thread(
|
||||
{
|
||||
"username": thread_owner_user.username,
|
||||
"user_id": str(thread_owner_user.id),
|
||||
"resp_total": 2,
|
||||
}
|
||||
)
|
||||
self.register_read_response(self.user, "thread", "test_thread")
|
||||
|
||||
request_data = {"read": True}
|
||||
response = self.request_patch(request_data)
|
||||
assert response.status_code == 200
|
||||
response_data = json.loads(response.content.decode('utf-8'))
|
||||
expected_data = self.expected_thread_data({
|
||||
'author': str(thread_owner_user.username),
|
||||
'comment_count': 1,
|
||||
'can_delete': False,
|
||||
'read': True,
|
||||
'editable_fields': ['abuse_flagged', 'copy_link', 'following', 'read', 'voted'],
|
||||
'response_count': 2
|
||||
})
|
||||
response_data = json.loads(response.content.decode("utf-8"))
|
||||
expected_data = self.expected_thread_data(
|
||||
{
|
||||
"author": str(thread_owner_user.username),
|
||||
"comment_count": 1,
|
||||
"can_delete": False,
|
||||
"read": True,
|
||||
"editable_fields": [
|
||||
"abuse_flagged",
|
||||
"copy_link",
|
||||
"following",
|
||||
"read",
|
||||
"voted",
|
||||
],
|
||||
"response_count": 2,
|
||||
}
|
||||
)
|
||||
assert response_data == expected_data
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@disable_signal(api, 'comment_edited')
|
||||
@disable_signal(api, "comment_edited")
|
||||
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
|
||||
class CommentViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin):
|
||||
class CommentViewSetPartialUpdateTest(
|
||||
DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin
|
||||
):
|
||||
"""Tests for CommentViewSet partial_update"""
|
||||
|
||||
def setUp(self):
|
||||
@@ -375,29 +455,33 @@ class CommentViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTes
|
||||
|
||||
def test_basic(self):
|
||||
self.register_thread()
|
||||
self.register_comment({"created_at": "Test Created Date", "updated_at": "Test Updated Date"})
|
||||
self.register_comment(
|
||||
{"created_at": "Test Created Date", "updated_at": "Test Updated Date"}
|
||||
)
|
||||
request_data = {"raw_body": "Edited body"}
|
||||
response = self.request_patch(request_data)
|
||||
assert response.status_code == 200
|
||||
response_data = json.loads(response.content.decode('utf-8'))
|
||||
assert response_data == self.expected_response_data({
|
||||
'raw_body': 'Edited body',
|
||||
'rendered_body': '<p>Edited body</p>',
|
||||
'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": "<p>Edited body</p>",
|
||||
"editable_fields": ["abuse_flagged", "anonymous", "raw_body"],
|
||||
"created_at": "Test Created Date",
|
||||
"updated_at": "Test Updated Date",
|
||||
}
|
||||
)
|
||||
params = {
|
||||
'comment_id': 'test_comment',
|
||||
'body': 'Edited body',
|
||||
'course_id': str(self.course.id),
|
||||
'user_id': str(self.user.id),
|
||||
'anonymous': False,
|
||||
'anonymous_to_peers': False,
|
||||
'endorsed': False,
|
||||
'editing_user_id': str(self.user.id),
|
||||
"comment_id": "test_comment",
|
||||
"body": "Edited body",
|
||||
"course_id": str(self.course.id),
|
||||
"user_id": str(self.user.id),
|
||||
"anonymous": False,
|
||||
"anonymous_to_peers": False,
|
||||
"endorsed": False,
|
||||
"editing_user_id": str(self.user.id),
|
||||
}
|
||||
self.check_mock_called_with('update_comment', -1, **params)
|
||||
self.check_mock_called_with("update_comment", -1, **params)
|
||||
|
||||
def test_error(self):
|
||||
self.register_thread()
|
||||
@@ -405,10 +489,12 @@ class CommentViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTes
|
||||
request_data = {"raw_body": ""}
|
||||
response = self.request_patch(request_data)
|
||||
expected_response_data = {
|
||||
"field_errors": {"raw_body": {"developer_message": "This field may not be blank."}}
|
||||
"field_errors": {
|
||||
"raw_body": {"developer_message": "This field may not be blank."}
|
||||
}
|
||||
}
|
||||
assert response.status_code == 400
|
||||
response_data = json.loads(response.content.decode('utf-8'))
|
||||
response_data = json.loads(response.content.decode("utf-8"))
|
||||
assert response_data == expected_response_data
|
||||
|
||||
@ddt.data(
|
||||
@@ -423,12 +509,14 @@ class CommentViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTes
|
||||
request_data = {field: value}
|
||||
response = self.request_patch(request_data)
|
||||
assert response.status_code == 200
|
||||
response_data = json.loads(response.content.decode('utf-8'))
|
||||
assert response_data == self.expected_response_data({
|
||||
'abuse_flagged': value,
|
||||
"abuse_flagged_any_user": None,
|
||||
'editable_fields': ['abuse_flagged']
|
||||
})
|
||||
response_data = json.loads(response.content.decode("utf-8"))
|
||||
assert response_data == self.expected_response_data(
|
||||
{
|
||||
"abuse_flagged": value,
|
||||
"abuse_flagged_any_user": None,
|
||||
"editable_fields": ["abuse_flagged"],
|
||||
}
|
||||
)
|
||||
|
||||
@ddt.data(
|
||||
("raw_body", "Edited body"),
|
||||
@@ -442,3 +530,396 @@ class CommentViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTes
|
||||
request_data = {field: value}
|
||||
response = self.request_patch(request_data)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@httpretty.activate
|
||||
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
|
||||
class ThreadViewSetListTest(
|
||||
DiscussionAPIViewTestMixin, ModuleStoreTestCase, ProfileImageTestMixin
|
||||
):
|
||||
"""Tests for ThreadViewSet list"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.author = UserFactory.create()
|
||||
self.url = reverse("thread-list")
|
||||
|
||||
def create_source_thread(self, overrides=None):
|
||||
"""
|
||||
Create a sample source cs_thread
|
||||
"""
|
||||
thread = make_minimal_cs_thread(
|
||||
{
|
||||
"id": "test_thread",
|
||||
"course_id": str(self.course.id),
|
||||
"commentable_id": "test_topic",
|
||||
"user_id": str(self.user.id),
|
||||
"username": self.user.username,
|
||||
"created_at": "2015-04-28T00:00:00Z",
|
||||
"updated_at": "2015-04-28T11:11:11Z",
|
||||
"title": "Test Title",
|
||||
"body": "Test body",
|
||||
"votes": {"up_count": 4},
|
||||
"comments_count": 5,
|
||||
"unread_comments_count": 3,
|
||||
}
|
||||
)
|
||||
|
||||
thread.update(overrides or {})
|
||||
return thread
|
||||
|
||||
def test_course_id_missing(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assert_response_correct(
|
||||
response,
|
||||
400,
|
||||
{"field_errors": {"course_id": {"developer_message": "This field is required."}}}
|
||||
)
|
||||
|
||||
def test_404(self):
|
||||
response = self.client.get(self.url, {"course_id": "non/existent/course"})
|
||||
self.assert_response_correct(
|
||||
response,
|
||||
404,
|
||||
{"developer_message": "Course not found."}
|
||||
)
|
||||
|
||||
def test_basic(self):
|
||||
self.register_get_user_response(self.user, upvoted_ids=["test_thread"])
|
||||
source_threads = [
|
||||
self.create_source_thread(
|
||||
{"user_id": str(self.author.id), "username": self.author.username}
|
||||
)
|
||||
]
|
||||
expected_threads = [
|
||||
self.expected_thread_data(
|
||||
{
|
||||
"created_at": "2015-04-28T00:00:00Z",
|
||||
"updated_at": "2015-04-28T11:11:11Z",
|
||||
"vote_count": 4,
|
||||
"comment_count": 6,
|
||||
"can_delete": False,
|
||||
"unread_comment_count": 3,
|
||||
"voted": True,
|
||||
"author": self.author.username,
|
||||
"editable_fields": [
|
||||
"abuse_flagged",
|
||||
"copy_link",
|
||||
"following",
|
||||
"read",
|
||||
"voted",
|
||||
],
|
||||
"abuse_flagged_count": None,
|
||||
}
|
||||
)
|
||||
]
|
||||
self.register_get_threads_response(source_threads, page=1, num_pages=2)
|
||||
response = self.client.get(
|
||||
self.url, {"course_id": str(self.course.id), "following": ""}
|
||||
)
|
||||
expected_response = make_paginated_api_response(
|
||||
results=expected_threads,
|
||||
count=1,
|
||||
num_pages=2,
|
||||
next_link="http://testserver/api/discussion/v1/threads/?course_id=course-v1%3Ax%2By%2Bz&following=&page=2",
|
||||
previous_link=None,
|
||||
)
|
||||
expected_response.update({"text_search_rewrite": None})
|
||||
self.assert_response_correct(response, 200, expected_response)
|
||||
params = {
|
||||
"user_id": str(self.user.id),
|
||||
"course_id": str(self.course.id),
|
||||
"sort_key": "activity",
|
||||
"page": 1,
|
||||
"per_page": 10,
|
||||
}
|
||||
self.check_mock_called_with(
|
||||
"get_user_threads",
|
||||
-1,
|
||||
**params,
|
||||
)
|
||||
|
||||
@ddt.data("unread", "unanswered", "unresponded")
|
||||
def test_view_query(self, query):
|
||||
threads = [make_minimal_cs_thread()]
|
||||
self.register_get_user_response(self.user)
|
||||
self.register_get_threads_response(threads, page=1, num_pages=1)
|
||||
self.client.get(
|
||||
self.url,
|
||||
{
|
||||
"course_id": str(self.course.id),
|
||||
"view": query,
|
||||
},
|
||||
)
|
||||
params = {
|
||||
"user_id": str(self.user.id),
|
||||
"course_id": str(self.course.id),
|
||||
"sort_key": "activity",
|
||||
"page": 1,
|
||||
"per_page": 10,
|
||||
query: True,
|
||||
}
|
||||
self.check_mock_called_with(
|
||||
"get_user_threads",
|
||||
-1,
|
||||
**params,
|
||||
)
|
||||
|
||||
def test_pagination(self):
|
||||
self.register_get_user_response(self.user)
|
||||
self.register_get_threads_response([], page=1, num_pages=1)
|
||||
response = self.client.get(
|
||||
self.url, {"course_id": str(self.course.id), "page": "18", "page_size": "4"}
|
||||
)
|
||||
self.assert_response_correct(
|
||||
response,
|
||||
404,
|
||||
{"developer_message": "Page not found (No results on this page)."},
|
||||
)
|
||||
params = {
|
||||
"user_id": str(self.user.id),
|
||||
"course_id": str(self.course.id),
|
||||
"sort_key": "activity",
|
||||
"page": 18,
|
||||
"per_page": 4,
|
||||
}
|
||||
self.check_mock_called_with(
|
||||
"get_user_threads",
|
||||
-1,
|
||||
**params,
|
||||
)
|
||||
|
||||
def test_text_search(self):
|
||||
self.register_get_user_response(self.user)
|
||||
self.register_get_threads_search_response([], None, num_pages=0)
|
||||
response = self.client.get(
|
||||
self.url,
|
||||
{"course_id": str(self.course.id), "text_search": "test search string"},
|
||||
)
|
||||
|
||||
expected_response = make_paginated_api_response(
|
||||
results=[], count=0, num_pages=0, next_link=None, previous_link=None
|
||||
)
|
||||
expected_response.update({"text_search_rewrite": None})
|
||||
self.assert_response_correct(response, 200, expected_response)
|
||||
params = {
|
||||
"user_id": str(self.user.id),
|
||||
"course_id": str(self.course.id),
|
||||
"sort_key": "activity",
|
||||
"page": 1,
|
||||
"per_page": 10,
|
||||
"text": "test search string",
|
||||
}
|
||||
self.check_mock_called_with(
|
||||
"search_threads",
|
||||
-1,
|
||||
**params,
|
||||
)
|
||||
|
||||
@ddt.data(True, "true", "1")
|
||||
def test_following_true(self, following):
|
||||
self.register_get_user_response(self.user)
|
||||
self.register_subscribed_threads_response(self.user, [], page=1, num_pages=0)
|
||||
response = self.client.get(
|
||||
self.url,
|
||||
{
|
||||
"course_id": str(self.course.id),
|
||||
"following": following,
|
||||
},
|
||||
)
|
||||
|
||||
expected_response = make_paginated_api_response(
|
||||
results=[], count=0, num_pages=0, next_link=None, previous_link=None
|
||||
)
|
||||
expected_response.update({"text_search_rewrite": None})
|
||||
self.assert_response_correct(response, 200, expected_response)
|
||||
self.check_mock_called("get_user_subscriptions")
|
||||
|
||||
@ddt.data(False, "false", "0")
|
||||
def test_following_false(self, following):
|
||||
response = self.client.get(
|
||||
self.url,
|
||||
{
|
||||
"course_id": str(self.course.id),
|
||||
"following": following,
|
||||
},
|
||||
)
|
||||
self.assert_response_correct(
|
||||
response,
|
||||
400,
|
||||
{
|
||||
"field_errors": {
|
||||
"following": {
|
||||
"developer_message": "The value of the 'following' parameter must be true."
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
def test_following_error(self):
|
||||
response = self.client.get(
|
||||
self.url,
|
||||
{
|
||||
"course_id": str(self.course.id),
|
||||
"following": "invalid-boolean",
|
||||
},
|
||||
)
|
||||
self.assert_response_correct(
|
||||
response,
|
||||
400,
|
||||
{
|
||||
"field_errors": {
|
||||
"following": {"developer_message": "Invalid Boolean Value."}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@ddt.data(
|
||||
("last_activity_at", "activity"),
|
||||
("comment_count", "comments"),
|
||||
("vote_count", "votes"),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_order_by(self, http_query, cc_query):
|
||||
"""
|
||||
Tests the order_by parameter
|
||||
|
||||
Arguments:
|
||||
http_query (str): Query string sent in the http request
|
||||
cc_query (str): Query string used for the comments client service
|
||||
"""
|
||||
threads = [make_minimal_cs_thread()]
|
||||
self.register_get_user_response(self.user)
|
||||
self.register_get_threads_response(threads, page=1, num_pages=1)
|
||||
self.client.get(
|
||||
self.url,
|
||||
{
|
||||
"course_id": str(self.course.id),
|
||||
"order_by": http_query,
|
||||
},
|
||||
)
|
||||
params = {
|
||||
"user_id": str(self.user.id),
|
||||
"course_id": str(self.course.id),
|
||||
"page": 1,
|
||||
"per_page": 10,
|
||||
"sort_key": cc_query,
|
||||
}
|
||||
self.check_mock_called_with(
|
||||
"get_user_threads",
|
||||
-1,
|
||||
**params,
|
||||
)
|
||||
|
||||
def test_order_direction(self):
|
||||
"""
|
||||
Test order direction, of which "desc" is the only valid option. The
|
||||
option actually just gets swallowed, so it doesn't affect the params.
|
||||
"""
|
||||
threads = [make_minimal_cs_thread()]
|
||||
self.register_get_user_response(self.user)
|
||||
self.register_get_threads_response(threads, page=1, num_pages=1)
|
||||
self.client.get(
|
||||
self.url,
|
||||
{
|
||||
"course_id": str(self.course.id),
|
||||
"order_direction": "desc",
|
||||
},
|
||||
)
|
||||
params = {
|
||||
"user_id": str(self.user.id),
|
||||
"course_id": str(self.course.id),
|
||||
"sort_key": "activity",
|
||||
"page": 1,
|
||||
"per_page": 10,
|
||||
}
|
||||
self.check_mock_called_with(
|
||||
"get_user_threads",
|
||||
-1,
|
||||
**params,
|
||||
)
|
||||
|
||||
def test_mutually_exclusive(self):
|
||||
"""
|
||||
Tests GET thread_list api does not allow filtering on mutually exclusive parameters
|
||||
"""
|
||||
self.register_get_user_response(self.user)
|
||||
self.register_get_threads_search_response([], None, num_pages=0)
|
||||
response = self.client.get(
|
||||
self.url,
|
||||
{
|
||||
"course_id": str(self.course.id),
|
||||
"text_search": "test search string",
|
||||
"topic_id": "topic1, topic2",
|
||||
},
|
||||
)
|
||||
self.assert_response_correct(
|
||||
response,
|
||||
400,
|
||||
{
|
||||
"developer_message": "The following query parameters are mutually exclusive: topic_id, "
|
||||
"text_search, following"
|
||||
},
|
||||
)
|
||||
|
||||
def test_profile_image_requested_field(self):
|
||||
"""
|
||||
Tests thread has user profile image details if called in requested_fields
|
||||
"""
|
||||
user_2 = UserFactory.create(password=self.password)
|
||||
# Ensure that parental controls don't apply to this user
|
||||
user_2.profile.year_of_birth = 1970
|
||||
user_2.profile.save()
|
||||
source_threads = [
|
||||
self.create_source_thread(),
|
||||
self.create_source_thread(
|
||||
{"user_id": str(user_2.id), "username": user_2.username}
|
||||
),
|
||||
]
|
||||
|
||||
self.register_get_user_response(self.user, upvoted_ids=["test_thread"])
|
||||
self.register_get_threads_response(source_threads, page=1, num_pages=1)
|
||||
self.create_profile_image(self.user, get_profile_image_storage())
|
||||
self.create_profile_image(user_2, get_profile_image_storage())
|
||||
|
||||
response = self.client.get(
|
||||
self.url,
|
||||
{"course_id": str(self.course.id), "requested_fields": "profile_image"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
response_threads = json.loads(response.content.decode("utf-8"))["results"]
|
||||
|
||||
for response_thread in response_threads:
|
||||
expected_profile_data = self.get_expected_user_profile(
|
||||
response_thread["author"]
|
||||
)
|
||||
response_users = response_thread["users"]
|
||||
assert expected_profile_data == response_users[response_thread["author"]]
|
||||
|
||||
def test_profile_image_requested_field_anonymous_user(self):
|
||||
"""
|
||||
Tests profile_image in requested_fields for thread created with anonymous user
|
||||
"""
|
||||
source_threads = [
|
||||
self.create_source_thread(
|
||||
{
|
||||
"user_id": None,
|
||||
"username": None,
|
||||
"anonymous": True,
|
||||
"anonymous_to_peers": True,
|
||||
}
|
||||
),
|
||||
]
|
||||
|
||||
self.register_get_user_response(self.user, upvoted_ids=["test_thread"])
|
||||
self.register_get_threads_response(source_threads, page=1, num_pages=1)
|
||||
|
||||
response = self.client.get(
|
||||
self.url,
|
||||
{"course_id": str(self.course.id), "requested_fields": "profile_image"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
response_thread = json.loads(response.content.decode("utf-8"))["results"][0]
|
||||
assert response_thread["author"] is None
|
||||
assert {} == response_thread["users"]
|
||||
|
||||
@@ -17,8 +17,6 @@ from django.urls import reverse
|
||||
from django.utils import translation
|
||||
from edx_django_utils.cache import RequestCache
|
||||
from edx_toggles.toggles.testutils import override_waffle_flag
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.django_utils import (
|
||||
TEST_DATA_SPLIT_MODULESTORE,
|
||||
ModuleStoreTestCase,
|
||||
@@ -27,7 +25,6 @@ from xmodule.modulestore.tests.django_utils import (
|
||||
from xmodule.modulestore.tests.factories import (
|
||||
CourseFactory,
|
||||
BlockFactory,
|
||||
check_mongo_calls
|
||||
)
|
||||
|
||||
from common.djangoapps.course_modes.models import CourseMode
|
||||
@@ -42,7 +39,6 @@ from lms.djangoapps.discussion.django_comment_client.permissions import get_team
|
||||
from lms.djangoapps.discussion.django_comment_client.tests.group_id import (
|
||||
CohortedTopicGroupIdTestMixin,
|
||||
GroupIdAssertionMixin,
|
||||
NonCohortedTopicGroupIdTestMixin
|
||||
)
|
||||
from lms.djangoapps.discussion.django_comment_client.tests.unicode import UnicodeTestMixin
|
||||
from lms.djangoapps.discussion.django_comment_client.tests.utils import (
|
||||
@@ -55,7 +51,6 @@ from lms.djangoapps.discussion.django_comment_client.utils import strip_none
|
||||
from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE
|
||||
from lms.djangoapps.discussion.views import _get_discussion_default_topic_id, course_discussions_settings_handler
|
||||
from lms.djangoapps.teams.tests.factories import CourseTeamFactory, CourseTeamMembershipFactory
|
||||
from openedx.core.djangoapps.course_groups.models import CourseUserGroup
|
||||
from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts
|
||||
from openedx.core.djangoapps.course_groups.tests.test_views import CohortViewsTestCase
|
||||
from openedx.core.djangoapps.django_comment_common.comment_client.utils import CommentClientPaginatedResult
|
||||
@@ -68,8 +63,6 @@ from openedx.core.djangoapps.django_comment_common.utils import ThreadContext, s
|
||||
from openedx.core.djangoapps.util.testing import ContentGroupTestCase
|
||||
from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES
|
||||
from openedx.core.lib.teams_config import TeamsConfig
|
||||
from openedx.features.content_type_gating.models import ContentTypeGatingConfig
|
||||
from openedx.features.enterprise_support.tests.mixins.enterprise import EnterpriseTestConsentRequired
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -529,93 +522,6 @@ class AllowPlusOrMinusOneInt(int):
|
||||
return f"({self.value} +/- 1)"
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@patch('requests.request', autospec=True)
|
||||
class SingleThreadQueryCountTestCase(ForumsEnableMixin, ModuleStoreTestCase):
|
||||
"""
|
||||
Ensures the number of modulestore queries and number of sql queries are
|
||||
independent of the number of responses retrieved for a given discussion thread.
|
||||
"""
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
patcher = mock.patch(
|
||||
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
|
||||
return_value=False
|
||||
)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
patcher = mock.patch(
|
||||
"openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread"
|
||||
)
|
||||
self.mock_get_course_id_by_thread = patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
@ddt.data(
|
||||
# split mongo: 3 queries, regardless of thread response size.
|
||||
(False, 1, 2, 2, 21, 8),
|
||||
(False, 50, 2, 2, 21, 8),
|
||||
|
||||
# Enabling Enterprise integration should have no effect on the number of mongo queries made.
|
||||
# split mongo: 3 queries, regardless of thread response size.
|
||||
(True, 1, 2, 2, 21, 8),
|
||||
(True, 50, 2, 2, 21, 8),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_number_of_mongo_queries(
|
||||
self,
|
||||
enterprise_enabled,
|
||||
num_thread_responses,
|
||||
num_uncached_mongo_calls,
|
||||
num_cached_mongo_calls,
|
||||
num_uncached_sql_queries,
|
||||
num_cached_sql_queries,
|
||||
mock_request
|
||||
):
|
||||
ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1))
|
||||
with modulestore().default_store(ModuleStoreEnum.Type.split):
|
||||
course = CourseFactory.create(discussion_topics={'dummy discussion': {'id': 'dummy_discussion_id'}})
|
||||
|
||||
student = UserFactory.create()
|
||||
CourseEnrollmentFactory.create(user=student, course_id=course.id)
|
||||
|
||||
test_thread_id = "test_thread_id"
|
||||
mock_request.side_effect = make_mock_request_impl(
|
||||
course=course, text="dummy content", thread_id=test_thread_id, num_thread_responses=num_thread_responses
|
||||
)
|
||||
request = RequestFactory().get(
|
||||
"dummy_url",
|
||||
HTTP_X_REQUESTED_WITH="XMLHttpRequest"
|
||||
)
|
||||
request.user = student
|
||||
|
||||
def call_single_thread():
|
||||
"""
|
||||
Call single_thread and assert that it returns what we expect.
|
||||
"""
|
||||
with patch.dict("django.conf.settings.FEATURES", dict(ENABLE_ENTERPRISE_INTEGRATION=enterprise_enabled)):
|
||||
response = views.single_thread(
|
||||
request,
|
||||
str(course.id),
|
||||
"dummy_discussion_id",
|
||||
test_thread_id
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert len(json.loads(response.content.decode('utf-8'))['content']['children']) == num_thread_responses
|
||||
|
||||
# Test uncached first, then cached now that the cache is warm.
|
||||
cached_calls = [
|
||||
[num_uncached_mongo_calls, num_uncached_sql_queries],
|
||||
# Sometimes there will be one more or fewer sql call than expected, because the call to
|
||||
# CourseMode.modes_for_course sometimes does / doesn't get cached and does / doesn't hit the DB.
|
||||
# EDUCATOR-5167
|
||||
[num_cached_mongo_calls, AllowPlusOrMinusOneInt(num_cached_sql_queries)],
|
||||
]
|
||||
for expected_mongo_calls, expected_sql_queries in cached_calls:
|
||||
with self.assertNumQueries(expected_sql_queries, table_ignorelist=QUERY_COUNT_TABLE_IGNORELIST):
|
||||
with check_mongo_calls(expected_mongo_calls):
|
||||
call_single_thread()
|
||||
|
||||
|
||||
@patch('requests.request', autospec=True)
|
||||
class SingleCohortedThreadTestCase(CohortedTestCase): # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
|
||||
@@ -868,92 +774,6 @@ class SingleThreadGroupIdTestCase(CohortedTestCase, GroupIdAssertionMixin): # l
|
||||
)
|
||||
|
||||
|
||||
@patch('requests.request', autospec=True)
|
||||
class ForumFormDiscussionContentGroupTestCase(ForumsEnableMixin, ContentGroupTestCase):
|
||||
"""
|
||||
Tests `forum_form_discussion api` works with different content groups.
|
||||
Discussion blocks are setup in ContentGroupTestCase class i.e
|
||||
alpha_block => alpha_group_discussion => alpha_cohort => alpha_user/community_ta
|
||||
beta_block => beta_group_discussion => beta_cohort => beta_user
|
||||
"""
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.thread_list = [
|
||||
{"thread_id": "test_general_thread_id"},
|
||||
{"thread_id": "test_global_group_thread_id", "commentable_id": self.global_block.discussion_id},
|
||||
{"thread_id": "test_alpha_group_thread_id", "group_id": self.alpha_block.group_access[0][0],
|
||||
"commentable_id": self.alpha_block.discussion_id},
|
||||
{"thread_id": "test_beta_group_thread_id", "group_id": self.beta_block.group_access[0][0],
|
||||
"commentable_id": self.beta_block.discussion_id}
|
||||
]
|
||||
|
||||
def assert_has_access(self, response, expected_discussion_threads):
|
||||
"""
|
||||
Verify that a users have access to the threads in their assigned
|
||||
cohorts and non-cohorted blocks.
|
||||
"""
|
||||
discussion_data = json.loads(response.content.decode('utf-8'))['discussion_data']
|
||||
assert len(discussion_data) == expected_discussion_threads
|
||||
|
||||
def call_view(self, mock_request, user): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
mock_request.side_effect = make_mock_request_impl(
|
||||
course=self.course,
|
||||
text="dummy content",
|
||||
thread_list=self.thread_list
|
||||
)
|
||||
self.client.login(username=user.username, password=self.TEST_PASSWORD)
|
||||
return self.client.get(
|
||||
reverse("forum_form_discussion", args=[str(self.course.id)]),
|
||||
HTTP_X_REQUESTED_WITH="XMLHttpRequest"
|
||||
)
|
||||
|
||||
def test_community_ta_user(self, mock_request):
|
||||
"""
|
||||
Verify that community_ta user has access to all threads regardless
|
||||
of cohort.
|
||||
"""
|
||||
response = self.call_view(
|
||||
mock_request,
|
||||
self.community_ta
|
||||
)
|
||||
self.assert_has_access(response, 4)
|
||||
|
||||
def test_alpha_cohort_user(self, mock_request):
|
||||
"""
|
||||
Verify that alpha_user has access to alpha_cohort and non-cohorted
|
||||
threads.
|
||||
"""
|
||||
response = self.call_view(
|
||||
mock_request,
|
||||
self.alpha_user
|
||||
)
|
||||
self.assert_has_access(response, 3)
|
||||
|
||||
def test_beta_cohort_user(self, mock_request):
|
||||
"""
|
||||
Verify that beta_user has access to beta_cohort and non-cohorted
|
||||
threads.
|
||||
"""
|
||||
response = self.call_view(
|
||||
mock_request,
|
||||
self.beta_user
|
||||
)
|
||||
self.assert_has_access(response, 3)
|
||||
|
||||
def test_global_staff_user(self, mock_request):
|
||||
"""
|
||||
Verify that global staff user has access to all threads regardless
|
||||
of cohort.
|
||||
"""
|
||||
response = self.call_view(
|
||||
mock_request,
|
||||
self.staff_user
|
||||
)
|
||||
self.assert_has_access(response, 4)
|
||||
|
||||
|
||||
@patch('requests.request', autospec=True)
|
||||
class SingleThreadContentGroupTestCase(ForumsEnableMixin, UrlResetMixin, ContentGroupTestCase): # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
|
||||
@@ -1080,417 +900,6 @@ class SingleThreadContentGroupTestCase(ForumsEnableMixin, UrlResetMixin, Content
|
||||
self.assert_can_access(self.beta_user, self.alpha_block.discussion_id, thread_id, True)
|
||||
|
||||
|
||||
@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True)
|
||||
class InlineDiscussionContextTestCase(ForumsEnableMixin, ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.course = CourseFactory.create()
|
||||
CourseEnrollmentFactory(user=self.user, course_id=self.course.id)
|
||||
self.discussion_topic_id = "dummy_topic"
|
||||
self.team = CourseTeamFactory(
|
||||
name="A team",
|
||||
course_id=self.course.id,
|
||||
topic_id='topic_id',
|
||||
discussion_topic_id=self.discussion_topic_id
|
||||
)
|
||||
|
||||
self.team.add_user(self.user)
|
||||
self.user_not_in_team = UserFactory.create()
|
||||
|
||||
def test_context_can_be_standalone(self, mock_request):
|
||||
mock_request.side_effect = make_mock_request_impl(
|
||||
course=self.course,
|
||||
text="dummy text",
|
||||
commentable_id=self.discussion_topic_id
|
||||
)
|
||||
|
||||
request = RequestFactory().get("dummy_url")
|
||||
request.user = self.user
|
||||
|
||||
response = views.inline_discussion(
|
||||
request,
|
||||
str(self.course.id),
|
||||
self.discussion_topic_id,
|
||||
)
|
||||
|
||||
json_response = json.loads(response.content.decode('utf-8'))
|
||||
assert json_response['discussion_data'][0]['context'] == ThreadContext.STANDALONE
|
||||
|
||||
def test_private_team_discussion(self, mock_request):
|
||||
# First set the team discussion to be private
|
||||
CourseEnrollmentFactory(user=self.user_not_in_team, course_id=self.course.id)
|
||||
request = RequestFactory().get("dummy_url")
|
||||
request.user = self.user_not_in_team
|
||||
|
||||
mock_request.side_effect = make_mock_request_impl(
|
||||
course=self.course,
|
||||
text="dummy text",
|
||||
commentable_id=self.discussion_topic_id
|
||||
)
|
||||
|
||||
with patch('lms.djangoapps.teams.api.is_team_discussion_private', autospec=True) as mocked:
|
||||
mocked.return_value = True
|
||||
response = views.inline_discussion(
|
||||
request,
|
||||
str(self.course.id),
|
||||
self.discussion_topic_id,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.content.decode('utf-8') == views.TEAM_PERMISSION_MESSAGE
|
||||
|
||||
|
||||
@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True)
|
||||
@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True)
|
||||
class InlineDiscussionGroupIdTestCase( # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
CohortedTestCase,
|
||||
CohortedTopicGroupIdTestMixin,
|
||||
NonCohortedTopicGroupIdTestMixin
|
||||
):
|
||||
cs_endpoint = "/threads"
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.cohorted_commentable_id = 'cohorted_topic'
|
||||
patcher = mock.patch(
|
||||
"openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread"
|
||||
)
|
||||
self.mock_get_course_id_by_thread = patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def call_view(
|
||||
self,
|
||||
mock_is_forum_v2_enabled,
|
||||
mock_request,
|
||||
commentable_id,
|
||||
user,
|
||||
group_id,
|
||||
pass_group_id=True
|
||||
): # pylint: disable=arguments-differ
|
||||
mock_is_forum_v2_enabled.return_value = False
|
||||
kwargs = {'commentable_id': self.cohorted_commentable_id}
|
||||
if group_id:
|
||||
# avoid causing a server error when the LMS chokes attempting
|
||||
# to find a group name for the group_id, when we're testing with
|
||||
# an invalid one.
|
||||
try:
|
||||
CourseUserGroup.objects.get(id=group_id)
|
||||
kwargs['group_id'] = group_id
|
||||
except CourseUserGroup.DoesNotExist:
|
||||
pass
|
||||
mock_request.side_effect = make_mock_request_impl(self.course, "dummy content", **kwargs)
|
||||
|
||||
request_data = {}
|
||||
if pass_group_id:
|
||||
request_data["group_id"] = group_id
|
||||
request = RequestFactory().get(
|
||||
"dummy_url",
|
||||
data=request_data
|
||||
)
|
||||
request.user = user
|
||||
return views.inline_discussion(
|
||||
request,
|
||||
str(self.course.id),
|
||||
commentable_id
|
||||
)
|
||||
|
||||
def test_group_info_in_ajax_response(self, mock_is_forum_v2_enabled, mock_request):
|
||||
response = self.call_view(
|
||||
mock_is_forum_v2_enabled,
|
||||
mock_request,
|
||||
self.cohorted_commentable_id,
|
||||
self.student,
|
||||
self.student_cohort.id
|
||||
)
|
||||
self._assert_json_response_contains_group_info(
|
||||
response, lambda d: d['discussion_data'][0]
|
||||
)
|
||||
|
||||
|
||||
@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True)
|
||||
@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True)
|
||||
class ForumFormDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGroupIdTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
cs_endpoint = "/threads"
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
patcher = mock.patch(
|
||||
"openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread"
|
||||
)
|
||||
self.mock_get_course_id_by_thread = patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def call_view(
|
||||
self,
|
||||
mock_is_forum_v2_enabled,
|
||||
mock_request,
|
||||
commentable_id,
|
||||
user,
|
||||
group_id,
|
||||
pass_group_id=True,
|
||||
is_ajax=False
|
||||
): # pylint: disable=arguments-differ
|
||||
mock_is_forum_v2_enabled.return_value = False
|
||||
kwargs = {}
|
||||
if group_id:
|
||||
kwargs['group_id'] = group_id
|
||||
mock_request.side_effect = make_mock_request_impl(self.course, "dummy content", **kwargs)
|
||||
|
||||
request_data = {}
|
||||
if pass_group_id:
|
||||
request_data["group_id"] = group_id
|
||||
headers = {}
|
||||
if is_ajax:
|
||||
headers['HTTP_X_REQUESTED_WITH'] = "XMLHttpRequest"
|
||||
|
||||
self.client.login(username=user.username, password=self.TEST_PASSWORD)
|
||||
return self.client.get(
|
||||
reverse("forum_form_discussion", args=[str(self.course.id)]),
|
||||
data=request_data,
|
||||
**headers
|
||||
)
|
||||
|
||||
def test_group_info_in_html_response(self, mock_is_forum_v2_enabled, mock_request):
|
||||
response = self.call_view(
|
||||
mock_is_forum_v2_enabled,
|
||||
mock_request,
|
||||
"cohorted_topic",
|
||||
self.student,
|
||||
self.student_cohort.id
|
||||
)
|
||||
self._assert_html_response_contains_group_info(response)
|
||||
|
||||
def test_group_info_in_ajax_response(self, mock_is_forum_v2_enabled, mock_request):
|
||||
response = self.call_view(
|
||||
mock_is_forum_v2_enabled,
|
||||
mock_request,
|
||||
"cohorted_topic",
|
||||
self.student,
|
||||
self.student_cohort.id,
|
||||
is_ajax=True
|
||||
)
|
||||
self._assert_json_response_contains_group_info(
|
||||
response, lambda d: d['discussion_data'][0]
|
||||
)
|
||||
|
||||
|
||||
@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True)
|
||||
@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True)
|
||||
class UserProfileDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGroupIdTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
cs_endpoint = "/active_threads"
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
patcher = mock.patch(
|
||||
"openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment"
|
||||
)
|
||||
self.mock_get_course_id_by_comment = patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
patcher = mock.patch(
|
||||
"openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread"
|
||||
)
|
||||
self.mock_get_course_id_by_thread = patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def call_view_for_profiled_user(
|
||||
self,
|
||||
mock_is_forum_v2_enabled,
|
||||
mock_request,
|
||||
requesting_user,
|
||||
profiled_user,
|
||||
group_id,
|
||||
pass_group_id,
|
||||
is_ajax=False
|
||||
):
|
||||
"""
|
||||
Calls "user_profile" view method on behalf of "requesting_user" to get information about
|
||||
the user "profiled_user".
|
||||
"""
|
||||
mock_is_forum_v2_enabled.return_value = False
|
||||
kwargs = {}
|
||||
if group_id:
|
||||
kwargs['group_id'] = group_id
|
||||
mock_request.side_effect = make_mock_request_impl(self.course, "dummy content", **kwargs)
|
||||
|
||||
request_data = {}
|
||||
if pass_group_id:
|
||||
request_data["group_id"] = group_id
|
||||
headers = {}
|
||||
if is_ajax:
|
||||
headers['HTTP_X_REQUESTED_WITH'] = "XMLHttpRequest"
|
||||
|
||||
self.client.login(username=requesting_user.username, password=self.TEST_PASSWORD)
|
||||
return self.client.get(
|
||||
reverse('user_profile', args=[str(self.course.id), profiled_user.id]),
|
||||
data=request_data,
|
||||
**headers
|
||||
)
|
||||
|
||||
def call_view(
|
||||
self,
|
||||
mock_is_forum_v2_enabled,
|
||||
mock_request,
|
||||
_commentable_id,
|
||||
user,
|
||||
group_id,
|
||||
pass_group_id=True,
|
||||
is_ajax=False
|
||||
): # pylint: disable=arguments-differ
|
||||
return self.call_view_for_profiled_user(
|
||||
mock_is_forum_v2_enabled, mock_request, user, user, group_id, pass_group_id=pass_group_id, is_ajax=is_ajax
|
||||
)
|
||||
|
||||
def test_group_info_in_html_response(self, mock_is_forum_v2_enabled, mock_request):
|
||||
response = self.call_view(
|
||||
mock_is_forum_v2_enabled,
|
||||
mock_request,
|
||||
"cohorted_topic",
|
||||
self.student,
|
||||
self.student_cohort.id,
|
||||
is_ajax=False
|
||||
)
|
||||
self._assert_html_response_contains_group_info(response)
|
||||
|
||||
def test_group_info_in_ajax_response(self, mock_is_forum_v2_enabled, mock_request):
|
||||
response = self.call_view(
|
||||
mock_is_forum_v2_enabled,
|
||||
mock_request,
|
||||
"cohorted_topic",
|
||||
self.student,
|
||||
self.student_cohort.id,
|
||||
is_ajax=True
|
||||
)
|
||||
self._assert_json_response_contains_group_info(
|
||||
response, lambda d: d['discussion_data'][0]
|
||||
)
|
||||
|
||||
def _test_group_id_passed_to_user_profile(
|
||||
self,
|
||||
mock_is_forum_v2_enabled,
|
||||
mock_request,
|
||||
expect_group_id_in_request,
|
||||
requesting_user,
|
||||
profiled_user,
|
||||
group_id,
|
||||
pass_group_id
|
||||
):
|
||||
"""
|
||||
Helper method for testing whether or not group_id was passed to the user_profile request.
|
||||
"""
|
||||
|
||||
def get_params_from_user_info_call(for_specific_course):
|
||||
"""
|
||||
Returns the request parameters for the user info call with either course_id specified or not,
|
||||
depending on value of 'for_specific_course'.
|
||||
"""
|
||||
# There will be 3 calls from user_profile. One has the cs_endpoint "active_threads", and it is already
|
||||
# tested. The other 2 calls are for user info; one of those calls is for general information about the user,
|
||||
# and it does not specify a course_id. The other call does specify a course_id, and if the caller did not
|
||||
# have discussion moderator privileges, it should also contain a group_id.
|
||||
for r_call in mock_request.call_args_list:
|
||||
if not r_call[0][1].endswith(self.cs_endpoint):
|
||||
params = r_call[1]["params"]
|
||||
has_course_id = "course_id" in params
|
||||
if (for_specific_course and has_course_id) or (not for_specific_course and not has_course_id):
|
||||
return params
|
||||
pytest.fail(f"Did not find appropriate user_profile call for 'for_specific_course'={for_specific_course}")
|
||||
|
||||
mock_request.reset_mock()
|
||||
self.call_view_for_profiled_user(
|
||||
mock_is_forum_v2_enabled,
|
||||
mock_request,
|
||||
requesting_user,
|
||||
profiled_user,
|
||||
group_id,
|
||||
pass_group_id=pass_group_id,
|
||||
is_ajax=False
|
||||
)
|
||||
# Should never have a group_id if course_id was not included in the request.
|
||||
params_without_course_id = get_params_from_user_info_call(False)
|
||||
assert 'group_id' not in params_without_course_id
|
||||
|
||||
params_with_course_id = get_params_from_user_info_call(True)
|
||||
if expect_group_id_in_request:
|
||||
assert 'group_id' in params_with_course_id
|
||||
assert group_id == params_with_course_id['group_id']
|
||||
else:
|
||||
assert 'group_id' not in params_with_course_id
|
||||
|
||||
def test_group_id_passed_to_user_profile_student(self, mock_is_forum_v2_enabled, mock_request):
|
||||
"""
|
||||
Test that the group id is always included when requesting user profile information for a particular
|
||||
course if the requester does not have discussion moderation privileges.
|
||||
"""
|
||||
def verify_group_id_always_present(profiled_user, pass_group_id):
|
||||
"""
|
||||
Helper method to verify that group_id is always present for student in course
|
||||
(non-privileged user).
|
||||
"""
|
||||
self._test_group_id_passed_to_user_profile(
|
||||
mock_is_forum_v2_enabled,
|
||||
mock_request,
|
||||
True,
|
||||
self.student,
|
||||
profiled_user,
|
||||
self.student_cohort.id,
|
||||
pass_group_id
|
||||
)
|
||||
|
||||
# In all these test cases, the requesting_user is the student (non-privileged user).
|
||||
# The profile returned on behalf of the student is for the profiled_user.
|
||||
verify_group_id_always_present(profiled_user=self.student, pass_group_id=True)
|
||||
verify_group_id_always_present(profiled_user=self.student, pass_group_id=False)
|
||||
verify_group_id_always_present(profiled_user=self.moderator, pass_group_id=True)
|
||||
verify_group_id_always_present(profiled_user=self.moderator, pass_group_id=False)
|
||||
|
||||
def test_group_id_user_profile_moderator(self, mock_is_forum_v2_enabled, mock_request):
|
||||
"""
|
||||
Test that the group id is only included when a privileged user requests user profile information for a
|
||||
particular course and user if the group_id is explicitly passed in.
|
||||
"""
|
||||
def verify_group_id_present(profiled_user, pass_group_id, requested_cohort=self.moderator_cohort):
|
||||
"""
|
||||
Helper method to verify that group_id is present.
|
||||
"""
|
||||
self._test_group_id_passed_to_user_profile(
|
||||
mock_is_forum_v2_enabled,
|
||||
mock_request,
|
||||
True,
|
||||
self.moderator,
|
||||
profiled_user,
|
||||
requested_cohort.id,
|
||||
pass_group_id
|
||||
)
|
||||
|
||||
def verify_group_id_not_present(profiled_user, pass_group_id, requested_cohort=self.moderator_cohort):
|
||||
"""
|
||||
Helper method to verify that group_id is not present.
|
||||
"""
|
||||
self._test_group_id_passed_to_user_profile(
|
||||
mock_is_forum_v2_enabled,
|
||||
mock_request,
|
||||
False,
|
||||
self.moderator,
|
||||
profiled_user,
|
||||
requested_cohort.id,
|
||||
pass_group_id
|
||||
)
|
||||
|
||||
# In all these test cases, the requesting_user is the moderator (privileged user).
|
||||
|
||||
# If the group_id is explicitly passed, it will be present in the request.
|
||||
verify_group_id_present(profiled_user=self.student, pass_group_id=True)
|
||||
verify_group_id_present(profiled_user=self.moderator, pass_group_id=True)
|
||||
verify_group_id_present(
|
||||
profiled_user=self.student, pass_group_id=True, requested_cohort=self.student_cohort
|
||||
)
|
||||
|
||||
# If the group_id is not explicitly passed, it will not be present because the requesting_user
|
||||
# has discussion moderator privileges.
|
||||
verify_group_id_not_present(profiled_user=self.student, pass_group_id=False)
|
||||
verify_group_id_not_present(profiled_user=self.moderator, pass_group_id=False)
|
||||
|
||||
|
||||
@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True)
|
||||
@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True)
|
||||
class FollowedThreadsDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGroupIdTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
@@ -1547,189 +956,6 @@ class FollowedThreadsDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGr
|
||||
)
|
||||
|
||||
|
||||
@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True)
|
||||
class InlineDiscussionTestCase(ForumsEnableMixin, ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.course = CourseFactory.create(
|
||||
org="TestX",
|
||||
number="101",
|
||||
display_name="Test Course",
|
||||
teams_configuration=TeamsConfig({
|
||||
'topics': [{
|
||||
'id': 'topic_id',
|
||||
'name': 'A topic',
|
||||
'description': 'A topic',
|
||||
}]
|
||||
})
|
||||
)
|
||||
self.student = UserFactory.create()
|
||||
CourseEnrollmentFactory(user=self.student, course_id=self.course.id)
|
||||
self.discussion1 = BlockFactory.create(
|
||||
parent_location=self.course.location,
|
||||
category="discussion",
|
||||
discussion_id="discussion1",
|
||||
display_name='Discussion1',
|
||||
discussion_category="Chapter",
|
||||
discussion_target="Discussion1"
|
||||
)
|
||||
|
||||
def send_request(self, mock_request, params=None):
|
||||
"""
|
||||
Creates and returns a request with params set, and configures
|
||||
mock_request to return appropriate values.
|
||||
"""
|
||||
request = RequestFactory().get("dummy_url", params if params else {})
|
||||
request.user = self.student
|
||||
mock_request.side_effect = make_mock_request_impl(
|
||||
course=self.course, text="dummy content", commentable_id=self.discussion1.discussion_id
|
||||
)
|
||||
return views.inline_discussion(
|
||||
request, str(self.course.id), self.discussion1.discussion_id
|
||||
)
|
||||
|
||||
def test_context(self, mock_request):
|
||||
team = CourseTeamFactory(
|
||||
name='Team Name',
|
||||
topic_id='topic_id',
|
||||
course_id=self.course.id,
|
||||
discussion_topic_id=self.discussion1.discussion_id
|
||||
)
|
||||
|
||||
team.add_user(self.student)
|
||||
|
||||
self.send_request(mock_request)
|
||||
assert mock_request.call_args[1]['params']['context'] == ThreadContext.STANDALONE
|
||||
|
||||
|
||||
@patch('requests.request', autospec=True)
|
||||
class UserProfileTestCase(ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
|
||||
TEST_THREAD_TEXT = 'userprofile-test-text'
|
||||
TEST_THREAD_ID = 'userprofile-test-thread-id'
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.course = CourseFactory.create()
|
||||
self.student = UserFactory.create()
|
||||
self.profiled_user = UserFactory.create()
|
||||
CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id)
|
||||
CourseEnrollmentFactory.create(user=self.profiled_user, course_id=self.course.id)
|
||||
|
||||
def get_response(self, mock_request, params, **headers): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
mock_request.side_effect = make_mock_request_impl(
|
||||
course=self.course, text=self.TEST_THREAD_TEXT, thread_id=self.TEST_THREAD_ID
|
||||
)
|
||||
self.client.login(username=self.student.username, password=self.TEST_PASSWORD)
|
||||
|
||||
response = self.client.get(
|
||||
reverse('user_profile', kwargs={
|
||||
'course_id': str(self.course.id),
|
||||
'user_id': self.profiled_user.id,
|
||||
}),
|
||||
data=params,
|
||||
**headers
|
||||
)
|
||||
mock_request.assert_any_call(
|
||||
"get",
|
||||
StringEndsWithMatcher(f'/users/{self.profiled_user.id}/active_threads'),
|
||||
data=None,
|
||||
params=PartialDictMatcher({
|
||||
"course_id": str(self.course.id),
|
||||
"page": params.get("page", 1),
|
||||
"per_page": views.THREADS_PER_PAGE
|
||||
}),
|
||||
headers=ANY,
|
||||
timeout=ANY
|
||||
)
|
||||
return response
|
||||
|
||||
def check_html(self, mock_request, **params): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
response = self.get_response(mock_request, params)
|
||||
assert response.status_code == 200
|
||||
assert response['Content-Type'] == 'text/html; charset=utf-8'
|
||||
html = response.content.decode('utf-8')
|
||||
self.assertRegex(html, r'data-page="1"')
|
||||
self.assertRegex(html, r'data-num-pages="1"')
|
||||
self.assertRegex(html, r'<span class="discussion-count">1</span> discussion started')
|
||||
self.assertRegex(html, r'<span class="discussion-count">2</span> comments')
|
||||
self.assertRegex(html, f''id': '{self.TEST_THREAD_ID}'')
|
||||
self.assertRegex(html, f''title': '{self.TEST_THREAD_TEXT}'')
|
||||
self.assertRegex(html, f''body': '{self.TEST_THREAD_TEXT}'')
|
||||
self.assertRegex(html, f''username': '{self.student.username}'')
|
||||
|
||||
def check_ajax(self, mock_request, **params): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
response = self.get_response(mock_request, params, HTTP_X_REQUESTED_WITH="XMLHttpRequest")
|
||||
assert response.status_code == 200
|
||||
assert response['Content-Type'] == 'application/json; charset=utf-8'
|
||||
response_data = json.loads(response.content.decode('utf-8'))
|
||||
assert sorted(response_data.keys()) == ['annotated_content_info', 'discussion_data', 'num_pages', 'page']
|
||||
assert len(response_data['discussion_data']) == 1
|
||||
assert response_data['page'] == 1
|
||||
assert response_data['num_pages'] == 1
|
||||
assert response_data['discussion_data'][0]['id'] == self.TEST_THREAD_ID
|
||||
assert response_data['discussion_data'][0]['title'] == self.TEST_THREAD_TEXT
|
||||
assert response_data['discussion_data'][0]['body'] == self.TEST_THREAD_TEXT
|
||||
|
||||
def test_html(self, mock_request):
|
||||
self.check_html(mock_request)
|
||||
|
||||
def test_ajax(self, mock_request):
|
||||
self.check_ajax(mock_request)
|
||||
|
||||
def test_404_non_enrolled_user(self, __):
|
||||
"""
|
||||
Test that when student try to visit un-enrolled students' discussion profile,
|
||||
the system raises Http404.
|
||||
"""
|
||||
unenrolled_user = UserFactory.create()
|
||||
request = RequestFactory().get("dummy_url")
|
||||
request.user = self.student
|
||||
with pytest.raises(Http404):
|
||||
views.user_profile(
|
||||
request,
|
||||
str(self.course.id),
|
||||
unenrolled_user.id
|
||||
)
|
||||
|
||||
def test_404_profiled_user(self, _mock_request):
|
||||
request = RequestFactory().get("dummy_url")
|
||||
request.user = self.student
|
||||
with pytest.raises(Http404):
|
||||
views.user_profile(
|
||||
request,
|
||||
str(self.course.id),
|
||||
-999
|
||||
)
|
||||
|
||||
def test_404_course(self, _mock_request):
|
||||
request = RequestFactory().get("dummy_url")
|
||||
request.user = self.student
|
||||
with pytest.raises(Http404):
|
||||
views.user_profile(
|
||||
request,
|
||||
"non/existent/course",
|
||||
self.profiled_user.id
|
||||
)
|
||||
|
||||
def test_post(self, mock_request):
|
||||
mock_request.side_effect = make_mock_request_impl(
|
||||
course=self.course, text=self.TEST_THREAD_TEXT, thread_id=self.TEST_THREAD_ID
|
||||
)
|
||||
request = RequestFactory().post("dummy_url")
|
||||
request.user = self.student
|
||||
response = views.user_profile(
|
||||
request,
|
||||
str(self.course.id),
|
||||
self.profiled_user.id
|
||||
)
|
||||
assert response.status_code == 405
|
||||
|
||||
|
||||
@patch('requests.request', autospec=True)
|
||||
class CommentsServiceRequestHeadersTestCase(ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
|
||||
@@ -1811,155 +1037,6 @@ class CommentsServiceRequestHeadersTestCase(ForumsEnableMixin, UrlResetMixin, Mo
|
||||
self.assert_all_calls_have_header(mock_request, "X-Edx-Api-Key", "test_api_key")
|
||||
|
||||
|
||||
class InlineDiscussionUnicodeTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# pylint: disable=super-method-not-called
|
||||
with super().setUpClassAndTestData():
|
||||
cls.course = CourseFactory.create()
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
super().setUpTestData()
|
||||
|
||||
cls.student = UserFactory.create()
|
||||
CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id)
|
||||
|
||||
@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True)
|
||||
def _test_unicode_data(self, text, mock_request): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
mock_request.side_effect = make_mock_request_impl(course=self.course, text=text)
|
||||
request = RequestFactory().get("dummy_url")
|
||||
request.user = self.student
|
||||
|
||||
response = views.inline_discussion(
|
||||
request, str(self.course.id), self.course.discussion_topics['General']['id']
|
||||
)
|
||||
assert response.status_code == 200
|
||||
response_data = json.loads(response.content.decode('utf-8'))
|
||||
assert response_data['discussion_data'][0]['title'] == text
|
||||
assert response_data['discussion_data'][0]['body'] == text
|
||||
|
||||
|
||||
class ForumFormDiscussionUnicodeTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# pylint: disable=super-method-not-called
|
||||
with super().setUpClassAndTestData():
|
||||
cls.course = CourseFactory.create()
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
super().setUpTestData()
|
||||
|
||||
cls.student = UserFactory.create()
|
||||
CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id)
|
||||
|
||||
@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True)
|
||||
def _test_unicode_data(self, text, mock_request): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
mock_request.side_effect = make_mock_request_impl(course=self.course, text=text)
|
||||
request = RequestFactory().get("dummy_url")
|
||||
request.user = self.student
|
||||
# so (request.headers.get('x-requested-with') == 'XMLHttpRequest') == True
|
||||
request.META["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest"
|
||||
|
||||
response = views.forum_form_discussion(request, str(self.course.id))
|
||||
assert response.status_code == 200
|
||||
response_data = json.loads(response.content.decode('utf-8'))
|
||||
assert response_data['discussion_data'][0]['title'] == text
|
||||
assert response_data['discussion_data'][0]['body'] == text
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True)
|
||||
class ForumDiscussionXSSTestCase(ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
username = "foo"
|
||||
password = "bar"
|
||||
|
||||
self.course = CourseFactory.create()
|
||||
self.student = UserFactory.create(username=username, password=password)
|
||||
CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id)
|
||||
assert self.client.login(username=username, password=password)
|
||||
|
||||
@ddt.data('"><script>alert(1)</script>', '<script>alert(1)</script>', '</script><script>alert(1)</script>')
|
||||
@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('"><script>alert(1)</script>', '<script>alert(1)</script>', '</script><script>alert(1)</script>')
|
||||
@patch('common.djangoapps.student.models.user.cc.User.from_django_user')
|
||||
@patch('common.djangoapps.student.models.user.cc.User.active_threads')
|
||||
def test_forum_user_profile_xss_prevent(self, malicious_code, mock_threads, mock_from_django_user, mock_request):
|
||||
"""
|
||||
Test that XSS attack is prevented
|
||||
"""
|
||||
mock_threads.return_value = [], 1, 1
|
||||
mock_from_django_user.return_value.to_dict.return_value = {
|
||||
'upvoted_ids': [],
|
||||
'downvoted_ids': [],
|
||||
'subscribed_thread_ids': []
|
||||
}
|
||||
mock_request.side_effect = make_mock_request_impl(course=self.course, text='dummy')
|
||||
|
||||
url = reverse('user_profile',
|
||||
kwargs={'course_id': str(self.course.id), 'user_id': str(self.student.id)})
|
||||
# Test that malicious code does not appear in html
|
||||
url_string = "{}?{}={}".format(url, 'page', malicious_code)
|
||||
resp = self.client.get(url_string)
|
||||
self.assertNotContains(resp, malicious_code)
|
||||
|
||||
|
||||
class ForumDiscussionSearchUnicodeTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# pylint: disable=super-method-not-called
|
||||
with super().setUpClassAndTestData():
|
||||
cls.course = CourseFactory.create()
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
super().setUpTestData()
|
||||
|
||||
cls.student = UserFactory.create()
|
||||
CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id)
|
||||
|
||||
@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True)
|
||||
def _test_unicode_data(self, text, mock_request): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
mock_request.side_effect = make_mock_request_impl(course=self.course, text=text)
|
||||
data = {
|
||||
"ajax": 1,
|
||||
"text": text,
|
||||
}
|
||||
request = RequestFactory().get("dummy_url", data)
|
||||
request.user = self.student
|
||||
# so (request.headers.get('x-requested-with') == 'XMLHttpRequest') == True
|
||||
request.META["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest"
|
||||
|
||||
response = views.forum_form_discussion(request, str(self.course.id))
|
||||
assert response.status_code == 200
|
||||
response_data = json.loads(response.content.decode('utf-8'))
|
||||
assert response_data['discussion_data'][0]['title'] == text
|
||||
assert response_data['discussion_data'][0]['body'] == text
|
||||
|
||||
|
||||
class SingleThreadUnicodeTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
|
||||
@classmethod
|
||||
@@ -2087,60 +1164,6 @@ class EnrollmentTestCase(ForumsEnableMixin, ModuleStoreTestCase):
|
||||
views.forum_form_discussion(request, course_id=str(self.course.id)) # pylint: disable=no-value-for-parameter, unexpected-keyword-arg
|
||||
|
||||
|
||||
@patch('requests.request', autospec=True)
|
||||
class EnterpriseConsentTestCase(EnterpriseTestConsentRequired, ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase):
|
||||
"""
|
||||
Ensure that the Enterprise Data Consent redirects are in place only when consent is required.
|
||||
"""
|
||||
CREATE_USER = False
|
||||
|
||||
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
|
||||
def setUp(self):
|
||||
# Invoke UrlResetMixin setUp
|
||||
super().setUp()
|
||||
patcher = mock.patch(
|
||||
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
|
||||
return_value=False
|
||||
)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
patcher = mock.patch(
|
||||
"openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread"
|
||||
)
|
||||
self.mock_get_course_id_by_thread = patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
username = "foo"
|
||||
password = "bar"
|
||||
|
||||
self.discussion_id = 'dummy_discussion_id'
|
||||
self.course = CourseFactory.create(discussion_topics={'dummy discussion': {'id': self.discussion_id}})
|
||||
self.student = UserFactory.create(username=username, password=password)
|
||||
CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id)
|
||||
assert self.client.login(username=username, password=password)
|
||||
|
||||
self.addCleanup(translation.deactivate)
|
||||
|
||||
@patch('openedx.features.enterprise_support.api.enterprise_customer_for_request')
|
||||
def test_consent_required(self, mock_enterprise_customer_for_request, mock_request):
|
||||
"""
|
||||
Test that enterprise data sharing consent is required when enabled for the various discussion views.
|
||||
"""
|
||||
# ENT-924: Temporary solution to replace sensitive SSO usernames.
|
||||
mock_enterprise_customer_for_request.return_value = None
|
||||
|
||||
thread_id = 'dummy'
|
||||
course_id = str(self.course.id)
|
||||
mock_request.side_effect = make_mock_request_impl(course=self.course, text='dummy', thread_id=thread_id)
|
||||
|
||||
for url in (
|
||||
reverse('forum_form_discussion',
|
||||
kwargs=dict(course_id=course_id)),
|
||||
reverse('single_thread',
|
||||
kwargs=dict(course_id=course_id, discussion_id=self.discussion_id, thread_id=thread_id)),
|
||||
):
|
||||
self.verify_consent_required(self.client, url) # pylint: disable=no-value-for-parameter
|
||||
|
||||
|
||||
class DividedDiscussionsTestCase(CohortViewsTestCase): # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
|
||||
def create_divided_discussions(self):
|
||||
|
||||
1264
lms/djangoapps/discussion/tests/test_views_v2.py
Normal file
1264
lms/djangoapps/discussion/tests/test_views_v2.py
Normal file
@@ -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(
|
||||
'"><script>alert(1)</script>',
|
||||
"<script>alert(1)</script>",
|
||||
"</script><script>alert(1)</script>",
|
||||
)
|
||||
@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(
|
||||
'"><script>alert(1)</script>',
|
||||
"<script>alert(1)</script>",
|
||||
"</script><script>alert(1)</script>",
|
||||
)
|
||||
@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'<span class="discussion-count">1</span> discussion started'
|
||||
)
|
||||
self.assertRegex(html, r'<span class="discussion-count">2</span> 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
|
||||
@@ -56,42 +56,28 @@ class Thread(models.Model):
|
||||
utils.strip_blank(utils.strip_none(query_params))
|
||||
)
|
||||
|
||||
if query_params.get('text'):
|
||||
url = cls.url(action='search')
|
||||
else:
|
||||
url = cls.url(action='get_all', params=utils.extract(params, 'commentable_id'))
|
||||
if params.get('commentable_id'):
|
||||
del params['commentable_id']
|
||||
# Convert user_id and author_id to strings if present
|
||||
for field in ['user_id', 'author_id']:
|
||||
if value := params.get(field):
|
||||
params[field] = str(value)
|
||||
|
||||
if is_forum_v2_enabled(utils.get_course_key(query_params['course_id'])):
|
||||
if query_params.get('text'):
|
||||
search_params = utils.strip_none(params)
|
||||
if user_id := search_params.get('user_id'):
|
||||
search_params['user_id'] = str(user_id)
|
||||
if group_ids := search_params.get('group_ids'):
|
||||
search_params['group_ids'] = [int(group_id) for group_id in group_ids.split(',')]
|
||||
elif group_id := search_params.get('group_id'):
|
||||
search_params['group_ids'] = [int(group_id)]
|
||||
search_params.pop('group_id', None)
|
||||
if commentable_ids := search_params.get('commentable_ids'):
|
||||
search_params['commentable_ids'] = commentable_ids.split(',')
|
||||
elif commentable_id := search_params.get('commentable_id'):
|
||||
search_params['commentable_ids'] = [commentable_id]
|
||||
search_params.pop('commentable_id', None)
|
||||
response = forum_api.search_threads(**search_params)
|
||||
else:
|
||||
if user_id := params.get('user_id'):
|
||||
params['user_id'] = str(user_id)
|
||||
response = forum_api.get_user_threads(**params)
|
||||
# Handle commentable_ids/commentable_id conversion
|
||||
if commentable_ids := params.get('commentable_ids'):
|
||||
params['commentable_ids'] = commentable_ids.split(',')
|
||||
elif commentable_id := params.get('commentable_id'):
|
||||
params['commentable_ids'] = [commentable_id]
|
||||
params.pop('commentable_id', None)
|
||||
|
||||
params = utils.clean_forum_params(params)
|
||||
if query_params.get('text'): # Handle group_ids/group_id conversion
|
||||
if group_ids := params.get('group_ids'):
|
||||
params['group_ids'] = [int(group_id) for group_id in group_ids.split(',')]
|
||||
elif group_id := params.get('group_id'):
|
||||
params['group_ids'] = [int(group_id)]
|
||||
params.pop('group_id', None)
|
||||
response = forum_api.search_threads(**params)
|
||||
else:
|
||||
response = utils.perform_request(
|
||||
'get',
|
||||
url,
|
||||
params,
|
||||
metric_tags=['course_id:{}'.format(query_params['course_id'])],
|
||||
metric_action='thread.search',
|
||||
paged_results=True
|
||||
)
|
||||
response = forum_api.get_user_threads(**params)
|
||||
|
||||
if query_params.get('text'):
|
||||
search_query = query_params['text']
|
||||
@@ -124,7 +110,6 @@ class Thread(models.Model):
|
||||
total_results=total_results
|
||||
)
|
||||
)
|
||||
|
||||
return utils.CommentClientPaginatedResult(
|
||||
collection=response.get('collection', []),
|
||||
page=response.get('page', 1),
|
||||
|
||||
@@ -181,7 +181,7 @@ class User(models.Model):
|
||||
user_id = params.pop("user_id", None)
|
||||
if "text" in params:
|
||||
params.pop("text")
|
||||
response = forum_api.get_user_subscriptions(user_id, str(course_key), params)
|
||||
response = forum_api.get_user_subscriptions(user_id, str(course_key), utils.clean_forum_params(params))
|
||||
else:
|
||||
response = utils.perform_request(
|
||||
'get',
|
||||
@@ -218,21 +218,17 @@ class User(models.Model):
|
||||
if is_forum_v2_enabled(course_key):
|
||||
group_ids = [retrieve_params['group_id']] if 'group_id' in retrieve_params else []
|
||||
is_complete = retrieve_params['complete']
|
||||
params = utils.clean_forum_params({
|
||||
"user_id": self.attributes["id"],
|
||||
"group_ids": group_ids,
|
||||
"course_id": course_id,
|
||||
"complete": is_complete
|
||||
})
|
||||
try:
|
||||
response = forum_api.get_user(
|
||||
self.attributes["id"],
|
||||
group_ids=group_ids,
|
||||
course_id=course_id,
|
||||
complete=is_complete
|
||||
)
|
||||
response = forum_api.get_user(**params)
|
||||
except ForumV2RequestError as e:
|
||||
self.save({"course_id": course_id})
|
||||
response = forum_api.get_user(
|
||||
self.attributes["id"],
|
||||
group_ids=group_ids,
|
||||
course_id=course_id,
|
||||
complete=is_complete
|
||||
)
|
||||
response = forum_api.get_user(**params)
|
||||
else:
|
||||
try:
|
||||
response = utils.perform_request(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user