diff --git a/lms/djangoapps/discussion/django_comment_client/base/tests.py b/lms/djangoapps/discussion/django_comment_client/base/tests.py index dbfab3a3f2..c48e9cbe7d 100644 --- a/lms/djangoapps/discussion/django_comment_client/base/tests.py +++ b/lms/djangoapps/discussion/django_comment_client/base/tests.py @@ -426,6 +426,8 @@ class ViewsQueryCountTestCase( @ddt.ddt +@disable_signal(views, 'comment_flagged') +@disable_signal(views, 'thread_flagged') @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) class ViewsTestCase( ForumsEnableMixin, @@ -1714,7 +1716,13 @@ class TeamsPermissionsTestCase(ForumsEnableMixin, UrlResetMixin, SharedModuleSto commentable_id = getattr(self, commentable_id) self._setup_mock( user, mock_request, - {"closed": False, "commentable_id": commentable_id, "thread_id": "dummy_thread", "body": 'dummy body'}, + { + "closed": False, + "commentable_id": commentable_id, + "thread_id": "dummy_thread", + "body": 'dummy body', + "course_id": str(self.course.id) + }, ) for action in ["upvote_comment", "downvote_comment", "un_flag_abuse_for_comment", "flag_abuse_for_comment"]: response = self.client.post( @@ -1735,7 +1743,7 @@ class TeamsPermissionsTestCase(ForumsEnableMixin, UrlResetMixin, SharedModuleSto commentable_id = getattr(self, commentable_id) self._setup_mock( user, mock_request, - {"closed": False, "commentable_id": commentable_id, "body": "dummy body"}, + {"closed": False, "commentable_id": commentable_id, "body": "dummy body", "course_id": str(self.course.id)} ) for action in ["upvote_thread", "downvote_thread", "un_flag_abuse_for_thread", "flag_abuse_for_thread", "follow_thread", "unfollow_thread"]: diff --git a/lms/djangoapps/discussion/rest_api/discussions_notifications.py b/lms/djangoapps/discussion/rest_api/discussions_notifications.py index cba7852023..a148fbdc9f 100644 --- a/lms/djangoapps/discussion/rest_api/discussions_notifications.py +++ b/lms/djangoapps/discussion/rest_api/discussions_notifications.py @@ -1,6 +1,8 @@ """ Discussion notifications sender util. """ +import re + from django.conf import settings from lms.djangoapps.discussion.django_comment_client.permissions import get_team from openedx_events.learning.data import UserNotificationData, CourseNotificationData @@ -70,7 +72,7 @@ class DiscussionNotificationSender: course_key=self.course.id, content_context={ "replier_name": self.creator.username, - "post_title": self.thread.title, + "post_title": getattr(self.thread, 'title', ''), "course_name": self.course.display_name, "sender_id": self.creator.id, **extra_context, @@ -201,16 +203,20 @@ class DiscussionNotificationSender: discussion_cohorted = is_discussion_cohorted(course_key_str) # Retrieves cohort divided discussion - discussion_settings = CourseDiscussionSettings.objects.get(course_id=course_key_str) + try: + discussion_settings = CourseDiscussionSettings.objects.get(course_id=course_key_str) + except CourseDiscussionSettings.DoesNotExist: + return {} 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) + group_id = self.thread.attributes.get('group_id') + if group_id is None: + return {} + group_id = int(group_id) # Course wide topics all_topics = divided_inline_discussions + divided_course_wide_discussions @@ -259,11 +265,50 @@ class DiscussionNotificationSender: } self._send_course_wide_notification(notification_type, audience_filters, context) + def send_reported_content_notification(self): + """ + Send notification to users who are subscribed to the thread. + """ + thread_body = self.thread.body if self.thread.body else '' + + thread_body = remove_html_tags(thread_body) + thread_types = { + # numeric key is the depth of the thread in the discussion + 'comment': { + 1: 'comment', + 0: 'response' + }, + 'thread': { + 0: 'thread' + } + } + + content_type = thread_types[self.thread.type][getattr(self.thread, 'depth', 0)] + + context = { + 'username': self.creator.username, + 'content_type': content_type, + 'content': thread_body + } + audience_filters = self._create_cohort_course_audience() + audience_filters['discussion_roles'] = [ + FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA + ] + self._send_course_wide_notification("content_reported", audience_filters, 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) + try: + cohort_settings = CourseCohortsSettings.objects.get(course_id=course_key_str) + discussion_settings = CourseDiscussionSettings.objects.get(course_id=course_key_str) + except (CourseCohortsSettings.DoesNotExist, CourseDiscussionSettings.DoesNotExist): + return False return cohort_settings.is_cohorted and discussion_settings.always_divide_inline_discussions + + +def remove_html_tags(text): + clean = re.compile('<.*?>') + return re.sub(clean, '', text) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_disucssions_notifications.py b/lms/djangoapps/discussion/rest_api/tests/test_disucssions_notifications.py new file mode 100644 index 0000000000..1156440b6d --- /dev/null +++ b/lms/djangoapps/discussion/rest_api/tests/test_disucssions_notifications.py @@ -0,0 +1,91 @@ +""" +Unit tests for the DiscussionNotificationSender class +""" + +import unittest +from unittest.mock import MagicMock, patch + +import pytest +from edx_toggles.toggles.testutils import override_waffle_flag + +from lms.djangoapps.discussion.rest_api.discussions_notifications import DiscussionNotificationSender +from lms.djangoapps.discussion.toggles import ENABLE_REPORTED_CONTENT_NOTIFICATIONS + + +@patch('lms.djangoapps.discussion.rest_api.discussions_notifications.DiscussionNotificationSender' + '._create_cohort_course_audience', return_value={}) +@patch('lms.djangoapps.discussion.rest_api.discussions_notifications.DiscussionNotificationSender' + '._send_course_wide_notification') +@pytest.mark.django_db +class TestDiscussionNotificationSender(unittest.TestCase): + """ + Tests for the DiscussionNotificationSender class + """ + + @override_waffle_flag(ENABLE_REPORTED_CONTENT_NOTIFICATIONS, True) + def setUp(self): + self.thread = MagicMock() + self.course = MagicMock() + self.creator = MagicMock() + self.notification_sender = DiscussionNotificationSender(self.thread, self.course, self.creator) + + def _setup_thread(self, thread_type, body, depth): + """ + Helper to set up the thread object + """ + self.thread.type = thread_type + self.thread.body = body + self.thread.depth = depth + self.creator.username = 'test_user' + + def _assert_send_notification_called_with(self, mock_send_notification, expected_content_type): + """ + Helper to assert that the send_notification method was called with the correct arguments + """ + notification_type, audience_filters, context = mock_send_notification.call_args[0] + mock_send_notification.assert_called_once() + + self.assertEqual(notification_type, "content_reported") + self.assertEqual(context, { + 'username': 'test_user', + 'content_type': expected_content_type, + 'content': 'Thread body' + }) + self.assertEqual(audience_filters, { + 'discussion_roles': ['Administrator', 'Moderator', 'Community TA'] + }) + + def test_send_reported_content_notification_for_response(self, mock_send_notification, mock_create_audience): + """ + Test that the send_reported_content_notification method calls the send_notification method with the correct + arguments for a comment with depth 0 + """ + self._setup_thread('comment', '

Thread body

', 0) + mock_create_audience.return_value = {} + + self.notification_sender.send_reported_content_notification() + + self._assert_send_notification_called_with(mock_send_notification, 'response') + + def test_send_reported_content_notification_for_comment(self, mock_send_notification, mock_create_audience): + """ + Test that the send_reported_content_notification method calls the send_notification method with the correct + arguments for a comment with depth 1 + """ + self._setup_thread('comment', '

Thread body

', 1) + mock_create_audience.return_value = {} + + self.notification_sender.send_reported_content_notification() + + self._assert_send_notification_called_with(mock_send_notification, 'comment') + + def test_send_reported_content_notification_for_thread(self, mock_send_notification, mock_create_audience): + """ + Test that the send_reported_content_notification method calls the send_notification method with the correct + """ + self._setup_thread('thread', '

Thread body

', 0) + mock_create_audience.return_value = {} + + self.notification_sender.send_reported_content_notification() + + self._assert_send_notification_called_with(mock_send_notification, 'thread') diff --git a/lms/djangoapps/discussion/signals/handlers.py b/lms/djangoapps/discussion/signals/handlers.py index 3e142aecd9..f857e1aa8c 100644 --- a/lms/djangoapps/discussion/signals/handlers.py +++ b/lms/djangoapps/discussion/signals/handlers.py @@ -2,14 +2,17 @@ Signal handlers related to discussions. """ - import logging from django.conf import settings from django.dispatch import receiver from django.utils.html import strip_tags +from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import LibraryLocator -from xmodule.modulestore.django import SignalHandler + +from lms.djangoapps.discussion.rest_api.discussions_notifications import DiscussionNotificationSender +from lms.djangoapps.discussion.toggles import ENABLE_REPORTED_CONTENT_NOTIFICATIONS +from xmodule.modulestore.django import SignalHandler, modulestore from lms.djangoapps.discussion import tasks from lms.djangoapps.discussion.rest_api.tasks import send_response_notifications, send_thread_created_notification @@ -19,7 +22,6 @@ from openedx.core.djangoapps.theming.helpers import get_current_site log = logging.getLogger(__name__) - ENABLE_FORUM_NOTIFICATIONS_FOR_SITE_KEY = 'enable_forum_notifications' @@ -43,7 +45,8 @@ def update_discussions_on_course_publish(sender, course_key, **kwargs): # pylin @receiver(signals.comment_created) -def send_discussion_email_notification(sender, user, post, **kwargs): # lint-amnesty, pylint: disable=missing-function-docstring, unused-argument +def send_discussion_email_notification(sender, user, post, + **kwargs): # lint-amnesty, pylint: disable=missing-function-docstring, unused-argument current_site = get_current_site() if current_site is None: log.info('Discussion: No current site, not sending notification about post: %s.', post.id) @@ -64,7 +67,10 @@ def send_discussion_email_notification(sender, user, post, **kwargs): # lint-am @receiver(signals.comment_flagged) @receiver(signals.thread_flagged) -def send_reported_content_email_notification(sender, user, post, **kwargs): # lint-amnesty, pylint: disable=missing-function-docstring, unused-argument +def send_reported_content_email_notification(sender, user, post, **kwargs): + """ + Sends email notification for reported content. + """ current_site = get_current_site() if current_site is None: log.info('Discussion: No current site, not sending notification about post: %s.', post.id) @@ -84,6 +90,19 @@ def send_reported_content_email_notification(sender, user, post, **kwargs): # l send_message_for_reported_content(user, post, current_site, sender) +@receiver(signals.comment_flagged) +@receiver(signals.thread_flagged) +def send_reported_content_notification(sender, user, post, **kwargs): + """ + Sends notification for reported content. + """ + course_key = CourseKey.from_string(post.course_id) + if not ENABLE_REPORTED_CONTENT_NOTIFICATIONS.is_enabled(course_key): + return + course = modulestore().get_course(course_key) + DiscussionNotificationSender(post, course, user).send_reported_content_notification() + + def create_message_context(comment, site): thread = comment.thread return { @@ -105,6 +124,7 @@ def create_message_context_for_reported_content(user, post, site, sender): """ Create message context for reported content. """ + def get_comment_type(comment): """ Returns type of comment. @@ -131,7 +151,8 @@ def send_message(comment, site): # lint-amnesty, pylint: disable=missing-functi tasks.send_ace_message.apply_async(args=[context]) -def send_message_for_reported_content(user, post, site, sender): # lint-amnesty, pylint: disable=missing-function-docstring +def send_message_for_reported_content(user, post, site, + sender): # lint-amnesty, pylint: disable=missing-function-docstring context = create_message_context_for_reported_content(user, post, site, sender) tasks.send_ace_message_for_reported_content.apply_async(args=[context], countdown=120) diff --git a/lms/djangoapps/discussion/toggles.py b/lms/djangoapps/discussion/toggles.py index a1c292a473..29918c45a9 100644 --- a/lms/djangoapps/discussion/toggles.py +++ b/lms/djangoapps/discussion/toggles.py @@ -12,3 +12,15 @@ from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag # .. toggle_creation_date: 2021-11-05 # .. toggle_target_removal_date: 2022-12-05 ENABLE_DISCUSSIONS_MFE = CourseWaffleFlag(f'{WAFFLE_FLAG_NAMESPACE}.enable_discussions_mfe', __name__) + +# .. toggle_name: discussions.enable_reported_content_notifications +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to enable reported content notifications. +# .. toggle_use_cases: temporary, open_edx +# .. toggle_creation_date: 18-Jan-2024 +# .. toggle_target_removal_date: 18-Feb-2024 +ENABLE_REPORTED_CONTENT_NOTIFICATIONS = CourseWaffleFlag( + f'{WAFFLE_FLAG_NAMESPACE}.enable_reported_content_notifications', + __name__ +) diff --git a/openedx/core/djangoapps/notifications/base_notification.py b/openedx/core/djangoapps/notifications/base_notification.py index 877b029ebc..9d620b5b61 100644 --- a/openedx/core/djangoapps/notifications/base_notification.py +++ b/openedx/core/djangoapps/notifications/base_notification.py @@ -113,6 +113,25 @@ COURSE_NOTIFICATION_TYPES = { 'email_template': '', 'filters': [FILTER_AUDIT_EXPIRED_USERS_WITH_NO_ROLE] }, + 'content_reported': { + 'notification_app': 'discussion', + 'name': 'content_reported', + 'is_core': False, + 'info': '', + 'web': True, + 'email': True, + 'push': True, + 'non_editable': [], + 'content_template': _('

{username}’s {content_type} has been reported {' + 'content}

'), + + 'content_context': { + 'post_title': 'Post title', + 'author_name': 'author name', + 'replier_name': 'replier name', + }, + 'email_template': '', + }, } COURSE_NOTIFICATION_APPS = { diff --git a/openedx/core/djangoapps/notifications/models.py b/openedx/core/djangoapps/notifications/models.py index 55f6c11a7c..c2c03f2023 100644 --- a/openedx/core/djangoapps/notifications/models.py +++ b/openedx/core/djangoapps/notifications/models.py @@ -21,7 +21,7 @@ log = logging.getLogger(__name__) NOTIFICATION_CHANNELS = ['web', 'push', 'email'] # Update this version when there is a change to any course specific notification type or app. -COURSE_NOTIFICATION_CONFIG_VERSION = 4 +COURSE_NOTIFICATION_CONFIG_VERSION = 5 def get_course_notification_preference_config(): diff --git a/openedx/core/djangoapps/notifications/tests/test_views.py b/openedx/core/djangoapps/notifications/tests/test_views.py index 90b3fd39d3..815266235b 100644 --- a/openedx/core/djangoapps/notifications/tests/test_views.py +++ b/openedx/core/djangoapps/notifications/tests/test_views.py @@ -18,6 +18,7 @@ from rest_framework.test import APIClient, APITestCase from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.tests.factories import UserFactory from lms.djangoapps.discussion.django_comment_client.tests.factories import RoleFactory +from lms.djangoapps.discussion.toggles import ENABLE_REPORTED_CONTENT_NOTIFICATIONS from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.core.djangoapps.django_comment_common.models import ( FORUM_ROLE_ADMINISTRATOR, @@ -169,6 +170,7 @@ class CourseEnrollmentPostSaveTest(ModuleStoreTestCase): @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) +@override_waffle_flag(ENABLE_REPORTED_CONTENT_NOTIFICATIONS, active=True) @ddt.ddt class UserNotificationPreferenceAPITest(ModuleStoreTestCase): """ @@ -246,6 +248,7 @@ class UserNotificationPreferenceAPITest(ModuleStoreTestCase): }, 'new_discussion_post': {'web': False, 'email': False, 'push': False, 'info': ''}, 'new_question_post': {'web': False, 'email': False, 'push': False, 'info': ''}, + 'content_reported': {'web': True, 'email': True, 'push': True, 'info': ''}, }, 'non_editable': { 'core': ['web'] diff --git a/openedx/core/djangoapps/notifications/utils.py b/openedx/core/djangoapps/notifications/utils.py index 99b133c501..05d9e4ecd2 100644 --- a/openedx/core/djangoapps/notifications/utils.py +++ b/openedx/core/djangoapps/notifications/utils.py @@ -4,6 +4,7 @@ Utils function for notifications app from typing import Dict, List from common.djangoapps.student.models import CourseEnrollment +from lms.djangoapps.discussion.toggles import ENABLE_REPORTED_CONTENT_NOTIFICATIONS from openedx.core.djangoapps.django_comment_common.models import Role from openedx.core.lib.cache_utils import request_cached @@ -65,6 +66,10 @@ def filter_course_wide_preferences(course_key, preferences): if ENABLE_COURSEWIDE_NOTIFICATIONS.is_enabled(course_key): return preferences course_wide_notification_types = ['new_discussion_post', 'new_question_post'] + + if not ENABLE_REPORTED_CONTENT_NOTIFICATIONS.is_enabled(course_key): + course_wide_notification_types.append('content_reported') + config = preferences['notification_preference_config'] for app_prefs in config.values(): notification_types = app_prefs['notification_types']