Files
edx-platform/lms/djangoapps/discussion/rest_api/tests/test_api.py
Ali-Salman29 539666dc40 feat!: remove cs_comments_service support for forum's content APIs
- This will force the use of the new v2 forum's APIs for Threads & Comment.
- Update params for get_user_subscription function. It uses the same structure as we have in the get_user_threads.
2025-09-15 09:10:06 -04:00

755 lines
29 KiB
Python

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