Files
edx-platform/lms/djangoapps/discussion/rest_api/tests/test_api.py
Régis Behmo 065adf398e feat: reapply forum v2 changes (#36002)
* feat: Reapply "Integrate Forum V2 into edx-platform"

This reverts commit 818aa343a2.

* feat: make it possible to globally disable forum v2 with setting

We introduce a setting that allows us to bypass any course waffle flag
check. The advantage of such a setting is that we don't need to find the
course ID: in some cases, we might not have access to the course ID, and
we need to look for it... in forum v2.

See discussion here: https://github.com/openedx/forum/issues/137

* chore: bump openedx-forum to 0.1.5

This should fix an issue with index creation on edX.org.
2024-12-12 12:18:33 +05:00

4374 lines
174 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 parse_qs, urlencode, urlparse, urlunparse
import ddt
import httpretty
import pytest
from django.test import override_settings
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.test.client import RequestFactory
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import CourseLocator
from pytz import UTC
from rest_framework.exceptions import PermissionDenied
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory
from xmodule.partitions.partitions import Group, UserPartition
from common.djangoapps.student.tests.factories import (
AdminFactory,
BetaTesterFactory,
CourseEnrollmentFactory,
StaffFactory,
UserFactory
)
from common.djangoapps.util.testing import UrlResetMixin
from common.test.utils import MockSignalHandlerMixin, disable_signal
from lms.djangoapps.discussion.django_comment_client.tests.utils import ForumsEnableMixin
from lms.djangoapps.discussion.rest_api import api
from lms.djangoapps.discussion.rest_api.api import (
create_comment,
create_thread,
delete_comment,
delete_thread,
get_comment_list,
get_course,
get_course_topics,
get_course_topics_v2,
get_thread,
get_thread_list,
get_user_comments,
update_comment,
update_thread
)
from lms.djangoapps.discussion.rest_api.exceptions import (
CommentNotFoundError,
DiscussionBlackOutException,
DiscussionDisabledError,
ThreadNotFoundError
)
from lms.djangoapps.discussion.rest_api.serializers import TopicOrdering
from lms.djangoapps.discussion.rest_api.tests.utils import (
CommentsServiceMockMixin,
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_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,
}
@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 GetThreadListTest(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMixin, SharedModuleStoreTestCase):
"""Test for get_thread_list"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.course = CourseFactory.create()
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
httpretty.reset()
httpretty.enable()
self.addCleanup(httpretty.reset)
self.addCleanup(httpretty.disable)
self.maxDiff = None # pylint: disable=invalid-name
self.user = UserFactory.create()
self.register_get_user_response(self.user)
self.request = RequestFactory().get("/test_path")
self.request.user = self.user
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
self.author = UserFactory.create()
self.course.cohort_config = {"cohorted": False}
modulestore().update_item(self.course, ModuleStoreEnum.UserID.test)
self.cohort = CohortFactory.create(course_id=self.course.id)
def get_thread_list(
self,
threads,
page=1,
page_size=1,
num_pages=1,
course=None,
topic_id_list=None,
):
"""
Register the appropriate comments service response, then call
get_thread_list and return the result.
"""
course = course or self.course
self.register_get_threads_response(threads, page, num_pages)
ret = get_thread_list(self.request, course.id, page, page_size, topic_id_list)
return ret
def test_nonexistent_course(self):
with pytest.raises(CourseNotFoundError):
get_thread_list(self.request, CourseLocator.from_string("course-v1:non+existent+course"), 1, 1)
def test_not_enrolled(self):
self.request.user = UserFactory.create()
with pytest.raises(CourseNotFoundError):
self.get_thread_list([])
def test_discussions_disabled(self):
with pytest.raises(DiscussionDisabledError):
self.get_thread_list([], course=_discussion_disabled_course_for(self.user))
def test_empty(self):
assert self.get_thread_list(
[], num_pages=0
).data == {
'pagination': {
'next': None,
'previous': None,
'num_pages': 0,
'count': 0
},
'results': [],
'text_search_rewrite': None
}
def test_get_threads_by_topic_id(self):
self.get_thread_list([], topic_id_list=["topic_x", "topic_meow"])
assert urlparse(httpretty.last_request().path).path == '/api/v1/threads' # lint-amnesty, pylint: disable=no-member
self.assert_last_query_params({
"user_id": [str(self.user.id)],
"course_id": [str(self.course.id)],
"sort_key": ["activity"],
"page": ["1"],
"per_page": ["1"],
"commentable_ids": ["topic_x,topic_meow"]
})
def test_basic_query_params(self):
self.get_thread_list([], page=6, page_size=14)
self.assert_last_query_params({
"user_id": [str(self.user.id)],
"course_id": [str(self.course.id)],
"sort_key": ["activity"],
"page": ["6"],
"per_page": ["14"],
})
def test_thread_content(self):
self.course.cohort_config = {"cohorted": True}
modulestore().update_item(self.course, ModuleStoreEnum.UserID.test)
source_threads = [
make_minimal_cs_thread({
"id": "test_thread_id_0",
"course_id": str(self.course.id),
"commentable_id": "topic_x",
"username": self.author.username,
"user_id": str(self.author.id),
"title": "Test Title",
"body": "Test body",
"votes": {"up_count": 4},
"comments_count": 5,
"unread_comments_count": 3,
"endorsed": True,
"read": True,
"created_at": "2015-04-28T00:00:00Z",
"updated_at": "2015-04-28T11:11:11Z",
}),
make_minimal_cs_thread({
"id": "test_thread_id_1",
"course_id": str(self.course.id),
"commentable_id": "topic_y",
"group_id": self.cohort.id,
"username": self.author.username,
"user_id": str(self.author.id),
"thread_type": "question",
"title": "Another Test Title",
"body": "More content",
"votes": {"up_count": 9},
"comments_count": 18,
"created_at": "2015-04-28T22:22:22Z",
"updated_at": "2015-04-28T00:33:33Z",
})
]
expected_threads = [
self.expected_thread_data({
"id": "test_thread_id_0",
"author": self.author.username,
"topic_id": "topic_x",
"vote_count": 4,
"comment_count": 6,
"unread_comment_count": 3,
"comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_0",
"editable_fields": ["abuse_flagged", "copy_link", "following", "read", "voted"],
"has_endorsed": True,
"read": True,
"created_at": "2015-04-28T00:00:00Z",
"updated_at": "2015-04-28T11:11:11Z",
"abuse_flagged_count": None,
"can_delete": False,
}),
self.expected_thread_data({
"id": "test_thread_id_1",
"author": self.author.username,
"topic_id": "topic_y",
"group_id": self.cohort.id,
"group_name": self.cohort.name,
"type": "question",
"title": "Another Test Title",
"raw_body": "More content",
"preview_body": "More content",
"rendered_body": "<p>More content</p>",
"vote_count": 9,
"comment_count": 19,
"created_at": "2015-04-28T22:22:22Z",
"updated_at": "2015-04-28T00:33:33Z",
"comment_list_url": None,
"endorsed_comment_list_url": (
"http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_1&endorsed=True"
),
"non_endorsed_comment_list_url": (
"http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_1&endorsed=False"
),
"editable_fields": ["abuse_flagged", "copy_link", "following", "read", "voted"],
"abuse_flagged_count": None,
"can_delete": False,
}),
]
expected_result = make_paginated_api_response(
results=expected_threads, count=2, num_pages=1, next_link=None, previous_link=None
)
expected_result.update({"text_search_rewrite": None})
assert self.get_thread_list(source_threads).data == expected_result
@ddt.data(
*itertools.product(
[
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_STUDENT,
],
[True, False]
)
)
@ddt.unpack
def test_request_group(self, role_name, course_is_cohorted):
cohort_course = CourseFactory.create(cohort_config={"cohorted": course_is_cohorted})
CourseEnrollmentFactory.create(user=self.user, course_id=cohort_course.id)
CohortFactory.create(course_id=cohort_course.id, users=[self.user])
_assign_role_to_user(user=self.user, course_id=cohort_course.id, role=role_name)
self.get_thread_list([], course=cohort_course)
actual_has_group = "group_id" in httpretty.last_request().querystring # lint-amnesty, pylint: disable=no-member
expected_has_group = (course_is_cohorted and role_name == FORUM_ROLE_STUDENT)
assert actual_has_group == expected_has_group
def test_pagination(self):
# N.B. Empty thread list is not realistic but convenient for this test
expected_result = make_paginated_api_response(
results=[], count=0, num_pages=3, next_link="http://testserver/test_path?page=2", previous_link=None
)
expected_result.update({"text_search_rewrite": None})
assert self.get_thread_list([], page=1, num_pages=3).data == expected_result
expected_result = make_paginated_api_response(
results=[],
count=0,
num_pages=3,
next_link="http://testserver/test_path?page=3",
previous_link="http://testserver/test_path?page=1"
)
expected_result.update({"text_search_rewrite": None})
assert self.get_thread_list([], page=2, num_pages=3).data == expected_result
expected_result = make_paginated_api_response(
results=[], count=0, num_pages=3, next_link=None, previous_link="http://testserver/test_path?page=2"
)
expected_result.update({"text_search_rewrite": None})
assert self.get_thread_list([], page=3, num_pages=3).data == expected_result
# Test page past the last one
self.register_get_threads_response([], page=3, num_pages=3)
with pytest.raises(PageNotFoundError):
get_thread_list(self.request, self.course.id, page=4, page_size=10)
@ddt.data(None, "rewritten search string")
def test_text_search(self, text_search_rewrite):
expected_result = make_paginated_api_response(
results=[], count=0, num_pages=0, next_link=None, previous_link=None
)
expected_result.update({"text_search_rewrite": text_search_rewrite})
self.register_get_threads_search_response([], text_search_rewrite, num_pages=0)
assert get_thread_list(
self.request,
self.course.id,
page=1,
page_size=10,
text_search='test search string'
).data == expected_result
self.assert_last_query_params({
"user_id": [str(self.user.id)],
"course_id": [str(self.course.id)],
"sort_key": ["activity"],
"page": ["1"],
"per_page": ["10"],
"text": ["test search string"],
})
def test_filter_threads_by_author(self):
thread = make_minimal_cs_thread()
self.register_get_threads_response([thread], page=1, num_pages=10)
thread_results = get_thread_list(
self.request,
self.course.id,
page=1,
page_size=10,
author=self.user.username,
).data.get('results')
assert len(thread_results) == 1
expected_last_query_params = {
"user_id": [str(self.user.id)],
"course_id": [str(self.course.id)],
"sort_key": ["activity"],
"page": ["1"],
"per_page": ["10"],
"author_id": [str(self.user.id)],
}
self.assert_last_query_params(expected_last_query_params)
def test_filter_threads_by_missing_author(self):
self.register_get_threads_response([make_minimal_cs_thread()], page=1, num_pages=10)
results = get_thread_list(
self.request,
self.course.id,
page=1,
page_size=10,
author="a fake and missing username",
).data.get('results')
assert len(results) == 0
@ddt.data('question', 'discussion', None)
def test_thread_type(self, thread_type):
expected_result = make_paginated_api_response(
results=[], count=0, num_pages=0, next_link=None, previous_link=None
)
expected_result.update({"text_search_rewrite": None})
self.register_get_threads_response([], page=1, num_pages=0)
assert get_thread_list(
self.request,
self.course.id,
page=1,
page_size=10,
thread_type=thread_type,
).data == expected_result
expected_last_query_params = {
"user_id": [str(self.user.id)],
"course_id": [str(self.course.id)],
"sort_key": ["activity"],
"page": ["1"],
"per_page": ["10"],
"thread_type": [thread_type],
}
if thread_type is None:
del expected_last_query_params["thread_type"]
self.assert_last_query_params(expected_last_query_params)
@ddt.data(True, False, None)
def test_flagged(self, flagged_boolean):
expected_result = make_paginated_api_response(
results=[], count=0, num_pages=0, next_link=None, previous_link=None
)
expected_result.update({"text_search_rewrite": None})
self.register_get_threads_response([], page=1, num_pages=0)
assert get_thread_list(
self.request,
self.course.id,
page=1,
page_size=10,
flagged=flagged_boolean,
).data == expected_result
expected_last_query_params = {
"user_id": [str(self.user.id)],
"course_id": [str(self.course.id)],
"sort_key": ["activity"],
"page": ["1"],
"per_page": ["10"],
"flagged": [str(flagged_boolean)],
}
if flagged_boolean is None:
del expected_last_query_params["flagged"]
self.assert_last_query_params(expected_last_query_params)
@ddt.data(
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_COMMUNITY_TA,
)
def test_flagged_count(self, role):
expected_result = make_paginated_api_response(
results=[], count=0, num_pages=0, next_link=None, previous_link=None
)
expected_result.update({"text_search_rewrite": None})
_assign_role_to_user(self.user, self.course.id, role=role)
self.register_get_threads_response([], page=1, num_pages=0)
get_thread_list(
self.request,
self.course.id,
page=1,
page_size=10,
count_flagged=True,
)
expected_last_query_params = {
"user_id": [str(self.user.id)],
"course_id": [str(self.course.id)],
"sort_key": ["activity"],
"count_flagged": ["True"],
"page": ["1"],
"per_page": ["10"],
}
self.assert_last_query_params(expected_last_query_params)
def test_flagged_count_denied(self):
expected_result = make_paginated_api_response(
results=[], count=0, num_pages=0, next_link=None, previous_link=None
)
expected_result.update({"text_search_rewrite": None})
_assign_role_to_user(self.user, self.course.id, role=FORUM_ROLE_STUDENT)
self.register_get_threads_response([], page=1, num_pages=0)
with pytest.raises(PermissionDenied):
get_thread_list(
self.request,
self.course.id,
page=1,
page_size=10,
count_flagged=True,
)
def test_following(self):
self.register_subscribed_threads_response(self.user, [], page=1, num_pages=0)
result = get_thread_list(
self.request,
self.course.id,
page=1,
page_size=11,
following=True,
).data
expected_result = make_paginated_api_response(
results=[], count=0, num_pages=0, next_link=None, previous_link=None
)
expected_result.update({"text_search_rewrite": None})
assert result == expected_result
assert urlparse(
httpretty.last_request().path # lint-amnesty, pylint: disable=no-member
).path == f"/api/v1/users/{self.user.id}/subscribed_threads"
self.assert_last_query_params({
"user_id": [str(self.user.id)],
"course_id": [str(self.course.id)],
"sort_key": ["activity"],
"page": ["1"],
"per_page": ["11"],
})
@ddt.data("unanswered", "unread")
def test_view_query(self, query):
self.register_get_threads_response([], page=1, num_pages=0)
result = get_thread_list(
self.request,
self.course.id,
page=1,
page_size=11,
view=query,
).data
expected_result = make_paginated_api_response(
results=[], count=0, num_pages=0, next_link=None, previous_link=None
)
expected_result.update({"text_search_rewrite": None})
assert result == expected_result
assert urlparse(httpretty.last_request().path).path == '/api/v1/threads' # lint-amnesty, pylint: disable=no-member
self.assert_last_query_params({
"user_id": [str(self.user.id)],
"course_id": [str(self.course.id)],
"sort_key": ["activity"],
"page": ["1"],
"per_page": ["11"],
query: ["true"],
})
@ddt.data(
("last_activity_at", "activity"),
("comment_count", "comments"),
("vote_count", "votes")
)
@ddt.unpack
def test_order_by_query(self, http_query, cc_query):
"""
Tests the order_by parameter
Arguments:
http_query (str): Query string sent in the http request
cc_query (str): Query string used for the comments client service
"""
self.register_get_threads_response([], page=1, num_pages=0)
result = get_thread_list(
self.request,
self.course.id,
page=1,
page_size=11,
order_by=http_query,
).data
expected_result = make_paginated_api_response(
results=[], count=0, num_pages=0, next_link=None, previous_link=None
)
expected_result.update({"text_search_rewrite": None})
assert result == expected_result
assert urlparse(httpretty.last_request().path).path == '/api/v1/threads' # lint-amnesty, pylint: disable=no-member
self.assert_last_query_params({
"user_id": [str(self.user.id)],
"course_id": [str(self.course.id)],
"sort_key": [cc_query],
"page": ["1"],
"per_page": ["11"],
})
def test_order_direction(self):
"""
Only "desc" is supported for order. Also, since it is simply swallowed,
it isn't included in the params.
"""
self.register_get_threads_response([], page=1, num_pages=0)
result = get_thread_list(
self.request,
self.course.id,
page=1,
page_size=11,
order_direction="desc",
).data
expected_result = make_paginated_api_response(
results=[], count=0, num_pages=0, next_link=None, previous_link=None
)
expected_result.update({"text_search_rewrite": None})
assert result == expected_result
assert urlparse(httpretty.last_request().path).path == '/api/v1/threads' # lint-amnesty, pylint: disable=no-member
self.assert_last_query_params({
"user_id": [str(self.user.id)],
"course_id": [str(self.course.id)],
"sort_key": ["activity"],
"page": ["1"],
"per_page": ["11"],
})
def test_invalid_order_direction(self):
"""
Test with invalid order_direction (e.g. "asc")
"""
with pytest.raises(ValidationError) as assertion:
self.register_get_threads_response([], page=1, num_pages=0)
get_thread_list( # pylint: disable=expression-not-assigned
self.request,
self.course.id,
page=1,
page_size=11,
order_direction="asc",
).data
assert 'order_direction' in assertion.value.message_dict
@ddt.ddt
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class GetCommentListTest(ForumsEnableMixin, CommentsServiceMockMixin, SharedModuleStoreTestCase):
"""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',)):
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},
'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',)):
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},
"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',)):
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},
'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_following(self):
self.register_post_thread_response({"id": "test_id", "username": self.user.username})
self.register_subscription_response(self.user)
data = self.minimal_data.copy()
data["following"] = "True"
result = create_thread(self.request, data)
assert result['following'] is True
cs_request = httpretty.last_request()
assert urlparse(cs_request.path).path == f"/api/v1/users/{self.user.id}/subscriptions" # lint-amnesty, pylint: disable=no-member
assert cs_request.method == 'POST'
assert parsed_body(cs_request) == {'source_type': ['thread'], 'source_id': ['test_id']}
def test_abuse_flagged(self):
self.register_post_thread_response({"id": "test_id", "username": self.user.username})
self.register_thread_flag_response("test_id")
data = self.minimal_data.copy()
data["abuse_flagged"] = "True"
result = create_thread(self.request, data)
assert result['abuse_flagged'] is True
cs_request = httpretty.last_request()
assert urlparse(cs_request.path).path == '/api/v1/threads/test_id/abuse_flag' # lint-amnesty, pylint: disable=no-member
assert cs_request.method == 'PUT'
assert parsed_body(cs_request) == {'user_id': [str(self.user.id)]}
def test_course_id_missing(self):
with pytest.raises(ValidationError) as assertion:
create_thread(self.request, {})
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_abuse_flagged(self):
self.register_post_comment_response({"id": "test_comment", "username": self.user.username}, "test_thread")
self.register_comment_flag_response("test_comment")
data = self.minimal_data.copy()
data["abuse_flagged"] = "True"
result = create_comment(self.request, data)
assert result['abuse_flagged'] is True
cs_request = httpretty.last_request()
assert urlparse(cs_request.path).path == '/api/v1/comments/test_comment/abuse_flag' # lint-amnesty, pylint: disable=no-member
assert cs_request.method == 'PUT'
assert parsed_body(cs_request) == {'user_id': [str(self.user.id)]}
def test_thread_id_missing(self):
with pytest.raises(ValidationError) as assertion:
create_comment(self.request, {})
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()}
@ddt.data(*itertools.product([True, False], [True, False]))
@ddt.unpack
@mock.patch("eventtracking.tracker.emit")
def test_following(self, old_following, new_following, mock_emit):
"""
Test attempts to edit the "following" field.
old_following indicates whether the thread should be followed at the
start of the test. new_following indicates the value for the "following"
field in the update. If old_following and new_following are the same, no
update should be made. Otherwise, a subscription should be POSTed or
DELETEd according to the new_following value.
"""
if old_following:
self.register_get_user_response(self.user, subscribed_thread_ids=["test_thread"])
self.register_subscription_response(self.user)
self.register_thread()
data = {"following": new_following}
signal_name = "thread_followed" if new_following else "thread_unfollowed"
mock_path = f"openedx.core.djangoapps.django_comment_common.signals.{signal_name}.send"
with mock.patch(mock_path) as signal_patch:
result = update_thread(self.request, "test_thread", data)
if old_following != new_following:
self.assertEqual(signal_patch.call_count, 1)
assert result['following'] == new_following
last_request_path = urlparse(httpretty.last_request().path).path # lint-amnesty, pylint: disable=no-member
subscription_url = f"/api/v1/users/{self.user.id}/subscriptions"
if old_following == new_following:
assert last_request_path != subscription_url
else:
assert last_request_path == subscription_url
assert httpretty.last_request().method == ('POST' if new_following else 'DELETE')
request_data = (
parsed_body(httpretty.last_request()) if new_following else
parse_qs(urlparse(httpretty.last_request().path).query) # lint-amnesty, pylint: disable=no-member
)
request_data.pop("request_id", None)
assert request_data == {'source_type': ['thread'], 'source_id': ['test_thread']}
event_name, event_data = mock_emit.call_args[0]
expected_event_action = 'followed' if new_following else 'unfollowed'
assert event_name == f'edx.forum.thread.{expected_event_action}'
assert event_data['commentable_id'] == 'original_topic'
assert event_data['id'] == 'test_thread'
assert event_data['followed'] == new_following
assert event_data['user_forums_roles'] == ['Student']
@ddt.data(*itertools.product([True, False], [True, False]))
@ddt.unpack
@mock.patch("eventtracking.tracker.emit")
def test_voted(self, current_vote_status, new_vote_status, mock_emit):
"""
Test attempts to edit the "voted" field.
current_vote_status indicates whether the thread should be upvoted at
the start of the test. new_vote_status indicates the value for the
"voted" field in the update. If current_vote_status and new_vote_status
are the same, no update should be made. Otherwise, a vote should be PUT
or DELETEd according to the new_vote_status value.
"""
#setup
user1, request1 = self.create_user_with_request()
if current_vote_status:
self.register_get_user_response(user1, upvoted_ids=["test_thread"])
self.register_thread_votes_response("test_thread")
self.register_thread()
data = {"voted": new_vote_status}
result = update_thread(request1, "test_thread", data)
assert result['voted'] == new_vote_status
last_request_path = urlparse(httpretty.last_request().path).path # lint-amnesty, pylint: disable=no-member
votes_url = "/api/v1/threads/test_thread/votes"
if current_vote_status == new_vote_status:
assert last_request_path != votes_url
else:
assert last_request_path == votes_url
assert httpretty.last_request().method == ('PUT' if new_vote_status else 'DELETE')
actual_request_data = (
parsed_body(httpretty.last_request()) if new_vote_status else
parse_qs(urlparse(httpretty.last_request().path).query) # lint-amnesty, pylint: disable=no-member
)
actual_request_data.pop("request_id", None)
expected_request_data = {"user_id": [str(user1.id)]}
if new_vote_status:
expected_request_data["value"] = ["up"]
assert actual_request_data == expected_request_data
event_name, event_data = mock_emit.call_args[0]
assert event_name == 'edx.forum.thread.voted'
assert event_data == {
'undo_vote': (not new_vote_status),
'url': '',
'target_username': self.user.username,
'vote_value': 'up',
'user_forums_roles': [FORUM_ROLE_STUDENT],
'user_course_roles': [],
'commentable_id': 'original_topic',
'id': 'test_thread'
}
@ddt.data(*itertools.product([True, False], [True, False], [True, False]))
@ddt.unpack
def test_vote_count(self, current_vote_status, first_vote, second_vote):
"""
Tests vote_count increases and decreases correctly from the same user
"""
#setup
starting_vote_count = 0
user, request = self.create_user_with_request()
if current_vote_status:
self.register_get_user_response(user, upvoted_ids=["test_thread"])
starting_vote_count = 1
self.register_thread_votes_response("test_thread")
self.register_thread(overrides={"votes": {"up_count": starting_vote_count}})
#first vote
data = {"voted": first_vote}
result = update_thread(request, "test_thread", data)
self.register_thread(overrides={"voted": first_vote})
assert result['vote_count'] == (1 if first_vote else 0)
#second vote
data = {"voted": second_vote}
result = update_thread(request, "test_thread", data)
assert result['vote_count'] == (1 if second_vote else 0)
@ddt.data(*itertools.product([True, False], [True, False], [True, False], [True, False]))
@ddt.unpack
def test_vote_count_two_users(
self,
current_user1_vote,
current_user2_vote,
user1_vote,
user2_vote
):
"""
Tests vote_count increases and decreases correctly from different users
"""
#setup
user1, request1 = self.create_user_with_request()
user2, request2 = self.create_user_with_request()
vote_count = 0
if current_user1_vote:
self.register_get_user_response(user1, upvoted_ids=["test_thread"])
vote_count += 1
if current_user2_vote:
self.register_get_user_response(user2, upvoted_ids=["test_thread"])
vote_count += 1
for (current_vote, user_vote, request) in \
[(current_user1_vote, user1_vote, request1),
(current_user2_vote, user2_vote, request2)]:
self.register_thread_votes_response("test_thread")
self.register_thread(overrides={"votes": {"up_count": vote_count}})
data = {"voted": user_vote}
result = update_thread(request, "test_thread", data)
if current_vote == user_vote:
assert result['vote_count'] == vote_count
elif user_vote:
vote_count += 1
assert result['vote_count'] == vote_count
self.register_get_user_response(self.user, upvoted_ids=["test_thread"])
else:
vote_count -= 1
assert result['vote_count'] == vote_count
self.register_get_user_response(self.user, upvoted_ids=[])
@ddt.data(*itertools.product([True, False], [True, False]))
@ddt.unpack
@mock.patch("eventtracking.tracker.emit")
def test_abuse_flagged(self, old_flagged, new_flagged, mock_emit):
"""
Test attempts to edit the "abuse_flagged" field.
old_flagged indicates whether the thread should be flagged at the start
of the test. new_flagged indicates the value for the "abuse_flagged"
field in the update. If old_flagged and new_flagged are the same, no
update should be made. Otherwise, a PUT should be made to the flag or
or unflag endpoint according to the new_flagged value.
"""
self.register_get_user_response(self.user)
self.register_thread_flag_response("test_thread")
self.register_thread({"abuse_flaggers": [str(self.user.id)] if old_flagged else []})
data = {"abuse_flagged": new_flagged}
result = update_thread(self.request, "test_thread", data)
assert result['abuse_flagged'] == new_flagged
last_request_path = urlparse(httpretty.last_request().path).path # lint-amnesty, pylint: disable=no-member
flag_url = "/api/v1/threads/test_thread/abuse_flag"
unflag_url = "/api/v1/threads/test_thread/abuse_unflag"
if old_flagged == new_flagged:
assert last_request_path != flag_url
assert last_request_path != unflag_url
else:
assert last_request_path == (flag_url if new_flagged else unflag_url)
assert httpretty.last_request().method == 'PUT'
assert parsed_body(httpretty.last_request()) == {'user_id': [str(self.user.id)]}
expected_event_name = 'edx.forum.thread.reported' if new_flagged else 'edx.forum.thread.unreported'
expected_event_data = {
'body': 'Original body',
'id': 'test_thread',
'content_type': 'Post',
'commentable_id': 'original_topic',
'url': '',
'user_course_roles': [],
'user_forums_roles': [FORUM_ROLE_STUDENT],
'target_username': self.user.username,
'title_truncated': False,
'title': 'Original Title',
'thread_type': 'discussion',
'group_id': None,
'truncated': False,
}
if not new_flagged:
expected_event_data['reported_status_cleared'] = False
actual_event_name, actual_event_data = mock_emit.call_args[0]
self.assertEqual(actual_event_name, expected_event_name)
self.assertEqual(actual_event_data, expected_event_data)
@ddt.data(
(False, True),
(True, True),
)
@ddt.unpack
@mock.patch("eventtracking.tracker.emit")
def test_thread_un_abuse_flag_for_moderator_role(self, is_author, remove_all, mock_emit):
"""
Test un-abuse flag for moderator role.
When moderator unflags a reported thread, it should
pass the "all" flag to the api. This will indicate
to the api to clear all abuse_flaggers, and mark the
thread as unreported.
"""
_assign_role_to_user(user=self.user, course_id=self.course.id, role=FORUM_ROLE_ADMINISTRATOR)
self.register_get_user_response(self.user)
self.register_thread_flag_response("test_thread")
self.register_thread({"abuse_flaggers": ["11"], "user_id": str(self.user.id) if is_author else "12"})
data = {"abuse_flagged": False}
update_thread(self.request, "test_thread", data)
assert httpretty.last_request().method == 'PUT'
query_params = {'user_id': [str(self.user.id)]}
if remove_all:
query_params.update({'all': ['True']})
assert parsed_body(httpretty.last_request()) == query_params
expected_event_name = 'edx.forum.thread.unreported'
expected_event_data = {
'body': 'Original body',
'id': 'test_thread',
'content_type': 'Post',
'commentable_id': 'original_topic',
'url': '',
'user_course_roles': [],
'user_forums_roles': [FORUM_ROLE_STUDENT, FORUM_ROLE_ADMINISTRATOR],
'target_username': self.user.username,
'title_truncated': False,
'title': 'Original Title',
'reported_status_cleared': False,
'thread_type': 'discussion',
'group_id': None,
'truncated': False,
}
actual_event_name, actual_event_data = mock_emit.call_args[0]
self.assertEqual(actual_event_name, expected_event_name)
self.assertEqual(actual_event_data, expected_event_data)
def test_invalid_field(self):
self.register_thread()
with pytest.raises(ValidationError) as assertion:
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(*itertools.product([True, False], [True, False]))
@ddt.unpack
@mock.patch("eventtracking.tracker.emit")
def test_voted(self, current_vote_status, new_vote_status, mock_emit):
"""
Test attempts to edit the "voted" field.
current_vote_status indicates whether the comment should be upvoted at
the start of the test. new_vote_status indicates the value for the
"voted" field in the update. If current_vote_status and new_vote_status
are the same, no update should be made. Otherwise, a vote should be PUT
or DELETEd according to the new_vote_status value.
"""
vote_count = 0
user1, request1 = self.create_user_with_request()
if current_vote_status:
self.register_get_user_response(user1, upvoted_ids=["test_comment"])
vote_count = 1
self.register_comment_votes_response("test_comment")
self.register_comment(overrides={"votes": {"up_count": vote_count}})
data = {"voted": new_vote_status}
result = update_comment(request1, "test_comment", data)
assert result['vote_count'] == (1 if new_vote_status else 0)
assert result['voted'] == new_vote_status
last_request_path = urlparse(httpretty.last_request().path).path # lint-amnesty, pylint: disable=no-member
votes_url = "/api/v1/comments/test_comment/votes"
if current_vote_status == new_vote_status:
assert last_request_path != votes_url
else:
assert last_request_path == votes_url
assert httpretty.last_request().method == ('PUT' if new_vote_status else 'DELETE')
actual_request_data = (
parsed_body(httpretty.last_request()) if new_vote_status else
parse_qs(urlparse(httpretty.last_request().path).query) # lint-amnesty, pylint: disable=no-member
)
actual_request_data.pop("request_id", None)
expected_request_data = {"user_id": [str(user1.id)]}
if new_vote_status:
expected_request_data["value"] = ["up"]
assert actual_request_data == expected_request_data
event_name, event_data = mock_emit.call_args[0]
assert event_name == 'edx.forum.response.voted'
assert event_data == {
'undo_vote': (not new_vote_status),
'url': '',
'target_username': self.user.username,
'vote_value': 'up',
'user_forums_roles': [FORUM_ROLE_STUDENT],
'user_course_roles': [],
'commentable_id': 'dummy',
'id': 'test_comment'
}
@ddt.data(*itertools.product([True, False], [True, False], [True, False]))
@ddt.unpack
def test_vote_count(self, current_vote_status, first_vote, second_vote):
"""
Tests vote_count increases and decreases correctly from the same user
"""
#setup
starting_vote_count = 0
user1, request1 = self.create_user_with_request()
if current_vote_status:
self.register_get_user_response(user1, upvoted_ids=["test_comment"])
starting_vote_count = 1
self.register_comment_votes_response("test_comment")
self.register_comment(overrides={"votes": {"up_count": starting_vote_count}})
#first vote
data = {"voted": first_vote}
result = update_comment(request1, "test_comment", data)
self.register_comment(overrides={"voted": first_vote})
assert result['vote_count'] == (1 if first_vote else 0)
#second vote
data = {"voted": second_vote}
result = update_comment(request1, "test_comment", data)
assert result['vote_count'] == (1 if second_vote else 0)
@ddt.data(*itertools.product([True, False], [True, False], [True, False], [True, False]))
@ddt.unpack
def test_vote_count_two_users(
self,
current_user1_vote,
current_user2_vote,
user1_vote,
user2_vote
):
"""
Tests vote_count increases and decreases correctly from different users
"""
user1, request1 = self.create_user_with_request()
user2, request2 = self.create_user_with_request()
vote_count = 0
if current_user1_vote:
self.register_get_user_response(user1, upvoted_ids=["test_comment"])
vote_count += 1
if current_user2_vote:
self.register_get_user_response(user2, upvoted_ids=["test_comment"])
vote_count += 1
for (current_vote, user_vote, request) in \
[(current_user1_vote, user1_vote, request1),
(current_user2_vote, user2_vote, request2)]:
self.register_comment_votes_response("test_comment")
self.register_comment(overrides={"votes": {"up_count": vote_count}})
data = {"voted": user_vote}
result = update_comment(request, "test_comment", data)
if current_vote == user_vote:
assert result['vote_count'] == vote_count
elif user_vote:
vote_count += 1
assert result['vote_count'] == vote_count
self.register_get_user_response(self.user, upvoted_ids=["test_comment"])
else:
vote_count -= 1
assert result['vote_count'] == vote_count
self.register_get_user_response(self.user, upvoted_ids=[])
@ddt.data(*itertools.product([True, False], [True, False]))
@ddt.unpack
@mock.patch("eventtracking.tracker.emit")
def test_abuse_flagged(self, old_flagged, new_flagged, mock_emit):
"""
Test attempts to edit the "abuse_flagged" field.
old_flagged indicates whether the comment should be flagged at the start
of the test. new_flagged indicates the value for the "abuse_flagged"
field in the update. If old_flagged and new_flagged are the same, no
update should be made. Otherwise, a PUT should be made to the flag or
or unflag endpoint according to the new_flagged value.
"""
self.register_get_user_response(self.user)
self.register_comment_flag_response("test_comment")
self.register_comment({"abuse_flaggers": [str(self.user.id)] if old_flagged else []})
data = {"abuse_flagged": new_flagged}
result = update_comment(self.request, "test_comment", data)
assert result['abuse_flagged'] == new_flagged
last_request_path = urlparse(httpretty.last_request().path).path # lint-amnesty, pylint: disable=no-member
flag_url = "/api/v1/comments/test_comment/abuse_flag"
unflag_url = "/api/v1/comments/test_comment/abuse_unflag"
if old_flagged == new_flagged:
assert last_request_path != flag_url
assert last_request_path != unflag_url
else:
assert last_request_path == (flag_url if new_flagged else unflag_url)
assert httpretty.last_request().method == 'PUT'
assert parsed_body(httpretty.last_request()) == {'user_id': [str(self.user.id)]}
expected_event_name = 'edx.forum.response.reported' if new_flagged else 'edx.forum.response.unreported'
expected_event_data = {
'body': 'Original body',
'id': 'test_comment',
'content_type': 'Response',
'commentable_id': 'dummy',
'url': '',
'truncated': False,
'user_course_roles': [],
'user_forums_roles': [FORUM_ROLE_STUDENT],
'target_username': self.user.username,
}
if not new_flagged:
expected_event_data['reported_status_cleared'] = False
actual_event_name, actual_event_data = mock_emit.call_args[0]
self.assertEqual(actual_event_name, expected_event_name)
self.assertEqual(actual_event_data, expected_event_data)
@ddt.data(
(False, True),
(True, True),
)
@ddt.unpack
@mock.patch("eventtracking.tracker.emit")
def test_comment_un_abuse_flag_for_moderator_role(self, is_author, remove_all, mock_emit):
"""
Test un-abuse flag for moderator role.
When moderator unflags a reported comment, it should
pass the "all" flag to the api. This will indicate
to the api to clear all abuse_flaggers, and mark the
comment as unreported.
"""
_assign_role_to_user(user=self.user, course_id=self.course.id, role=FORUM_ROLE_ADMINISTRATOR)
self.register_get_user_response(self.user)
self.register_comment_flag_response("test_comment")
self.register_comment({"abuse_flaggers": ["11"], "user_id": str(self.user.id) if is_author else "12"})
data = {"abuse_flagged": False}
update_comment(self.request, "test_comment", data)
assert httpretty.last_request().method == 'PUT'
query_params = {'user_id': [str(self.user.id)]}
if remove_all:
query_params.update({'all': ['True']})
assert parsed_body(httpretty.last_request()) == query_params
expected_event_name = 'edx.forum.response.unreported'
expected_event_data = {
'body': 'Original body',
'id': 'test_comment',
'content_type': 'Response',
'commentable_id': 'dummy',
'truncated': False,
'url': '',
'user_course_roles': [],
'user_forums_roles': [FORUM_ROLE_STUDENT, FORUM_ROLE_ADMINISTRATOR],
'target_username': self.user.username,
'reported_status_cleared': False,
}
actual_event_name, actual_event_data = mock_emit.call_args[0]
self.assertEqual(actual_event_name, expected_event_name)
self.assertEqual(actual_event_data, expected_event_data)
@ddt.data(
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
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,
)