Files
edx-platform/lms/djangoapps/discussion/rest_api/tests/test_api.py
Ahtisham Shahid c2a86534e6 fix: updated captcha api to use enterprise assessment (#37079)
* fix: updated captcha api to use enterprise assessment
2025-08-03 21:27:12 +00:00

3337 lines
131 KiB
Python

"""
Tests for Discussion API internal interface
"""
import itertools
import random
from datetime import datetime, timedelta
from unittest import mock
from urllib.parse import urlencode, urlparse, urlunparse
import ddt
import httpretty
import pytest
from django.test import override_settings
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.test.client import RequestFactory
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import CourseLocator
from pytz import UTC
from rest_framework.exceptions import PermissionDenied
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory
from xmodule.partitions.partitions import Group, UserPartition
from common.djangoapps.student.tests.factories import (
AdminFactory,
BetaTesterFactory,
CourseEnrollmentFactory,
StaffFactory,
UserFactory
)
from common.djangoapps.util.testing import UrlResetMixin
from common.test.utils import MockSignalHandlerMixin, disable_signal
from lms.djangoapps.discussion.django_comment_client.tests.utils import ForumsEnableMixin
from lms.djangoapps.discussion.rest_api import api
from lms.djangoapps.discussion.rest_api.api import (
create_comment,
create_thread,
delete_comment,
delete_thread,
get_comment_list,
get_course,
get_course_topics,
get_course_topics_v2,
get_thread,
get_user_comments,
update_comment,
update_thread
)
from lms.djangoapps.discussion.rest_api.exceptions import (
CommentNotFoundError,
DiscussionBlackOutException,
DiscussionDisabledError,
ThreadNotFoundError
)
from lms.djangoapps.discussion.rest_api.serializers import TopicOrdering
from lms.djangoapps.discussion.rest_api.tests.utils import (
CommentsServiceMockMixin,
make_minimal_cs_comment,
make_minimal_cs_thread,
make_paginated_api_response,
parsed_body,
)
from openedx.core.djangoapps.course_groups.models import CourseUserGroupPartitionGroup
from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, DiscussionTopicLink, Provider, \
PostingRestriction
from openedx.core.djangoapps.discussions.tasks import update_discussions_settings_from_course_task
from openedx.core.djangoapps.django_comment_common.models import (
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_STUDENT,
Role
)
from openedx.core.lib.exceptions import CourseNotFoundError, PageNotFoundError
User = get_user_model()
def _remove_discussion_tab(course, user_id):
"""
Remove the discussion tab for the course.
user_id is passed to the modulestore as the editor of the xblock.
"""
course.tabs = [tab for tab in course.tabs if not tab.type == 'discussion']
modulestore().update_item(course, user_id)
def _discussion_disabled_course_for(user):
"""
Create and return a course with discussions disabled.
The user passed in will be enrolled in the course.
"""
course_with_disabled_forums = CourseFactory.create()
CourseEnrollmentFactory.create(user=user, course_id=course_with_disabled_forums.id)
_remove_discussion_tab(course_with_disabled_forums, user.id)
return course_with_disabled_forums
def _assign_role_to_user(user, course_id, role):
"""
Unset the blackout period for course discussions.
Arguments:
user: User to assign role to
course_id: Course id of the course user will be assigned role in
role: Role assigned to user for course
"""
role = Role.objects.create(name=role, course_id=course_id)
role.users.set([user])
def _create_course_and_cohort_with_user_role(course_is_cohorted, user, role_name):
"""
Creates a course with the value of `course_is_cohorted`, plus `always_cohort_inline_discussions`
set to True (which is no longer the default value). Then 1) enrolls the user in that course,
2) creates a cohort that the user is placed in, and 3) adds the user to the given role.
Returns: a tuple of the created course and the created cohort
"""
cohort_course = CourseFactory.create(
cohort_config={"cohorted": course_is_cohorted, "always_cohort_inline_discussions": True}
)
CourseEnrollmentFactory.create(user=user, course_id=cohort_course.id)
cohort = CohortFactory.create(course_id=cohort_course.id, users=[user])
_assign_role_to_user(user=user, course_id=cohort_course.id, role=role_name)
return [cohort_course, cohort]
def _set_course_discussion_blackout(course, user_id):
"""
Set the blackout period for course discussions.
Arguments:
course: Course for which blackout period is set
user_id: User id of user enrolled in the course
"""
course.discussion_blackouts = [
datetime.now(UTC) - timedelta(days=3),
datetime.now(UTC) + timedelta(days=3)
]
configuration = DiscussionsConfiguration.get(course.id)
configuration.posting_restrictions = PostingRestriction.SCHEDULED
configuration.save()
modulestore().update_item(course, user_id)
@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 GetCommentListTest(ForumsEnableMixin, CommentsServiceMockMixin, SharedModuleStoreTestCase):
"""Test for get_comment_list"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.course = CourseFactory.create()
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
httpretty.reset()
httpretty.enable()
self.addCleanup(httpretty.reset)
self.addCleanup(httpretty.disable)
patcher = mock.patch(
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
return_value=False
)
patcher.start()
self.addCleanup(patcher.stop)
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment"
)
self.mock_get_course_id_by_comment = patcher.start()
self.addCleanup(patcher.stop)
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread"
)
self.mock_get_course_id_by_thread = patcher.start()
self.addCleanup(patcher.stop)
self.maxDiff = None # pylint: disable=invalid-name
self.user = UserFactory.create()
self.register_get_user_response(self.user)
self.request = RequestFactory().get("/test_path")
self.request.user = self.user
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
self.author = UserFactory.create()
def make_minimal_cs_thread(self, overrides=None):
"""
Create a thread with the given overrides, plus the course_id if not
already in overrides.
"""
overrides = overrides.copy() if overrides else {}
overrides.setdefault("course_id", str(self.course.id))
return make_minimal_cs_thread(overrides)
def get_comment_list(self, thread, endorsed=None, page=1, page_size=1,
merge_question_type_responses=False):
"""
Register the appropriate comments service response, then call
get_comment_list and return the result.
"""
self.register_get_thread_response(thread)
return get_comment_list(self.request, thread["id"], endorsed, page, page_size,
merge_question_type_responses=merge_question_type_responses)
def test_nonexistent_thread(self):
thread_id = "nonexistent_thread"
self.register_get_thread_error_response(thread_id, 404)
with pytest.raises(ThreadNotFoundError):
get_comment_list(self.request, thread_id, endorsed=False, page=1, page_size=1)
def test_nonexistent_course(self):
with pytest.raises(CourseNotFoundError):
self.get_comment_list(self.make_minimal_cs_thread({"course_id": "course-v1:non+existent+course"}))
def test_not_enrolled(self):
self.request.user = UserFactory.create()
with pytest.raises(CourseNotFoundError):
self.get_comment_list(self.make_minimal_cs_thread())
def test_discussions_disabled(self):
disabled_course = _discussion_disabled_course_for(self.user)
with pytest.raises(DiscussionDisabledError):
self.get_comment_list(
self.make_minimal_cs_thread(
overrides={"course_id": str(disabled_course.id)}
)
)
@ddt.data(
*itertools.product(
[
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_STUDENT,
],
[True, False],
[True, False],
["no_group", "match_group", "different_group"],
)
)
@ddt.unpack
def test_group_access(
self,
role_name,
course_is_cohorted,
topic_is_cohorted,
thread_group_state
):
cohort_course = CourseFactory.create(
discussion_topics={"Test Topic": {"id": "test_topic"}},
cohort_config={
"cohorted": course_is_cohorted,
"cohorted_discussions": ["test_topic"] if topic_is_cohorted else [],
}
)
CourseEnrollmentFactory.create(user=self.user, course_id=cohort_course.id)
cohort = CohortFactory.create(course_id=cohort_course.id, users=[self.user])
_assign_role_to_user(user=self.user, course_id=cohort_course.id, role=role_name)
thread = self.make_minimal_cs_thread({
"course_id": str(cohort_course.id),
"commentable_id": "test_topic",
"group_id": (
None if thread_group_state == "no_group" else
cohort.id if thread_group_state == "match_group" else
cohort.id + 1
),
})
expected_error = (
role_name == FORUM_ROLE_STUDENT and
course_is_cohorted and
topic_is_cohorted and
thread_group_state == "different_group"
)
try:
self.get_comment_list(thread)
assert not expected_error
except ThreadNotFoundError:
assert expected_error
@ddt.data(True, False)
def test_discussion_endorsed(self, endorsed_value):
with pytest.raises(ValidationError) as assertion:
self.get_comment_list(
self.make_minimal_cs_thread({"thread_type": "discussion"}),
endorsed=endorsed_value
)
assert assertion.value.message_dict == {'endorsed': ['This field may not be specified for discussion threads.']}
def test_question_without_endorsed(self):
with pytest.raises(ValidationError) as assertion:
self.get_comment_list(
self.make_minimal_cs_thread({"thread_type": "question"}),
endorsed=None
)
assert assertion.value.message_dict == {'endorsed': ['This field is required for question threads.']}
def test_empty(self):
discussion_thread = self.make_minimal_cs_thread(
{"thread_type": "discussion", "children": [], "resp_total": 0}
)
assert self.get_comment_list(discussion_thread).data == make_paginated_api_response(
results=[], count=0, num_pages=1, next_link=None, previous_link=None)
question_thread = self.make_minimal_cs_thread({
"thread_type": "question",
"endorsed_responses": [],
"non_endorsed_responses": [],
"non_endorsed_resp_total": 0
})
assert self.get_comment_list(question_thread, endorsed=False).data == make_paginated_api_response(
results=[], count=0, num_pages=1, next_link=None, previous_link=None)
assert self.get_comment_list(question_thread, endorsed=True).data == make_paginated_api_response(
results=[], count=0, num_pages=1, next_link=None, previous_link=None)
def test_basic_query_params(self):
self.get_comment_list(
self.make_minimal_cs_thread({
"children": [make_minimal_cs_comment({"username": self.user.username})],
"resp_total": 71
}),
page=6,
page_size=14
)
self.assert_query_params_equal(
httpretty.httpretty.latest_requests[-2],
{
"user_id": [str(self.user.id)],
"mark_as_read": ["False"],
"recursive": ["False"],
"resp_skip": ["70"],
"resp_limit": ["14"],
"with_responses": ["True"],
"reverse_order": ["False"],
"merge_question_type_responses": ["False"],
}
)
def get_source_and_expected_comments(self):
"""
Returns the source comments and expected comments for testing purposes.
"""
source_comments = [
{
"type": "comment",
"id": "test_comment_1",
"thread_id": "test_thread",
"user_id": str(self.author.id),
"username": self.author.username,
"anonymous": False,
"anonymous_to_peers": False,
"created_at": "2015-05-11T00:00:00Z",
"updated_at": "2015-05-11T11:11:11Z",
"body": "Test body",
"endorsed": True,
"abuse_flaggers": [],
"votes": {"up_count": 4},
"child_count": 0,
"children": [],
},
{
"type": "comment",
"id": "test_comment_2",
"thread_id": "test_thread",
"user_id": str(self.author.id),
"username": self.author.username,
"anonymous": True,
"anonymous_to_peers": False,
"created_at": "2015-05-11T22:22:22Z",
"updated_at": "2015-05-11T33:33:33Z",
"body": "More content",
"endorsed": False,
"abuse_flaggers": [str(self.user.id)],
"votes": {"up_count": 7},
"child_count": 0,
"children": [],
}
]
expected_comments = [
{
"id": "test_comment_1",
"thread_id": "test_thread",
"parent_id": None,
"author": self.author.username,
"author_label": None,
"created_at": "2015-05-11T00:00:00Z",
"updated_at": "2015-05-11T11:11:11Z",
"raw_body": "Test body",
"rendered_body": "<p>Test body</p>",
"endorsed": True,
"endorsed_by": None,
"endorsed_by_label": None,
"endorsed_at": None,
"abuse_flagged": False,
"abuse_flagged_any_user": None,
"voted": False,
"vote_count": 4,
"editable_fields": ["abuse_flagged", "voted"],
"child_count": 0,
"children": [],
"can_delete": False,
"anonymous": False,
"anonymous_to_peers": False,
"last_edit": None,
"edit_by_label": None,
"profile_image": {
"has_image": False,
"image_url_full": "http://testserver/static/default_500.png",
"image_url_large": "http://testserver/static/default_120.png",
"image_url_medium": "http://testserver/static/default_50.png",
"image_url_small": "http://testserver/static/default_30.png",
},
},
{
"id": "test_comment_2",
"thread_id": "test_thread",
"parent_id": None,
"author": None,
"author_label": None,
"created_at": "2015-05-11T22:22:22Z",
"updated_at": "2015-05-11T33:33:33Z",
"raw_body": "More content",
"rendered_body": "<p>More content</p>",
"endorsed": False,
"endorsed_by": None,
"endorsed_by_label": None,
"endorsed_at": None,
"abuse_flagged": True,
"abuse_flagged_any_user": None,
"voted": False,
"vote_count": 7,
"editable_fields": ["abuse_flagged", "voted"],
"child_count": 0,
"children": [],
"can_delete": False,
"anonymous": True,
"anonymous_to_peers": False,
"last_edit": None,
"edit_by_label": None,
"profile_image": {
"has_image": False,
"image_url_full": "http://testserver/static/default_500.png",
"image_url_large": "http://testserver/static/default_120.png",
"image_url_medium": "http://testserver/static/default_50.png",
"image_url_small": "http://testserver/static/default_30.png",
},
},
]
return source_comments, expected_comments
def test_discussion_content(self):
source_comments, expected_comments = self.get_source_and_expected_comments()
actual_comments = self.get_comment_list(
self.make_minimal_cs_thread({"children": source_comments})
).data["results"]
assert actual_comments == expected_comments
def test_question_content_with_merge_question_type_responses(self):
source_comments, expected_comments = self.get_source_and_expected_comments()
actual_comments = self.get_comment_list(
self.make_minimal_cs_thread({
"thread_type": "question",
"children": source_comments,
"resp_total": len(source_comments)
}), merge_question_type_responses=True).data["results"]
assert actual_comments == expected_comments
def test_question_content_(self):
thread = self.make_minimal_cs_thread({
"thread_type": "question",
"endorsed_responses": [make_minimal_cs_comment({"id": "endorsed_comment", "username": self.user.username})],
"non_endorsed_responses": [make_minimal_cs_comment({
"id": "non_endorsed_comment", "username": self.user.username
})],
"non_endorsed_resp_total": 1,
})
endorsed_actual = self.get_comment_list(thread, endorsed=True).data
assert endorsed_actual['results'][0]['id'] == 'endorsed_comment'
non_endorsed_actual = self.get_comment_list(thread, endorsed=False).data
assert non_endorsed_actual['results'][0]['id'] == 'non_endorsed_comment'
def test_endorsed_by_anonymity(self):
"""
Ensure thread anonymity is properly considered in serializing
endorsed_by.
"""
thread = self.make_minimal_cs_thread({
"anonymous": True,
"children": [
make_minimal_cs_comment({
"username": self.user.username,
"endorsement": {"user_id": str(self.author.id), "time": "2015-05-18T12:34:56Z"},
})
]
})
actual_comments = self.get_comment_list(thread).data["results"]
assert actual_comments[0]['endorsed_by'] is None
@ddt.data(
("discussion", None, "children", "resp_total", False),
("question", False, "non_endorsed_responses", "non_endorsed_resp_total", False),
("question", None, "children", "resp_total", True),
)
@ddt.unpack
def test_cs_pagination(self, thread_type, endorsed_arg, response_field,
response_total_field, merge_question_type_responses):
"""
Test cases in which pagination is done by the comments service.
thread_type is the type of thread (question or discussion).
endorsed_arg is the value of the endorsed argument.
repsonse_field is the field in which responses are returned for the
given thread type.
response_total_field is the field in which the total number of responses
is returned for the given thread type.
"""
# N.B. The mismatch between the number of children and the listed total
# number of responses is unrealistic but convenient for this test
thread = self.make_minimal_cs_thread({
"thread_type": thread_type,
response_field: [make_minimal_cs_comment({"username": self.user.username})],
response_total_field: 5,
})
# Only page
actual = self.get_comment_list(thread, endorsed=endorsed_arg, page=1, page_size=5,
merge_question_type_responses=merge_question_type_responses).data
assert actual['pagination']['next'] is None
assert actual['pagination']['previous'] is None
# First page of many
actual = self.get_comment_list(thread, endorsed=endorsed_arg, page=1, page_size=2,
merge_question_type_responses=merge_question_type_responses).data
assert actual['pagination']['next'] == 'http://testserver/test_path?page=2'
assert actual['pagination']['previous'] is None
# Middle page of many
actual = self.get_comment_list(thread, endorsed=endorsed_arg, page=2, page_size=2,
merge_question_type_responses=merge_question_type_responses).data
assert actual['pagination']['next'] == 'http://testserver/test_path?page=3'
assert actual['pagination']['previous'] == 'http://testserver/test_path?page=1'
# Last page of many
actual = self.get_comment_list(thread, endorsed=endorsed_arg, page=3, page_size=2,
merge_question_type_responses=merge_question_type_responses).data
assert actual['pagination']['next'] is None
assert actual['pagination']['previous'] == 'http://testserver/test_path?page=2'
# Page past the end
thread = self.make_minimal_cs_thread({
"thread_type": thread_type,
response_field: [],
response_total_field: 5
})
with pytest.raises(PageNotFoundError):
self.get_comment_list(thread, endorsed=endorsed_arg, page=2, page_size=5,
merge_question_type_responses=merge_question_type_responses)
def test_question_endorsed_pagination(self):
thread = self.make_minimal_cs_thread({
"thread_type": "question",
"endorsed_responses": [make_minimal_cs_comment({
"id": f"comment_{i}",
"username": self.user.username
}) for i in range(10)]
})
def assert_page_correct(page, page_size, expected_start, expected_stop, expected_next, expected_prev):
"""
Check that requesting the given page/page_size returns the expected
output
"""
actual = self.get_comment_list(thread, endorsed=True, page=page, page_size=page_size).data
result_ids = [result["id"] for result in actual["results"]]
assert result_ids == [f"comment_{i}" for i in range(expected_start, expected_stop)]
assert actual['pagination']['next'] == (
f"http://testserver/test_path?page={expected_next}" if expected_next else None
)
assert actual['pagination']['previous'] == (
f"http://testserver/test_path?page={expected_prev}" if expected_prev else None
)
# Only page
assert_page_correct(
page=1,
page_size=10,
expected_start=0,
expected_stop=10,
expected_next=None,
expected_prev=None
)
# First page of many
assert_page_correct(
page=1,
page_size=4,
expected_start=0,
expected_stop=4,
expected_next=2,
expected_prev=None
)
# Middle page of many
assert_page_correct(
page=2,
page_size=4,
expected_start=4,
expected_stop=8,
expected_next=3,
expected_prev=1
)
# Last page of many
assert_page_correct(
page=3,
page_size=4,
expected_start=8,
expected_stop=10,
expected_next=None,
expected_prev=2
)
# Page past the end
with pytest.raises(PageNotFoundError):
self.get_comment_list(thread, endorsed=True, page=2, page_size=10)
@ddt.ddt
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class GetUserCommentsTest(ForumsEnableMixin, CommentsServiceMockMixin, SharedModuleStoreTestCase):
"""
Tests for get_user_comments.
"""
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
httpretty.reset()
httpretty.enable()
self.addCleanup(httpretty.reset)
self.addCleanup(httpretty.disable)
self.course = CourseFactory.create()
# create staff user so that we don't need to worry about
# permissions here
self.user = UserFactory.create(is_staff=True)
self.register_get_user_response(self.user)
self.request = RequestFactory().get(f'/api/discussion/v1/users/{self.user.username}/{self.course.id}')
self.request.user = self.user
def test_call_with_single_results_page(self):
"""
Assert that a minimal call with valid inputs, and single result,
returns the expected response structure.
"""
self.register_get_comments_response(
[make_minimal_cs_comment()],
page=1,
num_pages=1,
)
response = get_user_comments(
request=self.request,
author=self.user,
course_key=self.course.id,
)
assert "results" in response.data
assert "pagination" in response.data
assert response.data["pagination"]["count"] == 1
assert response.data["pagination"]["num_pages"] == 1
assert response.data["pagination"]["next"] is None
assert response.data["pagination"]["previous"] is None
@ddt.data(1, 2, 3)
def test_call_with_paginated_results(self, page):
"""
Assert that paginated results return the correct pagination
information at the pagination boundaries.
"""
self.register_get_comments_response(
[make_minimal_cs_comment() for _ in range(30)],
page=page,
num_pages=3,
)
response = get_user_comments(
request=self.request,
author=self.user,
course_key=self.course.id,
page=page,
)
assert "pagination" in response.data
assert response.data["pagination"]["count"] == 30
assert response.data["pagination"]["num_pages"] == 3
if page in (1, 2):
assert response.data["pagination"]["next"] is not None
assert f"page={page + 1}" in response.data["pagination"]["next"]
if page in (2, 3):
assert response.data["pagination"]["previous"] is not None
assert f"page={page - 1}" in response.data["pagination"]["previous"]
if page == 1:
assert response.data["pagination"]["previous"] is None
if page == 3:
assert response.data["pagination"]["next"] is None
def test_call_with_invalid_page(self):
"""
Assert that calls for pages that exceed the existing number of
results pages raise PageNotFoundError.
"""
self.register_get_comments_response([], page=2, num_pages=1)
with pytest.raises(PageNotFoundError):
get_user_comments(
request=self.request,
author=self.user,
course_key=self.course.id,
page=2,
)
def test_call_with_non_existent_course(self):
"""
Assert that calls for comments in a course that doesn't exist
result in a CourseNotFoundError error.
"""
self.register_get_comments_response(
[make_minimal_cs_comment()],
page=1,
num_pages=1,
)
with pytest.raises(CourseNotFoundError):
get_user_comments(
request=self.request,
author=self.user,
course_key=CourseKey.from_string("course-v1:x+y+z"),
page=2,
)
@ddt.ddt
@disable_signal(api, 'thread_created')
@disable_signal(api, 'thread_voted')
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class CreateThreadTest(
ForumsEnableMixin,
CommentsServiceMockMixin,
UrlResetMixin,
SharedModuleStoreTestCase,
MockSignalHandlerMixin
):
"""Tests for create_thread"""
LONG_TITLE = (
'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. '
'Aenean commodo ligula eget dolor. Aenean massa. Cum sociis '
'natoque penatibus et magnis dis parturient montes, nascetur '
'ridiculus mus. Donec quam felis, ultricies nec, '
'pellentesque eu, pretium quis, sem. Nulla consequat massa '
'quis enim. Donec pede justo, fringilla vel, aliquet nec, '
'vulputate eget, arcu. In enim justo, rhoncus ut, imperdiet '
'a, venenatis vitae, justo. Nullam dictum felis eu pede '
'mollis pretium. Integer tincidunt. Cras dapibus. Vivamus '
'elementum semper nisi. Aenean vulputate eleifend tellus. '
'Aenean leo ligula, porttitor eu, consequat vitae, eleifend '
'ac, enim. Aliquam lorem ante, dapibus in, viverra quis, '
'feugiat a, tellus. Phasellus viverra nulla ut metus varius '
'laoreet. Quisque rutrum. Aenean imperdiet. Etiam ultricies '
'nisi vel augue. Curabitur ullamcorper ultricies nisi. Nam '
'eget dui. Etiam rhoncus. Maecenas tempus, tellus eget '
'condimentum rhoncus, sem quam semper libero, sit amet '
'adipiscing sem neque sed ipsum. Nam quam nunc, blandit vel, '
'luctus pulvinar, hendrerit id, lorem. Maecenas nec odio et '
'ante tincidunt tempus. Donec vitae sapien ut libero '
'venenatis faucibus. Nullam quis ante. Etiam sit amet orci '
'eget eros faucibus tincidunt. Duis leo. Sed fringilla '
'mauris sit amet nibh. Donec sodales sagittis magna. Sed '
'consequat, leo eget bibendum sodales, augue velit cursus '
'nunc, quis gravida magna mi a libero. Fusce vulputate '
'eleifend sapien. Vestibulum purus quam, scelerisque ut, '
'mollis sed, nonummy id, metus. Nullam accumsan lorem in '
'dui. Cras ultricies mi eu turpis hendrerit fringilla. '
'Vestibulum ante ipsum primis in faucibus orci luctus et '
'ultrices posuere cubilia Curae; In ac dui quis mi '
'consectetuer lacinia. Nam pretium turpis et arcu. Duis arcu '
'tortor, suscipit eget, imperdiet nec, imperdiet iaculis, '
'ipsum. Sed aliquam ultrices mauris. Integer ante arcu, '
'accumsan a, consectetuer eget, posuere ut, mauris. Praesent '
'adipiscing. Phasellus ullamcorper ipsum rutrum nunc. Nunc '
'nonummy metus.'
)
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
self.course = CourseFactory.create()
httpretty.reset()
httpretty.enable()
self.addCleanup(httpretty.reset)
self.addCleanup(httpretty.disable)
patcher = mock.patch(
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
return_value=False
)
patcher.start()
self.addCleanup(patcher.stop)
self.user = UserFactory.create()
self.register_get_user_response(self.user)
self.request = RequestFactory().get("/test_path")
self.request.user = self.user
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
self.minimal_data = {
"course_id": str(self.course.id),
"topic_id": "test_topic",
"type": "discussion",
"title": "Test Title",
"raw_body": "Test body",
}
@mock.patch("eventtracking.tracker.emit")
def test_basic(self, mock_emit):
cs_thread = make_minimal_cs_thread({
"id": "test_id",
"username": self.user.username,
"read": True,
})
self.register_post_thread_response(cs_thread)
with self.assert_signal_sent(
api, 'thread_created', sender=None, user=self.user, exclude_args=('post', 'notify_all_learners')
):
actual = create_thread(self.request, self.minimal_data)
expected = self.expected_thread_data({
"id": "test_id",
"course_id": str(self.course.id),
"comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_id",
"read": True,
})
assert actual == expected
assert parsed_body(httpretty.last_request()) == {
'course_id': [str(self.course.id)],
'commentable_id': ['test_topic'],
'thread_type': ['discussion'],
'title': ['Test Title'],
'body': ['Test body'],
'user_id': [str(self.user.id)],
'anonymous': ['False'],
'anonymous_to_peers': ['False'],
}
event_name, event_data = mock_emit.call_args[0]
assert event_name == 'edx.forum.thread.created'
assert event_data == {
'commentable_id': 'test_topic',
'group_id': None,
'thread_type': 'discussion',
'title': 'Test Title',
'title_truncated': False,
'anonymous': False,
'anonymous_to_peers': False,
'options': {
'followed': False,
'notify_all_learners': False
},
'id': 'test_id',
'truncated': False,
'body': 'Test body',
'url': '',
'user_forums_roles': [FORUM_ROLE_STUDENT],
'user_course_roles': [],
'from_mfe_sidebar': False,
}
def test_basic_in_blackout_period(self):
"""
Test case when course is in blackout period and user does not have special privileges.
"""
_set_course_discussion_blackout(course=self.course, user_id=self.user.id)
with self.assertRaises(DiscussionBlackOutException) as assertion:
create_thread(self.request, self.minimal_data)
self.assertEqual(assertion.exception.status_code, 403)
self.assertEqual(assertion.exception.detail, "Discussions are in blackout period.")
@mock.patch("eventtracking.tracker.emit")
def test_basic_in_blackout_period_with_user_access(self, mock_emit):
"""
Test case when course is in blackout period and user has special privileges.
"""
cs_thread = make_minimal_cs_thread({
"id": "test_id",
"username": self.user.username,
"read": True,
})
self.register_post_thread_response(cs_thread)
_set_course_discussion_blackout(course=self.course, user_id=self.user.id)
_assign_role_to_user(user=self.user, course_id=self.course.id, role=FORUM_ROLE_MODERATOR)
with self.assert_signal_sent(
api, 'thread_created', sender=None, user=self.user, exclude_args=('post', 'notify_all_learners')
):
actual = create_thread(self.request, self.minimal_data)
expected = self.expected_thread_data({
"author_label": "Moderator",
"id": "test_id",
"course_id": str(self.course.id),
"comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_id",
"read": True,
"editable_fields": [
"abuse_flagged",
"anonymous",
"close_reason_code",
"closed",
"copy_link",
"following",
"pinned",
"raw_body",
"read",
"title",
"topic_id",
"type",
"voted",
],
})
assert actual == expected
self.assertEqual(
parsed_body(httpretty.last_request()),
{
"course_id": [str(self.course.id)],
"commentable_id": ["test_topic"],
"thread_type": ["discussion"],
"title": ["Test Title"],
"body": ["Test body"],
"user_id": [str(self.user.id)],
"anonymous": ["False"],
"anonymous_to_peers": ["False"],
}
)
event_name, event_data = mock_emit.call_args[0]
self.assertEqual(event_name, "edx.forum.thread.created")
self.assertEqual(
event_data,
{
"commentable_id": "test_topic",
"group_id": None,
"thread_type": "discussion",
"title": "Test Title",
"title_truncated": False,
"anonymous": False,
"anonymous_to_peers": False,
"options": {
"followed": False,
"notify_all_learners": False
},
"id": "test_id",
"truncated": False,
"body": "Test body",
"url": "",
"user_forums_roles": [FORUM_ROLE_STUDENT, FORUM_ROLE_MODERATOR],
"user_course_roles": [],
"from_mfe_sidebar": False,
}
)
@mock.patch("eventtracking.tracker.emit")
def test_title_truncation(self, mock_emit):
data = self.minimal_data.copy()
data['title'] = self.LONG_TITLE
cs_thread = make_minimal_cs_thread({
"id": "test_id",
"username": self.user.username,
"read": True,
})
self.register_post_thread_response(cs_thread)
with self.assert_signal_sent(
api, 'thread_created', sender=None, user=self.user, exclude_args=('post', 'notify_all_learners')
):
create_thread(self.request, data)
event_name, event_data = mock_emit.call_args[0]
assert event_name == 'edx.forum.thread.created'
assert event_data == {
'commentable_id': 'test_topic',
'group_id': None,
'thread_type': 'discussion',
'title': self.LONG_TITLE[:1000],
'title_truncated': True,
'anonymous': False,
'anonymous_to_peers': False,
'options': {
'followed': False,
'notify_all_learners': False
},
'id': 'test_id',
'truncated': False,
'body': 'Test body',
'url': '',
'user_forums_roles': [FORUM_ROLE_STUDENT],
'user_course_roles': [],
'from_mfe_sidebar': False,
}
@ddt.data(
*itertools.product(
[
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_STUDENT,
],
[True, False],
[True, False],
["no_group_set", "group_is_none", "group_is_set"],
)
)
@ddt.unpack
def test_group_id(self, role_name, course_is_cohorted, topic_is_cohorted, data_group_state):
"""
Tests whether the user has permission to create a thread with certain
group_id values.
If there is no group, user cannot create a thread.
Else if group is None or set, and the course is not cohorted and/or the
role is a student, user can create a thread.
"""
cohort_course = CourseFactory.create(
discussion_topics={"Test Topic": {"id": "test_topic"}},
cohort_config={
"cohorted": course_is_cohorted,
"cohorted_discussions": ["test_topic"] if topic_is_cohorted else [],
}
)
CourseEnrollmentFactory.create(user=self.user, course_id=cohort_course.id)
if course_is_cohorted:
cohort = CohortFactory.create(course_id=cohort_course.id, users=[self.user])
_assign_role_to_user(user=self.user, course_id=cohort_course.id, role=role_name)
self.register_post_thread_response({"username": self.user.username})
data = self.minimal_data.copy()
data["course_id"] = str(cohort_course.id)
if data_group_state == "group_is_none":
data["group_id"] = None
elif data_group_state == "group_is_set":
if course_is_cohorted:
data["group_id"] = cohort.id + 1
else:
data["group_id"] = 1 # Set to any value since there is no cohort
expected_error = (
data_group_state in ["group_is_none", "group_is_set"] and
(not course_is_cohorted or role_name == FORUM_ROLE_STUDENT)
)
try:
create_thread(self.request, data)
assert not expected_error
actual_post_data = parsed_body(httpretty.last_request())
if data_group_state == "group_is_set":
assert actual_post_data['group_id'] == [str(data['group_id'])]
elif data_group_state == "no_group_set" and course_is_cohorted and topic_is_cohorted:
assert actual_post_data['group_id'] == [str(cohort.id)]
else:
assert 'group_id' not in actual_post_data
except ValidationError as ex:
if not expected_error:
self.fail(f"Unexpected validation error: {ex}")
def test_course_id_missing(self):
with pytest.raises(ValidationError) as assertion:
create_thread(self.request, {})
assert assertion.value.message_dict == {'course_id': ['This field is required.']}
def test_course_id_invalid(self):
with pytest.raises(ValidationError) as assertion:
create_thread(self.request, {"course_id": "invalid!"})
assert assertion.value.message_dict == {'course_id': ['Invalid value.']}
def test_nonexistent_course(self):
with pytest.raises(CourseNotFoundError):
create_thread(self.request, {"course_id": "course-v1:non+existent+course"})
def test_not_enrolled(self):
self.request.user = UserFactory.create()
with pytest.raises(CourseNotFoundError):
create_thread(self.request, self.minimal_data)
def test_discussions_disabled(self):
disabled_course = _discussion_disabled_course_for(self.user)
self.minimal_data["course_id"] = str(disabled_course.id)
with pytest.raises(DiscussionDisabledError):
create_thread(self.request, self.minimal_data)
def test_invalid_field(self):
data = self.minimal_data.copy()
data["type"] = "invalid_type"
with pytest.raises(ValidationError):
create_thread(self.request, data)
@ddt.ddt
@disable_signal(api, 'comment_created')
@disable_signal(api, 'comment_voted')
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
@mock.patch("lms.djangoapps.discussion.signals.handlers.send_response_notifications", new=mock.Mock())
class CreateCommentTest(
ForumsEnableMixin,
CommentsServiceMockMixin,
UrlResetMixin,
SharedModuleStoreTestCase,
MockSignalHandlerMixin
):
"""Tests for create_comment"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.course = CourseFactory.create()
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
httpretty.reset()
httpretty.enable()
self.course = CourseFactory.create()
self.addCleanup(httpretty.reset)
self.addCleanup(httpretty.disable)
patcher = mock.patch(
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
return_value=False
)
patcher.start()
self.addCleanup(patcher.stop)
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment"
)
self.mock_get_course_id_by_comment = patcher.start()
self.addCleanup(patcher.stop)
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread"
)
self.mock_get_course_id_by_thread = patcher.start()
self.addCleanup(patcher.stop)
self.user = UserFactory.create()
self.register_get_user_response(self.user)
self.request = RequestFactory().get("/test_path")
self.request.user = self.user
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
self.register_get_thread_response(
make_minimal_cs_thread({
"id": "test_thread",
"course_id": str(self.course.id),
"commentable_id": "test_topic",
})
)
self.minimal_data = {
"thread_id": "test_thread",
"raw_body": "Test body",
}
mock_response = {
'collection': [],
'page': 1,
'num_pages': 1,
'subscriptions_count': 1,
'corrected_text': None
}
self.register_get_subscriptions('cohort_thread', mock_response)
self.register_get_subscriptions('test_thread', mock_response)
@ddt.data(None, "test_parent")
@mock.patch("eventtracking.tracker.emit")
def test_success(self, parent_id, mock_emit):
if parent_id:
self.register_get_comment_response({"id": parent_id, "thread_id": "test_thread"})
self.register_post_comment_response(
{
"id": "test_comment",
"username": self.user.username,
"created_at": "2015-05-27T00:00:00Z",
"updated_at": "2015-05-27T00:00:00Z",
},
thread_id="test_thread",
parent_id=parent_id
)
data = self.minimal_data.copy()
if parent_id:
data["parent_id"] = parent_id
with self.assert_signal_sent(api, 'comment_created', sender=None, user=self.user, exclude_args=('post',)):
actual = create_comment(self.request, data)
expected = {
"id": "test_comment",
"thread_id": "test_thread",
"parent_id": parent_id,
"author": self.user.username,
"author_label": None,
"created_at": "2015-05-27T00:00:00Z",
"updated_at": "2015-05-27T00:00:00Z",
"raw_body": "Test body",
"rendered_body": "<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": 0,
"children": [],
"editable_fields": ["abuse_flagged", "anonymous", "raw_body"],
"child_count": 0,
"can_delete": True,
"anonymous": False,
"anonymous_to_peers": False,
"last_edit": None,
"edit_by_label": None,
"profile_image": {
"has_image": False,
"image_url_full": "http://testserver/static/default_500.png",
"image_url_large": "http://testserver/static/default_120.png",
"image_url_medium": "http://testserver/static/default_50.png",
"image_url_small": "http://testserver/static/default_30.png",
},
}
assert actual == expected
expected_url = (
f"/api/v1/comments/{parent_id}" if parent_id else
"/api/v1/threads/test_thread/comments"
)
assert urlparse(httpretty.last_request().path).path == expected_url # lint-amnesty, pylint: disable=no-member
data = httpretty.latest_requests()
assert parsed_body(data[len(data) - 2]) == {
'course_id': [str(self.course.id)],
'body': ['Test body'],
'user_id': [str(self.user.id)],
'anonymous': ['False'],
'anonymous_to_peers': ['False'],
}
expected_event_name = (
"edx.forum.comment.created" if parent_id else
"edx.forum.response.created"
)
expected_event_data = {
"discussion": {"id": "test_thread"},
"commentable_id": "test_topic",
"options": {"followed": False},
"id": "test_comment",
"truncated": False,
"body": "Test body",
"url": "",
"user_forums_roles": [FORUM_ROLE_STUDENT],
"user_course_roles": [],
"from_mfe_sidebar": False,
}
if parent_id:
expected_event_data["response"] = {"id": parent_id}
actual_event_name, actual_event_data = mock_emit.call_args[0]
assert actual_event_name == expected_event_name
assert actual_event_data == expected_event_data
@ddt.data(None, "test_parent")
@mock.patch("eventtracking.tracker.emit")
def test_success_in_black_out_with_user_access(self, parent_id, mock_emit):
"""
Test case when course is in blackout period and user has special privileges.
"""
if parent_id:
self.register_get_comment_response({"id": parent_id, "thread_id": "test_thread"})
self.register_post_comment_response(
{
"id": "test_comment",
"username": self.user.username,
"created_at": "2015-05-27T00:00:00Z",
"updated_at": "2015-05-27T00:00:00Z",
},
thread_id="test_thread",
parent_id=parent_id
)
data = self.minimal_data.copy()
editable_fields = [
"abuse_flagged",
"anonymous",
"raw_body",
"voted"
]
if parent_id:
data["parent_id"] = parent_id
else:
editable_fields.insert(2, "endorsed")
_set_course_discussion_blackout(course=self.course, user_id=self.user.id)
_assign_role_to_user(user=self.user, course_id=self.course.id, role=FORUM_ROLE_MODERATOR)
with self.assert_signal_sent(api, 'comment_created', sender=None, user=self.user, exclude_args=('post',)):
actual = create_comment(self.request, data)
expected = {
"id": "test_comment",
"thread_id": "test_thread",
"parent_id": parent_id,
"author": self.user.username,
"author_label": "Moderator",
"created_at": "2015-05-27T00:00:00Z",
"updated_at": "2015-05-27T00:00:00Z",
"raw_body": "Test body",
"rendered_body": "<p>Test body</p>",
"endorsed": False,
"endorsed_by": None,
"endorsed_by_label": None,
"endorsed_at": None,
"abuse_flagged": False,
"abuse_flagged_any_user": False,
"voted": False,
"vote_count": 0,
"children": [],
"editable_fields": editable_fields,
"child_count": 0,
"can_delete": True,
"anonymous": False,
"anonymous_to_peers": False,
"last_edit": None,
"edit_by_label": None,
"profile_image": {
"has_image": False,
"image_url_full": "http://testserver/static/default_500.png",
"image_url_large": "http://testserver/static/default_120.png",
"image_url_medium": "http://testserver/static/default_50.png",
"image_url_small": "http://testserver/static/default_30.png",
},
}
assert actual == expected
expected_url = (
f"/api/v1/comments/{parent_id}" if parent_id else
"/api/v1/threads/test_thread/comments"
)
assert urlparse(httpretty.last_request().path).path == expected_url # pylint: disable=no-member
data = httpretty.latest_requests()
assert parsed_body(data[len(data) - 2]) == {
"course_id": [str(self.course.id)],
"body": ["Test body"],
"user_id": [str(self.user.id)],
"anonymous": ['False'],
"anonymous_to_peers": ['False'],
}
expected_event_name = (
"edx.forum.comment.created" if parent_id else
"edx.forum.response.created"
)
expected_event_data = {
"discussion": {"id": "test_thread"},
"commentable_id": "test_topic",
"options": {"followed": False},
"id": "test_comment",
"truncated": False,
"body": "Test body",
"url": "",
"user_forums_roles": [FORUM_ROLE_STUDENT, FORUM_ROLE_MODERATOR],
"user_course_roles": [],
"from_mfe_sidebar": False,
}
if parent_id:
expected_event_data["response"] = {"id": parent_id}
actual_event_name, actual_event_data = mock_emit.call_args[0]
self.assertEqual(actual_event_name, expected_event_name)
self.assertEqual(actual_event_data, expected_event_data)
def test_error_in_black_out(self):
"""
Test case when course is in blackout period and user does not have special privileges.
"""
_set_course_discussion_blackout(course=self.course, user_id=self.user.id)
with self.assertRaises(DiscussionBlackOutException) as assertion:
create_comment(self.request, self.minimal_data)
self.assertEqual(assertion.exception.status_code, 403)
self.assertEqual(assertion.exception.detail, "Discussions are in blackout period.")
@ddt.data(
*itertools.product(
[
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_STUDENT,
],
[True, False],
["question", "discussion"],
)
)
@ddt.unpack
def test_endorsed(self, role_name, is_thread_author, thread_type):
_assign_role_to_user(user=self.user, course_id=self.course.id, role=role_name)
self.register_get_thread_response(
make_minimal_cs_thread({
"id": "test_thread",
"course_id": str(self.course.id),
"thread_type": thread_type,
"user_id": str(self.user.id) if is_thread_author else str(self.user.id + 1),
})
)
self.register_post_comment_response({"username": self.user.username}, "test_thread")
data = self.minimal_data.copy()
data["endorsed"] = True
expected_error = (
role_name == FORUM_ROLE_STUDENT and
(not is_thread_author or thread_type == "discussion")
)
try:
create_comment(self.request, data)
assert parsed_body(httpretty.last_request())['endorsed'] == ['True']
assert not expected_error
except ValidationError:
assert expected_error
def test_thread_id_missing(self):
with pytest.raises(ValidationError) as assertion:
create_comment(self.request, {})
assert assertion.value.message_dict == {'thread_id': ['This field is required.']}
def test_thread_id_not_found(self):
self.register_get_thread_error_response("test_thread", 404)
with pytest.raises(ThreadNotFoundError):
create_comment(self.request, self.minimal_data)
def test_nonexistent_course(self):
self.register_get_thread_response(
make_minimal_cs_thread({"id": "test_thread", "course_id": "course-v1:non+existent+course"})
)
with pytest.raises(CourseNotFoundError):
create_comment(self.request, self.minimal_data)
def test_not_enrolled(self):
self.request.user = UserFactory.create()
with pytest.raises(CourseNotFoundError):
create_comment(self.request, self.minimal_data)
def test_discussions_disabled(self):
disabled_course = _discussion_disabled_course_for(self.user)
self.register_get_thread_response(
make_minimal_cs_thread({
"id": "test_thread",
"course_id": str(disabled_course.id),
"commentable_id": "test_topic",
})
)
with pytest.raises(DiscussionDisabledError):
create_comment(self.request, self.minimal_data)
@ddt.data(
*itertools.product(
[
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_STUDENT,
],
[True, False],
["no_group", "match_group", "different_group"],
)
)
@ddt.unpack
def test_group_access(self, role_name, course_is_cohorted, thread_group_state):
cohort_course, cohort = _create_course_and_cohort_with_user_role(course_is_cohorted, self.user, role_name)
self.register_get_thread_response(make_minimal_cs_thread({
"id": "cohort_thread",
"course_id": str(cohort_course.id),
"group_id": (
None if thread_group_state == "no_group" else
cohort.id if thread_group_state == "match_group" else
cohort.id + 1
),
}))
self.register_post_comment_response({"username": self.user.username}, thread_id="cohort_thread")
data = self.minimal_data.copy()
data["thread_id"] = "cohort_thread"
expected_error = (
role_name == FORUM_ROLE_STUDENT and
course_is_cohorted and
thread_group_state == "different_group"
)
try:
create_comment(self.request, data)
assert not expected_error
except ThreadNotFoundError:
assert expected_error
def test_invalid_field(self):
data = self.minimal_data.copy()
del data["raw_body"]
with pytest.raises(ValidationError):
create_comment(self.request, data)
@ddt.ddt
@disable_signal(api, 'thread_edited')
@disable_signal(api, 'thread_voted')
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class UpdateThreadTest(
ForumsEnableMixin,
CommentsServiceMockMixin,
UrlResetMixin,
SharedModuleStoreTestCase,
MockSignalHandlerMixin
):
"""Tests for update_thread"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.course = CourseFactory.create()
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
httpretty.reset()
httpretty.enable()
self.addCleanup(httpretty.reset)
self.addCleanup(httpretty.disable)
patcher = mock.patch(
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
return_value=False
)
patcher.start()
self.addCleanup(patcher.stop)
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread"
)
self.mock_get_course_id_by_thread = patcher.start()
self.addCleanup(patcher.stop)
self.user = UserFactory.create()
self.register_get_user_response(self.user)
self.request = RequestFactory().get("/test_path")
self.request.user = self.user
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
def register_thread(self, overrides=None):
"""
Make a thread with appropriate data overridden by the overrides
parameter and register mock responses for both GET and PUT on its
endpoint.
"""
cs_data = make_minimal_cs_thread({
"id": "test_thread",
"course_id": str(self.course.id),
"commentable_id": "original_topic",
"username": self.user.username,
"user_id": str(self.user.id),
"thread_type": "discussion",
"title": "Original Title",
"body": "Original body",
})
cs_data.update(overrides or {})
self.register_get_thread_response(cs_data)
self.register_put_thread_response(cs_data)
def create_user_with_request(self):
"""
Create a user and an associated request for a specific course enrollment.
"""
user = UserFactory.create()
self.register_get_user_response(user)
request = RequestFactory().get("/test_path")
request.user = user
CourseEnrollmentFactory.create(user=user, course_id=self.course.id)
return user, request
def test_empty(self):
"""Check that an empty update does not make any modifying requests."""
# Ensure that the default following value of False is not applied implicitly
self.register_get_user_response(self.user, subscribed_thread_ids=["test_thread"])
self.register_thread()
update_thread(self.request, "test_thread", {})
for request in httpretty.httpretty.latest_requests:
assert request.method == 'GET'
def test_basic(self):
self.register_thread()
with self.assert_signal_sent(api, 'thread_edited', sender=None, user=self.user, exclude_args=('post',)):
actual = update_thread(self.request, "test_thread", {"raw_body": "Edited body"})
assert actual == self.expected_thread_data({
'raw_body': 'Edited body',
'rendered_body': '<p>Edited body</p>',
'preview_body': 'Edited body',
'topic_id': 'original_topic',
'read': True,
'title': 'Original Title',
})
assert parsed_body(httpretty.last_request()) == {
'course_id': [str(self.course.id)],
'commentable_id': ['original_topic'],
'thread_type': ['discussion'],
'title': ['Original Title'],
'body': ['Edited body'],
'user_id': [str(self.user.id)],
'anonymous': ['False'],
'anonymous_to_peers': ['False'],
'closed': ['False'],
'pinned': ['False'],
'read': ['False'],
'editing_user_id': [str(self.user.id)],
}
def test_nonexistent_thread(self):
self.register_get_thread_error_response("test_thread", 404)
with pytest.raises(ThreadNotFoundError):
update_thread(self.request, "test_thread", {})
def test_nonexistent_course(self):
self.register_thread({"course_id": "course-v1:non+existent+course"})
with pytest.raises(CourseNotFoundError):
update_thread(self.request, "test_thread", {})
def test_not_enrolled(self):
self.register_thread()
self.request.user = UserFactory.create()
with pytest.raises(CourseNotFoundError):
update_thread(self.request, "test_thread", {})
def test_discussions_disabled(self):
disabled_course = _discussion_disabled_course_for(self.user)
self.register_thread(overrides={"course_id": str(disabled_course.id)})
with pytest.raises(DiscussionDisabledError):
update_thread(self.request, "test_thread", {})
@ddt.data(
*itertools.product(
[
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_STUDENT,
],
[True, False],
["no_group", "match_group", "different_group"],
)
)
@ddt.unpack
def test_group_access(self, role_name, course_is_cohorted, thread_group_state):
cohort_course, cohort = _create_course_and_cohort_with_user_role(course_is_cohorted, self.user, role_name)
self.register_thread({
"course_id": str(cohort_course.id),
"group_id": (
None if thread_group_state == "no_group" else
cohort.id if thread_group_state == "match_group" else
cohort.id + 1
),
})
expected_error = (
role_name == FORUM_ROLE_STUDENT and
course_is_cohorted and
thread_group_state == "different_group"
)
try:
update_thread(self.request, "test_thread", {})
assert not expected_error
except ThreadNotFoundError:
assert expected_error
@ddt.data(
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_STUDENT,
)
def test_author_only_fields(self, role_name):
_assign_role_to_user(user=self.user, course_id=self.course.id, role=role_name)
self.register_thread({"user_id": str(self.user.id + 1)})
data = {field: "edited" for field in ["topic_id", "title", "raw_body"]}
data["type"] = "question"
expected_error = role_name == FORUM_ROLE_STUDENT
try:
update_thread(self.request, "test_thread", data)
assert not expected_error
except ValidationError as err:
assert expected_error
assert err.message_dict == {field: ['This field is not editable.'] for field in data.keys()}
def test_invalid_field(self):
self.register_thread()
with pytest.raises(ValidationError) as assertion:
update_thread(self.request, "test_thread", {"raw_body": ""})
assert assertion.value.message_dict == {'raw_body': ['This field may not be blank.']}
@ddt.data(
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_STUDENT,
)
@mock.patch("lms.djangoapps.discussion.rest_api.serializers.EDIT_REASON_CODES", {
"test-edit-reason": "Test Edit Reason",
})
@mock.patch("eventtracking.tracker.emit")
def test_update_thread_with_edit_reason_code(self, role_name, mock_emit):
"""
Test editing comments, specifying and retrieving edit reason codes.
"""
_assign_role_to_user(user=self.user, course_id=self.course.id, role=role_name)
self.register_thread({"user_id": str(self.user.id + 1)})
try:
result = update_thread(self.request, "test_thread", {
"raw_body": "Edited body",
"edit_reason_code": "test-edit-reason",
})
assert role_name != FORUM_ROLE_STUDENT
assert result["last_edit"] == {
"original_body": "Original body",
"reason": "Test Edit Reason",
"reason_code": "test-edit-reason",
"author": self.user.username,
}
request_body = httpretty.last_request().parsed_body # pylint: disable=no-member
assert request_body["edit_reason_code"] == ["test-edit-reason"]
expected_event_name = 'edx.forum.thread.edited'
expected_event_data = {
'id': 'test_thread',
'content_type': 'Post',
'own_content': False,
'url': '',
'user_course_roles': [],
'user_forums_roles': ['Student', role_name],
'target_username': self.user.username,
'edit_reason': 'test-edit-reason',
'commentable_id': 'original_topic'
}
actual_event_name, actual_event_data = mock_emit.call_args[0]
self.assertEqual(actual_event_name, expected_event_name)
self.assertEqual(actual_event_data, expected_event_data)
except ValidationError as error:
assert role_name == FORUM_ROLE_STUDENT
assert error.message_dict == {"edit_reason_code": ["This field is not editable."],
"raw_body": ["This field is not editable."]}
@ddt.data(
*itertools.product(
[
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_STUDENT,
],
[True, False]
)
)
@ddt.unpack
@mock.patch("lms.djangoapps.discussion.rest_api.serializers.CLOSE_REASON_CODES", {
"test-close-reason": "Test Close Reason",
})
@mock.patch("eventtracking.tracker.emit")
def test_update_thread_with_close_reason_code(self, role_name, closed, mock_emit):
"""
Test editing comments, specifying and retrieving edit reason codes.
"""
_assign_role_to_user(user=self.user, course_id=self.course.id, role=role_name)
self.register_thread()
try:
self.request.META['HTTP_REFERER'] = 'https://example.com'
result = update_thread(self.request, "test_thread", {
"closed": closed,
"close_reason_code": "test-close-reason",
})
assert role_name != FORUM_ROLE_STUDENT
assert result["closed"] == closed
request_body = httpretty.last_request().parsed_body # pylint: disable=no-member
assert request_body["close_reason_code"] == ["test-close-reason"]
assert request_body["closing_user_id"] == [str(self.user.id)]
expected_event_name = f'edx.forum.thread.{"locked" if closed else "unlocked"}'
expected_event_data = {
'id': 'test_thread',
'team_id': None,
'url': self.request.META['HTTP_REFERER'],
'user_course_roles': [],
'user_forums_roles': ['Student', role_name],
'target_username': self.user.username,
'lock_reason': 'test-close-reason',
'commentable_id': 'original_topic'
}
actual_event_name, actual_event_data = mock_emit.call_args[0]
self.assertEqual(actual_event_name, expected_event_name)
self.assertEqual(actual_event_data, expected_event_data)
except ValidationError as error:
assert role_name == FORUM_ROLE_STUDENT
assert error.message_dict == {
"closed": ["This field is not editable."],
"close_reason_code": ["This field is not editable."],
}
@ddt.ddt
@disable_signal(api, 'comment_edited')
@disable_signal(api, 'comment_voted')
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class UpdateCommentTest(
ForumsEnableMixin,
CommentsServiceMockMixin,
UrlResetMixin,
SharedModuleStoreTestCase,
MockSignalHandlerMixin
):
"""Tests for update_comment"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.course = CourseFactory.create()
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
httpretty.reset()
httpretty.enable()
self.addCleanup(httpretty.reset)
self.addCleanup(httpretty.disable)
patcher = mock.patch(
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
return_value=False
)
patcher.start()
self.addCleanup(patcher.stop)
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment"
)
self.mock_get_course_id_by_comment = patcher.start()
self.addCleanup(patcher.stop)
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread"
)
self.mock_get_course_id_by_thread = patcher.start()
self.addCleanup(patcher.stop)
self.user = UserFactory.create()
self.register_get_user_response(self.user)
self.request = RequestFactory().get("/test_path")
self.request.user = self.user
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
def register_comment(self, overrides=None, thread_overrides=None, course=None):
"""
Make a comment with appropriate data overridden by the overrides
parameter and register mock responses for both GET and PUT on its
endpoint. Also mock GET for the related thread with thread_overrides.
"""
if course is None:
course = self.course
cs_thread_data = make_minimal_cs_thread({
"id": "test_thread",
"course_id": str(course.id)
})
cs_thread_data.update(thread_overrides or {})
self.register_get_thread_response(cs_thread_data)
cs_comment_data = make_minimal_cs_comment({
"id": "test_comment",
"course_id": cs_thread_data["course_id"],
"thread_id": cs_thread_data["id"],
"username": self.user.username,
"user_id": str(self.user.id),
"created_at": "2015-06-03T00:00:00Z",
"updated_at": "2015-06-03T00:00:00Z",
"body": "Original body",
})
cs_comment_data.update(overrides or {})
self.register_get_comment_response(cs_comment_data)
self.register_put_comment_response(cs_comment_data)
def create_user_with_request(self):
"""
Create a user and an associated request for a specific course enrollment.
"""
user = UserFactory.create()
self.register_get_user_response(user)
request = RequestFactory().get("/test_path")
request.user = user
CourseEnrollmentFactory.create(user=user, course_id=self.course.id)
return user, request
def test_empty(self):
"""Check that an empty update does not make any modifying requests."""
self.register_comment()
update_comment(self.request, "test_comment", {})
for request in httpretty.httpretty.latest_requests:
assert request.method == 'GET'
@ddt.data(None, "test_parent")
def test_basic(self, parent_id):
self.register_comment({"parent_id": parent_id})
with self.assert_signal_sent(api, 'comment_edited', sender=None, user=self.user, exclude_args=('post',)):
actual = update_comment(self.request, "test_comment", {"raw_body": "Edited body"})
expected = {
"anonymous": False,
"anonymous_to_peers": False,
"id": "test_comment",
"thread_id": "test_thread",
"parent_id": parent_id,
"author": self.user.username,
"author_label": None,
"created_at": "2015-06-03T00:00:00Z",
"updated_at": "2015-06-03T00:00:00Z",
"raw_body": "Edited body",
"rendered_body": "<p>Edited body</p>",
"endorsed": False,
"endorsed_by": None,
"endorsed_by_label": None,
"endorsed_at": None,
"abuse_flagged": False,
"abuse_flagged_any_user": None,
"voted": False,
"vote_count": 0,
"children": [],
"editable_fields": ["abuse_flagged", "anonymous", "raw_body"],
"child_count": 0,
"can_delete": True,
"last_edit": None,
"edit_by_label": None,
"profile_image": {
"has_image": False,
"image_url_full": "http://testserver/static/default_500.png",
"image_url_large": "http://testserver/static/default_120.png",
"image_url_medium": "http://testserver/static/default_50.png",
"image_url_small": "http://testserver/static/default_30.png",
},
}
assert actual == expected
assert parsed_body(httpretty.last_request()) == {
'body': ['Edited body'],
'course_id': [str(self.course.id)],
'user_id': [str(self.user.id)],
'anonymous': ['False'],
'anonymous_to_peers': ['False'],
'endorsed': ['False'],
'editing_user_id': [str(self.user.id)],
}
def test_nonexistent_comment(self):
self.register_get_comment_error_response("test_comment", 404)
with pytest.raises(CommentNotFoundError):
update_comment(self.request, "test_comment", {})
def test_nonexistent_course(self):
self.register_comment(thread_overrides={"course_id": "course-v1:non+existent+course"})
with pytest.raises(CourseNotFoundError):
update_comment(self.request, "test_comment", {})
def test_unenrolled(self):
self.register_comment()
self.request.user = UserFactory.create()
with pytest.raises(CourseNotFoundError):
update_comment(self.request, "test_comment", {})
def test_discussions_disabled(self):
self.register_comment(course=_discussion_disabled_course_for(self.user))
with pytest.raises(DiscussionDisabledError):
update_comment(self.request, "test_comment", {})
@ddt.data(
*itertools.product(
[
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_STUDENT,
],
[True, False],
["no_group", "match_group", "different_group"],
)
)
@ddt.unpack
def test_group_access(self, role_name, course_is_cohorted, thread_group_state):
cohort_course, cohort = _create_course_and_cohort_with_user_role(course_is_cohorted, self.user, role_name)
self.register_get_thread_response(make_minimal_cs_thread())
self.register_comment(
{"thread_id": "test_thread"},
thread_overrides={
"id": "test_thread",
"course_id": str(cohort_course.id),
"group_id": (
None if thread_group_state == "no_group" else
cohort.id if thread_group_state == "match_group" else
cohort.id + 1
),
}
)
expected_error = (
role_name == FORUM_ROLE_STUDENT and
course_is_cohorted and
thread_group_state == "different_group"
)
try:
update_comment(self.request, "test_comment", {})
assert not expected_error
except ThreadNotFoundError:
assert expected_error
@ddt.data(*itertools.product(
[
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_STUDENT,
],
[True, False],
[True, False],
))
@ddt.unpack
def test_raw_body_access(self, role_name, is_thread_author, is_comment_author):
_assign_role_to_user(user=self.user, course_id=self.course.id, role=role_name)
self.register_comment(
{"user_id": str(self.user.id if is_comment_author else (self.user.id + 1))},
thread_overrides={
"user_id": str(self.user.id if is_thread_author else (self.user.id + 1))
}
)
expected_error = role_name == FORUM_ROLE_STUDENT and not is_comment_author
try:
update_comment(self.request, "test_comment", {"raw_body": "edited"})
assert not expected_error
except ValidationError as err:
assert expected_error
assert err.message_dict == {'raw_body': ['This field is not editable.']}
@ddt.data(*itertools.product(
[
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_STUDENT,
],
[True, False],
["question", "discussion"],
[True, False],
))
@ddt.unpack
@mock.patch('openedx.core.djangoapps.django_comment_common.signals.comment_endorsed.send')
def test_endorsed_access(self, role_name, is_thread_author, thread_type, is_comment_author, endorsed_mock):
_assign_role_to_user(user=self.user, course_id=self.course.id, role=role_name)
self.register_comment(
{"user_id": str(self.user.id if is_comment_author else (self.user.id + 1))},
thread_overrides={
"thread_type": thread_type,
"user_id": str(self.user.id if is_thread_author else (self.user.id + 1)),
}
)
expected_error = (
role_name == FORUM_ROLE_STUDENT and
(thread_type == "discussion" or not is_thread_author)
)
try:
update_comment(self.request, "test_comment", {"endorsed": True})
self.assertEqual(endorsed_mock.call_count, 1)
assert not expected_error
except ValidationError as err:
assert expected_error
assert err.message_dict == {'endorsed': ['This field is not editable.']}
@ddt.data(
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_STUDENT,
)
@mock.patch("lms.djangoapps.discussion.rest_api.serializers.EDIT_REASON_CODES", {
"test-edit-reason": "Test Edit Reason",
})
@mock.patch("eventtracking.tracker.emit")
def test_update_comment_with_edit_reason_code(self, role_name, mock_emit):
"""
Test editing comments, specifying and retrieving edit reason codes.
"""
_assign_role_to_user(user=self.user, course_id=self.course.id, role=role_name)
self.register_comment({"user_id": str(self.user.id + 1)})
try:
result = update_comment(self.request, "test_comment", {
"raw_body": "Edited body",
"edit_reason_code": "test-edit-reason",
})
assert role_name != FORUM_ROLE_STUDENT
assert result["last_edit"] == {
"original_body": "Original body",
"reason": "Test Edit Reason",
"reason_code": "test-edit-reason",
"author": self.user.username,
}
request_body = httpretty.last_request().parsed_body # pylint: disable=no-member
assert request_body["edit_reason_code"] == ["test-edit-reason"]
expected_event_name = 'edx.forum.response.edited'
expected_event_data = {
'id': 'test_comment',
'content_type': 'Response',
'own_content': False,
'url': '',
'user_course_roles': [],
'user_forums_roles': ['Student', role_name],
'target_username': self.user.username,
'edit_reason': 'test-edit-reason',
'commentable_id': 'dummy'
}
actual_event_name, actual_event_data = mock_emit.call_args[0]
self.assertEqual(actual_event_name, expected_event_name)
self.assertEqual(actual_event_data, expected_event_data)
except ValidationError:
assert role_name == FORUM_ROLE_STUDENT
@ddt.ddt
@disable_signal(api, 'thread_deleted')
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class DeleteThreadTest(
ForumsEnableMixin,
CommentsServiceMockMixin,
UrlResetMixin,
SharedModuleStoreTestCase,
MockSignalHandlerMixin
):
"""Tests for delete_thread"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.course = CourseFactory.create()
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
httpretty.reset()
httpretty.enable()
self.addCleanup(httpretty.reset)
self.addCleanup(httpretty.disable)
patcher = mock.patch(
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
return_value=False
)
patcher.start()
self.addCleanup(patcher.stop)
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment"
)
self.mock_get_course_id_by_comment = patcher.start()
self.addCleanup(patcher.stop)
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread"
)
self.mock_get_course_id_by_thread = patcher.start()
self.addCleanup(patcher.stop)
self.user = UserFactory.create()
self.register_get_user_response(self.user)
self.request = RequestFactory().get("/test_path")
self.request.user = self.user
self.thread_id = "test_thread"
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
def register_thread(self, overrides=None):
"""
Make a thread with appropriate data overridden by the overrides
parameter and register mock responses for both GET and DELETE on its
endpoint.
"""
cs_data = make_minimal_cs_thread({
"id": self.thread_id,
"course_id": str(self.course.id),
"user_id": str(self.user.id),
})
cs_data.update(overrides or {})
self.register_get_thread_response(cs_data)
self.register_delete_thread_response(cs_data["id"])
@mock.patch("eventtracking.tracker.emit")
def test_basic(self, mock_emit):
self.register_thread()
with self.assert_signal_sent(api, 'thread_deleted', sender=None, user=self.user, exclude_args=('post',)):
assert delete_thread(self.request, self.thread_id) is None
assert urlparse(httpretty.last_request().path).path == f"/api/v1/threads/{self.thread_id}" # lint-amnesty, pylint: disable=no-member
assert httpretty.last_request().method == 'DELETE'
expected_event_name = 'edx.forum.thread.deleted'
expected_event_data = {
'body': 'dummy',
'content_type': 'Post',
'own_content': True,
'commentable_id': 'dummy',
'target_username': 'dummy',
'title_truncated': False,
'title': 'dummy',
'id': 'test_thread',
'url': '',
'user_forums_roles': ['Student'],
'user_course_roles': []
}
actual_event_name, actual_event_data = mock_emit.call_args[0]
self.assertEqual(actual_event_name, expected_event_name)
self.assertEqual(actual_event_data, expected_event_data)
def test_thread_id_not_found(self):
self.register_get_thread_error_response("missing_thread", 404)
with pytest.raises(ThreadNotFoundError):
delete_thread(self.request, "missing_thread")
def test_nonexistent_course(self):
self.register_thread({"course_id": "course-v1:non+existent+course"})
with pytest.raises(CourseNotFoundError):
delete_thread(self.request, self.thread_id)
def test_not_enrolled(self):
self.register_thread()
self.request.user = UserFactory.create()
with pytest.raises(CourseNotFoundError):
delete_thread(self.request, self.thread_id)
def test_discussions_disabled(self):
disabled_course = _discussion_disabled_course_for(self.user)
self.register_thread(overrides={"course_id": str(disabled_course.id)})
with pytest.raises(DiscussionDisabledError):
delete_thread(self.request, self.thread_id)
@ddt.data(
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_STUDENT,
)
def test_non_author_delete_allowed(self, role_name):
_assign_role_to_user(user=self.user, course_id=self.course.id, role=role_name)
self.register_thread({"user_id": str(self.user.id + 1)})
expected_error = role_name == FORUM_ROLE_STUDENT
try:
delete_thread(self.request, self.thread_id)
assert not expected_error
except PermissionDenied:
assert expected_error
@ddt.data(
*itertools.product(
[
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_STUDENT,
],
[True, False],
["no_group", "match_group", "different_group"],
)
)
@ddt.unpack
def test_group_access(self, role_name, course_is_cohorted, thread_group_state):
"""
Tests group access for deleting a thread
All privileged roles are able to delete a thread. A student role can
only delete a thread if,
the student role is the author and the thread is not in a cohort,
the student role is the author and the thread is in the author's cohort.
"""
cohort_course, cohort = _create_course_and_cohort_with_user_role(course_is_cohorted, self.user, role_name)
self.register_thread({
"course_id": str(cohort_course.id),
"group_id": (
None if thread_group_state == "no_group" else
cohort.id if thread_group_state == "match_group" else
cohort.id + 1
),
})
expected_error = (
role_name == FORUM_ROLE_STUDENT and
course_is_cohorted and
thread_group_state == "different_group"
)
try:
delete_thread(self.request, self.thread_id)
assert not expected_error
except ThreadNotFoundError:
assert expected_error
@ddt.ddt
@disable_signal(api, 'comment_deleted')
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class DeleteCommentTest(
ForumsEnableMixin,
CommentsServiceMockMixin,
UrlResetMixin,
SharedModuleStoreTestCase,
MockSignalHandlerMixin
):
"""Tests for delete_comment"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.course = CourseFactory.create()
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
httpretty.reset()
httpretty.enable()
self.addCleanup(httpretty.reset)
self.addCleanup(httpretty.disable)
patcher = mock.patch(
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
return_value=False
)
patcher.start()
self.addCleanup(patcher.stop)
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment"
)
self.mock_get_course_id_by_comment = patcher.start()
self.addCleanup(patcher.stop)
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread"
)
self.mock_get_course_id_by_thread = patcher.start()
self.addCleanup(patcher.stop)
self.user = UserFactory.create()
self.register_get_user_response(self.user)
self.request = RequestFactory().get("/test_path")
self.request.user = self.user
self.thread_id = "test_thread"
self.comment_id = "test_comment"
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
def register_comment_and_thread(self, overrides=None, thread_overrides=None):
"""
Make a comment with appropriate data overridden by the override
parameters and register mock responses for both GET and DELETE on its
endpoint. Also mock GET for the related thread with thread_overrides.
"""
cs_thread_data = make_minimal_cs_thread({
"id": self.thread_id,
"course_id": str(self.course.id)
})
cs_thread_data.update(thread_overrides or {})
self.register_get_thread_response(cs_thread_data)
cs_comment_data = make_minimal_cs_comment({
"id": self.comment_id,
"course_id": cs_thread_data["course_id"],
"thread_id": cs_thread_data["id"],
"username": self.user.username,
"user_id": str(self.user.id),
})
cs_comment_data.update(overrides or {})
self.register_get_comment_response(cs_comment_data)
self.register_delete_comment_response(self.comment_id)
@mock.patch("eventtracking.tracker.emit")
def test_basic(self, mock_emit):
self.register_comment_and_thread()
with self.assert_signal_sent(api, 'comment_deleted', sender=None, user=self.user, exclude_args=('post',)):
assert delete_comment(self.request, self.comment_id) is None
assert urlparse(httpretty.last_request().path).path == f"/api/v1/comments/{self.comment_id}" # lint-amnesty, pylint: disable=no-member
assert httpretty.last_request().method == 'DELETE'
expected_event_name = 'edx.forum.response.deleted'
expected_event_data = {
'body': 'dummy',
'content_type': 'Response',
'own_content': True,
'commentable_id': 'dummy',
'target_username': self.user.username,
'id': 'test_comment',
'url': '',
'user_forums_roles': ['Student'],
'user_course_roles': []
}
actual_event_name, actual_event_data = mock_emit.call_args[0]
self.assertEqual(actual_event_name, expected_event_name)
self.assertEqual(actual_event_data, expected_event_data)
def test_comment_id_not_found(self):
self.register_get_comment_error_response("missing_comment", 404)
with pytest.raises(CommentNotFoundError):
delete_comment(self.request, "missing_comment")
def test_nonexistent_course(self):
self.register_comment_and_thread(
thread_overrides={"course_id": "course-v1:non+existent+course"}
)
with pytest.raises(CourseNotFoundError):
delete_comment(self.request, self.comment_id)
def test_not_enrolled(self):
self.register_comment_and_thread()
self.request.user = UserFactory.create()
with pytest.raises(CourseNotFoundError):
delete_comment(self.request, self.comment_id)
def test_discussions_disabled(self):
disabled_course = _discussion_disabled_course_for(self.user)
self.register_comment_and_thread(
thread_overrides={"course_id": str(disabled_course.id)},
overrides={"course_id": str(disabled_course.id)}
)
with pytest.raises(DiscussionDisabledError):
delete_comment(self.request, self.comment_id)
@ddt.data(
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_STUDENT,
)
def test_non_author_delete_allowed(self, role_name):
_assign_role_to_user(user=self.user, course_id=self.course.id, role=role_name)
self.register_comment_and_thread(
overrides={"user_id": str(self.user.id + 1)}
)
expected_error = role_name == FORUM_ROLE_STUDENT
try:
delete_comment(self.request, self.comment_id)
assert not expected_error
except PermissionDenied:
assert expected_error
@ddt.data(
*itertools.product(
[
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_STUDENT,
],
[True, False],
["no_group", "match_group", "different_group"],
)
)
@ddt.unpack
def test_group_access(self, role_name, course_is_cohorted, thread_group_state):
"""
Tests group access for deleting a comment
All privileged roles are able to delete a comment. A student role can
only delete a comment if,
the student role is the author and the comment is not in a cohort,
the student role is the author and the comment is in the author's cohort.
"""
cohort_course, cohort = _create_course_and_cohort_with_user_role(course_is_cohorted, self.user, role_name)
self.register_comment_and_thread(
overrides={"thread_id": "test_thread"},
thread_overrides={
"course_id": str(cohort_course.id),
"group_id": (
None if thread_group_state == "no_group" else
cohort.id if thread_group_state == "match_group" else
cohort.id + 1
),
}
)
expected_error = (
role_name == FORUM_ROLE_STUDENT and
course_is_cohorted and
thread_group_state == "different_group"
)
try:
delete_comment(self.request, self.comment_id)
assert not expected_error
except ThreadNotFoundError:
assert expected_error
@ddt.ddt
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class RetrieveThreadTest(
ForumsEnableMixin,
CommentsServiceMockMixin,
UrlResetMixin,
SharedModuleStoreTestCase
):
"""Tests for get_thread"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.course = CourseFactory.create()
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
httpretty.reset()
httpretty.enable()
self.addCleanup(httpretty.reset)
self.addCleanup(httpretty.disable)
patcher = mock.patch(
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
return_value=False
)
patcher.start()
self.addCleanup(patcher.stop)
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread"
)
self.mock_get_course_id_by_thread = patcher.start()
self.addCleanup(patcher.stop)
self.user = UserFactory.create()
self.register_get_user_response(self.user)
self.request = RequestFactory().get("/test_path")
self.request.user = self.user
self.thread_id = "test_thread"
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
def register_thread(self, overrides=None):
"""
Make a thread with appropriate data overridden by the overrides
parameter and register mock responses for GET on its
endpoint.
"""
cs_data = make_minimal_cs_thread({
"id": self.thread_id,
"course_id": str(self.course.id),
"commentable_id": "test_topic",
"username": self.user.username,
"user_id": str(self.user.id),
"title": "Test Title",
"body": "Test body",
"resp_total": 0,
})
cs_data.update(overrides or {})
self.register_get_thread_response(cs_data)
def test_basic(self):
self.register_thread({"resp_total": 2})
assert get_thread(self.request, self.thread_id) == self.expected_thread_data({
'response_count': 2,
'unread_comment_count': 1
})
assert httpretty.last_request().method == 'GET'
def test_thread_id_not_found(self):
self.register_get_thread_error_response("missing_thread", 404)
with pytest.raises(ThreadNotFoundError):
get_thread(self.request, "missing_thread")
def test_nonauthor_enrolled_in_course(self):
non_author_user = UserFactory.create()
self.register_get_user_response(non_author_user)
CourseEnrollmentFactory.create(user=non_author_user, course_id=self.course.id)
self.register_thread()
self.request.user = non_author_user
assert get_thread(self.request, self.thread_id) == self.expected_thread_data({
'can_delete': False,
'editable_fields': ['abuse_flagged', 'copy_link', 'following', 'read', 'voted'],
'unread_comment_count': 1
})
assert httpretty.last_request().method == 'GET'
def test_not_enrolled_in_course(self):
self.register_thread()
self.request.user = UserFactory.create()
with pytest.raises(CourseNotFoundError):
get_thread(self.request, self.thread_id)
@ddt.data(
*itertools.product(
[
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_STUDENT,
],
[True, False],
["no_group", "match_group", "different_group"],
)
)
@ddt.unpack
def test_group_access(self, role_name, course_is_cohorted, thread_group_state):
"""
Tests group access for retrieving a thread
All privileged roles are able to retrieve a thread. A student role can
only retrieve a thread if,
the student role is the author and the thread is not in a cohort,
the student role is the author and the thread is in the author's cohort.
"""
cohort_course, cohort = _create_course_and_cohort_with_user_role(course_is_cohorted, self.user, role_name)
self.register_thread({
"course_id": str(cohort_course.id),
"group_id": (
None if thread_group_state == "no_group" else
cohort.id if thread_group_state == "match_group" else
cohort.id + 1
),
})
expected_error = (
role_name == FORUM_ROLE_STUDENT and
course_is_cohorted and
thread_group_state == "different_group"
)
try:
get_thread(self.request, self.thread_id)
assert not expected_error
except ThreadNotFoundError:
assert expected_error
def test_course_id_mismatch(self):
"""
Test if the api throws not found exception if course_id from params mismatches course_id in thread
"""
self.register_thread()
get_thread(self.request, self.thread_id, 'different_course_id')
assert ThreadNotFoundError
@mock.patch('lms.djangoapps.discussion.rest_api.api._get_course', mock.Mock())
class CourseTopicsV2Test(ModuleStoreTestCase):
"""
Tests for discussions topic API v2 code.
"""
def setUp(self) -> None:
super().setUp()
self.course = CourseFactory.create(
discussion_topics={f"Course Wide Topic {idx}": {"id": f'course-wide-topic-{idx}'} for idx in range(10)}
)
self.chapter = BlockFactory.create(
parent_location=self.course.location,
category='chapter',
display_name="Week 1",
start=datetime(2015, 3, 1, tzinfo=UTC),
)
self.sequential = BlockFactory.create(
parent_location=self.chapter.location,
category='sequential',
display_name="Lesson 1",
start=datetime(2015, 3, 1, tzinfo=UTC),
)
self.verticals = [
BlockFactory.create(
parent_location=self.sequential.location,
category='vertical',
display_name=f'vertical-{idx}',
start=datetime(2015, 4, 1, tzinfo=UTC),
)
for idx in range(10)
]
staff_only_unit = BlockFactory.create(
parent_location=self.sequential.location,
category='vertical',
display_name='staff-vertical-1',
metadata=dict(visible_to_staff_only=True),
)
self.course_key = course_key = self.course.id
self.config = DiscussionsConfiguration.objects.create(context_key=course_key, provider_type=Provider.OPEN_EDX)
topic_links = []
update_discussions_settings_from_course_task(str(self.course_key))
self.staff_only_id = DiscussionTopicLink.objects.filter(
usage_key__in=[staff_only_unit.location]
).values_list(
'external_id', flat=True,
).get()
topic_id_query = DiscussionTopicLink.objects.filter(context_key=course_key).values_list(
'external_id', flat=True,
)
topic_ids = list(topic_id_query.order_by('ordering'))
topic_ids.remove(self.staff_only_id)
topic_ids_by_name = list(topic_id_query.order_by('title'))
topic_ids_by_name.remove(self.staff_only_id)
self.deleted_topic_ids = deleted_topic_ids = [f'disabled-topic-{idx}' for idx in range(10)]
for idx, topic_id in enumerate(deleted_topic_ids):
usage_key = course_key.make_usage_key('vertical', topic_id)
topic_links.append(
DiscussionTopicLink(
context_key=course_key,
usage_key=usage_key,
title=f"Discussion on {topic_id}",
external_id=topic_id,
provider_id=Provider.OPEN_EDX,
ordering=idx,
enabled_in_context=False,
)
)
DiscussionTopicLink.objects.bulk_create(topic_links)
self.topic_ids = topic_ids
self.topic_ids_by_name = topic_ids_by_name
self.user = UserFactory.create()
self.staff = AdminFactory.create()
self.all_topic_ids = set(topic_ids) | set(deleted_topic_ids) | {self.staff_only_id}
# Set up topic stats for all topics, but have one deleted topic
# and one active topic return zero stats for testing.
self.topic_stats = {
**{topic_id: dict(discussion=random.randint(0, 10), question=random.randint(0, 10))
for topic_id in self.all_topic_ids},
deleted_topic_ids[0]: dict(discussion=0, question=0),
self.topic_ids[0]: dict(discussion=0, question=0),
}
patcher = mock.patch(
'lms.djangoapps.discussion.rest_api.api.get_course_commentable_counts',
mock.Mock(return_value=self.topic_stats),
)
patcher.start()
self.addCleanup(patcher.stop)
def test_default_response(self):
"""
Test that the standard response contains the correct number of items
"""
topics_list = get_course_topics_v2(course_key=self.course_key, user=self.user)
assert {t['id'] for t in topics_list} == set(self.topic_ids)
def test_filtering(self):
"""
Tests that filtering by topic id works
"""
filter_ids = set(random.sample(self.topic_ids, 4))
topics_list = get_course_topics_v2(course_key=self.course_key, user=self.user, topic_ids=filter_ids)
assert len(topics_list) == 4
# All the filtered ids should be returned
assert filter_ids == set(topic_data.get('id') for topic_data in topics_list)
def test_sort_by_name(self):
"""
Test sorting by name
"""
topics_list = get_course_topics_v2(
course_key=self.course_key,
user=self.user,
order_by=TopicOrdering.NAME,
)
returned_topic_ids = [topic_data.get('id') for topic_data in topics_list]
assert returned_topic_ids == self.topic_ids_by_name
def test_sort_by_structure(self):
"""
Test sorting by course structure
"""
topics_list = get_course_topics_v2(
course_key=self.course_key,
user=self.user,
order_by=TopicOrdering.COURSE_STRUCTURE,
)
returned_topic_ids = [topic_data.get('id') for topic_data in topics_list]
# The topics are already sorted in their simulated course order
sorted_topic_ids = self.topic_ids
assert returned_topic_ids == sorted_topic_ids
def test_sort_by_activity(self):
"""
Test sorting by activity
"""
topics_list = get_course_topics_v2(
course_key=self.course_key,
user=self.user,
order_by=TopicOrdering.ACTIVITY,
)
returned_topic_ids = [topic_data.get('id') for topic_data in topics_list]
# The topics are already sorted in their simulated course order
sorted_topic_ids = sorted(
self.topic_ids,
key=lambda tid: sum(self.topic_stats.get(tid, {}).values()),
reverse=True,
)
assert returned_topic_ids == sorted_topic_ids
def test_other_providers_ordering_error(self):
"""
Test that activity sorting raises an error for other providers
"""
self.config.provider_type = 'other'
self.config.save()
with pytest.raises(ValidationError):
get_course_topics_v2(
course_key=self.course_key,
user=self.user,
order_by=TopicOrdering.ACTIVITY,
)