feat!: remove cs_comments_service support for forum's flag APIs

This will force the use of the new v2 forum's APIs for flaging/unflaging.
This commit is contained in:
Ali-Salman29
2025-05-22 09:00:51 +02:00
committed by David Ormsbee
parent 610906218a
commit 6f522f3992
12 changed files with 2308 additions and 1012 deletions

View File

@@ -202,14 +202,6 @@ class ThreadActionGroupIdTestCase(
response = self.call_view("undo_vote_for_thread", mock_is_forum_v2_enabled, mock_request)
self._assert_json_response_contains_group_info(response)
def test_flag(self, mock_is_forum_v2_enabled, mock_request):
with mock.patch('openedx.core.djangoapps.django_comment_common.signals.thread_flagged.send') as signal_mock:
response = self.call_view("flag_abuse_for_thread", mock_is_forum_v2_enabled, mock_request)
self._assert_json_response_contains_group_info(response)
self.assertEqual(signal_mock.call_count, 1)
response = self.call_view("un_flag_abuse_for_thread", mock_is_forum_v2_enabled, mock_request)
self._assert_json_response_contains_group_info(response)
def test_openclose(self, mock_is_forum_v2_enabled, mock_request):
response = self.call_view(
"openclose_thread",
@@ -799,309 +791,6 @@ class ViewsTestCase(
data={"body": updated_body, "course_id": str(self.course_id)}
)
def test_flag_thread_open(self, mock_is_forum_v2_enabled, mock_request):
self.flag_thread(mock_is_forum_v2_enabled, mock_request, False)
def test_flag_thread_close(self, mock_is_forum_v2_enabled, mock_request):
self.flag_thread(mock_is_forum_v2_enabled, mock_request, True)
def flag_thread(self, mock_is_forum_v2_enabled, mock_request, is_closed):
mock_is_forum_v2_enabled.return_value = False
self._set_mock_request_data(mock_request, {
"title": "Hello",
"body": "this is a post",
"course_id": "MITx/999/Robot_Super_Course",
"anonymous": False,
"anonymous_to_peers": False,
"commentable_id": "i4x-MITx-999-course-Robot_Super_Course",
"created_at": "2013-05-10T18:53:43Z",
"updated_at": "2013-05-10T18:53:43Z",
"at_position_list": [],
"closed": is_closed,
"id": "518d4237b023791dca00000d",
"user_id": "1", "username": "robot",
"votes": {
"count": 0,
"up_count": 0,
"down_count": 0,
"point": 0
},
"abuse_flaggers": [1],
"type": "thread",
"group_id": None,
"pinned": False,
"endorsed": False,
"unread_comments_count": 0,
"read": False,
"comments_count": 0,
})
url = reverse('flag_abuse_for_thread', kwargs={
'thread_id': '518d4237b023791dca00000d',
'course_id': str(self.course_id)
})
response = self.client.post(url)
assert mock_request.called
call_list = [
(
('get', f'{CS_PREFIX}/threads/518d4237b023791dca00000d'),
{
'data': None,
'params': {'mark_as_read': True, 'request_id': ANY, 'with_responses': False, 'reverse_order': False,
'merge_question_type_responses': False},
'headers': ANY,
'timeout': 5
}
),
(
('put', f'{CS_PREFIX}/threads/518d4237b023791dca00000d/abuse_flag'),
{
'data': {'user_id': '1'},
'params': {'request_id': ANY},
'headers': ANY,
'timeout': 5
}
),
(
('get', f'{CS_PREFIX}/threads/518d4237b023791dca00000d'),
{
'data': None,
'params': {'mark_as_read': True, 'request_id': ANY, 'with_responses': False, 'reverse_order': False,
'merge_question_type_responses': False},
'headers': ANY,
'timeout': 5
}
)
]
assert mock_request.call_args_list == call_list
assert response.status_code == 200
def test_un_flag_thread_open(self, mock_is_forum_v2_enabled, mock_request):
self.un_flag_thread(mock_is_forum_v2_enabled, mock_request, False)
def test_un_flag_thread_close(self, mock_is_forum_v2_enabled, mock_request):
self.un_flag_thread(mock_is_forum_v2_enabled, mock_request, True)
def un_flag_thread(self, mock_is_forum_v2_enabled, mock_request, is_closed):
mock_is_forum_v2_enabled.return_value = False
self._set_mock_request_data(mock_request, {
"title": "Hello",
"body": "this is a post",
"course_id": "MITx/999/Robot_Super_Course",
"anonymous": False,
"anonymous_to_peers": False,
"commentable_id": "i4x-MITx-999-course-Robot_Super_Course",
"created_at": "2013-05-10T18:53:43Z",
"updated_at": "2013-05-10T18:53:43Z",
"at_position_list": [],
"closed": is_closed,
"id": "518d4237b023791dca00000d",
"user_id": "1",
"username": "robot",
"votes": {
"count": 0,
"up_count": 0,
"down_count": 0,
"point": 0
},
"abuse_flaggers": [],
"type": "thread",
"group_id": None,
"pinned": False,
"endorsed": False,
"unread_comments_count": 0,
"read": False,
"comments_count": 0
})
url = reverse('un_flag_abuse_for_thread', kwargs={
'thread_id': '518d4237b023791dca00000d',
'course_id': str(self.course_id)
})
response = self.client.post(url)
assert mock_request.called
call_list = [
(
('get', f'{CS_PREFIX}/threads/518d4237b023791dca00000d'),
{
'data': None,
'params': {'mark_as_read': True, 'request_id': ANY, 'with_responses': False, 'reverse_order': False,
'merge_question_type_responses': False},
'headers': ANY,
'timeout': 5
}
),
(
('put', f'{CS_PREFIX}/threads/518d4237b023791dca00000d/abuse_unflag'),
{
'data': {'user_id': '1'},
'params': {'request_id': ANY},
'headers': ANY,
'timeout': 5
}
),
(
('get', f'{CS_PREFIX}/threads/518d4237b023791dca00000d'),
{
'data': None,
'params': {'mark_as_read': True, 'request_id': ANY, 'with_responses': False, 'reverse_order': False,
'merge_question_type_responses': False},
'headers': ANY,
'timeout': 5
}
)
]
assert mock_request.call_args_list == call_list
assert response.status_code == 200
def test_flag_comment_open(self, mock_is_forum_v2_enabled, mock_request):
self.flag_comment(mock_is_forum_v2_enabled, mock_request, False)
def test_flag_comment_close(self, mock_is_forum_v2_enabled, mock_request):
self.flag_comment(mock_is_forum_v2_enabled, mock_request, True)
def flag_comment(self, mock_is_forum_v2_enabled, mock_request, is_closed):
mock_is_forum_v2_enabled.return_value = False
self._set_mock_request_data(mock_request, {
"body": "this is a comment",
"course_id": "MITx/999/Robot_Super_Course",
"anonymous": False,
"anonymous_to_peers": False,
"commentable_id": "i4x-MITx-999-course-Robot_Super_Course",
"created_at": "2013-05-10T18:53:43Z",
"updated_at": "2013-05-10T18:53:43Z",
"at_position_list": [],
"closed": is_closed,
"id": "518d4237b023791dca00000d",
"user_id": "1",
"username": "robot",
"votes": {
"count": 0,
"up_count": 0,
"down_count": 0,
"point": 0
},
"abuse_flaggers": [1],
"type": "comment",
"endorsed": False
})
url = reverse('flag_abuse_for_comment', kwargs={
'comment_id': '518d4237b023791dca00000d',
'course_id': str(self.course_id)
})
response = self.client.post(url)
assert mock_request.called
call_list = [
(
('get', f'{CS_PREFIX}/comments/518d4237b023791dca00000d'),
{
'data': None,
'params': {'request_id': ANY},
'headers': ANY,
'timeout': 5
}
),
(
('put', f'{CS_PREFIX}/comments/518d4237b023791dca00000d/abuse_flag'),
{
'data': {'user_id': '1'},
'params': {'request_id': ANY},
'headers': ANY,
'timeout': 5
}
),
(
('get', f'{CS_PREFIX}/comments/518d4237b023791dca00000d'),
{
'data': None,
'params': {'request_id': ANY},
'headers': ANY,
'timeout': 5
}
)
]
assert mock_request.call_args_list == call_list
assert response.status_code == 200
def test_un_flag_comment_open(self, mock_is_forum_v2_enabled, mock_request):
self.un_flag_comment(mock_is_forum_v2_enabled, mock_request, False)
def test_un_flag_comment_close(self, mock_is_forum_v2_enabled, mock_request):
self.un_flag_comment(mock_is_forum_v2_enabled, mock_request, True)
def un_flag_comment(self, mock_is_forum_v2_enabled, mock_request, is_closed):
mock_is_forum_v2_enabled.return_value = False
self._set_mock_request_data(mock_request, {
"body": "this is a comment",
"course_id": "MITx/999/Robot_Super_Course",
"anonymous": False,
"anonymous_to_peers": False,
"commentable_id": "i4x-MITx-999-course-Robot_Super_Course",
"created_at": "2013-05-10T18:53:43Z",
"updated_at": "2013-05-10T18:53:43Z",
"at_position_list": [],
"closed": is_closed,
"id": "518d4237b023791dca00000d",
"user_id": "1",
"username": "robot",
"votes": {
"count": 0,
"up_count": 0,
"down_count": 0,
"point": 0
},
"abuse_flaggers": [],
"type": "comment",
"endorsed": False
})
url = reverse('un_flag_abuse_for_comment', kwargs={
'comment_id': '518d4237b023791dca00000d',
'course_id': str(self.course_id)
})
response = self.client.post(url)
assert mock_request.called
call_list = [
(
('get', f'{CS_PREFIX}/comments/518d4237b023791dca00000d'),
{
'data': None,
'params': {'request_id': ANY},
'headers': ANY,
'timeout': 5
}
),
(
('put', f'{CS_PREFIX}/comments/518d4237b023791dca00000d/abuse_unflag'),
{
'data': {'user_id': '1'},
'params': {'request_id': ANY},
'headers': ANY,
'timeout': 5
}
),
(
('get', f'{CS_PREFIX}/comments/518d4237b023791dca00000d'),
{
'data': None,
'params': {'request_id': ANY},
'headers': ANY,
'timeout': 5
}
)
]
assert mock_request.call_args_list == call_list
assert response.status_code == 200
@ddt.data(
('upvote_thread', 'thread_id', 'thread_voted'),
('upvote_comment', 'comment_id', 'comment_voted'),
@@ -1427,56 +1116,6 @@ class UpdateCommentUnicodeTestCase(
assert mock_request.call_args[1]['data']['body'] == text
@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True)
@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True)
class CommentActionTestCase(
MockRequestSetupMixin,
CohortedTestCase,
GroupIdAssertionMixin
):
def call_view(
self,
view_name,
mock_is_forum_v2_enabled,
mock_request,
user=None,
post_params=None,
view_args=None
):
mock_is_forum_v2_enabled.return_value = False
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment"
)
self.mock_get_course_id_by_comment = patcher.start()
self.addCleanup(patcher.stop)
self._set_mock_request_data(
mock_request,
{
"user_id": str(self.student.id),
"group_id": self.student_cohort.id,
"closed": False,
"type": "thread",
"commentable_id": "non_team_dummy_id",
"body": "test body",
}
)
request = RequestFactory().post("dummy_url", post_params or {})
request.user = user or self.student
request.view_name = view_name
return getattr(views, view_name)(
request,
course_id=str(self.course.id),
comment_id="dummy",
**(view_args or {})
)
def test_flag(self, mock_is_forum_v2_enabled, mock_request):
with mock.patch('openedx.core.djangoapps.django_comment_common.signals.comment_flagged.send') as signal_mock:
self.call_view("flag_abuse_for_comment", mock_is_forum_v2_enabled, mock_request)
self.assertEqual(signal_mock.call_count, 1)
@disable_signal(views, 'comment_created')
class CreateSubCommentUnicodeTestCase(
ForumsEnableMixin,
@@ -1830,7 +1469,7 @@ class TeamsPermissionsTestCase(ForumsEnableMixin, UrlResetMixin, SharedModuleSto
"course_id": str(self.course.id)
},
)
for action in ["upvote_comment", "downvote_comment", "un_flag_abuse_for_comment", "flag_abuse_for_comment"]:
for action in ["upvote_comment", "downvote_comment"]:
response = self.client.post(
reverse(
action,
@@ -1851,8 +1490,7 @@ class TeamsPermissionsTestCase(ForumsEnableMixin, UrlResetMixin, SharedModuleSto
user, mock_is_forum_v2_enabled, mock_request,
{"closed": False, "commentable_id": commentable_id, "body": "dummy body", "course_id": str(self.course.id)}
)
for action in ["upvote_thread", "downvote_thread", "un_flag_abuse_for_thread", "flag_abuse_for_thread",
"follow_thread", "unfollow_thread"]:
for action in ["upvote_thread", "downvote_thread", "follow_thread", "unfollow_thread"]:
response = self.client.post(
reverse(
action,

View File

@@ -86,6 +86,11 @@ from lms.djangoapps.discussion.django_comment_client.tests.mixins import (
MockForumApiMixin,
)
from lms.djangoapps.discussion.tests.utils import (
make_minimal_cs_thread,
make_minimal_cs_comment,
)
@disable_signal(views, "thread_edited")
@disable_signal(views, "thread_voted")
@@ -111,16 +116,11 @@ class ThreadActionGroupIdTestCase(
self, view_name, mock_function, user=None, post_params=None, view_args=None
):
"""Call a view with the given parameters."""
thread_response = {
"user_id": str(self.student.id),
"group_id": self.student_cohort.id,
"closed": False,
"type": "thread",
"commentable_id": "non_team_dummy_id",
"body": "test body",
}
thread_response = make_minimal_cs_thread(
{"user_id": str(self.student.id), "group_id": self.student_cohort.id}
)
self.set_mock_return_value("get_course_id_by_thread", self.course.id)
self.set_mock_return_value("get_course_id_by_thread", str(self.course.id))
self.set_mock_return_value("get_thread", thread_response)
self.set_mock_return_value(mock_function, thread_response)
@@ -132,9 +132,19 @@ class ThreadActionGroupIdTestCase(
request,
course_id=str(self.course.id),
thread_id="dummy",
**(view_args or {})
**(view_args or {}),
)
def test_flag(self):
with mock.patch(
"openedx.core.djangoapps.django_comment_common.signals.thread_flagged.send"
) as signal_mock:
response = self.call_view("flag_abuse_for_thread", "update_thread_flag")
self._assert_json_response_contains_group_info(response)
self.assertEqual(signal_mock.call_count, 1)
response = self.call_view("un_flag_abuse_for_thread", "update_thread_flag")
self._assert_json_response_contains_group_info(response)
def test_pin_thread(self):
"""Test pinning a thread."""
response = self.call_view("pin_thread", "pin_thread", user=self.moderator)
@@ -146,6 +156,400 @@ class ThreadActionGroupIdTestCase(
self._assert_json_response_contains_group_info(response)
class ViewsTestCaseMixin:
def set_up_course(self, block_count=0):
"""
Creates a course, optionally with block_count discussion blocks, and
a user with appropriate permissions.
"""
# create a course
self.course = CourseFactory.create(
org="MITx",
course="999",
discussion_topics={"Some Topic": {"id": "some_topic"}},
display_name="Robot Super Course",
)
self.course_id = self.course.id
# add some discussion blocks
for i in range(block_count):
BlockFactory.create(
parent_location=self.course.location,
category="discussion",
discussion_id=f"id_module_{i}",
discussion_category=f"Category {i}",
discussion_target=f"Discussion {i}",
)
# seed the forums permissions and roles
call_command("seed_permissions_roles", str(self.course_id))
# Patch the comment client user save method so it does not try
# to create a new cc user when creating a django user
with patch("common.djangoapps.student.models.user.cc.User.save"):
uname = "student"
email = "student@edx.org"
self.password = "Password1234"
# Create the user and make them active so we can log them in.
self.student = UserFactory.create(
username=uname, email=email, password=self.password
)
self.student.is_active = True
self.student.save()
# Add a discussion moderator
self.moderator = UserFactory.create(password=self.password)
# Enroll the student in the course
CourseEnrollmentFactory(user=self.student, course_id=self.course_id)
# Enroll the moderator and give them the appropriate roles
CourseEnrollmentFactory(user=self.moderator, course_id=self.course.id)
self.moderator.roles.add(
Role.objects.get(name="Moderator", course_id=self.course.id)
)
assert self.client.login(username="student", password=self.password)
@ddt.ddt
@disable_signal(views, "comment_flagged")
@disable_signal(views, "thread_flagged")
class ViewsTestCase(
ForumsEnableMixin,
MockForumApiMixin,
UrlResetMixin,
SharedModuleStoreTestCase,
ViewsTestCaseMixin,
MockSignalHandlerMixin,
):
@classmethod
def setUpClass(cls):
# pylint: disable=super-method-not-called
super().setUpClassAndForumMock()
with super().setUpClassAndTestData():
cls.course = CourseFactory.create(
org="MITx",
course="999",
discussion_topics={"Some Topic": {"id": "some_topic"}},
display_name="Robot Super Course",
)
@classmethod
def tearDownClass(cls):
"""Stop patches after tests complete."""
super().tearDownClass()
super().disposeForumMocks()
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.course_id = cls.course.id
# seed the forums permissions and roles
call_command("seed_permissions_roles", str(cls.course_id))
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
# Patching the ENABLE_DISCUSSION_SERVICE value affects the contents of urls.py,
# so we need to call super.setUp() which reloads urls.py (because
# of the UrlResetMixin)
super().setUp()
# Patch the comment client user save method so it does not try
# to create a new cc user when creating a django user
with patch("common.djangoapps.student.models.user.cc.User.save"):
uname = "student"
email = "student@edx.org"
self.password = "Password1234"
# Create the user and make them active so we can log them in.
self.student = UserFactory.create(
username=uname, email=email, password=self.password
)
self.student.is_active = True
self.student.save()
# Add a discussion moderator
self.moderator = UserFactory.create(password=self.password)
# Enroll the student in the course
CourseEnrollmentFactory(user=self.student, course_id=self.course_id)
# Enroll the moderator and give them the appropriate roles
CourseEnrollmentFactory(user=self.moderator, course_id=self.course.id)
self.moderator.roles.add(
Role.objects.get(name="Moderator", course_id=self.course.id)
)
assert self.client.login(username="student", password=self.password)
self.set_mock_return_value("get_course_id_by_thread", str(self.course.id))
self.set_mock_return_value("get_course_id_by_comment", str(self.course.id))
@contextmanager
def assert_discussion_signals(self, signal, user=None):
if user is None:
user = self.student
with self.assert_signal_sent(
views, signal, sender=None, user=user, exclude_args=("post",)
):
yield
def test_flag_thread_open(self):
self.flag_thread(False)
def test_flag_thread_close(self):
self.flag_thread(True)
def flag_thread(self, is_closed):
thread_data = make_minimal_cs_thread(
{
"id": "518d4237b023791dca00000d",
"course_id": str(self.course_id),
"closed": is_closed,
"user_id": "1",
"username": "robot",
"abuse_flaggers": [1],
}
)
self.set_mock_return_value("get_thread", thread_data)
self.set_mock_return_value("update_thread_flag", thread_data)
url = reverse(
"flag_abuse_for_thread",
kwargs={
"thread_id": "518d4237b023791dca00000d",
"course_id": str(self.course_id),
},
)
response = self.client.post(url)
self.check_mock_called("update_thread_flag")
self.check_mock_called_with(
"get_thread",
0,
thread_id="518d4237b023791dca00000d",
params={
"mark_as_read": True,
"with_responses": False,
"reverse_order": False,
"merge_question_type_responses": False,
},
course_id=str(self.course_id),
)
self.check_mock_called_with(
"update_thread_flag",
0,
thread_id="518d4237b023791dca00000d",
action="flag",
user_id=ANY,
course_id=str(self.course_id),
)
self.check_mock_called_with(
"get_thread",
1,
thread_id="518d4237b023791dca00000d",
params={
"mark_as_read": True,
"with_responses": False,
"reverse_order": False,
"merge_question_type_responses": False,
},
course_id=str(self.course_id),
)
assert response.status_code == 200
def test_un_flag_thread_open(self):
self.un_flag_thread(False)
def test_un_flag_thread_close(self):
self.un_flag_thread(True)
def un_flag_thread(self, is_closed):
thread_data = make_minimal_cs_thread(
{
"id": "518d4237b023791dca00000d",
"course_id": str(self.course_id),
"closed": is_closed,
"user_id": "1",
"username": "robot",
"abuse_flaggers": [1],
}
)
self.set_mock_return_value("get_thread", thread_data)
self.set_mock_return_value("update_thread_flag", thread_data)
url = reverse(
"un_flag_abuse_for_thread",
kwargs={
"thread_id": "518d4237b023791dca00000d",
"course_id": str(self.course_id),
},
)
response = self.client.post(url)
self.check_mock_called("update_thread_flag")
self.check_mock_called_with(
"get_thread",
0,
thread_id="518d4237b023791dca00000d",
params={
"mark_as_read": True,
"with_responses": False,
"reverse_order": False,
"merge_question_type_responses": False,
},
course_id=str(self.course_id),
)
self.check_mock_called_with(
"update_thread_flag",
0,
thread_id="518d4237b023791dca00000d",
action="unflag",
user_id=ANY,
update_all=False,
course_id=str(self.course_id),
)
self.check_mock_called_with(
"get_thread",
1,
thread_id="518d4237b023791dca00000d",
params={
"mark_as_read": True,
"with_responses": False,
"reverse_order": False,
"merge_question_type_responses": False,
},
course_id=str(self.course_id),
)
assert response.status_code == 200
def test_flag_comment_open(self):
self.flag_comment(False)
def test_flag_comment_close(self):
self.flag_comment(True)
def flag_comment(self, is_closed):
comment_data = make_minimal_cs_comment(
{
"id": "518d4237b023791dca00000d",
"body": "this is a comment",
"course_id": str(self.course_id),
"closed": is_closed,
"user_id": "1",
"username": "robot",
"abuse_flaggers": [1],
}
)
self.set_mock_return_value("get_parent_comment", comment_data)
self.set_mock_return_value("update_comment_flag", comment_data)
url = reverse(
"flag_abuse_for_comment",
kwargs={
"comment_id": "518d4237b023791dca00000d",
"course_id": str(self.course_id),
},
)
response = self.client.post(url)
self.check_mock_called("update_thread_flag")
self.check_mock_called_with(
"get_parent_comment",
0,
comment_id="518d4237b023791dca00000d",
course_id=str(self.course_id),
)
self.check_mock_called_with(
"update_comment_flag",
0,
comment_id="518d4237b023791dca00000d",
action="flag",
user_id=ANY,
course_id=str(self.course_id),
)
self.check_mock_called_with(
"get_parent_comment",
1,
comment_id="518d4237b023791dca00000d",
course_id=str(self.course_id),
)
assert response.status_code == 200
def test_un_flag_comment_open(self):
self.un_flag_comment(False)
def test_un_flag_comment_close(self):
self.un_flag_comment(True)
def un_flag_comment(self, is_closed):
comment_data = make_minimal_cs_comment(
{
"id": "518d4237b023791dca00000d",
"body": "this is a comment",
"course_id": str(self.course_id),
"closed": is_closed,
"user_id": "1",
"username": "robot",
"abuse_flaggers": [],
}
)
self.set_mock_return_value("get_parent_comment", comment_data)
self.set_mock_return_value("update_comment_flag", comment_data)
url = reverse(
"un_flag_abuse_for_comment",
kwargs={
"comment_id": "518d4237b023791dca00000d",
"course_id": str(self.course_id),
},
)
response = self.client.post(url)
self.check_mock_called("update_thread_flag")
self.check_mock_called_with(
"get_parent_comment",
0,
comment_id="518d4237b023791dca00000d",
course_id=str(self.course_id),
)
self.check_mock_called_with(
"update_comment_flag",
0,
comment_id="518d4237b023791dca00000d",
action="unflag",
update_all=False,
user_id=ANY,
course_id=str(self.course_id),
)
self.check_mock_called_with(
"get_parent_comment",
1,
comment_id="518d4237b023791dca00000d",
course_id=str(self.course_id),
)
assert response.status_code == 200
@disable_signal(views, "comment_endorsed")
class ViewPermissionsTestCase(
ForumsEnableMixin,
@@ -242,3 +646,250 @@ class ViewPermissionsTestCase(
)
)
assert response.status_code == 200
class CommentActionTestCase(CohortedTestCase, MockForumApiMixin):
"""Test case for thread actions with group ID assertions."""
@classmethod
def setUpClass(cls):
"""Set up class and forum mock."""
super().setUpClass()
super().setUpClassAndForumMock()
@classmethod
def tearDownClass(cls):
"""Stop patches after tests complete."""
super().tearDownClass()
super().disposeForumMocks()
def call_view(
self, view_name, mock_function, user=None, post_params=None, view_args=None
):
"""Call a view with the given parameters."""
comment_response = make_minimal_cs_comment(
{"user_id": str(self.student.id), "group_id": self.student_cohort.id}
)
self.set_mock_return_value("get_course_id_by_comment", str(self.course.id))
self.set_mock_return_value("get_parent_comment", comment_response)
self.set_mock_return_value(mock_function, comment_response)
request = RequestFactory().post("dummy_url", post_params or {})
request.user = user or self.student
request.view_name = view_name
return getattr(views, view_name)(
request,
course_id=str(self.course.id),
comment_id="dummy",
**(view_args or {}),
)
def test_flag(self):
with mock.patch(
"openedx.core.djangoapps.django_comment_common.signals.comment_flagged.send"
) as signal_mock:
self.call_view("flag_abuse_for_comment", "update_comment_flag")
self.assertEqual(signal_mock.call_count, 1)
@ddt.ddt
@disable_signal(views, "thread_voted")
@disable_signal(views, "thread_edited")
@disable_signal(views, "comment_created")
@disable_signal(views, "comment_voted")
@disable_signal(views, "comment_deleted")
@disable_signal(views, "comment_flagged")
@disable_signal(views, "thread_flagged")
class TeamsPermissionsTestCase(
UrlResetMixin, SharedModuleStoreTestCase, MockForumApiMixin
):
# Most of the test points use the same ddt data.
# args: user, commentable_id, status_code
ddt_permissions_args = [
# Student in team can do operations on threads/comments within the team commentable.
("student_in_team", "team_commentable_id", 200),
# Non-team commentables can be edited by any student.
("student_in_team", "course_commentable_id", 200),
# Student not in team cannot do operations within the team commentable.
("student_not_in_team", "team_commentable_id", 401),
# Non-team commentables can be edited by any student.
("student_not_in_team", "course_commentable_id", 200),
# Moderators can always operator on threads within a team, regardless of team membership.
("moderator", "team_commentable_id", 200),
# Group moderators have regular student privileges for creating a thread and commenting
("group_moderator", "course_commentable_id", 200),
]
def change_divided_discussion_settings(self, scheme):
"""
Change divided discussion settings for the current course.
If dividing by cohorts, create and assign users to a cohort.
"""
enable_cohorts = True if scheme is CourseDiscussionSettings.COHORT else False
discussion_settings = CourseDiscussionSettings.get(self.course.id)
discussion_settings.update(
{
"enable_cohorts": enable_cohorts,
"divided_discussions": [],
"always_divide_inline_discussions": True,
"division_scheme": scheme,
}
)
set_course_cohorted(self.course.id, enable_cohorts)
@classmethod
def setUpClass(cls):
super().setUpClassAndForumMock()
# pylint: disable=super-method-not-called
with super().setUpClassAndTestData():
teams_config_data = {
"topics": [
{
"id": "topic_id",
"name": "Solar Power",
"description": "Solar power is hot",
}
]
}
cls.course = CourseFactory.create(
teams_configuration=TeamsConfig(teams_config_data)
)
@classmethod
def tearDownClass(cls):
"""Stop patches after tests complete."""
super().tearDownClass()
super().disposeForumMocks()
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.password = "test password"
seed_permissions_roles(cls.course.id)
# Create enrollment tracks
CourseModeFactory.create(course_id=cls.course.id, mode_slug=CourseMode.VERIFIED)
CourseModeFactory.create(course_id=cls.course.id, mode_slug=CourseMode.AUDIT)
# Create 6 users--
# student in team (in the team, audit)
# student not in team (not in the team, audit)
# cohorted (in the cohort, audit)
# verified (not in the cohort, verified)
# moderator (in the cohort, audit, moderator permissions)
# group moderator (in the cohort, verified, group moderator permissions)
def create_users_and_enroll(coursemode):
student = UserFactory.create(password=cls.password)
CourseEnrollmentFactory(
course_id=cls.course.id, user=student, mode=coursemode
)
return student
cls.student_in_team, cls.student_not_in_team, cls.moderator, cls.cohorted = [
create_users_and_enroll(CourseMode.AUDIT) for _ in range(4)
]
cls.verified, cls.group_moderator = [
create_users_and_enroll(CourseMode.VERIFIED) for _ in range(2)
]
# Give moderator and group moderator permissions
cls.moderator.roles.add(
Role.objects.get(name="Moderator", course_id=cls.course.id)
)
assign_role(cls.course.id, cls.group_moderator, "Group Moderator")
# Create a team
cls.team_commentable_id = "team_discussion_id"
cls.team = CourseTeamFactory.create(
name="The Only Team",
course_id=cls.course.id,
topic_id="topic_id",
discussion_topic_id=cls.team_commentable_id,
)
CourseTeamMembershipFactory.create(team=cls.team, user=cls.student_in_team)
# Dummy commentable ID not linked to a team
cls.course_commentable_id = "course_level_commentable"
# Create cohort and add students to it
CohortFactory(
course_id=cls.course.id,
name="Test Cohort",
users=[cls.group_moderator, cls.cohorted],
)
@mock.patch.dict(
"django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}
)
def setUp(self):
super().setUp()
def _setup_mock(self, user, mock_functions=[], data=None):
user = getattr(self, user)
mock_functions = mock_functions or []
for mock_func in mock_functions:
self.set_mock_return_value(mock_func, data or {})
self.client.login(username=user.username, password=self.password)
@ddt.data(*ddt_permissions_args)
@ddt.unpack
def test_comment_actions(self, user, commentable_id, status_code):
"""
Verify that voting and flagging of comments is limited to members of the team or users with
'edit_content' permission.
"""
commentable_id = getattr(self, commentable_id)
self._setup_mock(
user,
["get_parent_comment", "update_comment_flag"],
make_minimal_cs_comment(
{
"commentable_id": commentable_id,
"course_id": str(self.course.id),
}
),
)
for action in ["un_flag_abuse_for_comment", "flag_abuse_for_comment"]:
response = self.client.post(
reverse(
action,
kwargs={
"course_id": str(self.course.id),
"comment_id": "dummy",
},
)
)
assert response.status_code == status_code
@ddt.data(*ddt_permissions_args)
@ddt.unpack
def test_threads_actions(self, user, commentable_id, status_code):
"""
Verify that voting, flagging, and following of threads is limited to members of the team or users with
'edit_content' permission.
"""
commentable_id = getattr(self, commentable_id)
self._setup_mock(
user,
["get_thread", "update_thread_flag"],
make_minimal_cs_thread(
{
"commentable_id": commentable_id,
"course_id": str(self.course.id),
}
),
)
for action in ["un_flag_abuse_for_thread", "flag_abuse_for_thread"]:
response = self.client.post(
reverse(
action,
kwargs={
"course_id": str(self.course.id),
"thread_id": "dummy",
},
)
)
assert response.status_code == status_code

View File

@@ -8,11 +8,6 @@ from unittest import mock
class MockForumApiMixin:
"""Mixin to mock forum_api across different test cases with a single mock instance."""
@classmethod
def setUpClass(cls):
"""Apply a single forum_api mock at the class level."""
cls.setUpClassAndForumMock()
@classmethod
def setUpClassAndForumMock(cls):
"""
@@ -49,11 +44,6 @@ class MockForumApiMixin:
for patcher in cls.forum_api_patchers:
patcher.stop()
@classmethod
def tearDownClass(cls):
"""Stop patches after tests complete."""
cls.disposeForumMocks()
def set_mock_return_value(self, function_name, return_value):
"""
Set a return value for a specific method in forum_api mock.
@@ -65,3 +55,50 @@ class MockForumApiMixin:
setattr(
self.mock_forum_api, function_name, mock.Mock(return_value=return_value)
)
def set_mock_side_effect(self, function_name, side_effect_fn):
"""
Set a side effect for a specific method in forum_api mock.
Args:
function_name (str): The method name in the mock to set a side effect for.
side_effect_fn (Callable): A function to be called when the mock is called.
"""
setattr(
self.mock_forum_api, function_name, mock.Mock(side_effect=side_effect_fn)
)
def check_mock_called_with(self, function_name, index, *parms, **kwargs):
"""
Check if a specific method in forum_api mock was called with the given parameters.
Args:
function_name (str): The method name in the mock to check.
parms (tuple): The parameters to check the method was called with.
"""
call_args = getattr(self.mock_forum_api, function_name).call_args_list[index]
assert call_args == mock.call(*parms, **kwargs)
def check_mock_called(self, function_name):
"""
Check if a specific method in the forum_api mock was called.
Args:
function_name (str): The method name in the mock to check.
Returns:
bool: True if the method was called, False otherwise.
"""
return getattr(self.mock_forum_api, function_name).called
def get_mock_func_calls(self, function_name):
"""
Returns a list of call arguments for a specific method in the mock_forum_api.
Args:
function_name (str): The name of the method in the mock_forum_api to retrieve call arguments for.
Returns:
list: A list of call arguments for the specified method.
"""
return getattr(self.mock_forum_api, function_name).call_args_list

View File

@@ -2153,18 +2153,6 @@ class CreateThreadTest(
assert cs_request.method == 'POST'
assert parsed_body(cs_request) == {'source_type': ['thread'], 'source_id': ['test_id']}
def test_abuse_flagged(self):
self.register_post_thread_response({"id": "test_id", "username": self.user.username})
self.register_thread_flag_response("test_id")
data = self.minimal_data.copy()
data["abuse_flagged"] = "True"
result = create_thread(self.request, data)
assert result['abuse_flagged'] is True
cs_request = httpretty.last_request()
assert urlparse(cs_request.path).path == '/api/v1/threads/test_id/abuse_flag' # lint-amnesty, pylint: disable=no-member
assert cs_request.method == 'PUT'
assert parsed_body(cs_request) == {'user_id': [str(self.user.id)]}
def test_course_id_missing(self):
with pytest.raises(ValidationError) as assertion:
create_thread(self.request, {})
@@ -2513,18 +2501,6 @@ class CreateCommentTest(
except ValidationError:
assert expected_error
def test_abuse_flagged(self):
self.register_post_comment_response({"id": "test_comment", "username": self.user.username}, "test_thread")
self.register_comment_flag_response("test_comment")
data = self.minimal_data.copy()
data["abuse_flagged"] = "True"
result = create_comment(self.request, data)
assert result['abuse_flagged'] is True
cs_request = httpretty.last_request()
assert urlparse(cs_request.path).path == '/api/v1/comments/test_comment/abuse_flag' # lint-amnesty, pylint: disable=no-member
assert cs_request.method == 'PUT'
assert parsed_body(cs_request) == {'user_id': [str(self.user.id)]}
def test_thread_id_missing(self):
with pytest.raises(ValidationError) as assertion:
create_comment(self.request, {})
@@ -2960,108 +2936,6 @@ class UpdateThreadTest(
assert result['vote_count'] == vote_count
self.register_get_user_response(self.user, upvoted_ids=[])
@ddt.data(*itertools.product([True, False], [True, False]))
@ddt.unpack
@mock.patch("eventtracking.tracker.emit")
def test_abuse_flagged(self, old_flagged, new_flagged, mock_emit):
"""
Test attempts to edit the "abuse_flagged" field.
old_flagged indicates whether the thread should be flagged at the start
of the test. new_flagged indicates the value for the "abuse_flagged"
field in the update. If old_flagged and new_flagged are the same, no
update should be made. Otherwise, a PUT should be made to the flag or
or unflag endpoint according to the new_flagged value.
"""
self.register_get_user_response(self.user)
self.register_thread_flag_response("test_thread")
self.register_thread({"abuse_flaggers": [str(self.user.id)] if old_flagged else []})
data = {"abuse_flagged": new_flagged}
result = update_thread(self.request, "test_thread", data)
assert result['abuse_flagged'] == new_flagged
last_request_path = urlparse(httpretty.last_request().path).path # lint-amnesty, pylint: disable=no-member
flag_url = "/api/v1/threads/test_thread/abuse_flag"
unflag_url = "/api/v1/threads/test_thread/abuse_unflag"
if old_flagged == new_flagged:
assert last_request_path != flag_url
assert last_request_path != unflag_url
else:
assert last_request_path == (flag_url if new_flagged else unflag_url)
assert httpretty.last_request().method == 'PUT'
assert parsed_body(httpretty.last_request()) == {'user_id': [str(self.user.id)]}
expected_event_name = 'edx.forum.thread.reported' if new_flagged else 'edx.forum.thread.unreported'
expected_event_data = {
'body': 'Original body',
'id': 'test_thread',
'content_type': 'Post',
'commentable_id': 'original_topic',
'url': '',
'user_course_roles': [],
'user_forums_roles': [FORUM_ROLE_STUDENT],
'target_username': self.user.username,
'title_truncated': False,
'title': 'Original Title',
'thread_type': 'discussion',
'group_id': None,
'truncated': False,
}
if not new_flagged:
expected_event_data['reported_status_cleared'] = False
actual_event_name, actual_event_data = mock_emit.call_args[0]
self.assertEqual(actual_event_name, expected_event_name)
self.assertEqual(actual_event_data, expected_event_data)
@ddt.data(
(False, True),
(True, True),
)
@ddt.unpack
@mock.patch("eventtracking.tracker.emit")
def test_thread_un_abuse_flag_for_moderator_role(self, is_author, remove_all, mock_emit):
"""
Test un-abuse flag for moderator role.
When moderator unflags a reported thread, it should
pass the "all" flag to the api. This will indicate
to the api to clear all abuse_flaggers, and mark the
thread as unreported.
"""
_assign_role_to_user(user=self.user, course_id=self.course.id, role=FORUM_ROLE_ADMINISTRATOR)
self.register_get_user_response(self.user)
self.register_thread_flag_response("test_thread")
self.register_thread({"abuse_flaggers": ["11"], "user_id": str(self.user.id) if is_author else "12"})
data = {"abuse_flagged": False}
update_thread(self.request, "test_thread", data)
assert httpretty.last_request().method == 'PUT'
query_params = {'user_id': [str(self.user.id)]}
if remove_all:
query_params.update({'all': ['True']})
assert parsed_body(httpretty.last_request()) == query_params
expected_event_name = 'edx.forum.thread.unreported'
expected_event_data = {
'body': 'Original body',
'id': 'test_thread',
'content_type': 'Post',
'commentable_id': 'original_topic',
'url': '',
'user_course_roles': [],
'user_forums_roles': [FORUM_ROLE_STUDENT, FORUM_ROLE_ADMINISTRATOR],
'target_username': self.user.username,
'title_truncated': False,
'title': 'Original Title',
'reported_status_cleared': False,
'thread_type': 'discussion',
'group_id': None,
'truncated': False,
}
actual_event_name, actual_event_data = mock_emit.call_args[0]
self.assertEqual(actual_event_name, expected_event_name)
self.assertEqual(actual_event_data, expected_event_data)
def test_invalid_field(self):
self.register_thread()
with pytest.raises(ValidationError) as assertion:
@@ -3569,100 +3443,6 @@ class UpdateCommentTest(
assert result['vote_count'] == vote_count
self.register_get_user_response(self.user, upvoted_ids=[])
@ddt.data(*itertools.product([True, False], [True, False]))
@ddt.unpack
@mock.patch("eventtracking.tracker.emit")
def test_abuse_flagged(self, old_flagged, new_flagged, mock_emit):
"""
Test attempts to edit the "abuse_flagged" field.
old_flagged indicates whether the comment should be flagged at the start
of the test. new_flagged indicates the value for the "abuse_flagged"
field in the update. If old_flagged and new_flagged are the same, no
update should be made. Otherwise, a PUT should be made to the flag or
or unflag endpoint according to the new_flagged value.
"""
self.register_get_user_response(self.user)
self.register_comment_flag_response("test_comment")
self.register_comment({"abuse_flaggers": [str(self.user.id)] if old_flagged else []})
data = {"abuse_flagged": new_flagged}
result = update_comment(self.request, "test_comment", data)
assert result['abuse_flagged'] == new_flagged
last_request_path = urlparse(httpretty.last_request().path).path # lint-amnesty, pylint: disable=no-member
flag_url = "/api/v1/comments/test_comment/abuse_flag"
unflag_url = "/api/v1/comments/test_comment/abuse_unflag"
if old_flagged == new_flagged:
assert last_request_path != flag_url
assert last_request_path != unflag_url
else:
assert last_request_path == (flag_url if new_flagged else unflag_url)
assert httpretty.last_request().method == 'PUT'
assert parsed_body(httpretty.last_request()) == {'user_id': [str(self.user.id)]}
expected_event_name = 'edx.forum.response.reported' if new_flagged else 'edx.forum.response.unreported'
expected_event_data = {
'body': 'Original body',
'id': 'test_comment',
'content_type': 'Response',
'commentable_id': 'dummy',
'url': '',
'truncated': False,
'user_course_roles': [],
'user_forums_roles': [FORUM_ROLE_STUDENT],
'target_username': self.user.username,
}
if not new_flagged:
expected_event_data['reported_status_cleared'] = False
actual_event_name, actual_event_data = mock_emit.call_args[0]
self.assertEqual(actual_event_name, expected_event_name)
self.assertEqual(actual_event_data, expected_event_data)
@ddt.data(
(False, True),
(True, True),
)
@ddt.unpack
@mock.patch("eventtracking.tracker.emit")
def test_comment_un_abuse_flag_for_moderator_role(self, is_author, remove_all, mock_emit):
"""
Test un-abuse flag for moderator role.
When moderator unflags a reported comment, it should
pass the "all" flag to the api. This will indicate
to the api to clear all abuse_flaggers, and mark the
comment as unreported.
"""
_assign_role_to_user(user=self.user, course_id=self.course.id, role=FORUM_ROLE_ADMINISTRATOR)
self.register_get_user_response(self.user)
self.register_comment_flag_response("test_comment")
self.register_comment({"abuse_flaggers": ["11"], "user_id": str(self.user.id) if is_author else "12"})
data = {"abuse_flagged": False}
update_comment(self.request, "test_comment", data)
assert httpretty.last_request().method == 'PUT'
query_params = {'user_id': [str(self.user.id)]}
if remove_all:
query_params.update({'all': ['True']})
assert parsed_body(httpretty.last_request()) == query_params
expected_event_name = 'edx.forum.response.unreported'
expected_event_data = {
'body': 'Original body',
'id': 'test_comment',
'content_type': 'Response',
'commentable_id': 'dummy',
'truncated': False,
'url': '',
'user_course_roles': [],
'user_forums_roles': [FORUM_ROLE_STUDENT, FORUM_ROLE_ADMINISTRATOR],
'target_username': self.user.username,
'reported_status_cleared': False,
}
actual_event_name, actual_event_data = mock_emit.call_args[0]
self.assertEqual(actual_event_name, expected_event_name)
self.assertEqual(actual_event_data, expected_event_data)
@ddt.data(
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,

View File

@@ -0,0 +1,758 @@
# pylint: skip-file
"""
Tests for the internal interface of the Discussion API (rest_api/api.py).
This module directly tests the internal API functions of the Discussion API, such as create_thread,
create_comment, update_thread, update_comment, and related helpers, by invoking them with various data and request objects.
"""
import itertools
import random
from datetime import datetime, timedelta
from unittest import mock
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
import ddt
import httpretty
import pytest
from django.test import override_settings
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.test.client import RequestFactory
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import CourseLocator
from pytz import UTC
from rest_framework.exceptions import PermissionDenied
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import (
ModuleStoreTestCase,
SharedModuleStoreTestCase,
)
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory
from xmodule.partitions.partitions import Group, UserPartition
from common.djangoapps.student.tests.factories import (
AdminFactory,
BetaTesterFactory,
CourseEnrollmentFactory,
StaffFactory,
UserFactory,
)
from common.djangoapps.util.testing import UrlResetMixin
from common.test.utils import MockSignalHandlerMixin, disable_signal
from lms.djangoapps.discussion.django_comment_client.tests.utils import (
ForumsEnableMixin,
)
from lms.djangoapps.discussion.tests.utils import (
make_minimal_cs_comment,
make_minimal_cs_thread,
)
from lms.djangoapps.discussion.rest_api import api
from lms.djangoapps.discussion.rest_api.api import (
create_comment,
create_thread,
delete_comment,
delete_thread,
get_comment_list,
get_course,
get_course_topics,
get_course_topics_v2,
get_thread,
get_thread_list,
get_user_comments,
update_comment,
update_thread,
)
from lms.djangoapps.discussion.rest_api.exceptions import (
CommentNotFoundError,
DiscussionBlackOutException,
DiscussionDisabledError,
ThreadNotFoundError,
)
from lms.djangoapps.discussion.rest_api.serializers import TopicOrdering
from lms.djangoapps.discussion.rest_api.tests.utils import (
CommentsServiceMockMixin,
ForumMockUtilsMixin,
make_paginated_api_response,
parsed_body,
)
from openedx.core.djangoapps.course_groups.models import CourseUserGroupPartitionGroup
from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
from openedx.core.djangoapps.discussions.models import (
DiscussionsConfiguration,
DiscussionTopicLink,
Provider,
PostingRestriction,
)
from openedx.core.djangoapps.discussions.tasks import (
update_discussions_settings_from_course_task,
)
from openedx.core.djangoapps.django_comment_common.models import (
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_STUDENT,
Role,
)
from openedx.core.lib.exceptions import CourseNotFoundError, PageNotFoundError
User = get_user_model()
def _remove_discussion_tab(course, user_id):
"""
Remove the discussion tab for the course.
user_id is passed to the modulestore as the editor of the xblock.
"""
course.tabs = [tab for tab in course.tabs if not tab.type == "discussion"]
modulestore().update_item(course, user_id)
def _discussion_disabled_course_for(user):
"""
Create and return a course with discussions disabled.
The user passed in will be enrolled in the course.
"""
course_with_disabled_forums = CourseFactory.create()
CourseEnrollmentFactory.create(user=user, course_id=course_with_disabled_forums.id)
_remove_discussion_tab(course_with_disabled_forums, user.id)
return course_with_disabled_forums
def _assign_role_to_user(user, course_id, role):
"""
Assign a discussion role to a user for a given course.
Arguments:
user: User to assign role to
course_id: Course id of the course user will be assigned role in
role: Role assigned to user for course
"""
role = Role.objects.create(name=role, course_id=course_id)
role.users.set([user])
def _create_course_and_cohort_with_user_role(course_is_cohorted, user, role_name):
"""
Creates a course with the value of `course_is_cohorted`, plus `always_cohort_inline_discussions`
set to True (which is no longer the default value). Then 1) enrolls the user in that course,
2) creates a cohort that the user is placed in, and 3) adds the user to the given role.
Returns: a tuple of the created course and the created cohort
"""
cohort_course = CourseFactory.create(
cohort_config={
"cohorted": course_is_cohorted,
"always_cohort_inline_discussions": True,
}
)
CourseEnrollmentFactory.create(user=user, course_id=cohort_course.id)
cohort = CohortFactory.create(course_id=cohort_course.id, users=[user])
_assign_role_to_user(user=user, course_id=cohort_course.id, role=role_name)
return [cohort_course, cohort]
def _set_course_discussion_blackout(course, user_id):
"""
Set the blackout period for course discussions.
Arguments:
course: Course for which blackout period is set
user_id: User id of user enrolled in the course
"""
course.discussion_blackouts = [
datetime.now(UTC) - timedelta(days=3),
datetime.now(UTC) + timedelta(days=3),
]
configuration = DiscussionsConfiguration.get(course.id)
configuration.posting_restrictions = PostingRestriction.SCHEDULED
configuration.save()
modulestore().update_item(course, user_id)
@ddt.ddt
@disable_signal(api, "thread_created")
@disable_signal(api, "thread_voted")
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class CreateThreadTest(
ForumsEnableMixin,
UrlResetMixin,
SharedModuleStoreTestCase,
MockSignalHandlerMixin,
ForumMockUtilsMixin,
):
"""Tests for create_thread"""
LONG_TITLE = (
"Lorem ipsum dolor sit amet, consectetuer adipiscing elit. "
"Aenean commodo ligula eget dolor. Aenean massa. Cum sociis "
"natoque penatibus et magnis dis parturient montes, nascetur "
"ridiculus mus. Donec quam felis, ultricies nec, "
"pellentesque eu, pretium quis, sem. Nulla consequat massa "
"quis enim. Donec pede justo, fringilla vel, aliquet nec, "
"vulputate eget, arcu. In enim justo, rhoncus ut, imperdiet "
"a, venenatis vitae, justo. Nullam dictum felis eu pede "
"mollis pretium. Integer tincidunt. Cras dapibus. Vivamus "
"elementum semper nisi. Aenean vulputate eleifend tellus. "
"Aenean leo ligula, porttitor eu, consequat vitae, eleifend "
"ac, enim. Aliquam lorem ante, dapibus in, viverra quis, "
"feugiat a, tellus. Phasellus viverra nulla ut metus varius "
"laoreet. Quisque rutrum. Aenean imperdiet. Etiam ultricies "
"nisi vel augue. Curabitur ullamcorper ultricies nisi. Nam "
"eget dui. Etiam rhoncus. Maecenas tempus, tellus eget "
"condimentum rhoncus, sem quam semper libero, sit amet "
"adipiscing sem neque sed ipsum. Nam quam nunc, blandit vel, "
"luctus pulvinar, hendrerit id, lorem. Maecenas nec odio et "
"ante tincidunt tempus. Donec vitae sapien ut libero "
"venenatis faucibus. Nullam quis ante. Etiam sit amet orci "
"eget eros faucibus tincidunt. Duis leo. Sed fringilla "
"mauris sit amet nibh. Donec sodales sagittis magna. Sed "
"consequat, leo eget bibendum sodales, augue velit cursus "
"nunc, quis gravida magna mi a libero. Fusce vulputate "
"eleifend sapien. Vestibulum purus quam, scelerisque ut, "
"mollis sed, nonummy id, metus. Nullam accumsan lorem in "
"dui. Cras ultricies mi eu turpis hendrerit fringilla. "
"Vestibulum ante ipsum primis in faucibus orci luctus et "
"ultrices posuere cubilia Curae; In ac dui quis mi "
"consectetuer lacinia. Nam pretium turpis et arcu. Duis arcu "
"tortor, suscipit eget, imperdiet nec, imperdiet iaculis, "
"ipsum. Sed aliquam ultrices mauris. Integer ante arcu, "
"accumsan a, consectetuer eget, posuere ut, mauris. Praesent "
"adipiscing. Phasellus ullamcorper ipsum rutrum nunc. Nunc "
"nonummy metus."
)
@classmethod
def setUpClass(cls):
super().setUpClass()
super().setUpClassAndForumMock()
@classmethod
def tearDownClass(cls):
"""Stop patches after tests complete."""
super().tearDownClass()
super().disposeForumMocks()
@mock.patch.dict(
"django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}
)
def setUp(self):
super().setUp()
self.course = CourseFactory.create()
httpretty.reset()
httpretty.enable()
self.addCleanup(httpretty.reset)
self.addCleanup(httpretty.disable)
self.user = UserFactory.create()
self.register_get_user_response(self.user)
self.request = RequestFactory().get("/test_path")
self.request.user = self.user
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
self.minimal_data = {
"course_id": str(self.course.id),
"topic_id": "test_topic",
"type": "discussion",
"title": "Test Title",
"raw_body": "Test body",
}
def test_abuse_flagged(self):
self.register_post_thread_response(
{"id": "test_id", "username": self.user.username}
)
self.register_thread_flag_response("test_id")
data = self.minimal_data.copy()
data["abuse_flagged"] = "True"
result = create_thread(self.request, data)
assert result["abuse_flagged"] is True
self.check_mock_called("update_thread_flag")
params = {
"thread_id": "test_id",
"action": "flag",
"user_id": "1",
"course_id": str(self.course.id),
}
self.check_mock_called_with("update_thread_flag", -1, **params)
@ddt.ddt
@disable_signal(api, "comment_created")
@disable_signal(api, "comment_voted")
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
@mock.patch(
"lms.djangoapps.discussion.signals.handlers.send_response_notifications",
new=mock.Mock(),
)
class CreateCommentTest(
ForumsEnableMixin,
UrlResetMixin,
SharedModuleStoreTestCase,
MockSignalHandlerMixin,
ForumMockUtilsMixin,
):
"""Tests for create_comment"""
@classmethod
def setUpClass(cls):
super().setUpClass()
super().setUpClassAndForumMock()
cls.course = CourseFactory.create()
@classmethod
def tearDownClass(cls):
"""Stop patches after tests complete."""
super().tearDownClass()
super().disposeForumMocks()
@mock.patch.dict(
"django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}
)
def setUp(self):
super().setUp()
httpretty.reset()
httpretty.enable()
self.addCleanup(httpretty.reset)
self.addCleanup(httpretty.disable)
self.course = CourseFactory.create()
self.user = UserFactory.create()
self.register_get_user_response(self.user)
self.request = RequestFactory().get("/test_path")
self.request.user = self.user
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
self.register_get_thread_response(
make_minimal_cs_thread(
{
"id": "test_thread",
"course_id": str(self.course.id),
"commentable_id": "test_topic",
}
)
)
self.minimal_data = {
"thread_id": "test_thread",
"raw_body": "Test body",
}
mock_response = {
"collection": [],
"page": 1,
"num_pages": 1,
"subscriptions_count": 1,
"corrected_text": None,
}
self.register_get_subscriptions("cohort_thread", mock_response)
self.register_get_subscriptions("test_thread", mock_response)
def test_abuse_flagged(self):
self.register_post_comment_response(
{"id": "test_comment", "username": self.user.username}, "test_thread"
)
self.register_comment_flag_response("test_comment")
data = self.minimal_data.copy()
data["abuse_flagged"] = "True"
result = create_comment(self.request, data)
assert result["abuse_flagged"] is True
self.check_mock_called("update_comment_flag")
params = {
"comment_id": "test_comment",
"action": "flag",
"user_id": "1",
"course_id": str(self.course.id),
}
self.check_mock_called_with("update_comment_flag", -1, **params)
@ddt.ddt
@disable_signal(api, "thread_edited")
@disable_signal(api, "thread_voted")
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class UpdateThreadTest(
ForumsEnableMixin,
UrlResetMixin,
SharedModuleStoreTestCase,
MockSignalHandlerMixin,
ForumMockUtilsMixin,
):
"""Tests for update_thread"""
@classmethod
def setUpClass(cls):
super().setUpClass()
super().setUpClassAndForumMock()
cls.course = CourseFactory.create()
@classmethod
def tearDownClass(cls):
"""Stop patches after tests complete."""
super().tearDownClass()
super().disposeForumMocks()
@mock.patch.dict(
"django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}
)
def setUp(self):
super().setUp()
httpretty.reset()
httpretty.enable()
self.addCleanup(httpretty.reset)
self.addCleanup(httpretty.disable)
self.user = UserFactory.create()
self.register_get_user_response(self.user)
self.request = RequestFactory().get("/test_path")
self.request.user = self.user
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
def register_thread(self, overrides=None):
"""
Make a thread with appropriate data overridden by the overrides
parameter and register mock responses for both GET and PUT on its
endpoint.
"""
cs_data = make_minimal_cs_thread(
{
"id": "test_thread",
"course_id": str(self.course.id),
"commentable_id": "original_topic",
"username": self.user.username,
"user_id": str(self.user.id),
"thread_type": "discussion",
"title": "Original Title",
"body": "Original body",
}
)
cs_data.update(overrides or {})
self.register_get_thread_response(cs_data)
self.register_put_thread_response(cs_data)
@ddt.data(*itertools.product([True, False], [True, False]))
@ddt.unpack
@mock.patch("eventtracking.tracker.emit")
def test_abuse_flagged(self, old_flagged, new_flagged, mock_emit):
"""
Test attempts to edit the "abuse_flagged" field.
old_flagged indicates whether the thread should be flagged at the start
of the test. new_flagged indicates the value for the "abuse_flagged"
field in the update. If old_flagged and new_flagged are the same, no
update should be made. Otherwise, a PUT should be made to the flag or
or unflag endpoint according to the new_flagged value.
"""
self.register_get_user_response(self.user)
self.register_thread_flag_response("test_thread")
self.register_thread(
{"abuse_flaggers": [str(self.user.id)] if old_flagged else []}
)
data = {"abuse_flagged": new_flagged}
result = update_thread(self.request, "test_thread", data)
assert result["abuse_flagged"] == new_flagged
flag_func_calls = self.get_mock_func_calls("update_thread_flag")
last_function_args = flag_func_calls[-1] if flag_func_calls else None
if old_flagged == new_flagged:
assert last_function_args is None
else:
assert last_function_args[1]["action"] == (
"flag" if new_flagged else "unflag"
)
params = {
"thread_id": "test_thread",
"action": "flag" if new_flagged else "unflag",
"user_id": "1",
"course_id": str(self.course.id),
}
if not new_flagged:
params["update_all"] = False
self.check_mock_called_with("update_thread_flag", -1, **params)
expected_event_name = (
"edx.forum.thread.reported"
if new_flagged
else "edx.forum.thread.unreported"
)
expected_event_data = {
"body": "Original body",
"id": "test_thread",
"content_type": "Post",
"commentable_id": "original_topic",
"url": "",
"user_course_roles": [],
"user_forums_roles": [FORUM_ROLE_STUDENT],
"target_username": self.user.username,
"title_truncated": False,
"title": "Original Title",
"thread_type": "discussion",
"group_id": None,
"truncated": False,
}
if not new_flagged:
expected_event_data["reported_status_cleared"] = False
actual_event_name, actual_event_data = mock_emit.call_args[0]
self.assertEqual(actual_event_name, expected_event_name)
self.assertEqual(actual_event_data, expected_event_data)
@ddt.data(
(False, True),
(True, True),
)
@ddt.unpack
@mock.patch("eventtracking.tracker.emit")
def test_thread_un_abuse_flag_for_moderator_role(
self, is_author, remove_all, mock_emit
):
"""
Test un-abuse flag for moderator role.
When moderator unflags a reported thread, it should
pass the "all" flag to the api. This will indicate
to the api to clear all abuse_flaggers, and mark the
thread as unreported.
"""
_assign_role_to_user(
user=self.user, course_id=self.course.id, role=FORUM_ROLE_ADMINISTRATOR
)
self.register_get_user_response(self.user)
self.register_thread_flag_response("test_thread")
self.register_thread(
{
"abuse_flaggers": ["11"],
"user_id": str(self.user.id) if is_author else "12",
}
)
data = {"abuse_flagged": False}
update_thread(self.request, "test_thread", data)
params = {
"thread_id": "test_thread",
"action": "unflag",
"user_id": "1",
"update_all": True if remove_all else False,
"course_id": str(self.course.id),
}
self.check_mock_called_with("update_thread_flag", -1, **params)
expected_event_name = "edx.forum.thread.unreported"
expected_event_data = {
"body": "Original body",
"id": "test_thread",
"content_type": "Post",
"commentable_id": "original_topic",
"url": "",
"user_course_roles": [],
"user_forums_roles": [FORUM_ROLE_STUDENT, FORUM_ROLE_ADMINISTRATOR],
"target_username": self.user.username,
"title_truncated": False,
"title": "Original Title",
"reported_status_cleared": False,
"thread_type": "discussion",
"group_id": None,
"truncated": False,
}
actual_event_name, actual_event_data = mock_emit.call_args[0]
self.assertEqual(actual_event_name, expected_event_name)
self.assertEqual(actual_event_data, expected_event_data)
@ddt.ddt
@disable_signal(api, "comment_edited")
@disable_signal(api, "comment_voted")
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class UpdateCommentTest(
ForumsEnableMixin,
UrlResetMixin,
SharedModuleStoreTestCase,
MockSignalHandlerMixin,
ForumMockUtilsMixin,
):
"""Tests for update_comment"""
@classmethod
def setUpClass(cls):
super().setUpClass()
super().setUpClassAndForumMock()
cls.course = CourseFactory.create()
@classmethod
def tearDownClass(cls):
"""Stop patches after tests complete."""
super().tearDownClass()
super().disposeForumMocks()
@mock.patch.dict(
"django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}
)
def setUp(self):
super().setUp()
httpretty.reset()
httpretty.enable()
self.addCleanup(httpretty.reset)
self.addCleanup(httpretty.disable)
self.user = UserFactory.create()
self.register_get_user_response(self.user)
self.request = RequestFactory().get("/test_path")
self.request.user = self.user
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
def register_comment(self, overrides=None, thread_overrides=None, course=None):
"""
Make a comment with appropriate data overridden by the overrides
parameter and register mock responses for both GET and PUT on its
endpoint. Also mock GET for the related thread with thread_overrides.
"""
if course is None:
course = self.course
cs_thread_data = make_minimal_cs_thread(
{"id": "test_thread", "course_id": str(course.id)}
)
cs_thread_data.update(thread_overrides or {})
self.register_get_thread_response(cs_thread_data)
cs_comment_data = make_minimal_cs_comment(
{
"id": "test_comment",
"course_id": cs_thread_data["course_id"],
"thread_id": cs_thread_data["id"],
"username": self.user.username,
"user_id": str(self.user.id),
"created_at": "2015-06-03T00:00:00Z",
"updated_at": "2015-06-03T00:00:00Z",
"body": "Original body",
}
)
cs_comment_data.update(overrides or {})
self.register_get_comment_response(cs_comment_data)
self.register_put_comment_response(cs_comment_data)
@ddt.data(*itertools.product([True, False], [True, False]))
@ddt.unpack
@mock.patch("eventtracking.tracker.emit")
def test_abuse_flagged(self, old_flagged, new_flagged, mock_emit):
"""
Test attempts to edit the "abuse_flagged" field.
old_flagged indicates whether the comment should be flagged at the start
of the test. new_flagged indicates the value for the "abuse_flagged"
field in the update. If old_flagged and new_flagged are the same, no
update should be made. Otherwise, a PUT should be made to the flag or
or unflag endpoint according to the new_flagged value.
"""
self.register_get_user_response(self.user)
self.register_comment_flag_response("test_comment")
self.register_comment(
{"abuse_flaggers": [str(self.user.id)] if old_flagged else []}
)
data = {"abuse_flagged": new_flagged}
result = update_comment(self.request, "test_comment", data)
assert result["abuse_flagged"] == new_flagged
flag_func_calls = self.get_mock_func_calls("update_comment_flag")
last_function_args = flag_func_calls[-1] if flag_func_calls else None
if old_flagged == new_flagged:
assert last_function_args is None
else:
assert last_function_args[1]["action"] == (
"flag" if new_flagged else "unflag"
)
params = {
"comment_id": "test_comment",
"action": "flag" if new_flagged else "unflag",
"user_id": "1",
"course_id": str(self.course.id),
}
if not new_flagged:
params["update_all"] = False
self.check_mock_called_with("update_comment_flag", -1, **params)
expected_event_name = (
"edx.forum.response.reported"
if new_flagged
else "edx.forum.response.unreported"
)
expected_event_data = {
"body": "Original body",
"id": "test_comment",
"content_type": "Response",
"commentable_id": "dummy",
"url": "",
"truncated": False,
"user_course_roles": [],
"user_forums_roles": [FORUM_ROLE_STUDENT],
"target_username": self.user.username,
}
if not new_flagged:
expected_event_data["reported_status_cleared"] = False
actual_event_name, actual_event_data = mock_emit.call_args[0]
self.assertEqual(actual_event_name, expected_event_name)
self.assertEqual(actual_event_data, expected_event_data)
@ddt.data(
(False, True),
(True, True),
)
@ddt.unpack
@mock.patch("eventtracking.tracker.emit")
def test_comment_un_abuse_flag_for_moderator_role(
self, is_author, remove_all, mock_emit
):
"""
Test un-abuse flag for moderator role.
When moderator unflags a reported comment, it should
pass the "all" flag to the api. This will indicate
to the api to clear all abuse_flaggers, and mark the
comment as unreported.
"""
_assign_role_to_user(
user=self.user, course_id=self.course.id, role=FORUM_ROLE_ADMINISTRATOR
)
self.register_get_user_response(self.user)
self.register_comment_flag_response("test_comment")
self.register_comment(
{
"abuse_flaggers": ["11"],
"user_id": str(self.user.id) if is_author else "12",
}
)
data = {"abuse_flagged": False}
update_comment(self.request, "test_comment", data)
params = {
"comment_id": "test_comment",
"action": "unflag",
"user_id": "1",
"update_all": True if remove_all else False,
"course_id": str(self.course.id),
}
self.check_mock_called_with("update_comment_flag", -1, **params)
expected_event_name = "edx.forum.response.unreported"
expected_event_data = {
"body": "Original body",
"id": "test_comment",
"content_type": "Response",
"commentable_id": "dummy",
"truncated": False,
"url": "",
"user_course_roles": [],
"user_forums_roles": [FORUM_ROLE_STUDENT, FORUM_ROLE_ADMINISTRATOR],
"target_username": self.user.username,
"reported_status_cleared": False,
}
actual_event_name, actual_event_data = mock_emit.call_args[0]
self.assertEqual(actual_event_name, expected_event_name)
self.assertEqual(actual_event_data, expected_event_data)

View File

@@ -18,7 +18,6 @@ from edx_toggles.toggles.testutils import override_waffle_flag
from opaque_keys.edx.keys import CourseKey
from pytz import UTC
from rest_framework import status
from rest_framework.parsers import JSONParser
from rest_framework.test import APIClient, APITestCase
from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE
@@ -38,7 +37,7 @@ from common.djangoapps.student.tests.factories import (
SuperuserFactory,
UserFactory
)
from common.djangoapps.util.testing import PatchMediaTypeMixin, UrlResetMixin
from common.djangoapps.util.testing import UrlResetMixin
from common.test.utils import disable_signal
from lms.djangoapps.discussion.django_comment_client.tests.utils import (
ForumsEnableMixin,
@@ -1481,162 +1480,6 @@ class ThreadViewSetCreateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
assert response_data == expected_response_data
@ddt.ddt
@httpretty.activate
@disable_signal(api, 'thread_edited')
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class ThreadViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin):
"""Tests for ThreadViewSet partial_update"""
def setUp(self):
self.unsupported_media_type = JSONParser.media_type
super().setUp()
self.url = reverse("thread-detail", kwargs={"thread_id": "test_thread"})
patcher = mock.patch(
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
return_value=False
)
patcher.start()
self.addCleanup(patcher.stop)
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread"
)
self.mock_get_course_id_by_thread = patcher.start()
self.addCleanup(patcher.stop)
def test_basic(self):
self.register_get_user_response(self.user)
self.register_thread({
"created_at": "Test Created Date",
"updated_at": "Test Updated Date",
"read": True,
"resp_total": 2,
})
request_data = {"raw_body": "Edited body"}
response = self.request_patch(request_data)
assert response.status_code == 200
response_data = json.loads(response.content.decode('utf-8'))
assert response_data == self.expected_thread_data({
'raw_body': 'Edited body',
'rendered_body': '<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,
})
assert parsed_body(httpretty.last_request()) == {
'course_id': [str(self.course.id)],
'commentable_id': ['test_topic'],
'thread_type': ['discussion'],
'title': ['Test Title'],
'body': ['Edited body'],
'user_id': [str(self.user.id)],
'anonymous': ['False'],
'anonymous_to_peers': ['False'],
'closed': ['False'],
'pinned': ['False'],
'read': ['True'],
'editing_user_id': [str(self.user.id)],
}
def test_error(self):
self.register_get_user_response(self.user)
self.register_thread()
request_data = {"title": ""}
response = self.request_patch(request_data)
expected_response_data = {
"field_errors": {"title": {"developer_message": "This field may not be blank."}}
}
assert response.status_code == 400
response_data = json.loads(response.content.decode('utf-8'))
assert response_data == expected_response_data
@ddt.data(
("abuse_flagged", True),
("abuse_flagged", False),
)
@ddt.unpack
def test_closed_thread(self, field, value):
self.register_get_user_response(self.user)
self.register_thread({"closed": True, "read": True})
self.register_flag_response("thread", "test_thread")
request_data = {field: value}
response = self.request_patch(request_data)
assert response.status_code == 200
response_data = json.loads(response.content.decode('utf-8'))
assert response_data == self.expected_thread_data({
'read': True,
'closed': True,
'abuse_flagged': value,
'editable_fields': ['abuse_flagged', 'copy_link', 'read'],
'comment_count': 1, 'unread_comment_count': 0
})
@ddt.data(
("raw_body", "Edited body"),
("voted", True),
("following", True),
)
@ddt.unpack
def test_closed_thread_error(self, field, value):
self.register_get_user_response(self.user)
self.register_thread({"closed": True})
self.register_flag_response("thread", "test_thread")
request_data = {field: value}
response = self.request_patch(request_data)
assert response.status_code == 400
def test_patch_read_owner_user(self):
self.register_get_user_response(self.user)
self.register_thread({"resp_total": 2})
self.register_read_response(self.user, "thread", "test_thread")
request_data = {"read": True}
response = self.request_patch(request_data)
assert response.status_code == 200
response_data = json.loads(response.content.decode('utf-8'))
assert response_data == self.expected_thread_data({
'comment_count': 1,
'read': True,
'editable_fields': [
'abuse_flagged', 'anonymous', 'copy_link', 'following', 'raw_body', 'read',
'title', 'topic_id', 'type'
],
'response_count': 2
})
def test_patch_read_non_owner_user(self):
self.register_get_user_response(self.user)
thread_owner_user = UserFactory.create(password=self.password)
CourseEnrollmentFactory.create(user=thread_owner_user, course_id=self.course.id)
self.register_get_user_response(thread_owner_user)
self.register_thread({
"username": thread_owner_user.username,
"user_id": str(thread_owner_user.id),
"resp_total": 2,
})
self.register_read_response(self.user, "thread", "test_thread")
request_data = {"read": True}
response = self.request_patch(request_data)
assert response.status_code == 200
response_data = json.loads(response.content.decode('utf-8'))
assert response_data == self.expected_thread_data({
'author': str(thread_owner_user.username),
'comment_count': 1,
'can_delete': False,
'read': True,
'editable_fields': ['abuse_flagged', 'copy_link', 'following', 'read', 'voted'],
'response_count': 2
})
@httpretty.activate
@disable_signal(api, 'thread_deleted')
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
@@ -2632,148 +2475,6 @@ class CommentViewSetCreateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
assert response.status_code == 403
@ddt.ddt
@disable_signal(api, 'comment_edited')
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class CommentViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin):
"""Tests for CommentViewSet partial_update"""
def setUp(self):
self.unsupported_media_type = JSONParser.media_type
super().setUp()
httpretty.reset()
httpretty.enable()
self.addCleanup(httpretty.reset)
self.addCleanup(httpretty.disable)
patcher = mock.patch(
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
return_value=False
)
patcher.start()
self.addCleanup(patcher.stop)
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment"
)
self.mock_get_course_id_by_comment = patcher.start()
self.addCleanup(patcher.stop)
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread"
)
self.mock_get_course_id_by_thread = patcher.start()
self.addCleanup(patcher.stop)
self.register_get_user_response(self.user)
self.url = reverse("comment-detail", kwargs={"comment_id": "test_comment"})
def expected_response_data(self, overrides=None):
"""
create expected response data from comment update endpoint
"""
response_data = {
"id": "test_comment",
"thread_id": "test_thread",
"parent_id": None,
"author": self.user.username,
"author_label": None,
"created_at": "1970-01-01T00:00:00Z",
"updated_at": "1970-01-01T00:00:00Z",
"raw_body": "Original body",
"rendered_body": "<p>Original body</p>",
"endorsed": False,
"endorsed_by": None,
"endorsed_by_label": None,
"endorsed_at": None,
"abuse_flagged": False,
"abuse_flagged_any_user": None,
"voted": False,
"vote_count": 0,
"children": [],
"editable_fields": [],
"child_count": 0,
"can_delete": True,
"anonymous": False,
"anonymous_to_peers": False,
"last_edit": None,
"edit_by_label": None,
"profile_image": {
"has_image": False,
"image_url_full": "http://testserver/static/default_500.png",
"image_url_large": "http://testserver/static/default_120.png",
"image_url_medium": "http://testserver/static/default_50.png",
"image_url_small": "http://testserver/static/default_30.png",
},
}
response_data.update(overrides or {})
return response_data
def test_basic(self):
self.register_thread()
self.register_comment({"created_at": "Test Created Date", "updated_at": "Test Updated Date"})
request_data = {"raw_body": "Edited body"}
response = self.request_patch(request_data)
assert response.status_code == 200
response_data = json.loads(response.content.decode('utf-8'))
assert response_data == self.expected_response_data({
'raw_body': 'Edited body',
'rendered_body': '<p>Edited body</p>',
'editable_fields': ['abuse_flagged', 'anonymous', 'raw_body'],
'created_at': 'Test Created Date',
'updated_at': 'Test Updated Date'
})
assert parsed_body(httpretty.last_request()) == {
'body': ['Edited body'],
'course_id': [str(self.course.id)],
'user_id': [str(self.user.id)],
'anonymous': ['False'],
'anonymous_to_peers': ['False'],
'endorsed': ['False'],
'editing_user_id': [str(self.user.id)],
}
def test_error(self):
self.register_thread()
self.register_comment()
request_data = {"raw_body": ""}
response = self.request_patch(request_data)
expected_response_data = {
"field_errors": {"raw_body": {"developer_message": "This field may not be blank."}}
}
assert response.status_code == 400
response_data = json.loads(response.content.decode('utf-8'))
assert response_data == expected_response_data
@ddt.data(
("abuse_flagged", True),
("abuse_flagged", False),
)
@ddt.unpack
def test_closed_thread(self, field, value):
self.register_thread({"closed": True})
self.register_comment()
self.register_flag_response("comment", "test_comment")
request_data = {field: value}
response = self.request_patch(request_data)
assert response.status_code == 200
response_data = json.loads(response.content.decode('utf-8'))
assert response_data == self.expected_response_data({
'abuse_flagged': value,
"abuse_flagged_any_user": None,
'editable_fields': ['abuse_flagged']
})
@ddt.data(
("raw_body", "Edited body"),
("voted", True),
("following", True),
)
@ddt.unpack
def test_closed_thread_error(self, field, value):
self.register_thread({"closed": True})
self.register_comment()
request_data = {field: value}
response = self.request_patch(request_data)
assert response.status_code == 400
@httpretty.activate
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class ThreadViewSetRetrieveTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase, ProfileImageTestMixin):

View File

@@ -0,0 +1,444 @@
# pylint: skip-file
"""
Tests for the external REST API endpoints of the Discussion API (views_v2.py).
This module focuses on integration tests for the Django REST Framework views that expose the Discussion API.
It verifies the correct behavior of the API endpoints, including authentication, permissions, request/response formats,
and integration with the underlying discussion service. These tests ensure that the endpoints correctly handle
various user roles, input data, and edge cases, and that they return appropriate HTTP status codes and response bodies.
"""
import json
import random
from datetime import datetime
from unittest import mock
from urllib.parse import parse_qs, urlencode, urlparse
import ddt
import httpretty
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import override_settings
from django.urls import reverse
from edx_toggles.toggles.testutils import override_waffle_flag
from lms.djangoapps.discussion.django_comment_client.tests.mixins import MockForumApiMixin
from opaque_keys.edx.keys import CourseKey
from pytz import UTC
from rest_framework import status
from rest_framework.parsers import JSONParser
from rest_framework.test import APIClient, APITestCase
from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE
from lms.djangoapps.discussion.rest_api.utils import get_usernames_from_search_string
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory, check_mongo_calls
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
from common.djangoapps.student.models import get_retired_username_by_username, CourseEnrollment
from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole, GlobalStaff
from common.djangoapps.student.tests.factories import (
AdminFactory,
CourseEnrollmentFactory,
SuperuserFactory,
UserFactory
)
from common.djangoapps.util.testing import PatchMediaTypeMixin, UrlResetMixin
from common.test.utils import disable_signal
from lms.djangoapps.discussion.tests.utils import (
make_minimal_cs_comment,
make_minimal_cs_thread,
)
from lms.djangoapps.discussion.django_comment_client.tests.utils import (
ForumsEnableMixin,
config_course_discussions,
topic_name_to_id,
)
from lms.djangoapps.discussion.rest_api import api
from lms.djangoapps.discussion.rest_api.tests.utils import (
CommentsServiceMockMixin,
ForumMockUtilsMixin,
ProfileImageTestMixin,
make_paginated_api_response,
parsed_body,
)
from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts
from openedx.core.djangoapps.discussions.config.waffle import ENABLE_NEW_STRUCTURE_DISCUSSIONS
from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, DiscussionTopicLink, Provider
from openedx.core.djangoapps.discussions.tasks import update_discussions_settings_from_course_task
from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings, Role
from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles
from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user
from openedx.core.djangoapps.oauth_dispatch.tests.factories import AccessTokenFactory, ApplicationFactory
from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_storage
from openedx.core.djangoapps.user_api.models import RetirementState, UserRetirementStatus
class DiscussionAPIViewTestMixin(ForumsEnableMixin, ForumMockUtilsMixin, UrlResetMixin):
"""
Mixin for common code in tests of Discussion API views. This includes
creation of common structures (e.g. a course, user, and enrollment), logging
in the test client, utility functions, and a test case for unauthenticated
requests. Subclasses must set self.url in their setUp methods.
"""
client_class = APIClient
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
self.maxDiff = None # pylint: disable=invalid-name
self.course = CourseFactory.create(
org="x",
course="y",
run="z",
start=datetime.now(UTC),
discussion_topics={"Test Topic": {"id": "test_topic"}}
)
self.password = "Password1234"
self.user = UserFactory.create(password=self.password)
# Ensure that parental controls don't apply to this user
self.user.profile.year_of_birth = 1970
self.user.profile.save()
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
self.client.login(username=self.user.username, password=self.password)
@classmethod
def setUpClass(cls):
super().setUpClass()
super().setUpClassAndForumMock()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
super().disposeForumMocks()
def assert_response_correct(self, response, expected_status, expected_content):
"""
Assert that the response has the given status code and parsed content
"""
assert response.status_code == expected_status
parsed_content = json.loads(response.content.decode('utf-8'))
assert parsed_content == expected_content
def register_thread(self, overrides=None):
"""
Create cs_thread with minimal fields and register response
"""
cs_thread = make_minimal_cs_thread({
"id": "test_thread",
"course_id": str(self.course.id),
"commentable_id": "test_topic",
"username": self.user.username,
"user_id": str(self.user.id),
"thread_type": "discussion",
"title": "Test Title",
"body": "Test body",
})
cs_thread.update(overrides or {})
self.register_get_thread_response(cs_thread)
self.register_put_thread_response(cs_thread)
def register_comment(self, overrides=None):
"""
Create cs_comment with minimal fields and register response
"""
cs_comment = make_minimal_cs_comment({
"id": "test_comment",
"course_id": str(self.course.id),
"thread_id": "test_thread",
"username": self.user.username,
"user_id": str(self.user.id),
"body": "Original body",
})
cs_comment.update(overrides or {})
self.register_get_comment_response(cs_comment)
self.register_put_comment_response(cs_comment)
self.register_post_comment_response(cs_comment, thread_id="test_thread")
def test_not_authenticated(self):
self.client.logout()
response = self.client.get(self.url)
self.assert_response_correct(
response,
401,
{"developer_message": "Authentication credentials were not provided."}
)
def test_inactive(self):
self.user.is_active = False
self.test_basic()
@ddt.ddt
@httpretty.activate
@disable_signal(api, 'thread_edited')
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class ThreadViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin):
"""Tests for ThreadViewSet partial_update"""
def setUp(self):
self.unsupported_media_type = JSONParser.media_type
super().setUp()
self.url = reverse("thread-detail", kwargs={"thread_id": "test_thread"})
def test_basic(self):
self.register_get_user_response(self.user)
self.register_thread({
"created_at": "Test Created Date",
"updated_at": "Test Updated Date",
"read": True,
"resp_total": 2,
})
request_data = {"raw_body": "Edited body"}
response = self.request_patch(request_data)
assert response.status_code == 200
response_data = json.loads(response.content.decode('utf-8'))
assert response_data == self.expected_thread_data({
'raw_body': 'Edited body',
'rendered_body': '<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),
}
self.check_mock_called_with('update_thread', -1, **params)
def test_error(self):
self.register_get_user_response(self.user)
self.register_thread()
request_data = {"title": ""}
response = self.request_patch(request_data)
expected_response_data = {
"field_errors": {"title": {"developer_message": "This field may not be blank."}}
}
assert response.status_code == 400
response_data = json.loads(response.content.decode('utf-8'))
assert response_data == expected_response_data
@ddt.data(
("abuse_flagged", True),
("abuse_flagged", False),
)
@ddt.unpack
def test_closed_thread(self, field, value):
self.register_get_user_response(self.user)
self.register_thread({"closed": True, "read": True})
self.register_flag_response("thread", "test_thread")
request_data = {field: value}
response = self.request_patch(request_data)
assert response.status_code == 200
response_data = json.loads(response.content.decode('utf-8'))
assert response_data == self.expected_thread_data({
'read': True,
'closed': True,
'abuse_flagged': value,
'editable_fields': ['abuse_flagged', 'copy_link', 'read'],
'comment_count': 1, 'unread_comment_count': 0
})
@ddt.data(
("raw_body", "Edited body"),
("voted", True),
("following", True),
)
@ddt.unpack
def test_closed_thread_error(self, field, value):
self.register_get_user_response(self.user)
self.register_thread({"closed": True})
self.register_flag_response("thread", "test_thread")
request_data = {field: value}
response = self.request_patch(request_data)
assert response.status_code == 400
def test_patch_read_owner_user(self):
self.register_get_user_response(self.user)
self.register_thread({"resp_total": 2})
self.register_read_response(self.user, "thread", "test_thread")
request_data = {"read": True}
response = self.request_patch(request_data)
assert response.status_code == 200
response_data = json.loads(response.content.decode('utf-8'))
assert response_data == self.expected_thread_data({
'comment_count': 1,
'read': True,
'editable_fields': [
'abuse_flagged', 'anonymous', 'copy_link', 'following', 'raw_body', 'read',
'title', 'topic_id', 'type'
],
'response_count': 2
})
def test_patch_read_non_owner_user(self):
self.register_get_user_response(self.user)
thread_owner_user = UserFactory.create(password=self.password)
CourseEnrollmentFactory.create(user=thread_owner_user, course_id=self.course.id)
self.register_thread({
"username": thread_owner_user.username,
"user_id": str(thread_owner_user.id),
"resp_total": 2,
})
self.register_read_response(self.user, "thread", "test_thread")
request_data = {"read": True}
response = self.request_patch(request_data)
assert response.status_code == 200
response_data = json.loads(response.content.decode('utf-8'))
expected_data = self.expected_thread_data({
'author': str(thread_owner_user.username),
'comment_count': 1,
'can_delete': False,
'read': True,
'editable_fields': ['abuse_flagged', 'copy_link', 'following', 'read', 'voted'],
'response_count': 2
})
assert response_data == expected_data
@ddt.ddt
@disable_signal(api, 'comment_edited')
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class CommentViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin):
"""Tests for CommentViewSet partial_update"""
def setUp(self):
self.unsupported_media_type = JSONParser.media_type
super().setUp()
self.register_get_user_response(self.user)
self.url = reverse("comment-detail", kwargs={"comment_id": "test_comment"})
def expected_response_data(self, overrides=None):
"""
create expected response data from comment update endpoint
"""
response_data = {
"id": "test_comment",
"thread_id": "test_thread",
"parent_id": None,
"author": self.user.username,
"author_label": None,
"created_at": "1970-01-01T00:00:00Z",
"updated_at": "1970-01-01T00:00:00Z",
"raw_body": "Original body",
"rendered_body": "<p>Original body</p>",
"endorsed": False,
"endorsed_by": None,
"endorsed_by_label": None,
"endorsed_at": None,
"abuse_flagged": False,
"abuse_flagged_any_user": None,
"voted": False,
"vote_count": 0,
"children": [],
"editable_fields": [],
"child_count": 0,
"can_delete": True,
"anonymous": False,
"anonymous_to_peers": False,
"last_edit": None,
"edit_by_label": None,
"profile_image": {
"has_image": False,
"image_url_full": "http://testserver/static/default_500.png",
"image_url_large": "http://testserver/static/default_120.png",
"image_url_medium": "http://testserver/static/default_50.png",
"image_url_small": "http://testserver/static/default_30.png",
},
}
response_data.update(overrides or {})
return response_data
def test_basic(self):
self.register_thread()
self.register_comment({"created_at": "Test Created Date", "updated_at": "Test Updated Date"})
request_data = {"raw_body": "Edited body"}
response = self.request_patch(request_data)
assert response.status_code == 200
response_data = json.loads(response.content.decode('utf-8'))
assert response_data == self.expected_response_data({
'raw_body': 'Edited body',
'rendered_body': '<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),
}
self.check_mock_called_with('update_comment', -1, **params)
def test_error(self):
self.register_thread()
self.register_comment()
request_data = {"raw_body": ""}
response = self.request_patch(request_data)
expected_response_data = {
"field_errors": {"raw_body": {"developer_message": "This field may not be blank."}}
}
assert response.status_code == 400
response_data = json.loads(response.content.decode('utf-8'))
assert response_data == expected_response_data
@ddt.data(
("abuse_flagged", True),
("abuse_flagged", False),
)
@ddt.unpack
def test_closed_thread(self, field, value):
self.register_thread({"closed": True})
self.register_comment()
self.register_flag_response("comment", "test_comment")
request_data = {field: value}
response = self.request_patch(request_data)
assert response.status_code == 200
response_data = json.loads(response.content.decode('utf-8'))
assert response_data == self.expected_response_data({
'abuse_flagged': value,
"abuse_flagged_any_user": None,
'editable_fields': ['abuse_flagged']
})
@ddt.data(
("raw_body", "Edited body"),
("voted", True),
("following", True),
)
@ddt.unpack
def test_closed_thread_error(self, field, value):
self.register_thread({"closed": True})
self.register_comment()
request_data = {field: value}
response = self.request_patch(request_data)
assert response.status_code == 400

View File

@@ -14,6 +14,7 @@ import httpretty
from PIL import Image
from pytz import UTC
from lms.djangoapps.discussion.django_comment_client.tests.mixins import MockForumApiMixin
from openedx.core.djangoapps.profile_images.images import create_profile_images
from openedx.core.djangoapps.profile_images.tests.helpers import make_image_file
from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_names, set_has_profile_image
@@ -51,6 +52,34 @@ def _get_thread_callback(thread_data):
return callback
def make_thread_callback(thread_data):
"""
Returns a function that simulates thread creation/update behavior,
applying overrides based on keyword arguments (e.g., mock request body).
"""
def callback(*args, **kwargs):
# Simulate default thread response
response_data = make_minimal_cs_thread(thread_data)
original_data = response_data.copy()
for key, val in kwargs.items():
if key in ["anonymous", "anonymous_to_peers", "closed", "pinned"]:
response_data[key] = val is True or val == "True"
elif key == "edit_reason_code":
response_data["edit_history"] = [{
"original_body": original_data["body"],
"author": thread_data.get("username"),
"reason_code": val,
}]
else:
response_data[key] = val
return response_data
return callback
def _get_comment_callback(comment_data, thread_id, parent_id):
"""
Get a callback function that will return a comment containing the given data
@@ -86,6 +115,38 @@ def _get_comment_callback(comment_data, thread_id, parent_id):
return callback
def make_comment_callback(comment_data, thread_id, parent_id):
"""
Returns a callable that mimics comment creation or update behavior,
applying overrides based on keyword arguments like a parsed request body.
"""
def callback(*args, **kwargs):
response_data = make_minimal_cs_comment(comment_data)
original_data = response_data.copy()
# Inject thread_id and parent_id
response_data["thread_id"] = thread_id
response_data["parent_id"] = parent_id
# Override fields based on "incoming request"
for key, val in kwargs.items():
if key in ["anonymous", "anonymous_to_peers", "endorsed"]:
response_data[key] = val is True or val == "True"
elif key == "edit_reason_code":
response_data["edit_history"] = [{
"original_body": original_data["body"],
"author": comment_data.get("username"),
"reason_code": val,
}]
else:
response_data[key] = val
return response_data
return callback
class CommentsServiceMockMixin:
"""Mixin with utility methods for mocking the comments service"""
@@ -521,6 +582,226 @@ class CommentsServiceMockMixin:
return response_data
class ForumMockUtilsMixin(MockForumApiMixin):
"""Mixin with utility methods for mocking the comments service"""
def register_get_threads_response(self, threads, page, num_pages):
"""Register a mock response for GET on the CS thread list endpoint"""
self.set_mock_return_value('get_user_threads', {
"collection": threads,
"page": page,
"num_pages": num_pages,
"thread_count": len(threads),
})
def register_get_course_commentable_counts_response(self, course_id, thread_counts):
"""Register a mock response for GET on the CS thread list endpoint"""
self.set_mock_return_value('get_commentables_stats', thread_counts)
def register_get_threads_search_response(self, threads, rewrite, num_pages=1):
"""Register a mock response for GET on the CS thread search endpoint"""
self.set_mock_return_value('search_threads', {
"collection": threads,
"page": 1,
"num_pages": num_pages,
"corrected_text": rewrite,
"thread_count": len(threads),
})
def register_post_thread_response(self, thread_data):
self.set_mock_side_effect('create_thread', make_thread_callback(thread_data))
def register_put_thread_response(self, thread_data):
self.set_mock_side_effect('update_thread', make_thread_callback(thread_data))
def register_get_thread_error_response(self, thread_id, status_code):
self.set_mock_return_value('get_thread', Exception(f"Error {status_code}"))
def register_get_thread_response(self, thread):
self.set_mock_return_value('get_thread', thread)
def register_get_comments_response(self, comments, page, num_pages):
self.set_mock_return_value('get_parent_comment', {
"collection": comments,
"page": page,
"num_pages": num_pages,
"comment_count": len(comments),
})
def register_post_comment_response(self, comment_data, thread_id, parent_id=None):
self.set_mock_side_effect(
'create_child_comment' if parent_id else 'create_parent_comment',
make_comment_callback(comment_data, thread_id, parent_id)
)
def register_put_comment_response(self, comment_data):
thread_id = comment_data["thread_id"]
parent_id = comment_data.get("parent_id")
self.set_mock_side_effect(
'update_comment',
make_comment_callback(comment_data, thread_id, parent_id)
)
def register_get_comment_error_response(self, comment_id, status_code):
self.set_mock_return_value('get_parent_comment', Exception(f"Error {status_code}"))
def register_get_comment_response(self, response_overrides):
comment = make_minimal_cs_comment(response_overrides)
self.set_mock_return_value('get_parent_comment', comment)
def register_get_user_response(self, user, subscribed_thread_ids=None, upvoted_ids=None):
self.set_mock_return_value('get_user', {
"id": str(user.id),
"subscribed_thread_ids": subscribed_thread_ids or [],
"upvoted_ids": upvoted_ids or [],
})
def register_get_user_retire_response(self, user, body=""):
self.set_mock_return_value('retire_user', body)
def register_get_username_replacement_response(self, user, status=200, body=""):
self.set_mock_return_value('update_username', body)
def register_subscribed_threads_response(self, user, threads, page, num_pages):
self.set_mock_return_value('get_user_subscriptions', {
"collection": threads,
"page": page,
"num_pages": num_pages,
"thread_count": len(threads),
})
def register_course_stats_response(self, course_key, stats, page, num_pages):
self.set_mock_return_value('get_user_course_stats', {
"user_stats": stats,
"page": page,
"num_pages": num_pages,
"count": len(stats),
})
def register_subscription_response(self, user):
self.set_mock_return_value('create_subscription', {})
self.set_mock_return_value('delete_subscription', {})
def register_thread_votes_response(self, thread_id):
self.set_mock_return_value('update_thread_votes', {})
self.set_mock_return_value('delete_thread_vote', {})
def register_comment_votes_response(self, comment_id):
self.set_mock_return_value('update_comment_votes', {})
self.set_mock_return_value('delete_comment_vote', {})
def register_flag_response(self, content_type, content_id):
if content_type == 'thread':
self.set_mock_return_value('update_thread_flag', {})
elif content_type == 'comment':
self.set_mock_return_value('update_comment_flag', {})
def register_read_response(self, user, content_type, content_id):
self.set_mock_return_value('mark_thread_as_read', {})
def register_delete_thread_response(self, thread_id):
self.set_mock_return_value('delete_thread', {})
def register_delete_comment_response(self, comment_id):
self.set_mock_return_value('delete_comment', {})
def register_user_active_threads(self, user_id, response):
self.set_mock_return_value('get_user_active_threads', response)
def register_get_subscriptions(self, thread_id, response):
self.set_mock_return_value('get_thread_subscriptions', response)
def register_thread_flag_response(self, thread_id):
"""Register a mock response for PUT on the CS thread flag endpoints"""
self.register_flag_response("thread", thread_id)
def register_comment_flag_response(self, comment_id):
"""Register a mock response for PUT on the CS comment flag endpoints"""
self.register_flag_response("comment", comment_id)
def assert_query_params_equal(self, httpretty_request, expected_params):
"""
Assert that the given mock request had the expected query parameters
"""
actual_params = dict(querystring(httpretty_request))
actual_params.pop("request_id") # request_id is random
assert actual_params == expected_params
def assert_last_query_params(self, expected_params):
"""
Assert that the last mock request had the expected query parameters
"""
self.assert_query_params_equal(httpretty.last_request(), expected_params)
def request_patch(self, request_data):
"""
make a request to PATCH endpoint and return response
"""
return self.client.patch(
self.url,
json.dumps(request_data),
content_type="application/merge-patch+json"
)
def expected_thread_data(self, overrides=None):
"""
Returns expected thread data in API response
"""
response_data = {
"anonymous": False,
"anonymous_to_peers": False,
"author": self.user.username,
"author_label": None,
"created_at": "1970-01-01T00:00:00Z",
"updated_at": "1970-01-01T00:00:00Z",
"raw_body": "Test body",
"rendered_body": "<p>Test body</p>",
"preview_body": "Test body",
"abuse_flagged": False,
"abuse_flagged_count": None,
"voted": False,
"vote_count": 0,
"editable_fields": [
"abuse_flagged",
"anonymous",
"copy_link",
"following",
"raw_body",
"read",
"title",
"topic_id",
"type",
],
"course_id": str(self.course.id),
"topic_id": "test_topic",
"group_id": None,
"group_name": None,
"title": "Test Title",
"pinned": False,
"closed": False,
"can_delete": True,
"following": False,
"comment_count": 1,
"unread_comment_count": 0,
"comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread",
"endorsed_comment_list_url": None,
"non_endorsed_comment_list_url": None,
"read": False,
"has_endorsed": False,
"id": "test_thread",
"type": "discussion",
"response_count": 0,
"last_edit": None,
"edit_by_label": None,
"closed_by": None,
"closed_by_label": None,
"close_reason": None,
"close_reason_code": None,
}
response_data.update(overrides or {})
return response_data
def make_minimal_cs_thread(overrides=None):
"""
Create a dictionary containing all needed thread fields as returned by the

View File

@@ -0,0 +1,70 @@
"""
Utils for the discussion app.
"""
def make_minimal_cs_thread(overrides=None):
"""
Create a dictionary containing all needed thread fields as returned by the
comments service with dummy data and optional overrides
"""
ret = {
"type": "thread",
"id": "dummy",
"course_id": "course-v1:dummy+dummy+dummy",
"commentable_id": "dummy",
"group_id": None,
"user_id": "0",
"username": "dummy",
"anonymous": False,
"anonymous_to_peers": False,
"created_at": "1970-01-01T00:00:00Z",
"updated_at": "1970-01-01T00:00:00Z",
"last_activity_at": "1970-01-01T00:00:00Z",
"thread_type": "discussion",
"title": "dummy",
"body": "dummy",
"pinned": False,
"closed": False,
"abuse_flaggers": [],
"abuse_flagged_count": None,
"votes": {"up_count": 0},
"comments_count": 0,
"unread_comments_count": 0,
"children": [],
"read": False,
"endorsed": False,
"resp_total": 0,
"closed_by": None,
"close_reason_code": None,
}
ret.update(overrides or {})
return ret
def make_minimal_cs_comment(overrides=None):
"""
Create a dictionary containing all needed comment fields as returned by the
comments service with dummy data and optional overrides
"""
ret = {
"type": "comment",
"id": "dummy",
"commentable_id": "dummy",
"thread_id": "dummy",
"parent_id": None,
"user_id": "0",
"username": "dummy",
"anonymous": False,
"anonymous_to_peers": False,
"created_at": "1970-01-01T00:00:00Z",
"updated_at": "1970-01-01T00:00:00Z",
"body": "dummy",
"abuse_flaggers": [],
"votes": {"up_count": 0},
"endorsed": False,
"child_count": 0,
"children": [],
}
ret.update(overrides or {})
return ret

View File

@@ -3,10 +3,9 @@ from bs4 import BeautifulSoup
from openedx.core.djangoapps.django_comment_common.comment_client import models, settings
from .thread import Thread, _url_for_flag_abuse_thread, _url_for_unflag_abuse_thread
from .utils import CommentClientRequestError, get_course_key, perform_request
from .thread import Thread
from .utils import CommentClientRequestError, get_course_key
from forum import api as forum_api
from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled
class Comment(models.Model):
@@ -64,71 +63,30 @@ class Comment(models.Model):
return super().url(action, params)
def flagAbuse(self, user, voteable, course_id=None):
if voteable.type == 'thread':
url = _url_for_flag_abuse_thread(voteable.id)
elif voteable.type == 'comment':
url = _url_for_flag_abuse_comment(voteable.id)
else:
raise CommentClientRequestError("Can only flag/unflag threads or comments")
if voteable.type != 'comment':
raise CommentClientRequestError("Can only flag comments")
course_key = get_course_key(self.attributes.get("course_id") or course_id)
if is_forum_v2_enabled(course_key):
if voteable.type == 'thread':
response = forum_api.update_thread_flag(
voteable.id, "flag", user_id=user.id, course_id=str(course_key)
)
else:
response = forum_api.update_comment_flag(
voteable.id, "flag", user_id=user.id, course_id=str(course_key)
)
else:
params = {'user_id': user.id}
response = perform_request(
'put',
url,
params,
metric_tags=self._metric_tags,
metric_action='comment.abuse.flagged'
)
response = forum_api.update_comment_flag(
comment_id=voteable.id,
action="flag",
user_id=str(user.id),
course_id=str(course_key),
)
voteable._update_from_response(response)
def unFlagAbuse(self, user, voteable, removeAll, course_id=None):
if voteable.type == 'thread':
url = _url_for_unflag_abuse_thread(voteable.id)
elif voteable.type == 'comment':
url = _url_for_unflag_abuse_comment(voteable.id)
else:
raise CommentClientRequestError("Can flag/unflag for threads or comments")
if voteable.type != 'comment':
raise CommentClientRequestError("Can only unflag comments")
course_key = get_course_key(self.attributes.get("course_id") or course_id)
if is_forum_v2_enabled(course_key):
if voteable.type == "thread":
response = forum_api.update_thread_flag(
thread_id=voteable.id,
action="unflag",
user_id=user.id,
update_all=bool(removeAll),
course_id=str(course_key)
)
else:
response = forum_api.update_comment_flag(
comment_id=voteable.id,
action="unflag",
user_id=user.id,
update_all=bool(removeAll),
course_id=str(course_key)
)
else:
params = {'user_id': user.id}
if removeAll:
params['all'] = True
response = perform_request(
'put',
url,
params,
metric_tags=self._metric_tags,
metric_action='comment.abuse.unflagged'
)
response = forum_api.update_comment_flag(
comment_id=voteable.id,
action="unflag",
user_id=str(user.id),
update_all=bool(removeAll),
course_id=str(course_key),
)
voteable._update_from_response(response)
@property

View File

@@ -170,7 +170,6 @@ class Model:
response = self.handle_update(params)
else: # otherwise, treat this as an insert
response = self.handle_create(params)
self.retrieved = True
self._update_from_response(response)
self.after_save(self)
@@ -256,7 +255,7 @@ class Model:
request_data = {
"comment_id": self.attributes["id"],
"body": request_params.get("body"),
"course_id": request_params.get("course_id"),
"course_id": request_params.get("course_id") or course_id,
"user_id": request_params.get("user_id"),
"anonymous": request_params.get("anonymous"),
"anonymous_to_peers": request_params.get("anonymous_to_peers"),
@@ -265,7 +264,6 @@ class Model:
"editing_user_id": request_params.get("editing_user_id"),
"edit_reason_code": request_params.get("edit_reason_code"),
"endorsement_user_id": request_params.get("endorsement_user_id"),
"course_key": course_id
}
request_data = {k: v for k, v in request_data.items() if v is not None}
response = forum_api.update_comment(**request_data)
@@ -276,7 +274,7 @@ class Model:
"thread_id": self.attributes["id"],
"title": request_params.get("title"),
"body": request_params.get("body"),
"course_id": request_params.get("course_id"),
"course_id": request_params.get("course_id") or course_id,
"anonymous": request_params.get("anonymous"),
"anonymous_to_peers": request_params.get("anonymous_to_peers"),
"closed": request_params.get("closed"),
@@ -289,7 +287,7 @@ class Model:
"close_reason_code": request_params.get("close_reason_code"),
"closing_user_id": request_params.get("closing_user_id"),
"endorsed": request_params.get("endorsed"),
"course_key": course_id
"read": request_params.get("read"),
}
request_data = {k: v for k, v in request_data.items() if v is not None}
response = forum_api.update_thread(**request_data)

View File

@@ -199,51 +199,31 @@ class Thread(models.Model):
self._update_from_response(response)
def flagAbuse(self, user, voteable, course_id=None):
if voteable.type == 'thread':
url = _url_for_flag_abuse_thread(voteable.id)
else:
raise utils.CommentClientRequestError("Can only flag/unflag threads or comments")
if voteable.type != 'thread':
raise utils.CommentClientRequestError("Can only flag threads")
course_key = utils.get_course_key(self.attributes.get("course_id") or course_id)
if is_forum_v2_enabled(course_key):
response = forum_api.update_thread_flag(voteable.id, "flag", user_id=user.id, course_id=str(course_key))
else:
params = {'user_id': user.id}
response = utils.perform_request(
'put',
url,
params,
metric_action='thread.abuse.flagged',
metric_tags=self._metric_tags
)
response = forum_api.update_thread_flag(
thread_id=voteable.id,
action="flag",
user_id=str(user.id),
course_id=str(course_key)
)
voteable._update_from_response(response)
def unFlagAbuse(self, user, voteable, removeAll, course_id=None):
if voteable.type == 'thread':
url = _url_for_unflag_abuse_thread(voteable.id)
else:
raise utils.CommentClientRequestError("Can only flag/unflag for threads or comments")
course_key = utils.get_course_key(self.attributes.get("course_id") or course_id)
if is_forum_v2_enabled(course_key):
response = forum_api.update_thread_flag(
thread_id=voteable.id,
action="unflag",
user_id=user.id,
update_all=bool(removeAll),
course_id=str(course_key)
)
else:
params = {'user_id': user.id}
#if you're an admin, when you unflag, remove ALL flags
if removeAll:
params['all'] = True
if voteable.type != 'thread':
raise utils.CommentClientRequestError("Can only unflag threads")
course_key = utils.get_course_key(self.attributes.get("course_id") or course_id)
response = forum_api.update_thread_flag(
thread_id=voteable.id,
action="unflag",
user_id=user.id,
update_all=bool(removeAll),
course_id=str(course_key)
)
response = utils.perform_request(
'put',
url,
params,
metric_tags=self._metric_tags,
metric_action='thread.abuse.unflagged'
)
voteable._update_from_response(response)
def pin(self, user, thread_id, course_id=None):