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)