EDUCATOR-1572 | Send an ACE message to thread author when new comment signal is received.
This commit is contained in:
committed by
Alex Dusenbery
parent
9920a3247e
commit
fedf35c36e
@@ -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])
|
||||
|
||||
92
lms/djangoapps/discussion/tasks.py
Normal file
92
lms/djangoapps/discussion/tasks.py
Normal 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))
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1,5 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% blocktrans trimmed %}
|
||||
This is the reply to your thread:
|
||||
{% endblocktrans %}
|
||||
@@ -0,0 +1 @@
|
||||
{{ platform_name }}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% blocktrans %}
|
||||
Someone replied to your thread on {{ platform_name }}
|
||||
{% endblocktrans %}
|
||||
151
lms/djangoapps/discussion/tests/test_tasks.py
Normal file
151
lms/djangoapps/discussion/tests/test_tasks.py
Normal 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)
|
||||
Reference in New Issue
Block a user