Merge remote-tracking branch 'upstream/master' into salman/update-drf-endpoints
This commit is contained in:
@@ -127,8 +127,8 @@ from .utils import (
|
||||
discussion_open_for_user,
|
||||
get_usernames_for_course,
|
||||
get_usernames_from_search_string,
|
||||
is_posting_allowed,
|
||||
set_attribute,
|
||||
is_posting_allowed
|
||||
)
|
||||
|
||||
|
||||
|
||||
260
lms/djangoapps/discussion/rest_api/discussions_notifications.py
Normal file
260
lms/djangoapps/discussion/rest_api/discussions_notifications.py
Normal file
@@ -0,0 +1,260 @@
|
||||
"""
|
||||
Discussion notifications sender util.
|
||||
"""
|
||||
from django.conf import settings
|
||||
from lms.djangoapps.discussion.django_comment_client.permissions import get_team
|
||||
from openedx_events.learning.data import UserNotificationData
|
||||
from openedx_events.learning.signals import USER_NOTIFICATION_REQUESTED
|
||||
|
||||
from common.djangoapps.student.models import CourseEnrollment
|
||||
from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole
|
||||
from openedx.core.djangoapps.course_groups.models import CourseCohortsSettings, CourseUserGroup
|
||||
from openedx.core.djangoapps.discussions.utils import get_divided_discussions
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment
|
||||
from openedx.core.djangoapps.django_comment_common.comment_client.subscriptions import Subscription
|
||||
from openedx.core.djangoapps.django_comment_common.models import (
|
||||
FORUM_ROLE_ADMINISTRATOR,
|
||||
FORUM_ROLE_COMMUNITY_TA,
|
||||
FORUM_ROLE_MODERATOR,
|
||||
CourseDiscussionSettings,
|
||||
Role
|
||||
)
|
||||
|
||||
|
||||
class DiscussionNotificationSender:
|
||||
"""
|
||||
Class to send notifications to users who are subscribed to the thread.
|
||||
"""
|
||||
|
||||
def __init__(self, thread, course, creator, parent_id=None):
|
||||
self.thread = thread
|
||||
self.course = course
|
||||
self.creator = creator
|
||||
self.parent_id = parent_id
|
||||
self.parent_response = None
|
||||
self._get_parent_response()
|
||||
|
||||
def _send_notification(self, user_ids, notification_type, extra_context=None):
|
||||
"""
|
||||
Send notification to users
|
||||
"""
|
||||
if not user_ids:
|
||||
return
|
||||
|
||||
if extra_context is None:
|
||||
extra_context = {}
|
||||
|
||||
notification_data = UserNotificationData(
|
||||
user_ids=[int(user_id) for user_id in user_ids],
|
||||
context={
|
||||
"replier_name": self.creator.username,
|
||||
"post_title": self.thread.title,
|
||||
"course_name": self.course.display_name,
|
||||
**extra_context,
|
||||
},
|
||||
notification_type=notification_type,
|
||||
content_url=f"{settings.DISCUSSIONS_MICROFRONTEND_URL}/{str(self.course.id)}/posts/{self.thread.id}",
|
||||
app_name="discussion",
|
||||
course_key=self.course.id,
|
||||
)
|
||||
USER_NOTIFICATION_REQUESTED.send_event(notification_data=notification_data)
|
||||
|
||||
def _get_parent_response(self):
|
||||
"""
|
||||
Get parent response object
|
||||
"""
|
||||
if self.parent_id and not self.parent_response:
|
||||
self.parent_response = Comment(id=self.parent_id).retrieve()
|
||||
|
||||
return self.parent_response
|
||||
|
||||
def send_new_response_notification(self):
|
||||
"""
|
||||
Send notification to users who are subscribed to the main thread/post i.e.
|
||||
there is a response to the main thread.
|
||||
"""
|
||||
if not self.parent_id and self.creator.id != int(self.thread.user_id):
|
||||
self._send_notification([self.thread.user_id], "new_response")
|
||||
|
||||
def _response_and_thread_has_same_creator(self) -> bool:
|
||||
"""
|
||||
Check if response and main thread have same author.
|
||||
"""
|
||||
return int(self.parent_response.user_id) == int(self.thread.user_id)
|
||||
|
||||
def _response_and_comment_has_same_creator(self):
|
||||
return int(self.parent_response.attributes['user_id']) == self.creator.id
|
||||
|
||||
def send_new_comment_notification(self):
|
||||
"""
|
||||
Send notification to parent thread creator i.e. comment on the response.
|
||||
"""
|
||||
if (
|
||||
self.parent_response and
|
||||
self.creator.id != int(self.thread.user_id)
|
||||
):
|
||||
# use your if author of response is same as author of post.
|
||||
# use 'their' if comment author is also response author.
|
||||
author_name = (
|
||||
# Translators: Replier commented on "your" response to your post
|
||||
_("your")
|
||||
if self._response_and_thread_has_same_creator()
|
||||
else (
|
||||
# Translators: Replier commented on "their" response to your post
|
||||
_("their")
|
||||
if self._response_and_comment_has_same_creator()
|
||||
else f"{self.parent_response.username}'s"
|
||||
)
|
||||
)
|
||||
context = {
|
||||
"author_name": str(author_name),
|
||||
}
|
||||
self._send_notification([self.thread.user_id], "new_comment", extra_context=context)
|
||||
|
||||
def send_new_comment_on_response_notification(self):
|
||||
"""
|
||||
Send notification to parent response creator i.e. comment on the response.
|
||||
Do not send notification if author of response is same as author of post.
|
||||
"""
|
||||
if (
|
||||
self.parent_response and
|
||||
self.creator.id != int(self.parent_response.user_id) and not
|
||||
self._response_and_thread_has_same_creator()
|
||||
):
|
||||
self._send_notification([self.parent_response.user_id], "new_comment_on_response")
|
||||
|
||||
def _check_if_subscriber_is_not_thread_or_content_creator(self, subscriber_id) -> bool:
|
||||
"""
|
||||
Check if the subscriber is not the thread creator or response creator
|
||||
"""
|
||||
is_not_creator = (
|
||||
subscriber_id != int(self.thread.user_id) and
|
||||
subscriber_id != int(self.creator.id)
|
||||
)
|
||||
if self.parent_response:
|
||||
return is_not_creator and subscriber_id != int(self.parent_response.user_id)
|
||||
|
||||
return is_not_creator
|
||||
|
||||
def send_response_on_followed_post_notification(self):
|
||||
"""
|
||||
Send notification to followers of the thread/post
|
||||
except:
|
||||
Tread creator , response creator,
|
||||
"""
|
||||
users = []
|
||||
page = 1
|
||||
has_more_subscribers = True
|
||||
|
||||
while has_more_subscribers:
|
||||
|
||||
subscribers = Subscription.fetch(self.thread.id, query_params={'page': page})
|
||||
if page <= subscribers.num_pages:
|
||||
for subscriber in subscribers.collection:
|
||||
# Check if the subscriber is not the thread creator or response creator
|
||||
subscriber_id = int(subscriber.get('subscriber_id'))
|
||||
# do not send notification to the user who created the response and the thread
|
||||
if self._check_if_subscriber_is_not_thread_or_content_creator(subscriber_id):
|
||||
users.append(subscriber_id)
|
||||
else:
|
||||
has_more_subscribers = False
|
||||
page += 1
|
||||
# Remove duplicate users from the list of users to send notification
|
||||
users = list(set(users))
|
||||
if not self.parent_id:
|
||||
self._send_notification(users, "response_on_followed_post")
|
||||
else:
|
||||
self._send_notification(
|
||||
users,
|
||||
"comment_on_followed_post",
|
||||
extra_context={"author_name": self.parent_response.username}
|
||||
)
|
||||
|
||||
def _create_cohort_course_audience(self):
|
||||
"""
|
||||
Creates audience based on user cohort and role
|
||||
"""
|
||||
course_key_str = str(self.course.id)
|
||||
discussion_cohorted = is_discussion_cohorted(course_key_str)
|
||||
|
||||
# Retrieves cohort divided discussion
|
||||
discussion_settings = CourseDiscussionSettings.objects.get(course_id=course_key_str)
|
||||
divided_course_wide_discussions, divided_inline_discussions = get_divided_discussions(
|
||||
self.course,
|
||||
discussion_settings
|
||||
)
|
||||
|
||||
# Checks if post has any cohort assigned
|
||||
group_id = self.thread.attributes['group_id']
|
||||
if group_id is not None:
|
||||
group_id = int(group_id)
|
||||
|
||||
# Course wide topics
|
||||
topic_id = self.thread.attributes['commentable_id']
|
||||
all_topics = divided_inline_discussions + divided_course_wide_discussions
|
||||
topic_divided = topic_id in all_topics or discussion_settings.always_divide_inline_discussions
|
||||
|
||||
# Team object from topic id
|
||||
team = get_team(topic_id)
|
||||
|
||||
user_ids = []
|
||||
if team:
|
||||
user_ids = team.users.all().values_list('id', flat=True)
|
||||
elif discussion_cohorted and topic_divided and group_id is not None:
|
||||
users_in_cohort = CourseUserGroup.objects.filter(
|
||||
course_id=course_key_str, id=group_id
|
||||
).values_list('users__id', flat=True)
|
||||
user_ids.extend(users_in_cohort)
|
||||
|
||||
privileged_roles = [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA]
|
||||
privileged_users = Role.objects.filter(
|
||||
name__in=privileged_roles,
|
||||
course_id=course_key_str
|
||||
).values_list('users__id', flat=True)
|
||||
user_ids.extend(privileged_users)
|
||||
|
||||
staff_users = CourseStaffRole(self.course.id).users_with_role().values_list('id', flat=True)
|
||||
user_ids.extend(staff_users)
|
||||
|
||||
admin_users = CourseInstructorRole(self.course.id).users_with_role().values_list('id', flat=True)
|
||||
user_ids.extend(admin_users)
|
||||
else:
|
||||
user_ids = CourseEnrollment.objects.filter(
|
||||
course__id=course_key_str, is_active=True
|
||||
).values_list('user__id', flat=True)
|
||||
|
||||
unique_user_ids = list(set(user_ids))
|
||||
if self.creator.id in unique_user_ids:
|
||||
unique_user_ids.remove(self.creator.id)
|
||||
return unique_user_ids
|
||||
|
||||
def send_new_thread_created_notification(self):
|
||||
"""
|
||||
Send notification based on notification_type
|
||||
"""
|
||||
thread_type = self.thread.attributes['thread_type']
|
||||
notification_type = (
|
||||
"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']:
|
||||
raise ValueError(f'Invalid notification type {notification_type}')
|
||||
|
||||
user_ids = self._create_cohort_course_audience()
|
||||
context = {
|
||||
'username': self.creator.username,
|
||||
'post_title': self.thread.title
|
||||
}
|
||||
self._send_notification(user_ids, notification_type, context)
|
||||
|
||||
|
||||
def is_discussion_cohorted(course_key_str):
|
||||
"""
|
||||
Returns if the discussion is divided by cohorts
|
||||
"""
|
||||
cohort_settings = CourseCohortsSettings.objects.get(course_id=course_key_str)
|
||||
discussion_settings = CourseDiscussionSettings.objects.get(course_id=course_key_str)
|
||||
return cohort_settings.is_cohorted and discussion_settings.always_divide_inline_discussions
|
||||
@@ -8,7 +8,7 @@ from opaque_keys.edx.locator import CourseKey
|
||||
from lms.djangoapps.courseware.courses import get_course_with_access
|
||||
from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread
|
||||
from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS
|
||||
from lms.djangoapps.discussion.rest_api.utils import DiscussionNotificationSender
|
||||
from lms.djangoapps.discussion.rest_api.discussions_notifications import DiscussionNotificationSender
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
@@ -46,3 +46,4 @@ def send_response_notifications(thread_id, course_key_str, user_id, parent_id=No
|
||||
notification_sender.send_new_comment_notification()
|
||||
notification_sender.send_new_response_notification()
|
||||
notification_sender.send_new_comment_on_response_notification()
|
||||
notification_sender.send_response_on_followed_post_notification()
|
||||
|
||||
@@ -2156,6 +2156,7 @@ class CreateThreadTest(
|
||||
@disable_signal(api, 'comment_created')
|
||||
@disable_signal(api, 'comment_voted')
|
||||
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
|
||||
@mock.patch("lms.djangoapps.discussion.signals.handlers.send_response_notifications", new=mock.Mock())
|
||||
class CreateCommentTest(
|
||||
ForumsEnableMixin,
|
||||
CommentsServiceMockMixin,
|
||||
@@ -2194,6 +2195,17 @@ class CreateCommentTest(
|
||||
"raw_body": "Test body",
|
||||
}
|
||||
|
||||
mock_response = {
|
||||
'collection': [],
|
||||
'page': 1,
|
||||
'num_pages': 1,
|
||||
'subscriptions_count': 1,
|
||||
'corrected_text': None
|
||||
|
||||
}
|
||||
self.register_get_subscriptions('cohort_thread', mock_response)
|
||||
self.register_get_subscriptions('test_thread', mock_response)
|
||||
|
||||
@ddt.data(None, "test_parent")
|
||||
@mock.patch("eventtracking.tracker.emit")
|
||||
def test_success(self, parent_id, mock_emit):
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
Test cases for tasks.py
|
||||
"""
|
||||
from unittest import mock
|
||||
from django.conf import settings
|
||||
from edx_toggles.toggles.testutils import override_waffle_flag
|
||||
from unittest.mock import Mock
|
||||
|
||||
import ddt
|
||||
import httpretty
|
||||
from django.conf import settings
|
||||
from edx_toggles.toggles.testutils import override_waffle_flag
|
||||
from openedx_events.learning.signals import USER_NOTIFICATION_REQUESTED
|
||||
|
||||
from common.djangoapps.student.models import CourseEnrollment
|
||||
from common.djangoapps.student.tests.factories import StaffFactory, UserFactory
|
||||
from lms.djangoapps.discussion.django_comment_client.tests.factories import RoleFactory
|
||||
@@ -14,18 +17,19 @@ from lms.djangoapps.discussion.rest_api.tasks import send_response_notifications
|
||||
from lms.djangoapps.discussion.rest_api.tests.utils import ThreadMock, make_minimal_cs_thread
|
||||
from openedx.core.djangoapps.course_groups.models import CohortMembership, CourseCohortsSettings
|
||||
from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
|
||||
from openedx.core.djangoapps.discussions.models import DiscussionTopicLink
|
||||
from openedx.core.djangoapps.django_comment_common.models import (
|
||||
CourseDiscussionSettings,
|
||||
FORUM_ROLE_COMMUNITY_TA,
|
||||
FORUM_ROLE_GROUP_MODERATOR,
|
||||
FORUM_ROLE_MODERATOR,
|
||||
FORUM_ROLE_STUDENT,
|
||||
CourseDiscussionSettings
|
||||
)
|
||||
from openedx.core.djangoapps.discussions.models import DiscussionTopicLink
|
||||
from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS
|
||||
from openedx_events.learning.signals import USER_NOTIFICATION_REQUESTED
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
from ..discussions_notifications import DiscussionNotificationSender
|
||||
from .test_views import DiscussionAPIViewTestMixin
|
||||
|
||||
|
||||
@@ -241,6 +245,7 @@ class TestNewThreadCreatedNotification(DiscussionAPIViewTestMixin, ModuleStoreTe
|
||||
self.assert_users_id_list(user_ids_list, handler.call_args[1]['notification_data'].user_ids)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
|
||||
class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
|
||||
"""
|
||||
@@ -272,6 +277,7 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC
|
||||
"thread_type": 'discussion',
|
||||
"title": thread.title,
|
||||
})
|
||||
self._register_subscriptions_endpoint()
|
||||
|
||||
def test_basic(self):
|
||||
"""
|
||||
@@ -293,8 +299,8 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC
|
||||
# Post the form or do what it takes to send the signal
|
||||
|
||||
send_response_notifications(self.thread.id, str(self.course.id), self.user_2.id, parent_id=None)
|
||||
self.assertEqual(handler.call_count, 1)
|
||||
args = handler.call_args[1]['notification_data']
|
||||
self.assertEqual(handler.call_count, 2)
|
||||
args = handler.call_args_list[0][1]['notification_data']
|
||||
self.assertEqual([int(user_id) for user_id in args.user_ids], [self.user_1.id])
|
||||
self.assertEqual(args.notification_type, 'new_response')
|
||||
expected_context = {
|
||||
@@ -363,13 +369,13 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC
|
||||
|
||||
def test_no_signal_on_creators_own_thread(self):
|
||||
"""
|
||||
Makes sure that no signal is emitted if user creates response on
|
||||
Makes sure that 1 signal is emitted if user creates response on
|
||||
their own thread.
|
||||
"""
|
||||
handler = mock.Mock()
|
||||
USER_NOTIFICATION_REQUESTED.connect(handler)
|
||||
send_response_notifications(self.thread.id, str(self.course.id), self.user_1.id, parent_id=None)
|
||||
self.assertEqual(handler.call_count, 0)
|
||||
self.assertEqual(handler.call_count, 1)
|
||||
|
||||
def test_comment_creators_own_response(self):
|
||||
"""
|
||||
@@ -387,7 +393,7 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC
|
||||
|
||||
send_response_notifications(self.thread.id, str(self.course.id), self.user_3.id, parent_id=self.thread_2.id)
|
||||
# check if 1 call is made to the handler i.e. for the thread creator
|
||||
self.assertEqual(handler.call_count, 1)
|
||||
self.assertEqual(handler.call_count, 2)
|
||||
|
||||
# check if the notification is sent to the thread creator
|
||||
args_comment = handler.call_args_list[0][1]['notification_data']
|
||||
@@ -406,6 +412,71 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC
|
||||
)
|
||||
self.assertEqual(args_comment.app_name, 'discussion')
|
||||
|
||||
@ddt.data(
|
||||
(None, 'response_on_followed_post'), (1, 'comment_on_followed_post')
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_send_notification_to_followers(self, parent_id, notification_type):
|
||||
"""
|
||||
Test that the notification is sent to the followers of the thread
|
||||
"""
|
||||
self.register_get_comment_response({
|
||||
'id': self.thread.id,
|
||||
'thread_id': self.thread.id,
|
||||
'user_id': self.thread.user_id
|
||||
})
|
||||
handler = Mock()
|
||||
USER_NOTIFICATION_REQUESTED.connect(handler)
|
||||
|
||||
# Post the form or do what it takes to send the signal
|
||||
notification_sender = DiscussionNotificationSender(self.thread, self.course, self.user_2, parent_id=parent_id)
|
||||
notification_sender.send_response_on_followed_post_notification()
|
||||
self.assertEqual(handler.call_count, 1)
|
||||
args = handler.call_args[1]['notification_data']
|
||||
# only sent to user_3 because user_2 is the one who created the response
|
||||
self.assertEqual([self.user_3.id], args.user_ids)
|
||||
self.assertEqual(args.notification_type, notification_type)
|
||||
expected_context = {
|
||||
'replier_name': self.user_2.username,
|
||||
'post_title': 'test thread',
|
||||
'course_name': self.course.display_name,
|
||||
}
|
||||
if parent_id:
|
||||
expected_context['author_name'] = 'dummy'
|
||||
self.assertDictEqual(args.context, expected_context)
|
||||
self.assertEqual(
|
||||
args.content_url,
|
||||
_get_mfe_url(self.course.id, self.thread.id)
|
||||
)
|
||||
self.assertEqual(args.app_name, 'discussion')
|
||||
|
||||
def _register_subscriptions_endpoint(self):
|
||||
"""
|
||||
Registers the endpoint for the subscriptions API
|
||||
"""
|
||||
mock_response = {
|
||||
'collection': [
|
||||
{
|
||||
'_id': 1,
|
||||
'subscriber_id': str(self.user_2.id),
|
||||
"source_id": self.thread.id,
|
||||
"source_type": "thread",
|
||||
},
|
||||
{
|
||||
'_id': 2,
|
||||
'subscriber_id': str(self.user_3.id),
|
||||
"source_id": self.thread.id,
|
||||
"source_type": "thread",
|
||||
},
|
||||
],
|
||||
'page': 1,
|
||||
'num_pages': 1,
|
||||
'subscriptions_count': 2,
|
||||
'corrected_text': None
|
||||
|
||||
}
|
||||
self.register_get_subscriptions(self.thread.id, mock_response)
|
||||
|
||||
|
||||
@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
|
||||
class TestSendCommentNotification(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
|
||||
@@ -456,6 +527,7 @@ class TestSendCommentNotification(DiscussionAPIViewTestMixin, ModuleStoreTestCas
|
||||
'thread_id': thread.id,
|
||||
'user_id': response.user_id
|
||||
})
|
||||
self.register_get_subscriptions(1, {})
|
||||
send_response_notifications(thread.id, str(self.course.id), self.user_2.id, parent_id=response.id)
|
||||
handler.assert_called_once()
|
||||
context = handler.call_args[1]['notification_data'].context
|
||||
|
||||
@@ -2,29 +2,29 @@
|
||||
Tests for Discussion REST API utils.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import ddt
|
||||
from pytz import UTC
|
||||
import unittest
|
||||
from common.djangoapps.student.roles import CourseStaffRole, CourseInstructorRole
|
||||
from lms.djangoapps.discussion.django_comment_client.tests.utils import ForumsEnableMixin
|
||||
from lms.djangoapps.discussion.rest_api.tests.utils import CommentsServiceMockMixin
|
||||
from openedx.core.djangoapps.discussions.models import PostingRestriction, DiscussionsConfiguration
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole
|
||||
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
|
||||
from lms.djangoapps.discussion.django_comment_client.tests.factories import RoleFactory
|
||||
from lms.djangoapps.discussion.django_comment_client.tests.utils import ForumsEnableMixin
|
||||
from lms.djangoapps.discussion.rest_api.tests.utils import CommentsServiceMockMixin
|
||||
from lms.djangoapps.discussion.rest_api.utils import (
|
||||
discussion_open_for_user,
|
||||
get_course_ta_users_list,
|
||||
get_course_staff_users_list,
|
||||
get_moderator_users_list,
|
||||
get_archived_topics,
|
||||
remove_empty_sequentials,
|
||||
is_posting_allowed
|
||||
get_course_staff_users_list,
|
||||
get_course_ta_users_list,
|
||||
get_moderator_users_list,
|
||||
is_posting_allowed,
|
||||
remove_empty_sequentials
|
||||
)
|
||||
from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, PostingRestriction
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
|
||||
class DiscussionAPIUtilsTestCase(ModuleStoreTestCase):
|
||||
|
||||
@@ -2357,6 +2357,7 @@ class CommentViewSetDeleteTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
|
||||
@httpretty.activate
|
||||
@disable_signal(api, 'comment_created')
|
||||
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
|
||||
@mock.patch("lms.djangoapps.discussion.signals.handlers.send_response_notifications", new=mock.Mock())
|
||||
class CommentViewSetCreateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
|
||||
"""Tests for CommentViewSet create"""
|
||||
def setUp(self):
|
||||
|
||||
@@ -425,6 +425,18 @@ class CommentsServiceMockMixin:
|
||||
status=200
|
||||
)
|
||||
|
||||
def register_get_subscriptions(self, thread_id, response):
|
||||
"""
|
||||
Register a mock response for GET on the CS comment active threads endpoint
|
||||
"""
|
||||
assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.'
|
||||
httpretty.register_uri(
|
||||
httpretty.GET,
|
||||
f"http://localhost:4567/api/v1/threads/{thread_id}/subscriptions",
|
||||
body=json.dumps(response),
|
||||
status=200
|
||||
)
|
||||
|
||||
def assert_query_params_equal(self, httpretty_request, expected_params):
|
||||
"""
|
||||
Assert that the given mock request had the expected query parameters
|
||||
|
||||
@@ -2,33 +2,23 @@
|
||||
Utils for discussion API.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from pytz import UTC
|
||||
from typing import List, Dict
|
||||
from typing import Dict, List
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models.functions import Length
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from pytz import UTC
|
||||
|
||||
from common.djangoapps.student.models import CourseEnrollment
|
||||
from common.djangoapps.student.roles import CourseStaffRole, CourseInstructorRole
|
||||
from lms.djangoapps.discussion.django_comment_client.permissions import get_team
|
||||
from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole
|
||||
from lms.djangoapps.discussion.django_comment_client.utils import has_discussion_privileges
|
||||
from openedx.core.djangoapps.course_groups.models import CourseCohortsSettings, CourseUserGroup
|
||||
from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, PostingRestriction
|
||||
from openedx.core.djangoapps.discussions.utils import get_divided_discussions
|
||||
from openedx.core.djangoapps.django_comment_common.models import (
|
||||
Role,
|
||||
CourseDiscussionSettings,
|
||||
FORUM_ROLE_ADMINISTRATOR,
|
||||
FORUM_ROLE_MODERATOR,
|
||||
FORUM_ROLE_GROUP_MODERATOR,
|
||||
FORUM_ROLE_COMMUNITY_TA,
|
||||
FORUM_ROLE_GROUP_MODERATOR,
|
||||
FORUM_ROLE_MODERATOR,
|
||||
Role
|
||||
)
|
||||
from openedx_events.learning.signals import USER_NOTIFICATION_REQUESTED
|
||||
from openedx_events.learning.data import UserNotificationData
|
||||
from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment
|
||||
|
||||
|
||||
class AttributeDict(dict):
|
||||
@@ -371,196 +361,6 @@ def get_archived_topics(filtered_topic_ids: List[str], topics: List[Dict[str, st
|
||||
return archived_topics
|
||||
|
||||
|
||||
def is_discussion_cohorted(course_key_str):
|
||||
"""
|
||||
Returns if the discussion is divided by cohorts
|
||||
"""
|
||||
cohort_settings = CourseCohortsSettings.objects.get(course_id=course_key_str)
|
||||
discussion_settings = CourseDiscussionSettings.objects.get(course_id=course_key_str)
|
||||
return cohort_settings.is_cohorted and discussion_settings.always_divide_inline_discussions
|
||||
|
||||
|
||||
class DiscussionNotificationSender:
|
||||
"""
|
||||
Class to send notifications to users who are subscribed to the thread.
|
||||
"""
|
||||
|
||||
def __init__(self, thread, course, creator, parent_id=None):
|
||||
self.thread = thread
|
||||
self.course = course
|
||||
self.creator = creator
|
||||
self.parent_id = parent_id
|
||||
self.parent_response = None
|
||||
self._get_parent_response()
|
||||
|
||||
def _send_notification(self, user_ids, notification_type, extra_context=None):
|
||||
"""
|
||||
Send notification to users
|
||||
"""
|
||||
if not user_ids:
|
||||
return
|
||||
|
||||
if extra_context is None:
|
||||
extra_context = {}
|
||||
|
||||
notification_data = UserNotificationData(
|
||||
user_ids=[int(user_id) for user_id in user_ids],
|
||||
context={
|
||||
"replier_name": self.creator.username,
|
||||
"post_title": self.thread.title,
|
||||
"course_name": self.course.display_name,
|
||||
**extra_context,
|
||||
},
|
||||
notification_type=notification_type,
|
||||
content_url=f"{settings.DISCUSSIONS_MICROFRONTEND_URL}/{str(self.course.id)}/posts/{self.thread.id}",
|
||||
app_name="discussion",
|
||||
course_key=self.course.id,
|
||||
)
|
||||
USER_NOTIFICATION_REQUESTED.send_event(notification_data=notification_data)
|
||||
|
||||
def _get_parent_response(self):
|
||||
"""
|
||||
Get parent response object
|
||||
"""
|
||||
if self.parent_id and not self.parent_response:
|
||||
self.parent_response = Comment(id=self.parent_id).retrieve()
|
||||
|
||||
return self.parent_response
|
||||
|
||||
def _create_cohort_course_audience(self):
|
||||
"""
|
||||
Creates audience based on user cohort and role
|
||||
"""
|
||||
course_key_str = str(self.course.id)
|
||||
discussion_cohorted = is_discussion_cohorted(course_key_str)
|
||||
|
||||
# Retrieves cohort divided discussion
|
||||
discussion_settings = CourseDiscussionSettings.objects.get(course_id=course_key_str)
|
||||
divided_course_wide_discussions, divided_inline_discussions = get_divided_discussions(
|
||||
self.course,
|
||||
discussion_settings
|
||||
)
|
||||
|
||||
# Checks if post has any cohort assigned
|
||||
group_id = self.thread.attributes['group_id']
|
||||
if group_id is not None:
|
||||
group_id = int(group_id)
|
||||
|
||||
# Course wide topics
|
||||
topic_id = self.thread.attributes['commentable_id']
|
||||
all_topics = divided_inline_discussions + divided_course_wide_discussions
|
||||
topic_divided = topic_id in all_topics or discussion_settings.always_divide_inline_discussions
|
||||
|
||||
# Team object from topic id
|
||||
team = get_team(topic_id)
|
||||
|
||||
user_ids = []
|
||||
if team:
|
||||
user_ids = team.users.all().values_list('id', flat=True)
|
||||
elif discussion_cohorted and topic_divided and group_id is not None:
|
||||
users_in_cohort = CourseUserGroup.objects.filter(
|
||||
course_id=course_key_str, id=group_id
|
||||
).values_list('users__id', flat=True)
|
||||
user_ids.extend(users_in_cohort)
|
||||
|
||||
privileged_roles = [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA]
|
||||
privileged_users = Role.objects.filter(
|
||||
name__in=privileged_roles,
|
||||
course_id=course_key_str
|
||||
).values_list('users__id', flat=True)
|
||||
user_ids.extend(privileged_users)
|
||||
|
||||
staff_users = CourseStaffRole(self.course.id).users_with_role().values_list('id', flat=True)
|
||||
user_ids.extend(staff_users)
|
||||
|
||||
admin_users = CourseInstructorRole(self.course.id).users_with_role().values_list('id', flat=True)
|
||||
user_ids.extend(admin_users)
|
||||
else:
|
||||
user_ids = CourseEnrollment.objects.filter(
|
||||
course__id=course_key_str, is_active=True
|
||||
).values_list('user__id', flat=True)
|
||||
|
||||
unique_user_ids = list(set(user_ids))
|
||||
if self.creator.id in unique_user_ids:
|
||||
unique_user_ids.remove(self.creator.id)
|
||||
return unique_user_ids
|
||||
|
||||
def send_new_response_notification(self):
|
||||
"""
|
||||
Send notification to users who are subscribed to the main thread/post i.e.
|
||||
there is a response to the main thread.
|
||||
"""
|
||||
if not self.parent_id and self.creator.id != int(self.thread.user_id):
|
||||
self._send_notification([self.thread.user_id], "new_response")
|
||||
|
||||
def _response_and_thread_has_same_creator(self) -> bool:
|
||||
"""
|
||||
Check if response and main thread have same author.
|
||||
"""
|
||||
return int(self.parent_response.user_id) == int(self.thread.user_id)
|
||||
|
||||
def _response_and_comment_has_same_creator(self):
|
||||
return int(self.parent_response.attributes['user_id']) == self.creator.id
|
||||
|
||||
def send_new_comment_notification(self):
|
||||
"""
|
||||
Send notification to parent thread creator i.e. comment on the response.
|
||||
"""
|
||||
if (
|
||||
self.parent_response and
|
||||
self.creator.id != int(self.thread.user_id)
|
||||
):
|
||||
# use your if author of response is same as author of post.
|
||||
# use 'their' if comment author is also response author.
|
||||
author_name = (
|
||||
# Translators: Replier commented on "your" response to your post
|
||||
_("your")
|
||||
if self._response_and_thread_has_same_creator()
|
||||
else (
|
||||
# Translators: Replier commented on "their" response to your post
|
||||
_("their")
|
||||
if self._response_and_comment_has_same_creator()
|
||||
else f"{self.parent_response.username}'s"
|
||||
)
|
||||
)
|
||||
context = {
|
||||
"author_name": str(author_name),
|
||||
}
|
||||
self._send_notification([self.thread.user_id], "new_comment", extra_context=context)
|
||||
|
||||
def send_new_comment_on_response_notification(self):
|
||||
"""
|
||||
Send notification to parent response creator i.e. comment on the response.
|
||||
Do not send notification if author of response is same as author of post.
|
||||
"""
|
||||
if (
|
||||
self.parent_response and
|
||||
self.creator.id != int(self.parent_response.user_id) and not
|
||||
self._response_and_thread_has_same_creator()
|
||||
):
|
||||
self._send_notification([self.parent_response.user_id], "new_comment_on_response")
|
||||
|
||||
def send_new_thread_created_notification(self):
|
||||
"""
|
||||
Send notification based on notification_type
|
||||
"""
|
||||
thread_type = self.thread.attributes['thread_type']
|
||||
notification_type = (
|
||||
"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']:
|
||||
raise ValueError(f'Invalid notification type {notification_type}')
|
||||
|
||||
user_ids = self._create_cohort_course_audience()
|
||||
context = {
|
||||
'username': self.creator.username,
|
||||
'post_title': self.thread.title
|
||||
}
|
||||
self._send_notification(user_ids, notification_type, context)
|
||||
|
||||
|
||||
def is_posting_allowed(posting_restrictions: str, blackout_schedules: List):
|
||||
"""
|
||||
Check if posting is allowed based on the given posting restrictions and blackout schedules.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Subscription model is used to get users who are subscribed to the main thread/post i.e.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from . import models, settings, utils
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Subscription(models.Model):
|
||||
"""
|
||||
Subscription model is used to get users who are subscribed to the main thread/post i.e.
|
||||
"""
|
||||
# accessible_fields can be set and retrieved on the model
|
||||
accessible_fields = [
|
||||
'_id', 'subscriber_id', "source_id", "source_type"
|
||||
]
|
||||
|
||||
type = 'subscriber'
|
||||
base_url = f"{settings.PREFIX}/threads"
|
||||
|
||||
@classmethod
|
||||
def fetch(cls, thread_id, query_params):
|
||||
"""
|
||||
Fetches the subscriptions for a given thread_id
|
||||
"""
|
||||
params = {
|
||||
'page': query_params.get('page', 1),
|
||||
'per_page': query_params.get('per_page', 20),
|
||||
'id': thread_id
|
||||
}
|
||||
params.update(
|
||||
utils.strip_blank(utils.strip_none(query_params))
|
||||
)
|
||||
response = utils.perform_request(
|
||||
'get',
|
||||
cls.url(action='get', params=params) + "/subscriptions",
|
||||
params,
|
||||
metric_tags=[],
|
||||
metric_action='subscription.get',
|
||||
paged_results=True
|
||||
)
|
||||
return utils.SubscriptionsPaginatedResult(
|
||||
collection=response.get('collection', []),
|
||||
page=response.get('page', 1),
|
||||
num_pages=response.get('num_pages', 1),
|
||||
subscriptions_count=response.get('subscriptions_count', 0),
|
||||
corrected_text=response.get('corrected_text', None)
|
||||
)
|
||||
@@ -11,7 +11,6 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Thread(models.Model):
|
||||
|
||||
# accessible_fields can be set and retrieved on the model
|
||||
accessible_fields = [
|
||||
'id', 'title', 'body', 'anonymous', 'anonymous_to_peers', 'course_id',
|
||||
|
||||
@@ -131,6 +131,17 @@ class CommentClientPaginatedResult:
|
||||
self.corrected_text = corrected_text
|
||||
|
||||
|
||||
class SubscriptionsPaginatedResult:
|
||||
""" class for paginated results returned from comment services"""
|
||||
|
||||
def __init__(self, collection, page, num_pages, subscriptions_count=0, corrected_text=None):
|
||||
self.collection = collection
|
||||
self.page = page
|
||||
self.num_pages = num_pages
|
||||
self.subscriptions_count = subscriptions_count
|
||||
self.corrected_text = corrected_text
|
||||
|
||||
|
||||
def check_forum_heartbeat():
|
||||
"""
|
||||
Check the forum connection via its built-in heartbeat service and create an answer which can be used in the LMS
|
||||
|
||||
@@ -77,7 +77,42 @@ COURSE_NOTIFICATION_TYPES = {
|
||||
'username': 'Post author name',
|
||||
},
|
||||
'email_template': '',
|
||||
}
|
||||
},
|
||||
'response_on_followed_post': {
|
||||
'notification_app': 'discussion',
|
||||
'name': 'response_on_followed_post',
|
||||
'is_core': True,
|
||||
'web': False,
|
||||
'email': False,
|
||||
'push': False,
|
||||
'info': '',
|
||||
'non_editable': [],
|
||||
'content_template': _('<{p}><{strong}>{replier_name}</{strong}> responded to a post you’re following: '
|
||||
'<{strong}>{post_title}</{strong}></{p}>'),
|
||||
'content_context': {
|
||||
'post_title': 'Post title',
|
||||
'replier_name': 'replier name',
|
||||
},
|
||||
'email_template': '',
|
||||
},
|
||||
'comment_on_followed_post': {
|
||||
'notification_app': 'discussion',
|
||||
'name': 'comment_on_followed_post',
|
||||
'is_core': True,
|
||||
'web': False,
|
||||
'email': False,
|
||||
'push': False,
|
||||
'info': '',
|
||||
'non_editable': [],
|
||||
'content_template': _('<{p}><{strong}>{replier_name}</{strong}> commented on {author_name}\'s response in '
|
||||
'a post you’re following <{strong}>{post_title}</{strong}></{p}>'),
|
||||
'content_context': {
|
||||
'post_title': 'Post title',
|
||||
'author_name': 'author name',
|
||||
'replier_name': 'replier name',
|
||||
},
|
||||
'email_template': '',
|
||||
},
|
||||
}
|
||||
|
||||
COURSE_NOTIFICATION_APPS = {
|
||||
|
||||
@@ -217,7 +217,13 @@ class UserNotificationPreferenceAPITest(ModuleStoreTestCase):
|
||||
'notification_preference_config': {
|
||||
'discussion': {
|
||||
'enabled': True,
|
||||
'core_notification_types': ['new_comment_on_response', 'new_comment', 'new_response'],
|
||||
'core_notification_types': [
|
||||
'new_comment_on_response',
|
||||
'new_comment',
|
||||
'new_response',
|
||||
'response_on_followed_post',
|
||||
'comment_on_followed_post'
|
||||
],
|
||||
'notification_types': {
|
||||
'core': {
|
||||
'web': True,
|
||||
@@ -227,7 +233,7 @@ class UserNotificationPreferenceAPITest(ModuleStoreTestCase):
|
||||
'following, including endorsements to your responses and on your posts.'
|
||||
},
|
||||
'new_discussion_post': {'web': False, 'email': False, 'push': False, 'info': ''},
|
||||
'new_question_post': {'web': False, 'email': False, 'push': False, 'info': ''}
|
||||
'new_question_post': {'web': False, 'email': False, 'push': False, 'info': ''},
|
||||
},
|
||||
'non_editable': {
|
||||
'core': ['web']
|
||||
|
||||
@@ -26,7 +26,7 @@ django-storages==1.14
|
||||
# The team that owns this package will manually bump this package rather than having it pulled in automatically.
|
||||
# This is to allow them to better control its deployment and to do it in a process that works better
|
||||
# for them.
|
||||
edx-enterprise==4.6.1
|
||||
edx-enterprise==4.6.3
|
||||
|
||||
# django-oauth-toolkit version >=2.0.0 has breaking changes. More details
|
||||
# mentioned on this issue https://github.com/openedx/edx-platform/issues/32884
|
||||
|
||||
@@ -482,7 +482,7 @@ edx-drf-extensions==8.11.0
|
||||
# edx-when
|
||||
# edxval
|
||||
# openedx-learning
|
||||
edx-enterprise==4.6.1
|
||||
edx-enterprise==4.6.3
|
||||
# via
|
||||
# -c requirements/edx/../constraints.txt
|
||||
# -r requirements/edx/kernel.in
|
||||
|
||||
@@ -754,7 +754,7 @@ edx-drf-extensions==8.11.0
|
||||
# edx-when
|
||||
# edxval
|
||||
# openedx-learning
|
||||
edx-enterprise==4.6.1
|
||||
edx-enterprise==4.6.3
|
||||
# via
|
||||
# -c requirements/edx/../constraints.txt
|
||||
# -r requirements/edx/doc.txt
|
||||
|
||||
@@ -558,7 +558,7 @@ edx-drf-extensions==8.11.0
|
||||
# edx-when
|
||||
# edxval
|
||||
# openedx-learning
|
||||
edx-enterprise==4.6.1
|
||||
edx-enterprise==4.6.3
|
||||
# via
|
||||
# -c requirements/edx/../constraints.txt
|
||||
# -r requirements/edx/base.txt
|
||||
|
||||
@@ -585,7 +585,7 @@ edx-drf-extensions==8.11.0
|
||||
# edx-when
|
||||
# edxval
|
||||
# openedx-learning
|
||||
edx-enterprise==4.6.1
|
||||
edx-enterprise==4.6.3
|
||||
# via
|
||||
# -c requirements/edx/../constraints.txt
|
||||
# -r requirements/edx/base.txt
|
||||
|
||||
Reference in New Issue
Block a user