EDUCATOR-1572 | Send an ACE message to thread author when new comment signal is received.

This commit is contained in:
Alex Dusenbery
2017-10-24 16:30:58 -04:00
committed by Alex Dusenbery
parent 9920a3247e
commit fedf35c36e
8 changed files with 359 additions and 5 deletions

View File

@@ -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])

View File

@@ -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))

View File

@@ -0,0 +1,58 @@
{% load i18n %}
{% load static %}
{% block preview_text %}
{% blocktrans trimmed %}
Hi {{ thread_username }}
{% endblocktrans %}
{% endblock %}
{% block content %}
<table width="100%" align="left" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td>
{% blocktrans trimmed %}
<h1>
Hi {{ thread_username }},
</h1>
<p>
{{ comment_username }} made the following reply to {{ thread_title }} at {{ comment_created_at }}.
</p>
<p>
{{ comment_body }}
</p>
<p>
<a href="{{ post_link }}"> View the discussion.</a>
</p>
{% endblocktrans %}
<p>
{# email client support for style sheets is pretty spotty, so we have to inline all of these styles #}
<a
{% if course_ids|length == 1 %}
href="{{ upsell_link }}"
{% else %}
href="{{ dashboard_url }}"
{% endif %}
style="
color: #ffffff;
text-decoration: none;
border-radius: 4px;
-webkit-border-radius: 4px;
-moz-border-radius: 4px;
background-color: #005686;
border-top: 10px solid #005686;
border-bottom: 10px solid #005686;
border-right: 16px solid #005686;
border-left: 16px solid #005686;
display: inline-block;
">
</a>
</p>
</td>
</tr>
</table>
{% endblock %}

View File

@@ -0,0 +1,5 @@
{% load i18n %}
{% blocktrans trimmed %}
This is the reply to your thread:
{% endblocktrans %}

View File

@@ -0,0 +1,29 @@
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>{% block title %}Reply to {{ thread_title }} at {{ comment_created_at }} {% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<style type="text/css">
@media only screen and (min-device-width: 601px) {
.content {
width: 600px !important;
}
}
@-ms-viewport{
width: device-width;
}
/* Column Drop Layout Pattern CSS */
@media only screen and (max-width: 450px) {
td[class="col"] {
display: block;
width: 100%;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
float: left;
text-align: left !important;
padding-bottom: 20px;
}
}
</style>

View File

@@ -0,0 +1,5 @@
{% load i18n %}
{% blocktrans %}
Someone replied to your thread on {{ platform_name }}
{% endblocktrans %}

View File

@@ -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)