feat!: remove last calls to cs_comments_service (#37376)

This removes the last remaining code that called out to the
cs_comments_service. All forums backend logic now uses the v2 API from
the forum repo (https://github.com/openedx/forum). This does NOT remove
MongoDB support.

This also implements the endpoint to retrieve all comments for a user
using the new forum backend. This is not actually called from any known
frontend code, but it has not been formally deprecated as an endpoint,
and therefore needs to be supported.

As part of the cleanup, the ENABLE_FORUM_V2 course waffle flag has also
been removed, along with all remaining switching logic that used to
route between the Python API in the forum repo and service calls to the
cs_comments_service Ruby service.

Other endpoints affected (switching logic removed):

* get course commentable counts
* get/update course user stats
* update comment/thread/user
* delete thread (implementation moved to forum repo)
* follow
* retire user

This is part of the following overall DEPR ticket:
  https://github.com/openedx/cs_comments_service/issues/437
This commit is contained in:
Taimoor Ahmed
2025-10-08 20:36:52 +05:00
committed by GitHub
parent e5b497cbba
commit 4c051378d0
20 changed files with 3688 additions and 4655 deletions

View File

@@ -1,636 +0,0 @@
# pylint: skip-file
"""Tests for django comment client views."""
import pytest
import json
import logging
from unittest import mock
from unittest.mock import ANY, Mock, patch
import ddt
from django.contrib.auth.models import User
from django.core.management import call_command
from django.test.client import RequestFactory
from django.urls import reverse
from eventtracking.processors.exceptions import EventEmissionExit
from opaque_keys.edx.keys import CourseKey
from common.djangoapps.student.roles import CourseStaffRole, UserBasedRole
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
from common.djangoapps.track.middleware import TrackMiddleware
from common.djangoapps.track.views import segmentio
from common.djangoapps.track.views.tests.base import SEGMENTIO_TEST_USER_ID, SegmentIOTrackingTestCaseBase
from common.djangoapps.util.testing import UrlResetMixin
from lms.djangoapps.discussion.django_comment_client.base import views
from lms.djangoapps.discussion.django_comment_client.tests.utils import ForumsEnableMixin
from lms.djangoapps.teams.tests.factories import CourseTeamFactory
from openedx.core.djangoapps.django_comment_common.comment_client import Thread
from openedx.core.djangoapps.django_comment_common.models import (
FORUM_ROLE_STUDENT,
Role
)
from openedx.core.djangoapps.django_comment_common.utils import (
ThreadContext,
seed_permissions_roles,
)
from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.django_utils import (
TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase, SharedModuleStoreTestCase,
)
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory
from .event_transformers import ForumThreadViewedEventTransformer
log = logging.getLogger(__name__)
CS_PREFIX = "http://localhost:4567/api/v1"
QUERY_COUNT_TABLE_IGNORELIST = WAFFLE_TABLES
# pylint: disable=missing-docstring
class MockRequestSetupMixin:
def _create_response_mock(self, data):
return Mock(
text=json.dumps(data),
json=Mock(return_value=data),
status_code=200
)
def _set_mock_request_data(self, mock_request, data):
mock_request.return_value = self._create_response_mock(data)
class ViewsTestCaseMixin:
def set_up_course(self, block_count=0):
"""
Creates a course, optionally with block_count discussion blocks, and
a user with appropriate permissions.
"""
# create a course
self.course = CourseFactory.create(
org='MITx', course='999',
discussion_topics={"Some Topic": {"id": "some_topic"}},
display_name='Robot Super Course',
)
self.course_id = self.course.id
# add some discussion blocks
for i in range(block_count):
BlockFactory.create(
parent_location=self.course.location,
category='discussion',
discussion_id=f'id_module_{i}',
discussion_category=f'Category {i}',
discussion_target=f'Discussion {i}'
)
# seed the forums permissions and roles
call_command('seed_permissions_roles', str(self.course_id))
# Patch the comment client user save method so it does not try
# to create a new cc user when creating a django user
with patch('common.djangoapps.student.models.user.cc.User.save'):
uname = 'student'
email = 'student@edx.org'
self.password = 'Password1234'
# Create the user and make them active so we can log them in.
self.student = UserFactory.create(username=uname, email=email, password=self.password)
self.student.is_active = True
self.student.save()
# Add a discussion moderator
self.moderator = UserFactory.create(password=self.password)
# Enroll the student in the course
CourseEnrollmentFactory(user=self.student,
course_id=self.course_id)
# Enroll the moderator and give them the appropriate roles
CourseEnrollmentFactory(user=self.moderator, course_id=self.course.id)
self.moderator.roles.add(Role.objects.get(name="Moderator", course_id=self.course.id))
assert self.client.login(username='student', password=self.password)
def _setup_mock_request(self, mock_request, include_depth=False):
"""
Ensure that mock_request returns the data necessary to make views
function correctly
"""
data = {
"user_id": str(self.student.id),
"closed": False,
"commentable_id": "non_team_dummy_id",
"thread_id": "dummy",
"thread_type": "discussion"
}
if include_depth:
data["depth"] = 0
self._set_mock_request_data(mock_request, data)
def create_thread_helper(self, mock_is_forum_v2_enabled, mock_request, extra_request_data=None, extra_response_data=None):
"""
Issues a request to create a thread and verifies the result.
"""
mock_is_forum_v2_enabled.return_value = False
self._set_mock_request_data(mock_request, {
"thread_type": "discussion",
"title": "Hello",
"body": "this is a post",
"course_id": "MITx/999/Robot_Super_Course",
"anonymous": False,
"anonymous_to_peers": False,
"commentable_id": "i4x-MITx-999-course-Robot_Super_Course",
"created_at": "2013-05-10T18:53:43Z",
"updated_at": "2013-05-10T18:53:43Z",
"at_position_list": [],
"closed": False,
"id": "518d4237b023791dca00000d",
"user_id": "1",
"username": "robot",
"votes": {
"count": 0,
"up_count": 0,
"down_count": 0,
"point": 0
},
"abuse_flaggers": [],
"type": "thread",
"group_id": None,
"pinned": False,
"endorsed": False,
"unread_comments_count": 0,
"read": False,
"comments_count": 0,
})
thread = {
"thread_type": "discussion",
"body": ["this is a post"],
"anonymous_to_peers": ["false"],
"auto_subscribe": ["false"],
"anonymous": ["false"],
"title": ["Hello"],
}
if extra_request_data:
thread.update(extra_request_data)
url = reverse('create_thread', kwargs={'commentable_id': 'i4x-MITx-999-course-Robot_Super_Course',
'course_id': str(self.course_id)})
response = self.client.post(url, data=thread)
assert mock_request.called
expected_data = {
'thread_type': 'discussion',
'body': 'this is a post',
'context': ThreadContext.COURSE,
'anonymous_to_peers': False, 'user_id': 1,
'title': 'Hello',
'commentable_id': 'i4x-MITx-999-course-Robot_Super_Course',
'anonymous': False,
'course_id': str(self.course_id),
}
if extra_response_data:
expected_data.update(extra_response_data)
mock_request.assert_called_with(
'post',
f'{CS_PREFIX}/i4x-MITx-999-course-Robot_Super_Course/threads',
data=expected_data,
params={'request_id': ANY},
headers=ANY,
timeout=5
)
assert response.status_code == 200
def update_thread_helper(self, mock_is_forum_v2_enabled, mock_request):
"""
Issues a request to update a thread and verifies the result.
"""
mock_is_forum_v2_enabled.return_value = False
self._setup_mock_request(mock_request)
# Mock out saving in order to test that content is correctly
# updated. Otherwise, the call to thread.save() receives the
# same mocked request data that the original call to retrieve
# the thread did, overwriting any changes.
with patch.object(Thread, 'save'):
response = self.client.post(
reverse("update_thread", kwargs={
"thread_id": "dummy",
"course_id": str(self.course_id)
}),
data={"body": "foo", "title": "foo", "commentable_id": "some_topic"}
)
assert response.status_code == 200
data = json.loads(response.content.decode('utf-8'))
assert data['body'] == 'foo'
assert data['title'] == 'foo'
assert data['commentable_id'] == 'some_topic'
class UsersEndpointTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, MockRequestSetupMixin):
@classmethod
def setUpClass(cls):
# pylint: disable=super-method-not-called
with super().setUpClassAndTestData():
cls.course = CourseFactory.create()
@classmethod
def setUpTestData(cls):
super().setUpTestData()
seed_permissions_roles(cls.course.id)
cls.student = UserFactory.create()
cls.enrollment = CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id)
cls.other_user = UserFactory.create(username="other")
CourseEnrollmentFactory(user=cls.other_user, course_id=cls.course.id)
def set_post_counts(self, mock_is_forum_v2_enabled, mock_request, threads_count=1, comments_count=1):
"""
sets up a mock response from the comments service for getting post counts for our other_user
"""
mock_is_forum_v2_enabled.return_value = False
self._set_mock_request_data(mock_request, {
"threads_count": threads_count,
"comments_count": comments_count,
})
def make_request(self, method='get', course_id=None, **kwargs):
course_id = course_id or self.course.id
request = getattr(RequestFactory(), method)("dummy_url", kwargs)
request.user = self.student
request.view_name = "users"
return views.users(request, course_id=str(course_id))
@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True)
@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True)
def test_finds_exact_match(self, mock_is_forum_v2_enabled, mock_request):
self.set_post_counts(mock_is_forum_v2_enabled, mock_request)
response = self.make_request(username="other")
assert response.status_code == 200
assert json.loads(response.content.decode('utf-8'))['users'] == [{'id': self.other_user.id, 'username': self.other_user.username}]
@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True)
@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True)
def test_finds_no_match(self, mock_is_forum_v2_enabled, mock_request):
self.set_post_counts(mock_is_forum_v2_enabled, mock_request)
response = self.make_request(username="othor")
assert response.status_code == 200
assert json.loads(response.content.decode('utf-8'))['users'] == []
def test_requires_GET(self):
response = self.make_request(method='post', username="other")
assert response.status_code == 405
def test_requires_username_param(self):
response = self.make_request()
assert response.status_code == 400
content = json.loads(response.content.decode('utf-8'))
assert 'errors' in content
assert 'users' not in content
def test_course_does_not_exist(self):
course_id = CourseKey.from_string("does/not/exist")
response = self.make_request(course_id=course_id, username="other")
assert response.status_code == 404
content = json.loads(response.content.decode('utf-8'))
assert 'errors' in content
assert 'users' not in content
def test_requires_requestor_enrolled_in_course(self):
# unenroll self.student from the course.
self.enrollment.delete()
response = self.make_request(username="other")
assert response.status_code == 404
content = json.loads(response.content.decode('utf-8'))
assert 'errors' in content
assert 'users' not in content
@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True)
@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True)
def test_requires_matched_user_has_forum_content(self, mock_is_forum_v2_enabled, mock_request):
self.set_post_counts(mock_is_forum_v2_enabled, mock_request, 0, 0)
response = self.make_request(username="other")
assert response.status_code == 200
assert json.loads(response.content.decode('utf-8'))['users'] == []
@ddt.ddt
class SegmentIOForumThreadViewedEventTestCase(SegmentIOTrackingTestCaseBase):
def _raise_navigation_event(self, label, include_name):
middleware = TrackMiddleware(get_response=lambda request: None)
kwargs = {'label': label}
if include_name:
kwargs['name'] = 'edx.bi.app.navigation.screen'
else:
kwargs['exclude_name'] = True
request = self.create_request(
data=self.create_segmentio_event_json(**kwargs),
content_type='application/json',
)
User.objects.create(pk=SEGMENTIO_TEST_USER_ID, username=str(mock.sentinel.username))
middleware.process_request(request)
try:
response = segmentio.segmentio_event(request)
assert response.status_code == 200
finally:
middleware.process_response(request, None)
@ddt.data(True, False)
def test_thread_viewed(self, include_name):
"""
Tests that a SegmentIO thread viewed event is accepted and transformed.
Only tests that the transformation happens at all; does not
comprehensively test that it happens correctly.
ForumThreadViewedEventTransformerTestCase tests for correctness.
"""
self._raise_navigation_event('Forum: View Thread', include_name)
event = self.get_event()
assert event['name'] == 'edx.forum.thread.viewed'
assert event['event_type'] == event['name']
@ddt.data(True, False)
def test_non_thread_viewed(self, include_name):
"""
Tests that other BI events are thrown out.
"""
self._raise_navigation_event('Forum: Create Thread', include_name)
self.assert_no_events_emitted()
def _get_transformed_event(input_event):
transformer = ForumThreadViewedEventTransformer(**input_event)
transformer.transform()
return transformer
def _create_event(
label='Forum: View Thread',
include_context=True,
inner_context=None,
username=None,
course_id=None,
**event_data
):
result = {'name': 'edx.bi.app.navigation.screen'}
if include_context:
result['context'] = {'label': label}
if course_id:
result['context']['course_id'] = str(course_id)
if username:
result['username'] = username
if event_data:
result['event'] = event_data
if inner_context:
if not event_data:
result['event'] = {}
result['event']['context'] = inner_context
return result
def _create_and_transform_event(**kwargs):
event = _create_event(**kwargs)
return event, _get_transformed_event(event)
@ddt.ddt
class ForumThreadViewedEventTransformerTestCase(ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase):
"""
Test that the ForumThreadViewedEventTransformer transforms events correctly
and without raising exceptions.
Because the events passed through the transformer can come from external
sources (e.g., a mobile app), we carefully test a myriad of cases, including
those with incomplete and malformed events.
"""
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
CATEGORY_ID = 'i4x-edx-discussion-id'
CATEGORY_NAME = 'Discussion 1'
PARENT_CATEGORY_NAME = 'Chapter 1'
TEAM_CATEGORY_ID = 'i4x-edx-team-discussion-id'
TEAM_CATEGORY_NAME = 'Team Chat'
TEAM_PARENT_CATEGORY_NAME = PARENT_CATEGORY_NAME
DUMMY_CATEGORY_ID = 'i4x-edx-dummy-commentable-id'
DUMMY_THREAD_ID = 'dummy_thread_id'
@mock.patch.dict("common.djangoapps.student.models.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
self.course = CourseFactory.create(
org='TestX',
course='TR-101S',
run='Event_Transform_Test_Split',
default_store=ModuleStoreEnum.Type.split,
)
self.student = UserFactory.create()
self.staff = UserFactory.create(is_staff=True)
UserBasedRole(user=self.staff, role=CourseStaffRole.ROLE).add_course(self.course.id)
CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id)
self.category = BlockFactory.create(
parent_location=self.course.location,
category='discussion',
discussion_id=self.CATEGORY_ID,
discussion_category=self.PARENT_CATEGORY_NAME,
discussion_target=self.CATEGORY_NAME,
)
self.team_category = BlockFactory.create(
parent_location=self.course.location,
category='discussion',
discussion_id=self.TEAM_CATEGORY_ID,
discussion_category=self.TEAM_PARENT_CATEGORY_NAME,
discussion_target=self.TEAM_CATEGORY_NAME,
)
self.team = CourseTeamFactory.create(
name='Team 1',
course_id=self.course.id,
topic_id='arbitrary-topic-id',
discussion_topic_id=self.team_category.discussion_id,
)
def test_missing_context(self):
event = _create_event(include_context=False)
with pytest.raises(EventEmissionExit):
_get_transformed_event(event)
def test_no_data(self):
event, event_trans = _create_and_transform_event()
event['name'] = 'edx.forum.thread.viewed'
event['event_type'] = event['name']
event['event'] = {}
self.assertDictEqual(event_trans, event)
def test_inner_context(self):
_, event_trans = _create_and_transform_event(inner_context={})
assert 'context' not in event_trans['event']
def test_non_thread_view(self):
event = _create_event(
label='Forum: Create Thread',
course_id=self.course.id,
topic_id=self.DUMMY_CATEGORY_ID,
thread_id=self.DUMMY_THREAD_ID,
)
with pytest.raises(EventEmissionExit):
_get_transformed_event(event)
def test_bad_field_types(self):
event, event_trans = _create_and_transform_event(
course_id={},
topic_id=3,
thread_id=object(),
action=3.14,
)
event['name'] = 'edx.forum.thread.viewed'
event['event_type'] = event['name']
self.assertDictEqual(event_trans, event)
def test_bad_course_id(self):
event, event_trans = _create_and_transform_event(course_id='non-existent-course-id')
event_data = event_trans['event']
assert 'category_id' not in event_data
assert 'category_name' not in event_data
assert 'url' not in event_data
assert 'user_forums_roles' not in event_data
assert 'user_course_roles' not in event_data
def test_bad_username(self):
event, event_trans = _create_and_transform_event(username='non-existent-username')
event_data = event_trans['event']
assert 'category_id' not in event_data
assert 'category_name' not in event_data
assert 'user_forums_roles' not in event_data
assert 'user_course_roles' not in event_data
def test_bad_url(self):
event, event_trans = _create_and_transform_event(
course_id=self.course.id,
topic_id='malformed/commentable/id',
thread_id='malformed/thread/id',
)
assert 'url' not in event_trans['event']
def test_renamed_fields(self):
AUTHOR = 'joe-the-plumber'
event, event_trans = _create_and_transform_event(
course_id=self.course.id,
topic_id=self.DUMMY_CATEGORY_ID,
thread_id=self.DUMMY_THREAD_ID,
author=AUTHOR,
)
assert event_trans['event']['commentable_id'] == self.DUMMY_CATEGORY_ID
assert event_trans['event']['id'] == self.DUMMY_THREAD_ID
assert event_trans['event']['target_username'] == AUTHOR
def test_titles(self):
# No title
_, event_1_trans = _create_and_transform_event()
assert 'title' not in event_1_trans['event']
assert 'title_truncated' not in event_1_trans['event']
# Short title
_, event_2_trans = _create_and_transform_event(
action='!',
)
assert 'title' in event_2_trans['event']
assert 'title_truncated' in event_2_trans['event']
assert not event_2_trans['event']['title_truncated']
# Long title
_, event_3_trans = _create_and_transform_event(
action=('covfefe' * 200),
)
assert 'title' in event_3_trans['event']
assert 'title_truncated' in event_3_trans['event']
assert event_3_trans['event']['title_truncated']
def test_urls(self):
commentable_id = self.DUMMY_CATEGORY_ID
thread_id = self.DUMMY_THREAD_ID
_, event_trans = _create_and_transform_event(
course_id=self.course.id,
topic_id=commentable_id,
thread_id=thread_id,
)
expected_path = '/courses/{}/discussion/forum/{}/threads/{}'.format(
self.course.id, commentable_id, thread_id
)
assert event_trans['event'].get('url').endswith(expected_path)
def test_categories(self):
# Bad category
_, event_trans_1 = _create_and_transform_event(
username=self.student.username,
course_id=self.course.id,
topic_id='non-existent-category-id',
)
assert 'category_id' not in event_trans_1['event']
assert 'category_name' not in event_trans_1['event']
# Good category
_, event_trans_2 = _create_and_transform_event(
username=self.student.username,
course_id=self.course.id,
topic_id=self.category.discussion_id,
)
assert event_trans_2['event'].get('category_id') == self.category.discussion_id
full_category_name = f'{self.category.discussion_category} / {self.category.discussion_target}'
assert event_trans_2['event'].get('category_name') == full_category_name
def test_roles(self):
# No user
_, event_trans_1 = _create_and_transform_event(
course_id=self.course.id,
)
assert 'user_forums_roles' not in event_trans_1['event']
assert 'user_course_roles' not in event_trans_1['event']
# Student user
_, event_trans_2 = _create_and_transform_event(
course_id=self.course.id,
username=self.student.username,
)
assert event_trans_2['event'].get('user_forums_roles') == [FORUM_ROLE_STUDENT]
assert event_trans_2['event'].get('user_course_roles') == []
# Course staff user
_, event_trans_3 = _create_and_transform_event(
course_id=self.course.id,
username=self.staff.username,
)
assert event_trans_3['event'].get('user_forums_roles') == []
assert event_trans_3['event'].get('user_course_roles') == [CourseStaffRole.ROLE]
def test_teams(self):
# No category
_, event_trans_1 = _create_and_transform_event(
course_id=self.course.id,
)
assert 'team_id' not in event_trans_1
# Non-team category
_, event_trans_2 = _create_and_transform_event(
course_id=self.course.id,
topic_id=self.CATEGORY_ID,
)
assert 'team_id' not in event_trans_2
# Team category
_, event_trans_3 = _create_and_transform_event(
course_id=self.course.id,
topic_id=self.TEAM_CATEGORY_ID,
)
assert event_trans_3['event'].get('team_id') == self.team.team_id

View File

@@ -1,6 +1,7 @@
# pylint: skip-file
"""Tests for django comment client views."""
import pytest
import json
import logging
from contextlib import contextmanager
@@ -2288,3 +2289,412 @@ class CreateSubCommentUnicodeTestCase(
assert create_call_params["body"] == text
finally:
del Thread.commentable_id
class UsersEndpointTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, MockForumApiMixin):
@classmethod
def setUpClass(cls): # pylint: disable=super-method-not-called
super().setUpClassAndForumMock()
with super().setUpClassAndTestData():
cls.course = CourseFactory.create()
@classmethod
def tearDownClass(cls):
"""Stop patches after tests complete."""
super().tearDownClass()
super().disposeForumMocks()
@classmethod
def setUpTestData(cls):
super().setUpTestData()
seed_permissions_roles(cls.course.id)
cls.student = UserFactory.create()
cls.enrollment = CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id)
cls.other_user = UserFactory.create(username="other")
CourseEnrollmentFactory(user=cls.other_user, course_id=cls.course.id)
def set_post_counts(self, threads_count=1, comments_count=1):
"""
sets up a mock response from the comments service for getting post counts for our other_user
"""
self.set_mock_return_value("get_user", {
"threads_count": threads_count,
"comments_count": comments_count,
})
def make_request(self, method='get', course_id=None, **kwargs):
course_id = course_id or self.course.id
request = getattr(RequestFactory(), method)("dummy_url", kwargs)
request.user = self.student
request.view_name = "users"
return views.users(request, course_id=str(course_id))
def test_finds_exact_match(self):
self.set_post_counts()
response = self.make_request(username="other")
assert response.status_code == 200
assert json.loads(response.content.decode('utf-8'))['users'] == [
{'id': self.other_user.id, 'username': self.other_user.username}
]
def test_finds_no_match(self):
self.set_post_counts()
response = self.make_request(username="othor")
assert response.status_code == 200
assert json.loads(response.content.decode('utf-8'))['users'] == []
def test_requires_GET(self):
response = self.make_request(method='post', username="other")
assert response.status_code == 405
def test_requires_username_param(self):
response = self.make_request()
assert response.status_code == 400
content = json.loads(response.content.decode('utf-8'))
assert 'errors' in content
assert 'users' not in content
def test_course_does_not_exist(self):
course_id = CourseKey.from_string("does/not/exist")
response = self.make_request(course_id=course_id, username="other")
assert response.status_code == 404
content = json.loads(response.content.decode('utf-8'))
assert 'errors' in content
assert 'users' not in content
def test_requires_requestor_enrolled_in_course(self):
# unenroll self.student from the course.
self.enrollment.delete()
response = self.make_request(username="other")
assert response.status_code == 404
content = json.loads(response.content.decode('utf-8'))
assert 'errors' in content
assert 'users' not in content
def test_requires_matched_user_has_forum_content(self):
self.set_post_counts(0, 0)
response = self.make_request(username="other")
assert response.status_code == 200
assert json.loads(response.content.decode('utf-8'))['users'] == []
@ddt.ddt
class SegmentIOForumThreadViewedEventTestCase(SegmentIOTrackingTestCaseBase):
def _raise_navigation_event(self, label, include_name):
middleware = TrackMiddleware(get_response=lambda request: None)
kwargs = {'label': label}
if include_name:
kwargs['name'] = 'edx.bi.app.navigation.screen'
else:
kwargs['exclude_name'] = True
request = self.create_request(
data=self.create_segmentio_event_json(**kwargs),
content_type='application/json',
)
User.objects.create(pk=SEGMENTIO_TEST_USER_ID, username=str(mock.sentinel.username))
middleware.process_request(request)
try:
response = segmentio.segmentio_event(request)
assert response.status_code == 200
finally:
middleware.process_response(request, None)
@ddt.data(True, False)
def test_thread_viewed(self, include_name):
"""
Tests that a SegmentIO thread viewed event is accepted and transformed.
Only tests that the transformation happens at all; does not
comprehensively test that it happens correctly.
ForumThreadViewedEventTransformerTestCase tests for correctness.
"""
self._raise_navigation_event('Forum: View Thread', include_name)
event = self.get_event()
assert event['name'] == 'edx.forum.thread.viewed'
assert event['event_type'] == event['name']
@ddt.data(True, False)
def test_non_thread_viewed(self, include_name):
"""
Tests that other BI events are thrown out.
"""
self._raise_navigation_event('Forum: Create Thread', include_name)
self.assert_no_events_emitted()
def _get_transformed_event(input_event):
transformer = ForumThreadViewedEventTransformer(**input_event)
transformer.transform()
return transformer
def _create_event(
label='Forum: View Thread',
include_context=True,
inner_context=None,
username=None,
course_id=None,
**event_data
):
result = {'name': 'edx.bi.app.navigation.screen'}
if include_context:
result['context'] = {'label': label}
if course_id:
result['context']['course_id'] = str(course_id)
if username:
result['username'] = username
if event_data:
result['event'] = event_data
if inner_context:
if not event_data:
result['event'] = {}
result['event']['context'] = inner_context
return result
def _create_and_transform_event(**kwargs):
event = _create_event(**kwargs)
return event, _get_transformed_event(event)
@ddt.ddt
class ForumThreadViewedEventTransformerTestCase(ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase):
"""
Test that the ForumThreadViewedEventTransformer transforms events correctly
and without raising exceptions.
Because the events passed through the transformer can come from external
sources (e.g., a mobile app), we carefully test a myriad of cases, including
those with incomplete and malformed events.
"""
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
CATEGORY_ID = 'i4x-edx-discussion-id'
CATEGORY_NAME = 'Discussion 1'
PARENT_CATEGORY_NAME = 'Chapter 1'
TEAM_CATEGORY_ID = 'i4x-edx-team-discussion-id'
TEAM_CATEGORY_NAME = 'Team Chat'
TEAM_PARENT_CATEGORY_NAME = PARENT_CATEGORY_NAME
DUMMY_CATEGORY_ID = 'i4x-edx-dummy-commentable-id'
DUMMY_THREAD_ID = 'dummy_thread_id'
@mock.patch.dict("common.djangoapps.student.models.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
self.course = CourseFactory.create(
org='TestX',
course='TR-101S',
run='Event_Transform_Test_Split',
default_store=ModuleStoreEnum.Type.split,
)
self.student = UserFactory.create()
self.staff = UserFactory.create(is_staff=True)
UserBasedRole(user=self.staff, role=CourseStaffRole.ROLE).add_course(self.course.id)
CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id)
self.category = BlockFactory.create(
parent_location=self.course.location,
category='discussion',
discussion_id=self.CATEGORY_ID,
discussion_category=self.PARENT_CATEGORY_NAME,
discussion_target=self.CATEGORY_NAME,
)
self.team_category = BlockFactory.create(
parent_location=self.course.location,
category='discussion',
discussion_id=self.TEAM_CATEGORY_ID,
discussion_category=self.TEAM_PARENT_CATEGORY_NAME,
discussion_target=self.TEAM_CATEGORY_NAME,
)
self.team = CourseTeamFactory.create(
name='Team 1',
course_id=self.course.id,
topic_id='arbitrary-topic-id',
discussion_topic_id=self.team_category.discussion_id,
)
def test_missing_context(self):
event = _create_event(include_context=False)
with pytest.raises(EventEmissionExit):
_get_transformed_event(event)
def test_no_data(self):
event, event_trans = _create_and_transform_event()
event['name'] = 'edx.forum.thread.viewed'
event['event_type'] = event['name']
event['event'] = {}
self.assertDictEqual(event_trans, event)
def test_inner_context(self):
_, event_trans = _create_and_transform_event(inner_context={})
assert 'context' not in event_trans['event']
def test_non_thread_view(self):
event = _create_event(
label='Forum: Create Thread',
course_id=self.course.id,
topic_id=self.DUMMY_CATEGORY_ID,
thread_id=self.DUMMY_THREAD_ID,
)
with pytest.raises(EventEmissionExit):
_get_transformed_event(event)
def test_bad_field_types(self):
event, event_trans = _create_and_transform_event(
course_id={},
topic_id=3,
thread_id=object(),
action=3.14,
)
event['name'] = 'edx.forum.thread.viewed'
event['event_type'] = event['name']
self.assertDictEqual(event_trans, event)
def test_bad_course_id(self):
event, event_trans = _create_and_transform_event(course_id='non-existent-course-id')
event_data = event_trans['event']
assert 'category_id' not in event_data
assert 'category_name' not in event_data
assert 'url' not in event_data
assert 'user_forums_roles' not in event_data
assert 'user_course_roles' not in event_data
def test_bad_username(self):
event, event_trans = _create_and_transform_event(username='non-existent-username')
event_data = event_trans['event']
assert 'category_id' not in event_data
assert 'category_name' not in event_data
assert 'user_forums_roles' not in event_data
assert 'user_course_roles' not in event_data
def test_bad_url(self):
event, event_trans = _create_and_transform_event(
course_id=self.course.id,
topic_id='malformed/commentable/id',
thread_id='malformed/thread/id',
)
assert 'url' not in event_trans['event']
def test_renamed_fields(self):
AUTHOR = 'joe-the-plumber'
event, event_trans = _create_and_transform_event(
course_id=self.course.id,
topic_id=self.DUMMY_CATEGORY_ID,
thread_id=self.DUMMY_THREAD_ID,
author=AUTHOR,
)
assert event_trans['event']['commentable_id'] == self.DUMMY_CATEGORY_ID
assert event_trans['event']['id'] == self.DUMMY_THREAD_ID
assert event_trans['event']['target_username'] == AUTHOR
def test_titles(self):
# No title
_, event_1_trans = _create_and_transform_event()
assert 'title' not in event_1_trans['event']
assert 'title_truncated' not in event_1_trans['event']
# Short title
_, event_2_trans = _create_and_transform_event(
action='!',
)
assert 'title' in event_2_trans['event']
assert 'title_truncated' in event_2_trans['event']
assert not event_2_trans['event']['title_truncated']
# Long title
_, event_3_trans = _create_and_transform_event(
action=('covfefe' * 200),
)
assert 'title' in event_3_trans['event']
assert 'title_truncated' in event_3_trans['event']
assert event_3_trans['event']['title_truncated']
def test_urls(self):
commentable_id = self.DUMMY_CATEGORY_ID
thread_id = self.DUMMY_THREAD_ID
_, event_trans = _create_and_transform_event(
course_id=self.course.id,
topic_id=commentable_id,
thread_id=thread_id,
)
expected_path = '/courses/{}/discussion/forum/{}/threads/{}'.format(
self.course.id, commentable_id, thread_id
)
assert event_trans['event'].get('url').endswith(expected_path)
def test_categories(self):
# Bad category
_, event_trans_1 = _create_and_transform_event(
username=self.student.username,
course_id=self.course.id,
topic_id='non-existent-category-id',
)
assert 'category_id' not in event_trans_1['event']
assert 'category_name' not in event_trans_1['event']
# Good category
_, event_trans_2 = _create_and_transform_event(
username=self.student.username,
course_id=self.course.id,
topic_id=self.category.discussion_id,
)
assert event_trans_2['event'].get('category_id') == self.category.discussion_id
full_category_name = f'{self.category.discussion_category} / {self.category.discussion_target}'
assert event_trans_2['event'].get('category_name') == full_category_name
def test_roles(self):
# No user
_, event_trans_1 = _create_and_transform_event(
course_id=self.course.id,
)
assert 'user_forums_roles' not in event_trans_1['event']
assert 'user_course_roles' not in event_trans_1['event']
# Student user
_, event_trans_2 = _create_and_transform_event(
course_id=self.course.id,
username=self.student.username,
)
assert event_trans_2['event'].get('user_forums_roles') == [FORUM_ROLE_STUDENT]
assert event_trans_2['event'].get('user_course_roles') == []
# Course staff user
_, event_trans_3 = _create_and_transform_event(
course_id=self.course.id,
username=self.staff.username,
)
assert event_trans_3['event'].get('user_forums_roles') == []
assert event_trans_3['event'].get('user_course_roles') == [CourseStaffRole.ROLE]
def test_teams(self):
# No category
_, event_trans_1 = _create_and_transform_event(
course_id=self.course.id,
)
assert 'team_id' not in event_trans_1
# Non-team category
_, event_trans_2 = _create_and_transform_event(
course_id=self.course.id,
topic_id=self.CATEGORY_ID,
)
assert 'team_id' not in event_trans_2
# Team category
_, event_trans_3 = _create_and_transform_event(
course_id=self.course.id,
topic_id=self.TEAM_CATEGORY_ID,
)
assert event_trans_3['event'].get('team_id') == self.team.team_id

View File

@@ -60,28 +60,27 @@ class CohortedTopicGroupIdTestMixin(GroupIdAssertionMixin):
Provides test cases to verify that views pass the correct `group_id` to
the comments service when requesting content in cohorted discussions.
"""
def call_view(self, mock_is_forum_v2_enabled, mock_request, commentable_id, user, group_id, pass_group_id=True):
def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id=True):
"""
Call the view for the implementing test class, constructing a request
from the parameters.
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
def test_cohorted_topic_student_without_group_id(self, mock_is_forum_v2_enabled, mock_request):
self.call_view(mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.student, '', pass_group_id=False)
def test_cohorted_topic_student_without_group_id(self, mock_request):
self.call_view(mock_request, "cohorted_topic", self.student, '', pass_group_id=False)
self._assert_comments_service_called_with_group_id(mock_request, self.student_cohort.id)
def test_cohorted_topic_student_none_group_id(self, mock_is_forum_v2_enabled, mock_request):
self.call_view(mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.student, "")
def test_cohorted_topic_student_none_group_id(self, mock_request):
self.call_view(mock_request, "cohorted_topic", self.student, "")
self._assert_comments_service_called_with_group_id(mock_request, self.student_cohort.id)
def test_cohorted_topic_student_with_own_group_id(self, mock_is_forum_v2_enabled, mock_request):
self.call_view(mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.student, self.student_cohort.id)
def test_cohorted_topic_student_with_own_group_id(self, mock_request):
self.call_view(mock_request, "cohorted_topic", self.student, self.student_cohort.id)
self._assert_comments_service_called_with_group_id(mock_request, self.student_cohort.id)
def test_cohorted_topic_student_with_other_group_id(self, mock_is_forum_v2_enabled, mock_request):
def test_cohorted_topic_student_with_other_group_id(self, mock_request):
self.call_view(
mock_is_forum_v2_enabled,
mock_request,
"cohorted_topic",
self.student,
@@ -89,9 +88,8 @@ class CohortedTopicGroupIdTestMixin(GroupIdAssertionMixin):
)
self._assert_comments_service_called_with_group_id(mock_request, self.student_cohort.id)
def test_cohorted_topic_moderator_without_group_id(self, mock_is_forum_v2_enabled, mock_request):
def test_cohorted_topic_moderator_without_group_id(self, mock_request):
self.call_view(
mock_is_forum_v2_enabled,
mock_request,
"cohorted_topic",
self.moderator,
@@ -100,13 +98,12 @@ class CohortedTopicGroupIdTestMixin(GroupIdAssertionMixin):
)
self._assert_comments_service_called_without_group_id(mock_request)
def test_cohorted_topic_moderator_none_group_id(self, mock_is_forum_v2_enabled, mock_request):
self.call_view(mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.moderator, "")
def test_cohorted_topic_moderator_none_group_id(self, mock_request):
self.call_view(mock_request, "cohorted_topic", self.moderator, "")
self._assert_comments_service_called_without_group_id(mock_request)
def test_cohorted_topic_moderator_with_own_group_id(self, mock_is_forum_v2_enabled, mock_request):
def test_cohorted_topic_moderator_with_own_group_id(self, mock_request):
self.call_view(
mock_is_forum_v2_enabled,
mock_request,
"cohorted_topic",
self.moderator,
@@ -114,9 +111,8 @@ class CohortedTopicGroupIdTestMixin(GroupIdAssertionMixin):
)
self._assert_comments_service_called_with_group_id(mock_request, self.moderator_cohort.id)
def test_cohorted_topic_moderator_with_other_group_id(self, mock_is_forum_v2_enabled, mock_request):
def test_cohorted_topic_moderator_with_other_group_id(self, mock_request):
self.call_view(
mock_is_forum_v2_enabled,
mock_request,
"cohorted_topic",
self.moderator,
@@ -124,12 +120,12 @@ class CohortedTopicGroupIdTestMixin(GroupIdAssertionMixin):
)
self._assert_comments_service_called_with_group_id(mock_request, self.student_cohort.id)
def test_cohorted_topic_moderator_with_invalid_group_id(self, mock_is_forum_v2_enabled, mock_request):
def test_cohorted_topic_moderator_with_invalid_group_id(self, mock_request):
invalid_id = self.student_cohort.id + self.moderator_cohort.id
response = self.call_view(mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.moderator, invalid_id) # lint-amnesty, pylint: disable=assignment-from-no-return
response = self.call_view(mock_request, "cohorted_topic", self.moderator, invalid_id) # lint-amnesty, pylint: disable=assignment-from-no-return
assert response.status_code == 500
def test_cohorted_topic_enrollment_track_invalid_group_id(self, mock_is_forum_v2_enabled, mock_request):
def test_cohorted_topic_enrollment_track_invalid_group_id(self, mock_request):
CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.AUDIT)
CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.VERIFIED)
discussion_settings = CourseDiscussionSettings.get(self.course.id)
@@ -140,7 +136,7 @@ class CohortedTopicGroupIdTestMixin(GroupIdAssertionMixin):
})
invalid_id = -1000
response = self.call_view(mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.moderator, invalid_id) # lint-amnesty, pylint: disable=assignment-from-no-return
response = self.call_view(mock_request, "cohorted_topic", self.moderator, invalid_id) # lint-amnesty, pylint: disable=assignment-from-no-return
assert response.status_code == 500
@@ -149,16 +145,15 @@ class NonCohortedTopicGroupIdTestMixin(GroupIdAssertionMixin):
Provides test cases to verify that views pass the correct `group_id` to
the comments service when requesting content in non-cohorted discussions.
"""
def call_view(self, mock_is_forum_v2_enabled, mock_request, commentable_id, user, group_id, pass_group_id=True):
def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id=True):
"""
Call the view for the implementing test class, constructing a request
from the parameters.
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
def test_non_cohorted_topic_student_without_group_id(self, mock_is_forum_v2_enabled, mock_request):
def test_non_cohorted_topic_student_without_group_id(self, mock_request):
self.call_view(
mock_is_forum_v2_enabled,
mock_request,
"non_cohorted_topic",
self.student,
@@ -167,13 +162,12 @@ class NonCohortedTopicGroupIdTestMixin(GroupIdAssertionMixin):
)
self._assert_comments_service_called_without_group_id(mock_request)
def test_non_cohorted_topic_student_none_group_id(self, mock_is_forum_v2_enabled, mock_request):
self.call_view(mock_is_forum_v2_enabled, mock_request, "non_cohorted_topic", self.student, '')
def test_non_cohorted_topic_student_none_group_id(self, mock_request):
self.call_view(mock_request, "non_cohorted_topic", self.student, '')
self._assert_comments_service_called_without_group_id(mock_request)
def test_non_cohorted_topic_student_with_own_group_id(self, mock_is_forum_v2_enabled, mock_request):
def test_non_cohorted_topic_student_with_own_group_id(self, mock_request):
self.call_view(
mock_is_forum_v2_enabled,
mock_request,
"non_cohorted_topic",
self.student,
@@ -181,9 +175,8 @@ class NonCohortedTopicGroupIdTestMixin(GroupIdAssertionMixin):
)
self._assert_comments_service_called_without_group_id(mock_request)
def test_non_cohorted_topic_student_with_other_group_id(self, mock_is_forum_v2_enabled, mock_request):
def test_non_cohorted_topic_student_with_other_group_id(self, mock_request):
self.call_view(
mock_is_forum_v2_enabled,
mock_request,
"non_cohorted_topic",
self.student,
@@ -191,9 +184,8 @@ class NonCohortedTopicGroupIdTestMixin(GroupIdAssertionMixin):
)
self._assert_comments_service_called_without_group_id(mock_request)
def test_non_cohorted_topic_moderator_without_group_id(self, mock_is_forum_v2_enabled, mock_request):
def test_non_cohorted_topic_moderator_without_group_id(self, mock_request):
self.call_view(
mock_is_forum_v2_enabled,
mock_request,
"non_cohorted_topic",
self.moderator,
@@ -202,13 +194,12 @@ class NonCohortedTopicGroupIdTestMixin(GroupIdAssertionMixin):
)
self._assert_comments_service_called_without_group_id(mock_request)
def test_non_cohorted_topic_moderator_none_group_id(self, mock_is_forum_v2_enabled, mock_request):
self.call_view(mock_is_forum_v2_enabled, mock_request, "non_cohorted_topic", self.moderator, '')
def test_non_cohorted_topic_moderator_none_group_id(self, mock_request):
self.call_view(mock_request, "non_cohorted_topic", self.moderator, '')
self._assert_comments_service_called_without_group_id(mock_request)
def test_non_cohorted_topic_moderator_with_own_group_id(self, mock_is_forum_v2_enabled, mock_request):
def test_non_cohorted_topic_moderator_with_own_group_id(self, mock_request):
self.call_view(
mock_is_forum_v2_enabled,
mock_request,
"non_cohorted_topic",
self.moderator,
@@ -216,9 +207,8 @@ class NonCohortedTopicGroupIdTestMixin(GroupIdAssertionMixin):
)
self._assert_comments_service_called_without_group_id(mock_request)
def test_non_cohorted_topic_moderator_with_other_group_id(self, mock_is_forum_v2_enabled, mock_request):
def test_non_cohorted_topic_moderator_with_other_group_id(self, mock_request):
self.call_view(
mock_is_forum_v2_enabled,
mock_request,
"non_cohorted_topic",
self.moderator,
@@ -226,19 +216,19 @@ class NonCohortedTopicGroupIdTestMixin(GroupIdAssertionMixin):
)
self._assert_comments_service_called_without_group_id(mock_request)
def test_non_cohorted_topic_moderator_with_invalid_group_id(self, mock_is_forum_v2_enabled, mock_request):
def test_non_cohorted_topic_moderator_with_invalid_group_id(self, mock_request):
invalid_id = self.student_cohort.id + self.moderator_cohort.id
self.call_view(mock_is_forum_v2_enabled, mock_request, "non_cohorted_topic", self.moderator, invalid_id)
self.call_view(mock_request, "non_cohorted_topic", self.moderator, invalid_id)
self._assert_comments_service_called_without_group_id(mock_request)
def test_team_discussion_id_not_cohorted(self, mock_is_forum_v2_enabled, mock_request):
def test_team_discussion_id_not_cohorted(self, mock_request):
team = CourseTeamFactory(
course_id=self.course.id,
topic_id='topic-id'
)
team.add_user(self.student)
self.call_view(mock_is_forum_v2_enabled, mock_request, team.discussion_topic_id, self.student, '')
self.call_view(mock_request, team.discussion_topic_id, self.student, '')
self._assert_comments_service_called_without_group_id(mock_request)

View File

@@ -17,13 +17,6 @@ class MockForumApiMixin:
"""
cls.mock_forum_api = mock.Mock()
# TODO: Remove this after moving all APIs
cls.flag_v2_patcher = mock.patch(
"openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled"
)
cls.mock_enable_forum_v2 = cls.flag_v2_patcher.start()
cls.mock_enable_forum_v2.return_value = True
patch_targets = [
"openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api",
"openedx.core.djangoapps.django_comment_common.comment_client.comment.forum_api",
@@ -41,8 +34,6 @@ class MockForumApiMixin:
@classmethod
def disposeForumMocks(cls):
"""Stop patches after tests complete."""
cls.flag_v2_patcher.stop()
for patcher in cls.forum_api_patchers:
patcher.stop()

View File

@@ -2,661 +2,52 @@
Tests for Discussion API internal interface
"""
from datetime import datetime, timedelta
from unittest import mock
from urllib.parse import urlencode, urlunparse
import ddt
import httpretty
import pytest
from django.test import override_settings
from django.contrib.auth import get_user_model
from django.test.client import RequestFactory
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import CourseLocator
from pytz import UTC
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory
from xmodule.partitions.partitions import Group, UserPartition
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from common.djangoapps.student.tests.factories import (
BetaTesterFactory,
CourseEnrollmentFactory,
StaffFactory,
UserFactory
)
from common.djangoapps.util.testing import UrlResetMixin
from lms.djangoapps.discussion.django_comment_client.tests.utils import ForumsEnableMixin
from lms.djangoapps.discussion.rest_api.api import (
get_course,
get_course_topics,
get_user_comments,
)
from lms.djangoapps.discussion.rest_api.exceptions import (
DiscussionDisabledError,
)
from lms.djangoapps.discussion.rest_api.api import get_user_comments
from lms.djangoapps.discussion.rest_api.tests.utils import (
CommentsServiceMockMixin,
ForumMockUtilsMixin,
make_minimal_cs_comment,
)
from openedx.core.djangoapps.course_groups.models import CourseUserGroupPartitionGroup
from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
from openedx.core.djangoapps.django_comment_common.models import (
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_STUDENT,
Role
)
from openedx.core.lib.exceptions import CourseNotFoundError, PageNotFoundError
User = get_user_model()
def _remove_discussion_tab(course, user_id):
"""
Remove the discussion tab for the course.
user_id is passed to the modulestore as the editor of the xblock.
"""
course.tabs = [tab for tab in course.tabs if not tab.type == 'discussion']
modulestore().update_item(course, user_id)
def _discussion_disabled_course_for(user):
"""
Create and return a course with discussions disabled.
The user passed in will be enrolled in the course.
"""
course_with_disabled_forums = CourseFactory.create()
CourseEnrollmentFactory.create(user=user, course_id=course_with_disabled_forums.id)
_remove_discussion_tab(course_with_disabled_forums, user.id)
return course_with_disabled_forums
def _assign_role_to_user(user, course_id, role):
"""
Unset the blackout period for course discussions.
Arguments:
user: User to assign role to
course_id: Course id of the course user will be assigned role in
role: Role assigned to user for course
"""
role = Role.objects.create(name=role, course_id=course_id)
role.users.set([user])
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
@override_settings(DISCUSSION_MODERATION_EDIT_REASON_CODES={"test-edit-reason": "Test Edit Reason"})
@override_settings(DISCUSSION_MODERATION_CLOSE_REASON_CODES={"test-close-reason": "Test Close Reason"})
@ddt.ddt
class GetCourseTest(ForumsEnableMixin, UrlResetMixin, SharedModuleStoreTestCase):
"""Test for get_course"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.course = CourseFactory.create(org="x", course="y", run="z")
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
self.user = UserFactory.create()
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
self.request = RequestFactory().get("/dummy")
self.request.user = self.user
def test_nonexistent_course(self):
with pytest.raises(CourseNotFoundError):
get_course(self.request, CourseLocator.from_string("course-v1:non+existent+course"))
def test_not_enrolled(self):
unenrolled_user = UserFactory.create()
self.request.user = unenrolled_user
with pytest.raises(CourseNotFoundError):
get_course(self.request, self.course.id)
def test_discussions_disabled(self):
with pytest.raises(DiscussionDisabledError):
get_course(self.request, _discussion_disabled_course_for(self.user).id)
def test_discussions_disabled_v2(self):
data = get_course(self.request, _discussion_disabled_course_for(self.user).id, False)
assert data['show_discussions'] is False
def test_basic(self):
assert get_course(self.request, self.course.id) == {
'id': str(self.course.id),
'is_posting_enabled': True,
'blackouts': [],
'thread_list_url': 'http://testserver/api/discussion/v1/threads/?course_id=course-v1%3Ax%2By%2Bz',
'following_thread_list_url':
'http://testserver/api/discussion/v1/threads/?course_id=course-v1%3Ax%2By%2Bz&following=True',
'topics_url': 'http://testserver/api/discussion/v1/course_topics/course-v1:x+y+z',
'allow_anonymous': True,
'allow_anonymous_to_peers': False,
'enable_in_context': True,
'group_at_subsection': False,
'provider': 'legacy',
"has_bulk_delete_privileges": False,
'has_moderation_privileges': False,
"is_course_staff": False,
"is_course_admin": False,
'is_group_ta': False,
'is_user_admin': False,
'user_roles': {'Student'},
'edit_reasons': [{'code': 'test-edit-reason', 'label': 'Test Edit Reason'}],
'post_close_reasons': [{'code': 'test-close-reason', 'label': 'Test Close Reason'}],
'show_discussions': True,
'is_notify_all_learners_enabled': False,
'captcha_settings': {
'enabled': False,
'site_key': None,
},
"is_email_verified": True,
"only_verified_users_can_post": False,
"content_creation_rate_limited": False
}
@ddt.data(
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_COMMUNITY_TA,
)
def test_privileged_roles(self, role):
"""
Test that the api returns the correct roles and privileges.
"""
_assign_role_to_user(user=self.user, course_id=self.course.id, role=role)
course_meta = get_course(self.request, self.course.id)
assert course_meta["has_moderation_privileges"]
assert course_meta["user_roles"] == {FORUM_ROLE_STUDENT} | {role}
@ddt.ddt
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class GetCourseTestBlackouts(ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase):
"""
Tests of get_course for courses that have blackout dates.
"""
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
self.course = CourseFactory.create(org="x", course="y", run="z")
self.user = UserFactory.create()
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
self.request = RequestFactory().get("/dummy")
self.request.user = self.user
def test_blackout(self):
# A variety of formats is accepted
self.course.discussion_blackouts = [
["2015-06-09T00:00:00Z", "6-10-15"],
[1433980800000, datetime(2015, 6, 12, tzinfo=UTC)],
]
self.update_course(self.course, self.user.id)
result = get_course(self.request, self.course.id)
assert result['blackouts'] == [
{'start': '2015-06-09T00:00:00Z', 'end': '2015-06-10T00:00:00Z'},
{'start': '2015-06-11T00:00:00Z', 'end': '2015-06-12T00:00:00Z'}
]
@ddt.data(None, "not a datetime", "2015", [])
def test_blackout_errors(self, bad_value):
self.course.discussion_blackouts = [
[bad_value, "2015-06-09T00:00:00Z"],
["2015-06-10T00:00:00Z", "2015-06-11T00:00:00Z"],
]
modulestore().update_item(self.course, self.user.id)
result = get_course(self.request, self.course.id)
assert result['blackouts'] == []
@mock.patch.dict("django.conf.settings.FEATURES", {"DISABLE_START_DATES": False})
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class GetCourseTopicsTest(CommentsServiceMockMixin, ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase):
"""Test for get_course_topics"""
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
httpretty.reset()
httpretty.enable()
self.addCleanup(httpretty.reset)
self.addCleanup(httpretty.disable)
super().setUp()
self.maxDiff = None # pylint: disable=invalid-name
self.partition = UserPartition(
0,
"partition",
"Test Partition",
[Group(0, "Cohort A"), Group(1, "Cohort B")],
scheme_id="cohort"
)
self.course = CourseFactory.create(
org="x",
course="y",
run="z",
start=datetime.now(UTC),
discussion_topics={"Test Topic": {"id": "non-courseware-topic-id"}},
user_partitions=[self.partition],
cohort_config={"cohorted": True},
days_early_for_beta=3
)
self.user = UserFactory.create()
self.request = RequestFactory().get("/dummy")
self.request.user = self.user
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
self.thread_counts_map = {
"courseware-1": {"discussion": 2, "question": 3},
"courseware-2": {"discussion": 4, "question": 5},
"courseware-3": {"discussion": 7, "question": 2},
}
self.register_get_course_commentable_counts_response(self.course.id, self.thread_counts_map)
def make_discussion_xblock(self, topic_id, category, subcategory, **kwargs):
"""
Build a discussion xblock in self.course.
"""
BlockFactory.create(
parent_location=self.course.location,
category="discussion",
discussion_id=topic_id,
discussion_category=category,
discussion_target=subcategory,
**kwargs
)
def get_thread_list_url(self, topic_id_list):
"""
Returns the URL for the thread_list_url field, given a list of topic_ids
"""
path = "http://testserver/api/discussion/v1/threads/"
topic_ids_to_query = [("topic_id", topic_id) for topic_id in topic_id_list]
query_list = [("course_id", str(self.course.id))] + topic_ids_to_query
return urlunparse(("", "", path, "", urlencode(query_list), ""))
def get_course_topics(self):
"""
Get course topics for self.course, using the given user or self.user if
not provided, and generating absolute URIs with a test scheme/host.
"""
return get_course_topics(self.request, self.course.id)
def make_expected_tree(self, topic_id, name, children=None):
"""
Build an expected result tree given a topic id, display name, and
children
"""
topic_id_list = [topic_id] if topic_id else [child["id"] for child in children]
children = children or []
thread_counts = self.thread_counts_map.get(topic_id, {"discussion": 0, "question": 0})
node = {
"id": topic_id,
"name": name,
"children": children,
"thread_list_url": self.get_thread_list_url(topic_id_list),
"thread_counts": thread_counts if not children else None
}
return node
def test_nonexistent_course(self):
with pytest.raises(CourseNotFoundError):
get_course_topics(self.request, CourseLocator.from_string("course-v1:non+existent+course"))
def test_not_enrolled(self):
unenrolled_user = UserFactory.create()
self.request.user = unenrolled_user
with pytest.raises(CourseNotFoundError):
self.get_course_topics()
def test_discussions_disabled(self):
_remove_discussion_tab(self.course, self.user.id)
with pytest.raises(DiscussionDisabledError):
self.get_course_topics()
def test_without_courseware(self):
actual = self.get_course_topics()
expected = {
"courseware_topics": [],
"non_courseware_topics": [
self.make_expected_tree("non-courseware-topic-id", "Test Topic")
],
}
assert actual == expected
def test_with_courseware(self):
self.make_discussion_xblock("courseware-topic-id", "Foo", "Bar")
actual = self.get_course_topics()
expected = {
"courseware_topics": [
self.make_expected_tree(
None,
"Foo",
[self.make_expected_tree("courseware-topic-id", "Bar")]
),
],
"non_courseware_topics": [
self.make_expected_tree("non-courseware-topic-id", "Test Topic")
],
}
assert actual == expected
def test_many(self):
with self.store.bulk_operations(self.course.id, emit_signals=False):
self.course.discussion_topics = {
"A": {"id": "non-courseware-1"},
"B": {"id": "non-courseware-2"},
}
self.store.update_item(self.course, self.user.id)
self.make_discussion_xblock("courseware-1", "Week 1", "1")
self.make_discussion_xblock("courseware-2", "Week 1", "2")
self.make_discussion_xblock("courseware-3", "Week 10", "1")
self.make_discussion_xblock("courseware-4", "Week 10", "2")
self.make_discussion_xblock("courseware-5", "Week 9", "1")
actual = self.get_course_topics()
expected = {
"courseware_topics": [
self.make_expected_tree(
None,
"Week 1",
[
self.make_expected_tree("courseware-1", "1"),
self.make_expected_tree("courseware-2", "2"),
]
),
self.make_expected_tree(
None,
"Week 9",
[self.make_expected_tree("courseware-5", "1")]
),
self.make_expected_tree(
None,
"Week 10",
[
self.make_expected_tree("courseware-3", "1"),
self.make_expected_tree("courseware-4", "2"),
]
),
],
"non_courseware_topics": [
self.make_expected_tree("non-courseware-1", "A"),
self.make_expected_tree("non-courseware-2", "B"),
],
}
assert actual == expected
def test_sort_key_doesnot_work(self):
"""
Test to check that providing sort_key doesn't change the sort order
"""
with self.store.bulk_operations(self.course.id, emit_signals=False):
self.course.discussion_topics = {
"W": {"id": "non-courseware-1", "sort_key": "Z"},
"X": {"id": "non-courseware-2"},
"Y": {"id": "non-courseware-3", "sort_key": "Y"},
"Z": {"id": "non-courseware-4", "sort_key": "W"},
}
self.store.update_item(self.course, self.user.id)
self.make_discussion_xblock("courseware-1", "First", "A", sort_key="B")
self.make_discussion_xblock("courseware-2", "First", "B", sort_key="D")
self.make_discussion_xblock("courseware-3", "First", "C", sort_key="E")
self.make_discussion_xblock("courseware-4", "Second", "A", sort_key="A")
self.make_discussion_xblock("courseware-5", "Second", "B", sort_key="B")
self.make_discussion_xblock("courseware-6", "Second", "C")
self.make_discussion_xblock("courseware-7", "Second", "D", sort_key="D")
actual = self.get_course_topics()
expected = {
"courseware_topics": [
self.make_expected_tree(
None,
"First",
[
self.make_expected_tree("courseware-1", "A"),
self.make_expected_tree("courseware-2", "B"),
self.make_expected_tree("courseware-3", "C"),
]
),
self.make_expected_tree(
None,
"Second",
[
self.make_expected_tree("courseware-4", "A"),
self.make_expected_tree("courseware-5", "B"),
self.make_expected_tree("courseware-6", "C"),
self.make_expected_tree("courseware-7", "D"),
]
),
],
"non_courseware_topics": [
self.make_expected_tree("non-courseware-1", "W"),
self.make_expected_tree("non-courseware-2", "X"),
self.make_expected_tree("non-courseware-3", "Y"),
self.make_expected_tree("non-courseware-4", "Z"),
],
}
assert actual == expected
def test_access_control(self):
"""
Test that only topics that a user has access to are returned. The
ways in which a user may not have access are:
* Block is visible to staff only
* Block is accessible only to a group the user is not in
Also, there is a case that ensures that a category with no accessible
subcategories does not appear in the result.
"""
beta_tester = BetaTesterFactory.create(course_key=self.course.id)
CourseEnrollmentFactory.create(user=beta_tester, course_id=self.course.id)
staff = StaffFactory.create(course_key=self.course.id)
for user, group_idx in [(self.user, 0), (beta_tester, 1)]:
cohort = CohortFactory.create(
course_id=self.course.id,
name=self.partition.groups[group_idx].name,
users=[user]
)
CourseUserGroupPartitionGroup.objects.create(
course_user_group=cohort,
partition_id=self.partition.id,
group_id=self.partition.groups[group_idx].id
)
with self.store.bulk_operations(self.course.id, emit_signals=False):
self.store.update_item(self.course, self.user.id)
self.make_discussion_xblock(
"courseware-2",
"First",
"Cohort A",
group_access={self.partition.id: [self.partition.groups[0].id]}
)
self.make_discussion_xblock(
"courseware-3",
"First",
"Cohort B",
group_access={self.partition.id: [self.partition.groups[1].id]}
)
self.make_discussion_xblock("courseware-1", "First", "Everybody")
self.make_discussion_xblock(
"courseware-5",
"Second",
"Future Start Date",
start=datetime.now(UTC) + timedelta(days=1)
)
self.make_discussion_xblock("courseware-4", "Second", "Staff Only", visible_to_staff_only=True)
student_actual = self.get_course_topics()
student_expected = {
"courseware_topics": [
self.make_expected_tree(
None,
"First",
[
self.make_expected_tree("courseware-2", "Cohort A"),
self.make_expected_tree("courseware-1", "Everybody"),
]
),
],
"non_courseware_topics": [
self.make_expected_tree("non-courseware-topic-id", "Test Topic"),
],
}
assert student_actual == student_expected
self.request.user = beta_tester
beta_actual = self.get_course_topics()
beta_expected = {
"courseware_topics": [
self.make_expected_tree(
None,
"First",
[
self.make_expected_tree("courseware-3", "Cohort B"),
self.make_expected_tree("courseware-1", "Everybody"),
]
)
],
"non_courseware_topics": [
self.make_expected_tree("non-courseware-topic-id", "Test Topic"),
],
}
assert beta_actual == beta_expected
self.request.user = staff
staff_actual = self.get_course_topics()
staff_expected = {
"courseware_topics": [
self.make_expected_tree(
None,
"First",
[
self.make_expected_tree("courseware-2", "Cohort A"),
self.make_expected_tree("courseware-3", "Cohort B"),
self.make_expected_tree("courseware-1", "Everybody"),
]
),
self.make_expected_tree(
None,
"Second",
[
self.make_expected_tree("courseware-4", "Staff Only"),
]
),
],
"non_courseware_topics": [
self.make_expected_tree("non-courseware-topic-id", "Test Topic"),
],
}
assert staff_actual == staff_expected
def test_un_released_discussion_topic(self):
"""
Test discussion topics that have not yet started
"""
staff = StaffFactory.create(course_key=self.course.id)
with self.store.bulk_operations(self.course.id, emit_signals=False):
self.store.update_item(self.course, self.user.id)
self.make_discussion_xblock(
"courseware-2",
"First",
"Released",
start=datetime.now(UTC) - timedelta(days=1)
)
self.make_discussion_xblock(
"courseware-3",
"First",
"Future release",
start=datetime.now(UTC) + timedelta(days=1)
)
self.request.user = staff
staff_actual = self.get_course_topics()
staff_expected = {
"courseware_topics": [
self.make_expected_tree(
None,
"First",
[
self.make_expected_tree("courseware-2", "Released"),
]
),
],
"non_courseware_topics": [
self.make_expected_tree("non-courseware-topic-id", "Test Topic"),
],
}
assert staff_actual == staff_expected
def test_discussion_topic(self):
"""
Tests discussion topic details against a requested topic id
"""
topic_id_1 = "topic_id_1"
topic_id_2 = "topic_id_2"
self.make_discussion_xblock(topic_id_1, "test_category_1", "test_target_1")
self.make_discussion_xblock(topic_id_2, "test_category_2", "test_target_2")
actual = get_course_topics(self.request, self.course.id, {"topic_id_1", "topic_id_2"})
assert actual == {
'non_courseware_topics': [],
'courseware_topics': [
{
'children': [
{
'children': [],
'id': 'topic_id_1',
'thread_list_url': 'http://testserver/api/discussion/v1/threads/'
'?course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1',
'name': 'test_target_1',
'thread_counts': {'discussion': 0, 'question': 0},
},
],
'id': None,
'thread_list_url': 'http://testserver/api/discussion/v1/threads/'
'?course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1',
'name': 'test_category_1',
'thread_counts': None,
},
{
'children': [
{
'children': [],
'id': 'topic_id_2',
'thread_list_url': 'http://testserver/api/discussion/v1/threads/'
'?course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2',
'name': 'test_target_2',
'thread_counts': {'discussion': 0, 'question': 0},
}
],
'id': None,
'thread_list_url': 'http://testserver/api/discussion/v1/threads/'
'?course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2',
'name': 'test_category_2',
'thread_counts': None,
}
]
}
@ddt.ddt
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class GetUserCommentsTest(ForumsEnableMixin, CommentsServiceMockMixin, SharedModuleStoreTestCase):
class GetUserCommentsTest(ForumsEnableMixin, ForumMockUtilsMixin, SharedModuleStoreTestCase):
"""
Tests for get_user_comments.
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
super().setUpClassAndForumMock()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
super().disposeForumMocks()
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
httpretty.reset()
httpretty.enable()
self.addCleanup(httpretty.reset)
self.addCleanup(httpretty.disable)
self.course = CourseFactory.create()
# create staff user so that we don't need to worry about
@@ -712,10 +103,10 @@ class GetUserCommentsTest(ForumsEnableMixin, CommentsServiceMockMixin, SharedMod
if page in (1, 2):
assert response.data["pagination"]["next"] is not None
assert f"page={page + 1}" in response.data["pagination"]["next"]
assert f"page={page+1}" in response.data["pagination"]["next"]
if page in (2, 3):
assert response.data["pagination"]["previous"] is not None
assert f"page={page - 1}" in response.data["pagination"]["previous"]
assert f"page={page-1}" in response.data["pagination"]["previous"]
if page == 1:
assert response.data["pagination"]["previous"] is None
if page == 3:

View File

@@ -1,9 +1,10 @@
# pylint: skip-file
# pylint: disable=unused-import
"""
Tests for the internal interface of the Discussion API (rest_api/api.py).
This module directly tests the internal API functions of the Discussion API, such as create_thread,
create_comment, update_thread, update_comment, and related helpers, by invoking them with various data and request objects.
create_comment, update_thread, update_comment, and related helpers, by invoking them with various data and
request objects.
"""
import itertools
@@ -1256,7 +1257,7 @@ class UpdateThreadTest(
"thread_id": "test_thread",
"action": "unflag",
"user_id": "1",
"update_all": True if remove_all else False,
"update_all": bool(remove_all),
"course_id": str(self.course.id),
}
@@ -1937,7 +1938,7 @@ class UpdateCommentTest(
"comment_id": "test_comment",
"action": "unflag",
"user_id": "1",
"update_all": True if remove_all else False,
"update_all": bool(remove_all),
"course_id": str(self.course.id),
}
self.check_mock_called_with("update_comment_flag", -1, **params)
@@ -3315,7 +3316,7 @@ class GetThreadListTest(
)
def test_following(self):
self.register_subscribed_threads_response(self.user, [], page=1, num_pages=0)
self.register_subscribed_threads_response(self.user, [], page=1, num_pages=1)
result = get_thread_list(
self.request,
self.course.id,
@@ -3325,7 +3326,7 @@ class GetThreadListTest(
).data
expected_result = make_paginated_api_response(
results=[], count=0, num_pages=0, next_link=None, previous_link=None
results=[], count=0, num_pages=1, next_link=None, previous_link=None
)
expected_result.update({"text_search_rewrite": None})
assert result == expected_result
@@ -4230,3 +4231,561 @@ class CourseTopicsV2Test(ModuleStoreTestCase):
user=self.user,
order_by=TopicOrdering.ACTIVITY,
)
@mock.patch.dict("django.conf.settings.FEATURES", {"DISABLE_START_DATES": False})
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class GetCourseTopicsTest(ForumMockUtilsMixin, ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase):
"""Test for get_course_topics"""
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
httpretty.reset()
httpretty.enable()
self.addCleanup(httpretty.reset)
self.addCleanup(httpretty.disable)
super().setUp()
self.maxDiff = None # pylint: disable=invalid-name
self.partition = UserPartition(
0,
"partition",
"Test Partition",
[Group(0, "Cohort A"), Group(1, "Cohort B")],
scheme_id="cohort"
)
self.course = CourseFactory.create(
org="x",
course="y",
run="z",
start=datetime.now(UTC),
discussion_topics={"Test Topic": {"id": "non-courseware-topic-id"}},
user_partitions=[self.partition],
cohort_config={"cohorted": True},
days_early_for_beta=3
)
self.user = UserFactory.create()
self.request = RequestFactory().get("/dummy")
self.request.user = self.user
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
self.thread_counts_map = {
"courseware-1": {"discussion": 2, "question": 3},
"courseware-2": {"discussion": 4, "question": 5},
"courseware-3": {"discussion": 7, "question": 2},
}
self.register_get_course_commentable_counts_response(self.course.id, self.thread_counts_map)
@classmethod
def setUpClass(cls):
super().setUpClass()
super().setUpClassAndForumMock()
@classmethod
def tearDownClass(cls):
"""Stop patches after tests complete."""
super().tearDownClass()
super().disposeForumMocks()
def make_discussion_xblock(self, topic_id, category, subcategory, **kwargs):
"""
Build a discussion xblock in self.course.
"""
BlockFactory.create(
parent_location=self.course.location,
category="discussion",
discussion_id=topic_id,
discussion_category=category,
discussion_target=subcategory,
**kwargs
)
def get_thread_list_url(self, topic_id_list):
"""
Returns the URL for the thread_list_url field, given a list of topic_ids
"""
path = "http://testserver/api/discussion/v1/threads/"
topic_ids_to_query = [("topic_id", topic_id) for topic_id in topic_id_list]
query_list = [("course_id", str(self.course.id))] + topic_ids_to_query
return urlunparse(("", "", path, "", urlencode(query_list), ""))
def get_course_topics(self):
"""
Get course topics for self.course, using the given user or self.user if
not provided, and generating absolute URIs with a test scheme/host.
"""
return get_course_topics(self.request, self.course.id)
def make_expected_tree(self, topic_id, name, children=None):
"""
Build an expected result tree given a topic id, display name, and
children
"""
topic_id_list = [topic_id] if topic_id else [child["id"] for child in children]
children = children or []
thread_counts = self.thread_counts_map.get(topic_id, {"discussion": 0, "question": 0})
node = {
"id": topic_id,
"name": name,
"children": children,
"thread_list_url": self.get_thread_list_url(topic_id_list),
"thread_counts": thread_counts if not children else None
}
return node
def test_nonexistent_course(self):
with pytest.raises(CourseNotFoundError):
get_course_topics(self.request, CourseLocator.from_string("course-v1:non+existent+course"))
def test_not_enrolled(self):
unenrolled_user = UserFactory.create()
self.request.user = unenrolled_user
with pytest.raises(CourseNotFoundError):
self.get_course_topics()
def test_discussions_disabled(self):
_remove_discussion_tab(self.course, self.user.id)
with pytest.raises(DiscussionDisabledError):
self.get_course_topics()
def test_without_courseware(self):
actual = self.get_course_topics()
expected = {
"courseware_topics": [],
"non_courseware_topics": [
self.make_expected_tree("non-courseware-topic-id", "Test Topic")
],
}
assert actual == expected
def test_with_courseware(self):
self.make_discussion_xblock("courseware-topic-id", "Foo", "Bar")
actual = self.get_course_topics()
expected = {
"courseware_topics": [
self.make_expected_tree(
None,
"Foo",
[self.make_expected_tree("courseware-topic-id", "Bar")]
),
],
"non_courseware_topics": [
self.make_expected_tree("non-courseware-topic-id", "Test Topic")
],
}
assert actual == expected
def test_many(self):
with self.store.bulk_operations(self.course.id, emit_signals=False):
self.course.discussion_topics = {
"A": {"id": "non-courseware-1"},
"B": {"id": "non-courseware-2"},
}
self.store.update_item(self.course, self.user.id)
self.make_discussion_xblock("courseware-1", "Week 1", "1")
self.make_discussion_xblock("courseware-2", "Week 1", "2")
self.make_discussion_xblock("courseware-3", "Week 10", "1")
self.make_discussion_xblock("courseware-4", "Week 10", "2")
self.make_discussion_xblock("courseware-5", "Week 9", "1")
actual = self.get_course_topics()
expected = {
"courseware_topics": [
self.make_expected_tree(
None,
"Week 1",
[
self.make_expected_tree("courseware-1", "1"),
self.make_expected_tree("courseware-2", "2"),
]
),
self.make_expected_tree(
None,
"Week 9",
[self.make_expected_tree("courseware-5", "1")]
),
self.make_expected_tree(
None,
"Week 10",
[
self.make_expected_tree("courseware-3", "1"),
self.make_expected_tree("courseware-4", "2"),
]
),
],
"non_courseware_topics": [
self.make_expected_tree("non-courseware-1", "A"),
self.make_expected_tree("non-courseware-2", "B"),
],
}
assert actual == expected
def test_sort_key_doesnot_work(self):
"""
Test to check that providing sort_key doesn't change the sort order
"""
with self.store.bulk_operations(self.course.id, emit_signals=False):
self.course.discussion_topics = {
"W": {"id": "non-courseware-1", "sort_key": "Z"},
"X": {"id": "non-courseware-2"},
"Y": {"id": "non-courseware-3", "sort_key": "Y"},
"Z": {"id": "non-courseware-4", "sort_key": "W"},
}
self.store.update_item(self.course, self.user.id)
self.make_discussion_xblock("courseware-1", "First", "A", sort_key="B")
self.make_discussion_xblock("courseware-2", "First", "B", sort_key="D")
self.make_discussion_xblock("courseware-3", "First", "C", sort_key="E")
self.make_discussion_xblock("courseware-4", "Second", "A", sort_key="A")
self.make_discussion_xblock("courseware-5", "Second", "B", sort_key="B")
self.make_discussion_xblock("courseware-6", "Second", "C")
self.make_discussion_xblock("courseware-7", "Second", "D", sort_key="D")
actual = self.get_course_topics()
expected = {
"courseware_topics": [
self.make_expected_tree(
None,
"First",
[
self.make_expected_tree("courseware-1", "A"),
self.make_expected_tree("courseware-2", "B"),
self.make_expected_tree("courseware-3", "C"),
]
),
self.make_expected_tree(
None,
"Second",
[
self.make_expected_tree("courseware-4", "A"),
self.make_expected_tree("courseware-5", "B"),
self.make_expected_tree("courseware-6", "C"),
self.make_expected_tree("courseware-7", "D"),
]
),
],
"non_courseware_topics": [
self.make_expected_tree("non-courseware-1", "W"),
self.make_expected_tree("non-courseware-2", "X"),
self.make_expected_tree("non-courseware-3", "Y"),
self.make_expected_tree("non-courseware-4", "Z"),
],
}
assert actual == expected
def test_access_control(self):
"""
Test that only topics that a user has access to are returned. The
ways in which a user may not have access are:
* Block is visible to staff only
* Block is accessible only to a group the user is not in
Also, there is a case that ensures that a category with no accessible
subcategories does not appear in the result.
"""
beta_tester = BetaTesterFactory.create(course_key=self.course.id)
CourseEnrollmentFactory.create(user=beta_tester, course_id=self.course.id)
staff = StaffFactory.create(course_key=self.course.id)
for user, group_idx in [(self.user, 0), (beta_tester, 1)]:
cohort = CohortFactory.create(
course_id=self.course.id,
name=self.partition.groups[group_idx].name,
users=[user]
)
CourseUserGroupPartitionGroup.objects.create(
course_user_group=cohort,
partition_id=self.partition.id,
group_id=self.partition.groups[group_idx].id
)
with self.store.bulk_operations(self.course.id, emit_signals=False):
self.store.update_item(self.course, self.user.id)
self.make_discussion_xblock(
"courseware-2",
"First",
"Cohort A",
group_access={self.partition.id: [self.partition.groups[0].id]}
)
self.make_discussion_xblock(
"courseware-3",
"First",
"Cohort B",
group_access={self.partition.id: [self.partition.groups[1].id]}
)
self.make_discussion_xblock("courseware-1", "First", "Everybody")
self.make_discussion_xblock(
"courseware-5",
"Second",
"Future Start Date",
start=datetime.now(UTC) + timedelta(days=1)
)
self.make_discussion_xblock("courseware-4", "Second", "Staff Only", visible_to_staff_only=True)
student_actual = self.get_course_topics()
student_expected = {
"courseware_topics": [
self.make_expected_tree(
None,
"First",
[
self.make_expected_tree("courseware-2", "Cohort A"),
self.make_expected_tree("courseware-1", "Everybody"),
]
),
],
"non_courseware_topics": [
self.make_expected_tree("non-courseware-topic-id", "Test Topic"),
],
}
assert student_actual == student_expected
self.request.user = beta_tester
beta_actual = self.get_course_topics()
beta_expected = {
"courseware_topics": [
self.make_expected_tree(
None,
"First",
[
self.make_expected_tree("courseware-3", "Cohort B"),
self.make_expected_tree("courseware-1", "Everybody"),
]
)
],
"non_courseware_topics": [
self.make_expected_tree("non-courseware-topic-id", "Test Topic"),
],
}
assert beta_actual == beta_expected
self.request.user = staff
staff_actual = self.get_course_topics()
staff_expected = {
"courseware_topics": [
self.make_expected_tree(
None,
"First",
[
self.make_expected_tree("courseware-2", "Cohort A"),
self.make_expected_tree("courseware-3", "Cohort B"),
self.make_expected_tree("courseware-1", "Everybody"),
]
),
self.make_expected_tree(
None,
"Second",
[
self.make_expected_tree("courseware-4", "Staff Only"),
]
),
],
"non_courseware_topics": [
self.make_expected_tree("non-courseware-topic-id", "Test Topic"),
],
}
assert staff_actual == staff_expected
def test_un_released_discussion_topic(self):
"""
Test discussion topics that have not yet started
"""
staff = StaffFactory.create(course_key=self.course.id)
with self.store.bulk_operations(self.course.id, emit_signals=False):
self.store.update_item(self.course, self.user.id)
self.make_discussion_xblock(
"courseware-2",
"First",
"Released",
start=datetime.now(UTC) - timedelta(days=1)
)
self.make_discussion_xblock(
"courseware-3",
"First",
"Future release",
start=datetime.now(UTC) + timedelta(days=1)
)
self.request.user = staff
staff_actual = self.get_course_topics()
staff_expected = {
"courseware_topics": [
self.make_expected_tree(
None,
"First",
[
self.make_expected_tree("courseware-2", "Released"),
]
),
],
"non_courseware_topics": [
self.make_expected_tree("non-courseware-topic-id", "Test Topic"),
],
}
assert staff_actual == staff_expected
def test_discussion_topic(self):
"""
Tests discussion topic details against a requested topic id
"""
topic_id_1 = "topic_id_1"
topic_id_2 = "topic_id_2"
self.make_discussion_xblock(topic_id_1, "test_category_1", "test_target_1")
self.make_discussion_xblock(topic_id_2, "test_category_2", "test_target_2")
actual = get_course_topics(self.request, self.course.id, {"topic_id_1", "topic_id_2"})
assert actual == {
'non_courseware_topics': [],
'courseware_topics': [
{
'children': [
{
'children': [],
'id': 'topic_id_1',
'thread_list_url': 'http://testserver/api/discussion/v1/threads/'
'?course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1',
'name': 'test_target_1',
'thread_counts': {'discussion': 0, 'question': 0},
},
],
'id': None,
'thread_list_url': 'http://testserver/api/discussion/v1/threads/'
'?course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1',
'name': 'test_category_1',
'thread_counts': None,
},
{
'children': [
{
'children': [],
'id': 'topic_id_2',
'thread_list_url': 'http://testserver/api/discussion/v1/threads/'
'?course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2',
'name': 'test_target_2',
'thread_counts': {'discussion': 0, 'question': 0},
}
],
'id': None,
'thread_list_url': 'http://testserver/api/discussion/v1/threads/'
'?course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2',
'name': 'test_category_2',
'thread_counts': None,
}
]
}
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
@override_settings(DISCUSSION_MODERATION_EDIT_REASON_CODES={"test-edit-reason": "Test Edit Reason"})
@override_settings(DISCUSSION_MODERATION_CLOSE_REASON_CODES={"test-close-reason": "Test Close Reason"})
@ddt.ddt
class GetCourseTest(ForumsEnableMixin, UrlResetMixin, SharedModuleStoreTestCase):
"""Test for get_course"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.course = CourseFactory.create(org="x", course="y", run="z")
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
self.user = UserFactory.create()
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
self.request = RequestFactory().get("/dummy")
self.request.user = self.user
def test_nonexistent_course(self):
with pytest.raises(CourseNotFoundError):
get_course(self.request, CourseLocator.from_string("course-v1:non+existent+course"))
def test_not_enrolled(self):
unenrolled_user = UserFactory.create()
self.request.user = unenrolled_user
with pytest.raises(CourseNotFoundError):
get_course(self.request, self.course.id)
def test_discussions_disabled(self):
with pytest.raises(DiscussionDisabledError):
get_course(self.request, _discussion_disabled_course_for(self.user).id)
def test_discussions_disabled_v2(self):
data = get_course(self.request, _discussion_disabled_course_for(self.user).id, False)
assert data['show_discussions'] is False
def test_basic(self):
assert get_course(self.request, self.course.id) == {
'id': str(self.course.id),
'is_posting_enabled': True,
'blackouts': [],
'thread_list_url': 'http://testserver/api/discussion/v1/threads/?course_id=course-v1%3Ax%2By%2Bz',
'following_thread_list_url':
'http://testserver/api/discussion/v1/threads/?course_id=course-v1%3Ax%2By%2Bz&following=True',
'topics_url': 'http://testserver/api/discussion/v1/course_topics/course-v1:x+y+z',
'allow_anonymous': True,
'allow_anonymous_to_peers': False,
'enable_in_context': True,
'group_at_subsection': False,
'provider': 'legacy',
'has_moderation_privileges': False,
"is_course_staff": False,
"is_course_admin": False,
'is_group_ta': False,
'is_user_admin': False,
'user_roles': {'Student'},
'edit_reasons': [{'code': 'test-edit-reason', 'label': 'Test Edit Reason'}],
'post_close_reasons': [{'code': 'test-close-reason', 'label': 'Test Close Reason'}],
'show_discussions': True,
'has_bulk_delete_privileges': False,
'is_notify_all_learners_enabled': False,
'captcha_settings': {'enabled': False, 'site_key': None},
'is_email_verified': True,
'only_verified_users_can_post': False,
'content_creation_rate_limited': False,
}
@ddt.data(
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_COMMUNITY_TA,
)
def test_privileged_roles(self, role):
"""
Test that the api returns the correct roles and privileges.
"""
_assign_role_to_user(user=self.user, course_id=self.course.id, role=role)
course_meta = get_course(self.request, self.course.id)
assert course_meta["has_moderation_privileges"]
assert course_meta["user_roles"] == {FORUM_ROLE_STUDENT} | {role}
@ddt.ddt
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class GetCourseTestBlackouts(ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase):
"""
Tests of get_course for courses that have blackout dates.
"""
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
self.course = CourseFactory.create(org="x", course="y", run="z")
self.user = UserFactory.create()
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
self.request = RequestFactory().get("/dummy")
self.request.user = self.user
def test_blackout(self):
# A variety of formats is accepted
self.course.discussion_blackouts = [
["2015-06-09T00:00:00Z", "6-10-15"],
[1433980800000, datetime(2015, 6, 12, tzinfo=UTC)],
]
self.update_course(self.course, self.user.id)
result = get_course(self.request, self.course.id)
assert result['blackouts'] == [
{'start': '2015-06-09T00:00:00Z', 'end': '2015-06-10T00:00:00Z'},
{'start': '2015-06-11T00:00:00Z', 'end': '2015-06-12T00:00:00Z'}
]
@ddt.data(None, "not a datetime", "2015", [])
def test_blackout_errors(self, bad_value):
self.course.discussion_blackouts = [
[bad_value, "2015-06-09T00:00:00Z"],
["2015-06-10T00:00:00Z", "2015-06-11T00:00:00Z"],
]
modulestore().update_item(self.course, self.user.id)
result = get_course(self.request, self.course.id)
assert result['blackouts'] == []

View File

@@ -1,881 +0,0 @@
"""
Tests for Discussion API serializers
"""
import itertools
from unittest import mock
import ddt
import httpretty
from django.test.client import RequestFactory
from django.test.utils import override_settings
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from common.djangoapps.student.tests.factories import UserFactory
from common.djangoapps.util.testing import UrlResetMixin
from lms.djangoapps.discussion.django_comment_client.tests.utils import ForumsEnableMixin
from lms.djangoapps.discussion.rest_api.serializers import (
CommentSerializer,
ThreadSerializer,
filter_spam_urls_from_html,
get_context
)
from lms.djangoapps.discussion.rest_api.tests.utils import (
CommentsServiceMockMixin,
make_minimal_cs_comment,
make_minimal_cs_thread,
parsed_body,
)
from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment
from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread
from openedx.core.djangoapps.django_comment_common.models import (
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_STUDENT,
Role,
)
@ddt.ddt
class SerializerTestMixin(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMixin):
"""
Test Mixin for Serializer tests
"""
@classmethod
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUpClass(cls):
super().setUpClass()
cls.course = CourseFactory.create()
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
httpretty.reset()
httpretty.enable()
self.addCleanup(httpretty.reset)
self.addCleanup(httpretty.disable)
patcher = mock.patch(
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
return_value=False
)
patcher.start()
self.addCleanup(patcher.stop)
self.maxDiff = None # pylint: disable=invalid-name
self.user = UserFactory.create()
self.register_get_user_response(self.user)
self.request = RequestFactory().get("/dummy")
self.request.user = self.user
self.author = UserFactory.create()
def create_role(self, role_name, users, course=None):
"""Create a Role in self.course with the given name and users"""
course = course or self.course
role = Role.objects.create(name=role_name, course_id=course.id)
role.users.set(users)
@ddt.data(
(FORUM_ROLE_ADMINISTRATOR, True, False, True),
(FORUM_ROLE_ADMINISTRATOR, False, True, False),
(FORUM_ROLE_MODERATOR, True, False, True),
(FORUM_ROLE_MODERATOR, False, True, False),
(FORUM_ROLE_COMMUNITY_TA, True, False, True),
(FORUM_ROLE_COMMUNITY_TA, False, True, False),
(FORUM_ROLE_STUDENT, True, False, True),
(FORUM_ROLE_STUDENT, False, True, True),
)
@ddt.unpack
def test_anonymity(self, role_name, anonymous, anonymous_to_peers, expected_serialized_anonymous):
"""
Test that content is properly made anonymous.
Content should be anonymous if the anonymous field is true or the
anonymous_to_peers field is true and the requester does not have a
privileged role.
role_name is the name of the requester's role.
anonymous is the value of the anonymous field in the content.
anonymous_to_peers is the value of the anonymous_to_peers field in the
content.
expected_serialized_anonymous is whether the content should actually be
anonymous in the API output when requested by a user with the given
role.
"""
self.create_role(role_name, [self.user])
serialized = self.serialize(
self.make_cs_content({"anonymous": anonymous, "anonymous_to_peers": anonymous_to_peers})
)
actual_serialized_anonymous = serialized["author"] is None
assert actual_serialized_anonymous == expected_serialized_anonymous
@ddt.data(
(FORUM_ROLE_ADMINISTRATOR, False, "Moderator"),
(FORUM_ROLE_ADMINISTRATOR, True, None),
(FORUM_ROLE_MODERATOR, False, "Moderator"),
(FORUM_ROLE_MODERATOR, True, None),
(FORUM_ROLE_COMMUNITY_TA, False, "Community TA"),
(FORUM_ROLE_COMMUNITY_TA, True, None),
(FORUM_ROLE_STUDENT, False, None),
(FORUM_ROLE_STUDENT, True, None),
)
@ddt.unpack
def test_author_labels(self, role_name, anonymous, expected_label):
"""
Test correctness of the author_label field.
The label should be "Staff", "Moderator", or "Community TA" for the
Administrator, Moderator, and Community TA roles, respectively, but
the label should not be present if the content is anonymous.
role_name is the name of the author's role.
anonymous is the value of the anonymous field in the content.
expected_label is the expected value of the author_label field in the
API output.
"""
self.create_role(role_name, [self.author])
serialized = self.serialize(self.make_cs_content({"anonymous": anonymous}))
assert serialized['author_label'] == expected_label
def test_abuse_flagged(self):
serialized = self.serialize(self.make_cs_content({"abuse_flaggers": [str(self.user.id)]}))
assert serialized['abuse_flagged'] is True
def test_voted(self):
thread_id = "test_thread"
self.register_get_user_response(self.user, upvoted_ids=[thread_id])
serialized = self.serialize(self.make_cs_content({"id": thread_id}))
assert serialized['voted'] is True
@ddt.ddt
class ThreadSerializerSerializationTest(SerializerTestMixin, SharedModuleStoreTestCase):
"""Tests for ThreadSerializer serialization."""
def make_cs_content(self, overrides):
"""
Create a thread with the given overrides, plus some useful test data.
"""
merged_overrides = {
"course_id": str(self.course.id),
"user_id": str(self.author.id),
"username": self.author.username,
"read": True,
"endorsed": True,
"resp_total": 0,
}
merged_overrides.update(overrides)
return make_minimal_cs_thread(merged_overrides)
def serialize(self, thread):
"""
Create a serializer with an appropriate context and use it to serialize
the given thread, returning the result.
"""
return ThreadSerializer(thread, context=get_context(self.course, self.request)).data
def test_basic(self):
thread = make_minimal_cs_thread({
"id": "test_thread",
"course_id": str(self.course.id),
"commentable_id": "test_topic",
"user_id": str(self.author.id),
"username": self.author.username,
"title": "Test Title",
"body": "Test body",
"pinned": True,
"votes": {"up_count": 4},
"comments_count": 5,
"unread_comments_count": 3,
})
expected = self.expected_thread_data({
"author": self.author.username,
"can_delete": False,
"vote_count": 4,
"comment_count": 6,
"unread_comment_count": 3,
"pinned": True,
"editable_fields": ["abuse_flagged", "copy_link", "following", "read", "voted"],
"abuse_flagged_count": None,
"edit_by_label": None,
"closed_by_label": None,
})
assert self.serialize(thread) == expected
thread["thread_type"] = "question"
expected.update({
"type": "question",
"comment_list_url": None,
"endorsed_comment_list_url": (
"http://testserver/api/discussion/v1/comments/?thread_id=test_thread&endorsed=True"
),
"non_endorsed_comment_list_url": (
"http://testserver/api/discussion/v1/comments/?thread_id=test_thread&endorsed=False"
),
})
assert self.serialize(thread) == expected
def test_pinned_missing(self):
"""
Make sure that older threads in the comments service without the pinned
field do not break serialization
"""
thread_data = self.make_cs_content({})
del thread_data["pinned"]
self.register_get_thread_response(thread_data)
serialized = self.serialize(thread_data)
assert serialized['pinned'] is False
def test_group(self):
self.course.cohort_config = {"cohorted": True}
modulestore().update_item(self.course, ModuleStoreEnum.UserID.test)
cohort = CohortFactory.create(course_id=self.course.id)
serialized = self.serialize(self.make_cs_content({"group_id": cohort.id}))
assert serialized['group_id'] == cohort.id
assert serialized['group_name'] == cohort.name
def test_following(self):
thread_id = "test_thread"
self.register_get_user_response(self.user, subscribed_thread_ids=[thread_id])
serialized = self.serialize(self.make_cs_content({"id": thread_id}))
assert serialized['following'] is True
def test_response_count(self):
thread_data = self.make_cs_content({"resp_total": 2})
self.register_get_thread_response(thread_data)
serialized = self.serialize(thread_data)
assert serialized['response_count'] == 2
def test_response_count_missing(self):
thread_data = self.make_cs_content({})
del thread_data["resp_total"]
self.register_get_thread_response(thread_data)
serialized = self.serialize(thread_data)
assert 'response_count' not in serialized
@ddt.data(
(FORUM_ROLE_MODERATOR, True),
(FORUM_ROLE_STUDENT, False),
("author", True),
)
@ddt.unpack
def test_closed_by_label_field(self, role, visible):
"""
Tests if closed by field is visible to author and priviledged users
"""
moderator = UserFactory()
request_role = FORUM_ROLE_STUDENT if role == "author" else role
author = self.user if role == "author" else self.author
self.create_role(FORUM_ROLE_MODERATOR, [moderator])
self.create_role(request_role, [self.user])
thread = make_minimal_cs_thread({
"id": "test_thread",
"course_id": str(self.course.id),
"commentable_id": "test_topic",
"user_id": str(author.id),
"username": author.username,
"title": "Test Title",
"body": "Test body",
"pinned": True,
"votes": {"up_count": 4},
"comments_count": 5,
"unread_comments_count": 3,
"closed_by": moderator
})
closed_by_label = "Moderator" if visible else None
closed_by = moderator if visible else None
can_delete = role != FORUM_ROLE_STUDENT
editable_fields = ["abuse_flagged", "copy_link", "following", "read", "voted"]
if role == "author":
editable_fields.remove("voted")
editable_fields.extend(['anonymous', 'raw_body', 'title', 'topic_id', 'type'])
elif role == FORUM_ROLE_MODERATOR:
editable_fields.extend(['close_reason_code', 'closed', 'edit_reason_code', 'pinned',
'raw_body', 'title', 'topic_id', 'type'])
expected = self.expected_thread_data({
"author": author.username,
"can_delete": can_delete,
"vote_count": 4,
"comment_count": 6,
"unread_comment_count": 3,
"pinned": True,
"editable_fields": sorted(editable_fields),
"abuse_flagged_count": None,
"edit_by_label": None,
"closed_by_label": closed_by_label,
"closed_by": closed_by,
})
assert self.serialize(thread) == expected
@ddt.data(
(FORUM_ROLE_MODERATOR, True),
(FORUM_ROLE_STUDENT, False),
("author", True),
)
@ddt.unpack
def test_edit_by_label_field(self, role, visible):
"""
Tests if closed by field is visible to author and priviledged users
"""
moderator = UserFactory()
request_role = FORUM_ROLE_STUDENT if role == "author" else role
author = self.user if role == "author" else self.author
self.create_role(FORUM_ROLE_MODERATOR, [moderator])
self.create_role(request_role, [self.user])
thread = make_minimal_cs_thread({
"id": "test_thread",
"course_id": str(self.course.id),
"commentable_id": "test_topic",
"user_id": str(author.id),
"username": author.username,
"title": "Test Title",
"body": "Test body",
"pinned": True,
"votes": {"up_count": 4},
"edit_history": [{"editor_username": moderator}],
"comments_count": 5,
"unread_comments_count": 3,
"closed_by": None
})
edit_by_label = "Moderator" if visible else None
can_delete = role != FORUM_ROLE_STUDENT
last_edit = None if role == FORUM_ROLE_STUDENT else {"editor_username": moderator}
editable_fields = ["abuse_flagged", "copy_link", "following", "read", "voted"]
if role == "author":
editable_fields.remove("voted")
editable_fields.extend(['anonymous', 'raw_body', 'title', 'topic_id', 'type'])
elif role == FORUM_ROLE_MODERATOR:
editable_fields.extend(['close_reason_code', 'closed', 'edit_reason_code', 'pinned',
'raw_body', 'title', 'topic_id', 'type'])
expected = self.expected_thread_data({
"author": author.username,
"can_delete": can_delete,
"vote_count": 4,
"comment_count": 6,
"unread_comment_count": 3,
"pinned": True,
"editable_fields": sorted(editable_fields),
"abuse_flagged_count": None,
"last_edit": last_edit,
"edit_by_label": edit_by_label,
"closed_by_label": None,
"closed_by": None,
})
assert self.serialize(thread) == expected
def test_get_preview_body(self):
"""
Test for the 'get_preview_body' method.
This test verifies that the 'get_preview_body' method returns a cleaned
version of the thread's body that is suitable for display as a preview.
The test specifically focuses on handling the presence of multiple
spaces within the body.
"""
thread_data = self.make_cs_content(
{"body": "<p>This is a test thread body with some text.</p>"}
)
serialized = self.serialize(thread_data)
assert serialized['preview_body'] == "This is a test thread body with some text."
@ddt.ddt
class CommentSerializerTest(SerializerTestMixin, SharedModuleStoreTestCase):
"""Tests for CommentSerializer."""
def setUp(self):
super().setUp()
self.endorser = UserFactory.create()
self.endorsed_at = "2015-05-18T12:34:56Z"
def make_cs_content(self, overrides=None, with_endorsement=False):
"""
Create a comment with the given overrides, plus some useful test data.
"""
merged_overrides = {
"user_id": str(self.author.id),
"username": self.author.username
}
if with_endorsement:
merged_overrides["endorsement"] = {
"user_id": str(self.endorser.id),
"time": self.endorsed_at
}
merged_overrides.update(overrides or {})
return make_minimal_cs_comment(merged_overrides)
def serialize(self, comment, thread_data=None):
"""
Create a serializer with an appropriate context and use it to serialize
the given comment, returning the result.
"""
context = get_context(self.course, self.request, make_minimal_cs_thread(thread_data))
return CommentSerializer(comment, context=context).data
def test_basic(self):
comment = {
"type": "comment",
"id": "test_comment",
"thread_id": "test_thread",
"user_id": str(self.author.id),
"username": self.author.username,
"anonymous": False,
"anonymous_to_peers": False,
"created_at": "2015-04-28T00:00:00Z",
"updated_at": "2015-04-28T11:11:11Z",
"body": "Test body",
"endorsed": False,
"abuse_flaggers": [],
"votes": {"up_count": 4},
"children": [],
"child_count": 0,
}
expected = {
"anonymous": False,
"anonymous_to_peers": False,
"id": "test_comment",
"thread_id": "test_thread",
"parent_id": None,
"author": self.author.username,
"author_label": None,
"created_at": "2015-04-28T00:00:00Z",
"updated_at": "2015-04-28T11:11:11Z",
"raw_body": "Test body",
"rendered_body": "<p>Test 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": 4,
"children": [],
"editable_fields": ["abuse_flagged", "voted"],
"child_count": 0,
"can_delete": False,
"last_edit": None,
"edit_by_label": None,
"profile_image": {
"has_image": False,
"image_url_full": "http://testserver/static/default_500.png",
"image_url_large": "http://testserver/static/default_120.png",
"image_url_medium": "http://testserver/static/default_50.png",
"image_url_small": "http://testserver/static/default_30.png",
},
}
assert self.serialize(comment) == expected
@ddt.data(
*itertools.product(
[
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_STUDENT,
],
[True, False]
)
)
@ddt.unpack
def test_endorsed_by(self, endorser_role_name, thread_anonymous):
"""
Test correctness of the endorsed_by field.
The endorser should be anonymous iff the thread is anonymous to the
requester, and the endorser is not a privileged user.
endorser_role_name is the name of the endorser's role.
thread_anonymous is the value of the anonymous field in the thread.
"""
self.create_role(endorser_role_name, [self.endorser])
serialized = self.serialize(
self.make_cs_content(with_endorsement=True),
thread_data={"anonymous": thread_anonymous}
)
actual_endorser_anonymous = serialized["endorsed_by"] is None
expected_endorser_anonymous = endorser_role_name == FORUM_ROLE_STUDENT and thread_anonymous
assert actual_endorser_anonymous == expected_endorser_anonymous
@ddt.data(
(FORUM_ROLE_ADMINISTRATOR, "Moderator"),
(FORUM_ROLE_MODERATOR, "Moderator"),
(FORUM_ROLE_COMMUNITY_TA, "Community TA"),
(FORUM_ROLE_STUDENT, None),
)
@ddt.unpack
def test_endorsed_by_labels(self, role_name, expected_label):
"""
Test correctness of the endorsed_by_label field.
The label should be "Staff", "Moderator", or "Community TA" for the
Administrator, Moderator, and Community TA roles, respectively.
role_name is the name of the author's role.
expected_label is the expected value of the author_label field in the
API output.
"""
self.create_role(role_name, [self.endorser])
serialized = self.serialize(self.make_cs_content(with_endorsement=True))
assert serialized['endorsed_by_label'] == expected_label
def test_endorsed_at(self):
serialized = self.serialize(self.make_cs_content(with_endorsement=True))
assert serialized['endorsed_at'] == self.endorsed_at
def test_children(self):
comment = self.make_cs_content({
"id": "test_root",
"children": [
self.make_cs_content({
"id": "test_child_1",
"parent_id": "test_root",
}),
self.make_cs_content({
"id": "test_child_2",
"parent_id": "test_root",
"children": [
self.make_cs_content({
"id": "test_grandchild",
"parent_id": "test_child_2"
})
],
}),
],
})
serialized = self.serialize(comment)
assert serialized['children'][0]['id'] == 'test_child_1'
assert serialized['children'][0]['parent_id'] == 'test_root'
assert serialized['children'][1]['id'] == 'test_child_2'
assert serialized['children'][1]['parent_id'] == 'test_root'
assert serialized['children'][1]['children'][0]['id'] == 'test_grandchild'
assert serialized['children'][1]['children'][0]['parent_id'] == 'test_child_2'
@ddt.ddt
class ThreadSerializerDeserializationTest(
ForumsEnableMixin,
CommentsServiceMockMixin,
UrlResetMixin,
SharedModuleStoreTestCase
):
"""Tests for ThreadSerializer deserialization."""
@classmethod
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUpClass(cls):
super().setUpClass()
cls.course = CourseFactory.create()
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
httpretty.reset()
httpretty.enable()
self.addCleanup(httpretty.reset)
self.addCleanup(httpretty.disable)
patcher = mock.patch(
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
return_value=False
)
patcher.start()
self.addCleanup(patcher.stop)
self.user = UserFactory.create()
self.register_get_user_response(self.user)
self.request = RequestFactory().get("/dummy")
self.request.user = self.user
self.minimal_data = {
"course_id": str(self.course.id),
"topic_id": "test_topic",
"type": "discussion",
"title": "Test Title",
"raw_body": "Test body",
}
self.existing_thread = Thread(**make_minimal_cs_thread({
"id": "existing_thread",
"course_id": str(self.course.id),
"commentable_id": "original_topic",
"thread_type": "discussion",
"title": "Original Title",
"body": "Original body",
"user_id": str(self.user.id),
"username": self.user.username,
"read": "False",
"endorsed": "False"
}))
def save_and_reserialize(self, data, instance=None):
"""
Create a serializer with the given data and (if updating) instance,
ensure that it is valid, save the result, and return the full thread
data from the serializer.
"""
serializer = ThreadSerializer(
instance,
data=data,
partial=(instance is not None),
context=get_context(self.course, self.request)
)
assert serializer.is_valid()
serializer.save()
return serializer.data
def test_create_missing_field(self):
for field in self.minimal_data:
data = self.minimal_data.copy()
data.pop(field)
serializer = ThreadSerializer(data=data)
assert not serializer.is_valid()
assert serializer.errors == {field: ['This field is required.']}
@ddt.data("", " ")
def test_create_empty_string(self, value):
data = self.minimal_data.copy()
data.update({field: value for field in ["topic_id", "title", "raw_body"]})
serializer = ThreadSerializer(data=data, context=get_context(self.course, self.request))
assert not serializer.is_valid()
assert serializer.errors == {
field: ['This field may not be blank.'] for field in ['topic_id', 'title', 'raw_body']
}
def test_update_empty(self):
self.register_put_thread_response(self.existing_thread.attributes)
self.save_and_reserialize({}, self.existing_thread)
assert parsed_body(httpretty.last_request()) == {
'course_id': [str(self.course.id)],
'commentable_id': ['original_topic'],
'thread_type': ['discussion'],
'title': ['Original Title'],
'body': ['Original body'],
'anonymous': ['False'],
'anonymous_to_peers': ['False'],
'closed': ['False'],
'pinned': ['False'],
'user_id': [str(self.user.id)],
'read': ['False']
}
@ddt.data(True, False)
def test_update_all(self, read):
self.register_put_thread_response(self.existing_thread.attributes)
data = {
"topic_id": "edited_topic",
"type": "question",
"title": "Edited Title",
"raw_body": "Edited body",
"read": read,
}
saved = self.save_and_reserialize(data, self.existing_thread)
assert parsed_body(httpretty.last_request()) == {
'course_id': [str(self.course.id)],
'commentable_id': ['edited_topic'],
'thread_type': ['question'],
'title': ['Edited Title'],
'body': ['Edited body'],
'anonymous': ['False'],
'anonymous_to_peers': ['False'],
'closed': ['False'],
'pinned': ['False'],
'user_id': [str(self.user.id)],
'read': [str(read)],
'editing_user_id': [str(self.user.id)],
}
for key in data:
assert saved[key] == data[key]
def test_update_anonymous(self):
"""
Test that serializer correctly deserializes the anonymous field when
updating an existing thread.
"""
self.register_put_thread_response(self.existing_thread.attributes)
data = {
"anonymous": True,
}
self.save_and_reserialize(data, self.existing_thread)
assert parsed_body(httpretty.last_request())["anonymous"] == ['True']
def test_update_anonymous_to_peers(self):
"""
Test that serializer correctly deserializes the anonymous_to_peers
field when updating an existing thread.
"""
self.register_put_thread_response(self.existing_thread.attributes)
data = {
"anonymous_to_peers": True,
}
self.save_and_reserialize(data, self.existing_thread)
assert parsed_body(httpretty.last_request())["anonymous_to_peers"] == ['True']
@ddt.data("", " ")
def test_update_empty_string(self, value):
serializer = ThreadSerializer(
self.existing_thread,
data={field: value for field in ["topic_id", "title", "raw_body"]},
partial=True,
context=get_context(self.course, self.request)
)
assert not serializer.is_valid()
assert serializer.errors == {
field: ['This field may not be blank.'] for field in ['topic_id', 'title', 'raw_body']
}
def test_update_course_id(self):
serializer = ThreadSerializer(
self.existing_thread,
data={"course_id": "some/other/course"},
partial=True,
context=get_context(self.course, self.request)
)
assert not serializer.is_valid()
assert serializer.errors == {'course_id': ['This field is not allowed in an update.']}
@ddt.ddt
class CommentSerializerDeserializationTest(ForumsEnableMixin, CommentsServiceMockMixin, SharedModuleStoreTestCase):
"""Tests for ThreadSerializer deserialization."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.course = CourseFactory.create()
def setUp(self):
super().setUp()
httpretty.reset()
httpretty.enable()
self.addCleanup(httpretty.reset)
self.addCleanup(httpretty.disable)
patcher = mock.patch(
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
return_value=False
)
patcher.start()
self.addCleanup(patcher.stop)
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment"
)
self.mock_get_course_id_by_comment = patcher.start()
self.addCleanup(patcher.stop)
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread"
)
self.mock_get_course_id_by_thread = patcher.start()
self.addCleanup(patcher.stop)
self.user = UserFactory.create()
self.register_get_user_response(self.user)
self.request = RequestFactory().get("/dummy")
self.request.user = self.user
self.minimal_data = {
"thread_id": "test_thread",
"raw_body": "Test body",
}
self.existing_comment = Comment(**make_minimal_cs_comment({
"id": "existing_comment",
"thread_id": "dummy",
"body": "Original body",
"user_id": str(self.user.id),
"username": self.user.username,
"course_id": str(self.course.id),
}))
def save_and_reserialize(self, data, instance=None):
"""
Create a serializer with the given data, ensure that it is valid, save
the result, and return the full comment data from the serializer.
"""
context = get_context(
self.course,
self.request,
make_minimal_cs_thread({"course_id": str(self.course.id)})
)
serializer = CommentSerializer(
instance,
data=data,
partial=(instance is not None),
context=context
)
assert serializer.is_valid()
serializer.save()
return serializer.data
def test_create_missing_field(self):
for field in self.minimal_data:
data = self.minimal_data.copy()
data.pop(field)
serializer = CommentSerializer(
data=data,
context=get_context(self.course, self.request, make_minimal_cs_thread())
)
assert not serializer.is_valid()
assert serializer.errors == {field: ['This field is required.']}
def test_update_empty(self):
self.register_put_comment_response(self.existing_comment.attributes)
self.save_and_reserialize({}, instance=self.existing_comment)
assert parsed_body(httpretty.last_request()) == {
'body': ['Original body'],
'course_id': [str(self.course.id)],
'user_id': [str(self.user.id)],
'anonymous': ['False'],
'anonymous_to_peers': ['False'],
'endorsed': ['False']
}
def test_update_anonymous(self):
"""
Test that serializer correctly deserializes the anonymous field when
updating an existing comment.
"""
self.register_put_comment_response(self.existing_comment.attributes)
data = {
"anonymous": True,
}
self.save_and_reserialize(data, self.existing_comment)
assert parsed_body(httpretty.last_request())["anonymous"] == ['True']
def test_update_anonymous_to_peers(self):
"""
Test that serializer correctly deserializes the anonymous_to_peers
field when updating an existing comment.
"""
self.register_put_comment_response(self.existing_comment.attributes)
data = {
"anonymous_to_peers": True,
}
self.save_and_reserialize(data, self.existing_comment)
assert parsed_body(httpretty.last_request())["anonymous_to_peers"] == ['True']
@ddt.data("thread_id", "parent_id")
def test_update_non_updatable(self, field):
serializer = CommentSerializer(
self.existing_comment,
data={field: "different_value"},
partial=True,
context=get_context(self.course, self.request)
)
assert not serializer.is_valid()
assert serializer.errors == {field: ['This field is not allowed in an update.']}
class FilterSpamTest(SharedModuleStoreTestCase):
"""
Tests for the filter_spam method
"""
@override_settings(DISCUSSION_SPAM_URLS=['example.com'])
def test_filter(self):
self.assertEqual(
filter_spam_urls_from_html('<div><a href="example.com/abc/def">abc</a></div>')[0],
'<div>abc</div>'
)
self.assertEqual(
filter_spam_urls_from_html('<div>example.com/abc/def</div>')[0],
'<div></div>'
)

View File

@@ -5,7 +5,6 @@ Tests for Discussion API serializers
import itertools
from unittest import mock
from urllib.parse import urlparse
import ddt
import httpretty
@@ -23,7 +22,6 @@ from lms.djangoapps.discussion.rest_api.tests.utils import (
ForumMockUtilsMixin,
make_minimal_cs_comment,
make_minimal_cs_thread,
parsed_body,
)
from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment
@@ -58,12 +56,6 @@ class CommentSerializerDeserializationTest(ForumsEnableMixin, ForumMockUtilsMixi
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=True
)
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"
)
@@ -322,6 +314,76 @@ class CommentSerializerDeserializationTest(ForumsEnableMixin, ForumMockUtilsMixi
assert saved['endorsed_by_label'] is None
assert saved['endorsed_at'] is None
def test_create_missing_field(self):
for field in self.minimal_data:
data = self.minimal_data.copy()
data.pop(field)
serializer = CommentSerializer(
data=data,
context=get_context(self.course, self.request, make_minimal_cs_thread())
)
assert not serializer.is_valid()
assert serializer.errors == {field: ['This field is required.']}
def test_update_empty(self):
self.register_put_comment_response(self.existing_comment.attributes)
self.save_and_reserialize({}, instance=self.existing_comment)
parsed_body = {
'body': 'Original body',
'course_id': str(self.course.id),
'user_id': str(self.user.id),
'anonymous': False,
'anonymous_to_peers': False,
'endorsed': False,
'comment_id': 'existing_comment',
}
self.check_mock_called("update_comment")
self.check_mock_called_with(
"update_comment",
-1,
**parsed_body
)
def test_update_anonymous(self):
"""
Test that serializer correctly deserializes the anonymous field when
updating an existing comment.
"""
self.register_put_comment_response(self.existing_comment.attributes)
data = {
"anonymous": True,
}
self.save_and_reserialize(data, self.existing_comment)
call_args = self.get_mock_func_calls("update_comment")[0]
args, kwargs = call_args
assert kwargs['anonymous']
def test_update_anonymous_to_peers(self):
"""
Test that serializer correctly deserializes the anonymous_to_peers
field when updating an existing comment.
"""
self.register_put_comment_response(self.existing_comment.attributes)
data = {
"anonymous_to_peers": True,
}
self.save_and_reserialize(data, self.existing_comment)
call_args = self.get_mock_func_calls("update_comment")[0]
args, kwargs = call_args
assert kwargs['anonymous_to_peers']
@ddt.data("thread_id", "parent_id")
def test_update_non_updatable(self, field):
serializer = CommentSerializer(
self.existing_comment,
data={field: "different_value"},
partial=True,
context=get_context(self.course, self.request)
)
assert not serializer.is_valid()
assert serializer.errors == {field: ['This field is not allowed in an update.']}
@ddt.ddt
class ThreadSerializerDeserializationTest(
@@ -351,12 +413,6 @@ class ThreadSerializerDeserializationTest(
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=True
)
patcher.start()
self.addCleanup(patcher.stop)
self.user = UserFactory.create()
self.register_get_user_response(self.user)
self.request = RequestFactory().get("/dummy")
@@ -476,3 +532,550 @@ class ThreadSerializerDeserializationTest(
call_args = self.get_mock_func_calls("create_thread")[0]
args, kwargs = call_args
assert kwargs['anonymous_to_peers']
@ddt.ddt
class SerializerTestMixin(ForumsEnableMixin, UrlResetMixin, ForumMockUtilsMixin):
"""
Test Mixin for Serializer tests
"""
@classmethod
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUpClass(cls):
super().setUpClass()
cls.course = CourseFactory.create()
super().setUpClassAndForumMock()
@classmethod
def tearDownClass(cls):
"""Stop patches after tests complete."""
super().tearDownClass()
super().disposeForumMocks()
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
httpretty.reset()
httpretty.enable()
self.addCleanup(httpretty.reset)
self.addCleanup(httpretty.disable)
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment"
)
self.mock_get_course_id_by_comment = patcher.start()
self.addCleanup(patcher.stop)
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread"
)
self.mock_get_course_id_by_thread = patcher.start()
self.addCleanup(patcher.stop)
self.maxDiff = None # pylint: disable=invalid-name
self.user = UserFactory.create()
self.register_get_user_response(self.user)
self.request = RequestFactory().get("/dummy")
self.request.user = self.user
self.author = UserFactory.create()
def create_role(self, role_name, users, course=None):
"""Create a Role in self.course with the given name and users"""
course = course or self.course
role = Role.objects.create(name=role_name, course_id=course.id)
role.users.set(users)
@ddt.data(
(FORUM_ROLE_ADMINISTRATOR, True, False, True),
(FORUM_ROLE_ADMINISTRATOR, False, True, False),
(FORUM_ROLE_MODERATOR, True, False, True),
(FORUM_ROLE_MODERATOR, False, True, False),
(FORUM_ROLE_COMMUNITY_TA, True, False, True),
(FORUM_ROLE_COMMUNITY_TA, False, True, False),
(FORUM_ROLE_STUDENT, True, False, True),
(FORUM_ROLE_STUDENT, False, True, True),
)
@ddt.unpack
def test_anonymity(self, role_name, anonymous, anonymous_to_peers, expected_serialized_anonymous):
"""
Test that content is properly made anonymous.
Content should be anonymous if the anonymous field is true or the
anonymous_to_peers field is true and the requester does not have a
privileged role.
role_name is the name of the requester's role.
anonymous is the value of the anonymous field in the content.
anonymous_to_peers is the value of the anonymous_to_peers field in the
content.
expected_serialized_anonymous is whether the content should actually be
anonymous in the API output when requested by a user with the given
role.
"""
self.register_get_user_response(self.user)
self.create_role(role_name, [self.user])
serialized = self.serialize(
self.make_cs_content({"anonymous": anonymous, "anonymous_to_peers": anonymous_to_peers})
)
actual_serialized_anonymous = serialized["author"] is None
assert actual_serialized_anonymous == expected_serialized_anonymous
@ddt.data(
(FORUM_ROLE_ADMINISTRATOR, False, "Moderator"),
(FORUM_ROLE_ADMINISTRATOR, True, None),
(FORUM_ROLE_MODERATOR, False, "Moderator"),
(FORUM_ROLE_MODERATOR, True, None),
(FORUM_ROLE_COMMUNITY_TA, False, "Community TA"),
(FORUM_ROLE_COMMUNITY_TA, True, None),
(FORUM_ROLE_STUDENT, False, None),
(FORUM_ROLE_STUDENT, True, None),
)
@ddt.unpack
def test_author_labels(self, role_name, anonymous, expected_label):
"""
Test correctness of the author_label field.
The label should be "Staff", "Moderator", or "Community TA" for the
Administrator, Moderator, and Community TA roles, respectively, but
the label should not be present if the content is anonymous.
role_name is the name of the author's role.
anonymous is the value of the anonymous field in the content.
expected_label is the expected value of the author_label field in the
API output.
"""
self.register_get_user_response(self.user)
self.create_role(role_name, [self.author])
serialized = self.serialize(self.make_cs_content({"anonymous": anonymous}))
assert serialized['author_label'] == expected_label
def test_abuse_flagged(self):
self.register_get_user_response(self.user)
serialized = self.serialize(self.make_cs_content({"abuse_flaggers": [str(self.user.id)]}))
assert serialized['abuse_flagged'] is True
def test_voted(self):
thread_id = "test_thread"
self.register_get_user_response(self.user, upvoted_ids=[thread_id])
serialized = self.serialize(self.make_cs_content({"id": thread_id}))
assert serialized['voted'] is True
@ddt.ddt
class CommentSerializerTest(SerializerTestMixin, SharedModuleStoreTestCase):
"""Tests for CommentSerializer."""
def setUp(self):
super().setUp()
self.endorser = UserFactory.create()
self.endorsed_at = "2015-05-18T12:34:56Z"
super().setUpClassAndForumMock()
@classmethod
def tearDownClass(cls):
"""Stop patches after tests complete."""
super().tearDownClass()
super().disposeForumMocks()
def make_cs_content(self, overrides=None, with_endorsement=False):
"""
Create a comment with the given overrides, plus some useful test data.
"""
merged_overrides = {
"user_id": str(self.author.id),
"username": self.author.username
}
if with_endorsement:
merged_overrides["endorsement"] = {
"user_id": str(self.endorser.id),
"time": self.endorsed_at
}
merged_overrides.update(overrides or {})
return make_minimal_cs_comment(merged_overrides)
def serialize(self, comment, thread_data=None):
"""
Create a serializer with an appropriate context and use it to serialize
the given comment, returning the result.
"""
context = get_context(self.course, self.request, make_minimal_cs_thread(thread_data))
return CommentSerializer(comment, context=context).data
def test_basic(self):
self.register_get_user_response(self.user)
comment = {
"type": "comment",
"id": "test_comment",
"thread_id": "test_thread",
"user_id": str(self.author.id),
"username": self.author.username,
"anonymous": False,
"anonymous_to_peers": False,
"created_at": "2015-04-28T00:00:00Z",
"updated_at": "2015-04-28T11:11:11Z",
"body": "Test body",
"endorsed": False,
"abuse_flaggers": [],
"votes": {"up_count": 4},
"children": [],
"child_count": 0,
}
expected = {
"anonymous": False,
"anonymous_to_peers": False,
"id": "test_comment",
"thread_id": "test_thread",
"parent_id": None,
"author": self.author.username,
"author_label": None,
"created_at": "2015-04-28T00:00:00Z",
"updated_at": "2015-04-28T11:11:11Z",
"raw_body": "Test body",
"rendered_body": "<p>Test 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": 4,
"children": [],
"editable_fields": ["abuse_flagged", "voted"],
"child_count": 0,
"can_delete": False,
"last_edit": None,
"edit_by_label": None,
"profile_image": {
"has_image": False,
"image_url_full": "http://testserver/static/default_500.png",
"image_url_large": "http://testserver/static/default_120.png",
"image_url_medium": "http://testserver/static/default_50.png",
"image_url_small": "http://testserver/static/default_30.png",
},
}
assert self.serialize(comment) == expected
@ddt.data(
*itertools.product(
[
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_STUDENT,
],
[True, False]
)
)
@ddt.unpack
def test_endorsed_by(self, endorser_role_name, thread_anonymous):
"""
Test correctness of the endorsed_by field.
The endorser should be anonymous iff the thread is anonymous to the
requester, and the endorser is not a privileged user.
endorser_role_name is the name of the endorser's role.
thread_anonymous is the value of the anonymous field in the thread.
"""
self.register_get_user_response(self.user)
self.create_role(endorser_role_name, [self.endorser])
serialized = self.serialize(
self.make_cs_content(with_endorsement=True),
thread_data={"anonymous": thread_anonymous}
)
actual_endorser_anonymous = serialized["endorsed_by"] is None
expected_endorser_anonymous = endorser_role_name == FORUM_ROLE_STUDENT and thread_anonymous
assert actual_endorser_anonymous == expected_endorser_anonymous
@ddt.data(
(FORUM_ROLE_ADMINISTRATOR, "Moderator"),
(FORUM_ROLE_MODERATOR, "Moderator"),
(FORUM_ROLE_COMMUNITY_TA, "Community TA"),
(FORUM_ROLE_STUDENT, None),
)
@ddt.unpack
def test_endorsed_by_labels(self, role_name, expected_label):
"""
Test correctness of the endorsed_by_label field.
The label should be "Staff", "Moderator", or "Community TA" for the
Administrator, Moderator, and Community TA roles, respectively.
role_name is the name of the author's role.
expected_label is the expected value of the author_label field in the
API output.
"""
self.register_get_user_response(self.user)
self.create_role(role_name, [self.endorser])
serialized = self.serialize(self.make_cs_content(with_endorsement=True))
assert serialized['endorsed_by_label'] == expected_label
def test_endorsed_at(self):
self.register_get_user_response(self.user)
serialized = self.serialize(self.make_cs_content(with_endorsement=True))
assert serialized['endorsed_at'] == self.endorsed_at
def test_children(self):
self.register_get_user_response(self.user)
comment = self.make_cs_content({
"id": "test_root",
"children": [
self.make_cs_content({
"id": "test_child_1",
"parent_id": "test_root",
}),
self.make_cs_content({
"id": "test_child_2",
"parent_id": "test_root",
"children": [
self.make_cs_content({
"id": "test_grandchild",
"parent_id": "test_child_2"
})
],
}),
],
})
serialized = self.serialize(comment)
assert serialized['children'][0]['id'] == 'test_child_1'
assert serialized['children'][0]['parent_id'] == 'test_root'
assert serialized['children'][1]['id'] == 'test_child_2'
assert serialized['children'][1]['parent_id'] == 'test_root'
assert serialized['children'][1]['children'][0]['id'] == 'test_grandchild'
assert serialized['children'][1]['children'][0]['parent_id'] == 'test_child_2'
@ddt.ddt
class ThreadSerializerSerializationTest(SerializerTestMixin, SharedModuleStoreTestCase, ForumMockUtilsMixin):
"""Tests for ThreadSerializer serialization."""
def make_cs_content(self, overrides):
"""
Create a thread with the given overrides, plus some useful test data.
"""
merged_overrides = {
"course_id": str(self.course.id),
"user_id": str(self.author.id),
"username": self.author.username,
"read": True,
"endorsed": True,
"resp_total": 0,
}
merged_overrides.update(overrides)
return make_minimal_cs_thread(merged_overrides)
def serialize(self, thread):
"""
Create a serializer with an appropriate context and use it to serialize
the given thread, returning the result.
"""
return ThreadSerializer(thread, context=get_context(self.course, self.request)).data
def test_basic(self):
thread = make_minimal_cs_thread({
"id": "test_thread",
"course_id": str(self.course.id),
"commentable_id": "test_topic",
"user_id": str(self.author.id),
"username": self.author.username,
"title": "Test Title",
"body": "Test body",
"pinned": True,
"votes": {"up_count": 4},
"comments_count": 5,
"unread_comments_count": 3,
})
expected = self.expected_thread_data({
"author": self.author.username,
"can_delete": False,
"vote_count": 4,
"comment_count": 6,
"unread_comment_count": 3,
"pinned": True,
"editable_fields": ["abuse_flagged", "copy_link", "following", "read", "voted"],
"abuse_flagged_count": None,
"edit_by_label": None,
"closed_by_label": None,
})
assert self.serialize(thread) == expected
thread["thread_type"] = "question"
expected.update({
"type": "question",
"comment_list_url": None,
"endorsed_comment_list_url": (
"http://testserver/api/discussion/v1/comments/?thread_id=test_thread&endorsed=True"
),
"non_endorsed_comment_list_url": (
"http://testserver/api/discussion/v1/comments/?thread_id=test_thread&endorsed=False"
),
})
assert self.serialize(thread) == expected
def test_pinned_missing(self):
"""
Make sure that older threads in the comments service without the pinned
field do not break serialization
"""
thread_data = self.make_cs_content({})
del thread_data["pinned"]
self.register_get_thread_response(thread_data)
serialized = self.serialize(thread_data)
assert serialized['pinned'] is False
def test_group(self):
self.course.cohort_config = {"cohorted": True}
modulestore().update_item(self.course, ModuleStoreEnum.UserID.test)
cohort = CohortFactory.create(course_id=self.course.id)
serialized = self.serialize(self.make_cs_content({"group_id": cohort.id}))
assert serialized['group_id'] == cohort.id
assert serialized['group_name'] == cohort.name
def test_following(self):
thread_id = "test_thread"
self.register_get_user_response(self.user, subscribed_thread_ids=[thread_id])
serialized = self.serialize(self.make_cs_content({"id": thread_id}))
assert serialized['following'] is True
def test_response_count(self):
thread_data = self.make_cs_content({"resp_total": 2})
self.register_get_thread_response(thread_data)
serialized = self.serialize(thread_data)
assert serialized['response_count'] == 2
def test_response_count_missing(self):
thread_data = self.make_cs_content({})
del thread_data["resp_total"]
self.register_get_thread_response(thread_data)
serialized = self.serialize(thread_data)
assert 'response_count' not in serialized
@ddt.data(
(FORUM_ROLE_MODERATOR, True),
(FORUM_ROLE_STUDENT, False),
("author", True),
)
@ddt.unpack
def test_closed_by_label_field(self, role, visible):
"""
Tests if closed by field is visible to author and priviledged users
"""
moderator = UserFactory()
request_role = FORUM_ROLE_STUDENT if role == "author" else role
author = self.user if role == "author" else self.author
self.create_role(FORUM_ROLE_MODERATOR, [moderator])
self.create_role(request_role, [self.user])
thread = make_minimal_cs_thread({
"id": "test_thread",
"course_id": str(self.course.id),
"commentable_id": "test_topic",
"user_id": str(author.id),
"username": author.username,
"title": "Test Title",
"body": "Test body",
"pinned": True,
"votes": {"up_count": 4},
"comments_count": 5,
"unread_comments_count": 3,
"closed_by": moderator
})
closed_by_label = "Moderator" if visible else None
closed_by = moderator if visible else None
can_delete = role != FORUM_ROLE_STUDENT
editable_fields = ["abuse_flagged", "copy_link", "following", "read", "voted"]
if role == "author":
editable_fields.remove("voted")
editable_fields.extend(['anonymous', 'raw_body', 'title', 'topic_id', 'type'])
elif role == FORUM_ROLE_MODERATOR:
editable_fields.extend(['close_reason_code', 'closed', 'edit_reason_code', 'pinned',
'raw_body', 'title', 'topic_id', 'type'])
expected = self.expected_thread_data({
"author": author.username,
"can_delete": can_delete,
"vote_count": 4,
"comment_count": 6,
"unread_comment_count": 3,
"pinned": True,
"editable_fields": sorted(editable_fields),
"abuse_flagged_count": None,
"edit_by_label": None,
"closed_by_label": closed_by_label,
"closed_by": closed_by,
})
assert self.serialize(thread) == expected
@ddt.data(
(FORUM_ROLE_MODERATOR, True),
(FORUM_ROLE_STUDENT, False),
("author", True),
)
@ddt.unpack
def test_edit_by_label_field(self, role, visible):
"""
Tests if closed by field is visible to author and priviledged users
"""
moderator = UserFactory()
request_role = FORUM_ROLE_STUDENT if role == "author" else role
author = self.user if role == "author" else self.author
self.create_role(FORUM_ROLE_MODERATOR, [moderator])
self.create_role(request_role, [self.user])
thread = make_minimal_cs_thread({
"id": "test_thread",
"course_id": str(self.course.id),
"commentable_id": "test_topic",
"user_id": str(author.id),
"username": author.username,
"title": "Test Title",
"body": "Test body",
"pinned": True,
"votes": {"up_count": 4},
"edit_history": [{"editor_username": moderator}],
"comments_count": 5,
"unread_comments_count": 3,
"closed_by": None
})
edit_by_label = "Moderator" if visible else None
can_delete = role != FORUM_ROLE_STUDENT
last_edit = None if role == FORUM_ROLE_STUDENT else {"editor_username": moderator}
editable_fields = ["abuse_flagged", "copy_link", "following", "read", "voted"]
if role == "author":
editable_fields.remove("voted")
editable_fields.extend(['anonymous', 'raw_body', 'title', 'topic_id', 'type'])
elif role == FORUM_ROLE_MODERATOR:
editable_fields.extend(['close_reason_code', 'closed', 'edit_reason_code', 'pinned',
'raw_body', 'title', 'topic_id', 'type'])
expected = self.expected_thread_data({
"author": author.username,
"can_delete": can_delete,
"vote_count": 4,
"comment_count": 6,
"unread_comment_count": 3,
"pinned": True,
"editable_fields": sorted(editable_fields),
"abuse_flagged_count": None,
"last_edit": last_edit,
"edit_by_label": edit_by_label,
"closed_by_label": None,
"closed_by": None,
})
assert self.serialize(thread) == expected
def test_get_preview_body(self):
"""
Test for the 'get_preview_body' method.
This test verifies that the 'get_preview_body' method returns a cleaned
version of the thread's body that is suitable for display as a preview.
The test specifically focuses on handling the presence of multiple
spaces within the body.
"""
thread_data = self.make_cs_content(
{"body": "<p>This is a test thread body with some text.</p>"}
)
serialized = self.serialize(thread_data)
assert serialized['preview_body'] == "This is a test thread body with some text."

View File

@@ -60,46 +60,19 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC
self.course = CourseFactory.create()
# Patch 1
patcher1 = mock.patch(
'openedx.core.djangoapps.django_comment_common.comment_client.thread.is_forum_v2_enabled_for_thread',
autospec=True
)
mock_forum_v2 = patcher1.start()
mock_forum_v2.return_value = (True, str(self.course.id))
self.addCleanup(patcher1.stop)
# Patch 2
patcher2 = mock.patch(
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
return_value=False
)
patcher2.start()
self.addCleanup(patcher2.stop)
# Patch 3
patcher3 = mock.patch(
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread",
return_value=self.course.id
)
self.mock_get_course_id_by_thread = patcher3.start()
self.addCleanup(patcher3.stop)
self.mock_get_course_id_by_thread = patcher.start()
self.addCleanup(patcher.stop)
# Patch 4
patcher4 = mock.patch(
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment",
return_value=self.course.id
)
self.mock_get_course_id_by_comment = patcher4.start()
self.addCleanup(patcher4.stop)
# Patch 5
patcher5 = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.models.is_forum_v2_enabled_for_comment",
return_value=(True, str(self.course.id))
)
self.mock_is_forum_v2_enabled_for_comment = patcher5.start()
self.addCleanup(patcher5.stop)
self.mock_get_course_id_by_comment = patcher.start()
self.addCleanup(patcher.stop)
self.user_1 = UserFactory.create()
CourseEnrollment.enroll(self.user_1, self.course.id)
@@ -410,20 +383,6 @@ class TestSendCommentNotification(DiscussionAPIViewTestMixin, ModuleStoreTestCas
super().setUp()
httpretty.reset()
httpretty.enable()
patcher = mock.patch(
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
return_value=False
)
patcher.start()
self.addCleanup(patcher.stop)
patcher = mock.patch(
'openedx.core.djangoapps.django_comment_common.comment_client.thread.is_forum_v2_enabled_for_thread',
autospec=True
)
mock_forum_v2 = patcher.start()
mock_forum_v2.return_value = (True, str(self.course.id))
self.addCleanup(patcher.stop)
self.course = CourseFactory.create()
patcher = mock.patch(
@@ -439,13 +398,6 @@ class TestSendCommentNotification(DiscussionAPIViewTestMixin, ModuleStoreTestCas
self.mock_get_course_id_by_comment = patcher.start()
self.addCleanup(patcher.stop)
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.models.is_forum_v2_enabled_for_comment",
return_value=(True, str(self.course.id))
)
self.mock_is_forum_v2_enabled_for_comment = patcher.start()
self.addCleanup(patcher.stop)
self.user_1 = UserFactory.create()
CourseEnrollment.enroll(self.user_1, self.course.id)
self.user_2 = UserFactory.create()

View File

@@ -2,311 +2,40 @@
Tests for Discussion API views
"""
import json
import random
from datetime import datetime
from unittest import mock
from urllib.parse import parse_qs, urlencode, urlparse
from urllib.parse import urlencode
import ddt
import httpretty
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import override_settings
from django.urls import reverse
from edx_toggles.toggles.testutils import override_waffle_flag
from opaque_keys.edx.keys import CourseKey
from pytz import UTC
from rest_framework import status
from rest_framework.test import APIClient, APITestCase
from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE
from lms.djangoapps.discussion.rest_api.utils import get_usernames_from_search_string
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory, check_mongo_calls
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
from common.djangoapps.student.models import get_retired_username_by_username, CourseEnrollment
from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole, GlobalStaff
from common.djangoapps.student.tests.factories import (
AdminFactory,
CourseEnrollmentFactory,
SuperuserFactory,
UserFactory
)
from common.djangoapps.util.testing import UrlResetMixin
from lms.djangoapps.discussion.django_comment_client.tests.utils import (
ForumsEnableMixin,
config_course_discussions,
topic_name_to_id,
)
from lms.djangoapps.discussion.rest_api.tests.utils import (
CommentsServiceMockMixin,
ForumMockUtilsMixin,
make_minimal_cs_comment,
make_minimal_cs_thread,
)
from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts
from openedx.core.djangoapps.discussions.config.waffle import ENABLE_NEW_STRUCTURE_DISCUSSIONS
from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, DiscussionTopicLink, Provider
from openedx.core.djangoapps.discussions.tasks import update_discussions_settings_from_course_task
from openedx.core.djangoapps.django_comment_common.models import (
CourseDiscussionSettings,
Role,
)
from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles
from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user
from openedx.core.djangoapps.oauth_dispatch.tests.factories import AccessTokenFactory, ApplicationFactory
from openedx.core.djangoapps.user_api.models import RetirementState, UserRetirementStatus
class DiscussionAPIViewTestMixin(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMixin):
"""
Mixin for common code in tests of Discussion API views. This includes
creation of common structures (e.g. a course, user, and enrollment), logging
in the test client, utility functions, and a test case for unauthenticated
requests. Subclasses must set self.url in their setUp methods.
"""
client_class = APIClient
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
self.maxDiff = None # pylint: disable=invalid-name
self.course = CourseFactory.create(
org="x",
course="y",
run="z",
start=datetime.now(UTC),
discussion_topics={"Test Topic": {"id": "test_topic"}}
)
self.password = "Password1234"
self.user = UserFactory.create(password=self.password)
# Ensure that parental controls don't apply to this user
self.user.profile.year_of_birth = 1970
self.user.profile.save()
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
self.client.login(username=self.user.username, password=self.password)
def assert_response_correct(self, response, expected_status, expected_content):
"""
Assert that the response has the given status code and parsed content
"""
assert response.status_code == expected_status
parsed_content = json.loads(response.content.decode('utf-8'))
assert parsed_content == expected_content
def register_thread(self, overrides=None):
"""
Create cs_thread with minimal fields and register response
"""
cs_thread = make_minimal_cs_thread({
"id": "test_thread",
"course_id": str(self.course.id),
"commentable_id": "test_topic",
"username": self.user.username,
"user_id": str(self.user.id),
"thread_type": "discussion",
"title": "Test Title",
"body": "Test body",
})
cs_thread.update(overrides or {})
self.register_get_thread_response(cs_thread)
self.register_put_thread_response(cs_thread)
def register_comment(self, overrides=None):
"""
Create cs_comment with minimal fields and register response
"""
cs_comment = make_minimal_cs_comment({
"id": "test_comment",
"course_id": str(self.course.id),
"thread_id": "test_thread",
"username": self.user.username,
"user_id": str(self.user.id),
"body": "Original body",
})
cs_comment.update(overrides or {})
self.register_get_comment_response(cs_comment)
self.register_put_comment_response(cs_comment)
self.register_post_comment_response(cs_comment, thread_id="test_thread")
def test_not_authenticated(self):
self.client.logout()
response = self.client.get(self.url)
self.assert_response_correct(
response,
401,
{"developer_message": "Authentication credentials were not provided."}
)
def test_inactive(self):
self.user.is_active = False
self.test_basic()
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class UploadFileViewTest(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTestCase):
"""
Tests for UploadFileView.
"""
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
self.valid_file = {
"uploaded_file": SimpleUploadedFile(
"test.jpg",
b"test content",
content_type="image/jpeg",
),
}
self.user = UserFactory.create(password=self.TEST_PASSWORD)
self.course = CourseFactory.create(org='a', course='b', run='c', start=datetime.now(UTC))
self.url = reverse("upload_file", kwargs={"course_id": str(self.course.id)})
patcher = mock.patch(
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
return_value=False
)
patcher.start()
self.addCleanup(patcher.stop)
def user_login(self):
"""
Authenticates the test client with the example user.
"""
self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
def enroll_user_in_course(self):
"""
Makes the example user enrolled to the course.
"""
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
def assert_upload_success(self, response):
"""
Asserts that the upload response was successful and returned the
expected contents.
"""
assert response.status_code == status.HTTP_200_OK
assert response.content_type == "application/json"
response_data = json.loads(response.content)
assert "location" in response_data
def test_file_upload_by_unauthenticated_user(self):
"""
Should fail if an unauthenticated user tries to upload a file.
"""
response = self.client.post(self.url, self.valid_file)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_file_upload_by_unauthorized_user(self):
"""
Should fail if the user is not either staff or a student
enrolled in the course.
"""
self.user_login()
response = self.client.post(self.url, self.valid_file)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_file_upload_by_enrolled_user(self):
"""
Should succeed when a valid file is uploaded by an authenticated
user who's enrolled in the course.
"""
self.user_login()
self.enroll_user_in_course()
response = self.client.post(self.url, self.valid_file)
self.assert_upload_success(response)
def test_file_upload_by_global_staff(self):
"""
Should succeed when a valid file is uploaded by a global staff
member.
"""
self.user_login()
GlobalStaff().add_users(self.user)
response = self.client.post(self.url, self.valid_file)
self.assert_upload_success(response)
def test_file_upload_by_instructor(self):
"""
Should succeed when a valid file is uploaded by a course instructor.
"""
self.user_login()
CourseInstructorRole(course_key=self.course.id).add_users(self.user)
response = self.client.post(self.url, self.valid_file)
self.assert_upload_success(response)
def test_file_upload_by_course_staff(self):
"""
Should succeed when a valid file is uploaded by a course staff
member.
"""
self.user_login()
CourseStaffRole(course_key=self.course.id).add_users(self.user)
response = self.client.post(self.url, self.valid_file)
self.assert_upload_success(response)
def test_file_upload_with_thread_key(self):
"""
Should contain the given thread_key in the uploaded file name.
"""
self.user_login()
self.enroll_user_in_course()
response = self.client.post(self.url, {
**self.valid_file,
"thread_key": "somethread",
})
response_data = json.loads(response.content)
assert "/somethread/" in response_data["location"]
def test_file_upload_with_invalid_file(self):
"""
Should fail if the uploaded file format is not allowed.
"""
self.user_login()
self.enroll_user_in_course()
invalid_file = {
"uploaded_file": SimpleUploadedFile(
"test.txt",
b"test content",
content_type="text/plain",
),
}
response = self.client.post(self.url, invalid_file)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_file_upload_with_invalid_course_id(self):
"""
Should fail if the course does not exist.
"""
self.user_login()
self.enroll_user_in_course()
url = reverse("upload_file", kwargs={"course_id": "d/e/f"})
response = self.client.post(url, self.valid_file)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_file_upload_with_no_data(self):
"""
Should fail when the user sends a request missing an
`uploaded_file` field.
"""
self.user_login()
self.enroll_user_in_course()
response = self.client.post(self.url, data={})
assert response.status_code == status.HTTP_400_BAD_REQUEST
@ddt.ddt
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_FORUM_V2": False})
class CommentViewSetListByUserTest(
ForumsEnableMixin,
CommentsServiceMockMixin,
ForumMockUtilsMixin,
UrlResetMixin,
ModuleStoreTestCase,
):
@@ -314,21 +43,20 @@ class CommentViewSetListByUserTest(
Common test cases for views retrieving user-published content.
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
super().setUpClassAndForumMock()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
super().disposeForumMocks()
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
httpretty.reset()
httpretty.enable()
self.addCleanup(httpretty.reset)
self.addCleanup(httpretty.disable)
patcher = mock.patch(
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
return_value=False
)
patcher.start()
self.addCleanup(patcher.stop)
self.user = UserFactory.create(password=self.TEST_PASSWORD)
self.register_get_user_response(self.user)
@@ -498,1530 +226,3 @@ class CommentViewSetListByUserTest(
url = self.build_url(self.user.username, self.course.id, page=2)
response = self.client.get(url)
assert response.status_code == status.HTTP_404_NOT_FOUND
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
@override_settings(DISCUSSION_MODERATION_EDIT_REASON_CODES={"test-edit-reason": "Test Edit Reason"})
@override_settings(DISCUSSION_MODERATION_CLOSE_REASON_CODES={"test-close-reason": "Test Close Reason"})
class CourseViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
"""Tests for CourseView"""
def setUp(self):
super().setUp()
self.url = reverse("discussion_course", kwargs={"course_id": str(self.course.id)})
patcher = mock.patch(
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
return_value=False
)
patcher.start()
self.addCleanup(patcher.stop)
def test_404(self):
response = self.client.get(
reverse("course_topics", kwargs={"course_id": "non/existent/course"})
)
self.assert_response_correct(
response,
404,
{"developer_message": "Course not found."}
)
def test_basic(self):
response = self.client.get(self.url)
self.assert_response_correct(
response,
200,
{
"id": str(self.course.id),
"is_posting_enabled": True,
"blackouts": [],
"thread_list_url": "http://testserver/api/discussion/v1/threads/?course_id=course-v1%3Ax%2By%2Bz",
"following_thread_list_url": (
"http://testserver/api/discussion/v1/threads/?course_id=course-v1%3Ax%2By%2Bz&following=True"
),
"topics_url": "http://testserver/api/discussion/v1/course_topics/course-v1:x+y+z",
"enable_in_context": True,
"group_at_subsection": False,
"provider": "legacy",
"allow_anonymous": True,
"allow_anonymous_to_peers": False,
"has_bulk_delete_privileges": False,
"has_moderation_privileges": False,
'is_course_admin': False,
'is_course_staff': False,
"is_group_ta": False,
'is_user_admin': False,
"user_roles": ["Student"],
"edit_reasons": [{"code": "test-edit-reason", "label": "Test Edit Reason"}],
"post_close_reasons": [{"code": "test-close-reason", "label": "Test Close Reason"}],
'show_discussions': True,
'is_notify_all_learners_enabled': False,
'captcha_settings': {
'enabled': False,
'site_key': None,
},
"is_email_verified": True,
"only_verified_users_can_post": False,
"content_creation_rate_limited": False
}
)
@httpretty.activate
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class RetireViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
"""Tests for CourseView"""
def setUp(self):
super().setUp()
RetirementState.objects.create(state_name='PENDING', state_execution_order=1)
self.retire_forums_state = RetirementState.objects.create(state_name='RETIRE_FORUMS', state_execution_order=11)
self.retirement = UserRetirementStatus.create_retirement(self.user)
self.retirement.current_state = self.retire_forums_state
self.retirement.save()
self.superuser = SuperuserFactory()
self.superuser_client = APIClient()
self.retired_username = get_retired_username_by_username(self.user.username)
self.url = reverse("retire_discussion_user")
patcher = mock.patch(
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
return_value=False
)
patcher.start()
self.addCleanup(patcher.stop)
def assert_response_correct(self, response, expected_status, expected_content):
"""
Assert that the response has the given status code and content
"""
assert response.status_code == expected_status
if expected_content:
assert response.content.decode('utf-8') == expected_content
def build_jwt_headers(self, user):
"""
Helper function for creating headers for the JWT authentication.
"""
token = create_jwt_for_user(user)
headers = {'HTTP_AUTHORIZATION': 'JWT ' + token}
return headers
def test_basic(self):
"""
Check successful retirement case
"""
self.register_get_user_retire_response(self.user)
headers = self.build_jwt_headers(self.superuser)
data = {'username': self.user.username}
response = self.superuser_client.post(self.url, data, **headers)
self.assert_response_correct(response, 204, b"")
def test_downstream_forums_error(self):
"""
Check that we bubble up errors from the comments service
"""
self.register_get_user_retire_response(self.user, status=500, body="Server error")
headers = self.build_jwt_headers(self.superuser)
data = {'username': self.user.username}
response = self.superuser_client.post(self.url, data, **headers)
self.assert_response_correct(response, 500, '"Server error"')
def test_nonexistent_user(self):
"""
Check that we handle unknown users appropriately
"""
nonexistent_username = "nonexistent user"
self.retired_username = get_retired_username_by_username(nonexistent_username)
data = {'username': nonexistent_username}
headers = self.build_jwt_headers(self.superuser)
response = self.superuser_client.post(self.url, data, **headers)
self.assert_response_correct(response, 404, None)
def test_not_authenticated(self):
"""
Override the parent implementation of this, we JWT auth for this API
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
@ddt.ddt
@httpretty.activate
@mock.patch('django.conf.settings.USERNAME_REPLACEMENT_WORKER', 'test_replace_username_service_worker')
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class ReplaceUsernamesViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
"""Tests for ReplaceUsernamesView"""
def setUp(self):
super().setUp()
self.worker = UserFactory()
self.worker.username = "test_replace_username_service_worker"
self.worker_client = APIClient()
self.new_username = "test_username_replacement"
self.url = reverse("replace_discussion_username")
patcher = mock.patch(
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
return_value=False
)
patcher.start()
self.addCleanup(patcher.stop)
def assert_response_correct(self, response, expected_status, expected_content):
"""
Assert that the response has the given status code and content
"""
assert response.status_code == expected_status
if expected_content:
assert str(response.content) == expected_content
def build_jwt_headers(self, user):
"""
Helper function for creating headers for the JWT authentication.
"""
token = create_jwt_for_user(user)
headers = {'HTTP_AUTHORIZATION': 'JWT ' + token}
return headers
def call_api(self, user, client, data):
""" Helper function to call API with data """
data = json.dumps(data)
headers = self.build_jwt_headers(user)
return client.post(self.url, data, content_type='application/json', **headers)
@ddt.data(
[{}, {}],
{},
[{"test_key": "test_value", "test_key_2": "test_value_2"}]
)
def test_bad_schema(self, mapping_data):
""" Verify the endpoint rejects bad data schema """
data = {
"username_mappings": mapping_data
}
response = self.call_api(self.worker, self.worker_client, data)
assert response.status_code == 400
def test_auth(self):
""" Verify the endpoint only works with the service worker """
data = {
"username_mappings": [
{"test_username_1": "test_new_username_1"},
{"test_username_2": "test_new_username_2"}
]
}
# Test unauthenticated
response = self.client.post(self.url, data)
assert response.status_code == 403
# Test non-service worker
random_user = UserFactory()
response = self.call_api(random_user, APIClient(), data)
assert response.status_code == 403
# Test service worker
response = self.call_api(self.worker, self.worker_client, data)
assert response.status_code == 200
def test_basic(self):
""" Check successful replacement """
data = {
"username_mappings": [
{self.user.username: self.new_username},
]
}
expected_response = {
'failed_replacements': [],
'successful_replacements': data["username_mappings"]
}
self.register_get_username_replacement_response(self.user)
response = self.call_api(self.worker, self.worker_client, data)
assert response.status_code == 200
assert response.data == expected_response
def test_not_authenticated(self):
"""
Override the parent implementation of this, we JWT auth for this API
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
@ddt.ddt
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class CourseTopicsViewTest(DiscussionAPIViewTestMixin, CommentsServiceMockMixin, ModuleStoreTestCase):
"""
Tests for CourseTopicsView
"""
def setUp(self):
httpretty.reset()
httpretty.enable()
self.addCleanup(httpretty.reset)
self.addCleanup(httpretty.disable)
super().setUp()
self.url = reverse("course_topics", kwargs={"course_id": str(self.course.id)})
self.thread_counts_map = {
"courseware-1": {"discussion": 2, "question": 3},
"courseware-2": {"discussion": 4, "question": 5},
"courseware-3": {"discussion": 7, "question": 2},
}
self.register_get_course_commentable_counts_response(self.course.id, self.thread_counts_map)
patcher = mock.patch(
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
return_value=False
)
patcher.start()
self.addCleanup(patcher.stop)
def create_course(self, blocks_count, module_store, topics):
"""
Create a course in a specified module store with discussion xblocks and topics
"""
course = CourseFactory.create(
org="a",
course="b",
run="c",
start=datetime.now(UTC),
default_store=module_store,
discussion_topics=topics
)
CourseEnrollmentFactory.create(user=self.user, course_id=course.id)
course_url = reverse("course_topics", kwargs={"course_id": str(course.id)})
# add some discussion xblocks
for i in range(blocks_count):
BlockFactory.create(
parent_location=course.location,
category='discussion',
discussion_id=f'id_module_{i}',
discussion_category=f'Category {i}',
discussion_target=f'Discussion {i}',
publish_item=False,
)
return course_url, course.id
def make_discussion_xblock(self, topic_id, category, subcategory, **kwargs):
"""
Build a discussion xblock in self.course
"""
BlockFactory.create(
parent_location=self.course.location,
category="discussion",
discussion_id=topic_id,
discussion_category=category,
discussion_target=subcategory,
**kwargs
)
def test_404(self):
response = self.client.get(
reverse("course_topics", kwargs={"course_id": "non/existent/course"})
)
self.assert_response_correct(
response,
404,
{"developer_message": "Course not found."}
)
def test_basic(self):
response = self.client.get(self.url)
self.assert_response_correct(
response,
200,
{
"courseware_topics": [],
"non_courseware_topics": [{
"id": "test_topic",
"name": "Test Topic",
"children": [],
"thread_list_url": 'http://testserver/api/discussion/v1/threads/'
'?course_id=course-v1%3Ax%2By%2Bz&topic_id=test_topic',
"thread_counts": {"discussion": 0, "question": 0},
}],
}
)
@ddt.data(
(2, ModuleStoreEnum.Type.split, 2, {"Test Topic 1": {"id": "test_topic_1"}}),
(2, ModuleStoreEnum.Type.split, 2,
{"Test Topic 1": {"id": "test_topic_1"}, "Test Topic 2": {"id": "test_topic_2"}}),
(10, ModuleStoreEnum.Type.split, 2, {"Test Topic 1": {"id": "test_topic_1"}}),
)
@ddt.unpack
def test_bulk_response(self, blocks_count, module_store, mongo_calls, topics):
course_url, course_id = self.create_course(blocks_count, module_store, topics)
self.register_get_course_commentable_counts_response(course_id, {})
with check_mongo_calls(mongo_calls):
with modulestore().default_store(module_store):
self.client.get(course_url)
def test_discussion_topic_404(self):
"""
Tests discussion topic does not exist for the given topic id.
"""
topic_id = "courseware-topic-id"
self.make_discussion_xblock(topic_id, "test_category", "test_target")
url = f"{self.url}?topic_id=invalid_topic_id"
response = self.client.get(url)
self.assert_response_correct(
response,
404,
{"developer_message": "Discussion not found for 'invalid_topic_id'."}
)
def test_topic_id(self):
"""
Tests discussion topic details against a requested topic id
"""
topic_id_1 = "topic_id_1"
topic_id_2 = "topic_id_2"
self.make_discussion_xblock(topic_id_1, "test_category_1", "test_target_1")
self.make_discussion_xblock(topic_id_2, "test_category_2", "test_target_2")
url = f"{self.url}?topic_id=topic_id_1,topic_id_2"
response = self.client.get(url)
self.assert_response_correct(
response,
200,
{
"non_courseware_topics": [],
"courseware_topics": [
{
"children": [{
"children": [],
"id": "topic_id_1",
"thread_list_url": "http://testserver/api/discussion/v1/threads/?"
"course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1",
"name": "test_target_1",
"thread_counts": {"discussion": 0, "question": 0},
}],
"id": None,
"thread_list_url": "http://testserver/api/discussion/v1/threads/?"
"course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1",
"name": "test_category_1",
"thread_counts": None,
},
{
"children":
[{
"children": [],
"id": "topic_id_2",
"thread_list_url": "http://testserver/api/discussion/v1/threads/?"
"course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2",
"name": "test_target_2",
"thread_counts": {"discussion": 0, "question": 0},
}],
"id": None,
"thread_list_url": "http://testserver/api/discussion/v1/threads/?"
"course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2",
"name": "test_category_2",
"thread_counts": None,
}
]
}
)
@override_waffle_flag(ENABLE_NEW_STRUCTURE_DISCUSSIONS, True)
def test_new_course_structure_response(self):
"""
Tests whether the new structure is available on old topics API
(For mobile compatibility)
"""
chapter = BlockFactory.create(
parent_location=self.course.location,
category='chapter',
display_name="Week 1",
start=datetime(2015, 3, 1, tzinfo=UTC),
)
sequential = BlockFactory.create(
parent_location=chapter.location,
category='sequential',
display_name="Lesson 1",
start=datetime(2015, 3, 1, tzinfo=UTC),
)
BlockFactory.create(
parent_location=sequential.location,
category='vertical',
display_name='vertical',
start=datetime(2015, 4, 1, tzinfo=UTC),
)
DiscussionsConfiguration.objects.create(
context_key=self.course.id,
provider_type=Provider.OPEN_EDX
)
update_discussions_settings_from_course_task(str(self.course.id))
response = json.loads(self.client.get(self.url).content.decode())
keys = ['children', 'id', 'name', 'thread_counts', 'thread_list_url']
assert list(response.keys()) == ['courseware_topics', 'non_courseware_topics']
assert len(response['courseware_topics']) == 1
courseware_keys = list(response['courseware_topics'][0].keys())
courseware_keys.sort()
assert courseware_keys == keys
assert len(response['non_courseware_topics']) == 1
non_courseware_keys = list(response['non_courseware_topics'][0].keys())
non_courseware_keys.sort()
assert non_courseware_keys == keys
@ddt.ddt
@mock.patch('lms.djangoapps.discussion.rest_api.api._get_course', mock.Mock())
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
@override_waffle_flag(ENABLE_NEW_STRUCTURE_DISCUSSIONS, True)
class CourseTopicsViewV3Test(DiscussionAPIViewTestMixin, CommentsServiceMockMixin, ModuleStoreTestCase):
"""
Tests for CourseTopicsViewV3
"""
def setUp(self) -> None:
super().setUp()
self.password = self.TEST_PASSWORD
self.user = UserFactory.create(password=self.password)
self.client.login(username=self.user.username, password=self.password)
self.staff = AdminFactory.create()
self.course = CourseFactory.create(
start=datetime(2020, 1, 1),
end=datetime(2028, 1, 1),
enrollment_start=datetime(2020, 1, 1),
enrollment_end=datetime(2028, 1, 1),
discussion_topics={"Course Wide Topic": {
"id": 'course-wide-topic',
"usage_key": None,
}}
)
self.chapter = BlockFactory.create(
parent_location=self.course.location,
category='chapter',
display_name="Week 1",
start=datetime(2015, 3, 1, tzinfo=UTC),
)
self.sequential = BlockFactory.create(
parent_location=self.chapter.location,
category='sequential',
display_name="Lesson 1",
start=datetime(2015, 3, 1, tzinfo=UTC),
)
self.verticals = [
BlockFactory.create(
parent_location=self.sequential.location,
category='vertical',
display_name='vertical',
start=datetime(2015, 4, 1, tzinfo=UTC),
)
]
course_key = self.course.id
self.config = DiscussionsConfiguration.objects.create(context_key=course_key, provider_type=Provider.OPEN_EDX)
topic_links = []
update_discussions_settings_from_course_task(str(course_key))
topic_id_query = DiscussionTopicLink.objects.filter(context_key=course_key).values_list(
'external_id', flat=True,
)
topic_ids = list(topic_id_query.order_by('ordering'))
DiscussionTopicLink.objects.bulk_create(topic_links)
self.topic_stats = {
**{topic_id: dict(discussion=random.randint(0, 10), question=random.randint(0, 10))
for topic_id in set(topic_ids)},
topic_ids[0]: dict(discussion=0, question=0),
}
patcher = mock.patch(
'lms.djangoapps.discussion.rest_api.api.get_course_commentable_counts',
mock.Mock(return_value=self.topic_stats),
)
patcher.start()
self.addCleanup(patcher.stop)
self.url = reverse("course_topics_v3", kwargs={"course_id": str(self.course.id)})
patcher = mock.patch(
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
return_value=False
)
patcher.start()
self.addCleanup(patcher.stop)
def test_basic(self):
response = self.client.get(self.url)
data = json.loads(response.content.decode())
expected_non_courseware_keys = [
'id', 'usage_key', 'name', 'thread_counts', 'enabled_in_context',
'courseware'
]
expected_courseware_keys = [
'id', 'block_id', 'lms_web_url', 'legacy_web_url', 'student_view_url',
'type', 'display_name', 'children', 'courseware'
]
assert response.status_code == 200
assert len(data) == 2
non_courseware_topic_keys = list(data[0].keys())
assert non_courseware_topic_keys == expected_non_courseware_keys
courseware_topic_keys = list(data[1].keys())
assert courseware_topic_keys == expected_courseware_keys
expected_courseware_keys.remove('courseware')
sequential_keys = list(data[1]['children'][0].keys())
assert sequential_keys == (expected_courseware_keys + ['thread_counts'])
expected_non_courseware_keys.remove('courseware')
vertical_keys = list(data[1]['children'][0]['children'][0].keys())
assert vertical_keys == expected_non_courseware_keys
@ddt.ddt
@httpretty.activate
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class LearnerThreadViewAPITest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
"""Tests for LearnerThreadView list"""
def setUp(self):
"""
Sets up the test case
"""
super().setUp()
self.author = self.user
self.remove_keys = [
"abuse_flaggers",
"body",
"children",
"commentable_id",
"endorsed",
"last_activity_at",
"resp_total",
"thread_type",
"user_id",
"username",
"votes",
]
self.replace_keys = [
{"from": "unread_comments_count", "to": "unread_comment_count"},
{"from": "comments_count", "to": "comment_count"},
]
self.add_keys = [
{"key": "author", "value": self.author.username},
{"key": "abuse_flagged", "value": False},
{"key": "author_label", "value": None},
{"key": "can_delete", "value": True},
{"key": "close_reason", "value": None},
{
"key": "comment_list_url",
"value": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread"
},
{
"key": "editable_fields",
"value": [
'abuse_flagged', 'anonymous', 'copy_link', 'following', 'raw_body',
'read', 'title', 'topic_id', 'type'
]
},
{"key": "endorsed_comment_list_url", "value": None},
{"key": "following", "value": False},
{"key": "group_name", "value": None},
{"key": "has_endorsed", "value": False},
{"key": "last_edit", "value": None},
{"key": "non_endorsed_comment_list_url", "value": None},
{"key": "preview_body", "value": "Test body"},
{"key": "raw_body", "value": "Test body"},
{"key": "rendered_body", "value": "<p>Test body</p>"},
{"key": "response_count", "value": 0},
{"key": "topic_id", "value": "test_topic"},
{"key": "type", "value": "discussion"},
{"key": "users", "value": {
self.user.username: {
"profile": {
"image": {
"has_image": False,
"image_url_full": "http://testserver/static/default_500.png",
"image_url_large": "http://testserver/static/default_120.png",
"image_url_medium": "http://testserver/static/default_50.png",
"image_url_small": "http://testserver/static/default_30.png",
}
}
}
}},
{"key": "vote_count", "value": 4},
{"key": "voted", "value": False},
]
self.url = reverse("discussion_learner_threads", kwargs={'course_id': str(self.course.id)})
patcher = mock.patch(
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
return_value=False
)
patcher.start()
self.addCleanup(patcher.stop)
def update_thread(self, thread):
"""
This function updates the thread by adding and remove some keys.
Value of these keys has been defined in setUp function
"""
for element in self.add_keys:
thread[element['key']] = element['value']
for pair in self.replace_keys:
thread[pair['to']] = thread.pop(pair['from'])
for key in self.remove_keys:
thread.pop(key)
thread['comment_count'] += 1
return thread
def test_basic(self):
"""
Tests the data is fetched correctly
Note: test_basic is required as the name because DiscussionAPIViewTestMixin
calls this test case automatically
"""
self.register_get_user_response(self.user)
expected_cs_comments_response = {
"collection": [make_minimal_cs_thread({
"id": "test_thread",
"course_id": str(self.course.id),
"commentable_id": "test_topic",
"user_id": str(self.user.id),
"username": self.user.username,
"created_at": "2015-04-28T00:00:00Z",
"updated_at": "2015-04-28T11:11:11Z",
"title": "Test Title",
"body": "Test body",
"votes": {"up_count": 4},
"comments_count": 5,
"unread_comments_count": 3,
"closed_by_label": None,
"edit_by_label": None,
})],
"page": 1,
"num_pages": 1,
}
self.register_user_active_threads(self.user.id, expected_cs_comments_response)
self.url += f"?username={self.user.username}"
response = self.client.get(self.url)
assert response.status_code == 200
response_data = json.loads(response.content.decode('utf-8'))
expected_api_response = expected_cs_comments_response['collection']
for thread in expected_api_response:
self.update_thread(thread)
assert response_data['results'] == expected_api_response
assert response_data['pagination'] == {
"next": None,
"previous": None,
"count": 1,
"num_pages": 1,
}
def test_no_username_given(self):
"""
Tests that 404 response is returned when no username is passed
"""
response = self.client.get(self.url)
assert response.status_code == 404
def test_not_authenticated(self):
"""
This test is called by DiscussionAPIViewTestMixin and is not required in
our case
"""
assert True
@ddt.data("None", "discussion", "question")
def test_thread_type_by(self, thread_type):
"""
Tests the thread_type parameter
Arguments:
thread_type (str): Value of thread_type can be 'None',
'discussion' and 'question'
"""
threads = [make_minimal_cs_thread({
"id": "test_thread",
"course_id": str(self.course.id),
"commentable_id": "test_topic",
"user_id": str(self.user.id),
"username": self.user.username,
"created_at": "2015-04-28T00:00:00Z",
"updated_at": "2015-04-28T11:11:11Z",
"title": "Test Title",
"body": "Test body",
"votes": {"up_count": 4},
"comments_count": 5,
"unread_comments_count": 3,
})]
expected_cs_comments_response = {
"collection": threads,
"page": 1,
"num_pages": 1,
}
self.register_get_user_response(self.user)
self.register_user_active_threads(self.user.id, expected_cs_comments_response)
response = self.client.get(
self.url,
{
"course_id": str(self.course.id),
"username": self.user.username,
"thread_type": thread_type,
}
)
assert response.status_code == 200
self.assert_last_query_params({
"user_id": [str(self.user.id)],
"course_id": [str(self.course.id)],
"page": ["1"],
"per_page": ["10"],
"thread_type": [thread_type],
"sort_key": ['activity'],
"count_flagged": ["False"]
})
@ddt.data(
("last_activity_at", "activity"),
("comment_count", "comments"),
("vote_count", "votes")
)
@ddt.unpack
def test_order_by(self, http_query, cc_query):
"""
Tests the order_by parameter for active threads
Arguments:
http_query (str): Query string sent in the http request
cc_query (str): Query string used for the comments client service
"""
threads = [make_minimal_cs_thread({
"id": "test_thread",
"course_id": str(self.course.id),
"commentable_id": "test_topic",
"user_id": str(self.user.id),
"username": self.user.username,
"created_at": "2015-04-28T00:00:00Z",
"updated_at": "2015-04-28T11:11:11Z",
"title": "Test Title",
"body": "Test body",
"votes": {"up_count": 4},
"comments_count": 5,
"unread_comments_count": 3,
})]
expected_cs_comments_response = {
"collection": threads,
"page": 1,
"num_pages": 1,
}
self.register_get_user_response(self.user)
self.register_user_active_threads(self.user.id, expected_cs_comments_response)
response = self.client.get(
self.url,
{
"course_id": str(self.course.id),
"username": self.user.username,
"order_by": http_query,
}
)
assert response.status_code == 200
self.assert_last_query_params({
"user_id": [str(self.user.id)],
"course_id": [str(self.course.id)],
"page": ["1"],
"per_page": ["10"],
"sort_key": [cc_query],
"count_flagged": ["False"]
})
@ddt.data("flagged", "unanswered", "unread", "unresponded")
def test_status_by(self, post_status):
"""
Tests the post_status parameter
Arguments:
post_status (str): Value of post_status can be 'flagged',
'unanswered' and 'unread'
"""
threads = [make_minimal_cs_thread({
"id": "test_thread",
"course_id": str(self.course.id),
"commentable_id": "test_topic",
"user_id": str(self.user.id),
"username": self.user.username,
"created_at": "2015-04-28T00:00:00Z",
"updated_at": "2015-04-28T11:11:11Z",
"title": "Test Title",
"body": "Test body",
"votes": {"up_count": 4},
"comments_count": 5,
"unread_comments_count": 3,
})]
expected_cs_comments_response = {
"collection": threads,
"page": 1,
"num_pages": 1,
}
self.register_get_user_response(self.user)
self.register_user_active_threads(self.user.id, expected_cs_comments_response)
response = self.client.get(
self.url,
{
"course_id": str(self.course.id),
"username": self.user.username,
"status": post_status,
}
)
if post_status == "flagged":
assert response.status_code == 403
else:
assert response.status_code == 200
self.assert_last_query_params({
"user_id": [str(self.user.id)],
"course_id": [str(self.course.id)],
"page": ["1"],
"per_page": ["10"],
post_status: ['True'],
"sort_key": ['activity'],
"count_flagged": ["False"]
})
@ddt.ddt
class CourseDiscussionSettingsAPIViewTest(APITestCase, UrlResetMixin, ModuleStoreTestCase):
"""
Test the course discussion settings handler API endpoint.
"""
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
self.course = CourseFactory.create(
org="x",
course="y",
run="z",
start=datetime.now(UTC),
discussion_topics={"Test Topic": {"id": "test_topic"}}
)
self.path = reverse('discussion_course_settings', kwargs={'course_id': str(self.course.id)})
self.password = self.TEST_PASSWORD
self.user = UserFactory(username='staff', password=self.password, is_staff=True)
patcher = mock.patch(
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
return_value=False
)
patcher.start()
self.addCleanup(patcher.stop)
def _get_oauth_headers(self, user):
"""Return the OAuth headers for testing OAuth authentication"""
access_token = AccessTokenFactory.create(user=user, application=ApplicationFactory()).token
headers = {
'HTTP_AUTHORIZATION': 'Bearer ' + access_token
}
return headers
def _login_as_staff(self):
"""Log the client in as the staff."""
self.client.login(username=self.user.username, password=self.password)
def _login_as_discussion_staff(self):
user = UserFactory(username='abc', password='abc')
role = Role.objects.create(name='Administrator', course_id=self.course.id)
role.users.set([user])
self.client.login(username=user.username, password='abc')
def _create_divided_discussions(self):
"""Create some divided discussions for testing."""
divided_inline_discussions = ['Topic A', ]
divided_course_wide_discussions = ['Topic B', ]
divided_discussions = divided_inline_discussions + divided_course_wide_discussions
BlockFactory.create(
parent=self.course,
category='discussion',
discussion_id=topic_name_to_id(self.course, 'Topic A'),
discussion_category='Chapter',
discussion_target='Discussion',
start=datetime.now()
)
discussion_topics = {
"Topic B": {"id": "Topic B"},
}
config_course_cohorts(self.course, is_cohorted=True)
config_course_discussions(
self.course,
discussion_topics=discussion_topics,
divided_discussions=divided_discussions
)
return divided_inline_discussions, divided_course_wide_discussions
def _get_expected_response(self):
"""Return the default expected response before any changes to the discussion settings."""
return {
'always_divide_inline_discussions': False,
'divided_inline_discussions': [],
'divided_course_wide_discussions': [],
'id': 1,
'division_scheme': 'cohort',
'available_division_schemes': ['cohort'],
'reported_content_email_notifications': False,
}
def patch_request(self, data, headers=None):
headers = headers if headers else {}
return self.client.patch(self.path, json.dumps(data), content_type='application/merge-patch+json', **headers)
def _assert_current_settings(self, expected_response):
"""Validate the current discussion settings against the expected response."""
response = self.client.get(self.path)
assert response.status_code == 200
content = json.loads(response.content.decode('utf-8'))
assert content == expected_response
def _assert_patched_settings(self, data, expected_response):
"""Validate the patched settings against the expected response."""
response = self.patch_request(data)
assert response.status_code == 204
self._assert_current_settings(expected_response)
@ddt.data('get', 'patch')
def test_authentication_required(self, method):
"""Test and verify that authentication is required for this endpoint."""
self.client.logout()
response = getattr(self.client, method)(self.path)
assert response.status_code == 401
@ddt.data(
{'is_staff': False, 'get_status': 403, 'put_status': 403},
{'is_staff': True, 'get_status': 200, 'put_status': 204},
)
@ddt.unpack
def test_oauth(self, is_staff, get_status, put_status):
"""Test that OAuth authentication works for this endpoint."""
user = UserFactory(is_staff=is_staff)
headers = self._get_oauth_headers(user)
self.client.logout()
response = self.client.get(self.path, **headers)
assert response.status_code == get_status
response = self.patch_request(
{'always_divide_inline_discussions': True}, headers
)
assert response.status_code == put_status
def test_non_existent_course_id(self):
"""Test the response when this endpoint is passed a non-existent course id."""
self._login_as_staff()
response = self.client.get(
reverse('discussion_course_settings', kwargs={
'course_id': 'course-v1:a+b+c'
})
)
assert response.status_code == 404
def test_patch_request_by_discussion_staff(self):
"""Test the response when patch request is sent by a user with discussions staff role."""
self._login_as_discussion_staff()
response = self.patch_request(
{'always_divide_inline_discussions': True}
)
assert response.status_code == 403
def test_get_request_by_discussion_staff(self):
"""Test the response when get request is sent by a user with discussions staff role."""
self._login_as_discussion_staff()
divided_inline_discussions, divided_course_wide_discussions = self._create_divided_discussions()
response = self.client.get(self.path)
assert response.status_code == 200
expected_response = self._get_expected_response()
expected_response['divided_course_wide_discussions'] = [
topic_name_to_id(self.course, name) for name in divided_course_wide_discussions
]
expected_response['divided_inline_discussions'] = [
topic_name_to_id(self.course, name) for name in divided_inline_discussions
]
content = json.loads(response.content.decode('utf-8'))
assert content == expected_response
def test_get_request_by_non_staff_user(self):
"""Test the response when get request is sent by a regular user with no staff role."""
user = UserFactory(username='abc', password='abc')
self.client.login(username=user.username, password='abc')
response = self.client.get(self.path)
assert response.status_code == 403
def test_patch_request_by_non_staff_user(self):
"""Test the response when patch request is sent by a regular user with no staff role."""
user = UserFactory(username='abc', password='abc')
self.client.login(username=user.username, password='abc')
response = self.patch_request(
{'always_divide_inline_discussions': True}
)
assert response.status_code == 403
def test_get_settings(self):
"""Test the current discussion settings against the expected response."""
divided_inline_discussions, divided_course_wide_discussions = self._create_divided_discussions()
self._login_as_staff()
response = self.client.get(self.path)
assert response.status_code == 200
expected_response = self._get_expected_response()
expected_response['divided_course_wide_discussions'] = [
topic_name_to_id(self.course, name) for name in divided_course_wide_discussions
]
expected_response['divided_inline_discussions'] = [
topic_name_to_id(self.course, name) for name in divided_inline_discussions
]
content = json.loads(response.content.decode('utf-8'))
assert content == expected_response
def test_available_schemes(self):
"""Test the available division schemes against the expected response."""
config_course_cohorts(self.course, is_cohorted=False)
self._login_as_staff()
expected_response = self._get_expected_response()
expected_response['available_division_schemes'] = []
self._assert_current_settings(expected_response)
CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.AUDIT)
CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.VERIFIED)
expected_response['available_division_schemes'] = [CourseDiscussionSettings.ENROLLMENT_TRACK]
self._assert_current_settings(expected_response)
config_course_cohorts(self.course, is_cohorted=True)
expected_response['available_division_schemes'] = [
CourseDiscussionSettings.COHORT, CourseDiscussionSettings.ENROLLMENT_TRACK
]
self._assert_current_settings(expected_response)
def test_empty_body_patch_request(self):
"""Test the response status code on sending a PATCH request with an empty body or missing fields."""
self._login_as_staff()
response = self.patch_request("")
assert response.status_code == 400
response = self.patch_request({})
assert response.status_code == 400
@ddt.data(
{'abc': 123},
{'divided_course_wide_discussions': 3},
{'divided_inline_discussions': 'a'},
{'always_divide_inline_discussions': ['a']},
{'division_scheme': True}
)
def test_invalid_body_parameters(self, body):
"""Test the response status code on sending a PATCH request with parameters having incorrect types."""
self._login_as_staff()
response = self.patch_request(body)
assert response.status_code == 400
def test_update_always_divide_inline_discussion_settings(self):
"""Test whether the 'always_divide_inline_discussions' setting is updated."""
config_course_cohorts(self.course, is_cohorted=True)
self._login_as_staff()
expected_response = self._get_expected_response()
self._assert_current_settings(expected_response)
expected_response['always_divide_inline_discussions'] = True
self._assert_patched_settings({'always_divide_inline_discussions': True}, expected_response)
def test_update_course_wide_discussion_settings(self):
"""Test whether the 'divided_course_wide_discussions' setting is updated."""
discussion_topics = {
'Topic B': {'id': 'Topic B'}
}
config_course_cohorts(self.course, is_cohorted=True)
config_course_discussions(self.course, discussion_topics=discussion_topics)
expected_response = self._get_expected_response()
self._login_as_staff()
self._assert_current_settings(expected_response)
expected_response['divided_course_wide_discussions'] = [
topic_name_to_id(self.course, "Topic B")
]
self._assert_patched_settings(
{'divided_course_wide_discussions': [topic_name_to_id(self.course, "Topic B")]},
expected_response
)
expected_response['divided_course_wide_discussions'] = []
self._assert_patched_settings(
{'divided_course_wide_discussions': []},
expected_response
)
def test_update_inline_discussion_settings(self):
"""Test whether the 'divided_inline_discussions' setting is updated."""
config_course_cohorts(self.course, is_cohorted=True)
self._login_as_staff()
expected_response = self._get_expected_response()
self._assert_current_settings(expected_response)
now = datetime.now()
BlockFactory.create(
parent_location=self.course.location,
category='discussion',
discussion_id='Topic_A',
discussion_category='Chapter',
discussion_target='Discussion',
start=now
)
expected_response['divided_inline_discussions'] = ['Topic_A', ]
self._assert_patched_settings({'divided_inline_discussions': ['Topic_A']}, expected_response)
expected_response['divided_inline_discussions'] = []
self._assert_patched_settings({'divided_inline_discussions': []}, expected_response)
def test_update_division_scheme(self):
"""Test whether the 'division_scheme' setting is updated."""
config_course_cohorts(self.course, is_cohorted=True)
self._login_as_staff()
expected_response = self._get_expected_response()
self._assert_current_settings(expected_response)
expected_response['division_scheme'] = 'none'
self._assert_patched_settings({'division_scheme': 'none'}, expected_response)
def test_update_reported_content_email_notifications(self):
"""Test whether the 'reported_content_email_notifications' setting is updated."""
config_course_cohorts(self.course, is_cohorted=True)
config_course_discussions(self.course, reported_content_email_notifications=True)
expected_response = self._get_expected_response()
expected_response['reported_content_email_notifications'] = True
self._login_as_staff()
self._assert_current_settings(expected_response)
@ddt.ddt
class CourseDiscussionRolesAPIViewTest(APITestCase, UrlResetMixin, ModuleStoreTestCase):
"""
Test the course discussion roles management endpoint.
"""
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
patcher = mock.patch(
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
return_value=False
)
patcher.start()
self.addCleanup(patcher.stop)
self.course = CourseFactory.create(
org="x",
course="y",
run="z",
start=datetime.now(UTC),
)
self.password = self.TEST_PASSWORD
self.user = UserFactory(username='staff', password=self.password, is_staff=True)
course_key = CourseKey.from_string('course-v1:x+y+z')
seed_permissions_roles(course_key)
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def path(self, course_id=None, role=None):
"""Return the URL path to the endpoint based on the provided arguments."""
course_id = str(self.course.id) if course_id is None else course_id
role = 'Moderator' if role is None else role
return reverse(
'discussion_course_roles',
kwargs={'course_id': course_id, 'rolename': role}
)
def _get_oauth_headers(self, user):
"""Return the OAuth headers for testing OAuth authentication."""
access_token = AccessTokenFactory.create(user=user, application=ApplicationFactory()).token
headers = {
'HTTP_AUTHORIZATION': 'Bearer ' + access_token
}
return headers
def _login_as_staff(self):
"""Log the client is as the staff user."""
self.client.login(username=self.user.username, password=self.password)
def _create_and_enroll_users(self, count):
"""Create 'count' number of users and enroll them in self.course."""
users = []
for _ in range(count):
user = UserFactory()
CourseEnrollmentFactory.create(user=user, course_id=self.course.id)
users.append(user)
return users
def _add_users_to_role(self, users, rolename):
"""Add the given users to the given role."""
role = Role.objects.get(name=rolename, course_id=self.course.id)
for user in users:
role.users.add(user)
def post(self, role, user_id, action):
"""Make a POST request to the endpoint using the provided parameters."""
self._login_as_staff()
return self.client.post(self.path(role=role), {'user_id': user_id, 'action': action})
@ddt.data('get', 'post')
def test_authentication_required(self, method):
"""Test and verify that authentication is required for this endpoint."""
self.client.logout()
response = getattr(self.client, method)(self.path())
assert response.status_code == 401
def test_oauth(self):
"""Test that OAuth authentication works for this endpoint."""
oauth_headers = self._get_oauth_headers(self.user)
self.client.logout()
response = self.client.get(self.path(), **oauth_headers)
assert response.status_code == 200
body = {'user_id': 'staff', 'action': 'allow'}
response = self.client.post(self.path(), body, format='json', **oauth_headers)
assert response.status_code == 200
@ddt.data(
{'username': 'u1', 'is_staff': False, 'expected_status': 403},
{'username': 'u2', 'is_staff': True, 'expected_status': 200},
)
@ddt.unpack
def test_staff_permission_required(self, username, is_staff, expected_status):
"""Test and verify that only users with staff permission can access this endpoint."""
UserFactory(username=username, password='edx', is_staff=is_staff)
self.client.login(username=username, password='edx')
response = self.client.get(self.path())
assert response.status_code == expected_status
response = self.client.post(self.path(), {'user_id': username, 'action': 'allow'}, format='json')
assert response.status_code == expected_status
def test_non_existent_course_id(self):
"""Test the response when the endpoint URL contains a non-existent course id."""
self._login_as_staff()
path = self.path(course_id='course-v1:a+b+c')
response = self.client.get(path)
assert response.status_code == 404
response = self.client.post(path)
assert response.status_code == 404
def test_non_existent_course_role(self):
"""Test the response when the endpoint URL contains a non-existent role."""
self._login_as_staff()
path = self.path(role='A')
response = self.client.get(path)
assert response.status_code == 400
response = self.client.post(path)
assert response.status_code == 400
@ddt.data(
{'role': 'Moderator', 'count': 0},
{'role': 'Moderator', 'count': 1},
{'role': 'Group Moderator', 'count': 2},
{'role': 'Community TA', 'count': 3},
)
@ddt.unpack
def test_get_role_members(self, role, count):
"""Test the get role members endpoint response."""
config_course_cohorts(self.course, is_cohorted=True)
users = self._create_and_enroll_users(count=count)
self._add_users_to_role(users, role)
self._login_as_staff()
response = self.client.get(self.path(role=role))
assert response.status_code == 200
content = json.loads(response.content.decode('utf-8'))
assert content['course_id'] == 'course-v1:x+y+z'
assert len(content['results']) == count
expected_fields = ('username', 'email', 'first_name', 'last_name', 'group_name')
for item in content['results']:
for expected_field in expected_fields:
assert expected_field in item
assert content['division_scheme'] == 'cohort'
def test_post_missing_body(self):
"""Test the response with a POST request without a body."""
self._login_as_staff()
response = self.client.post(self.path())
assert response.status_code == 400
@ddt.data(
{'a': 1},
{'user_id': 'xyz', 'action': 'allow'},
{'user_id': 'staff', 'action': 123},
)
def test_missing_or_invalid_parameters(self, body):
"""
Test the response when the POST request has missing required parameters or
invalid values for the required parameters.
"""
self._login_as_staff()
response = self.client.post(self.path(), body)
assert response.status_code == 400
response = self.client.post(self.path(), body, format='json')
assert response.status_code == 400
@ddt.data(
{'action': 'allow', 'user_in_role': False},
{'action': 'allow', 'user_in_role': True},
{'action': 'revoke', 'user_in_role': False},
{'action': 'revoke', 'user_in_role': True}
)
@ddt.unpack
def test_post_update_user_role(self, action, user_in_role):
"""Test the response when updating the user's role"""
users = self._create_and_enroll_users(count=1)
user = users[0]
role = 'Moderator'
if user_in_role:
self._add_users_to_role(users, role)
response = self.post(role, user.username, action)
assert response.status_code == 200
content = json.loads(response.content.decode('utf-8'))
assertion = self.assertTrue if action == 'allow' else self.assertFalse
assertion(any(user.username in x['username'] for x in content['results']))
@ddt.ddt
@httpretty.activate
@override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True)
class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceMockMixin, APITestCase,
SharedModuleStoreTestCase):
"""
Tests for the course stats endpoint
"""
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self) -> None:
super().setUp()
patcher = mock.patch(
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
return_value=False
)
patcher.start()
self.addCleanup(patcher.stop)
self.course = CourseFactory.create()
self.course_key = str(self.course.id)
seed_permissions_roles(self.course.id)
self.user = UserFactory(username='user')
self.moderator = UserFactory(username='moderator')
moderator_role = Role.objects.get(name="Moderator", course_id=self.course.id)
moderator_role.users.add(self.moderator)
self.stats = [
{
"active_flags": random.randint(0, 3),
"inactive_flags": random.randint(0, 2),
"replies": random.randint(0, 30),
"responses": random.randint(0, 100),
"threads": random.randint(0, 10),
"username": f"user-{idx}"
}
for idx in range(10)
]
for stat in self.stats:
user = UserFactory.create(
username=stat['username'],
email=f"{stat['username']}@example.com",
password=self.TEST_PASSWORD
)
CourseEnrollment.enroll(user, self.course.id, mode='audit')
CourseEnrollment.enroll(self.moderator, self.course.id, mode='audit')
self.stats_without_flags = [{**stat, "active_flags": None, "inactive_flags": None} for stat in self.stats]
self.register_course_stats_response(self.course_key, self.stats, 1, 3)
self.url = reverse("discussion_course_activity_stats", kwargs={"course_key_string": self.course_key})
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def test_regular_user(self):
"""
Tests that for a regular user stats are returned without flag counts
"""
self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
response = self.client.get(self.url)
data = response.json()
assert data["results"] == self.stats_without_flags
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def test_moderator_user(self):
"""
Tests that for a moderator user stats are returned with flag counts
"""
self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD)
response = self.client.get(self.url)
data = response.json()
assert data["results"] == self.stats
@ddt.data(
("moderator", "flagged", "flagged"),
("moderator", "activity", "activity"),
("moderator", "recency", "recency"),
("moderator", None, "flagged"),
("user", None, "activity"),
("user", "activity", "activity"),
("user", "recency", "recency"),
)
@ddt.unpack
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def test_sorting(self, username, ordering_requested, ordering_performed):
"""
Test valid sorting options and defaults
"""
self.client.login(username=username, password=self.TEST_PASSWORD)
params = {}
if ordering_requested:
params = {"order_by": ordering_requested}
self.client.get(self.url, params)
assert urlparse(
httpretty.last_request().path # lint-amnesty, pylint: disable=no-member
).path == f"/api/v1/users/{self.course_key}/stats"
assert parse_qs(
urlparse(httpretty.last_request().path).query # lint-amnesty, pylint: disable=no-member
).get("sort_key", None) == [ordering_performed]
@ddt.data("flagged", "xyz")
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def test_sorting_error_regular_user(self, order_by):
"""
Test for invalid sorting options for regular users.
"""
self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
response = self.client.get(self.url, {"order_by": order_by})
assert "order_by" in response.json()["field_errors"]
@ddt.data(
('user', 'user-0,user-1,user-2,user-3,user-4,user-5,user-6,user-7,user-8,user-9'),
('moderator', 'moderator'),
)
@ddt.unpack
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_DISCUSSION_SERVICE': True})
def test_with_username_param(self, username_search_string, comma_separated_usernames):
"""
Test for endpoint with username param.
"""
params = {'username': username_search_string}
self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD)
self.client.get(self.url, params)
assert urlparse(
httpretty.last_request().path # lint-amnesty, pylint: disable=no-member
).path == f'/api/v1/users/{self.course_key}/stats'
assert parse_qs(
urlparse(httpretty.last_request().path).query # lint-amnesty, pylint: disable=no-member
).get('usernames', [None]) == [comma_separated_usernames]
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_DISCUSSION_SERVICE': True})
def test_with_username_param_with_no_matches(self):
"""
Test for endpoint with username param with no matches.
"""
params = {'username': 'unknown'}
self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD)
response = self.client.get(self.url, params)
data = response.json()
self.assertFalse(data['results'])
assert data['pagination']['count'] == 0
@ddt.data(
'user-0',
'USER-1',
'User-2',
'UsEr-3'
)
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_DISCUSSION_SERVICE': True})
def test_with_username_param_case(self, username_search_string):
"""
Test user search function is case-insensitive.
"""
response = get_usernames_from_search_string(self.course_key, username_search_string, 1, 1)
assert response == (username_search_string.lower(), 1, 1)

View File

@@ -1,4 +1,3 @@
# pylint: skip-file
"""
Tests for the external REST API endpoints of the Discussion API (views_v2.py).
@@ -8,44 +7,68 @@ and integration with the underlying discussion service. These tests ensure that
various user roles, input data, and edge cases, and that they return appropriate HTTP status codes and response bodies.
"""
import json
import random
from datetime import datetime
from unittest import mock
import ddt
from forum.backends.mongodb.comments import Comment
from forum.backends.mongodb.threads import CommentThread
import httpretty
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import override_settings
from django.urls import reverse
from edx_toggles.toggles.testutils import override_waffle_flag
from opaque_keys.edx.keys import CourseKey
from pytz import UTC
from rest_framework import status
from rest_framework.parsers import JSONParser
from rest_framework.test import APIClient
from rest_framework.test import APIClient, APITestCase
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from common.djangoapps.student.tests.factories import (
CourseEnrollmentFactory,
UserFactory,
from lms.djangoapps.discussion.django_comment_client.tests.utils import (
ForumsEnableMixin,
config_course_discussions,
topic_name_to_id,
)
from common.djangoapps.util.testing import PatchMediaTypeMixin, UrlResetMixin
from common.test.utils import disable_signal
from lms.djangoapps.discussion.tests.utils import (
make_minimal_cs_comment,
make_minimal_cs_thread,
)
from lms.djangoapps.discussion.django_comment_client.tests.utils import ForumsEnableMixin
from lms.djangoapps.discussion.rest_api import api
from lms.djangoapps.discussion.rest_api.tests.utils import (
CommentsServiceMockMixin,
ForumMockUtilsMixin,
ProfileImageTestMixin,
make_paginated_api_response,
make_minimal_cs_comment,
make_minimal_cs_thread,
)
from lms.djangoapps.discussion.rest_api.utils import get_usernames_from_search_string
from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory, check_mongo_calls
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
from common.djangoapps.student.tests.factories import (
AdminFactory,
CourseEnrollmentFactory,
SuperuserFactory,
UserFactory
)
from common.djangoapps.student.models import get_retired_username_by_username, CourseEnrollment
from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole, GlobalStaff
from common.djangoapps.util.testing import PatchMediaTypeMixin, UrlResetMixin
from common.test.utils import disable_signal
from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts
from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, DiscussionTopicLink, Provider
from openedx.core.djangoapps.discussions.tasks import update_discussions_settings_from_course_task
from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user
from openedx.core.djangoapps.oauth_dispatch.tests.factories import AccessTokenFactory, ApplicationFactory
from openedx.core.djangoapps.user_api.models import RetirementState, UserRetirementStatus
from openedx.core.djangoapps.django_comment_common.models import (
FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_MODERATOR, FORUM_ROLE_STUDENT,
assign_role
CourseDiscussionSettings, Role
)
from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles
from openedx.core.djangoapps.discussions.config.waffle import ENABLE_NEW_STRUCTURE_DISCUSSIONS
from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_storage
@@ -657,7 +680,7 @@ class ThreadViewSetListTest(
@ddt.data(True, "true", "1")
def test_following_true(self, following):
self.register_get_user_response(self.user)
self.register_subscribed_threads_response(self.user, [], page=1, num_pages=0)
self.register_subscribed_threads_response(self.user, [], page=1, num_pages=1)
response = self.client.get(
self.url,
{
@@ -667,7 +690,7 @@ class ThreadViewSetListTest(
)
expected_response = make_paginated_api_response(
results=[], count=0, num_pages=0, next_link=None, previous_link=None
results=[], count=0, num_pages=1, next_link=None, previous_link=None
)
expected_response.update({"text_search_rewrite": None})
self.assert_response_correct(response, 200, expected_response)
@@ -868,81 +891,1780 @@ class BulkDeleteUserPostsTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
Tests for the BulkDeleteUserPostsViewSet
"""
def setUp(self):
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self) -> None:
super().setUp()
self.url = reverse("bulk_delete_user_posts", kwargs={"course_id": str(self.course.id)})
self.user2 = UserFactory.create(password=self.password)
CourseEnrollmentFactory.create(user=self.user2, course_id=self.course.id)
self.course = CourseFactory.create()
self.course_key = str(self.course.id)
seed_permissions_roles(self.course.id)
self.user = UserFactory(username='user')
self.moderator = UserFactory(username='moderator')
moderator_role = Role.objects.get(name="Moderator", course_id=self.course.id)
moderator_role.users.add(self.moderator)
self.stats = [
{
"active_flags": random.randint(0, 3),
"inactive_flags": random.randint(0, 2),
"replies": random.randint(0, 30),
"responses": random.randint(0, 100),
"threads": random.randint(0, 10),
"username": f"user-{idx}"
}
for idx in range(10)
]
for stat in self.stats:
user = UserFactory.create(
username=stat['username'],
email=f"{stat['username']}@example.com",
password=self.TEST_PASSWORD
)
CourseEnrollment.enroll(user, self.course.id, mode='audit')
CourseEnrollment.enroll(self.moderator, self.course.id, mode='audit')
self.stats_without_flags = [{**stat, "active_flags": None, "inactive_flags": None} for stat in self.stats]
self.register_course_stats_response(self.course_key, self.stats, 1, 3)
self.url = reverse("discussion_course_activity_stats", kwargs={"course_key_string": self.course_key})
@classmethod
def setUpClass(cls):
super().setUpClass()
super().setUpClassAndForumMock()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
super().disposeForumMocks()
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def test_regular_user(self):
"""
Tests that for a regular user stats are returned without flag counts
"""
self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
response = self.client.get(self.url)
data = response.json()
assert data["results"] == self.stats_without_flags
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def test_moderator_user(self):
"""
Tests that for a moderator user stats are returned with flag counts
"""
self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD)
response = self.client.get(self.url)
data = response.json()
assert data["results"] == self.stats
@ddt.data(
("moderator", "flagged", "flagged"),
("moderator", "activity", "activity"),
("moderator", "recency", "recency"),
("moderator", None, "flagged"),
("user", None, "activity"),
("user", "activity", "activity"),
("user", "recency", "recency"),
)
@ddt.unpack
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def test_sorting(self, username, ordering_requested, ordering_performed):
"""
Test valid sorting options and defaults
"""
self.client.login(username=username, password=self.TEST_PASSWORD)
params = {}
if ordering_requested:
params = {"order_by": ordering_requested}
self.client.get(self.url, params)
self.check_mock_called("get_user_course_stats")
params = self.get_mock_func_calls("get_user_course_stats")[-1][1]
assert params["sort_key"] == ordering_performed
@ddt.data("flagged", "xyz")
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def test_sorting_error_regular_user(self, order_by):
"""
Test for invalid sorting options for regular users.
"""
self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
response = self.client.get(self.url, {"order_by": order_by})
assert "order_by" in response.json()["field_errors"]
@ddt.data(
('user', 'user-0,user-1,user-2,user-3,user-4,user-5,user-6,user-7,user-8,user-9'),
('moderator', 'moderator'),
)
@ddt.unpack
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_DISCUSSION_SERVICE': True})
def test_with_username_param(self, username_search_string, comma_separated_usernames):
"""
Test for endpoint with username param.
"""
params = {'username': username_search_string}
self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD)
self.client.get(self.url, params)
self.check_mock_called("get_user_course_stats")
params = self.get_mock_func_calls("get_user_course_stats")[-1][1]
assert params["usernames"] == comma_separated_usernames
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_DISCUSSION_SERVICE': True})
def test_with_username_param_with_no_matches(self):
"""
Test for endpoint with username param with no matches.
"""
params = {'username': 'unknown'}
self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD)
response = self.client.get(self.url, params)
data = response.json()
self.assertFalse(data['results'])
assert data['pagination']['count'] == 0
@ddt.data(
'user-0',
'USER-1',
'User-2',
'UsEr-3'
)
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_DISCUSSION_SERVICE': True})
def test_with_username_param_case(self, username_search_string):
"""
Test user search function is case-insensitive.
"""
response = get_usernames_from_search_string(self.course_key, username_search_string, 1, 1)
assert response == (username_search_string.lower(), 1, 1)
def test_basic(self):
"""
Intentionally left empty because this test case is inherited from parent
Basic test method required by DiscussionAPIViewTestMixin
"""
def mock_comment_and_thread_count(self, comment_count=1, thread_count=1):
def user_login(self):
"""
Patches count_documents() for Comment and CommentThread._collection.
Authenticates the test client with the example user.
"""
thread_collection = mock.MagicMock()
thread_collection.count_documents.return_value = thread_count
patch_thread = mock.patch.object(
CommentThread, "_collection", new_callable=mock.PropertyMock, return_value=thread_collection
self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
@override_settings(DISCUSSION_MODERATION_EDIT_REASON_CODES={"test-edit-reason": "Test Edit Reason"})
@override_settings(DISCUSSION_MODERATION_CLOSE_REASON_CODES={"test-close-reason": "Test Close Reason"})
class CourseViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
"""Tests for CourseView"""
def setUp(self):
super().setUp()
self.url = reverse("discussion_course", kwargs={"course_id": str(self.course.id)})
def test_404(self):
response = self.client.get(
reverse("course_topics", kwargs={"course_id": "non/existent/course"})
)
self.assert_response_correct(
response,
404,
{"developer_message": "Course not found."}
)
comment_collection = mock.MagicMock()
comment_collection.count_documents.return_value = comment_count
patch_comment = mock.patch.object(
Comment, "_collection", new_callable=mock.PropertyMock, return_value=comment_collection
def test_basic(self):
response = self.client.get(self.url)
self.assert_response_correct(
response,
200,
{
"id": str(self.course.id),
"is_posting_enabled": True,
"blackouts": [],
"thread_list_url": "http://testserver/api/discussion/v1/threads/?course_id=course-v1%3Ax%2By%2Bz",
"following_thread_list_url": (
"http://testserver/api/discussion/v1/threads/?course_id=course-v1%3Ax%2By%2Bz&following=True"
),
"topics_url": "http://testserver/api/discussion/v1/course_topics/course-v1:x+y+z",
"enable_in_context": True,
"group_at_subsection": False,
"provider": "legacy",
"allow_anonymous": True,
"allow_anonymous_to_peers": False,
"has_moderation_privileges": False,
'is_course_admin': False,
'is_course_staff': False,
"is_group_ta": False,
'is_user_admin': False,
"user_roles": ["Student"],
"edit_reasons": [{"code": "test-edit-reason", "label": "Test Edit Reason"}],
"post_close_reasons": [{"code": "test-close-reason", "label": "Test Close Reason"}],
'show_discussions': True,
'has_bulk_delete_privileges': False,
'is_notify_all_learners_enabled': False,
'captcha_settings': {'enabled': False, 'site_key': None},
'is_email_verified': True,
'only_verified_users_can_post': False,
'content_creation_rate_limited': False,
}
)
thread_mock = patch_thread.start()
comment_mock = patch_comment.start()
self.addCleanup(patch_comment.stop)
self.addCleanup(patch_thread.stop)
return thread_mock, comment_mock
@ddt.ddt
@httpretty.activate
@mock.patch('django.conf.settings.USERNAME_REPLACEMENT_WORKER', 'test_replace_username_service_worker')
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class ReplaceUsernamesViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
"""Tests for ReplaceUsernamesView"""
@ddt.data(FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_STUDENT)
def test_bulk_delete_denied_for_discussion_roles(self, role):
def setUp(self):
super().setUp()
self.worker = UserFactory()
self.worker.username = "test_replace_username_service_worker"
self.worker_client = APIClient()
self.new_username = "test_username_replacement"
self.url = reverse("replace_discussion_username")
def assert_response_correct(self, response, expected_status, expected_content):
"""
Test bulk delete user posts denied with discussion roles.
Assert that the response has the given status code and content
"""
thread_mock, comment_mock = self.mock_comment_and_thread_count(comment_count=1, thread_count=1)
assign_role(self.course.id, self.user, role)
response = self.client.post(
f"{self.url}?username={self.user2.username}",
format="json",
assert response.status_code == expected_status
if expected_content:
assert str(response.content) == expected_content
def build_jwt_headers(self, user):
"""
Helper function for creating headers for the JWT authentication.
"""
token = create_jwt_for_user(user)
headers = {'HTTP_AUTHORIZATION': 'JWT ' + token}
return headers
def call_api(self, user, client, data):
""" Helper function to call API with data """
data = json.dumps(data)
headers = self.build_jwt_headers(user)
return client.post(self.url, data, content_type='application/json', **headers)
@ddt.data(
[{}, {}],
{},
[{"test_key": "test_value", "test_key_2": "test_value_2"}]
)
def test_bad_schema(self, mapping_data):
""" Verify the endpoint rejects bad data schema """
data = {
"username_mappings": mapping_data
}
response = self.call_api(self.worker, self.worker_client, data)
assert response.status_code == 400
def test_auth(self):
""" Verify the endpoint only works with the service worker """
data = {
"username_mappings": [
{"test_username_1": "test_new_username_1"},
{"test_username_2": "test_new_username_2"}
]
}
# Test unauthenticated
response = self.client.post(self.url, data)
assert response.status_code == 403
# Test non-service worker
random_user = UserFactory()
response = self.call_api(random_user, APIClient(), data)
assert response.status_code == 403
# Test service worker
response = self.call_api(self.worker, self.worker_client, data)
assert response.status_code == 200
def test_basic(self):
""" Check successful replacement """
data = {
"username_mappings": [
{self.user.username: self.new_username},
]
}
expected_response = {
'failed_replacements': [],
'successful_replacements': data["username_mappings"]
}
self.register_get_username_replacement_response(self.user)
response = self.call_api(self.worker, self.worker_client, data)
assert response.status_code == 200
assert response.data == expected_response
def test_not_authenticated(self):
"""
Override the parent implementation of this, we JWT auth for this API
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
@ddt.ddt
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class CourseTopicsViewTest(DiscussionAPIViewTestMixin, CommentsServiceMockMixin, ModuleStoreTestCase):
"""
Tests for CourseTopicsView
"""
def setUp(self):
httpretty.reset()
httpretty.enable()
self.addCleanup(httpretty.reset)
self.addCleanup(httpretty.disable)
super().setUp()
self.url = reverse("course_topics", kwargs={"course_id": str(self.course.id)})
self.thread_counts_map = {
"courseware-1": {"discussion": 2, "question": 3},
"courseware-2": {"discussion": 4, "question": 5},
"courseware-3": {"discussion": 7, "question": 2},
}
self.register_get_course_commentable_counts_response(self.course.id, self.thread_counts_map)
def create_course(self, blocks_count, module_store, topics):
"""
Create a course in a specified module store with discussion xblocks and topics
"""
course = CourseFactory.create(
org="a",
course="b",
run="c",
start=datetime.now(UTC),
default_store=module_store,
discussion_topics=topics
)
CourseEnrollmentFactory.create(user=self.user, course_id=course.id)
course_url = reverse("course_topics", kwargs={"course_id": str(course.id)})
# add some discussion xblocks
for i in range(blocks_count):
BlockFactory.create(
parent_location=course.location,
category='discussion',
discussion_id=f'id_module_{i}',
discussion_category=f'Category {i}',
discussion_target=f'Discussion {i}',
publish_item=False,
)
return course_url, course.id
def make_discussion_xblock(self, topic_id, category, subcategory, **kwargs):
"""
Build a discussion xblock in self.course
"""
BlockFactory.create(
parent_location=self.course.location,
category="discussion",
discussion_id=topic_id,
discussion_category=category,
discussion_target=subcategory,
**kwargs
)
def test_404(self):
response = self.client.get(
reverse("course_topics", kwargs={"course_id": "non/existent/course"})
)
self.assert_response_correct(
response,
404,
{"developer_message": "Course not found."}
)
def test_basic(self):
response = self.client.get(self.url)
self.assert_response_correct(
response,
200,
{
"courseware_topics": [],
"non_courseware_topics": [{
"id": "test_topic",
"name": "Test Topic",
"children": [],
"thread_list_url": 'http://testserver/api/discussion/v1/threads/'
'?course_id=course-v1%3Ax%2By%2Bz&topic_id=test_topic',
"thread_counts": {"discussion": 0, "question": 0},
}],
}
)
@ddt.data(
(2, ModuleStoreEnum.Type.split, 2, {"Test Topic 1": {"id": "test_topic_1"}}),
(2, ModuleStoreEnum.Type.split, 2,
{"Test Topic 1": {"id": "test_topic_1"}, "Test Topic 2": {"id": "test_topic_2"}}),
(10, ModuleStoreEnum.Type.split, 2, {"Test Topic 1": {"id": "test_topic_1"}}),
)
@ddt.unpack
def test_bulk_response(self, blocks_count, module_store, mongo_calls, topics):
course_url, course_id = self.create_course(blocks_count, module_store, topics)
self.register_get_course_commentable_counts_response(course_id, {})
with check_mongo_calls(mongo_calls):
with modulestore().default_store(module_store):
self.client.get(course_url)
def test_discussion_topic_404(self):
"""
Tests discussion topic does not exist for the given topic id.
"""
topic_id = "courseware-topic-id"
self.make_discussion_xblock(topic_id, "test_category", "test_target")
url = f"{self.url}?topic_id=invalid_topic_id"
response = self.client.get(url)
self.assert_response_correct(
response,
404,
{"developer_message": "Discussion not found for 'invalid_topic_id'."}
)
def test_topic_id(self):
"""
Tests discussion topic details against a requested topic id
"""
topic_id_1 = "topic_id_1"
topic_id_2 = "topic_id_2"
self.make_discussion_xblock(topic_id_1, "test_category_1", "test_target_1")
self.make_discussion_xblock(topic_id_2, "test_category_2", "test_target_2")
url = f"{self.url}?topic_id=topic_id_1,topic_id_2"
response = self.client.get(url)
self.assert_response_correct(
response,
200,
{
"non_courseware_topics": [],
"courseware_topics": [
{
"children": [{
"children": [],
"id": "topic_id_1",
"thread_list_url": "http://testserver/api/discussion/v1/threads/?"
"course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1",
"name": "test_target_1",
"thread_counts": {"discussion": 0, "question": 0},
}],
"id": None,
"thread_list_url": "http://testserver/api/discussion/v1/threads/?"
"course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_1",
"name": "test_category_1",
"thread_counts": None,
},
{
"children":
[{
"children": [],
"id": "topic_id_2",
"thread_list_url": "http://testserver/api/discussion/v1/threads/?"
"course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2",
"name": "test_target_2",
"thread_counts": {"discussion": 0, "question": 0},
}],
"id": None,
"thread_list_url": "http://testserver/api/discussion/v1/threads/?"
"course_id=course-v1%3Ax%2By%2Bz&topic_id=topic_id_2",
"name": "test_category_2",
"thread_counts": None,
}
]
}
)
@override_waffle_flag(ENABLE_NEW_STRUCTURE_DISCUSSIONS, True)
def test_new_course_structure_response(self):
"""
Tests whether the new structure is available on old topics API
(For mobile compatibility)
"""
chapter = BlockFactory.create(
parent_location=self.course.location,
category='chapter',
display_name="Week 1",
start=datetime(2015, 3, 1, tzinfo=UTC),
)
sequential = BlockFactory.create(
parent_location=chapter.location,
category='sequential',
display_name="Lesson 1",
start=datetime(2015, 3, 1, tzinfo=UTC),
)
BlockFactory.create(
parent_location=sequential.location,
category='vertical',
display_name='vertical',
start=datetime(2015, 4, 1, tzinfo=UTC),
)
DiscussionsConfiguration.objects.create(
context_key=self.course.id,
provider_type=Provider.OPEN_EDX
)
update_discussions_settings_from_course_task(str(self.course.id))
response = json.loads(self.client.get(self.url).content.decode())
keys = ['children', 'id', 'name', 'thread_counts', 'thread_list_url']
assert list(response.keys()) == ['courseware_topics', 'non_courseware_topics']
assert len(response['courseware_topics']) == 1
courseware_keys = list(response['courseware_topics'][0].keys())
courseware_keys.sort()
assert courseware_keys == keys
assert len(response['non_courseware_topics']) == 1
non_courseware_keys = list(response['non_courseware_topics'][0].keys())
non_courseware_keys.sort()
assert non_courseware_keys == keys
@ddt.ddt
@mock.patch('lms.djangoapps.discussion.rest_api.api._get_course', mock.Mock())
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
@override_waffle_flag(ENABLE_NEW_STRUCTURE_DISCUSSIONS, True)
class CourseTopicsViewV3Test(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
"""
Tests for CourseTopicsViewV3
"""
def setUp(self) -> None:
super().setUp()
self.password = self.TEST_PASSWORD
self.user = UserFactory.create(password=self.password)
self.client.login(username=self.user.username, password=self.password)
self.staff = AdminFactory.create()
self.course = CourseFactory.create(
start=datetime(2020, 1, 1),
end=datetime(2028, 1, 1),
enrollment_start=datetime(2020, 1, 1),
enrollment_end=datetime(2028, 1, 1),
discussion_topics={"Course Wide Topic": {
"id": 'course-wide-topic',
"usage_key": None,
}}
)
self.chapter = BlockFactory.create(
parent_location=self.course.location,
category='chapter',
display_name="Week 1",
start=datetime(2015, 3, 1, tzinfo=UTC),
)
self.sequential = BlockFactory.create(
parent_location=self.chapter.location,
category='sequential',
display_name="Lesson 1",
start=datetime(2015, 3, 1, tzinfo=UTC),
)
self.verticals = [
BlockFactory.create(
parent_location=self.sequential.location,
category='vertical',
display_name='vertical',
start=datetime(2015, 4, 1, tzinfo=UTC),
)
]
course_key = self.course.id
self.config = DiscussionsConfiguration.objects.create(context_key=course_key, provider_type=Provider.OPEN_EDX)
topic_links = []
update_discussions_settings_from_course_task(str(course_key))
topic_id_query = DiscussionTopicLink.objects.filter(context_key=course_key).values_list(
'external_id', flat=True,
)
topic_ids = list(topic_id_query.order_by('ordering'))
DiscussionTopicLink.objects.bulk_create(topic_links)
self.topic_stats = {
**{topic_id: dict(discussion=random.randint(0, 10), question=random.randint(0, 10))
for topic_id in set(topic_ids)},
topic_ids[0]: dict(discussion=0, question=0),
}
patcher = mock.patch(
'lms.djangoapps.discussion.rest_api.api.get_course_commentable_counts',
mock.Mock(return_value=self.topic_stats),
)
patcher.start()
self.addCleanup(patcher.stop)
self.url = reverse("course_topics_v3", kwargs={"course_id": str(self.course.id)})
def test_basic(self):
response = self.client.get(self.url)
data = json.loads(response.content.decode())
expected_non_courseware_keys = [
'id', 'usage_key', 'name', 'thread_counts', 'enabled_in_context',
'courseware'
]
expected_courseware_keys = [
'id', 'block_id', 'lms_web_url', 'legacy_web_url', 'student_view_url',
'type', 'display_name', 'children', 'courseware'
]
assert response.status_code == 200
assert len(data) == 2
non_courseware_topic_keys = list(data[0].keys())
assert non_courseware_topic_keys == expected_non_courseware_keys
courseware_topic_keys = list(data[1].keys())
assert courseware_topic_keys == expected_courseware_keys
expected_courseware_keys.remove('courseware')
sequential_keys = list(data[1]['children'][0].keys())
assert sequential_keys == (expected_courseware_keys + ['thread_counts'])
expected_non_courseware_keys.remove('courseware')
vertical_keys = list(data[1]['children'][0]['children'][0].keys())
assert vertical_keys == expected_non_courseware_keys
@ddt.ddt
@httpretty.activate
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class LearnerThreadViewAPITest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
"""Tests for LearnerThreadView list"""
def setUp(self):
"""
Sets up the test case
"""
super().setUp()
self.author = self.user
self.remove_keys = [
"abuse_flaggers",
"body",
"children",
"commentable_id",
"endorsed",
"last_activity_at",
"resp_total",
"thread_type",
"user_id",
"username",
"votes",
]
self.replace_keys = [
{"from": "unread_comments_count", "to": "unread_comment_count"},
{"from": "comments_count", "to": "comment_count"},
]
self.add_keys = [
{"key": "author", "value": self.author.username},
{"key": "abuse_flagged", "value": False},
{"key": "author_label", "value": None},
{"key": "can_delete", "value": True},
{"key": "close_reason", "value": None},
{
"key": "comment_list_url",
"value": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread"
},
{
"key": "editable_fields",
"value": [
'abuse_flagged', 'anonymous', 'copy_link', 'following', 'raw_body',
'read', 'title', 'topic_id', 'type'
]
},
{"key": "endorsed_comment_list_url", "value": None},
{"key": "following", "value": False},
{"key": "group_name", "value": None},
{"key": "has_endorsed", "value": False},
{"key": "last_edit", "value": None},
{"key": "non_endorsed_comment_list_url", "value": None},
{"key": "preview_body", "value": "Test body"},
{"key": "raw_body", "value": "Test body"},
{"key": "rendered_body", "value": "<p>Test body</p>"},
{"key": "response_count", "value": 0},
{"key": "topic_id", "value": "test_topic"},
{"key": "type", "value": "discussion"},
{"key": "users", "value": {
self.user.username: {
"profile": {
"image": {
"has_image": False,
"image_url_full": "http://testserver/static/default_500.png",
"image_url_large": "http://testserver/static/default_120.png",
"image_url_medium": "http://testserver/static/default_50.png",
"image_url_small": "http://testserver/static/default_30.png",
}
}
}
}},
{"key": "vote_count", "value": 4},
{"key": "voted", "value": False},
]
self.url = reverse("discussion_learner_threads", kwargs={'course_id': str(self.course.id)})
def update_thread(self, thread):
"""
This function updates the thread by adding and remove some keys.
Value of these keys has been defined in setUp function
"""
for element in self.add_keys:
thread[element['key']] = element['value']
for pair in self.replace_keys:
thread[pair['to']] = thread.pop(pair['from'])
for key in self.remove_keys:
thread.pop(key)
thread['comment_count'] += 1
return thread
def test_basic(self):
"""
Tests the data is fetched correctly
Note: test_basic is required as the name because DiscussionAPIViewTestMixin
calls this test case automatically
"""
self.register_get_user_response(self.user)
expected_cs_comments_response = {
"collection": [make_minimal_cs_thread({
"id": "test_thread",
"course_id": str(self.course.id),
"commentable_id": "test_topic",
"user_id": str(self.user.id),
"username": self.user.username,
"created_at": "2015-04-28T00:00:00Z",
"updated_at": "2015-04-28T11:11:11Z",
"title": "Test Title",
"body": "Test body",
"votes": {"up_count": 4},
"comments_count": 5,
"unread_comments_count": 3,
"closed_by_label": None,
"edit_by_label": None,
})],
"page": 1,
"num_pages": 1,
}
self.register_user_active_threads(self.user.id, expected_cs_comments_response)
self.url += f"?username={self.user.username}"
response = self.client.get(self.url)
assert response.status_code == 200
response_data = json.loads(response.content.decode('utf-8'))
expected_api_response = expected_cs_comments_response['collection']
for thread in expected_api_response:
self.update_thread(thread)
assert response_data['results'] == expected_api_response
assert response_data['pagination'] == {
"next": None,
"previous": None,
"count": 1,
"num_pages": 1,
}
def test_no_username_given(self):
"""
Tests that 404 response is returned when no username is passed
"""
response = self.client.get(self.url)
assert response.status_code == 404
def test_not_authenticated(self):
"""
This test is called by DiscussionAPIViewTestMixin and is not required in
our case
"""
assert True
@ddt.data("None", "discussion", "question")
def test_thread_type_by(self, thread_type):
"""
Tests the thread_type parameter
Arguments:
thread_type (str): Value of thread_type can be 'None',
'discussion' and 'question'
"""
threads = [make_minimal_cs_thread({
"id": "test_thread",
"course_id": str(self.course.id),
"commentable_id": "test_topic",
"user_id": str(self.user.id),
"username": self.user.username,
"created_at": "2015-04-28T00:00:00Z",
"updated_at": "2015-04-28T11:11:11Z",
"title": "Test Title",
"body": "Test body",
"votes": {"up_count": 4},
"comments_count": 5,
"unread_comments_count": 3,
})]
expected_cs_comments_response = {
"collection": threads,
"page": 1,
"num_pages": 1,
}
self.register_get_user_response(self.user)
self.register_user_active_threads(self.user.id, expected_cs_comments_response)
response = self.client.get(
self.url,
{
"course_id": str(self.course.id),
"username": self.user.username,
"thread_type": thread_type,
}
)
assert response.status_code == 200
params = {
"user_id": str(self.user.id),
"course_id": str(self.course.id),
"page": 1,
"per_page": 10,
"thread_type": thread_type,
"sort_key": 'activity',
"count_flagged": False
}
self.check_mock_called_with("get_user_active_threads", -1, **params)
@ddt.data(
("last_activity_at", "activity"),
("comment_count", "comments"),
("vote_count", "votes")
)
@ddt.unpack
def test_order_by(self, http_query, cc_query):
"""
Tests the order_by parameter for active threads
Arguments:
http_query (str): Query string sent in the http request
cc_query (str): Query string used for the comments client service
"""
threads = [make_minimal_cs_thread({
"id": "test_thread",
"course_id": str(self.course.id),
"commentable_id": "test_topic",
"user_id": str(self.user.id),
"username": self.user.username,
"created_at": "2015-04-28T00:00:00Z",
"updated_at": "2015-04-28T11:11:11Z",
"title": "Test Title",
"body": "Test body",
"votes": {"up_count": 4},
"comments_count": 5,
"unread_comments_count": 3,
})]
expected_cs_comments_response = {
"collection": threads,
"page": 1,
"num_pages": 1,
}
self.register_get_user_response(self.user)
self.register_user_active_threads(self.user.id, expected_cs_comments_response)
response = self.client.get(
self.url,
{
"course_id": str(self.course.id),
"username": self.user.username,
"order_by": http_query,
}
)
assert response.status_code == 200
params = {
"user_id": str(self.user.id),
"course_id": str(self.course.id),
"page": 1,
"per_page": 10,
"sort_key": cc_query,
"count_flagged": False
}
self.check_mock_called_with("get_user_active_threads", -1, **params)
@ddt.data("flagged", "unanswered", "unread", "unresponded")
def test_status_by(self, post_status):
"""
Tests the post_status parameter
Arguments:
post_status (str): Value of post_status can be 'flagged',
'unanswered' and 'unread'
"""
threads = [make_minimal_cs_thread({
"id": "test_thread",
"course_id": str(self.course.id),
"commentable_id": "test_topic",
"user_id": str(self.user.id),
"username": self.user.username,
"created_at": "2015-04-28T00:00:00Z",
"updated_at": "2015-04-28T11:11:11Z",
"title": "Test Title",
"body": "Test body",
"votes": {"up_count": 4},
"comments_count": 5,
"unread_comments_count": 3,
})]
expected_cs_comments_response = {
"collection": threads,
"page": 1,
"num_pages": 1,
}
self.register_get_user_response(self.user)
self.register_user_active_threads(self.user.id, expected_cs_comments_response)
response = self.client.get(
self.url,
{
"course_id": str(self.course.id),
"username": self.user.username,
"status": post_status,
}
)
if post_status == "flagged":
assert response.status_code == 403
else:
assert response.status_code == 200
params = {
"user_id": str(self.user.id),
"course_id": str(self.course.id),
"page": 1,
"per_page": 10,
post_status: True,
"sort_key": 'activity',
"count_flagged": False
}
self.check_mock_called_with("get_user_active_threads", -1, **params)
@ddt.ddt
@httpretty.activate
@override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True)
class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, ForumMockUtilsMixin, APITestCase,
SharedModuleStoreTestCase):
"""
Tests for the course stats endpoint
"""
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self) -> None:
super().setUp()
self.course = CourseFactory.create()
self.course_key = str(self.course.id)
seed_permissions_roles(self.course.id)
self.user = UserFactory(username='user')
self.moderator = UserFactory(username='moderator')
moderator_role = Role.objects.get(name="Moderator", course_id=self.course.id)
moderator_role.users.add(self.moderator)
self.stats = [
{
"active_flags": random.randint(0, 3),
"inactive_flags": random.randint(0, 2),
"replies": random.randint(0, 30),
"responses": random.randint(0, 100),
"threads": random.randint(0, 10),
"username": f"user-{idx}"
}
for idx in range(10)
]
for stat in self.stats:
user = UserFactory.create(
username=stat['username'],
email=f"{stat['username']}@example.com",
password=self.TEST_PASSWORD
)
CourseEnrollment.enroll(user, self.course.id, mode='audit')
CourseEnrollment.enroll(self.moderator, self.course.id, mode='audit')
self.stats_without_flags = [{**stat, "active_flags": None, "inactive_flags": None} for stat in self.stats]
self.register_course_stats_response(self.course_key, self.stats, 1, 3)
self.url = reverse("discussion_course_activity_stats", kwargs={"course_key_string": self.course_key})
@classmethod
def setUpClass(cls):
super().setUpClass()
super().setUpClassAndForumMock()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
super().disposeForumMocks()
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def test_regular_user(self):
"""
Tests that for a regular user stats are returned without flag counts
"""
self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
response = self.client.get(self.url)
data = response.json()
assert data["results"] == self.stats_without_flags
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def test_moderator_user(self):
"""
Tests that for a moderator user stats are returned with flag counts
"""
self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD)
response = self.client.get(self.url)
data = response.json()
assert data["results"] == self.stats
@ddt.data(
("moderator", "flagged", "flagged"),
("moderator", "activity", "activity"),
("moderator", "recency", "recency"),
("moderator", None, "flagged"),
("user", None, "activity"),
("user", "activity", "activity"),
("user", "recency", "recency"),
)
@ddt.unpack
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def test_sorting(self, username, ordering_requested, ordering_performed):
"""
Test valid sorting options and defaults
"""
self.client.login(username=username, password=self.TEST_PASSWORD)
params = {}
if ordering_requested:
params = {"order_by": ordering_requested}
self.client.get(self.url, params)
self.check_mock_called("get_user_course_stats")
params = self.get_mock_func_calls("get_user_course_stats")[-1][1]
assert params["sort_key"] == ordering_performed
@ddt.data("flagged", "xyz")
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def test_sorting_error_regular_user(self, order_by):
"""
Test for invalid sorting options for regular users.
"""
self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
response = self.client.get(self.url, {"order_by": order_by})
assert "order_by" in response.json()["field_errors"]
@ddt.data(
('user', 'user-0,user-1,user-2,user-3,user-4,user-5,user-6,user-7,user-8,user-9'),
('moderator', 'moderator'),
)
@ddt.unpack
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_DISCUSSION_SERVICE': True})
def test_with_username_param(self, username_search_string, comma_separated_usernames):
"""
Test for endpoint with username param.
"""
params = {'username': username_search_string}
self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD)
self.client.get(self.url, params)
self.check_mock_called("get_user_course_stats")
params = self.get_mock_func_calls("get_user_course_stats")[-1][1]
assert params["usernames"] == comma_separated_usernames
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_DISCUSSION_SERVICE': True})
def test_with_username_param_with_no_matches(self):
"""
Test for endpoint with username param with no matches.
"""
params = {'username': 'unknown'}
self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD)
response = self.client.get(self.url, params)
data = response.json()
self.assertFalse(data['results'])
assert data['pagination']['count'] == 0
@ddt.data(
'user-0',
'USER-1',
'User-2',
'UsEr-3'
)
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_DISCUSSION_SERVICE': True})
def test_with_username_param_case(self, username_search_string):
"""
Test user search function is case-insensitive.
"""
response = get_usernames_from_search_string(self.course_key, username_search_string, 1, 1)
assert response == (username_search_string.lower(), 1, 1)
@httpretty.activate
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class RetireViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
"""Tests for CourseView"""
def setUp(self):
super().setUp()
RetirementState.objects.create(state_name='PENDING', state_execution_order=1)
self.retire_forums_state = RetirementState.objects.create(state_name='RETIRE_FORUMS', state_execution_order=11)
self.retirement = UserRetirementStatus.create_retirement(self.user)
self.retirement.current_state = self.retire_forums_state
self.retirement.save()
self.superuser = SuperuserFactory()
self.superuser_client = APIClient()
self.retired_username = get_retired_username_by_username(self.user.username)
self.url = reverse("retire_discussion_user")
def assert_response_correct(self, response, expected_status, expected_content):
"""
Assert that the response has the given status code and content
"""
assert response.status_code == expected_status
if expected_content:
assert response.content.decode('utf-8') == expected_content
def build_jwt_headers(self, user):
"""
Helper function for creating headers for the JWT authentication.
"""
token = create_jwt_for_user(user)
headers = {'HTTP_AUTHORIZATION': 'JWT ' + token}
return headers
def test_basic(self):
"""
Check successful retirement case
"""
self.register_get_user_retire_response(self.user)
headers = self.build_jwt_headers(self.superuser)
data = {'username': self.user.username}
response = self.superuser_client.post(self.url, data, **headers)
self.assert_response_correct(response, 204, b"")
def test_nonexistent_user(self):
"""
Check that we handle unknown users appropriately
"""
nonexistent_username = "nonexistent user"
self.retired_username = get_retired_username_by_username(nonexistent_username)
data = {'username': nonexistent_username}
headers = self.build_jwt_headers(self.superuser)
response = self.superuser_client.post(self.url, data, **headers)
self.assert_response_correct(response, 404, None)
def test_not_authenticated(self):
"""
Override the parent implementation of this, we JWT auth for this API
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class UploadFileViewTest(ForumsEnableMixin, ForumMockUtilsMixin, UrlResetMixin, ModuleStoreTestCase):
"""
Tests for UploadFileView.
"""
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
self.valid_file = {
"uploaded_file": SimpleUploadedFile(
"test.jpg",
b"test content",
content_type="image/jpeg",
),
}
self.user = UserFactory.create(password=self.TEST_PASSWORD)
self.course = CourseFactory.create(org='a', course='b', run='c', start=datetime.now(UTC))
self.url = reverse("upload_file", kwargs={"course_id": str(self.course.id)})
@classmethod
def setUpClass(cls):
super().setUpClass()
super().setUpClassAndForumMock()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
super().disposeForumMocks()
def user_login(self):
"""
Authenticates the test client with the example user.
"""
self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
def enroll_user_in_course(self):
"""
Makes the example user enrolled to the course.
"""
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
def assert_upload_success(self, response):
"""
Asserts that the upload response was successful and returned the
expected contents.
"""
assert response.status_code == status.HTTP_200_OK
assert response.content_type == "application/json"
response_data = json.loads(response.content)
assert "location" in response_data
def test_file_upload_by_unauthenticated_user(self):
"""
Should fail if an unauthenticated user tries to upload a file.
"""
response = self.client.post(self.url, self.valid_file)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_file_upload_by_unauthorized_user(self):
"""
Should fail if the user is not either staff or a student
enrolled in the course.
"""
self.user_login()
response = self.client.post(self.url, self.valid_file)
assert response.status_code == status.HTTP_403_FORBIDDEN
thread_mock.count_documents.assert_not_called()
comment_mock.count_documents.assert_not_called()
@ddt.data(FORUM_ROLE_MODERATOR, FORUM_ROLE_ADMINISTRATOR)
def test_bulk_delete_allowed_for_discussion_roles(self, role):
def test_file_upload_by_enrolled_user(self):
"""
Test bulk delete user posts passed with discussion roles.
Should succeed when a valid file is uploaded by an authenticated
user who's enrolled in the course.
"""
self.mock_comment_and_thread_count(comment_count=1, thread_count=1)
assign_role(self.course.id, self.user, role)
response = self.client.post(
f"{self.url}?username={self.user2.username}",
format="json",
)
assert response.status_code == status.HTTP_202_ACCEPTED
assert response.json() == {"comment_count": 1, "thread_count": 1}
self.user_login()
self.enroll_user_in_course()
response = self.client.post(self.url, self.valid_file)
self.assert_upload_success(response)
@mock.patch('lms.djangoapps.discussion.rest_api.views.delete_course_post_for_user.apply_async')
@ddt.data(True, False)
def test_task_only_runs_if_execute_param_is_true(self, execute, task_mock):
def test_file_upload_by_global_staff(self):
"""
Test bulk delete user posts task runs only if execute parameter is set to true.
Should succeed when a valid file is uploaded by a global staff
member.
"""
assign_role(self.course.id, self.user, FORUM_ROLE_MODERATOR)
self.mock_comment_and_thread_count(comment_count=1, thread_count=1)
response = self.client.post(
f"{self.url}?username={self.user2.username}&execute={str(execute).lower()}",
format="json",
self.user_login()
GlobalStaff().add_users(self.user)
response = self.client.post(self.url, self.valid_file)
self.assert_upload_success(response)
def test_file_upload_by_instructor(self):
"""
Should succeed when a valid file is uploaded by a course instructor.
"""
self.user_login()
CourseInstructorRole(course_key=self.course.id).add_users(self.user)
response = self.client.post(self.url, self.valid_file)
self.assert_upload_success(response)
def test_file_upload_by_course_staff(self):
"""
Should succeed when a valid file is uploaded by a course staff
member.
"""
self.user_login()
CourseStaffRole(course_key=self.course.id).add_users(self.user)
response = self.client.post(self.url, self.valid_file)
self.assert_upload_success(response)
def test_file_upload_with_thread_key(self):
"""
Should contain the given thread_key in the uploaded file name.
"""
self.user_login()
self.enroll_user_in_course()
response = self.client.post(self.url, {
**self.valid_file,
"thread_key": "somethread",
})
response_data = json.loads(response.content)
assert "/somethread/" in response_data["location"]
def test_file_upload_with_invalid_file(self):
"""
Should fail if the uploaded file format is not allowed.
"""
self.user_login()
self.enroll_user_in_course()
invalid_file = {
"uploaded_file": SimpleUploadedFile(
"test.txt",
b"test content",
content_type="text/plain",
),
}
response = self.client.post(self.url, invalid_file)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_file_upload_with_invalid_course_id(self):
"""
Should fail if the course does not exist.
"""
self.user_login()
self.enroll_user_in_course()
url = reverse("upload_file", kwargs={"course_id": "d/e/f"})
response = self.client.post(url, self.valid_file)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_file_upload_with_no_data(self):
"""
Should fail when the user sends a request missing an
`uploaded_file` field.
"""
self.user_login()
self.enroll_user_in_course()
response = self.client.post(self.url, data={})
assert response.status_code == status.HTTP_400_BAD_REQUEST
@ddt.ddt
class CourseDiscussionSettingsAPIViewTest(APITestCase, UrlResetMixin, ModuleStoreTestCase):
"""
Test the course discussion settings handler API endpoint.
"""
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
self.course = CourseFactory.create(
org="x",
course="y",
run="z",
start=datetime.now(UTC),
discussion_topics={"Test Topic": {"id": "test_topic"}}
)
assert response.status_code == status.HTTP_202_ACCEPTED
assert response.json() == {"comment_count": 1, "thread_count": 1}
assert task_mock.called is execute
self.path = reverse('discussion_course_settings', kwargs={'course_id': str(self.course.id)})
self.password = self.TEST_PASSWORD
self.user = UserFactory(username='staff', password=self.password, is_staff=True)
def _get_oauth_headers(self, user):
"""Return the OAuth headers for testing OAuth authentication"""
access_token = AccessTokenFactory.create(user=user, application=ApplicationFactory()).token
headers = {
'HTTP_AUTHORIZATION': 'Bearer ' + access_token
}
return headers
def _login_as_staff(self):
"""Log the client in as the staff."""
self.client.login(username=self.user.username, password=self.password)
def _login_as_discussion_staff(self):
user = UserFactory(username='abc', password='abc')
role = Role.objects.create(name='Administrator', course_id=self.course.id)
role.users.set([user])
self.client.login(username=user.username, password='abc')
def _create_divided_discussions(self):
"""Create some divided discussions for testing."""
divided_inline_discussions = ['Topic A', ]
divided_course_wide_discussions = ['Topic B', ]
divided_discussions = divided_inline_discussions + divided_course_wide_discussions
BlockFactory.create(
parent=self.course,
category='discussion',
discussion_id=topic_name_to_id(self.course, 'Topic A'),
discussion_category='Chapter',
discussion_target='Discussion',
start=datetime.now()
)
discussion_topics = {
"Topic B": {"id": "Topic B"},
}
config_course_cohorts(self.course, is_cohorted=True)
config_course_discussions(
self.course,
discussion_topics=discussion_topics,
divided_discussions=divided_discussions
)
return divided_inline_discussions, divided_course_wide_discussions
def _get_expected_response(self):
"""Return the default expected response before any changes to the discussion settings."""
return {
'always_divide_inline_discussions': False,
'divided_inline_discussions': [],
'divided_course_wide_discussions': [],
'id': 1,
'division_scheme': 'cohort',
'available_division_schemes': ['cohort'],
'reported_content_email_notifications': False,
}
def patch_request(self, data, headers=None):
headers = headers if headers else {}
return self.client.patch(self.path, json.dumps(data), content_type='application/merge-patch+json', **headers)
def _assert_current_settings(self, expected_response):
"""Validate the current discussion settings against the expected response."""
response = self.client.get(self.path)
assert response.status_code == 200
content = json.loads(response.content.decode('utf-8'))
assert content == expected_response
def _assert_patched_settings(self, data, expected_response):
"""Validate the patched settings against the expected response."""
response = self.patch_request(data)
assert response.status_code == 204
self._assert_current_settings(expected_response)
@ddt.data('get', 'patch')
def test_authentication_required(self, method):
"""Test and verify that authentication is required for this endpoint."""
self.client.logout()
response = getattr(self.client, method)(self.path)
assert response.status_code == 401
@ddt.data(
{'is_staff': False, 'get_status': 403, 'put_status': 403},
{'is_staff': True, 'get_status': 200, 'put_status': 204},
)
@ddt.unpack
def test_oauth(self, is_staff, get_status, put_status):
"""Test that OAuth authentication works for this endpoint."""
user = UserFactory(is_staff=is_staff)
headers = self._get_oauth_headers(user)
self.client.logout()
response = self.client.get(self.path, **headers)
assert response.status_code == get_status
response = self.patch_request(
{'always_divide_inline_discussions': True}, headers
)
assert response.status_code == put_status
def test_non_existent_course_id(self):
"""Test the response when this endpoint is passed a non-existent course id."""
self._login_as_staff()
response = self.client.get(
reverse('discussion_course_settings', kwargs={
'course_id': 'course-v1:a+b+c'
})
)
assert response.status_code == 404
def test_patch_request_by_discussion_staff(self):
"""Test the response when patch request is sent by a user with discussions staff role."""
self._login_as_discussion_staff()
response = self.patch_request(
{'always_divide_inline_discussions': True}
)
assert response.status_code == 403
def test_get_request_by_discussion_staff(self):
"""Test the response when get request is sent by a user with discussions staff role."""
self._login_as_discussion_staff()
divided_inline_discussions, divided_course_wide_discussions = self._create_divided_discussions()
response = self.client.get(self.path)
assert response.status_code == 200
expected_response = self._get_expected_response()
expected_response['divided_course_wide_discussions'] = [
topic_name_to_id(self.course, name) for name in divided_course_wide_discussions
]
expected_response['divided_inline_discussions'] = [
topic_name_to_id(self.course, name) for name in divided_inline_discussions
]
content = json.loads(response.content.decode('utf-8'))
assert content == expected_response
def test_get_request_by_non_staff_user(self):
"""Test the response when get request is sent by a regular user with no staff role."""
user = UserFactory(username='abc', password='abc')
self.client.login(username=user.username, password='abc')
response = self.client.get(self.path)
assert response.status_code == 403
def test_patch_request_by_non_staff_user(self):
"""Test the response when patch request is sent by a regular user with no staff role."""
user = UserFactory(username='abc', password='abc')
self.client.login(username=user.username, password='abc')
response = self.patch_request(
{'always_divide_inline_discussions': True}
)
assert response.status_code == 403
def test_get_settings(self):
"""Test the current discussion settings against the expected response."""
divided_inline_discussions, divided_course_wide_discussions = self._create_divided_discussions()
self._login_as_staff()
response = self.client.get(self.path)
assert response.status_code == 200
expected_response = self._get_expected_response()
expected_response['divided_course_wide_discussions'] = [
topic_name_to_id(self.course, name) for name in divided_course_wide_discussions
]
expected_response['divided_inline_discussions'] = [
topic_name_to_id(self.course, name) for name in divided_inline_discussions
]
content = json.loads(response.content.decode('utf-8'))
assert content == expected_response
def test_available_schemes(self):
"""Test the available division schemes against the expected response."""
config_course_cohorts(self.course, is_cohorted=False)
self._login_as_staff()
expected_response = self._get_expected_response()
expected_response['available_division_schemes'] = []
self._assert_current_settings(expected_response)
CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.AUDIT)
CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.VERIFIED)
expected_response['available_division_schemes'] = [CourseDiscussionSettings.ENROLLMENT_TRACK]
self._assert_current_settings(expected_response)
config_course_cohorts(self.course, is_cohorted=True)
expected_response['available_division_schemes'] = [
CourseDiscussionSettings.COHORT, CourseDiscussionSettings.ENROLLMENT_TRACK
]
self._assert_current_settings(expected_response)
def test_empty_body_patch_request(self):
"""Test the response status code on sending a PATCH request with an empty body or missing fields."""
self._login_as_staff()
response = self.patch_request("")
assert response.status_code == 400
response = self.patch_request({})
assert response.status_code == 400
@ddt.data(
{'abc': 123},
{'divided_course_wide_discussions': 3},
{'divided_inline_discussions': 'a'},
{'always_divide_inline_discussions': ['a']},
{'division_scheme': True}
)
def test_invalid_body_parameters(self, body):
"""Test the response status code on sending a PATCH request with parameters having incorrect types."""
self._login_as_staff()
response = self.patch_request(body)
assert response.status_code == 400
def test_update_always_divide_inline_discussion_settings(self):
"""Test whether the 'always_divide_inline_discussions' setting is updated."""
config_course_cohorts(self.course, is_cohorted=True)
self._login_as_staff()
expected_response = self._get_expected_response()
self._assert_current_settings(expected_response)
expected_response['always_divide_inline_discussions'] = True
self._assert_patched_settings({'always_divide_inline_discussions': True}, expected_response)
def test_update_course_wide_discussion_settings(self):
"""Test whether the 'divided_course_wide_discussions' setting is updated."""
discussion_topics = {
'Topic B': {'id': 'Topic B'}
}
config_course_cohorts(self.course, is_cohorted=True)
config_course_discussions(self.course, discussion_topics=discussion_topics)
expected_response = self._get_expected_response()
self._login_as_staff()
self._assert_current_settings(expected_response)
expected_response['divided_course_wide_discussions'] = [
topic_name_to_id(self.course, "Topic B")
]
self._assert_patched_settings(
{'divided_course_wide_discussions': [topic_name_to_id(self.course, "Topic B")]},
expected_response
)
expected_response['divided_course_wide_discussions'] = []
self._assert_patched_settings(
{'divided_course_wide_discussions': []},
expected_response
)
def test_update_inline_discussion_settings(self):
"""Test whether the 'divided_inline_discussions' setting is updated."""
config_course_cohorts(self.course, is_cohorted=True)
self._login_as_staff()
expected_response = self._get_expected_response()
self._assert_current_settings(expected_response)
now = datetime.now()
BlockFactory.create(
parent_location=self.course.location,
category='discussion',
discussion_id='Topic_A',
discussion_category='Chapter',
discussion_target='Discussion',
start=now
)
expected_response['divided_inline_discussions'] = ['Topic_A', ]
self._assert_patched_settings({'divided_inline_discussions': ['Topic_A']}, expected_response)
expected_response['divided_inline_discussions'] = []
self._assert_patched_settings({'divided_inline_discussions': []}, expected_response)
def test_update_division_scheme(self):
"""Test whether the 'division_scheme' setting is updated."""
config_course_cohorts(self.course, is_cohorted=True)
self._login_as_staff()
expected_response = self._get_expected_response()
self._assert_current_settings(expected_response)
expected_response['division_scheme'] = 'none'
self._assert_patched_settings({'division_scheme': 'none'}, expected_response)
def test_update_reported_content_email_notifications(self):
"""Test whether the 'reported_content_email_notifications' setting is updated."""
config_course_cohorts(self.course, is_cohorted=True)
config_course_discussions(self.course, reported_content_email_notifications=True)
expected_response = self._get_expected_response()
expected_response['reported_content_email_notifications'] = True
self._login_as_staff()
self._assert_current_settings(expected_response)
@ddt.ddt
class CourseDiscussionRolesAPIViewTest(APITestCase, UrlResetMixin, ModuleStoreTestCase):
"""
Test the course discussion roles management endpoint.
"""
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
self.course = CourseFactory.create(
org="x",
course="y",
run="z",
start=datetime.now(UTC),
)
self.password = self.TEST_PASSWORD
self.user = UserFactory(username='staff', password=self.password, is_staff=True)
course_key = CourseKey.from_string('course-v1:x+y+z')
seed_permissions_roles(course_key)
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def path(self, course_id=None, role=None):
"""Return the URL path to the endpoint based on the provided arguments."""
course_id = str(self.course.id) if course_id is None else course_id
role = 'Moderator' if role is None else role
return reverse(
'discussion_course_roles',
kwargs={'course_id': course_id, 'rolename': role}
)
def _get_oauth_headers(self, user):
"""Return the OAuth headers for testing OAuth authentication."""
access_token = AccessTokenFactory.create(user=user, application=ApplicationFactory()).token
headers = {
'HTTP_AUTHORIZATION': 'Bearer ' + access_token
}
return headers
def _login_as_staff(self):
"""Log the client is as the staff user."""
self.client.login(username=self.user.username, password=self.password)
def _create_and_enroll_users(self, count):
"""Create 'count' number of users and enroll them in self.course."""
users = []
for _ in range(count):
user = UserFactory()
CourseEnrollmentFactory.create(user=user, course_id=self.course.id)
users.append(user)
return users
def _add_users_to_role(self, users, rolename):
"""Add the given users to the given role."""
role = Role.objects.get(name=rolename, course_id=self.course.id)
for user in users:
role.users.add(user)
def post(self, role, user_id, action):
"""Make a POST request to the endpoint using the provided parameters."""
self._login_as_staff()
return self.client.post(self.path(role=role), {'user_id': user_id, 'action': action})
@ddt.data('get', 'post')
def test_authentication_required(self, method):
"""Test and verify that authentication is required for this endpoint."""
self.client.logout()
response = getattr(self.client, method)(self.path())
assert response.status_code == 401
def test_oauth(self):
"""Test that OAuth authentication works for this endpoint."""
oauth_headers = self._get_oauth_headers(self.user)
self.client.logout()
response = self.client.get(self.path(), **oauth_headers)
assert response.status_code == 200
body = {'user_id': 'staff', 'action': 'allow'}
response = self.client.post(self.path(), body, format='json', **oauth_headers)
assert response.status_code == 200
@ddt.data(
{'username': 'u1', 'is_staff': False, 'expected_status': 403},
{'username': 'u2', 'is_staff': True, 'expected_status': 200},
)
@ddt.unpack
def test_staff_permission_required(self, username, is_staff, expected_status):
"""Test and verify that only users with staff permission can access this endpoint."""
UserFactory(username=username, password='edx', is_staff=is_staff)
self.client.login(username=username, password='edx')
response = self.client.get(self.path())
assert response.status_code == expected_status
response = self.client.post(self.path(), {'user_id': username, 'action': 'allow'}, format='json')
assert response.status_code == expected_status
def test_non_existent_course_id(self):
"""Test the response when the endpoint URL contains a non-existent course id."""
self._login_as_staff()
path = self.path(course_id='course-v1:a+b+c')
response = self.client.get(path)
assert response.status_code == 404
response = self.client.post(path)
assert response.status_code == 404
def test_non_existent_course_role(self):
"""Test the response when the endpoint URL contains a non-existent role."""
self._login_as_staff()
path = self.path(role='A')
response = self.client.get(path)
assert response.status_code == 400
response = self.client.post(path)
assert response.status_code == 400
@ddt.data(
{'role': 'Moderator', 'count': 0},
{'role': 'Moderator', 'count': 1},
{'role': 'Group Moderator', 'count': 2},
{'role': 'Community TA', 'count': 3},
)
@ddt.unpack
def test_get_role_members(self, role, count):
"""Test the get role members endpoint response."""
config_course_cohorts(self.course, is_cohorted=True)
users = self._create_and_enroll_users(count=count)
self._add_users_to_role(users, role)
self._login_as_staff()
response = self.client.get(self.path(role=role))
assert response.status_code == 200
content = json.loads(response.content.decode('utf-8'))
assert content['course_id'] == 'course-v1:x+y+z'
assert len(content['results']) == count
expected_fields = ('username', 'email', 'first_name', 'last_name', 'group_name')
for item in content['results']:
for expected_field in expected_fields:
assert expected_field in item
assert content['division_scheme'] == 'cohort'
def test_post_missing_body(self):
"""Test the response with a POST request without a body."""
self._login_as_staff()
response = self.client.post(self.path())
assert response.status_code == 400
@ddt.data(
{'a': 1},
{'user_id': 'xyz', 'action': 'allow'},
{'user_id': 'staff', 'action': 123},
)
def test_missing_or_invalid_parameters(self, body):
"""
Test the response when the POST request has missing required parameters or
invalid values for the required parameters.
"""
self._login_as_staff()
response = self.client.post(self.path(), body)
assert response.status_code == 400
response = self.client.post(self.path(), body, format='json')
assert response.status_code == 400
@ddt.data(
{'action': 'allow', 'user_in_role': False},
{'action': 'allow', 'user_in_role': True},
{'action': 'revoke', 'user_in_role': False},
{'action': 'revoke', 'user_in_role': True}
)
@ddt.unpack
def test_post_update_user_role(self, action, user_in_role):
"""Test the response when updating the user's role"""
users = self._create_and_enroll_users(count=1)
user = users[0]
role = 'Moderator'
if user_in_role:
self._add_users_to_role(users, role)
response = self.post(role, user.username, action)
assert response.status_code == 200
content = json.loads(response.content.decode('utf-8'))
assertion = self.assertTrue if action == 'allow' else self.assertFalse
assertion(any(user.username in x['username'] for x in content['results']))

View File

@@ -635,7 +635,8 @@ class ForumMockUtilsMixin(MockForumApiMixin):
self.set_mock_return_value('get_thread', thread)
def register_get_comments_response(self, comments, page, num_pages):
self.set_mock_return_value('get_parent_comment', {
"""Register a mock response for get_user_comments API call."""
self.set_mock_return_value('get_user_comments', {
"collection": comments,
"page": page,
"num_pages": num_pages,
@@ -675,13 +676,21 @@ class ForumMockUtilsMixin(MockForumApiMixin):
}
self.set_mock_side_effect('get_user', make_user_callbacks(self.users_map))
def register_get_user_retire_response(self, user, body=""):
def register_get_user_retire_response(self, user, status=200, body=""):
self.set_mock_return_value('retire_user', body)
def register_get_username_replacement_response(self, user, status=200, body=""):
self.set_mock_return_value('update_username', body)
def register_subscribed_threads_response(self, user, threads, page, num_pages):
"""Register a mock response for get_user_threads and get_user_subscriptions API calls."""
self.set_mock_return_value('get_user_threads', {
"collection": threads,
"page": page,
"num_pages": num_pages,
"thread_count": len(threads),
})
# Also mock get_user_subscriptions for the Forum v2 API
self.set_mock_return_value('get_user_subscriptions', {
"collection": threads,
"page": page,

View File

@@ -7,19 +7,15 @@ from datetime import datetime
from unittest import mock
from unittest.mock import ANY, Mock, call, patch
import ddt
import pytest
from django.conf import settings
from django.test.client import Client, RequestFactory
from django.test.utils import override_settings
from django.urls import reverse
from django.utils import translation
from edx_django_utils.cache import RequestCache
from edx_toggles.toggles.testutils import override_waffle_flag
from xmodule.modulestore.tests.django_utils import (
TEST_DATA_SPLIT_MODULESTORE,
ModuleStoreTestCase,
SharedModuleStoreTestCase
)
from xmodule.modulestore.tests.factories import (
CourseFactory,
@@ -28,19 +24,17 @@ from xmodule.modulestore.tests.factories import (
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
from common.djangoapps.student.tests.factories import AdminFactory, CourseEnrollmentFactory, UserFactory
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
from common.djangoapps.util.testing import UrlResetMixin
from lms.djangoapps.courseware.exceptions import CourseAccessRedirect
from lms.djangoapps.discussion import views
from lms.djangoapps.discussion.django_comment_client.constants import TYPE_ENTRY, TYPE_SUBCATEGORY
from lms.djangoapps.discussion.django_comment_client.permissions import get_team
from lms.djangoapps.discussion.django_comment_client.tests.unicode import UnicodeTestMixin
from lms.djangoapps.discussion.django_comment_client.tests.utils import (
ForumsEnableMixin,
config_course_discussions,
topic_name_to_id
)
from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE
from lms.djangoapps.discussion.views import _get_discussion_default_topic_id, course_discussions_settings_handler
from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts
from openedx.core.djangoapps.course_groups.tests.test_views import CohortViewsTestCase
@@ -91,12 +85,6 @@ class ViewsExceptionTestCase(UrlResetMixin, ModuleStoreTestCase): # lint-amnest
config = ForumsConfig.current()
config.enabled = True
config.save()
patcher = mock.patch(
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
return_value=False
)
patcher.start()
self.addCleanup(patcher.stop)
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread"
)
@@ -336,12 +324,6 @@ class CommentsServiceRequestHeadersTestCase(ForumsEnableMixin, UrlResetMixin, Mo
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
patcher = mock.patch(
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
return_value=False
)
patcher.start()
self.addCleanup(patcher.stop)
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment"
)
@@ -409,36 +391,6 @@ class CommentsServiceRequestHeadersTestCase(ForumsEnableMixin, UrlResetMixin, Mo
self.assert_all_calls_have_header(mock_request, "X-Edx-Api-Key", "test_api_key")
class UserProfileUnicodeTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring
@classmethod
def setUpClass(cls):
# pylint: disable=super-method-not-called
with super().setUpClassAndTestData():
cls.course = CourseFactory.create()
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.student = UserFactory.create()
CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id)
@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True)
def _test_unicode_data(self, text, mock_request): # lint-amnesty, pylint: disable=missing-function-docstring
mock_request.side_effect = make_mock_request_impl(course=self.course, text=text)
request = RequestFactory().get("dummy_url")
request.user = self.student
# so (request.headers.get('x-requested-with') == 'XMLHttpRequest') == True
request.META["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest"
response = views.user_profile(request, str(self.course.id), str(self.student.id))
assert response.status_code == 200
response_data = json.loads(response.content.decode('utf-8'))
assert response_data['discussion_data'][0]['title'] == text
assert response_data['discussion_data'][0]['body'] == text
class EnrollmentTestCase(ForumsEnableMixin, ModuleStoreTestCase):
"""
Tests for the behavior of views depending on if the student is enrolled
@@ -746,76 +698,3 @@ class DefaultTopicIdGetterTestCase(ModuleStoreTestCase):
expected_id = 'another_discussion_id'
result = _get_discussion_default_topic_id(course)
assert expected_id == result
@ddt.ddt
@patch(
'openedx.core.djangoapps.django_comment_common.comment_client.utils.perform_request',
Mock(
return_value={
"id": "test_thread",
"title": "Title",
"body": "<p></p>",
"default_sort_key": "date",
"upvoted_ids": [],
"downvoted_ids": [],
"subscribed_thread_ids": [],
}
)
)
class ForumMFETestCase(ForumsEnableMixin, SharedModuleStoreTestCase):
"""
Tests that the MFE upgrade banner and MFE is shown in the correct situation with the correct UI
"""
def setUp(self):
super().setUp()
self.course = CourseFactory.create()
self.user = UserFactory.create()
self.staff_user = AdminFactory.create()
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
@override_settings(DISCUSSIONS_MICROFRONTEND_URL="http://test.url")
def test_redirect_from_legacy_base_url_to_new_experience(self):
"""
Verify that the legacy url is redirected to MFE homepage when
ENABLE_DISCUSSIONS_MFE flag is enabled.
"""
with override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True):
self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
url = reverse("forum_form_discussion", args=[self.course.id])
response = self.client.get(url)
assert response.status_code == 302
expected_url = f"{settings.DISCUSSIONS_MICROFRONTEND_URL}/{str(self.course.id)}"
assert response.url == expected_url
@override_settings(DISCUSSIONS_MICROFRONTEND_URL="http://test.url")
def test_redirect_from_legacy_profile_url_to_new_experience(self):
"""
Verify that the requested user profile is redirected to MFE learners tab when
ENABLE_DISCUSSIONS_MFE flag is enabled
"""
with override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True):
self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
url = reverse("user_profile", args=[self.course.id, self.user.id])
response = self.client.get(url)
assert response.status_code == 302
expected_url = f"{settings.DISCUSSIONS_MICROFRONTEND_URL}/{str(self.course.id)}/learners"
assert response.url == expected_url
@override_settings(DISCUSSIONS_MICROFRONTEND_URL="http://test.url")
def test_redirect_from_legacy_single_thread_to_new_experience(self):
"""
Verify that a legacy single url is redirected to corresponding MFE thread url when the ENABLE_DISCUSSIONS_MFE
flag is enabled
"""
with override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True):
self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
url = reverse("single_thread", args=[self.course.id, "test_discussion", "test_thread"])
response = self.client.get(url)
assert response.status_code == 302
expected_url = f"{settings.DISCUSSIONS_MICROFRONTEND_URL}/{str(self.course.id)}/posts/test_thread"
assert response.url == expected_url

View File

@@ -2022,3 +2022,115 @@ class FollowedThreadsUnicodeTestCase(
response_data = json.loads(response.content.decode('utf-8'))
assert response_data['discussion_data'][0]['title'] == text
assert response_data['discussion_data'][0]['body'] == text
class UserProfileUnicodeTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin, ForumViewsUtilsMixin): # lint-amnesty, pylint: disable=missing-class-docstring
@classmethod
def setUpClass(cls):
# pylint: disable=super-method-not-called
with super().setUpClassAndTestData():
cls.course = CourseFactory.create()
super().setUpClassAndForumMock()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
super().disposeForumMocks()
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.student = UserFactory.create()
CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id)
def _test_unicode_data(self, text): # lint-amnesty, pylint: disable=missing-function-docstring
self._configure_mock_responses(course=self.course, text=text)
request = RequestFactory().get("dummy_url")
request.user = self.student
# so (request.headers.get('x-requested-with') == 'XMLHttpRequest') == True
request.META["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest"
response = views.user_profile(request, str(self.course.id), str(self.student.id))
assert response.status_code == 200
response_data = json.loads(response.content.decode('utf-8'))
assert response_data['discussion_data'][0]['title'] == text
assert response_data['discussion_data'][0]['body'] == text
class ForumMFETestCase(ForumsEnableMixin, SharedModuleStoreTestCase, ModuleStoreTestCase, MockForumApiMixin): # lint-amnesty, pylint: disable=missing-class-docstring
"""
Tests that the MFE upgrade banner and MFE is shown in the correct situation with the correct UI
"""
def setUp(self):
super().setUp()
self.course = CourseFactory.create()
self.user = UserFactory.create()
self.staff_user = AdminFactory.create()
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
self.set_mock_return_value("get_user", {
"id": "test_thread",
"title": "Title",
"body": "<p></p>",
"default_sort_key": "date",
"upvoted_ids": [],
"downvoted_ids": [],
"subscribed_thread_ids": [],
})
self.set_mock_return_value("get_user_active_threads", {})
@classmethod
def setUpClass(cls):
super().setUpClass()
super().setUpClassAndForumMock()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
super().disposeForumMocks()
@override_settings(DISCUSSIONS_MICROFRONTEND_URL="http://test.url")
def test_redirect_from_legacy_base_url_to_new_experience(self):
"""
Verify that the legacy url is redirected to MFE homepage when
ENABLE_DISCUSSIONS_MFE flag is enabled.
"""
with override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True):
self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
url = reverse("forum_form_discussion", args=[self.course.id])
response = self.client.get(url)
assert response.status_code == 302
expected_url = f"{settings.DISCUSSIONS_MICROFRONTEND_URL}/{str(self.course.id)}"
assert response.url == expected_url
@override_settings(DISCUSSIONS_MICROFRONTEND_URL="http://test.url")
def test_redirect_from_legacy_profile_url_to_new_experience(self):
"""
Verify that the requested user profile is redirected to MFE learners tab when
ENABLE_DISCUSSIONS_MFE flag is enabled
"""
with override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True):
self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
url = reverse("user_profile", args=[self.course.id, self.user.id])
response = self.client.get(url)
assert response.status_code == 302
expected_url = f"{settings.DISCUSSIONS_MICROFRONTEND_URL}/{str(self.course.id)}/learners"
assert response.url == expected_url
@override_settings(DISCUSSIONS_MICROFRONTEND_URL="http://test.url")
def test_redirect_from_legacy_single_thread_to_new_experience(self):
"""
Verify that a legacy single url is redirected to corresponding MFE thread url when the ENABLE_DISCUSSIONS_MFE
flag is enabled
"""
with override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True):
self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
url = reverse("single_thread", args=[self.course.id, "test_discussion", "test_thread"])
response = self.client.get(url)
assert response.status_code == 302
expected_url = f"{settings.DISCUSSIONS_MICROFRONTEND_URL}/{str(self.course.id)}/posts/test_thread"
assert response.url == expected_url

View File

@@ -2,8 +2,6 @@
This module contains various configuration settings via
waffle switches for the discussions app.
"""
from django.conf import settings
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag
WAFFLE_FLAG_NAMESPACE = "discussions"
@@ -45,31 +43,3 @@ ENABLE_PAGES_AND_RESOURCES_MICROFRONTEND = CourseWaffleFlag(
ENABLE_NEW_STRUCTURE_DISCUSSIONS = CourseWaffleFlag(
f"{WAFFLE_FLAG_NAMESPACE}.enable_new_structure_discussions", __name__
)
# .. toggle_name: discussions.enable_forum_v2
# .. toggle_implementation: CourseWaffleFlag
# .. toggle_default: False
# .. toggle_description: Waffle flag to use the forum v2 instead of v1(cs_comment_service)
# .. toggle_use_cases: temporary, open_edx
# .. toggle_creation_date: 2024-9-26
# .. toggle_target_removal_date: 2025-12-05
ENABLE_FORUM_V2 = CourseWaffleFlag(f"{WAFFLE_FLAG_NAMESPACE}.enable_forum_v2", __name__)
def is_forum_v2_enabled(course_key):
"""
Returns whether forum V2 is enabled on the course. This is a 2-step check:
1. Check value of settings.DISABLE_FORUM_V2: if it exists and is true, this setting overrides any course flag.
2. Else, check the value of the corresponding course waffle flag.
"""
if is_forum_v2_disabled_globally():
return False
return ENABLE_FORUM_V2.is_enabled(course_key)
def is_forum_v2_disabled_globally() -> bool:
"""
Return True if DISABLE_FORUM_V2 is defined and true-ish.
"""
return getattr(settings, "DISABLE_FORUM_V2", False)

View File

@@ -104,6 +104,32 @@ class Comment(models.Model):
soup = BeautifulSoup(self.body, 'html.parser')
return soup.get_text()
@classmethod
def retrieve_all(cls, params=None):
"""
Retrieve all comments for a user in a course using Forum v2 API.
Arguments:
params: Dictionary with keys:
- user_id: The ID of the user
- course_id: The ID of the course
- flagged: Boolean for flagged comments
- page: Page number
- per_page: Items per page
Returns:
Dictionary with collection, comment_count, num_pages, page
"""
if params is None:
params = {}
return forum_api.get_user_comments(
user_id=params.get('user_id'),
course_id=params.get('course_id'),
flagged=params.get('flagged', False),
page=params.get('page', 1),
per_page=params.get('per_page', 10),
)
@classmethod
def get_user_comment_count(cls, user_id, course_ids):
"""
@@ -149,11 +175,3 @@ def _url_for_thread_comments(thread_id):
def _url_for_comment(comment_id):
return f"{settings.PREFIX}/comments/{comment_id}"
def _url_for_flag_abuse_comment(comment_id):
return f"{settings.PREFIX}/comments/{comment_id}/abuse_flag"
def _url_for_unflag_abuse_comment(comment_id):
return f"{settings.PREFIX}/comments/{comment_id}/abuse_unflag"

View File

@@ -8,9 +8,6 @@ from edx_django_utils.monitoring import function_trace
from opaque_keys.edx.keys import CourseKey
from forum import api as forum_api
from openedx.core.djangoapps.django_comment_common.comment_client import settings
from openedx.core.djangoapps.django_comment_common.comment_client.utils import perform_request
from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled
def get_course_commentable_counts(course_key: CourseKey) -> Dict[str, Dict[str, int]]:
@@ -31,19 +28,7 @@ def get_course_commentable_counts(course_key: CourseKey) -> Dict[str, Dict[str,
}
"""
if is_forum_v2_enabled(course_key):
commentable_stats = forum_api.get_commentables_stats(str(course_key))
else:
url = f"{settings.PREFIX}/commentables/{course_key}/counts"
commentable_stats = perform_request(
'get',
url,
metric_tags=[
f"course_key:{course_key}",
"function:get_course_commentable_counts",
],
metric_action='commentable_stats.retrieve',
)
commentable_stats = forum_api.get_commentables_stats(str(course_key))
return commentable_stats
@@ -81,20 +66,7 @@ def get_course_user_stats(course_key: CourseKey, params: Optional[Dict] = None)
"""
if params is None:
params = {}
if is_forum_v2_enabled(course_key):
course_stats = forum_api.get_user_course_stats(str(course_key), **params)
else:
url = f"{settings.PREFIX}/users/{course_key}/stats"
course_stats = perform_request(
'get',
url,
params,
metric_action='user.course_stats',
metric_tags=[
f"course_key:{course_key}",
"function:get_course_user_stats",
],
)
course_stats = forum_api.get_user_course_stats(str(course_key), **params)
return course_stats
@@ -109,17 +81,5 @@ def update_course_users_stats(course_key: CourseKey) -> Dict:
Returns:
dict: data returned by API. Contains count of users updated.
"""
if is_forum_v2_enabled(course_key):
course_stats = forum_api.update_users_in_course(str(course_key))
else:
url = f"{settings.PREFIX}/users/{course_key}/update_stats"
course_stats = perform_request(
'post',
url,
metric_action='user.update_course_stats',
metric_tags=[
f"course_key:{course_key}",
"function:update_course_users_stats",
],
)
course_stats = forum_api.update_users_in_course(str(course_key))
return course_stats

View File

@@ -2,11 +2,9 @@
import logging
import typing as t
from .utils import CommentClientRequestError, extract, perform_request, get_course_key
from forum import api as forum_api
from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled, is_forum_v2_disabled_globally
log = logging.getLogger(__name__)
@@ -74,10 +72,11 @@ class Model:
def _retrieve(self, *args, **kwargs):
course_id = self.attributes.get("course_id") or kwargs.get("course_key")
if not course_id:
_, course_id = is_forum_v2_enabled_for_comment(self.id)
course_id = forum_api.get_course_id_by_comment(self.id)
response = None
if self.type == "comment":
response = forum_api.get_parent_comment(comment_id=self.attributes["id"], course_id=course_id)
else:
if response is None:
raise CommentClientRequestError("Forum v2 API call is missing")
self._update_from_response(response)
@@ -206,18 +205,15 @@ class Model:
request_params.update(params)
course_id = self.attributes.get("course_id") or request_params.get("course_id")
course_key = get_course_key(course_id)
if is_forum_v2_enabled(course_key):
response = None
if self.type == "comment":
response = self.handle_update_comment(request_params, str(course_key))
elif self.type == "thread":
response = self.handle_update_thread(request_params, str(course_key))
elif self.type == "user":
response = self.handle_update_user(request_params, str(course_key))
if response is None:
raise CommentClientRequestError("Forum v2 API call is missing")
else:
response = self.perform_http_put_request(request_params)
response = None
if self.type == "comment":
response = self.handle_update_comment(request_params, str(course_key))
elif self.type == "thread":
response = self.handle_update_thread(request_params, str(course_key))
elif self.type == "user":
response = self.handle_update_user(request_params, str(course_key))
if response is None:
raise CommentClientRequestError("Forum v2 API call is missing")
return response
def handle_update_user(self, request_params, course_id):
@@ -274,28 +270,6 @@ class Model:
response = forum_api.update_thread(**request_data)
return response
def perform_http_put_request(self, request_params):
url = self.url(action="put", params=self.attributes)
response = perform_request(
"put",
url,
request_params,
metric_tags=self._metric_tags,
metric_action="model.update",
)
return response
def perform_http_post_request(self):
url = self.url(action="post", params=self.attributes)
response = perform_request(
"post",
url,
self.initializable_attributes(),
metric_tags=self._metric_tags,
metric_action="model.insert",
)
return response
def handle_create(self, params=None):
course_id = self.attributes.get("course_id") or params.get("course_id")
course_key = str(get_course_key(course_id))
@@ -348,22 +322,3 @@ class Model:
response = forum_api.create_thread(**params)
return response
def is_forum_v2_enabled_for_comment(comment_id: str) -> tuple[bool, t.Optional[str]]:
"""
Figure out whether we use forum v2 for a given comment.
See is_forum_v2_enabled_for_thread.
Return:
enabled (bool)
course_id (str or None)
"""
if is_forum_v2_disabled_globally():
return False, None
course_id = forum_api.get_course_id_by_comment(comment_id)
course_key = get_course_key(course_id)
return is_forum_v2_enabled(course_key), course_id

View File

@@ -3,19 +3,12 @@
import logging
import time
import typing as t
from eventtracking import tracker
from django.core.exceptions import ObjectDoesNotExist
from forum import api as forum_api
from forum.api.threads import prepare_thread_api_response
from forum.backend import get_backend
from forum.backends.mongodb.threads import CommentThread
from forum.utils import ForumV2RequestError
from rest_framework.serializers import ValidationError
from forum.backends.mongodb.threads import CommentThread as ForumThread
from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled, is_forum_v2_disabled_globally
from . import models, settings, utils
@@ -169,7 +162,7 @@ class Thread(models.Model):
request_params = utils.clean_forum_params(request_params)
course_id = kwargs.get("course_id")
if not course_id:
_, course_id = is_forum_v2_enabled_for_thread(self.id)
course_id = forum_api.get_course_id_by_thread(self.id)
if user_id := request_params.get('user_id'):
request_params['user_id'] = str(user_id)
response = forum_api.get_thread(
@@ -228,7 +221,7 @@ class Thread(models.Model):
@classmethod
def get_user_threads_count(cls, user_id, course_ids):
"""
Returns threads and responses count of user in the given course_ids.
Returns threads count of user in the given course_ids.
TODO: Add support for MySQL backend as well
"""
query_params = {
@@ -236,51 +229,7 @@ class Thread(models.Model):
"author_id": str(user_id),
"_type": "CommentThread"
}
return CommentThread()._collection.count_documents(query_params) # pylint: disable=protected-access
@classmethod
def _delete_thread(cls, thread_id, course_id=None):
"""
Deletes a thread
"""
prefix = "<<Bulk Delete Thread>>"
backend = get_backend(course_id)()
try:
start_time = time.perf_counter()
thread = backend.validate_object("CommentThread", thread_id)
log.info(f"{prefix} Thread fetch {time.perf_counter() - start_time} sec")
except ObjectDoesNotExist as exc:
log.error("Forumv2RequestError for delete thread request.")
raise ForumV2RequestError(
f"Thread does not exist with Id: {thread_id}"
) from exc
start_time = time.perf_counter()
backend.delete_comments_of_a_thread(thread_id)
log.info(f"{prefix} Delete comments of thread {time.perf_counter() - start_time} sec")
try:
start_time = time.perf_counter()
serialized_data = prepare_thread_api_response(thread, backend)
log.info(f"{prefix} Prepare response {time.perf_counter() - start_time} sec")
except ValidationError as error:
log.error(f"Validation error in get_thread: {error}")
raise ForumV2RequestError("Failed to prepare thread API response") from error
start_time = time.perf_counter()
backend.delete_subscriptions_of_a_thread(thread_id)
log.info(f"{prefix} Delete subscriptions {time.perf_counter() - start_time} sec")
start_time = time.perf_counter()
result = backend.delete_thread(thread_id)
log.info(f"{prefix} Delete thread {time.perf_counter() - start_time} sec")
if result and not (thread["anonymous"] or thread["anonymous_to_peers"]):
start_time = time.perf_counter()
backend.update_stats_for_course(
thread["author_id"], thread["course_id"], threads=-1
)
log.info(f"{prefix} Update stats {time.perf_counter() - start_time} sec")
return serialized_data
return ForumThread()._collection.count_documents(query_params) # pylint: disable=protected-access
@classmethod
def delete_user_threads(cls, user_id, course_ids):
@@ -294,56 +243,32 @@ class Thread(models.Model):
"author_id": str(user_id),
}
threads_deleted = 0
threads = CommentThread().get_list(**query_params)
threads = ForumThread().get_list(**query_params)
log.info(f"<<Bulk Delete>> Fetched threads for user {user_id} in {time.time() - start_time} seconds")
for thread in threads:
start_time = time.time()
thread_id = thread.get("_id")
course_id = thread.get("course_id")
if thread_id:
cls._delete_thread(thread_id, course_id=course_id)
forum_api.delete_thread(thread_id, course_id=course_id)
threads_deleted += 1
log.info(f"<<Bulk Delete>> Deleted thread {thread_id} in {time.time() - start_time} seconds."
f" Thread Found: {thread_id is not None}")
return threads_deleted
def _url_for_flag_abuse_thread(thread_id):
return f"{settings.PREFIX}/threads/{thread_id}/abuse_flag"
def _url_for_unflag_abuse_thread(thread_id):
return f"{settings.PREFIX}/threads/{thread_id}/abuse_unflag"
def _url_for_pin_thread(thread_id):
return f"{settings.PREFIX}/threads/{thread_id}/pin"
def _url_for_un_pin_thread(thread_id):
return f"{settings.PREFIX}/threads/{thread_id}/unpin"
def is_forum_v2_enabled_for_thread(thread_id: str) -> tuple[bool, t.Optional[str]]:
"""
Figure out whether we use forum v2 for a given thread.
This is a complex affair... First, we check the value of the DISABLE_FORUM_V2
setting, which overrides everything. If this setting does not exist, then we need to
find the course ID that corresponds to the thread ID. Then, we return the value of
the course waffle flag for this course ID.
Note that to fetch the course ID associated to a thread ID, we need to connect both
to mongodb and mysql. As a consequence, when forum v2 needs adequate connection
strings for both backends.
Return:
enabled (bool)
course_id (str or None)
"""
if is_forum_v2_disabled_globally():
return False, None
course_id = forum_api.get_course_id_by_thread(thread_id)
course_key = utils.get_course_key(course_id)
return is_forum_v2_enabled(course_key), course_id
def _clean_forum_params(params):
"""Convert string booleans to actual booleans and remove None values from forum parameters."""
result = {}
for k, v in params.items():
if v is not None:
if isinstance(v, str):
if v.lower() == 'true':
result[k] = True
elif v.lower() == 'false':
result[k] = False
else:
result[k] = v
else:
result[k] = v
return result

View File

@@ -4,7 +4,6 @@
from . import models, settings, utils
from forum import api as forum_api
from forum.utils import ForumV2RequestError, str_to_bool
from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled
class User(models.Model):
@@ -38,17 +37,7 @@ class User(models.Model):
"""
course_id = self.attributes.get("course_id")
course_key = utils.get_course_key(course_id)
if is_forum_v2_enabled(course_key):
forum_api.mark_thread_as_read(self.id, source.id, course_id=str(course_id))
else:
params = {'source_type': source.type, 'source_id': source.id}
utils.perform_request(
'post',
_url_for_read(self.id),
params,
metric_action='user.read',
metric_tags=self._metric_tags + [f'target.type:{source.type}'],
)
forum_api.mark_thread_as_read(self.id, source.id, course_id=str(course_id))
def follow(self, source, course_id=None):
course_key = utils.get_course_key(self.attributes.get("course_id") or course_id)
@@ -110,31 +99,21 @@ class User(models.Model):
query_params = {}
if not self.course_id:
raise utils.CommentClientRequestError("Must provide course_id when retrieving active threads for the user")
url = _url_for_user_active_threads(self.id)
params = {'course_id': str(self.course_id)}
params.update(query_params)
course_key = utils.get_course_key(self.attributes.get("course_id"))
if is_forum_v2_enabled(course_key):
if user_id := params.get("user_id"):
params["user_id"] = str(user_id)
if page := params.get("page"):
params["page"] = int(page)
if per_page := params.get("per_page"):
params["per_page"] = int(per_page)
if count_flagged := params.get("count_flagged", False):
params["count_flagged"] = str_to_bool(count_flagged)
if not params.get("course_id"):
params["course_id"] = str(course_key)
response = forum_api.get_user_active_threads(**params)
else:
response = utils.perform_request(
'get',
url,
params,
metric_action='user.active_threads',
metric_tags=self._metric_tags,
paged_results=True,
)
if user_id := params.get("user_id"):
params["user_id"] = str(user_id)
if page := params.get("page"):
params["page"] = int(page)
if per_page := params.get("per_page"):
params["per_page"] = int(per_page)
if count_flagged := params.get("count_flagged", False):
params["count_flagged"] = str_to_bool(count_flagged)
if not params.get("course_id"):
params["course_id"] = str(course_key)
params = _clean_forum_params(params)
response = forum_api.get_user_active_threads(**params)
return response.get('collection', []), response.get('page', 1), response.get('num_pages', 1)
def subscribed_threads(self, query_params=None):
@@ -157,9 +136,7 @@ class User(models.Model):
params["count_flagged"] = str_to_bool(count_flagged)
if not params.get("course_id"):
params["course_id"] = str(course_key)
if 'text' in params:
params.pop('text')
params = utils.clean_forum_params(params)
params = _clean_forum_params(params)
response = forum_api.get_user_subscriptions(**params)
return utils.CommentClientPaginatedResult(
collection=response.get('collection', []),
@@ -169,7 +146,6 @@ class User(models.Model):
)
def _retrieve(self, *args, **kwargs):
url = self.url(action='get', params=self.attributes)
retrieve_params = self.default_retrieve_params.copy()
retrieve_params.update(kwargs)
@@ -183,116 +159,43 @@ class User(models.Model):
if course_id:
course_id = str(course_id)
retrieve_params['course_id'] = course_id
course_key = utils.get_course_key(course_id) or utils.get_course_key(kwargs.get("course_key"))
if is_forum_v2_enabled(course_key):
group_ids = [retrieve_params['group_id']] if 'group_id' in retrieve_params else []
is_complete = retrieve_params['complete']
params = utils.clean_forum_params({
"user_id": self.attributes["id"],
"group_ids": group_ids,
"course_id": course_id,
"complete": is_complete
})
try:
response = forum_api.get_user(**params)
except ForumV2RequestError as e:
course_id = str(course_key)
self.save({"course_id": course_id})
response = forum_api.get_user(**params)
else:
try:
response = utils.perform_request(
'get',
url,
retrieve_params,
metric_action='model.retrieve',
metric_tags=self._metric_tags,
)
except utils.CommentClientRequestError as e:
if e.status_code == 404:
# attempt to gracefully recover from a previous failure
# to sync this user to the comments service.
self.save()
response = utils.perform_request(
'get',
url,
retrieve_params,
metric_action='model.retrieve',
metric_tags=self._metric_tags,
)
else:
raise
group_ids = [retrieve_params['group_id']] if 'group_id' in retrieve_params else None
is_complete = retrieve_params['complete']
params = _clean_forum_params({
"user_id": self.attributes["id"],
"group_ids": group_ids,
"course_id": course_id,
"complete": is_complete
})
try:
response = forum_api.get_user(**params)
except ForumV2RequestError as e:
self.save({"course_id": course_id})
response = forum_api.get_user(**params)
self._update_from_response(response)
def retire(self, retired_username):
course_key = utils.get_course_key(self.attributes.get("course_id"))
if is_forum_v2_enabled(course_key):
forum_api.retire_user(user_id=self.id, retired_username=retired_username, course_id=str(course_key))
else:
url = _url_for_retire(self.id)
params = {'retired_username': retired_username}
utils.perform_request(
'post',
url,
params,
raw=True,
metric_action='user.retire',
metric_tags=self._metric_tags
)
forum_api.retire_user(user_id=self.id, retired_username=retired_username, course_id=str(course_key))
def replace_username(self, new_username):
course_key = utils.get_course_key(self.attributes.get("course_id"))
if is_forum_v2_enabled(course_key):
forum_api.update_username(user_id=self.id, new_username=new_username, course_id=str(course_key))
else:
url = _url_for_username_replacement(self.id)
params = {"new_username": new_username}
utils.perform_request(
'post',
url,
params,
raw=True,
)
forum_api.update_username(user_id=self.id, new_username=new_username, course_id=str(course_key))
def _url_for_vote_comment(comment_id):
return f"{settings.PREFIX}/comments/{comment_id}/votes"
def _url_for_vote_thread(thread_id):
return f"{settings.PREFIX}/threads/{thread_id}/votes"
def _url_for_subscription(user_id):
return f"{settings.PREFIX}/users/{user_id}/subscriptions"
def _url_for_user_active_threads(user_id):
return f"{settings.PREFIX}/users/{user_id}/active_threads"
def _url_for_user_subscribed_threads(user_id):
return f"{settings.PREFIX}/users/{user_id}/subscribed_threads"
def _url_for_read(user_id):
"""
Returns cs_comments_service url endpoint to mark thread as read for given user_id
"""
return f"{settings.PREFIX}/users/{user_id}/read"
def _url_for_retire(user_id):
"""
Returns cs_comments_service url endpoint to retire a user (remove all post content, etc.)
"""
return f"{settings.PREFIX}/users/{user_id}/retire"
def _url_for_username_replacement(user_id):
"""
Returns cs_comments_servuce url endpoint to replace the username of a user
"""
return f"{settings.PREFIX}/users/{user_id}/replace_username"
def _clean_forum_params(params):
"""Convert string booleans to actual booleans and remove None values from forum parameters."""
result = {}
for k, v in params.items():
if v is not None:
if isinstance(v, str):
if v.lower() == 'true':
result[k] = True
elif v.lower() == 'false':
result[k] = False
else:
result[k] = v
else:
result[k] = v
return result