From fedf35c36e7a79708fde6a10005eebc9de370e37 Mon Sep 17 00:00:00 2001 From: Alex Dusenbery Date: Tue, 24 Oct 2017 16:30:58 -0400 Subject: [PATCH] EDUCATOR-1572 | Send an ACE message to thread author when new comment signal is received. --- lms/djangoapps/discussion/signals/handlers.py | 23 ++- lms/djangoapps/discussion/tasks.py | 92 +++++++++++ .../response_notification/email/body.html | 58 +++++++ .../response_notification/email/body.txt | 5 + .../response_notification/email/from_name.txt | 1 + .../response_notification/email/head.html | 29 ++++ .../response_notification/email/subject.txt | 5 + lms/djangoapps/discussion/tests/test_tasks.py | 151 ++++++++++++++++++ 8 files changed, 359 insertions(+), 5 deletions(-) create mode 100644 lms/djangoapps/discussion/tasks.py create mode 100644 lms/djangoapps/discussion/templates/discussion/edx_ace/response_notification/email/body.html create mode 100644 lms/djangoapps/discussion/templates/discussion/edx_ace/response_notification/email/body.txt create mode 100644 lms/djangoapps/discussion/templates/discussion/edx_ace/response_notification/email/from_name.txt create mode 100644 lms/djangoapps/discussion/templates/discussion/edx_ace/response_notification/email/head.html create mode 100644 lms/djangoapps/discussion/templates/discussion/edx_ace/response_notification/email/subject.txt create mode 100644 lms/djangoapps/discussion/tests/test_tasks.py diff --git a/lms/djangoapps/discussion/signals/handlers.py b/lms/djangoapps/discussion/signals/handlers.py index 0ba16248c0..542f3875bd 100644 --- a/lms/djangoapps/discussion/signals/handlers.py +++ b/lms/djangoapps/discussion/signals/handlers.py @@ -7,6 +7,7 @@ from django.dispatch import receiver from django_comment_common import signals from lms.djangoapps.discussion.config.waffle import waffle, FORUM_RESPONSE_NOTIFICATIONS +from lms.djangoapps.discussion import tasks log = logging.getLogger(__name__) @@ -18,8 +19,20 @@ def send_discussion_email_notification(sender, user, post, **kwargs): send_message(post) -def send_message(post): - """ - TODO: https://openedx.atlassian.net/browse/EDUCATOR-1572 - """ - log.info('Sending message about thread %s', post.thread_id) +def send_message(comment): + thread = comment.thread + context = { + 'course_id': unicode(thread.course_id), + 'comment_id': comment.id, + 'comment_body': comment.body, + 'comment_author_id': comment.user_id, + 'comment_username': comment.username, + 'comment_created_at': comment.created_at, + 'thread_id': thread.id, + 'thread_title': thread.title, + 'thread_username': thread.username, + 'thread_author_id': thread.user_id, + 'thread_created_at': thread.created_at, + 'thread_commentable_id': thread.commentable_id, + } + tasks.send_ace_message.apply_async(args=[context]) diff --git a/lms/djangoapps/discussion/tasks.py b/lms/djangoapps/discussion/tasks.py new file mode 100644 index 0000000000..251152f363 --- /dev/null +++ b/lms/djangoapps/discussion/tasks.py @@ -0,0 +1,92 @@ +""" +Defines asynchronous celery task for sending email notification (through edx-ace) +pertaining to new discussion forum comments. +""" +import logging +from urlparse import urljoin + +from celery import task +from django.conf import settings +from django.contrib.auth.models import User +from django.contrib.sites.models import Site + +from celery_utils.logged_task import LoggedTask +from edx_ace import ace +from edx_ace.message import MessageType +from edx_ace.recipient import Recipient +from opaque_keys.edx.keys import CourseKey +from lms.djangoapps.django_comment_client.utils import permalink +import lms.lib.comment_client as cc +from lms.lib.comment_client.utils import merge_dict + +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.djangoapps.schedules.template_context import get_base_template_context + + +log = logging.getLogger(__name__) + + +DEFAULT_LANGUAGE = 'en' +ROUTING_KEY = getattr(settings, 'ACE_ROUTING_KEY', None) + + +class ResponseNotification(MessageType): + def __init__(self, *args, **kwargs): + super(ResponseNotification, self).__init__(*args, **kwargs) + self.name = 'response_notification' + + +@task(base=LoggedTask, routing_key=ROUTING_KEY) +def send_ace_message(context): + context['course_id'] = CourseKey.from_string(context['course_id']) + + if _should_send_message(context): + thread_author = User.objects.get(id=context['thread_author_id']) + message_context = _build_message_context(context) + message = ResponseNotification().personalize( + Recipient(thread_author.username, thread_author.email), + _get_course_language(context['course_id']), + message_context + ) + log.info('Sending forum comment email notification with context %s', message_context) + ace.send(message) + + +def _should_send_message(context): + cc_thread_author = cc.User(id=context['thread_author_id'], course_id=context['course_id']) + return _is_user_subscribed_to_thread(cc_thread_author, context['thread_id']) + + +def _is_user_subscribed_to_thread(cc_user, thread_id): + paginated_result = cc_user.subscribed_threads() + thread_ids = {thread['id'] for thread in paginated_result.collection} + + while paginated_result.page < paginated_result.num_pages: + next_page = paginated_result.page + 1 + paginated_result = cc_user.subscribed_threads(query_params={'page': next_page}) + thread_ids.update(thread['id'] for thread in paginated_result.collection) + + return thread_id in thread_ids + + +def _get_course_language(course_id): + course_overview = CourseOverview.objects.get(id=course_id) + language = course_overview.language or DEFAULT_LANGUAGE + return language + + +def _build_message_context(context): + message_context = get_base_template_context(Site.objects.get_current()) + message_context.update(context) + message_context['post_link'] = _get_thread_url(context) + return message_context + + +def _get_thread_url(context): + thread_content = { + 'type': 'thread', + 'course_id': context['course_id'], + 'commentable_id': context['thread_commentable_id'], + 'id': context['thread_id'], + } + return urljoin(settings.LMS_ROOT_URL, permalink(thread_content)) diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/response_notification/email/body.html b/lms/djangoapps/discussion/templates/discussion/edx_ace/response_notification/email/body.html new file mode 100644 index 0000000000..2a1632784f --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/response_notification/email/body.html @@ -0,0 +1,58 @@ +{% load i18n %} +{% load static %} + +{% block preview_text %} + {% blocktrans trimmed %} + Hi {{ thread_username }} + {% endblocktrans %} +{% endblock %} + +{% block content %} + + + + +
+ {% blocktrans trimmed %} +

+ Hi {{ thread_username }}, +

+ +

+ {{ comment_username }} made the following reply to {{ thread_title }} at {{ comment_created_at }}. +

+ +

+ {{ comment_body }} +

+ +

+ View the discussion. +

+ {% endblocktrans %} + +

+ {# email client support for style sheets is pretty spotty, so we have to inline all of these styles #} + + +

+
+{% endblock %} diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/response_notification/email/body.txt b/lms/djangoapps/discussion/templates/discussion/edx_ace/response_notification/email/body.txt new file mode 100644 index 0000000000..329cc9196e --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/response_notification/email/body.txt @@ -0,0 +1,5 @@ +{% load i18n %} + +{% blocktrans trimmed %} + This is the reply to your thread: +{% endblocktrans %} diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/response_notification/email/from_name.txt b/lms/djangoapps/discussion/templates/discussion/edx_ace/response_notification/email/from_name.txt new file mode 100644 index 0000000000..dcbc23c004 --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/response_notification/email/from_name.txt @@ -0,0 +1 @@ +{{ platform_name }} diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/response_notification/email/head.html b/lms/djangoapps/discussion/templates/discussion/edx_ace/response_notification/email/head.html new file mode 100644 index 0000000000..cf607a4521 --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/response_notification/email/head.html @@ -0,0 +1,29 @@ + +{% block title %}Reply to {{ thread_title }} at {{ comment_created_at }} {% endblock %} + + + \ No newline at end of file diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/response_notification/email/subject.txt b/lms/djangoapps/discussion/templates/discussion/edx_ace/response_notification/email/subject.txt new file mode 100644 index 0000000000..402b2aad45 --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/response_notification/email/subject.txt @@ -0,0 +1,5 @@ +{% load i18n %} + +{% blocktrans %} +Someone replied to your thread on {{ platform_name }} +{% endblocktrans %} diff --git a/lms/djangoapps/discussion/tests/test_tasks.py b/lms/djangoapps/discussion/tests/test_tasks.py new file mode 100644 index 0000000000..cb2702f259 --- /dev/null +++ b/lms/djangoapps/discussion/tests/test_tasks.py @@ -0,0 +1,151 @@ +""" +Tests the execution of forum notification tasks. +""" +from contextlib import contextmanager +from datetime import datetime, timedelta +import json +import math +from urlparse import urljoin + +import ddt +from django.conf import settings +from django.contrib.sites.models import Site +import mock + +from django_comment_common.models import ForumsConfig +from django_comment_common.signals import comment_created +from edx_ace.recipient import Recipient +from lms.djangoapps.discussion.config.waffle import waffle, FORUM_RESPONSE_NOTIFICATIONS +from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory +from openedx.core.djangoapps.schedules.template_context import get_base_template_context +from student.tests.factories import CourseEnrollmentFactory, UserFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase + + +@contextmanager +def mock_the_things(): + thread_permalink = '/courses/discussion/dummy_discussion_id' + with mock.patch('requests.request') as mock_request, mock.patch('edx_ace.ace.send') as mock_ace_send: + with mock.patch('lms.djangoapps.discussion.tasks.permalink', return_value=thread_permalink) as mock_permalink: + with mock.patch('lms.djangoapps.discussion.tasks.cc.Thread'): + yield (mock_request, mock_ace_send, mock_permalink) + + +def make_mock_responder(thread_ids, per_page=1): + collection = [ + {'id': thread_id} for thread_id in thread_ids + ] + + def mock_response(*args, **kwargs): + page = kwargs.get('params', {}).get('page', 1) + start_index = per_page * (page - 1) + end_index = per_page * page + data = { + 'collection': collection[start_index: end_index], + 'page': page, + 'num_pages': int(math.ceil(len(collection) / float(per_page))), + 'thread_count': len(collection) + } + return mock.Mock(status_code=200, text=json.dumps(data), json=mock.Mock(return_value=data)) + return mock_response + + +@ddt.ddt +class TaskTestCase(ModuleStoreTestCase): + + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + super(TaskTestCase, self).setUp() + + self.discussion_id = 'dummy_discussion_id' + self.course = CourseOverviewFactory.create(language='fr') + + # Patch the comment client user save method so it does not try + # to create a new cc user when creating a django user + with mock.patch('student.models.cc.User.save'): + + self.thread_author = UserFactory( + username='thread_author', + password='password', + email='email' + ) + self.comment_author = UserFactory( + username='comment_author', + password='password', + email='email' + ) + + CourseEnrollmentFactory( + user=self.thread_author, + course_id=self.course.id + ) + CourseEnrollmentFactory( + user=self.comment_author, + course_id=self.course.id + ) + + config = ForumsConfig.current() + config.enabled = True + config.save() + + @ddt.data(True, False) + def test_send_discussion_email_notification(self, user_subscribed): + with mock_the_things() as mocked_items: + mock_request, mock_ace_send, mock_permalink = mocked_items + if user_subscribed: + non_matching_id = 'not-a-match' + # with per_page left with a default value of 1, this ensures + # that we test a multiple page result when calling + # comment_client.User.subscribed_threads() + mock_request.side_effect = make_mock_responder([non_matching_id, self.discussion_id]) + else: + mock_request.side_effect = make_mock_responder([]) + + now = datetime.utcnow() + one_hour_ago = now - timedelta(hours=1) + thread = mock.Mock( + id=self.discussion_id, + course_id=self.course.id, + created_at=one_hour_ago, + title='thread-title', + user_id=self.thread_author.id, + username=self.thread_author.username, + commentable_id='thread-commentable-id' + ) + comment = mock.Mock( + id='comment-id', + body='comment-body', + created_at=now, + thread=thread, + user_id=self.comment_author.id, + username=self.comment_author.username + ) + user = mock.Mock() + + with waffle().override(FORUM_RESPONSE_NOTIFICATIONS): + comment_created.send(sender=None, user=user, post=comment) + + if user_subscribed: + expected_message_context = get_base_template_context(Site.objects.get_current()) + expected_message_context.update({ + 'comment_author_id': self.comment_author.id, + 'comment_body': 'comment-body', + 'comment_created_at': now, + 'comment_id': 'comment-id', + 'comment_username': self.comment_author.username, + 'course_id': self.course.id, + 'thread_author_id': self.thread_author.id, + 'thread_created_at': one_hour_ago, + 'thread_id': self.discussion_id, + 'thread_title': 'thread-title', + 'thread_username': self.thread_author.username, + 'thread_commentable_id': 'thread-commentable-id', + 'post_link': urljoin(settings.LMS_ROOT_URL, mock_permalink.return_value), + }) + expected_recipient = Recipient(self.thread_author.username, self.thread_author.email) + actual_message = mock_ace_send.call_args_list[0][0][0] + self.assertEqual(expected_message_context, actual_message.context) + self.assertEqual(expected_recipient, actual_message.recipient) + self.assertEqual(self.course.language, actual_message.language) + else: + self.assertFalse(mock_ace_send.called)