diff --git a/lms/djangoapps/discussion/django_comment_client/utils.py b/lms/djangoapps/discussion/django_comment_client/utils.py index c135ecee85..b74920a4fa 100644 --- a/lms/djangoapps/discussion/django_comment_client/utils.py +++ b/lms/djangoapps/discussion/django_comment_client/utils.py @@ -35,13 +35,14 @@ from openedx.core.djangoapps.discussions.utils import ( get_group_names_by_id, has_required_keys, ) +import openedx.core.djangoapps.django_comment_common.comment_client as cc from openedx.core.djangoapps.django_comment_common.models import ( FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_STUDENT, CourseDiscussionSettings, DiscussionsIdMapping, - Role -) + Role, + FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_GROUP_MODERATOR) from openedx.core.lib.cache_utils import request_cached from openedx.core.lib.courses import get_course_by_id from xmodule.modulestore.django import modulestore @@ -146,6 +147,40 @@ def is_user_community_ta(user, course_id): return has_forum_access(user, course_id, FORUM_ROLE_COMMUNITY_TA) +def get_users_with_roles(roles, course_id): + """ + Get all users with specified roles for a course + """ + users_with_roles = [ + user + for role in Role.objects.filter( + name__in=roles, + course_id=course_id + ) + for user in role.users.all() + ] + return users_with_roles + + +def get_users_with_moderator_roles(context): + """ + Get all users within the course with moderator roles + """ + moderators = get_users_with_roles([FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA], context['course_id']) + + context_thread = cc.Thread.find(context['thread_id']) + if getattr(context_thread, 'group_id', None) is not None: + group_moderators = get_users_with_roles([FORUM_ROLE_GROUP_MODERATOR], context['course_id']) + course_discussion_settings = CourseDiscussionSettings.get(context['course_id']) + moderators_in_group = [user for user in group_moderators if get_group_id_for_user( + user, course_discussion_settings) == context_thread.group_id] + moderators += moderators_in_group + + moderators = set(moderators) + return moderators + + def get_discussion_id_map_entry(xblock): """ Returns a tuple of (discussion_id, metadata) suitable for inclusion in the results of get_discussion_id_map(). diff --git a/lms/djangoapps/discussion/rest_api/api.py b/lms/djangoapps/discussion/rest_api/api.py index ac738f126a..cc02f74f71 100644 --- a/lms/djangoapps/discussion/rest_api/api.py +++ b/lms/djangoapps/discussion/rest_api/api.py @@ -27,6 +27,7 @@ from lms.djangoapps.course_blocks.api import get_course_blocks from lms.djangoapps.courseware.courses import get_course_with_access from lms.djangoapps.courseware.exceptions import CourseAccessRedirect from lms.djangoapps.discussion.toggles import ENABLE_LEARNERS_TAB_IN_DISCUSSIONS_MFE +from lms.djangoapps.discussion.toggles_utils import reported_content_email_notification_enabled from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, DiscussionTopicLink, Provider from openedx.core.djangoapps.discussions.utils import get_accessible_discussion_xblocks from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment @@ -51,6 +52,8 @@ from openedx.core.djangoapps.django_comment_common.signals import ( thread_deleted, thread_edited, thread_voted, + thread_flagged, + comment_flagged, ) from openedx.core.djangoapps.user_api.accounts.api import get_account_settings from openedx.core.lib.exceptions import CourseNotFoundError, DiscussionNotFoundError, PageNotFoundError @@ -1018,6 +1021,11 @@ def _handle_abuse_flagged_field(form_value, user, cc_content): """mark or unmark thread/comment as abused""" if form_value: cc_content.flagAbuse(user, cc_content) + if reported_content_email_notification_enabled(CourseKey.from_string(cc_content.course_id)): + if cc_content.type == 'thread': + thread_flagged.send(sender='flag_abuse_for_thread', user=user, post=cc_content) + else: + comment_flagged.send(sender='flag_abuse_for_comment', user=user, post=cc_content) else: cc_content.unFlagAbuse(user, cc_content, removeAll=False) diff --git a/lms/djangoapps/discussion/signals/handlers.py b/lms/djangoapps/discussion/signals/handlers.py index 4321bb4c23..e34e507a5f 100644 --- a/lms/djangoapps/discussion/signals/handlers.py +++ b/lms/djangoapps/discussion/signals/handlers.py @@ -60,9 +60,31 @@ def send_discussion_email_notification(sender, user, post, **kwargs): # lint-am send_message(post, current_site) -def send_message(comment, site): # lint-amnesty, pylint: disable=missing-function-docstring +@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 + current_site = get_current_site() + if current_site is None: + log.info('Discussion: No current site, not sending notification about post: %s.', post.id) + return + + try: + if not current_site.configuration.get_value(ENABLE_FORUM_NOTIFICATIONS_FOR_SITE_KEY, False): + log_message = 'Discussion: reported content notifications not enabled for site: %s. ' \ + 'Not sending message about post: %s.' + log.info(log_message, current_site, post.id) + return + except SiteConfiguration.DoesNotExist: + log_message = 'Discussion: No SiteConfiguration for site %s. Not sending message about post: %s.' + log.info(log_message, current_site, post.id) + return + + send_message_for_reported_content(user, post, current_site, sender) + + +def create_message_context(comment, site): thread = comment.thread - context = { + return { 'course_id': str(thread.course_id), 'comment_id': comment.id, 'comment_body': comment.body, @@ -75,4 +97,31 @@ def send_message(comment, site): # lint-amnesty, pylint: disable=missing-functi 'thread_commentable_id': thread.commentable_id, 'site_id': site.id } + + +def create_message_context_for_reported_content(user, post, site, sender): + """ + Create message context for reported content. + """ + context = { + 'user_id': user.id, + 'course_id': str(post.course_id), + 'thread_id': post.thread.id if sender == 'flag_abuse_for_comment' else post.id, + 'title': post.thread.title if sender == 'flag_abuse_for_comment' else post.title, + 'content_type': post.type, + 'comment_body': post.body, + 'thread_created_at': post.created_at, + 'thread_commentable_id': post.commentable_id, + 'site_id': site.id, + } + return context + + +def send_message(comment, site): # lint-amnesty, pylint: disable=missing-function-docstring + context = create_message_context(comment, site) 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 + 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/tasks.py b/lms/djangoapps/discussion/tasks.py index 6ee5af2cf1..4c04f0ffe0 100644 --- a/lms/djangoapps/discussion/tasks.py +++ b/lms/djangoapps/discussion/tasks.py @@ -21,8 +21,10 @@ from six.moves.urllib.parse import urljoin import openedx.core.djangoapps.django_comment_common.comment_client as cc from common.djangoapps.track import segment +from common.lib.xmodule.xmodule.modulestore.django import modulestore from lms.djangoapps.discussion.django_comment_client.utils import ( - permalink + permalink, + get_users_with_moderator_roles, ) from openedx.core.djangoapps.discussions.utils import get_accessible_discussion_xblocks_by_course_id from openedx.core.djangoapps.ace_common.message import BaseMessageType @@ -62,6 +64,12 @@ class ResponseNotification(BaseMessageType): self.options['transactional'] = True +class ReportedContentNotification(BaseMessageType): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.options['transactional'] = True + + @shared_task(base=LoggedTask) @set_code_owner_attribute def send_ace_message(context): # lint-amnesty, pylint: disable=missing-function-docstring @@ -82,6 +90,31 @@ def send_ace_message(context): # lint-amnesty, pylint: disable=missing-function _track_notification_sent(message, context) +@shared_task(base=LoggedTask) +@set_code_owner_attribute +def send_ace_message_for_reported_content(context): # lint-amnesty, pylint: disable=missing-function-docstring + context['course_id'] = CourseKey.from_string(context['course_id']) + context['course_name'] = modulestore().get_course(context['course_id']).display_name + + moderators = get_users_with_moderator_roles(context) + context['site'] = Site.objects.get(id=context['site_id'] + ) + if not _is_content_still_reported(context): + log.info('Reported content is no longer in reported state. Email to moderators will not be sent.') + return + for moderator in moderators: + with emulate_http_request(site=context['site'], user=User.objects.get(id=context['user_id'])): + message_context = _build_message_context_for_reported_content(context) + message = ReportedContentNotification().personalize( + Recipient(moderator.id, moderator.email), + _get_course_language(context['course_id']), + message_context + ) + log.info(f'Sending forum reported content email notification with context {message_context}') + ace.send(message) + # TODO: add tracking for reported content email + + def _track_notification_sent(message, context): """ Send analytics event for a sent email @@ -121,6 +154,12 @@ def _should_send_message(context): ) +def _is_content_still_reported(context): + if context.get('thread_id'): + return len(cc.Thread.find(context['thread_id']).abuse_flaggers) > 0 + return len(cc.Comment.find(context['comment_id']).abuse_flaggers) > 0 + + def _is_not_subcomment(comment_id): comment = cc.Comment.find(id=comment_id).retrieve() return not getattr(comment, 'parent_id', None) @@ -176,6 +215,15 @@ def _build_message_context(context): # lint-amnesty, pylint: disable=missing-fu return message_context +def _build_message_context_for_reported_content(context): # lint-amnesty, pylint: disable=missing-function-docstring + message_context = get_base_template_context(context['site']) + message_context.update(context) + message_context.update({ + 'post_link': _get_thread_url(context), + }) + return message_context + + def _get_thread_url(context): # lint-amnesty, pylint: disable=missing-function-docstring scheme = 'https' if settings.HTTPS == 'on' else 'http' base_url = '{}://{}'.format(scheme, context['site'].domain) diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/reportedcontentnotification/email/body.html b/lms/djangoapps/discussion/templates/discussion/edx_ace/reportedcontentnotification/email/body.html new file mode 100644 index 0000000000..8b9eb72d8d --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/reportedcontentnotification/email/body.html @@ -0,0 +1,36 @@ +{% extends 'ace_common/edx_ace/common/base_body.html' %} + +{% load i18n %} +{% load django_markup %} +{% load static %} +{% block content %} + + + + +
+

+ {% filter force_escape %} + {% blocktrans trimmed asvar replied_to_text %} + {{ course_name }} {{ course_id }} Reported content awaits review + {% endblocktrans %} + {% endfilter %} + {% interpolate_html replied_to_text start_tag=''|safe end_tag=''|safe %} +

+
+ {{ comment_body }} +
+ + {% filter force_escape %} + {% blocktrans asvar course_cta_text %}Go to Discussion{% endblocktrans %} + {% endfilter %} + {% include "ace_common/edx_ace/common/return_to_course_cta.html" with course_cta_text=course_cta_text course_cta_url=post_link%} + + {% block google_analytics_pixel %} + + {% endblock %} +
+{% endblock %} diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/reportedcontentnotification/email/body.txt b/lms/djangoapps/discussion/templates/discussion/edx_ace/reportedcontentnotification/email/body.txt new file mode 100644 index 0000000000..136662dfb1 --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/reportedcontentnotification/email/body.txt @@ -0,0 +1,22 @@ +{% load i18n %} + +{% block content %} +{% blocktrans trimmed %} + {{ course_name }} {{ course_id }} Reported content awaits review +{% endblocktrans %} + + +
+ You are receiving this email because the following {{ content_type }} was reported for review + {{ comment_body }} +
+ + {% trans "Go to Discussion" %} +{% endblock %} + +{% block google_analytics_pixel %} + +{% endblock %} diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/reportedcontentnotification/email/from_name.txt b/lms/djangoapps/discussion/templates/discussion/edx_ace/reportedcontentnotification/email/from_name.txt new file mode 100644 index 0000000000..dcbc23c004 --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/reportedcontentnotification/email/from_name.txt @@ -0,0 +1 @@ +{{ platform_name }} diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/reportedcontentnotification/email/head.html b/lms/djangoapps/discussion/templates/discussion/edx_ace/reportedcontentnotification/email/head.html new file mode 100644 index 0000000000..366ada7ad9 --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/reportedcontentnotification/email/head.html @@ -0,0 +1 @@ +{% extends 'ace_common/edx_ace/common/base_head.html' %} diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/reportedcontentnotification/email/subject.txt b/lms/djangoapps/discussion/templates/discussion/edx_ace/reportedcontentnotification/email/subject.txt new file mode 100644 index 0000000000..b7575cb58d --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/reportedcontentnotification/email/subject.txt @@ -0,0 +1,3 @@ +{% load i18n %} + +{% blocktrans %} {{ course_name }} {{ course_id }} moderator content for review {% endblocktrans %} diff --git a/lms/djangoapps/discussion/toggles_utils.py b/lms/djangoapps/discussion/toggles_utils.py new file mode 100644 index 0000000000..982303d5c8 --- /dev/null +++ b/lms/djangoapps/discussion/toggles_utils.py @@ -0,0 +1,14 @@ +""" +Utils for Discussions feature toggles +""" +from lms.djangoapps.discussion.toggles import ENABLE_REPORTED_CONTENT_EMAIL_NOTIFICATIONS +from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings + + +def reported_content_email_notification_enabled(course_key): + """ + Checks for relevant flag and setting and returns boolean for reported + content email notification for course + """ + return bool(ENABLE_REPORTED_CONTENT_EMAIL_NOTIFICATIONS.is_enabled(course_key) and + CourseDiscussionSettings.get(course_key).reported_content_email_notifications) diff --git a/openedx/core/djangoapps/django_comment_common/signals.py b/openedx/core/djangoapps/django_comment_common/signals.py index cc2612f88e..8fb13d8b82 100644 --- a/openedx/core/djangoapps/django_comment_common/signals.py +++ b/openedx/core/djangoapps/django_comment_common/signals.py @@ -11,8 +11,10 @@ thread_voted = Signal() thread_deleted = Signal() thread_followed = Signal() thread_unfollowed = Signal() +thread_flagged = Signal() comment_created = Signal() comment_edited = Signal() comment_voted = Signal() comment_deleted = Signal() comment_endorsed = Signal() +comment_flagged = Signal()