Commit c160a189 by Jason Bau Committed by Sarina Canelake

Bulk email - final tweaks and cleanup

parent 8f93051d
...@@ -27,24 +27,16 @@ log = get_task_logger(__name__) ...@@ -27,24 +27,16 @@ log = get_task_logger(__name__)
@task(default_retry_delay=10, max_retries=5) # pylint: disable=E1102 @task(default_retry_delay=10, max_retries=5) # pylint: disable=E1102
def delegate_email_batches(email_id, to_option, course_id, course_url, user_id): def delegate_email_batches(email_id, user_id):
""" """
Delegates emails by querying for the list of recipients who should Delegates emails by querying for the list of recipients who should
get the mail, chopping up into batches of settings.EMAILS_PER_TASK size, get the mail, chopping up into batches of settings.EMAILS_PER_TASK size,
and queueing up worker jobs. and queueing up worker jobs.
`to_option` is {'myself', 'staff', or 'all'}
Returns the number of batches (workers) kicked off. Returns the number of batches (workers) kicked off.
""" """
try: try:
course = get_course_by_id(course_id) email_obj = CourseEmail.objects.get(id=email_id)
except Http404 as exc:
log.error("get_course_by_id failed: %s", exc.args[0])
raise Exception("get_course_by_id failed: " + exc.args[0])
try:
CourseEmail.objects.get(id=email_id)
except CourseEmail.DoesNotExist as exc: except CourseEmail.DoesNotExist as exc:
# The retry behavior here is necessary because of a race condition between the commit of the transaction # The retry behavior here is necessary because of a race condition between the commit of the transaction
# that creates this CourseEmail row and the celery pipeline that starts this task. # that creates this CourseEmail row and the celery pipeline that starts this task.
...@@ -87,7 +79,6 @@ def delegate_email_batches(email_id, to_option, course_id, course_url, user_id): ...@@ -87,7 +79,6 @@ def delegate_email_batches(email_id, to_option, course_id, course_url, user_id):
log.error("Unexpected bulk email TO_OPTION found: %s", to_option) log.error("Unexpected bulk email TO_OPTION found: %s", to_option)
raise Exception("Unexpected bulk email TO_OPTION found: {0}".format(to_option)) raise Exception("Unexpected bulk email TO_OPTION found: {0}".format(to_option))
image_url = course_image_url(course)
recipient_qset = recipient_qset.order_by('pk') recipient_qset = recipient_qset.order_by('pk')
total_num_emails = recipient_qset.count() total_num_emails = recipient_qset.count()
num_queries = int(math.ceil(float(total_num_emails) / float(settings.EMAILS_PER_QUERY))) num_queries = int(math.ceil(float(total_num_emails) / float(settings.EMAILS_PER_QUERY)))
...@@ -135,9 +126,10 @@ def course_email(email_id, to_list, course_title, course_url, image_url, throttl ...@@ -135,9 +126,10 @@ def course_email(email_id, to_list, course_title, course_url, image_url, throttl
user__in=[i['pk'] for i in to_list]) user__in=[i['pk'] for i in to_list])
.values_list('user__email', flat=True)) .values_list('user__email', flat=True))
optouts = set(optouts)
num_optout = len(optouts) num_optout = len(optouts)
to_list = filter(lambda x: x['email'] not in set(optouts), to_list) to_list = filter(lambda x: x['email'] not in optouts, to_list)
subject = "[" + course_title + "] " + msg.subject subject = "[" + course_title + "] " + msg.subject
...@@ -208,6 +200,9 @@ def course_email(email_id, to_list, course_title, course_url, image_url, throttl ...@@ -208,6 +200,9 @@ def course_email(email_id, to_list, course_title, course_url, image_url, throttl
except (SMTPDataError, SMTPConnectError, SMTPServerDisconnected) as exc: except (SMTPDataError, SMTPConnectError, SMTPServerDisconnected) as exc:
# Error caught here cause the email to be retried. The entire task is actually retried without popping the list # Error caught here cause the email to be retried. The entire task is actually retried without popping the list
# Reasoning is that all of these errors may be temporary condition.
log.warning('Email with id %d not delivered due to temporary error %s, retrying send to %d recipients',
email_id, exc, len(to_list))
raise course_email.retry( raise course_email.retry(
arg=[ arg=[
email_id, email_id,
...@@ -220,6 +215,11 @@ def course_email(email_id, to_list, course_title, course_url, image_url, throttl ...@@ -220,6 +215,11 @@ def course_email(email_id, to_list, course_title, course_url, image_url, throttl
exc=exc, exc=exc,
countdown=(2 ** current_task.request.retries) * 15 countdown=(2 ** current_task.request.retries) * 15
) )
except:
log.exception('Email with id %d caused course_email task to fail with uncaught exception. To list: %s',
email_id,
[i['email'] for i in to_list])
raise
# This string format code is wrapped in this function to allow mocking for a unit test # This string format code is wrapped in this function to allow mocking for a unit test
......
...@@ -12,14 +12,20 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase ...@@ -12,14 +12,20 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from student.tests.factories import UserFactory, AdminFactory, CourseEnrollmentFactory from student.tests.factories import UserFactory, AdminFactory, CourseEnrollmentFactory
from bulk_email.models import CourseEmail
from bulk_email.tasks import delegate_email_batches
from bulk_email.tests.smtp_server_thread import FakeSMTPServerThread from bulk_email.tests.smtp_server_thread import FakeSMTPServerThread
from mock import patch from mock import patch, Mock
from smtplib import SMTPDataError, SMTPServerDisconnected, SMTPConnectError from smtplib import SMTPDataError, SMTPServerDisconnected, SMTPConnectError
TEST_SMTP_PORT = 1025 TEST_SMTP_PORT = 1025
class EmailTestException(Exception):
pass
@override_settings( @override_settings(
MODULESTORE=TEST_DATA_MONGO_MODULESTORE, MODULESTORE=TEST_DATA_MONGO_MODULESTORE,
EMAIL_BACKEND='django.core.mail.backends.smtp.EmailBackend', EMAIL_BACKEND='django.core.mail.backends.smtp.EmailBackend',
...@@ -33,8 +39,8 @@ class TestEmailErrors(ModuleStoreTestCase): ...@@ -33,8 +39,8 @@ class TestEmailErrors(ModuleStoreTestCase):
def setUp(self): def setUp(self):
self.course = CourseFactory.create() self.course = CourseFactory.create()
instructor = AdminFactory.create() self.instructor = AdminFactory.create()
self.client.login(username=instructor.username, password="test") self.client.login(username=self.instructor.username, password="test")
# load initial content (since we don't run migrations as part of tests): # load initial content (since we don't run migrations as part of tests):
call_command("loaddata", "course_email_template.json") call_command("loaddata", "course_email_template.json")
...@@ -145,3 +151,68 @@ class TestEmailErrors(ModuleStoreTestCase): ...@@ -145,3 +151,68 @@ class TestEmailErrors(ModuleStoreTestCase):
(_, kwargs) = retry.call_args (_, kwargs) = retry.call_args
exc = kwargs['exc'] exc = kwargs['exc']
self.assertTrue(type(exc) == SMTPConnectError) self.assertTrue(type(exc) == SMTPConnectError)
@patch('bulk_email.tasks.course_email_result')
@patch('bulk_email.tasks.course_email.retry')
@patch('bulk_email.tasks.log')
@patch('bulk_email.tasks.get_connection', Mock(return_value=EmailTestException))
def test_general_exception(self, mock_log, retry, result):
"""
Tests the if the error is not SMTP-related, we log and reraise
"""
test_email = {
'action': 'Send email',
'to_option': 'myself',
'subject': 'test subject for myself',
'message': 'test message for myself'
}
# For some reason (probably the weirdness of testing with celery tasks) assertRaises doesn't work here
# so we assert on the arguments of log.exception
self.client.post(self.url, test_email)
((log_str, email_id, to_list), _) = mock_log.exception.call_args
self.assertTrue(mock_log.exception.called)
self.assertIn('caused course_email task to fail with uncaught exception.', log_str)
self.assertEqual(email_id, 1)
self.assertEqual(to_list, [self.instructor.email])
self.assertFalse(retry.called)
self.assertFalse(result.called)
@patch('bulk_email.tasks.course_email_result')
@patch('bulk_email.tasks.delegate_email_batches.retry')
@patch('bulk_email.tasks.log')
def test_nonexist_email(self, mock_log, retry, result):
"""
Tests retries when the email doesn't exist
"""
delegate_email_batches.delay(-1, self.instructor.id)
((log_str, email_id, num_retries), _) = mock_log.warning.call_args
self.assertTrue(mock_log.warning.called)
self.assertIn('Failed to get CourseEmail with id', log_str)
self.assertEqual(email_id, -1)
self.assertTrue(retry.called)
self.assertFalse(result.called)
@patch('bulk_email.tasks.log')
def test_nonexist_course(self, mock_log):
"""
Tests exception when the course in the email doesn't exist
"""
email = CourseEmail(course_id="I/DONT/EXIST")
email.save()
delegate_email_batches.delay(email.id, self.instructor.id)
((log_str, _), _) = mock_log.exception.call_args
self.assertTrue(mock_log.exception.called)
self.assertIn('get_course_by_id failed:', log_str)
@patch('bulk_email.tasks.log')
def test_nonexist_to_option(self, mock_log):
"""
Tests exception when the to_option in the email doesn't exist
"""
email = CourseEmail(course_id=self.course.id, to_option="IDONTEXIST")
email.save()
delegate_email_batches.delay(email.id, self.instructor.id)
((log_str, opt_str), _) = mock_log.error.call_args
self.assertTrue(mock_log.error.called)
self.assertIn('Unexpected bulk email TO_OPTION found', log_str)
self.assertEqual("IDONTEXIST", opt_str)
...@@ -99,12 +99,12 @@ class TestStudentDashboardEmailView(ModuleStoreTestCase): ...@@ -99,12 +99,12 @@ class TestStudentDashboardEmailView(ModuleStoreTestCase):
# URL for dashboard # URL for dashboard
self.url = reverse('dashboard') self.url = reverse('dashboard')
# URL for email settings modal # URL for email settings modal
self.email_modal_link = '<a href="#email-settings-modal" class="email-settings" rel="leanModal" data-course-id="{0}/{1}/{2}" data-course-number="{1}" data-optout="False">Email Settings</a>'.format( self.email_modal_link = (('<a href="#email-settings-modal" class="email-settings" rel="leanModal" '
self.course.org, 'data-course-id="{0}/{1}/{2}" data-course-number="{1}" '
self.course.number, 'data-optout="False">Email Settings</a>')
self.course.display_name.replace(' ', '_') .format(self.course.org,
) self.course.number,
self.course.display_name.replace(' ', '_')))
def tearDown(self): def tearDown(self):
""" """
...@@ -118,7 +118,6 @@ class TestStudentDashboardEmailView(ModuleStoreTestCase): ...@@ -118,7 +118,6 @@ class TestStudentDashboardEmailView(ModuleStoreTestCase):
response = self.client.get(self.url) response = self.client.get(self.url)
self.assertTrue(self.email_modal_link in response.content) self.assertTrue(self.email_modal_link in response.content)
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': False}) @patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': False})
def test_email_flag_false(self): def test_email_flag_false(self):
# Assert that the URL for the email view is not in the response # Assert that the URL for the email view is not in the response
......
...@@ -84,8 +84,8 @@ def instructor_dashboard(request, course_id): ...@@ -84,8 +84,8 @@ def instructor_dashboard(request, course_id):
msg = '' msg = ''
email_msg = '' email_msg = ''
to_option = None email_to_option = None
subject = None email_subject = None
html_message = '' html_message = ''
show_email_tab = False show_email_tab = False
problems = [] problems = []
...@@ -703,30 +703,26 @@ def instructor_dashboard(request, course_id): ...@@ -703,30 +703,26 @@ def instructor_dashboard(request, course_id):
# email # email
elif action == 'Send email': elif action == 'Send email':
to_option = request.POST.get("to_option") email_to_option = request.POST.get("to_option")
subject = request.POST.get("subject") email_subject = request.POST.get("subject")
html_message = request.POST.get("message") html_message = request.POST.get("message")
text_message = html_to_text(html_message) text_message = html_to_text(html_message)
email = CourseEmail(course_id=course_id, email = CourseEmail(course_id=course_id,
sender=request.user, sender=request.user,
to_option=to_option, to_option=email_to_option,
subject=subject, subject=email_subject,
html_message=html_message, html_message=html_message,
text_message=text_message) text_message=text_message)
email.save() email.save()
course_url = request.build_absolute_uri(reverse('course_root', kwargs={'course_id': course_id}))
tasks.delegate_email_batches.delay( tasks.delegate_email_batches.delay(
email.id, email.id,
email.to_option,
course_id,
course_url,
request.user.id request.user.id
) )
if to_option == "all": if email_to_option == "all":
email_msg = '<div class="msg msg-confirm"><p class="copy">Your email was successfully queued for sending. Please note that for large public classes (~10k), it may take 1-2 hours to send all emails.</p></div>' email_msg = '<div class="msg msg-confirm"><p class="copy">Your email was successfully queued for sending. Please note that for large public classes (~10k), it may take 1-2 hours to send all emails.</p></div>'
else: else:
email_msg = '<div class="msg msg-confirm"><p class="copy">Your email was successfully queued for sending.</p></div>' email_msg = '<div class="msg msg-confirm"><p class="copy">Your email was successfully queued for sending.</p></div>'
...@@ -799,9 +795,9 @@ def instructor_dashboard(request, course_id): ...@@ -799,9 +795,9 @@ def instructor_dashboard(request, course_id):
# HTML editor for email # HTML editor for email
if idash_mode == 'Email': if idash_mode == 'Email':
html_module = HtmlDescriptor(course.system, {'data': html_message}) html_module = HtmlDescriptor(course.system, {'data': html_message})
editor = wrap_xmodule(html_module.get_html, html_module, 'xmodule_edit.html')() email_editor = wrap_xmodule(html_module.get_html, html_module, 'xmodule_edit.html')()
else: else:
editor = None email_editor = None
# Flag for whether or not we display the email tab (depending upon # Flag for whether or not we display the email tab (depending upon
# what backing store this course using (Mongo vs. XML)) # what backing store this course using (Mongo vs. XML))
...@@ -825,11 +821,13 @@ def instructor_dashboard(request, course_id): ...@@ -825,11 +821,13 @@ def instructor_dashboard(request, course_id):
'course_stats': course_stats, 'course_stats': course_stats,
'msg': msg, 'msg': msg,
'modeflag': {idash_mode: 'selectedmode'}, 'modeflag': {idash_mode: 'selectedmode'},
'to_option': to_option, # email
'subject': subject, # email 'to_option': email_to_option, # email
'editor': editor, # email 'subject': email_subject, # email
'editor': email_editor, # email
'email_msg': email_msg, # email 'email_msg': email_msg, # email
'show_email_tab': show_email_tab, # email 'show_email_tab': show_email_tab, # email
'problems': problems, # psychometrics 'problems': problems, # psychometrics
'plots': plots, # psychometrics 'plots': plots, # psychometrics
'course_errors': modulestore().get_item_errors(course.location), 'course_errors': modulestore().get_item_errors(course.location),
......
<%! from django.core.urlresolvers import reverse %>
<br />
----<br />
This email was automatically sent from ${settings.PLATFORM_NAME}. <br />
You are receiving this email at address ${ email } because you are enrolled in <a href="${course_url}">${ course_title }</a>.<br />
To stop receiving email like this, update your course email settings <a href="https://${settings.SITE_NAME}${reverse('dashboard')}">here</a>. <br />
<%! from django.core.urlresolvers import reverse %>
----
This email was automatically sent from ${settings.PLATFORM_NAME}.
You are receiving this email at address ${ email } because you are enrolled in ${ course_title }
(URL: ${course_url} ).
To stop receiving email like this, update your account settings at https://${settings.SITE_NAME}${reverse('dashboard')}.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment