Merge branch 'master' into sameeramin/ENT-10591

This commit is contained in:
Muhammad Sameer Amin
2025-06-30 18:56:07 +05:00
committed by GitHub
44 changed files with 3213 additions and 2140 deletions

View File

@@ -10,6 +10,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('oel_publishing', '0003_containers'),
('oel_components', '0003_remove_componentversioncontent_learner_downloadable'),
('contentstore', '0009_learningcontextlinksstatus_publishableentitylink'),
]

View File

@@ -85,7 +85,7 @@ class BulkEnableDisableDiscussionsTestCase(ModuleStoreTestCase):
self.assertEqual(response.status_code, 200)
response_data = response.json()
print(response_data)
self.assertEqual(response_data['updated_and_republished'], 0 if is_enabled else 2)
self.assertEqual(response_data['units_updated_and_republished'], 0 if is_enabled else 2)
# Check that all verticals now have discussion_enabled set to the expected value
with self.store.bulk_operations(self.course_key):

View File

@@ -29,6 +29,8 @@ from opaque_keys.edx.locator import BlockUsageLocator
from organizations.api import add_organization_course, ensure_organization
from organizations.exceptions import InvalidOrganizationException
from rest_framework.exceptions import ValidationError
from rest_framework.decorators import api_view
from openedx.core.lib.api.view_utils import view_auth_classes
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import create_xblock_info
from cms.djangoapps.course_creators.views import add_user_with_status_unrequested, get_course_creator_status
@@ -1710,10 +1712,9 @@ def group_configurations_detail_handler(request, course_key_string, group_config
)
@login_required
@api_view(['PUT'])
@view_auth_classes()
@expect_json
@ensure_csrf_cookie
@require_http_methods(["PUT"])
def bulk_enable_disable_discussions(request, course_key_string):
"""
API endpoint to enable/disable discussions for all verticals in the course and republish them.
@@ -1733,9 +1734,6 @@ def bulk_enable_disable_discussions(request, course_key_string):
if not has_studio_write_access(user, course_key):
raise PermissionDenied()
if 'application/json' not in request.META.get('HTTP_ACCEPT', 'application/json'):
return JsonResponseBadRequest({"error": "Only supports json requests"})
if 'discussion_enabled' not in request.json:
return JsonResponseBadRequest({"error": "Missing 'discussion_enabled' field in request body"})
discussion_enabled = request.json['discussion_enabled']
@@ -1760,7 +1758,7 @@ def bulk_enable_disable_discussions(request, course_key_string):
if store.has_published_version(vertical):
store.publish(vertical.location, user.id)
changed += 1
return JsonResponse({"updated_and_republished": changed})
return JsonResponse({"units_updated_and_republished": changed})
except Exception as e: # lint-amnesty, pylint: disable=broad-except
log.exception("Exception occurred while enabling/disabling discussion: %s", str(e))
return JsonResponseBadRequest({"error": str(e)})

View File

@@ -161,7 +161,7 @@ def add_truncated_title_to_event_data(event_data, full_title):
event_data['title'] = full_title[:TRACKING_MAX_FORUM_TITLE]
def track_thread_created_event(request, course, thread, followed, from_mfe_sidebar=False):
def track_thread_created_event(request, course, thread, followed, from_mfe_sidebar=False, notify_all_learners=False):
"""
Send analytics event for a newly created thread.
"""
@@ -172,7 +172,10 @@ def track_thread_created_event(request, course, thread, followed, from_mfe_sideb
'thread_type': thread.thread_type,
'anonymous': thread.anonymous,
'anonymous_to_peers': thread.anonymous_to_peers,
'options': {'followed': followed},
'options': {
'followed': followed,
'notify_all_learners': notify_all_learners
},
'from_mfe_sidebar': from_mfe_sidebar,
# There is a stated desire for an 'origin' property that will state
# whether this thread was created via courseware or the forum.

View File

@@ -241,3 +241,219 @@ class NonCohortedTopicGroupIdTestMixin(GroupIdAssertionMixin):
self.call_view(mock_is_forum_v2_enabled, mock_request, team.discussion_topic_id, self.student, '')
self._assert_comments_service_called_without_group_id(mock_request)
class GroupIdAssertionMixinV2:
"""
Provides assertion methods for testing group_id functionality in forum v2.
This mixin contains helper methods to verify that the comments service is called
with the correct group_id parameters and that responses contain the expected
group information.
"""
def _get_params_last_call(self, function_name):
"""
Returns the data or params dict that `mock_request` was called with.
"""
return self.get_mock_func_calls(function_name)[-1][1]
def _assert_comments_service_called_with_group_id(self, group_id):
assert self.check_mock_called('get_user_threads')
assert self._get_params_last_call('get_user_threads')['group_id'] == group_id
def _assert_comments_service_called_without_group_id(self):
assert self.check_mock_called('get_user_threads')
assert 'group_id' not in self._get_params_last_call('get_user_threads')
def _assert_html_response_contains_group_info(self, response):
group_info = {"group_id": None, "group_name": None}
match = re.search(r'"group_id": (\d*),', response.content.decode('utf-8'))
if match and match.group(1) != '':
group_info["group_id"] = int(match.group(1))
match = re.search(r'"group_name": "(\w*)"', response.content.decode('utf-8'))
if match:
group_info["group_name"] = match.group(1)
self._assert_thread_contains_group_info(group_info)
def _assert_json_response_contains_group_info(self, response, extract_thread=None):
"""
:param extract_thread: a function which accepts a dictionary (complete
json response payload) and returns another dictionary (first
occurrence of a thread model within that payload). if None is
passed, the identity function is assumed.
"""
payload = json.loads(response.content.decode('utf-8'))
thread = extract_thread(payload) if extract_thread else payload
self._assert_thread_contains_group_info(thread)
def _assert_thread_contains_group_info(self, thread):
assert thread['group_id'] == self.student_cohort.id
assert thread['group_name'] == self.student_cohort.name
class CohortedTopicGroupIdTestMixinV2(GroupIdAssertionMixinV2):
"""
Provides test cases to verify that views pass the correct `group_id` to
the comments service when requesting content in cohorted discussions for forum v2.
"""
def call_view(self, commentable_id, user, group_id, pass_group_id=True):
"""
Call the view for the implementing test class, constructing a request
from the parameters.
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
def test_cohorted_topic_student_without_group_id(self):
self.call_view("cohorted_topic", self.student, '', pass_group_id=False)
self._assert_comments_service_called_with_group_id(self.student_cohort.id)
def test_cohorted_topic_student_none_group_id(self):
self.call_view("cohorted_topic", self.student, "")
self._assert_comments_service_called_with_group_id(self.student_cohort.id)
def test_cohorted_topic_student_with_own_group_id(self):
self.call_view("cohorted_topic", self.student, self.student_cohort.id)
self._assert_comments_service_called_with_group_id(self.student_cohort.id)
def test_cohorted_topic_student_with_other_group_id(self):
self.call_view(
"cohorted_topic",
self.student,
self.moderator_cohort.id
)
self._assert_comments_service_called_with_group_id(self.student_cohort.id)
def test_cohorted_topic_moderator_without_group_id(self):
self.call_view(
"cohorted_topic",
self.moderator,
'',
pass_group_id=False
)
self._assert_comments_service_called_without_group_id()
def test_cohorted_topic_moderator_none_group_id(self):
self.call_view("cohorted_topic", self.moderator, "")
self._assert_comments_service_called_without_group_id()
def test_cohorted_topic_moderator_with_own_group_id(self):
self.call_view(
"cohorted_topic",
self.moderator,
self.moderator_cohort.id
)
self._assert_comments_service_called_with_group_id(self.moderator_cohort.id)
def test_cohorted_topic_moderator_with_other_group_id(self):
self.call_view(
"cohorted_topic",
self.moderator,
self.student_cohort.id
)
self._assert_comments_service_called_with_group_id(self.student_cohort.id)
def test_cohorted_topic_moderator_with_invalid_group_id(self):
invalid_id = self.student_cohort.id + self.moderator_cohort.id
response = self.call_view("cohorted_topic", self.moderator, invalid_id) # lint-amnesty, pylint: disable=assignment-from-no-return
assert response.status_code == 500
def test_cohorted_topic_enrollment_track_invalid_group_id(self):
CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.AUDIT)
CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.VERIFIED)
discussion_settings = CourseDiscussionSettings.get(self.course.id)
discussion_settings.update({
'divided_discussions': ['cohorted_topic'],
'division_scheme': CourseDiscussionSettings.ENROLLMENT_TRACK,
'always_divide_inline_discussions': True,
})
invalid_id = -1000
response = self.call_view("cohorted_topic", self.moderator, invalid_id) # lint-amnesty, pylint: disable=assignment-from-no-return
assert response.status_code == 500
class NonCohortedTopicGroupIdTestMixinV2(GroupIdAssertionMixinV2):
"""
Provides test cases to verify that views pass the correct `group_id` to
the comments service when requesting content in non-cohorted discussions for forum v2.
"""
def call_view(self, commentable_id, user, group_id, pass_group_id=True):
"""
Call the view for the implementing test class, constructing a request
from the parameters.
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
def test_non_cohorted_topic_student_without_group_id(self):
self.call_view(
"non_cohorted_topic",
self.student,
'',
pass_group_id=False
)
self._assert_comments_service_called_without_group_id()
def test_non_cohorted_topic_student_none_group_id(self):
self.call_view("non_cohorted_topic", self.student, '')
self._assert_comments_service_called_without_group_id()
def test_non_cohorted_topic_student_with_own_group_id(self):
self.call_view(
"non_cohorted_topic",
self.student,
self.student_cohort.id
)
self._assert_comments_service_called_without_group_id()
def test_non_cohorted_topic_student_with_other_group_id(self):
self.call_view(
"non_cohorted_topic",
self.student,
self.moderator_cohort.id
)
self._assert_comments_service_called_without_group_id()
def test_non_cohorted_topic_moderator_without_group_id(self):
self.call_view(
"non_cohorted_topic",
self.moderator,
"",
pass_group_id=False,
)
self._assert_comments_service_called_without_group_id()
def test_non_cohorted_topic_moderator_none_group_id(self):
self.call_view("non_cohorted_topic", self.moderator, '')
self._assert_comments_service_called_without_group_id()
def test_non_cohorted_topic_moderator_with_own_group_id(self):
self.call_view(
"non_cohorted_topic",
self.moderator,
self.moderator_cohort.id,
)
self._assert_comments_service_called_without_group_id()
def test_non_cohorted_topic_moderator_with_other_group_id(self):
self.call_view(
"non_cohorted_topic",
self.moderator,
self.student_cohort.id,
)
self._assert_comments_service_called_without_group_id()
def test_non_cohorted_topic_moderator_with_invalid_group_id(self):
invalid_id = self.student_cohort.id + self.moderator_cohort.id
self.call_view("non_cohorted_topic", self.moderator, invalid_id)
self._assert_comments_service_called_without_group_id()
def test_team_discussion_id_not_cohorted(self):
team = CourseTeamFactory(
course_id=self.course.id,
topic_id='topic-id'
)
team.add_user(self.student)
self.call_view(team.discussion_topic_id, self.student, '')
self._assert_comments_service_called_without_group_id()

View File

@@ -127,7 +127,8 @@ from .utils import (
get_usernames_for_course,
get_usernames_from_search_string,
set_attribute,
is_posting_allowed
is_posting_allowed,
can_user_notify_all_learners
)
User = get_user_model()
@@ -333,6 +334,8 @@ def get_course(request, course_key, check_tab=True):
course.get_discussion_blackout_datetimes()
)
discussion_tab = CourseTabList.get_tab_by_type(course.tabs, 'discussion')
is_course_staff = CourseStaffRole(course_key).has_user(request.user)
is_course_admin = CourseInstructorRole(course_key).has_user(request.user)
return {
"id": str(course_key),
"is_posting_enabled": is_posting_enabled,
@@ -358,8 +361,8 @@ def get_course(request, course_key, check_tab=True):
}),
"is_group_ta": bool(user_roles & {FORUM_ROLE_GROUP_MODERATOR}),
"is_user_admin": request.user.is_staff,
"is_course_staff": CourseStaffRole(course_key).has_user(request.user),
"is_course_admin": CourseInstructorRole(course_key).has_user(request.user),
"is_course_staff": is_course_staff,
"is_course_admin": is_course_admin,
"provider": course_config.provider_type,
"enable_in_context": course_config.enable_in_context,
"group_at_subsection": course_config.plugin_configuration.get("group_at_subsection", False),
@@ -372,6 +375,9 @@ def get_course(request, course_key, check_tab=True):
for (reason_code, label) in CLOSE_REASON_CODES.items()
],
'show_discussions': bool(discussion_tab and discussion_tab.is_enabled(course, request.user)),
'is_notify_all_learners_enabled': can_user_notify_all_learners(
course_key, user_roles, is_course_staff, is_course_admin
),
}
@@ -1469,6 +1475,8 @@ def create_thread(request, thread_data):
if not discussion_open_for_user(course, user):
raise DiscussionBlackOutException
notify_all_learners = thread_data.pop("notify_all_learners", False)
context = get_context(course, request)
_check_initializable_thread_fields(thread_data, context)
discussion_settings = CourseDiscussionSettings.get(course_key)
@@ -1484,12 +1492,12 @@ def create_thread(request, thread_data):
raise ValidationError(dict(list(serializer.errors.items()) + list(actions_form.errors.items())))
serializer.save()
cc_thread = serializer.instance
thread_created.send(sender=None, user=user, post=cc_thread)
thread_created.send(sender=None, user=user, post=cc_thread, notify_all_learners=notify_all_learners)
api_thread = serializer.data
_do_extra_actions(api_thread, cc_thread, list(thread_data.keys()), actions_form, context, request)
track_thread_created_event(request, course, cc_thread, actions_form.cleaned_data["following"],
from_mfe_sidebar)
from_mfe_sidebar, notify_all_learners)
return api_thread

View File

@@ -318,17 +318,18 @@ class DiscussionNotificationSender:
self._populate_context_with_ids_for_mobile(context, notification_type)
self._send_notification([self.creator.id], notification_type, extra_context=context)
def send_new_thread_created_notification(self):
def send_new_thread_created_notification(self, notify_all_learners=False):
"""
Send notification based on notification_type
"""
thread_type = self.thread.attributes['thread_type']
notification_type = (
notification_type = "new_instructor_all_learners_post" if notify_all_learners else (
"new_question_post"
if thread_type == "question"
else ("new_discussion_post" if thread_type == "discussion" else "")
)
if notification_type not in ['new_discussion_post', 'new_question_post']:
if notification_type not in ['new_discussion_post', 'new_question_post', 'new_instructor_all_learners_post']:
raise ValueError(f'Invalid notification type {notification_type}')
audience_filters = self._create_cohort_course_audience()

View File

@@ -6,8 +6,11 @@ from django.contrib.auth import get_user_model
from edx_django_utils.monitoring import set_code_owner_attribute
from opaque_keys.edx.locator import CourseKey
from common.djangoapps.student.roles import CourseStaffRole, CourseInstructorRole
from lms.djangoapps.courseware.courses import get_course_with_access
from lms.djangoapps.discussion.django_comment_client.utils import get_user_role_names
from lms.djangoapps.discussion.rest_api.discussions_notifications import DiscussionNotificationSender
from lms.djangoapps.discussion.rest_api.utils import can_user_notify_all_learners
from openedx.core.djangoapps.django_comment_common.comment_client import Comment
from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread
from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS
@@ -17,7 +20,7 @@ User = get_user_model()
@shared_task
@set_code_owner_attribute
def send_thread_created_notification(thread_id, course_key_str, user_id):
def send_thread_created_notification(thread_id, course_key_str, user_id, notify_all_learners=False):
"""
Send notification when a new thread is created
"""
@@ -26,9 +29,17 @@ def send_thread_created_notification(thread_id, course_key_str, user_id):
return
thread = Thread(id=thread_id).retrieve()
user = User.objects.get(id=user_id)
if notify_all_learners:
is_course_staff = CourseStaffRole(course_key).has_user(user)
is_course_admin = CourseInstructorRole(course_key).has_user(user)
user_roles = get_user_role_names(user, course_key)
if not can_user_notify_all_learners(course_key, user_roles, is_course_staff, is_course_admin):
return
course = get_course_with_access(user, 'load', course_key, check_if_enrolled=True)
notification_sender = DiscussionNotificationSender(thread, course, user)
notification_sender.send_new_thread_created_notification()
notification_sender.send_new_thread_created_notification(notify_all_learners)
@shared_task

View File

@@ -21,7 +21,6 @@ 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
@@ -48,7 +47,6 @@ from lms.djangoapps.discussion.rest_api.api import (
get_course_topics,
get_course_topics_v2,
get_thread,
get_thread_list,
get_user_comments,
update_comment,
update_thread
@@ -77,7 +75,6 @@ from openedx.core.djangoapps.django_comment_common.models import (
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_STUDENT,
FORUM_ROLE_GROUP_MODERATOR,
Role
)
from openedx.core.lib.exceptions import CourseNotFoundError, PageNotFoundError
@@ -217,6 +214,7 @@ class GetCourseTest(ForumsEnableMixin, UrlResetMixin, SharedModuleStoreTestCase)
'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
}
@ddt.data(
@@ -698,542 +696,6 @@ class GetCourseTopicsTest(CommentsServiceMockMixin, ForumsEnableMixin, UrlResetM
}
@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,
FORUM_ROLE_GROUP_MODERATOR,
],
[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 in (FORUM_ROLE_STUDENT, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_GROUP_MODERATOR))
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):
@@ -1918,7 +1380,9 @@ class CreateThreadTest(
"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',)):
with self.assert_signal_sent(
api, 'thread_created', sender=None, user=self.user, exclude_args=('post', 'notify_all_learners')
):
actual = create_thread(self.request, self.minimal_data)
expected = self.expected_thread_data({
"id": "test_id",
@@ -1947,7 +1411,10 @@ class CreateThreadTest(
'title_truncated': False,
'anonymous': False,
'anonymous_to_peers': False,
'options': {'followed': False},
'options': {
'followed': False,
'notify_all_learners': False
},
'id': 'test_id',
'truncated': False,
'body': 'Test body',
@@ -1984,7 +1451,9 @@ class CreateThreadTest(
_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',)):
with self.assert_signal_sent(
api, 'thread_created', sender=None, user=self.user, exclude_args=('post', 'notify_all_learners')
):
actual = create_thread(self.request, self.minimal_data)
expected = self.expected_thread_data({
"author_label": "Moderator",
@@ -2034,7 +1503,10 @@ class CreateThreadTest(
"title_truncated": False,
"anonymous": False,
"anonymous_to_peers": False,
"options": {"followed": False},
"options": {
"followed": False,
"notify_all_learners": False
},
"id": "test_id",
"truncated": False,
"body": "Test body",
@@ -2056,7 +1528,9 @@ class CreateThreadTest(
"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',)):
with self.assert_signal_sent(
api, 'thread_created', sender=None, user=self.user, exclude_args=('post', 'notify_all_learners')
):
create_thread(self.request, data)
event_name, event_data = mock_emit.call_args[0]
assert event_name == 'edx.forum.thread.created'
@@ -2068,7 +1542,10 @@ class CreateThreadTest(
'title_truncated': True,
'anonymous': False,
'anonymous_to_peers': False,
'options': {'followed': False},
'options': {
'followed': False,
'notify_all_learners': False
},
'id': 'test_id',
'truncated': False,
'body': 'Test body',

View File

@@ -92,6 +92,7 @@ from openedx.core.djangoapps.discussions.tasks import (
from openedx.core.djangoapps.django_comment_common.models import (
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_GROUP_MODERATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_STUDENT,
Role,
@@ -1054,3 +1055,639 @@ class UpdateCommentTest(
vote_count -= 1
assert result["vote_count"] == vote_count
self.register_get_user_response(self.user, upvoted_ids=[])
@ddt.ddt
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class GetThreadListTest(
ForumsEnableMixin, ForumMockUtilsMixin, UrlResetMixin, SharedModuleStoreTestCase
):
"""Test for get_thread_list"""
@classmethod
def setUpClass(cls):
super().setUpClass()
super().setUpClassAndForumMock()
cls.course = CourseFactory.create()
@classmethod
def tearDownClass(cls):
"""Stop patches after tests complete."""
super().tearDownClass()
super().disposeForumMocks()
@mock.patch.dict(
"django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}
)
def setUp(self):
super().setUp()
httpretty.reset()
httpretty.enable()
self.addCleanup(httpretty.reset)
self.addCleanup(httpretty.disable)
self.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"])
self.check_mock_called("get_user_threads")
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"],
}
self.check_mock_called_with(
"get_user_threads",
-1,
**params,
)
def test_basic_query_params(self):
self.get_thread_list([], page=6, page_size=14)
params = {
"user_id": str(self.user.id),
"course_id": str(self.course.id),
"sort_key": "activity",
"page": 6,
"per_page": 14,
}
self.check_mock_called_with(
"get_user_threads",
-1,
**params,
)
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)
thread_func_params = self.get_mock_func_calls("get_user_threads")[-1][1]
actual_has_group = "group_id" in thread_func_params
expected_has_group = (
course_is_cohorted and role_name in (
FORUM_ROLE_STUDENT, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_GROUP_MODERATOR
)
)
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
)
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",
}
self.check_mock_called_with(
"search_threads",
-1,
**params,
)
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
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.check_mock_called_with(
"get_user_threads",
-1,
**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.check_mock_called_with(
"get_user_threads",
-1,
**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": flagged_boolean,
}
if flagged_boolean is None:
del expected_last_query_params["flagged"]
self.check_mock_called_with(
"get_user_threads",
-1,
**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.check_mock_called_with(
"get_user_threads", -1, **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
self.check_mock_called("get_user_subscriptions")
params = {
"course_id": str(self.course.id),
"sort_key": "activity",
"page": 1,
"per_page": 11,
}
self.check_mock_called_with(
"get_user_subscriptions", -1, str(self.user.id), str(self.course.id), params
)
@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
self.check_mock_called("get_user_threads")
params = {
"user_id": str(self.user.id),
"course_id": str(self.course.id),
"sort_key": "activity",
"page": 1,
"per_page": 11,
query: True,
}
self.check_mock_called_with(
"get_user_threads",
-1,
**params,
)
@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
params = {
"user_id": str(self.user.id),
"course_id": str(self.course.id),
"sort_key": cc_query,
"page": 1,
"per_page": 11,
}
self.check_mock_called_with(
"get_user_threads",
-1,
**params,
)
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
params = {
"user_id": str(self.user.id),
"course_id": str(self.course.id),
"sort_key": "activity",
"page": 1,
"per_page": 11,
}
self.check_mock_called_with(
"get_user_threads",
-1,
**params,
)
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

View File

@@ -29,7 +29,7 @@ from openedx.core.djangoapps.django_comment_common.models import (
FORUM_ROLE_STUDENT,
CourseDiscussionSettings
)
from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS
from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS, ENABLE_NOTIFY_ALL_LEARNERS
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
@@ -190,29 +190,46 @@ class TestNewThreadCreatedNotification(DiscussionAPIViewTestMixin, ModuleStoreTe
"""
@ddt.data(
('new_question_post',),
('new_discussion_post',),
('new_question_post', False, False),
('new_discussion_post', False, False),
('new_discussion_post', True, True),
('new_discussion_post', True, False),
)
@ddt.unpack
def test_notification_is_send_to_all_enrollments(self, notification_type):
def test_notification_is_send_to_all_enrollments(
self, notification_type, notify_all_learners, waffle_flag_enabled
):
"""
Tests notification is sent to all users if course is not cohorted
"""
self._assign_enrollments()
thread_type = (
"discussion"
if notification_type == "new_discussion_post"
else ("question" if notification_type == "new_question_post" else "")
"discussion" if notification_type == "new_discussion_post" else "question"
)
thread = self._create_thread(thread_type=thread_type)
handler = mock.Mock()
COURSE_NOTIFICATION_REQUESTED.connect(handler)
send_thread_created_notification(thread['id'], str(self.course.id), self.author.id)
self.assertEqual(handler.call_count, 1)
course_notification_data = handler.call_args[1]['course_notification_data']
assert notification_type == course_notification_data.notification_type
notification_audience_filters = {}
assert notification_audience_filters == course_notification_data.audience_filters
with override_waffle_flag(ENABLE_NOTIFY_ALL_LEARNERS, active=waffle_flag_enabled):
thread = self._create_thread(thread_type=thread_type)
handler = mock.Mock()
COURSE_NOTIFICATION_REQUESTED.connect(handler)
send_thread_created_notification(
thread['id'],
str(self.course.id),
self.author.id,
notify_all_learners
)
expected_handler_calls = 0 if notify_all_learners and not waffle_flag_enabled else 1
self.assertEqual(handler.call_count, expected_handler_calls)
if handler.call_count:
course_notification_data = handler.call_args[1]['course_notification_data']
expected_type = (
'new_instructor_all_learners_post'
if notify_all_learners and waffle_flag_enabled
else notification_type
)
self.assertEqual(course_notification_data.notification_type, expected_type)
self.assertEqual(course_notification_data.audience_filters, {})
@ddt.data(
('cohort_1', 'new_question_post'),

View File

@@ -557,6 +557,7 @@ class CourseViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
"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
}
)
@@ -1056,354 +1057,6 @@ class CourseTopicsViewV3Test(DiscussionAPIViewTestMixin, CommentsServiceMockMixi
assert vertical_keys == expected_non_courseware_keys
@ddt.ddt
@httpretty.activate
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class ThreadViewSetListTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase, ProfileImageTestMixin):
"""Tests for ThreadViewSet list"""
def setUp(self):
super().setUp()
self.author = UserFactory.create()
self.url = reverse("thread-list")
patcher = mock.patch(
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
return_value=False
)
patcher.start()
self.addCleanup(patcher.stop)
def create_source_thread(self, overrides=None):
"""
Create a sample source cs_thread
"""
thread = make_minimal_cs_thread({
"id": "test_thread",
"course_id": str(self.course.id),
"commentable_id": "test_topic",
"user_id": str(self.user.id),
"username": self.user.username,
"created_at": "2015-04-28T00:00:00Z",
"updated_at": "2015-04-28T11:11:11Z",
"title": "Test Title",
"body": "Test body",
"votes": {"up_count": 4},
"comments_count": 5,
"unread_comments_count": 3,
})
thread.update(overrides or {})
return thread
def test_course_id_missing(self):
response = self.client.get(self.url)
self.assert_response_correct(
response,
400,
{"field_errors": {"course_id": {"developer_message": "This field is required."}}}
)
def test_404(self):
response = self.client.get(self.url, {"course_id": "non/existent/course"})
self.assert_response_correct(
response,
404,
{"developer_message": "Course not found."}
)
def test_basic(self):
self.register_get_user_response(self.user, upvoted_ids=["test_thread"])
source_threads = [
self.create_source_thread({"user_id": str(self.author.id), "username": self.author.username})
]
expected_threads = [self.expected_thread_data({
"created_at": "2015-04-28T00:00:00Z",
"updated_at": "2015-04-28T11:11:11Z",
"vote_count": 4,
"comment_count": 6,
"can_delete": False,
"unread_comment_count": 3,
"voted": True,
"author": self.author.username,
"editable_fields": ["abuse_flagged", "copy_link", "following", "read", "voted"],
"abuse_flagged_count": None,
})]
self.register_get_threads_response(source_threads, page=1, num_pages=2)
response = self.client.get(self.url, {"course_id": str(self.course.id), "following": ""})
expected_response = make_paginated_api_response(
results=expected_threads,
count=1,
num_pages=2,
next_link="http://testserver/api/discussion/v1/threads/?course_id=course-v1%3Ax%2By%2Bz&following=&page=2",
previous_link=None
)
expected_response.update({"text_search_rewrite": None})
self.assert_response_correct(
response,
200,
expected_response
)
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"],
})
@ddt.data("unread", "unanswered", "unresponded")
def test_view_query(self, query):
threads = [make_minimal_cs_thread()]
self.register_get_user_response(self.user)
self.register_get_threads_response(threads, page=1, num_pages=1)
self.client.get(
self.url,
{
"course_id": str(self.course.id),
"view": query,
}
)
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"],
query: ["true"],
})
def test_pagination(self):
self.register_get_user_response(self.user)
self.register_get_threads_response([], page=1, num_pages=1)
response = self.client.get(
self.url,
{"course_id": str(self.course.id), "page": "18", "page_size": "4"}
)
self.assert_response_correct(
response,
404,
{"developer_message": "Page not found (No results on this page)."}
)
self.assert_last_query_params({
"user_id": [str(self.user.id)],
"course_id": [str(self.course.id)],
"sort_key": ["activity"],
"page": ["18"],
"per_page": ["4"],
})
def test_text_search(self):
self.register_get_user_response(self.user)
self.register_get_threads_search_response([], None, num_pages=0)
response = self.client.get(
self.url,
{"course_id": str(self.course.id), "text_search": "test search string"}
)
expected_response = make_paginated_api_response(
results=[], count=0, num_pages=0, next_link=None, previous_link=None
)
expected_response.update({"text_search_rewrite": None})
self.assert_response_correct(
response,
200,
expected_response
)
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"],
})
@ddt.data(True, "true", "1")
def test_following_true(self, following):
self.register_get_user_response(self.user)
self.register_subscribed_threads_response(self.user, [], page=1, num_pages=0)
response = self.client.get(
self.url,
{
"course_id": str(self.course.id),
"following": following,
}
)
expected_response = make_paginated_api_response(
results=[], count=0, num_pages=0, next_link=None, previous_link=None
)
expected_response.update({"text_search_rewrite": None})
self.assert_response_correct(
response,
200,
expected_response
)
assert urlparse(
httpretty.last_request().path # lint-amnesty, pylint: disable=no-member
).path == f"/api/v1/users/{self.user.id}/subscribed_threads"
@ddt.data(False, "false", "0")
def test_following_false(self, following):
response = self.client.get(
self.url,
{
"course_id": str(self.course.id),
"following": following,
}
)
self.assert_response_correct(
response,
400,
{"field_errors": {
"following": {"developer_message": "The value of the 'following' parameter must be true."}
}}
)
def test_following_error(self):
response = self.client.get(
self.url,
{
"course_id": str(self.course.id),
"following": "invalid-boolean",
}
)
self.assert_response_correct(
response,
400,
{"field_errors": {
"following": {"developer_message": "Invalid Boolean Value."}
}}
)
@ddt.data(
("last_activity_at", "activity"),
("comment_count", "comments"),
("vote_count", "votes")
)
@ddt.unpack
def test_order_by(self, http_query, cc_query):
"""
Tests the order_by parameter
Arguments:
http_query (str): Query string sent in the http request
cc_query (str): Query string used for the comments client service
"""
threads = [make_minimal_cs_thread()]
self.register_get_user_response(self.user)
self.register_get_threads_response(threads, page=1, num_pages=1)
self.client.get(
self.url,
{
"course_id": str(self.course.id),
"order_by": http_query,
}
)
self.assert_last_query_params({
"user_id": [str(self.user.id)],
"course_id": [str(self.course.id)],
"page": ["1"],
"per_page": ["10"],
"sort_key": [cc_query],
})
def test_order_direction(self):
"""
Test order direction, of which "desc" is the only valid option. The
option actually just gets swallowed, so it doesn't affect the params.
"""
threads = [make_minimal_cs_thread()]
self.register_get_user_response(self.user)
self.register_get_threads_response(threads, page=1, num_pages=1)
self.client.get(
self.url,
{
"course_id": str(self.course.id),
"order_direction": "desc",
}
)
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"],
})
def test_mutually_exclusive(self):
"""
Tests GET thread_list api does not allow filtering on mutually exclusive parameters
"""
self.register_get_user_response(self.user)
self.register_get_threads_search_response([], None, num_pages=0)
response = self.client.get(self.url, {
"course_id": str(self.course.id),
"text_search": "test search string",
"topic_id": "topic1, topic2",
})
self.assert_response_correct(
response,
400,
{
"developer_message": "The following query parameters are mutually exclusive: topic_id, "
"text_search, following"
}
)
def test_profile_image_requested_field(self):
"""
Tests thread has user profile image details if called in requested_fields
"""
user_2 = UserFactory.create(password=self.password)
# Ensure that parental controls don't apply to this user
user_2.profile.year_of_birth = 1970
user_2.profile.save()
source_threads = [
self.create_source_thread(),
self.create_source_thread({"user_id": str(user_2.id), "username": user_2.username}),
]
self.register_get_user_response(self.user, upvoted_ids=["test_thread"])
self.register_get_threads_response(source_threads, page=1, num_pages=1)
self.create_profile_image(self.user, get_profile_image_storage())
self.create_profile_image(user_2, get_profile_image_storage())
response = self.client.get(
self.url,
{"course_id": str(self.course.id), "requested_fields": "profile_image"},
)
assert response.status_code == 200
response_threads = json.loads(response.content.decode('utf-8'))['results']
for response_thread in response_threads:
expected_profile_data = self.get_expected_user_profile(response_thread['author'])
response_users = response_thread['users']
assert expected_profile_data == response_users[response_thread['author']]
def test_profile_image_requested_field_anonymous_user(self):
"""
Tests profile_image in requested_fields for thread created with anonymous user
"""
source_threads = [
self.create_source_thread(
{"user_id": None, "username": None, "anonymous": True, "anonymous_to_peers": True}
),
]
self.register_get_user_response(self.user, upvoted_ids=["test_thread"])
self.register_get_threads_response(source_threads, page=1, num_pages=1)
response = self.client.get(
self.url,
{"course_id": str(self.course.id), "requested_fields": "profile_image"},
)
assert response.status_code == 200
response_thread = json.loads(response.content.decode('utf-8'))['results'][0]
assert response_thread['author'] is None
assert {} == response_thread['users']
@httpretty.activate
@disable_signal(api, 'thread_created')
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})

View File

@@ -21,7 +21,9 @@ from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import override_settings
from django.urls import reverse
from edx_toggles.toggles.testutils import override_waffle_flag
from lms.djangoapps.discussion.django_comment_client.tests.mixins import MockForumApiMixin
from lms.djangoapps.discussion.django_comment_client.tests.mixins import (
MockForumApiMixin,
)
from opaque_keys.edx.keys import CourseKey
from pytz import UTC
from rest_framework import status
@@ -32,18 +34,32 @@ from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE
from lms.djangoapps.discussion.rest_api.utils import get_usernames_from_search_string
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory, check_mongo_calls
from xmodule.modulestore.tests.django_utils import (
ModuleStoreTestCase,
SharedModuleStoreTestCase,
)
from xmodule.modulestore.tests.factories import (
CourseFactory,
BlockFactory,
check_mongo_calls,
)
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
from common.djangoapps.student.models import get_retired_username_by_username, CourseEnrollment
from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole, GlobalStaff
from common.djangoapps.student.models import (
get_retired_username_by_username,
CourseEnrollment,
)
from common.djangoapps.student.roles import (
CourseInstructorRole,
CourseStaffRole,
GlobalStaff,
)
from common.djangoapps.student.tests.factories import (
AdminFactory,
CourseEnrollmentFactory,
SuperuserFactory,
UserFactory
UserFactory,
)
from common.djangoapps.util.testing import PatchMediaTypeMixin, UrlResetMixin
from common.test.utils import disable_signal
@@ -65,15 +81,34 @@ from lms.djangoapps.discussion.rest_api.tests.utils import (
parsed_body,
)
from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts
from openedx.core.djangoapps.discussions.config.waffle import ENABLE_NEW_STRUCTURE_DISCUSSIONS
from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, DiscussionTopicLink, Provider
from openedx.core.djangoapps.discussions.tasks import update_discussions_settings_from_course_task
from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings, Role
from openedx.core.djangoapps.discussions.config.waffle import (
ENABLE_NEW_STRUCTURE_DISCUSSIONS,
)
from openedx.core.djangoapps.discussions.models import (
DiscussionsConfiguration,
DiscussionTopicLink,
Provider,
)
from openedx.core.djangoapps.discussions.tasks import (
update_discussions_settings_from_course_task,
)
from openedx.core.djangoapps.django_comment_common.models import (
CourseDiscussionSettings,
Role,
)
from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles
from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user
from openedx.core.djangoapps.oauth_dispatch.tests.factories import AccessTokenFactory, ApplicationFactory
from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_storage
from openedx.core.djangoapps.user_api.models import RetirementState, UserRetirementStatus
from openedx.core.djangoapps.oauth_dispatch.tests.factories import (
AccessTokenFactory,
ApplicationFactory,
)
from openedx.core.djangoapps.user_api.accounts.image_helpers import (
get_profile_image_storage,
)
from openedx.core.djangoapps.user_api.models import (
RetirementState,
UserRetirementStatus,
)
class DiscussionAPIViewTestMixin(ForumsEnableMixin, ForumMockUtilsMixin, UrlResetMixin):
@@ -86,7 +121,9 @@ class DiscussionAPIViewTestMixin(ForumsEnableMixin, ForumMockUtilsMixin, UrlRese
client_class = APIClient
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
@mock.patch.dict(
"django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}
)
def setUp(self):
super().setUp()
self.maxDiff = None # pylint: disable=invalid-name
@@ -95,7 +132,7 @@ class DiscussionAPIViewTestMixin(ForumsEnableMixin, ForumMockUtilsMixin, UrlRese
course="y",
run="z",
start=datetime.now(UTC),
discussion_topics={"Test Topic": {"id": "test_topic"}}
discussion_topics={"Test Topic": {"id": "test_topic"}},
)
self.password = "Password1234"
self.user = UserFactory.create(password=self.password)
@@ -120,23 +157,25 @@ class DiscussionAPIViewTestMixin(ForumsEnableMixin, ForumMockUtilsMixin, UrlRese
Assert that the response has the given status code and parsed content
"""
assert response.status_code == expected_status
parsed_content = json.loads(response.content.decode('utf-8'))
parsed_content = json.loads(response.content.decode("utf-8"))
assert parsed_content == expected_content
def register_thread(self, overrides=None):
"""
Create cs_thread with minimal fields and register response
"""
cs_thread = make_minimal_cs_thread({
"id": "test_thread",
"course_id": str(self.course.id),
"commentable_id": "test_topic",
"username": self.user.username,
"user_id": str(self.user.id),
"thread_type": "discussion",
"title": "Test Title",
"body": "Test body",
})
cs_thread = make_minimal_cs_thread(
{
"id": "test_thread",
"course_id": str(self.course.id),
"commentable_id": "test_topic",
"username": self.user.username,
"user_id": str(self.user.id),
"thread_type": "discussion",
"title": "Test Title",
"body": "Test body",
}
)
cs_thread.update(overrides or {})
self.register_get_thread_response(cs_thread)
self.register_put_thread_response(cs_thread)
@@ -145,14 +184,16 @@ class DiscussionAPIViewTestMixin(ForumsEnableMixin, ForumMockUtilsMixin, UrlRese
"""
Create cs_comment with minimal fields and register response
"""
cs_comment = make_minimal_cs_comment({
"id": "test_comment",
"course_id": str(self.course.id),
"thread_id": "test_thread",
"username": self.user.username,
"user_id": str(self.user.id),
"body": "Original body",
})
cs_comment = make_minimal_cs_comment(
{
"id": "test_comment",
"course_id": str(self.course.id),
"thread_id": "test_thread",
"username": self.user.username,
"user_id": str(self.user.id),
"body": "Original body",
}
)
cs_comment.update(overrides or {})
self.register_get_comment_response(cs_comment)
self.register_put_comment_response(cs_comment)
@@ -164,7 +205,7 @@ class DiscussionAPIViewTestMixin(ForumsEnableMixin, ForumMockUtilsMixin, UrlRese
self.assert_response_correct(
response,
401,
{"developer_message": "Authentication credentials were not provided."}
{"developer_message": "Authentication credentials were not provided."},
)
def test_inactive(self):
@@ -174,9 +215,11 @@ class DiscussionAPIViewTestMixin(ForumsEnableMixin, ForumMockUtilsMixin, UrlRese
@ddt.ddt
@httpretty.activate
@disable_signal(api, 'thread_edited')
@disable_signal(api, "thread_edited")
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class ThreadViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin):
class ThreadViewSetPartialUpdateTest(
DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin
):
"""Tests for ThreadViewSet partial_update"""
def setUp(self):
@@ -186,47 +229,58 @@ class ThreadViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTest
def test_basic(self):
self.register_get_user_response(self.user)
self.register_thread({
"created_at": "Test Created Date",
"updated_at": "Test Updated Date",
"read": True,
"resp_total": 2,
})
self.register_thread(
{
"created_at": "Test Created Date",
"updated_at": "Test Updated Date",
"read": True,
"resp_total": 2,
}
)
request_data = {"raw_body": "Edited body"}
response = self.request_patch(request_data)
assert response.status_code == 200
response_data = json.loads(response.content.decode('utf-8'))
assert response_data == self.expected_thread_data({
'raw_body': 'Edited body',
'rendered_body': '<p>Edited body</p>',
'preview_body': 'Edited body',
'editable_fields': [
'abuse_flagged', 'anonymous', 'copy_link', 'following', 'raw_body', 'read',
'title', 'topic_id', 'type'
],
'created_at': 'Test Created Date',
'updated_at': 'Test Updated Date',
'comment_count': 1,
'read': True,
'response_count': 2,
})
response_data = json.loads(response.content.decode("utf-8"))
assert response_data == self.expected_thread_data(
{
"raw_body": "Edited body",
"rendered_body": "<p>Edited body</p>",
"preview_body": "Edited body",
"editable_fields": [
"abuse_flagged",
"anonymous",
"copy_link",
"following",
"raw_body",
"read",
"title",
"topic_id",
"type",
],
"created_at": "Test Created Date",
"updated_at": "Test Updated Date",
"comment_count": 1,
"read": True,
"response_count": 2,
}
)
params = {
'thread_id': 'test_thread',
'course_id': str(self.course.id),
'commentable_id': 'test_topic',
'thread_type': 'discussion',
'title': 'Test Title',
'body': 'Edited body',
'user_id': str(self.user.id),
'anonymous': False,
'anonymous_to_peers': False,
'closed': False,
'pinned': False,
'read': True,
'editing_user_id': str(self.user.id),
"thread_id": "test_thread",
"course_id": str(self.course.id),
"commentable_id": "test_topic",
"thread_type": "discussion",
"title": "Test Title",
"body": "Edited body",
"user_id": str(self.user.id),
"anonymous": False,
"anonymous_to_peers": False,
"closed": False,
"pinned": False,
"read": True,
"editing_user_id": str(self.user.id),
}
self.check_mock_called_with('update_thread', -1, **params)
self.check_mock_called_with("update_thread", -1, **params)
def test_error(self):
self.register_get_user_response(self.user)
@@ -234,10 +288,12 @@ class ThreadViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTest
request_data = {"title": ""}
response = self.request_patch(request_data)
expected_response_data = {
"field_errors": {"title": {"developer_message": "This field may not be blank."}}
"field_errors": {
"title": {"developer_message": "This field may not be blank."}
}
}
assert response.status_code == 400
response_data = json.loads(response.content.decode('utf-8'))
response_data = json.loads(response.content.decode("utf-8"))
assert response_data == expected_response_data
@ddt.data(
@@ -252,14 +308,17 @@ class ThreadViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTest
request_data = {field: value}
response = self.request_patch(request_data)
assert response.status_code == 200
response_data = json.loads(response.content.decode('utf-8'))
assert response_data == self.expected_thread_data({
'read': True,
'closed': True,
'abuse_flagged': value,
'editable_fields': ['abuse_flagged', 'copy_link', 'read'],
'comment_count': 1, 'unread_comment_count': 0
})
response_data = json.loads(response.content.decode("utf-8"))
assert response_data == self.expected_thread_data(
{
"read": True,
"closed": True,
"abuse_flagged": value,
"editable_fields": ["abuse_flagged", "copy_link", "read"],
"comment_count": 1,
"unread_comment_count": 0,
}
)
@ddt.data(
("raw_body", "Edited body"),
@@ -283,47 +342,68 @@ class ThreadViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTest
response = self.request_patch(request_data)
assert response.status_code == 200
response_data = json.loads(response.content.decode('utf-8'))
assert response_data == self.expected_thread_data({
'comment_count': 1,
'read': True,
'editable_fields': [
'abuse_flagged', 'anonymous', 'copy_link', 'following', 'raw_body', 'read',
'title', 'topic_id', 'type'
],
'response_count': 2
})
response_data = json.loads(response.content.decode("utf-8"))
assert response_data == self.expected_thread_data(
{
"comment_count": 1,
"read": True,
"editable_fields": [
"abuse_flagged",
"anonymous",
"copy_link",
"following",
"raw_body",
"read",
"title",
"topic_id",
"type",
],
"response_count": 2,
}
)
def test_patch_read_non_owner_user(self):
self.register_get_user_response(self.user)
thread_owner_user = UserFactory.create(password=self.password)
CourseEnrollmentFactory.create(user=thread_owner_user, course_id=self.course.id)
self.register_thread({
"username": thread_owner_user.username,
"user_id": str(thread_owner_user.id),
"resp_total": 2,
})
self.register_thread(
{
"username": thread_owner_user.username,
"user_id": str(thread_owner_user.id),
"resp_total": 2,
}
)
self.register_read_response(self.user, "thread", "test_thread")
request_data = {"read": True}
response = self.request_patch(request_data)
assert response.status_code == 200
response_data = json.loads(response.content.decode('utf-8'))
expected_data = self.expected_thread_data({
'author': str(thread_owner_user.username),
'comment_count': 1,
'can_delete': False,
'read': True,
'editable_fields': ['abuse_flagged', 'copy_link', 'following', 'read', 'voted'],
'response_count': 2
})
response_data = json.loads(response.content.decode("utf-8"))
expected_data = self.expected_thread_data(
{
"author": str(thread_owner_user.username),
"comment_count": 1,
"can_delete": False,
"read": True,
"editable_fields": [
"abuse_flagged",
"copy_link",
"following",
"read",
"voted",
],
"response_count": 2,
}
)
assert response_data == expected_data
@ddt.ddt
@disable_signal(api, 'comment_edited')
@disable_signal(api, "comment_edited")
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class CommentViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin):
class CommentViewSetPartialUpdateTest(
DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin
):
"""Tests for CommentViewSet partial_update"""
def setUp(self):
@@ -375,29 +455,33 @@ class CommentViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTes
def test_basic(self):
self.register_thread()
self.register_comment({"created_at": "Test Created Date", "updated_at": "Test Updated Date"})
self.register_comment(
{"created_at": "Test Created Date", "updated_at": "Test Updated Date"}
)
request_data = {"raw_body": "Edited body"}
response = self.request_patch(request_data)
assert response.status_code == 200
response_data = json.loads(response.content.decode('utf-8'))
assert response_data == self.expected_response_data({
'raw_body': 'Edited body',
'rendered_body': '<p>Edited body</p>',
'editable_fields': ['abuse_flagged', 'anonymous', 'raw_body'],
'created_at': 'Test Created Date',
'updated_at': 'Test Updated Date'
})
response_data = json.loads(response.content.decode("utf-8"))
assert response_data == self.expected_response_data(
{
"raw_body": "Edited body",
"rendered_body": "<p>Edited body</p>",
"editable_fields": ["abuse_flagged", "anonymous", "raw_body"],
"created_at": "Test Created Date",
"updated_at": "Test Updated Date",
}
)
params = {
'comment_id': 'test_comment',
'body': 'Edited body',
'course_id': str(self.course.id),
'user_id': str(self.user.id),
'anonymous': False,
'anonymous_to_peers': False,
'endorsed': False,
'editing_user_id': str(self.user.id),
"comment_id": "test_comment",
"body": "Edited body",
"course_id": str(self.course.id),
"user_id": str(self.user.id),
"anonymous": False,
"anonymous_to_peers": False,
"endorsed": False,
"editing_user_id": str(self.user.id),
}
self.check_mock_called_with('update_comment', -1, **params)
self.check_mock_called_with("update_comment", -1, **params)
def test_error(self):
self.register_thread()
@@ -405,10 +489,12 @@ class CommentViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTes
request_data = {"raw_body": ""}
response = self.request_patch(request_data)
expected_response_data = {
"field_errors": {"raw_body": {"developer_message": "This field may not be blank."}}
"field_errors": {
"raw_body": {"developer_message": "This field may not be blank."}
}
}
assert response.status_code == 400
response_data = json.loads(response.content.decode('utf-8'))
response_data = json.loads(response.content.decode("utf-8"))
assert response_data == expected_response_data
@ddt.data(
@@ -423,12 +509,14 @@ class CommentViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTes
request_data = {field: value}
response = self.request_patch(request_data)
assert response.status_code == 200
response_data = json.loads(response.content.decode('utf-8'))
assert response_data == self.expected_response_data({
'abuse_flagged': value,
"abuse_flagged_any_user": None,
'editable_fields': ['abuse_flagged']
})
response_data = json.loads(response.content.decode("utf-8"))
assert response_data == self.expected_response_data(
{
"abuse_flagged": value,
"abuse_flagged_any_user": None,
"editable_fields": ["abuse_flagged"],
}
)
@ddt.data(
("raw_body", "Edited body"),
@@ -442,3 +530,396 @@ class CommentViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTes
request_data = {field: value}
response = self.request_patch(request_data)
assert response.status_code == 400
@ddt.ddt
@httpretty.activate
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
class ThreadViewSetListTest(
DiscussionAPIViewTestMixin, ModuleStoreTestCase, ProfileImageTestMixin
):
"""Tests for ThreadViewSet list"""
def setUp(self):
super().setUp()
self.author = UserFactory.create()
self.url = reverse("thread-list")
def create_source_thread(self, overrides=None):
"""
Create a sample source cs_thread
"""
thread = make_minimal_cs_thread(
{
"id": "test_thread",
"course_id": str(self.course.id),
"commentable_id": "test_topic",
"user_id": str(self.user.id),
"username": self.user.username,
"created_at": "2015-04-28T00:00:00Z",
"updated_at": "2015-04-28T11:11:11Z",
"title": "Test Title",
"body": "Test body",
"votes": {"up_count": 4},
"comments_count": 5,
"unread_comments_count": 3,
}
)
thread.update(overrides or {})
return thread
def test_course_id_missing(self):
response = self.client.get(self.url)
self.assert_response_correct(
response,
400,
{"field_errors": {"course_id": {"developer_message": "This field is required."}}}
)
def test_404(self):
response = self.client.get(self.url, {"course_id": "non/existent/course"})
self.assert_response_correct(
response,
404,
{"developer_message": "Course not found."}
)
def test_basic(self):
self.register_get_user_response(self.user, upvoted_ids=["test_thread"])
source_threads = [
self.create_source_thread(
{"user_id": str(self.author.id), "username": self.author.username}
)
]
expected_threads = [
self.expected_thread_data(
{
"created_at": "2015-04-28T00:00:00Z",
"updated_at": "2015-04-28T11:11:11Z",
"vote_count": 4,
"comment_count": 6,
"can_delete": False,
"unread_comment_count": 3,
"voted": True,
"author": self.author.username,
"editable_fields": [
"abuse_flagged",
"copy_link",
"following",
"read",
"voted",
],
"abuse_flagged_count": None,
}
)
]
self.register_get_threads_response(source_threads, page=1, num_pages=2)
response = self.client.get(
self.url, {"course_id": str(self.course.id), "following": ""}
)
expected_response = make_paginated_api_response(
results=expected_threads,
count=1,
num_pages=2,
next_link="http://testserver/api/discussion/v1/threads/?course_id=course-v1%3Ax%2By%2Bz&following=&page=2",
previous_link=None,
)
expected_response.update({"text_search_rewrite": None})
self.assert_response_correct(response, 200, expected_response)
params = {
"user_id": str(self.user.id),
"course_id": str(self.course.id),
"sort_key": "activity",
"page": 1,
"per_page": 10,
}
self.check_mock_called_with(
"get_user_threads",
-1,
**params,
)
@ddt.data("unread", "unanswered", "unresponded")
def test_view_query(self, query):
threads = [make_minimal_cs_thread()]
self.register_get_user_response(self.user)
self.register_get_threads_response(threads, page=1, num_pages=1)
self.client.get(
self.url,
{
"course_id": str(self.course.id),
"view": query,
},
)
params = {
"user_id": str(self.user.id),
"course_id": str(self.course.id),
"sort_key": "activity",
"page": 1,
"per_page": 10,
query: True,
}
self.check_mock_called_with(
"get_user_threads",
-1,
**params,
)
def test_pagination(self):
self.register_get_user_response(self.user)
self.register_get_threads_response([], page=1, num_pages=1)
response = self.client.get(
self.url, {"course_id": str(self.course.id), "page": "18", "page_size": "4"}
)
self.assert_response_correct(
response,
404,
{"developer_message": "Page not found (No results on this page)."},
)
params = {
"user_id": str(self.user.id),
"course_id": str(self.course.id),
"sort_key": "activity",
"page": 18,
"per_page": 4,
}
self.check_mock_called_with(
"get_user_threads",
-1,
**params,
)
def test_text_search(self):
self.register_get_user_response(self.user)
self.register_get_threads_search_response([], None, num_pages=0)
response = self.client.get(
self.url,
{"course_id": str(self.course.id), "text_search": "test search string"},
)
expected_response = make_paginated_api_response(
results=[], count=0, num_pages=0, next_link=None, previous_link=None
)
expected_response.update({"text_search_rewrite": None})
self.assert_response_correct(response, 200, expected_response)
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",
}
self.check_mock_called_with(
"search_threads",
-1,
**params,
)
@ddt.data(True, "true", "1")
def test_following_true(self, following):
self.register_get_user_response(self.user)
self.register_subscribed_threads_response(self.user, [], page=1, num_pages=0)
response = self.client.get(
self.url,
{
"course_id": str(self.course.id),
"following": following,
},
)
expected_response = make_paginated_api_response(
results=[], count=0, num_pages=0, next_link=None, previous_link=None
)
expected_response.update({"text_search_rewrite": None})
self.assert_response_correct(response, 200, expected_response)
self.check_mock_called("get_user_subscriptions")
@ddt.data(False, "false", "0")
def test_following_false(self, following):
response = self.client.get(
self.url,
{
"course_id": str(self.course.id),
"following": following,
},
)
self.assert_response_correct(
response,
400,
{
"field_errors": {
"following": {
"developer_message": "The value of the 'following' parameter must be true."
}
}
},
)
def test_following_error(self):
response = self.client.get(
self.url,
{
"course_id": str(self.course.id),
"following": "invalid-boolean",
},
)
self.assert_response_correct(
response,
400,
{
"field_errors": {
"following": {"developer_message": "Invalid Boolean Value."}
}
},
)
@ddt.data(
("last_activity_at", "activity"),
("comment_count", "comments"),
("vote_count", "votes"),
)
@ddt.unpack
def test_order_by(self, http_query, cc_query):
"""
Tests the order_by parameter
Arguments:
http_query (str): Query string sent in the http request
cc_query (str): Query string used for the comments client service
"""
threads = [make_minimal_cs_thread()]
self.register_get_user_response(self.user)
self.register_get_threads_response(threads, page=1, num_pages=1)
self.client.get(
self.url,
{
"course_id": str(self.course.id),
"order_by": http_query,
},
)
params = {
"user_id": str(self.user.id),
"course_id": str(self.course.id),
"page": 1,
"per_page": 10,
"sort_key": cc_query,
}
self.check_mock_called_with(
"get_user_threads",
-1,
**params,
)
def test_order_direction(self):
"""
Test order direction, of which "desc" is the only valid option. The
option actually just gets swallowed, so it doesn't affect the params.
"""
threads = [make_minimal_cs_thread()]
self.register_get_user_response(self.user)
self.register_get_threads_response(threads, page=1, num_pages=1)
self.client.get(
self.url,
{
"course_id": str(self.course.id),
"order_direction": "desc",
},
)
params = {
"user_id": str(self.user.id),
"course_id": str(self.course.id),
"sort_key": "activity",
"page": 1,
"per_page": 10,
}
self.check_mock_called_with(
"get_user_threads",
-1,
**params,
)
def test_mutually_exclusive(self):
"""
Tests GET thread_list api does not allow filtering on mutually exclusive parameters
"""
self.register_get_user_response(self.user)
self.register_get_threads_search_response([], None, num_pages=0)
response = self.client.get(
self.url,
{
"course_id": str(self.course.id),
"text_search": "test search string",
"topic_id": "topic1, topic2",
},
)
self.assert_response_correct(
response,
400,
{
"developer_message": "The following query parameters are mutually exclusive: topic_id, "
"text_search, following"
},
)
def test_profile_image_requested_field(self):
"""
Tests thread has user profile image details if called in requested_fields
"""
user_2 = UserFactory.create(password=self.password)
# Ensure that parental controls don't apply to this user
user_2.profile.year_of_birth = 1970
user_2.profile.save()
source_threads = [
self.create_source_thread(),
self.create_source_thread(
{"user_id": str(user_2.id), "username": user_2.username}
),
]
self.register_get_user_response(self.user, upvoted_ids=["test_thread"])
self.register_get_threads_response(source_threads, page=1, num_pages=1)
self.create_profile_image(self.user, get_profile_image_storage())
self.create_profile_image(user_2, get_profile_image_storage())
response = self.client.get(
self.url,
{"course_id": str(self.course.id), "requested_fields": "profile_image"},
)
assert response.status_code == 200
response_threads = json.loads(response.content.decode("utf-8"))["results"]
for response_thread in response_threads:
expected_profile_data = self.get_expected_user_profile(
response_thread["author"]
)
response_users = response_thread["users"]
assert expected_profile_data == response_users[response_thread["author"]]
def test_profile_image_requested_field_anonymous_user(self):
"""
Tests profile_image in requested_fields for thread created with anonymous user
"""
source_threads = [
self.create_source_thread(
{
"user_id": None,
"username": None,
"anonymous": True,
"anonymous_to_peers": True,
}
),
]
self.register_get_user_response(self.user, upvoted_ids=["test_thread"])
self.register_get_threads_response(source_threads, page=1, num_pages=1)
response = self.client.get(
self.url,
{"course_id": str(self.course.id), "requested_fields": "profile_image"},
)
assert response.status_code == 200
response_thread = json.loads(response.content.decode("utf-8"))["results"][0]
assert response_thread["author"] is None
assert {} == response_thread["users"]

View File

@@ -11,6 +11,7 @@ from pytz import UTC
from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole
from lms.djangoapps.discussion.django_comment_client.utils import has_discussion_privileges
from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFY_ALL_LEARNERS
from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, PostingRestriction
from openedx.core.djangoapps.django_comment_common.models import (
FORUM_ROLE_ADMINISTRATOR,
@@ -379,3 +380,25 @@ def is_posting_allowed(posting_restrictions: str, blackout_schedules: List):
return not any(schedule["start"] <= now <= schedule["end"] for schedule in blackout_schedules)
else:
return False
def can_user_notify_all_learners(course_key, user_roles, is_course_staff, is_course_admin):
"""
Check if user posting is allowed to notify all learners based on the given restrictions
Args:
course_key (CourseKey): CourseKey for which user creating any discussion post.
user_roles (Dict): Roles of the posting user
is_course_staff (Boolean): Whether the user has a course staff access.
is_course_admin (Boolean): Whether the user has a course admin access.
Returns:
bool: True if posting for all learner is allowed to this user, False otherwise.
"""
is_staff_or_instructor = any([
user_roles.intersection({FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR}),
is_course_staff,
is_course_admin,
])
return is_staff_or_instructor and ENABLE_NOTIFY_ALL_LEARNERS.is_enabled(course_key)

View File

@@ -166,7 +166,10 @@ def create_thread_created_notification(*args, **kwargs):
"""
user = kwargs['user']
post = kwargs['post']
send_thread_created_notification.apply_async(args=[post.id, post.attributes['course_id'], user.id])
notify_all_learners = kwargs.get('notify_all_learners', False)
send_thread_created_notification.apply_async(
args=[post.id, post.attributes['course_id'], user.id, notify_all_learners]
)
@receiver(signals.comment_created)

View File

@@ -17,8 +17,6 @@ from django.urls import reverse
from django.utils import translation
from edx_django_utils.cache import RequestCache
from edx_toggles.toggles.testutils import override_waffle_flag
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import (
TEST_DATA_SPLIT_MODULESTORE,
ModuleStoreTestCase,
@@ -27,7 +25,6 @@ from xmodule.modulestore.tests.django_utils import (
from xmodule.modulestore.tests.factories import (
CourseFactory,
BlockFactory,
check_mongo_calls
)
from common.djangoapps.course_modes.models import CourseMode
@@ -42,7 +39,6 @@ from lms.djangoapps.discussion.django_comment_client.permissions import get_team
from lms.djangoapps.discussion.django_comment_client.tests.group_id import (
CohortedTopicGroupIdTestMixin,
GroupIdAssertionMixin,
NonCohortedTopicGroupIdTestMixin
)
from lms.djangoapps.discussion.django_comment_client.tests.unicode import UnicodeTestMixin
from lms.djangoapps.discussion.django_comment_client.tests.utils import (
@@ -55,7 +51,6 @@ from lms.djangoapps.discussion.django_comment_client.utils import strip_none
from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE
from lms.djangoapps.discussion.views import _get_discussion_default_topic_id, course_discussions_settings_handler
from lms.djangoapps.teams.tests.factories import CourseTeamFactory, CourseTeamMembershipFactory
from openedx.core.djangoapps.course_groups.models import CourseUserGroup
from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts
from openedx.core.djangoapps.course_groups.tests.test_views import CohortViewsTestCase
from openedx.core.djangoapps.django_comment_common.comment_client.utils import CommentClientPaginatedResult
@@ -68,8 +63,6 @@ from openedx.core.djangoapps.django_comment_common.utils import ThreadContext, s
from openedx.core.djangoapps.util.testing import ContentGroupTestCase
from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES
from openedx.core.lib.teams_config import TeamsConfig
from openedx.features.content_type_gating.models import ContentTypeGatingConfig
from openedx.features.enterprise_support.tests.mixins.enterprise import EnterpriseTestConsentRequired
log = logging.getLogger(__name__)
@@ -529,93 +522,6 @@ class AllowPlusOrMinusOneInt(int):
return f"({self.value} +/- 1)"
@ddt.ddt
@patch('requests.request', autospec=True)
class SingleThreadQueryCountTestCase(ForumsEnableMixin, ModuleStoreTestCase):
"""
Ensures the number of modulestore queries and number of sql queries are
independent of the number of responses retrieved for a given discussion thread.
"""
def setUp(self):
super().setUp()
patcher = mock.patch(
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
return_value=False
)
patcher.start()
self.addCleanup(patcher.stop)
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread"
)
self.mock_get_course_id_by_thread = patcher.start()
self.addCleanup(patcher.stop)
@ddt.data(
# split mongo: 3 queries, regardless of thread response size.
(False, 1, 2, 2, 21, 8),
(False, 50, 2, 2, 21, 8),
# Enabling Enterprise integration should have no effect on the number of mongo queries made.
# split mongo: 3 queries, regardless of thread response size.
(True, 1, 2, 2, 21, 8),
(True, 50, 2, 2, 21, 8),
)
@ddt.unpack
def test_number_of_mongo_queries(
self,
enterprise_enabled,
num_thread_responses,
num_uncached_mongo_calls,
num_cached_mongo_calls,
num_uncached_sql_queries,
num_cached_sql_queries,
mock_request
):
ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1))
with modulestore().default_store(ModuleStoreEnum.Type.split):
course = CourseFactory.create(discussion_topics={'dummy discussion': {'id': 'dummy_discussion_id'}})
student = UserFactory.create()
CourseEnrollmentFactory.create(user=student, course_id=course.id)
test_thread_id = "test_thread_id"
mock_request.side_effect = make_mock_request_impl(
course=course, text="dummy content", thread_id=test_thread_id, num_thread_responses=num_thread_responses
)
request = RequestFactory().get(
"dummy_url",
HTTP_X_REQUESTED_WITH="XMLHttpRequest"
)
request.user = student
def call_single_thread():
"""
Call single_thread and assert that it returns what we expect.
"""
with patch.dict("django.conf.settings.FEATURES", dict(ENABLE_ENTERPRISE_INTEGRATION=enterprise_enabled)):
response = views.single_thread(
request,
str(course.id),
"dummy_discussion_id",
test_thread_id
)
assert response.status_code == 200
assert len(json.loads(response.content.decode('utf-8'))['content']['children']) == num_thread_responses
# Test uncached first, then cached now that the cache is warm.
cached_calls = [
[num_uncached_mongo_calls, num_uncached_sql_queries],
# Sometimes there will be one more or fewer sql call than expected, because the call to
# CourseMode.modes_for_course sometimes does / doesn't get cached and does / doesn't hit the DB.
# EDUCATOR-5167
[num_cached_mongo_calls, AllowPlusOrMinusOneInt(num_cached_sql_queries)],
]
for expected_mongo_calls, expected_sql_queries in cached_calls:
with self.assertNumQueries(expected_sql_queries, table_ignorelist=QUERY_COUNT_TABLE_IGNORELIST):
with check_mongo_calls(expected_mongo_calls):
call_single_thread()
@patch('requests.request', autospec=True)
class SingleCohortedThreadTestCase(CohortedTestCase): # lint-amnesty, pylint: disable=missing-class-docstring
@@ -868,92 +774,6 @@ class SingleThreadGroupIdTestCase(CohortedTestCase, GroupIdAssertionMixin): # l
)
@patch('requests.request', autospec=True)
class ForumFormDiscussionContentGroupTestCase(ForumsEnableMixin, ContentGroupTestCase):
"""
Tests `forum_form_discussion api` works with different content groups.
Discussion blocks are setup in ContentGroupTestCase class i.e
alpha_block => alpha_group_discussion => alpha_cohort => alpha_user/community_ta
beta_block => beta_group_discussion => beta_cohort => beta_user
"""
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
self.thread_list = [
{"thread_id": "test_general_thread_id"},
{"thread_id": "test_global_group_thread_id", "commentable_id": self.global_block.discussion_id},
{"thread_id": "test_alpha_group_thread_id", "group_id": self.alpha_block.group_access[0][0],
"commentable_id": self.alpha_block.discussion_id},
{"thread_id": "test_beta_group_thread_id", "group_id": self.beta_block.group_access[0][0],
"commentable_id": self.beta_block.discussion_id}
]
def assert_has_access(self, response, expected_discussion_threads):
"""
Verify that a users have access to the threads in their assigned
cohorts and non-cohorted blocks.
"""
discussion_data = json.loads(response.content.decode('utf-8'))['discussion_data']
assert len(discussion_data) == expected_discussion_threads
def call_view(self, mock_request, user): # lint-amnesty, pylint: disable=missing-function-docstring
mock_request.side_effect = make_mock_request_impl(
course=self.course,
text="dummy content",
thread_list=self.thread_list
)
self.client.login(username=user.username, password=self.TEST_PASSWORD)
return self.client.get(
reverse("forum_form_discussion", args=[str(self.course.id)]),
HTTP_X_REQUESTED_WITH="XMLHttpRequest"
)
def test_community_ta_user(self, mock_request):
"""
Verify that community_ta user has access to all threads regardless
of cohort.
"""
response = self.call_view(
mock_request,
self.community_ta
)
self.assert_has_access(response, 4)
def test_alpha_cohort_user(self, mock_request):
"""
Verify that alpha_user has access to alpha_cohort and non-cohorted
threads.
"""
response = self.call_view(
mock_request,
self.alpha_user
)
self.assert_has_access(response, 3)
def test_beta_cohort_user(self, mock_request):
"""
Verify that beta_user has access to beta_cohort and non-cohorted
threads.
"""
response = self.call_view(
mock_request,
self.beta_user
)
self.assert_has_access(response, 3)
def test_global_staff_user(self, mock_request):
"""
Verify that global staff user has access to all threads regardless
of cohort.
"""
response = self.call_view(
mock_request,
self.staff_user
)
self.assert_has_access(response, 4)
@patch('requests.request', autospec=True)
class SingleThreadContentGroupTestCase(ForumsEnableMixin, UrlResetMixin, ContentGroupTestCase): # lint-amnesty, pylint: disable=missing-class-docstring
@@ -1080,417 +900,6 @@ class SingleThreadContentGroupTestCase(ForumsEnableMixin, UrlResetMixin, Content
self.assert_can_access(self.beta_user, self.alpha_block.discussion_id, thread_id, True)
@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True)
class InlineDiscussionContextTestCase(ForumsEnableMixin, ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring
def setUp(self):
super().setUp()
self.course = CourseFactory.create()
CourseEnrollmentFactory(user=self.user, course_id=self.course.id)
self.discussion_topic_id = "dummy_topic"
self.team = CourseTeamFactory(
name="A team",
course_id=self.course.id,
topic_id='topic_id',
discussion_topic_id=self.discussion_topic_id
)
self.team.add_user(self.user)
self.user_not_in_team = UserFactory.create()
def test_context_can_be_standalone(self, mock_request):
mock_request.side_effect = make_mock_request_impl(
course=self.course,
text="dummy text",
commentable_id=self.discussion_topic_id
)
request = RequestFactory().get("dummy_url")
request.user = self.user
response = views.inline_discussion(
request,
str(self.course.id),
self.discussion_topic_id,
)
json_response = json.loads(response.content.decode('utf-8'))
assert json_response['discussion_data'][0]['context'] == ThreadContext.STANDALONE
def test_private_team_discussion(self, mock_request):
# First set the team discussion to be private
CourseEnrollmentFactory(user=self.user_not_in_team, course_id=self.course.id)
request = RequestFactory().get("dummy_url")
request.user = self.user_not_in_team
mock_request.side_effect = make_mock_request_impl(
course=self.course,
text="dummy text",
commentable_id=self.discussion_topic_id
)
with patch('lms.djangoapps.teams.api.is_team_discussion_private', autospec=True) as mocked:
mocked.return_value = True
response = views.inline_discussion(
request,
str(self.course.id),
self.discussion_topic_id,
)
assert response.status_code == 403
assert response.content.decode('utf-8') == views.TEAM_PERMISSION_MESSAGE
@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True)
@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True)
class InlineDiscussionGroupIdTestCase( # lint-amnesty, pylint: disable=missing-class-docstring
CohortedTestCase,
CohortedTopicGroupIdTestMixin,
NonCohortedTopicGroupIdTestMixin
):
cs_endpoint = "/threads"
def setUp(self):
super().setUp()
self.cohorted_commentable_id = 'cohorted_topic'
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread"
)
self.mock_get_course_id_by_thread = patcher.start()
self.addCleanup(patcher.stop)
def call_view(
self,
mock_is_forum_v2_enabled,
mock_request,
commentable_id,
user,
group_id,
pass_group_id=True
): # pylint: disable=arguments-differ
mock_is_forum_v2_enabled.return_value = False
kwargs = {'commentable_id': self.cohorted_commentable_id}
if group_id:
# avoid causing a server error when the LMS chokes attempting
# to find a group name for the group_id, when we're testing with
# an invalid one.
try:
CourseUserGroup.objects.get(id=group_id)
kwargs['group_id'] = group_id
except CourseUserGroup.DoesNotExist:
pass
mock_request.side_effect = make_mock_request_impl(self.course, "dummy content", **kwargs)
request_data = {}
if pass_group_id:
request_data["group_id"] = group_id
request = RequestFactory().get(
"dummy_url",
data=request_data
)
request.user = user
return views.inline_discussion(
request,
str(self.course.id),
commentable_id
)
def test_group_info_in_ajax_response(self, mock_is_forum_v2_enabled, mock_request):
response = self.call_view(
mock_is_forum_v2_enabled,
mock_request,
self.cohorted_commentable_id,
self.student,
self.student_cohort.id
)
self._assert_json_response_contains_group_info(
response, lambda d: d['discussion_data'][0]
)
@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True)
@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True)
class ForumFormDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGroupIdTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring
cs_endpoint = "/threads"
def setUp(self):
super().setUp()
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread"
)
self.mock_get_course_id_by_thread = patcher.start()
self.addCleanup(patcher.stop)
def call_view(
self,
mock_is_forum_v2_enabled,
mock_request,
commentable_id,
user,
group_id,
pass_group_id=True,
is_ajax=False
): # pylint: disable=arguments-differ
mock_is_forum_v2_enabled.return_value = False
kwargs = {}
if group_id:
kwargs['group_id'] = group_id
mock_request.side_effect = make_mock_request_impl(self.course, "dummy content", **kwargs)
request_data = {}
if pass_group_id:
request_data["group_id"] = group_id
headers = {}
if is_ajax:
headers['HTTP_X_REQUESTED_WITH'] = "XMLHttpRequest"
self.client.login(username=user.username, password=self.TEST_PASSWORD)
return self.client.get(
reverse("forum_form_discussion", args=[str(self.course.id)]),
data=request_data,
**headers
)
def test_group_info_in_html_response(self, mock_is_forum_v2_enabled, mock_request):
response = self.call_view(
mock_is_forum_v2_enabled,
mock_request,
"cohorted_topic",
self.student,
self.student_cohort.id
)
self._assert_html_response_contains_group_info(response)
def test_group_info_in_ajax_response(self, mock_is_forum_v2_enabled, mock_request):
response = self.call_view(
mock_is_forum_v2_enabled,
mock_request,
"cohorted_topic",
self.student,
self.student_cohort.id,
is_ajax=True
)
self._assert_json_response_contains_group_info(
response, lambda d: d['discussion_data'][0]
)
@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True)
@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True)
class UserProfileDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGroupIdTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring
cs_endpoint = "/active_threads"
def setUp(self):
super().setUp()
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)
def call_view_for_profiled_user(
self,
mock_is_forum_v2_enabled,
mock_request,
requesting_user,
profiled_user,
group_id,
pass_group_id,
is_ajax=False
):
"""
Calls "user_profile" view method on behalf of "requesting_user" to get information about
the user "profiled_user".
"""
mock_is_forum_v2_enabled.return_value = False
kwargs = {}
if group_id:
kwargs['group_id'] = group_id
mock_request.side_effect = make_mock_request_impl(self.course, "dummy content", **kwargs)
request_data = {}
if pass_group_id:
request_data["group_id"] = group_id
headers = {}
if is_ajax:
headers['HTTP_X_REQUESTED_WITH'] = "XMLHttpRequest"
self.client.login(username=requesting_user.username, password=self.TEST_PASSWORD)
return self.client.get(
reverse('user_profile', args=[str(self.course.id), profiled_user.id]),
data=request_data,
**headers
)
def call_view(
self,
mock_is_forum_v2_enabled,
mock_request,
_commentable_id,
user,
group_id,
pass_group_id=True,
is_ajax=False
): # pylint: disable=arguments-differ
return self.call_view_for_profiled_user(
mock_is_forum_v2_enabled, mock_request, user, user, group_id, pass_group_id=pass_group_id, is_ajax=is_ajax
)
def test_group_info_in_html_response(self, mock_is_forum_v2_enabled, mock_request):
response = self.call_view(
mock_is_forum_v2_enabled,
mock_request,
"cohorted_topic",
self.student,
self.student_cohort.id,
is_ajax=False
)
self._assert_html_response_contains_group_info(response)
def test_group_info_in_ajax_response(self, mock_is_forum_v2_enabled, mock_request):
response = self.call_view(
mock_is_forum_v2_enabled,
mock_request,
"cohorted_topic",
self.student,
self.student_cohort.id,
is_ajax=True
)
self._assert_json_response_contains_group_info(
response, lambda d: d['discussion_data'][0]
)
def _test_group_id_passed_to_user_profile(
self,
mock_is_forum_v2_enabled,
mock_request,
expect_group_id_in_request,
requesting_user,
profiled_user,
group_id,
pass_group_id
):
"""
Helper method for testing whether or not group_id was passed to the user_profile request.
"""
def get_params_from_user_info_call(for_specific_course):
"""
Returns the request parameters for the user info call with either course_id specified or not,
depending on value of 'for_specific_course'.
"""
# There will be 3 calls from user_profile. One has the cs_endpoint "active_threads", and it is already
# tested. The other 2 calls are for user info; one of those calls is for general information about the user,
# and it does not specify a course_id. The other call does specify a course_id, and if the caller did not
# have discussion moderator privileges, it should also contain a group_id.
for r_call in mock_request.call_args_list:
if not r_call[0][1].endswith(self.cs_endpoint):
params = r_call[1]["params"]
has_course_id = "course_id" in params
if (for_specific_course and has_course_id) or (not for_specific_course and not has_course_id):
return params
pytest.fail(f"Did not find appropriate user_profile call for 'for_specific_course'={for_specific_course}")
mock_request.reset_mock()
self.call_view_for_profiled_user(
mock_is_forum_v2_enabled,
mock_request,
requesting_user,
profiled_user,
group_id,
pass_group_id=pass_group_id,
is_ajax=False
)
# Should never have a group_id if course_id was not included in the request.
params_without_course_id = get_params_from_user_info_call(False)
assert 'group_id' not in params_without_course_id
params_with_course_id = get_params_from_user_info_call(True)
if expect_group_id_in_request:
assert 'group_id' in params_with_course_id
assert group_id == params_with_course_id['group_id']
else:
assert 'group_id' not in params_with_course_id
def test_group_id_passed_to_user_profile_student(self, mock_is_forum_v2_enabled, mock_request):
"""
Test that the group id is always included when requesting user profile information for a particular
course if the requester does not have discussion moderation privileges.
"""
def verify_group_id_always_present(profiled_user, pass_group_id):
"""
Helper method to verify that group_id is always present for student in course
(non-privileged user).
"""
self._test_group_id_passed_to_user_profile(
mock_is_forum_v2_enabled,
mock_request,
True,
self.student,
profiled_user,
self.student_cohort.id,
pass_group_id
)
# In all these test cases, the requesting_user is the student (non-privileged user).
# The profile returned on behalf of the student is for the profiled_user.
verify_group_id_always_present(profiled_user=self.student, pass_group_id=True)
verify_group_id_always_present(profiled_user=self.student, pass_group_id=False)
verify_group_id_always_present(profiled_user=self.moderator, pass_group_id=True)
verify_group_id_always_present(profiled_user=self.moderator, pass_group_id=False)
def test_group_id_user_profile_moderator(self, mock_is_forum_v2_enabled, mock_request):
"""
Test that the group id is only included when a privileged user requests user profile information for a
particular course and user if the group_id is explicitly passed in.
"""
def verify_group_id_present(profiled_user, pass_group_id, requested_cohort=self.moderator_cohort):
"""
Helper method to verify that group_id is present.
"""
self._test_group_id_passed_to_user_profile(
mock_is_forum_v2_enabled,
mock_request,
True,
self.moderator,
profiled_user,
requested_cohort.id,
pass_group_id
)
def verify_group_id_not_present(profiled_user, pass_group_id, requested_cohort=self.moderator_cohort):
"""
Helper method to verify that group_id is not present.
"""
self._test_group_id_passed_to_user_profile(
mock_is_forum_v2_enabled,
mock_request,
False,
self.moderator,
profiled_user,
requested_cohort.id,
pass_group_id
)
# In all these test cases, the requesting_user is the moderator (privileged user).
# If the group_id is explicitly passed, it will be present in the request.
verify_group_id_present(profiled_user=self.student, pass_group_id=True)
verify_group_id_present(profiled_user=self.moderator, pass_group_id=True)
verify_group_id_present(
profiled_user=self.student, pass_group_id=True, requested_cohort=self.student_cohort
)
# If the group_id is not explicitly passed, it will not be present because the requesting_user
# has discussion moderator privileges.
verify_group_id_not_present(profiled_user=self.student, pass_group_id=False)
verify_group_id_not_present(profiled_user=self.moderator, pass_group_id=False)
@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True)
@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True)
class FollowedThreadsDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGroupIdTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring
@@ -1547,189 +956,6 @@ class FollowedThreadsDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGr
)
@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True)
class InlineDiscussionTestCase(ForumsEnableMixin, ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring
def setUp(self):
super().setUp()
self.course = CourseFactory.create(
org="TestX",
number="101",
display_name="Test Course",
teams_configuration=TeamsConfig({
'topics': [{
'id': 'topic_id',
'name': 'A topic',
'description': 'A topic',
}]
})
)
self.student = UserFactory.create()
CourseEnrollmentFactory(user=self.student, course_id=self.course.id)
self.discussion1 = BlockFactory.create(
parent_location=self.course.location,
category="discussion",
discussion_id="discussion1",
display_name='Discussion1',
discussion_category="Chapter",
discussion_target="Discussion1"
)
def send_request(self, mock_request, params=None):
"""
Creates and returns a request with params set, and configures
mock_request to return appropriate values.
"""
request = RequestFactory().get("dummy_url", params if params else {})
request.user = self.student
mock_request.side_effect = make_mock_request_impl(
course=self.course, text="dummy content", commentable_id=self.discussion1.discussion_id
)
return views.inline_discussion(
request, str(self.course.id), self.discussion1.discussion_id
)
def test_context(self, mock_request):
team = CourseTeamFactory(
name='Team Name',
topic_id='topic_id',
course_id=self.course.id,
discussion_topic_id=self.discussion1.discussion_id
)
team.add_user(self.student)
self.send_request(mock_request)
assert mock_request.call_args[1]['params']['context'] == ThreadContext.STANDALONE
@patch('requests.request', autospec=True)
class UserProfileTestCase(ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring
TEST_THREAD_TEXT = 'userprofile-test-text'
TEST_THREAD_ID = 'userprofile-test-thread-id'
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
self.course = CourseFactory.create()
self.student = UserFactory.create()
self.profiled_user = UserFactory.create()
CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id)
CourseEnrollmentFactory.create(user=self.profiled_user, course_id=self.course.id)
def get_response(self, mock_request, params, **headers): # lint-amnesty, pylint: disable=missing-function-docstring
mock_request.side_effect = make_mock_request_impl(
course=self.course, text=self.TEST_THREAD_TEXT, thread_id=self.TEST_THREAD_ID
)
self.client.login(username=self.student.username, password=self.TEST_PASSWORD)
response = self.client.get(
reverse('user_profile', kwargs={
'course_id': str(self.course.id),
'user_id': self.profiled_user.id,
}),
data=params,
**headers
)
mock_request.assert_any_call(
"get",
StringEndsWithMatcher(f'/users/{self.profiled_user.id}/active_threads'),
data=None,
params=PartialDictMatcher({
"course_id": str(self.course.id),
"page": params.get("page", 1),
"per_page": views.THREADS_PER_PAGE
}),
headers=ANY,
timeout=ANY
)
return response
def check_html(self, mock_request, **params): # lint-amnesty, pylint: disable=missing-function-docstring
response = self.get_response(mock_request, params)
assert response.status_code == 200
assert response['Content-Type'] == 'text/html; charset=utf-8'
html = response.content.decode('utf-8')
self.assertRegex(html, r'data-page="1"')
self.assertRegex(html, r'data-num-pages="1"')
self.assertRegex(html, r'<span class="discussion-count">1</span> discussion started')
self.assertRegex(html, r'<span class="discussion-count">2</span> comments')
self.assertRegex(html, f'&#39;id&#39;: &#39;{self.TEST_THREAD_ID}&#39;')
self.assertRegex(html, f'&#39;title&#39;: &#39;{self.TEST_THREAD_TEXT}&#39;')
self.assertRegex(html, f'&#39;body&#39;: &#39;{self.TEST_THREAD_TEXT}&#39;')
self.assertRegex(html, f'&#39;username&#39;: &#39;{self.student.username}&#39;')
def check_ajax(self, mock_request, **params): # lint-amnesty, pylint: disable=missing-function-docstring
response = self.get_response(mock_request, params, HTTP_X_REQUESTED_WITH="XMLHttpRequest")
assert response.status_code == 200
assert response['Content-Type'] == 'application/json; charset=utf-8'
response_data = json.loads(response.content.decode('utf-8'))
assert sorted(response_data.keys()) == ['annotated_content_info', 'discussion_data', 'num_pages', 'page']
assert len(response_data['discussion_data']) == 1
assert response_data['page'] == 1
assert response_data['num_pages'] == 1
assert response_data['discussion_data'][0]['id'] == self.TEST_THREAD_ID
assert response_data['discussion_data'][0]['title'] == self.TEST_THREAD_TEXT
assert response_data['discussion_data'][0]['body'] == self.TEST_THREAD_TEXT
def test_html(self, mock_request):
self.check_html(mock_request)
def test_ajax(self, mock_request):
self.check_ajax(mock_request)
def test_404_non_enrolled_user(self, __):
"""
Test that when student try to visit un-enrolled students' discussion profile,
the system raises Http404.
"""
unenrolled_user = UserFactory.create()
request = RequestFactory().get("dummy_url")
request.user = self.student
with pytest.raises(Http404):
views.user_profile(
request,
str(self.course.id),
unenrolled_user.id
)
def test_404_profiled_user(self, _mock_request):
request = RequestFactory().get("dummy_url")
request.user = self.student
with pytest.raises(Http404):
views.user_profile(
request,
str(self.course.id),
-999
)
def test_404_course(self, _mock_request):
request = RequestFactory().get("dummy_url")
request.user = self.student
with pytest.raises(Http404):
views.user_profile(
request,
"non/existent/course",
self.profiled_user.id
)
def test_post(self, mock_request):
mock_request.side_effect = make_mock_request_impl(
course=self.course, text=self.TEST_THREAD_TEXT, thread_id=self.TEST_THREAD_ID
)
request = RequestFactory().post("dummy_url")
request.user = self.student
response = views.user_profile(
request,
str(self.course.id),
self.profiled_user.id
)
assert response.status_code == 405
@patch('requests.request', autospec=True)
class CommentsServiceRequestHeadersTestCase(ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring
@@ -1811,155 +1037,6 @@ class CommentsServiceRequestHeadersTestCase(ForumsEnableMixin, UrlResetMixin, Mo
self.assert_all_calls_have_header(mock_request, "X-Edx-Api-Key", "test_api_key")
class InlineDiscussionUnicodeTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring
@classmethod
def setUpClass(cls):
# pylint: disable=super-method-not-called
with super().setUpClassAndTestData():
cls.course = CourseFactory.create()
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.student = UserFactory.create()
CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id)
@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True)
def _test_unicode_data(self, text, mock_request): # lint-amnesty, pylint: disable=missing-function-docstring
mock_request.side_effect = make_mock_request_impl(course=self.course, text=text)
request = RequestFactory().get("dummy_url")
request.user = self.student
response = views.inline_discussion(
request, str(self.course.id), self.course.discussion_topics['General']['id']
)
assert response.status_code == 200
response_data = json.loads(response.content.decode('utf-8'))
assert response_data['discussion_data'][0]['title'] == text
assert response_data['discussion_data'][0]['body'] == text
class ForumFormDiscussionUnicodeTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring
@classmethod
def setUpClass(cls):
# pylint: disable=super-method-not-called
with super().setUpClassAndTestData():
cls.course = CourseFactory.create()
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.student = UserFactory.create()
CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id)
@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True)
def _test_unicode_data(self, text, mock_request): # lint-amnesty, pylint: disable=missing-function-docstring
mock_request.side_effect = make_mock_request_impl(course=self.course, text=text)
request = RequestFactory().get("dummy_url")
request.user = self.student
# so (request.headers.get('x-requested-with') == 'XMLHttpRequest') == True
request.META["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest"
response = views.forum_form_discussion(request, str(self.course.id))
assert response.status_code == 200
response_data = json.loads(response.content.decode('utf-8'))
assert response_data['discussion_data'][0]['title'] == text
assert response_data['discussion_data'][0]['body'] == text
@ddt.ddt
@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True)
class ForumDiscussionXSSTestCase(ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
username = "foo"
password = "bar"
self.course = CourseFactory.create()
self.student = UserFactory.create(username=username, password=password)
CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id)
assert self.client.login(username=username, password=password)
@ddt.data('"><script>alert(1)</script>', '<script>alert(1)</script>', '</script><script>alert(1)</script>')
@patch('common.djangoapps.student.models.user.cc.User.from_django_user')
def test_forum_discussion_xss_prevent(self, malicious_code, mock_user, mock_req):
"""
Test that XSS attack is prevented
"""
mock_user.return_value.to_dict.return_value = {}
mock_req.return_value.status_code = 200
reverse_url = "{}{}".format(reverse(
"forum_form_discussion",
kwargs={"course_id": str(self.course.id)}), '/forum_form_discussion')
# Test that malicious code does not appear in html
url = "{}?{}={}".format(reverse_url, 'sort_key', malicious_code)
resp = self.client.get(url)
self.assertNotContains(resp, malicious_code)
@ddt.data('"><script>alert(1)</script>', '<script>alert(1)</script>', '</script><script>alert(1)</script>')
@patch('common.djangoapps.student.models.user.cc.User.from_django_user')
@patch('common.djangoapps.student.models.user.cc.User.active_threads')
def test_forum_user_profile_xss_prevent(self, malicious_code, mock_threads, mock_from_django_user, mock_request):
"""
Test that XSS attack is prevented
"""
mock_threads.return_value = [], 1, 1
mock_from_django_user.return_value.to_dict.return_value = {
'upvoted_ids': [],
'downvoted_ids': [],
'subscribed_thread_ids': []
}
mock_request.side_effect = make_mock_request_impl(course=self.course, text='dummy')
url = reverse('user_profile',
kwargs={'course_id': str(self.course.id), 'user_id': str(self.student.id)})
# Test that malicious code does not appear in html
url_string = "{}?{}={}".format(url, 'page', malicious_code)
resp = self.client.get(url_string)
self.assertNotContains(resp, malicious_code)
class ForumDiscussionSearchUnicodeTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring
@classmethod
def setUpClass(cls):
# pylint: disable=super-method-not-called
with super().setUpClassAndTestData():
cls.course = CourseFactory.create()
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.student = UserFactory.create()
CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id)
@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True)
def _test_unicode_data(self, text, mock_request): # lint-amnesty, pylint: disable=missing-function-docstring
mock_request.side_effect = make_mock_request_impl(course=self.course, text=text)
data = {
"ajax": 1,
"text": text,
}
request = RequestFactory().get("dummy_url", data)
request.user = self.student
# so (request.headers.get('x-requested-with') == 'XMLHttpRequest') == True
request.META["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest"
response = views.forum_form_discussion(request, str(self.course.id))
assert response.status_code == 200
response_data = json.loads(response.content.decode('utf-8'))
assert response_data['discussion_data'][0]['title'] == text
assert response_data['discussion_data'][0]['body'] == text
class SingleThreadUnicodeTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring
@classmethod
@@ -2087,60 +1164,6 @@ class EnrollmentTestCase(ForumsEnableMixin, ModuleStoreTestCase):
views.forum_form_discussion(request, course_id=str(self.course.id)) # pylint: disable=no-value-for-parameter, unexpected-keyword-arg
@patch('requests.request', autospec=True)
class EnterpriseConsentTestCase(EnterpriseTestConsentRequired, ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase):
"""
Ensure that the Enterprise Data Consent redirects are in place only when consent is required.
"""
CREATE_USER = False
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
# Invoke UrlResetMixin setUp
super().setUp()
patcher = mock.patch(
'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled',
return_value=False
)
patcher.start()
self.addCleanup(patcher.stop)
patcher = mock.patch(
"openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread"
)
self.mock_get_course_id_by_thread = patcher.start()
self.addCleanup(patcher.stop)
username = "foo"
password = "bar"
self.discussion_id = 'dummy_discussion_id'
self.course = CourseFactory.create(discussion_topics={'dummy discussion': {'id': self.discussion_id}})
self.student = UserFactory.create(username=username, password=password)
CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id)
assert self.client.login(username=username, password=password)
self.addCleanup(translation.deactivate)
@patch('openedx.features.enterprise_support.api.enterprise_customer_for_request')
def test_consent_required(self, mock_enterprise_customer_for_request, mock_request):
"""
Test that enterprise data sharing consent is required when enabled for the various discussion views.
"""
# ENT-924: Temporary solution to replace sensitive SSO usernames.
mock_enterprise_customer_for_request.return_value = None
thread_id = 'dummy'
course_id = str(self.course.id)
mock_request.side_effect = make_mock_request_impl(course=self.course, text='dummy', thread_id=thread_id)
for url in (
reverse('forum_form_discussion',
kwargs=dict(course_id=course_id)),
reverse('single_thread',
kwargs=dict(course_id=course_id, discussion_id=self.discussion_id, thread_id=thread_id)),
):
self.verify_consent_required(self.client, url) # pylint: disable=no-value-for-parameter
class DividedDiscussionsTestCase(CohortViewsTestCase): # lint-amnesty, pylint: disable=missing-class-docstring
def create_divided_discussions(self):

View File

@@ -0,0 +1,1264 @@
# pylint: disable=unused-import
"""
Tests the forum notification views.
"""
import json
import logging
from datetime import datetime
from unittest import mock
from unittest.mock import ANY, Mock, call, patch
import ddt
import pytest
from django.conf import settings
from django.http import Http404
from django.test.client import Client, RequestFactory
from django.test.utils import override_settings
from django.urls import reverse
from django.utils import translation
from edx_django_utils.cache import RequestCache
from edx_toggles.toggles.testutils import override_waffle_flag
from lms.djangoapps.discussion.django_comment_client.tests.mixins import (
MockForumApiMixin,
)
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import (
TEST_DATA_SPLIT_MODULESTORE,
ModuleStoreTestCase,
SharedModuleStoreTestCase,
)
from xmodule.modulestore.tests.factories import (
CourseFactory,
BlockFactory,
check_mongo_calls,
)
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
from common.djangoapps.student.roles import CourseStaffRole, UserBasedRole
from common.djangoapps.student.tests.factories import (
AdminFactory,
CourseEnrollmentFactory,
UserFactory,
)
from common.djangoapps.util.testing import EventTestMixin, UrlResetMixin
from lms.djangoapps.courseware.exceptions import CourseAccessRedirect
from lms.djangoapps.discussion import views
from lms.djangoapps.discussion.django_comment_client.constants import (
TYPE_ENTRY,
TYPE_SUBCATEGORY,
)
from lms.djangoapps.discussion.django_comment_client.permissions import get_team
from lms.djangoapps.discussion.django_comment_client.tests.group_id import (
CohortedTopicGroupIdTestMixinV2,
GroupIdAssertionMixinV2,
NonCohortedTopicGroupIdTestMixinV2,
)
from lms.djangoapps.discussion.django_comment_client.tests.unicode import (
UnicodeTestMixin,
)
from lms.djangoapps.discussion.django_comment_client.tests.utils import (
CohortedTestCase,
ForumsEnableMixin,
config_course_discussions,
topic_name_to_id,
)
from lms.djangoapps.discussion.django_comment_client.utils import strip_none
from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE
from lms.djangoapps.discussion.views import (
_get_discussion_default_topic_id,
course_discussions_settings_handler,
)
from lms.djangoapps.teams.tests.factories import (
CourseTeamFactory,
CourseTeamMembershipFactory,
)
from openedx.core.djangoapps.course_groups.models import CourseUserGroup
from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts
from openedx.core.djangoapps.course_groups.tests.test_views import CohortViewsTestCase
from openedx.core.djangoapps.django_comment_common.comment_client.utils import (
CommentClientPaginatedResult,
)
from openedx.core.djangoapps.django_comment_common.models import (
FORUM_ROLE_STUDENT,
CourseDiscussionSettings,
ForumsConfig,
)
from openedx.core.djangoapps.django_comment_common.utils import (
ThreadContext,
seed_permissions_roles,
)
from openedx.core.djangoapps.util.testing import ContentGroupTestCase
from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES
from openedx.core.lib.teams_config import TeamsConfig
from openedx.features.content_type_gating.models import ContentTypeGatingConfig
from openedx.features.enterprise_support.tests.mixins.enterprise import (
EnterpriseTestConsentRequired,
)
log = logging.getLogger(__name__)
QUERY_COUNT_TABLE_IGNORELIST = WAFFLE_TABLES
def make_mock_thread_data(
course,
text,
thread_id,
num_children,
group_id=None,
group_name=None,
commentable_id=None,
is_commentable_divided=None,
anonymous=False,
anonymous_to_peers=False,
):
"""
Creates mock thread data for testing purposes.
"""
data_commentable_id = (
commentable_id
or course.discussion_topics.get("General", {}).get("id")
or "dummy_commentable_id"
)
thread_data = {
"id": thread_id,
"type": "thread",
"title": text,
"body": text,
"commentable_id": data_commentable_id,
"resp_total": 42,
"resp_skip": 25,
"resp_limit": 5,
"group_id": group_id,
"anonymous": anonymous,
"anonymous_to_peers": anonymous_to_peers,
"context": (
ThreadContext.COURSE
if get_team(data_commentable_id) is None
else ThreadContext.STANDALONE
),
}
if group_id is not None:
thread_data["group_name"] = group_name
if is_commentable_divided is not None:
thread_data["is_commentable_divided"] = is_commentable_divided
if num_children is not None:
thread_data["children"] = [
{
"id": f"dummy_comment_id_{i}",
"type": "comment",
"body": text,
}
for i in range(num_children)
]
return thread_data
def make_mock_collection_data(
course,
text,
thread_id,
num_children=None,
group_id=None,
commentable_id=None,
thread_list=None,
):
"""
Creates mock collection data for testing purposes.
"""
if thread_list:
return [
make_mock_thread_data(
course=course, text=text, num_children=num_children, **thread
)
for thread in thread_list
]
else:
return [
make_mock_thread_data(
course=course,
text=text,
thread_id=thread_id,
num_children=num_children,
group_id=group_id,
commentable_id=commentable_id,
)
]
def make_collection_callback(
course,
text,
thread_id="dummy_thread_id",
group_id=None,
commentable_id=None,
thread_list=None,
):
"""
Creates a callback function for simulating collection data.
"""
def callback(*args, **kwargs):
# Simulate default user thread response
return {
"collection": make_mock_collection_data(
course, text, thread_id, None, group_id, commentable_id, thread_list
)
}
return callback
def make_thread_callback(
course,
text,
thread_id="dummy_thread_id",
group_id=None,
commentable_id=None,
num_thread_responses=1,
anonymous=False,
anonymous_to_peers=False,
):
"""
Creates a callback function for simulating thread data.
"""
def callback(*args, **kwargs):
# Simulate default user thread response
return make_mock_thread_data(
course=course,
text=text,
thread_id=thread_id,
num_children=num_thread_responses,
group_id=group_id,
commentable_id=commentable_id,
anonymous=anonymous,
anonymous_to_peers=anonymous_to_peers,
)
return callback
def make_user_callback():
"""
Creates a callback function for simulating user data.
"""
def callback(*args, **kwargs):
res = {
"default_sort_key": "date",
"upvoted_ids": [],
"downvoted_ids": [],
"subscribed_thread_ids": [],
}
# comments service adds these attributes when course_id param is present
if kwargs.get("course_id"):
res.update({"threads_count": 1, "comments_count": 2})
return res
return callback
class ForumViewsUtilsMixin(MockForumApiMixin):
"""
Utils for the Forum Views.
"""
def _configure_mock_responses(
self,
course,
text,
thread_id="dummy_thread_id",
group_id=None,
commentable_id=None,
num_thread_responses=1,
thread_list=None,
anonymous=False,
anonymous_to_peers=False,
):
"""
Configure mock responses for the Forum Views.
"""
for func_name in [
"search_threads",
"get_user_active_threads",
"get_user_threads",
]:
self.set_mock_side_effect(
func_name,
make_collection_callback(
course,
text,
thread_id,
group_id,
commentable_id,
thread_list,
),
)
self.set_mock_side_effect(
"get_thread",
make_thread_callback(
course,
text,
thread_id,
group_id,
commentable_id,
num_thread_responses,
anonymous,
anonymous_to_peers,
),
)
self.set_mock_side_effect("get_user", make_user_callback())
class ForumFormDiscussionContentGroupTestCase(
ForumsEnableMixin, ContentGroupTestCase, ForumViewsUtilsMixin
):
"""
Tests `forum_form_discussion api` works with different content groups.
Discussion blocks are setup in ContentGroupTestCase class i.e
alpha_block => alpha_group_discussion => alpha_cohort => alpha_user/community_ta
beta_block => beta_group_discussion => beta_cohort => beta_user
"""
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
self.thread_list = [
{"thread_id": "test_general_thread_id"},
{
"thread_id": "test_global_group_thread_id",
"commentable_id": self.global_block.discussion_id,
},
{
"thread_id": "test_alpha_group_thread_id",
"group_id": self.alpha_block.group_access[0][0],
"commentable_id": self.alpha_block.discussion_id,
},
{
"thread_id": "test_beta_group_thread_id",
"group_id": self.beta_block.group_access[0][0],
"commentable_id": self.beta_block.discussion_id,
},
]
@classmethod
def setUpClass(cls):
super().setUpClass()
super().setUpClassAndForumMock()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
super().disposeForumMocks()
def assert_has_access(self, response, expected_discussion_threads):
"""
Verify that a users have access to the threads in their assigned
cohorts and non-cohorted blocks.
"""
discussion_data = json.loads(response.content.decode("utf-8"))[
"discussion_data"
]
assert len(discussion_data) == expected_discussion_threads
def call_view(
self, user
): # lint-amnesty, pylint: disable=missing-function-docstring
self._configure_mock_responses(
course=self.course, text="dummy content", thread_list=self.thread_list
)
self.client.login(username=user.username, password=self.TEST_PASSWORD)
return self.client.get(
reverse("forum_form_discussion", args=[str(self.course.id)]),
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
)
def test_community_ta_user(self):
"""
Verify that community_ta user has access to all threads regardless
of cohort.
"""
response = self.call_view(self.community_ta)
self.assert_has_access(response, 4)
def test_alpha_cohort_user(self):
"""
Verify that alpha_user has access to alpha_cohort and non-cohorted
threads.
"""
response = self.call_view(self.alpha_user)
self.assert_has_access(response, 3)
def test_beta_cohort_user(self):
"""
Verify that beta_user has access to beta_cohort and non-cohorted
threads.
"""
response = self.call_view(self.beta_user)
self.assert_has_access(response, 3)
def test_global_staff_user(self):
"""
Verify that global staff user has access to all threads regardless
of cohort.
"""
response = self.call_view(self.staff_user)
self.assert_has_access(response, 4)
class ForumFormDiscussionUnicodeTestCase(
ForumsEnableMixin,
SharedModuleStoreTestCase,
UnicodeTestMixin,
ForumViewsUtilsMixin,
):
"""
Discussiin Unicode Tests.
"""
@classmethod
def setUpClass(cls): # pylint: disable=super-method-not-called
super().setUpClassAndForumMock()
with super().setUpClassAndTestData():
cls.course = CourseFactory.create()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
super().disposeForumMocks()
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.student = UserFactory.create()
CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id)
def _test_unicode_data(
self, text
): # lint-amnesty, pylint: disable=missing-function-docstring
self._configure_mock_responses(course=self.course, text=text)
request = RequestFactory().get("dummy_url")
request.user = self.student
# so (request.headers.get('x-requested-with') == 'XMLHttpRequest') == True
request.META["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest"
response = views.forum_form_discussion(request, str(self.course.id))
assert response.status_code == 200
response_data = json.loads(response.content.decode("utf-8"))
assert response_data["discussion_data"][0]["title"] == text
assert response_data["discussion_data"][0]["body"] == text
class EnterpriseConsentTestCase(
EnterpriseTestConsentRequired,
ForumsEnableMixin,
UrlResetMixin,
ModuleStoreTestCase,
ForumViewsUtilsMixin,
):
"""
Ensure that the Enterprise Data Consent redirects are in place only when consent is required.
"""
CREATE_USER = False
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
# Invoke UrlResetMixin setUp
super().setUp()
username = "foo"
password = "bar"
self.discussion_id = "dummy_discussion_id"
self.course = CourseFactory.create(
discussion_topics={"dummy discussion": {"id": self.discussion_id}}
)
self.student = UserFactory.create(username=username, password=password)
CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id)
assert self.client.login(username=username, password=password)
self.addCleanup(translation.deactivate)
@classmethod
def setUpClass(cls):
super().setUpClass()
super().setUpClassAndForumMock()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
super().disposeForumMocks()
@patch("openedx.features.enterprise_support.api.enterprise_customer_for_request")
def test_consent_required(self, mock_enterprise_customer_for_request):
"""
Test that enterprise data sharing consent is required when enabled for the various discussion views.
"""
# ENT-924: Temporary solution to replace sensitive SSO usernames.
mock_enterprise_customer_for_request.return_value = None
thread_id = "dummy"
course_id = str(self.course.id)
self._configure_mock_responses(
course=self.course, text="dummy", thread_id=thread_id
)
for url in (
reverse("forum_form_discussion", kwargs=dict(course_id=course_id)),
reverse(
"single_thread",
kwargs=dict(
course_id=course_id,
discussion_id=self.discussion_id,
thread_id=thread_id,
),
),
):
self.verify_consent_required( # pylint: disable=no-value-for-parameter
self.client, url
)
class InlineDiscussionGroupIdTestCase( # lint-amnesty, pylint: disable=missing-class-docstring
CohortedTestCase,
CohortedTopicGroupIdTestMixinV2,
NonCohortedTopicGroupIdTestMixinV2,
ForumViewsUtilsMixin,
):
function_name = "get_user_threads"
def setUp(self):
super().setUp()
self.cohorted_commentable_id = "cohorted_topic"
@classmethod
def setUpClass(cls):
super().setUpClass()
super().setUpClassAndForumMock()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
super().disposeForumMocks()
def call_view(
self, commentable_id, user, group_id, pass_group_id=True
): # pylint: disable=arguments-differ
kwargs = {"commentable_id": self.cohorted_commentable_id}
if group_id:
# avoid causing a server error when the LMS chokes attempting
# to find a group name for the group_id, when we're testing with
# an invalid one.
try:
CourseUserGroup.objects.get(id=group_id)
kwargs["group_id"] = group_id
except CourseUserGroup.DoesNotExist:
pass
self._configure_mock_responses(self.course, "dummy content", **kwargs)
request_data = {}
if pass_group_id:
request_data["group_id"] = group_id
request = RequestFactory().get("dummy_url", data=request_data)
request.user = user
return views.inline_discussion(request, str(self.course.id), commentable_id)
def test_group_info_in_ajax_response(self):
response = self.call_view(
self.cohorted_commentable_id, self.student, self.student_cohort.id
)
self._assert_json_response_contains_group_info(
response, lambda d: d["discussion_data"][0]
)
class InlineDiscussionContextTestCase(
ForumsEnableMixin, ModuleStoreTestCase, ForumViewsUtilsMixin
): # lint-amnesty, pylint: disable=missing-class-docstring
def setUp(self):
super().setUp()
self.course = CourseFactory.create()
CourseEnrollmentFactory(user=self.user, course_id=self.course.id)
self.discussion_topic_id = "dummy_topic"
self.team = CourseTeamFactory(
name="A team",
course_id=self.course.id,
topic_id="topic_id",
discussion_topic_id=self.discussion_topic_id,
)
self.team.add_user(self.user)
self.user_not_in_team = UserFactory.create()
@classmethod
def setUpClass(cls):
super().setUpClass()
super().setUpClassAndForumMock()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
super().disposeForumMocks()
def test_context_can_be_standalone(self):
self._configure_mock_responses(
course=self.course,
text="dummy text",
commentable_id=self.discussion_topic_id,
)
request = RequestFactory().get("dummy_url")
request.user = self.user
response = views.inline_discussion(
request,
str(self.course.id),
self.discussion_topic_id,
)
json_response = json.loads(response.content.decode("utf-8"))
assert (
json_response["discussion_data"][0]["context"] == ThreadContext.STANDALONE
)
def test_private_team_discussion(self):
# First set the team discussion to be private
CourseEnrollmentFactory(user=self.user_not_in_team, course_id=self.course.id)
request = RequestFactory().get("dummy_url")
request.user = self.user_not_in_team
self._configure_mock_responses(
course=self.course,
text="dummy text",
commentable_id=self.discussion_topic_id,
)
with patch(
"lms.djangoapps.teams.api.is_team_discussion_private", autospec=True
) as mocked:
mocked.return_value = True
response = views.inline_discussion(
request,
str(self.course.id),
self.discussion_topic_id,
)
assert response.status_code == 403
assert response.content.decode("utf-8") == views.TEAM_PERMISSION_MESSAGE
class UserProfileDiscussionGroupIdTestCase(
CohortedTestCase, CohortedTopicGroupIdTestMixinV2, ForumViewsUtilsMixin
): # lint-amnesty, pylint: disable=missing-class-docstring
function_name = "get_user_active_threads"
@classmethod
def setUpClass(cls):
super().setUpClass()
super().setUpClassAndForumMock()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
super().disposeForumMocks()
def call_view_for_profiled_user(
self, requesting_user, profiled_user, group_id, pass_group_id, is_ajax=False
):
"""
Calls "user_profile" view method on behalf of "requesting_user" to get information about
the user "profiled_user".
"""
kwargs = {}
if group_id:
kwargs["group_id"] = group_id
self._configure_mock_responses(self.course, "dummy content", **kwargs)
request_data = {}
if pass_group_id:
request_data["group_id"] = group_id
headers = {}
if is_ajax:
headers["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest"
self.client.login(
username=requesting_user.username, password=self.TEST_PASSWORD
)
return self.client.get(
reverse("user_profile", args=[str(self.course.id), profiled_user.id]),
data=request_data,
**headers,
)
def call_view(
self, _commentable_id, user, group_id, pass_group_id=True, is_ajax=False
): # pylint: disable=arguments-differ
return self.call_view_for_profiled_user(
user, user, group_id, pass_group_id=pass_group_id, is_ajax=is_ajax
)
def test_group_info_in_html_response(self):
response = self.call_view(
"cohorted_topic", self.student, self.student_cohort.id, is_ajax=False
)
self._assert_html_response_contains_group_info(response)
def test_group_info_in_ajax_response(self):
response = self.call_view(
"cohorted_topic", self.student, self.student_cohort.id, is_ajax=True
)
self._assert_json_response_contains_group_info(
response, lambda d: d["discussion_data"][0]
)
def _test_group_id_passed_to_user_profile(
self,
expect_group_id_in_request,
requesting_user,
profiled_user,
group_id,
pass_group_id,
):
"""
Helper method for testing whether or not group_id was passed to the user_profile request.
"""
def get_params_from_user_info_call(for_specific_course):
"""
Returns the request parameters for the user info call with either course_id specified or not,
depending on value of 'for_specific_course'.
"""
# There will be 3 calls from user_profile. One has the cs_endpoint "active_threads", and it is already
# tested. The other 2 calls are for user info; one of those calls is for general information about the user,
# and it does not specify a course_id. The other call does specify a course_id, and if the caller did not
# have discussion moderator privileges, it should also contain a group_id.
user_func_calls = self.get_mock_func_calls("get_user")
for r_call in user_func_calls:
has_course_id = "course_id" in r_call[1]
if (for_specific_course and has_course_id) or (
not for_specific_course and not has_course_id
):
return r_call[1]
pytest.fail(
f"Did not find appropriate user_profile call for 'for_specific_course'={for_specific_course}"
)
self.call_view_for_profiled_user(
requesting_user,
profiled_user,
group_id,
pass_group_id=pass_group_id,
is_ajax=False,
)
# Should never have a group_id if course_id was not included in the request.
params_without_course_id = get_params_from_user_info_call(False)
assert "group_ids" not in params_without_course_id
params_with_course_id = get_params_from_user_info_call(True)
if expect_group_id_in_request:
assert "group_ids" in params_with_course_id
assert [group_id] == params_with_course_id["group_ids"]
else:
assert "group_ids" not in params_with_course_id
def test_group_id_passed_to_user_profile_student(self):
"""
Test that the group id is always included when requesting user profile information for a particular
course if the requester does not have discussion moderation privileges.
"""
def verify_group_id_always_present(profiled_user, pass_group_id):
"""
Helper method to verify that group_id is always present for student in course
(non-privileged user).
"""
self._test_group_id_passed_to_user_profile(
True, self.student, profiled_user, self.student_cohort.id, pass_group_id
)
# In all these test cases, the requesting_user is the student (non-privileged user).
# The profile returned on behalf of the student is for the profiled_user.
verify_group_id_always_present(profiled_user=self.student, pass_group_id=True)
verify_group_id_always_present(profiled_user=self.student, pass_group_id=False)
verify_group_id_always_present(profiled_user=self.moderator, pass_group_id=True)
verify_group_id_always_present(
profiled_user=self.moderator, pass_group_id=False
)
def test_group_id_user_profile_moderator(self):
"""
Test that the group id is only included when a privileged user requests user profile information for a
particular course and user if the group_id is explicitly passed in.
"""
def verify_group_id_present(
profiled_user, pass_group_id, requested_cohort=self.moderator_cohort
):
"""
Helper method to verify that group_id is present.
"""
self._test_group_id_passed_to_user_profile(
True, self.moderator, profiled_user, requested_cohort.id, pass_group_id
)
def verify_group_id_not_present(
profiled_user, pass_group_id, requested_cohort=self.moderator_cohort
):
"""
Helper method to verify that group_id is not present.
"""
self._test_group_id_passed_to_user_profile(
False, self.moderator, profiled_user, requested_cohort.id, pass_group_id
)
# In all these test cases, the requesting_user is the moderator (privileged user).
# If the group_id is explicitly passed, it will be present in the request.
verify_group_id_present(profiled_user=self.student, pass_group_id=True)
verify_group_id_present(profiled_user=self.moderator, pass_group_id=True)
verify_group_id_present(
profiled_user=self.student,
pass_group_id=True,
requested_cohort=self.student_cohort,
)
# If the group_id is not explicitly passed, it will not be present because the requesting_user
# has discussion moderator privileges.
verify_group_id_not_present(profiled_user=self.student, pass_group_id=False)
verify_group_id_not_present(profiled_user=self.moderator, pass_group_id=False)
@ddt.ddt
class ForumDiscussionXSSTestCase(
ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase, ForumViewsUtilsMixin
): # lint-amnesty, pylint: disable=missing-class-docstring
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
username = "foo"
password = "bar"
self.course = CourseFactory.create()
self.student = UserFactory.create(username=username, password=password)
CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id)
assert self.client.login(username=username, password=password)
@classmethod
def setUpClass(cls):
super().setUpClass()
super().setUpClassAndForumMock()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
super().disposeForumMocks()
@ddt.data(
'"><script>alert(1)</script>',
"<script>alert(1)</script>",
"</script><script>alert(1)</script>",
)
@patch("common.djangoapps.student.models.user.cc.User.from_django_user")
def test_forum_discussion_xss_prevent(self, malicious_code, mock_user):
"""
Test that XSS attack is prevented
"""
self.set_mock_return_value("get_user", {})
self.set_mock_return_value("get_user_threads", {})
self.set_mock_return_value("get_user_active_threads", {})
mock_user.return_value.to_dict.return_value = {}
reverse_url = "{}{}".format(
reverse("forum_form_discussion", kwargs={"course_id": str(self.course.id)}),
"/forum_form_discussion",
)
# Test that malicious code does not appear in html
url = "{}?{}={}".format(reverse_url, "sort_key", malicious_code)
resp = self.client.get(url)
self.assertNotContains(resp, malicious_code)
@ddt.data(
'"><script>alert(1)</script>',
"<script>alert(1)</script>",
"</script><script>alert(1)</script>",
)
@patch("common.djangoapps.student.models.user.cc.User.from_django_user")
@patch("common.djangoapps.student.models.user.cc.User.active_threads")
def test_forum_user_profile_xss_prevent(
self, malicious_code, mock_threads, mock_from_django_user
):
"""
Test that XSS attack is prevented
"""
mock_threads.return_value = [], 1, 1
mock_from_django_user.return_value.to_dict.return_value = {
"upvoted_ids": [],
"downvoted_ids": [],
"subscribed_thread_ids": [],
}
self._configure_mock_responses(course=self.course, text="dummy")
url = reverse(
"user_profile",
kwargs={"course_id": str(self.course.id), "user_id": str(self.student.id)},
)
# Test that malicious code does not appear in html
url_string = "{}?{}={}".format(url, "page", malicious_code)
resp = self.client.get(url_string)
self.assertNotContains(resp, malicious_code)
class InlineDiscussionTestCase(
ForumsEnableMixin, ModuleStoreTestCase, ForumViewsUtilsMixin
): # lint-amnesty, pylint: disable=missing-class-docstring
def setUp(self):
super().setUp()
self.course = CourseFactory.create(
org="TestX",
number="101",
display_name="Test Course",
teams_configuration=TeamsConfig(
{
"topics": [
{
"id": "topic_id",
"name": "A topic",
"description": "A topic",
}
]
}
),
)
self.student = UserFactory.create()
CourseEnrollmentFactory(user=self.student, course_id=self.course.id)
self.discussion1 = BlockFactory.create(
parent_location=self.course.location,
category="discussion",
discussion_id="discussion1",
display_name="Discussion1",
discussion_category="Chapter",
discussion_target="Discussion1",
)
@classmethod
def setUpClass(cls):
super().setUpClass()
super().setUpClassAndForumMock()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
super().disposeForumMocks()
def send_request(self, params=None):
"""
Creates and returns a request with params set, and configures
mock_request to return appropriate values.
"""
request = RequestFactory().get("dummy_url", params if params else {})
request.user = self.student
self._configure_mock_responses(
course=self.course,
text="dummy content",
commentable_id=self.discussion1.discussion_id,
)
return views.inline_discussion(
request, str(self.course.id), self.discussion1.discussion_id
)
def test_context(self):
team = CourseTeamFactory(
name="Team Name",
topic_id="topic_id",
course_id=self.course.id,
discussion_topic_id=self.discussion1.discussion_id,
)
team.add_user(self.student)
self.send_request()
last_call = self.get_mock_func_calls("get_user_threads")[-1][1]
assert last_call["context"] == ThreadContext.STANDALONE
class ForumDiscussionSearchUnicodeTestCase(
ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin, ForumViewsUtilsMixin
): # lint-amnesty, pylint: disable=missing-class-docstring
@classmethod
def setUpClass(cls): # pylint: disable=super-method-not-called
super().setUpClassAndForumMock()
with super().setUpClassAndTestData():
cls.course = CourseFactory.create()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
super().disposeForumMocks()
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.student = UserFactory.create()
CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id)
def _test_unicode_data(
self, text
): # lint-amnesty, pylint: disable=missing-function-docstring
self._configure_mock_responses(course=self.course, text=text)
data = {
"ajax": 1,
"text": text,
}
request = RequestFactory().get("dummy_url", data)
request.user = self.student
# so (request.headers.get('x-requested-with') == 'XMLHttpRequest') == True
request.META["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest"
response = views.forum_form_discussion(request, str(self.course.id))
assert response.status_code == 200
response_data = json.loads(response.content.decode("utf-8"))
assert response_data["discussion_data"][0]["title"] == text
assert response_data["discussion_data"][0]["body"] == text
class InlineDiscussionUnicodeTestCase(
ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin, ForumViewsUtilsMixin
): # lint-amnesty, pylint: disable=missing-class-docstring
@classmethod
def setUpClass(cls): # pylint: disable=super-method-not-called
super().setUpClassAndForumMock()
with super().setUpClassAndTestData():
cls.course = CourseFactory.create()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
super().disposeForumMocks()
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.student = UserFactory.create()
CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id)
def _test_unicode_data(
self, text
): # lint-amnesty, pylint: disable=missing-function-docstring
self._configure_mock_responses(course=self.course, text=text)
request = RequestFactory().get("dummy_url")
request.user = self.student
response = views.inline_discussion(
request, str(self.course.id), self.course.discussion_topics["General"]["id"]
)
assert response.status_code == 200
response_data = json.loads(response.content.decode("utf-8"))
assert response_data["discussion_data"][0]["title"] == text
assert response_data["discussion_data"][0]["body"] == text
class ForumFormDiscussionGroupIdTestCase(
CohortedTestCase, CohortedTopicGroupIdTestMixinV2, ForumViewsUtilsMixin
): # lint-amnesty, pylint: disable=missing-class-docstring
function_name = "get_user_threads"
def call_view(
self, commentable_id, user, group_id, pass_group_id=True, is_ajax=False
): # pylint: disable=arguments-differ
kwargs = {}
if group_id:
kwargs["group_id"] = group_id
self._configure_mock_responses(self.course, "dummy content", **kwargs)
request_data = {}
if pass_group_id:
request_data["group_id"] = group_id
headers = {}
if is_ajax:
headers["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest"
self.client.login(username=user.username, password=self.TEST_PASSWORD)
return self.client.get(
reverse("forum_form_discussion", args=[str(self.course.id)]),
data=request_data,
**headers,
)
@classmethod
def setUpClass(cls):
super().setUpClass()
super().setUpClassAndForumMock()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
super().disposeForumMocks()
def test_group_info_in_html_response(self):
response = self.call_view(
"cohorted_topic", self.student, self.student_cohort.id
)
self._assert_html_response_contains_group_info(response)
def test_group_info_in_ajax_response(self):
response = self.call_view(
"cohorted_topic", self.student, self.student_cohort.id, is_ajax=True
)
self._assert_json_response_contains_group_info(
response, lambda d: d["discussion_data"][0]
)
class UserProfileTestCase(
ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase, ForumViewsUtilsMixin
): # lint-amnesty, pylint: disable=missing-class-docstring
TEST_THREAD_TEXT = "userprofile-test-text"
TEST_THREAD_ID = "userprofile-test-thread-id"
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
self.course = CourseFactory.create()
self.student = UserFactory.create()
self.profiled_user = UserFactory.create()
CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id)
CourseEnrollmentFactory.create(
user=self.profiled_user, course_id=self.course.id
)
@classmethod
def setUpClass(cls):
super().setUpClass()
super().setUpClassAndForumMock()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
super().disposeForumMocks()
def get_response(
self, params, **headers
): # lint-amnesty, pylint: disable=missing-function-docstring
self._configure_mock_responses(
course=self.course,
text=self.TEST_THREAD_TEXT,
thread_id=self.TEST_THREAD_ID,
)
self.client.login(username=self.student.username, password=self.TEST_PASSWORD)
response = self.client.get(
reverse(
"user_profile",
kwargs={
"course_id": str(self.course.id),
"user_id": self.profiled_user.id,
},
),
data=params,
**headers,
)
params = {
"course_id": str(self.course.id),
"page": params.get("page", 1),
"per_page": views.THREADS_PER_PAGE,
}
self.check_mock_called_with("get_user_active_threads", -1, **params)
return response
def check_html(
self, **params
): # lint-amnesty, pylint: disable=missing-function-docstring
response = self.get_response(params)
assert response.status_code == 200
assert response["Content-Type"] == "text/html; charset=utf-8"
html = response.content.decode("utf-8")
self.assertRegex(html, r'data-page="1"')
self.assertRegex(html, r'data-num-pages="1"')
self.assertRegex(
html, r'<span class="discussion-count">1</span> discussion started'
)
self.assertRegex(html, r'<span class="discussion-count">2</span> comments')
self.assertRegex(html, f"&#39;id&#39;: &#39;{self.TEST_THREAD_ID}&#39;")
self.assertRegex(html, f"&#39;title&#39;: &#39;{self.TEST_THREAD_TEXT}&#39;")
self.assertRegex(html, f"&#39;body&#39;: &#39;{self.TEST_THREAD_TEXT}&#39;")
self.assertRegex(html, f"&#39;username&#39;: &#39;{self.student.username}&#39;")
def check_ajax(
self, **params
): # lint-amnesty, pylint: disable=missing-function-docstring
response = self.get_response(params, HTTP_X_REQUESTED_WITH="XMLHttpRequest")
assert response.status_code == 200
assert response["Content-Type"] == "application/json; charset=utf-8"
response_data = json.loads(response.content.decode("utf-8"))
assert sorted(response_data.keys()) == [
"annotated_content_info",
"discussion_data",
"num_pages",
"page",
]
assert len(response_data["discussion_data"]) == 1
assert response_data["page"] == 1
assert response_data["num_pages"] == 1
assert response_data["discussion_data"][0]["id"] == self.TEST_THREAD_ID
assert response_data["discussion_data"][0]["title"] == self.TEST_THREAD_TEXT
assert response_data["discussion_data"][0]["body"] == self.TEST_THREAD_TEXT
def test_html(self):
self.check_html()
def test_ajax(self):
self.check_ajax()
def test_404_non_enrolled_user(self):
"""
Test that when student try to visit un-enrolled students' discussion profile,
the system raises Http404.
"""
unenrolled_user = UserFactory.create()
request = RequestFactory().get("dummy_url")
request.user = self.student
with pytest.raises(Http404):
views.user_profile(request, str(self.course.id), unenrolled_user.id)
def test_404_profiled_user(self):
request = RequestFactory().get("dummy_url")
request.user = self.student
with pytest.raises(Http404):
views.user_profile(request, str(self.course.id), -999)
def test_404_course(self):
request = RequestFactory().get("dummy_url")
request.user = self.student
with pytest.raises(Http404):
views.user_profile(request, "non/existent/course", self.profiled_user.id)
def test_post(self):
self._configure_mock_responses(
course=self.course,
text=self.TEST_THREAD_TEXT,
thread_id=self.TEST_THREAD_ID,
)
request = RequestFactory().post("dummy_url")
request.user = self.student
response = views.user_profile(
request, str(self.course.id), self.profiled_user.id
)
assert response.status_code == 405

View File

@@ -2773,26 +2773,30 @@ class ExportOra2SummaryView(DeveloperErrorViewMixin, APIView):
return JsonResponse({"error": str(err)}, status=400)
@transaction.non_atomic_requests
@require_POST
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_course_permission(permissions.CAN_RESEARCH)
@common_exceptions_400
def export_ora2_submission_files(request, course_id):
@method_decorator(transaction.non_atomic_requests, name='dispatch')
class ExportOra2SubmissionFilesView(DeveloperErrorViewMixin, APIView):
"""
Pushes a Celery task which will download and compress all submission
files (texts, attachments) into a zip archive.
"""
course_key = CourseKey.from_string(course_id)
permission_classes = (IsAuthenticated, permissions.InstructorPermission)
permission_name = permissions.CAN_RESEARCH
task_api.submit_export_ora2_submission_files(request, course_key)
return JsonResponse({
"status": _(
"Attachments archive is being created."
)
})
@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True))
@method_decorator(ensure_csrf_cookie)
def post(self, request, course_id):
"""
Initiates a task to export all ORA2 submission files for a course.
Returns a JSON response indicating the export task has been started.
"""
course_key = CourseKey.from_string(course_id)
try:
task_api.submit_export_ora2_submission_files(request, course_key)
return Response({
"status": _("Attachments archive is being created.")
})
except (AlreadyRunningError, QueueConnectionError, AttributeError) as err:
return JsonResponse({"error": str(err)}, status=400)
@method_decorator(transaction.non_atomic_requests, name='dispatch')

View File

@@ -69,7 +69,7 @@ urlpatterns = [
path('export_ora2_data', api.ExportOra2DataView.as_view(), name='export_ora2_data'),
path('export_ora2_summary', api.ExportOra2SummaryView.as_view(), name='export_ora2_summary'),
path('export_ora2_submission_files', api.export_ora2_submission_files,
path('export_ora2_submission_files', api.ExportOra2SubmissionFilesView.as_view(),
name='export_ora2_submission_files'),
# spoc gradebook

View File

@@ -56,42 +56,28 @@ class Thread(models.Model):
utils.strip_blank(utils.strip_none(query_params))
)
if query_params.get('text'):
url = cls.url(action='search')
else:
url = cls.url(action='get_all', params=utils.extract(params, 'commentable_id'))
if params.get('commentable_id'):
del params['commentable_id']
# Convert user_id and author_id to strings if present
for field in ['user_id', 'author_id']:
if value := params.get(field):
params[field] = str(value)
if is_forum_v2_enabled(utils.get_course_key(query_params['course_id'])):
if query_params.get('text'):
search_params = utils.strip_none(params)
if user_id := search_params.get('user_id'):
search_params['user_id'] = str(user_id)
if group_ids := search_params.get('group_ids'):
search_params['group_ids'] = [int(group_id) for group_id in group_ids.split(',')]
elif group_id := search_params.get('group_id'):
search_params['group_ids'] = [int(group_id)]
search_params.pop('group_id', None)
if commentable_ids := search_params.get('commentable_ids'):
search_params['commentable_ids'] = commentable_ids.split(',')
elif commentable_id := search_params.get('commentable_id'):
search_params['commentable_ids'] = [commentable_id]
search_params.pop('commentable_id', None)
response = forum_api.search_threads(**search_params)
else:
if user_id := params.get('user_id'):
params['user_id'] = str(user_id)
response = forum_api.get_user_threads(**params)
# Handle commentable_ids/commentable_id conversion
if commentable_ids := params.get('commentable_ids'):
params['commentable_ids'] = commentable_ids.split(',')
elif commentable_id := params.get('commentable_id'):
params['commentable_ids'] = [commentable_id]
params.pop('commentable_id', None)
params = utils.clean_forum_params(params)
if query_params.get('text'): # Handle group_ids/group_id conversion
if group_ids := params.get('group_ids'):
params['group_ids'] = [int(group_id) for group_id in group_ids.split(',')]
elif group_id := params.get('group_id'):
params['group_ids'] = [int(group_id)]
params.pop('group_id', None)
response = forum_api.search_threads(**params)
else:
response = utils.perform_request(
'get',
url,
params,
metric_tags=['course_id:{}'.format(query_params['course_id'])],
metric_action='thread.search',
paged_results=True
)
response = forum_api.get_user_threads(**params)
if query_params.get('text'):
search_query = query_params['text']
@@ -124,7 +110,6 @@ class Thread(models.Model):
total_results=total_results
)
)
return utils.CommentClientPaginatedResult(
collection=response.get('collection', []),
page=response.get('page', 1),

View File

@@ -181,7 +181,7 @@ class User(models.Model):
user_id = params.pop("user_id", None)
if "text" in params:
params.pop("text")
response = forum_api.get_user_subscriptions(user_id, str(course_key), params)
response = forum_api.get_user_subscriptions(user_id, str(course_key), utils.clean_forum_params(params))
else:
response = utils.perform_request(
'get',
@@ -218,21 +218,17 @@ class User(models.Model):
if is_forum_v2_enabled(course_key):
group_ids = [retrieve_params['group_id']] if 'group_id' in retrieve_params else []
is_complete = retrieve_params['complete']
params = utils.clean_forum_params({
"user_id": self.attributes["id"],
"group_ids": group_ids,
"course_id": course_id,
"complete": is_complete
})
try:
response = forum_api.get_user(
self.attributes["id"],
group_ids=group_ids,
course_id=course_id,
complete=is_complete
)
response = forum_api.get_user(**params)
except ForumV2RequestError as e:
self.save({"course_id": course_id})
response = forum_api.get_user(
self.attributes["id"],
group_ids=group_ids,
course_id=course_id,
complete=is_complete
)
response = forum_api.get_user(**params)
else:
try:
response = utils.perform_request(

View File

@@ -103,6 +103,23 @@ def perform_request(method, url, data_or_params=None, raw=False,
return data
def clean_forum_params(params):
"""Convert string booleans to actual booleans and remove None values and empty lists from forum parameters."""
result = {}
for k, v in params.items():
if v is not None and v != []:
if isinstance(v, str):
if v.lower() == 'true':
result[k] = True
elif v.lower() == 'false':
result[k] = False
else:
result[k] = v
else:
result[k] = v
return result
class CommentClientError(Exception):
pass

View File

@@ -230,6 +230,24 @@ COURSE_NOTIFICATION_TYPES = {
'email_template': '',
'filters': [FILTER_AUDIT_EXPIRED_USERS_WITH_NO_ROLE],
},
'new_instructor_all_learners_post': {
'notification_app': 'discussion',
'name': 'new_instructor_all_learners_post',
'is_core': False,
'info': '',
'web': True,
'email': False,
'email_cadence': EmailCadence.DAILY,
'push': False,
'non_editable': [],
'content_template': _('<{p}>Your instructor posted <{strong}>{post_title}</{strong}></{p}>'),
'grouped_content_template': '',
'content_context': {
'post_title': 'Post title',
},
'email_template': '',
'filters': [FILTER_AUDIT_EXPIRED_USERS_WITH_NO_ROLE]
},
}
COURSE_NOTIFICATION_APPS = {

View File

@@ -49,3 +49,23 @@ ENABLE_ORA_GRADE_NOTIFICATION = CourseWaffleFlag(f"{WAFFLE_NAMESPACE}.enable_ora
# .. toggle_warning: When the flag is ON, Notifications Grouping feature is enabled.
# .. toggle_tickets: INF-1472
ENABLE_NOTIFICATION_GROUPING = CourseWaffleFlag(f'{WAFFLE_NAMESPACE}.enable_notification_grouping', __name__)
# .. toggle_name: notifications.post_enable_notify_all_learners
# .. toggle_implementation: CourseWaffleFlag
# .. toggle_default: False
# .. toggle_description: Waffle flag to enable the notify all learners on discussion post
# .. toggle_use_cases: open_edx
# .. toggle_creation_date: 2025-06-11
# .. toggle_warning: When the flag is ON, notification to all learners feature is enabled on discussion post.
# .. toggle_tickets: INF-1917
ENABLE_NOTIFY_ALL_LEARNERS = CourseWaffleFlag(f'{WAFFLE_NAMESPACE}.enable_post_notify_all_learners', __name__)
# .. toggle_name: notifications.enable_push_notifications
# .. toggle_implementation: CourseWaffleFlag
# .. toggle_default: False
# .. toggle_description: Waffle flag to enable push Notifications feature on mobile devices
# .. toggle_use_cases: temporary
# .. toggle_creation_date: 2025-05-27
# .. toggle_target_removal_date: 2026-05-27
# .. toggle_warning: When the flag is ON, Notifications will go through ace push channels.
ENABLE_PUSH_NOTIFICATIONS = CourseWaffleFlag(f'{WAFFLE_NAMESPACE}.enable_push_notifications', __name__)

View File

@@ -48,3 +48,30 @@ def send_user_email_digest_sent_event(user, cadence_type, notifications, message
EMAIL_DIGEST_SENT,
event_data,
)
def send_immediate_email_digest_sent_event(user, cadence_type, notification):
"""
Sends tracker and segment event for immediate notification email
"""
event_data = {
"username": user.username,
"email": user.email,
"cadence_type": cadence_type,
"course_id": str(notification.course_id),
"app_name": notification.app_name,
"notification_type": notification.notification_type,
"content_url": notification.content_url,
"content": notification.content,
"send_at": str(datetime.datetime.now())
}
with tracker.get_tracker().context(EMAIL_DIGEST_SENT, event_data):
tracker.emit(
EMAIL_DIGEST_SENT,
event_data,
)
segment.track(
user.id,
EMAIL_DIGEST_SENT,
event_data,
)

View File

@@ -16,7 +16,7 @@ from openedx.core.djangoapps.notifications.models import (
Notification,
get_course_notification_preference_config_version
)
from .events import send_user_email_digest_sent_event
from .events import send_immediate_email_digest_sent_event, send_user_email_digest_sent_event
from .message_type import EmailNotificationMessageType
from .utils import (
add_headers_to_email_message,
@@ -183,3 +183,4 @@ def send_immediate_cadence_email(email_notification_mapping, course_key):
).personalize(Recipient(user.id, user.email), language, message_context)
message = add_headers_to_email_message(message, message_context)
ace.send(message)
send_immediate_email_digest_sent_event(user, EmailCadence.IMMEDIATELY, notification)

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.18 on 2025-03-12 22:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('notifications', '0008_notificationpreference'),
]
operations = [
migrations.AddField(
model_name='notification',
name='push',
field=models.BooleanField(default=False),
),
]

View File

@@ -26,7 +26,7 @@ NOTIFICATION_CHANNELS = ['web', 'push', 'email']
ADDITIONAL_NOTIFICATION_CHANNEL_SETTINGS = ['email_cadence']
# Update this version when there is a change to any course specific notification type or app.
COURSE_NOTIFICATION_CONFIG_VERSION = 13
COURSE_NOTIFICATION_CONFIG_VERSION = 14
def get_course_notification_preference_config():
@@ -110,6 +110,7 @@ class Notification(TimeStampedModel):
content_url = models.URLField(null=True, blank=True)
web = models.BooleanField(default=True, null=False, blank=False)
email = models.BooleanField(default=False, null=False, blank=False)
push = models.BooleanField(default=False, null=False, blank=False)
last_read = models.DateTimeField(null=True, blank=True)
last_seen = models.DateTimeField(null=True, blank=True)
group_by_id = models.CharField(max_length=255, db_index=True, null=False, default="")

View File

@@ -0,0 +1,10 @@
"""
Push notifications MessageType
"""
from openedx.core.djangoapps.ace_common.message import BaseMessageType
class PushNotificationMessageType(BaseMessageType):
"""
Edx-ace MessageType for Push Notifications
"""

View File

@@ -0,0 +1,45 @@
""" Tasks for sending notification to ace push channel """
from celery.utils.log import get_task_logger
from django.conf import settings
from django.contrib.auth import get_user_model
from edx_ace import ace
from .message_type import PushNotificationMessageType
User = get_user_model()
logger = get_task_logger(__name__)
def send_ace_msg_to_push_channel(audience_ids, notification_object, sender_id):
"""
Send mobile notifications using ace to push channels.
"""
if not audience_ids:
return
# We are releasing this feature gradually. For now, it is only tested with the discussion app.
# We might have a list here in the future.
if notification_object.app_name != 'discussion':
return
notification_type = notification_object.notification_type
post_data = {
'notification_type': notification_type,
'course_id': str(notification_object.course_id),
'content_url': notification_object.content_url,
**notification_object.content_context
}
emails = list(User.objects.filter(id__in=audience_ids).values_list('email', flat=True))
context = {'post_data': post_data}
message = PushNotificationMessageType(
app_label="notifications", name="push"
).personalize(None, 'en', context)
message.options['emails'] = emails
message.options['notification_type'] = notification_type
message.options['skip_disable_user_policy'] = True
ace.send(message, limit_to_channels=getattr(settings, 'ACE_PUSH_CHANNELS', []))
log_msg = 'Sent mobile notification for %s to ace push channel. Audience IDs: %s'
logger.info(log_msg, notification_type, audience_ids)

View File

@@ -0,0 +1,73 @@
"""
Tests for push notifications tasks.
"""
from unittest import mock
from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangoapps.notifications.push.tasks import send_ace_msg_to_push_channel
from openedx.core.djangoapps.notifications.tests.utils import create_notification
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
class SendNotificationsTest(ModuleStoreTestCase):
"""
Tests for send_notifications.
"""
def setUp(self):
"""
Create a course and users for the course.
"""
super().setUp()
self.user_1 = UserFactory()
self.user_2 = UserFactory()
self.course_1 = CourseFactory.create(
org='testorg',
number='testcourse',
run='testrun'
)
self.notification = create_notification(
self.user, self.course_1.id, app_name='discussion', notification_type='new_comment'
)
@mock.patch('openedx.core.djangoapps.notifications.push.tasks.ace.send')
def test_send_ace_msg_success(self, mock_ace_send):
""" Test send_ace_msg_success """
send_ace_msg_to_push_channel(
[self.user_1.id, self.user_2.id],
self.notification,
sender_id=self.user_1.id
)
mock_ace_send.assert_called_once()
message_sent = mock_ace_send.call_args[0][0]
assert message_sent.options['emails'] == [self.user_1.email, self.user_2.email]
assert message_sent.options['notification_type'] == 'new_comment'
@mock.patch('openedx.core.djangoapps.notifications.push.tasks.ace.send')
def test_send_ace_msg_no_sender(self, mock_ace_send):
""" Test when sender is not valid """
send_ace_msg_to_push_channel(
[self.user_1.id, self.user_2.id],
self.notification,
sender_id=999
)
mock_ace_send.assert_called_once()
@mock.patch('openedx.core.djangoapps.notifications.push.tasks.ace.send')
def test_send_ace_msg_empty_audience(self, mock_ace_send):
""" Test send_ace_msg_success with empty audience """
send_ace_msg_to_push_channel([], self.notification, sender_id=self.user_1.id)
mock_ace_send.assert_not_called()
@mock.patch('openedx.core.djangoapps.notifications.push.tasks.ace.send')
def test_send_ace_msg_non_discussion_app(self, mock_ace_send):
""" Test send_ace_msg_success with non-discussion app """
self.notification.app_name = 'ecommerce'
self.notification.save()
send_ace_msg_to_push_channel([1], self.notification, sender_id=self.user_1.id)
mock_ace_send.assert_not_called()

View File

@@ -19,19 +19,25 @@ from openedx.core.djangoapps.notifications.base_notification import (
get_default_values_of_preference,
get_notification_content
)
from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATION_GROUPING, ENABLE_NOTIFICATIONS
from openedx.core.djangoapps.notifications.email.tasks import send_immediate_cadence_email
from openedx.core.djangoapps.notifications.email_notifications import EmailCadence
from openedx.core.djangoapps.notifications.config.waffle import (
ENABLE_NOTIFICATION_GROUPING,
ENABLE_NOTIFICATIONS,
ENABLE_PUSH_NOTIFICATIONS
)
from openedx.core.djangoapps.notifications.events import notification_generated_event
from openedx.core.djangoapps.notifications.grouping_notifications import (
NotificationRegistry,
get_user_existing_notifications,
group_user_notifications, NotificationRegistry,
group_user_notifications
)
from openedx.core.djangoapps.notifications.models import (
CourseNotificationPreference,
Notification,
get_course_notification_preference_config_version
)
from openedx.core.djangoapps.notifications.push.tasks import send_ace_msg_to_push_channel
from openedx.core.djangoapps.notifications.utils import clean_arguments, get_list_in_batches
@@ -123,6 +129,7 @@ def send_notifications(user_ids, course_key: str, app_name, notification_type, c
"""
Send notifications to the users.
"""
# pylint: disable=too-many-statements
course_key = CourseKey.from_string(course_key)
if not ENABLE_NOTIFICATIONS.is_enabled(course_key):
return
@@ -136,12 +143,13 @@ def send_notifications(user_ids, course_key: str, app_name, notification_type, c
grouping_function = NotificationRegistry.get_grouper(notification_type)
waffle_flag_enabled = ENABLE_NOTIFICATION_GROUPING.is_enabled(course_key)
grouping_enabled = waffle_flag_enabled and group_by_id and grouping_function is not None
notifications_generated = False
notification_content = ''
generated_notification = None
sender_id = context.pop('sender_id', None)
default_web_config = get_default_values_of_preference(app_name, notification_type).get('web', False)
generated_notification_audience = []
email_notification_mapping = {}
push_notification_audience = []
is_push_notification_enabled = ENABLE_PUSH_NOTIFICATIONS.is_enabled(course_key)
if group_by_id and not grouping_enabled:
logger.info(
@@ -185,6 +193,7 @@ def send_notifications(user_ids, course_key: str, app_name, notification_type, c
notification_preferences = preference.get_channels_for_notification_type(app_name, notification_type)
email_enabled = 'email' in preference.get_channels_for_notification_type(app_name, notification_type)
email_cadence = preference.get_email_cadence_for_notification_type(app_name, notification_type)
push_notification = is_push_notification_enabled and 'push' in notification_preferences
new_notification = Notification(
user_id=user_id,
app_name=app_name,
@@ -194,34 +203,37 @@ def send_notifications(user_ids, course_key: str, app_name, notification_type, c
course_id=course_key,
web='web' in notification_preferences,
email=email_enabled,
push=push_notification,
group_by_id=group_by_id,
)
if email_enabled and (email_cadence == EmailCadence.IMMEDIATELY):
email_notification_mapping[user_id] = new_notification
if push_notification:
push_notification_audience.append(user_id)
if grouping_enabled and existing_notifications.get(user_id, None):
group_user_notifications(new_notification, existing_notifications[user_id])
if not notifications_generated:
notifications_generated = True
notification_content = new_notification.content
if not generated_notification:
generated_notification = new_notification
else:
notifications.append(new_notification)
generated_notification_audience.append(user_id)
# send notification to users but use bulk_create
notification_objects = Notification.objects.bulk_create(notifications)
if notification_objects and not notifications_generated:
notifications_generated = True
notification_content = notification_objects[0].content
if notification_objects and not generated_notification:
generated_notification = notification_objects[0]
if email_notification_mapping:
send_immediate_cadence_email(email_notification_mapping, course_key)
if notifications_generated:
if generated_notification:
notification_generated_event(
generated_notification_audience, app_name, notification_type, course_key, content_url,
notification_content, sender_id=sender_id
generated_notification.content, sender_id=sender_id
)
send_ace_msg_to_push_channel(push_notification_audience, generated_notification, sender_id)
def is_notification_valid(notification_type, context):

View File

@@ -15,7 +15,7 @@ from common.djangoapps.student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from ..config.waffle import ENABLE_NOTIFICATIONS, ENABLE_NOTIFICATION_GROUPING
from ..config.waffle import ENABLE_NOTIFICATION_GROUPING, ENABLE_NOTIFICATIONS, ENABLE_PUSH_NOTIFICATIONS
from ..models import CourseNotificationPreference, Notification
from ..tasks import (
create_notification_pref_if_not_exists,
@@ -116,6 +116,7 @@ class SendNotificationsTest(ModuleStoreTestCase):
)
@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
@override_waffle_flag(ENABLE_PUSH_NOTIFICATIONS, active=True)
@ddt.data(
('discussion', 'new_comment_on_response'), # core notification
('discussion', 'new_response'), # non core notification
@@ -168,6 +169,7 @@ class SendNotificationsTest(ModuleStoreTestCase):
self.assertEqual(len(Notification.objects.all()), created_notifications_count)
@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
@override_waffle_flag(ENABLE_PUSH_NOTIFICATIONS, active=True)
def test_notification_not_send_with_preference_disabled(self):
"""
Tests notification not send if preference is disabled
@@ -192,6 +194,7 @@ class SendNotificationsTest(ModuleStoreTestCase):
@override_waffle_flag(ENABLE_NOTIFICATION_GROUPING, True)
@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
@override_waffle_flag(ENABLE_PUSH_NOTIFICATIONS, active=True)
def test_send_notification_with_grouping_enabled(self):
"""
Test send_notifications with grouping enabled.
@@ -292,9 +295,9 @@ class SendBatchNotificationsTest(ModuleStoreTestCase):
@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
@ddt.data(
(settings.NOTIFICATION_CREATION_BATCH_SIZE, 10, 4),
(settings.NOTIFICATION_CREATION_BATCH_SIZE + 10, 12, 7),
(settings.NOTIFICATION_CREATION_BATCH_SIZE - 10, 10, 4),
(settings.NOTIFICATION_CREATION_BATCH_SIZE, 13, 6),
(settings.NOTIFICATION_CREATION_BATCH_SIZE + 10, 15, 9),
(settings.NOTIFICATION_CREATION_BATCH_SIZE - 10, 13, 5),
)
@ddt.unpack
def test_notification_is_send_in_batch(self, creation_size, prefs_query_count, notifications_query_count):
@@ -323,6 +326,7 @@ class SendBatchNotificationsTest(ModuleStoreTestCase):
for preference in preferences:
discussion_config = preference.notification_preference_config['discussion']
discussion_config['notification_types'][notification_type]['web'] = True
discussion_config['notification_types'][notification_type]['push'] = True
preference.save()
# Creating notifications and asserting query count
@@ -344,7 +348,7 @@ class SendBatchNotificationsTest(ModuleStoreTestCase):
"username": "Test Author"
}
with override_waffle_flag(ENABLE_NOTIFICATIONS, active=True):
with self.assertNumQueries(10):
with self.assertNumQueries(13):
send_notifications(user_ids, str(self.course.id), notification_app, notification_type,
context, "http://test.url")
@@ -363,9 +367,10 @@ class SendBatchNotificationsTest(ModuleStoreTestCase):
"replier_name": "Replier Name"
}
with override_waffle_flag(ENABLE_NOTIFICATIONS, active=True):
with self.assertNumQueries(12):
send_notifications(user_ids, str(self.course.id), notification_app, notification_type,
context, "http://test.url")
with override_waffle_flag(ENABLE_PUSH_NOTIFICATIONS, active=True):
with self.assertNumQueries(15):
send_notifications(user_ids, str(self.course.id), notification_app, notification_type,
context, "http://test.url")
def _update_user_preference(self, user_id, pref_exists):
"""
@@ -377,6 +382,7 @@ class SendBatchNotificationsTest(ModuleStoreTestCase):
CourseNotificationPreference.objects.filter(user_id=user_id, course_id=self.course.id).delete()
@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
@override_waffle_flag(ENABLE_PUSH_NOTIFICATIONS, active=True)
@ddt.data(
("new_response", True, True, 2),
("new_response", False, False, 2),

View File

@@ -311,7 +311,10 @@ class TestVisibilityFilter(unittest.TestCase):
'core': {'web': True, 'push': True, 'email': True, 'email_cadence': 'Daily'},
'content_reported': {'web': True, 'push': True, 'email': True, 'email_cadence': 'Daily'},
'new_question_post': {'web': False, 'push': False, 'email': False, 'email_cadence': 'Daily'},
'new_discussion_post': {'web': False, 'push': False, 'email': False, 'email_cadence': 'Daily'}
'new_discussion_post': {'web': False, 'push': False, 'email': False, 'email_cadence': 'Daily'},
'new_instructor_all_learners_post': {
'web': True, 'push': False, 'email': False, 'email_cadence': 'Daily'
}
},
'core_notification_types': [
'new_response', 'comment_on_followed_post',

View File

@@ -4,9 +4,11 @@ Utils function for notifications app
import copy
from typing import Dict, List, Set
from opaque_keys.edx.keys import CourseKey
from common.djangoapps.student.models import CourseAccessRole, CourseEnrollment
from openedx.core.djangoapps.django_comment_common.models import Role
from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS
from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS, ENABLE_NOTIFY_ALL_LEARNERS
from openedx.core.lib.cache_utils import request_cached
@@ -132,12 +134,21 @@ def remove_preferences_with_no_access(preferences: dict, user) -> dict:
user=user,
course_id=preferences['course_id']
).values_list('role', flat=True)
preferences['notification_preference_config'] = filter_out_visible_notifications(
user_preferences = filter_out_visible_notifications(
user_preferences,
notifications_with_visibility_settings,
user_forum_roles,
user_course_roles
)
course_key = CourseKey.from_string(preferences['course_id'])
discussion_config = user_preferences.get('discussion', {})
notification_types = discussion_config.get('notification_types', {})
if notification_types and not ENABLE_NOTIFY_ALL_LEARNERS.is_enabled(course_key):
notification_types.pop('new_instructor_all_learners_post', None)
return preferences

View File

@@ -26,7 +26,7 @@ from openedx.core.djangoapps.notifications.serializers import add_info_to_notifi
from openedx.core.djangoapps.user_api.models import UserPreference
from .base_notification import COURSE_NOTIFICATION_APPS
from .config.waffle import ENABLE_NOTIFICATIONS
from .config.waffle import ENABLE_NOTIFICATIONS, ENABLE_NOTIFY_ALL_LEARNERS
from .events import (
notification_preference_update_event,
notification_preferences_viewed_event,
@@ -603,13 +603,23 @@ class AggregatedNotificationPreferences(APIView):
notification_configs = aggregate_notification_configs(
notification_configs
)
course_ids = notification_preferences.values_list('course_id', flat=True)
filter_out_visible_preferences_by_course_ids(
request.user,
notification_configs,
notification_preferences.values_list('course_id', flat=True),
course_ids,
)
notification_preferences_viewed_event(request)
notification_configs = add_info_to_notification_config(notification_configs)
discussion_config = notification_configs.get('discussion', {})
notification_types = discussion_config.get('notification_types', {})
if not any(ENABLE_NOTIFY_ALL_LEARNERS.is_enabled(course_key) for course_key in course_ids):
notification_types.pop('new_instructor_all_learners_post', None)
return Response({
'status': 'success',
'message': 'Notification preferences retrieved',

View File

@@ -409,7 +409,7 @@ drf-yasg==1.21.10
# via
# django-user-tasks
# edx-api-doc-tools
edx-ace==1.14.0
edx-ace==1.15.0
# via -r requirements/edx/kernel.in
edx-api-doc-tools==2.1.0
# via
@@ -566,7 +566,7 @@ enmerkar==0.7.1
# via enmerkar-underscore
enmerkar-underscore==2.4.0
# via -r requirements/edx/kernel.in
enterprise-integrated-channels==0.1.7
enterprise-integrated-channels==0.1.8
# via -r requirements/edx/bundled.in
event-tracking==3.3.0
# via

View File

@@ -669,7 +669,7 @@ drf-yasg==1.21.10
# -r requirements/edx/testing.txt
# django-user-tasks
# edx-api-doc-tools
edx-ace==1.14.0
edx-ace==1.15.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -879,7 +879,7 @@ enmerkar-underscore==2.4.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
enterprise-integrated-channels==0.1.7
enterprise-integrated-channels==0.1.8
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt

View File

@@ -491,7 +491,7 @@ drf-yasg==1.21.10
# -r requirements/edx/base.txt
# django-user-tasks
# edx-api-doc-tools
edx-ace==1.14.0
edx-ace==1.15.0
# via -r requirements/edx/base.txt
edx-api-doc-tools==2.1.0
# via
@@ -653,7 +653,7 @@ enmerkar==0.7.1
# enmerkar-underscore
enmerkar-underscore==2.4.0
# via -r requirements/edx/base.txt
enterprise-integrated-channels==0.1.7
enterprise-integrated-channels==0.1.8
# via -r requirements/edx/base.txt
event-tracking==3.3.0
# via

View File

@@ -516,7 +516,7 @@ drf-yasg==1.21.10
# -r requirements/edx/base.txt
# django-user-tasks
# edx-api-doc-tools
edx-ace==1.14.0
edx-ace==1.15.0
# via -r requirements/edx/base.txt
edx-api-doc-tools==2.1.0
# via
@@ -680,7 +680,7 @@ enmerkar==0.7.1
# enmerkar-underscore
enmerkar-underscore==2.4.0
# via -r requirements/edx/base.txt
enterprise-integrated-channels==0.1.7
enterprise-integrated-channels==0.1.8
# via -r requirements/edx/base.txt
event-tracking==3.3.0
# via