Commit 907bf6e1 by Kevin Luo Committed by Sarina Canelake

Add bulk email feature for instructors, with optout option

Adds a new Email link to the instructor dashboard for frontend interface to send
email to course members. Adds a feature flag ENABLE_INSTRUCTOR_EMAIL to toggle this.
Creates a new djangoapp bulk_email that handles this action by getting the recipient
list and batching the emails to different celery tasks to do the actual sending.
Requires lynx package to convert HTML email to plaintext. Handles SMTP errors by
retrying or falling through to the next email. Adds the option to opt out of course
specific emails in the user dashboard with an Email Settings link for each course.
Uses severable configurable settings with defaults. DEFAULT_BULK_FROM_EMAIL
specifies the from address for email. EMAILS_PER_TASK specifies the number of emails
each celery task takes on. EMAIL_HOST, EMAIL_PORT, EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD, and EMAIL_USE_TLS for the SMTP email backend settings.

Co-authored-by: Akshay Jagadeesh <akjags@gmail.com>
parent bfca803b
......@@ -84,3 +84,5 @@ Mukul Goyal <miki@edx.org>
Robert Marks <rmarks@edx.org>
Yarko Tymciurak <yarkot1@gmail.com>
Miles Steele <miles@milessteele.com>
Kevin Luo <kevluo@edx.org>
Akshay Jagadeesh <akjags@gmail.com>
......@@ -33,6 +33,9 @@ logic has been consolidated into the model -- you should use new class methods
to `enroll()`, `unenroll()`, and to check `is_enrolled()`, instead of creating
CourseEnrollment objects or querying them directly.
LMS: Added bulk email for course feature, with option to optout of individual
course emails.
Studio: Email will be sent to admin address when a user requests course creator
privileges for Studio (edge only).
......
......@@ -54,6 +54,10 @@ from courseware.access import has_access
from external_auth.models import ExternalAuthMap
from bulk_email.models import Optout
import track.views
from statsd import statsd
from pytz import UTC
......@@ -267,6 +271,8 @@ def dashboard(request):
log.error("User {0} enrolled in non-existent course {1}"
.format(user.username, enrollment.course_id))
course_optouts = Optout.objects.filter(email=user.email).values_list('course_id', flat=True)
message = ""
if not user.is_active:
message = render_to_string('registration/activate_account_notice.html', {'email': user.email})
......@@ -294,6 +300,7 @@ def dashboard(request):
pass
context = {'courses': courses,
'course_optouts': course_optouts,
'message': message,
'external_auth_map': external_auth_map,
'staff_access': staff_access,
......@@ -1272,3 +1279,27 @@ def accept_name_change(request):
raise Http404
return accept_name_change_by_id(int(request.POST['id']))
@ensure_csrf_cookie
def change_email_settings(request):
"""Modify logged-in user's setting for receiving emails from a course."""
if request.method != "POST":
return HttpResponseNotAllowed(["POST"])
user = request.user
if not user.is_authenticated():
return HttpResponseForbidden()
course_id = request.POST.get("course_id")
receive_emails = request.POST.get("receive_emails")
if receive_emails:
Optout.objects.filter(email=user.email, course_id=course_id).delete()
log.info(u"User {0} ({1}) opted to receive emails from course {2}".format(user.username, user.email, course_id))
track.views.server_track(request, "change-email-settings", {"receive_emails": "yes", "course": course_id}, page='dashboard')
else:
Optout.objects.get_or_create(email=request.user.email, course_id=course_id)
log.info(u"User {0} ({1}) opted out of receiving emails from course {2}".format(user.username, user.email, course_id))
track.views.server_track(request, "change-email-settings", {"receive_emails": "no", "course": course_id}, page='dashboard')
return HttpResponse(json.dumps({'success': True}))
from django.contrib import admin
from bulk_email.models import CourseEmail, Optout
admin.site.register(Optout)
class CourseEmailAdmin(admin.ModelAdmin):
readonly_fields = ('sender',)
admin.site.register(CourseEmail, CourseEmailAdmin)
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'CourseEmail'
db.create_table('bulk_email_courseemail', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('sender', self.gf('django.db.models.fields.related.ForeignKey')(default=1, to=orm['auth.User'], null=True, blank=True)),
('hash', self.gf('django.db.models.fields.CharField')(max_length=128, db_index=True)),
('subject', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)),
('html_message', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
('modified', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)),
('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
('to', self.gf('django.db.models.fields.CharField')(default='myself', max_length=64)),
))
db.send_create_signal('bulk_email', ['CourseEmail'])
# Adding model 'Optout'
db.create_table('bulk_email_optout', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('email', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
))
db.send_create_signal('bulk_email', ['Optout'])
# Adding unique constraint on 'Optout', fields ['email', 'course_id']
db.create_unique('bulk_email_optout', ['email', 'course_id'])
def backwards(self, orm):
# Removing unique constraint on 'Optout', fields ['email', 'course_id']
db.delete_unique('bulk_email_optout', ['email', 'course_id'])
# Deleting model 'CourseEmail'
db.delete_table('bulk_email_courseemail')
# Deleting model 'Optout'
db.delete_table('bulk_email_optout')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'bulk_email.courseemail': {
'Meta': {'object_name': 'CourseEmail'},
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'hash': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}),
'html_message': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
'sender': ('django.db.models.fields.related.ForeignKey', [], {'default': '1', 'to': "orm['auth.User']", 'null': 'True', 'blank': 'True'}),
'subject': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
'to': ('django.db.models.fields.CharField', [], {'default': "'myself'", 'max_length': '64'})
},
'bulk_email.optout': {
'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'Optout'},
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
}
}
complete_apps = ['bulk_email']
\ No newline at end of file
from django.db import models
from django.contrib.auth.models import User
class Email(models.Model):
"""
Abstract base class for common information for an email.
"""
sender = models.ForeignKey(User, default=1, blank=True, null=True)
hash = models.CharField(max_length=128, db_index=True)
subject = models.CharField(max_length=128, blank=True)
html_message = models.TextField(null=True, blank=True)
created = models.DateTimeField(auto_now_add=True)
modified = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
class CourseEmail(Email, models.Model):
"""
Stores information for an email to a course.
"""
TO_OPTIONS = (('myself', 'Myself'),
('staff', 'Staff and instructors'),
('all', 'All')
)
course_id = models.CharField(max_length=255, db_index=True)
to = models.CharField(max_length=64, choices=TO_OPTIONS, default='myself')
def __unicode__(self):
return self.subject
class Optout(models.Model):
"""
Stores emails that have opted out of receiving emails from a course.
"""
email = models.CharField(max_length=255, db_index=True)
course_id = models.CharField(max_length=255, db_index=True)
class Meta:
unique_together = ('email', 'course_id')
import logging
import math
import re
import time
from smtplib import SMTPServerDisconnected, SMTPDataError, SMTPConnectError
from subprocess import Popen, PIPE
from django.conf import settings
from django.contrib.auth.models import User, Group
from django.core.mail import EmailMultiAlternatives, get_connection
from django.http import Http404
from celery import task, current_task
from bulk_email.models import CourseEmail, Optout
from courseware.access import _course_staff_group_name, _course_instructor_group_name
from courseware.courses import get_course_by_id
from mitxmako.shortcuts import render_to_string
log = logging.getLogger(__name__)
@task()
def delegate_email_batches(hash_for_msg, recipient, course_id, course_url, user_id):
'''
Delegates emails by querying for the list of recipients who should
get the mail, chopping up into batches of settings.EMAILS_PER_TASK size,
and queueing up worker jobs.
Recipient is {'students', 'staff', or 'all'}
Returns the number of batches (workers) kicked off.
'''
try:
course = get_course_by_id(course_id)
except Http404 as exc:
log.error("get_course_by_id failed: " + exc.args[0])
raise Exception("get_course_by_id failed: " + exc.args[0])
if recipient == "myself":
recipient_qset = User.objects.filter(id=user_id).values('profile__name', 'email')
else:
staff_grpname = _course_staff_group_name(course.location)
staff_group, _ = Group.objects.get_or_create(name=staff_grpname)
staff_qset = staff_group.user_set.values('profile__name', 'email')
instructor_grpname = _course_instructor_group_name(course.location)
instructor_group, _ = Group.objects.get_or_create(name=instructor_grpname)
instructor_qset = instructor_group.user_set.values('profile__name', 'email')
recipient_qset = staff_qset | instructor_qset
if recipient == "all":
#Execute two queries per performance considerations for MySQL
#https://docs.djangoproject.com/en/1.2/ref/models/querysets/#in
course_optouts = Optout.objects.filter(course_id=course_id).values_list('email', flat=True)
enrollment_qset = User.objects.filter(courseenrollment__course_id=course_id).exclude(email__in=list(course_optouts)).values('profile__name', 'email')
recipient_qset = recipient_qset | enrollment_qset
recipient_qset = recipient_qset.distinct()
recipient_list = list(recipient_qset)
total_num_emails = recipient_qset.count()
num_workers = int(math.ceil(float(total_num_emails) / float(settings.EMAILS_PER_TASK)))
chunk = int(math.ceil(float(total_num_emails) / float(num_workers)))
for i in range(num_workers):
to_list = recipient_list[i * chunk:i * chunk + chunk]
course_email.delay(hash_for_msg, to_list, course.display_name, course_url, False)
return num_workers
@task(default_retry_delay=15, max_retries=5)
def course_email(hash_for_msg, to_list, course_title, course_url, throttle=False):
"""
Takes a subject and an html formatted email and sends it from
sender to all addresses in the to_list, with each recipient
being the only "to". Emails are sent multipart, in both plain
text and html.
"""
try:
msg = CourseEmail.objects.get(hash=hash_for_msg)
except CourseEmail.DoesNotExist as exc:
log.exception(exc.args[0])
raise exc
subject = "[" + course_title + "] " + msg.subject
process = Popen(['lynx', '-stdin', '-display_charset=UTF-8', '-assume_charset=UTF-8', '-dump'], stdin=PIPE, stdout=PIPE)
(plaintext, err_from_stderr) = process.communicate(input=msg.html_message.encode('utf-8')) # use lynx to get plaintext
course_title_no_quotes = re.sub(r'"', '', course_title)
from_addr = '"%s" Course Staff <%s>' % (course_title_no_quotes, settings.DEFAULT_BULK_FROM_EMAIL)
if err_from_stderr:
log.info(err_from_stderr)
try:
connection = get_connection()
connection.open()
num_sent = 0
num_error = 0
while to_list:
(name, email) = to_list[-1].values()
html_footer = render_to_string('emails/email_footer.html',
{'name': name,
'email': email,
'course_title': course_title,
'course_url': course_url})
plain_footer = render_to_string('emails/email_footer.txt',
{'name': name,
'email': email,
'course_title': course_title,
'course_url': course_url})
email_msg = EmailMultiAlternatives(subject, plaintext + plain_footer.encode('utf-8'), from_addr, [email], connection=connection)
email_msg.attach_alternative(msg.html_message + html_footer.encode('utf-8'), 'text/html')
if throttle or current_task.request.retries > 0: # throttle if we tried a few times and got the rate limiter
time.sleep(0.2)
try:
connection.send_messages([email_msg])
log.info('Email with hash ' + hash_for_msg + ' sent to ' + email)
num_sent += 1
except SMTPDataError as exc:
#According to SMTP spec, we'll retry error codes in the 4xx range. 5xx range indicates hard failure
if exc.smtp_code >= 400 and exc.smtp_code < 500:
raise exc # this will cause the outer handler to catch the exception and retry the entire task
else:
#this will fall through and not retry the message, since it will be popped
log.warn('Email with hash ' + hash_for_msg + ' not delivered to ' + email + ' due to error: ' + exc.smtp_error)
num_error += 1
to_list.pop()
connection.close()
return course_email_result(num_sent, num_error)
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
raise course_email.retry(arg=[hash_for_msg, to_list, course_title, course_url, current_task.request.retries > 0], exc=exc, countdown=(2 ** current_task.request.retries) * 15)
#This string format code is wrapped in this function to allow mocking for a unit test
def course_email_result(num_sent, num_error):
return "Sent %d, Fail %d" % (num_sent, num_error)
"""
Fake SMTP Server used for testing error handling for sending email.
We could have mocked smptlib to raise connection errors, but this simulates
connection errors from an SMTP server.
"""
import smtpd
import socket
import asyncore
import asynchat
import errno
class FakeSMTPChannel(smtpd.SMTPChannel):
"""
A fake SMTPChannel for sending fake error response through socket.
This causes smptlib to raise an SMTPConnectError.
Adapted from http://hg.python.org/cpython/file/2.7/Lib/smtpd.py
"""
# Disable pylint warnings that arise from subclassing SMTPChannel
# and calling init -- overriding SMTPChannel's init to return error
# message but keeping the rest of the class.
# pylint: disable=W0231, W0233
def __init__(self, server, conn, addr):
asynchat.async_chat.__init__(self, conn)
self.__server = server
self.__conn = conn
self.__addr = addr
self.__line = []
self.__state = self.COMMAND
self.__greeting = 0
self.__mailfrom = None
self.__rcpttos = []
self.__data = ''
self.__fqdn = socket.getfqdn()
try:
self.__peer = conn.getpeername()
except socket.error, err:
# a race condition may occur if the other end is closing
# before we can get the peername
self.close()
if err[0] != errno.ENOTCONN:
raise
return
self.push('421 SMTP Server error: too many concurrent sessions, please try again later.')
self.set_terminator('\r\n')
class FakeSMTPServer(smtpd.SMTPServer):
"""A fake SMTP server for generating different smptlib exceptions."""
def __init__(self, *args, **kwargs):
smtpd.SMTPServer.__init__(self, *args, **kwargs)
self.errtype = None
self.reply = None
def set_errtype(self, errtype, reply=''):
self.errtype = errtype
self.reply = reply
def handle_accept(self):
if self.errtype == "DISCONN":
self.accept()
elif self.errtype == "CONN":
pair = self.accept()
if pair is not None:
conn, addr = pair
_channel = FakeSMTPChannel(self, conn, addr)
else:
smtpd.SMTPServer.handle_accept(self)
def process_message(self, *_args, **_kwargs):
if self.errtype == "DATA":
#after failing on the first email, succeed on rest
self.errtype = None
return self.reply
else:
return None
def serve_forever(self):
asyncore.loop()
import threading
from bulk_email.tests.fake_smtp import FakeSMTPServer
class FakeSMTPServerThread(threading.Thread):
"""
Thread for running a fake SMTP server for testing email
"""
def __init__(self, host, port):
self.host = host
self.port = port
self.is_ready = threading.Event()
self.error = None
self.server = None
super(FakeSMTPServerThread, self).__init__()
def start(self):
self.daemon = True
super(FakeSMTPServerThread, self).start()
self.is_ready.wait()
if self.error:
raise self.error
def stop(self):
if hasattr(self, 'server'):
self.server.close()
self.join()
def run(self):
"""
Sets up the test smtp server and handle requests
"""
try:
self.server = FakeSMTPServer((self.host, self.port), None)
self.is_ready.set()
self.server.serve_forever()
except Exception, e:
self.error = e
self.is_ready.set()
"""
Unit tests for student optouts from course email
"""
import json
from django.core import mail
from django.test.utils import override_settings
from django.core.urlresolvers import reverse
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from student.tests.factories import UserFactory, AdminFactory, CourseEnrollmentFactory
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class TestOptoutCourseEmails(ModuleStoreTestCase):
def setUp(self):
self.course = CourseFactory.create()
self.instructor = AdminFactory.create()
self.student = UserFactory.create()
CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id)
self.client.login(username=self.student.username, password="test")
def test_optout_course(self):
"""
Make sure student does not receive course email after opting out.
"""
url = reverse('change_email_settings')
response = self.client.post(url, {'course_id': self.course.id})
self.assertEquals(json.loads(response.content), {'success': True})
self.client.logout()
self.client.login(username=self.instructor.username, password="test")
url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id})
response = self.client.post(url, {'action': 'Send email', 'to': 'all', 'subject': 'test subject for all', 'message': 'test message for all'})
self.assertContains(response, "Your email was successfully queued for sending.")
#assert that self.student.email not in mail.to, outbox should be empty
self.assertEqual(len(mail.outbox), 0)
def test_optin_course(self):
"""
Make sure student receives course email after opting in.
"""
url = reverse('change_email_settings')
response = self.client.post(url, {'course_id': self.course.id, 'receive_emails': 'on'})
self.assertEquals(json.loads(response.content), {'success': True})
self.client.logout()
self.client.login(username=self.instructor.username, password="test")
url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id})
response = self.client.post(url, {'action': 'Send email', 'to': 'all', 'subject': 'test subject for all', 'message': 'test message for all'})
self.assertContains(response, "Your email was successfully queued for sending.")
#assert that self.student.email in mail.to
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(len(mail.outbox[0].to), 1)
self.assertEquals(mail.outbox[0].to[0], self.student.email)
"""
Unit tests for sending course email
"""
from django.test.utils import override_settings
from django.core.urlresolvers import reverse
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from student.tests.factories import UserFactory, GroupFactory, CourseEnrollmentFactory
from django.core import mail
from bulk_email.tasks import delegate_email_batches, course_email
from bulk_email.models import CourseEmail
STAFF_COUNT = 3
STUDENT_COUNT = 10
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class TestEmail(ModuleStoreTestCase):
def setUp(self):
self.course = CourseFactory.create()
self.instructor = UserFactory.create(username="instructor", email="robot+instructor@edx.org")
#Create instructor group for course
instructor_group = GroupFactory.create(name="instructor_MITx/999/Robot_Super_Course")
instructor_group.user_set.add(self.instructor)
#create staff
self.staff = [UserFactory() for _ in xrange(STAFF_COUNT)]
staff_group = GroupFactory()
for staff in self.staff:
staff_group.user_set.add(staff)
#create students
self.students = [UserFactory() for _ in xrange(STUDENT_COUNT)]
for student in self.students:
CourseEnrollmentFactory.create(user=student, course_id=self.course.id)
self.client.login(username=self.instructor.username, password="test")
def test_send_to_self(self):
"""
Make sure email send to myself goes to myself.
"""
url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id})
response = self.client.post(url, {'action': 'Send email', 'to': 'myself', 'subject': 'test subject for myself', 'message': 'test message for myself'})
self.assertContains(response, "Your email was successfully queued for sending.")
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(len(mail.outbox[0].to), 1)
self.assertEquals(mail.outbox[0].to[0], self.instructor.email)
self.assertEquals(mail.outbox[0].subject, '[' + self.course.display_name + ']' + ' test subject for myself')
def test_send_to_staff(self):
"""
Make sure email send to staff and instructors goes there.
"""
url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id})
response = self.client.post(url, {'action': 'Send email', 'to': 'staff', 'subject': 'test subject for staff', 'message': 'test message for subject'})
self.assertContains(response, "Your email was successfully queued for sending.")
self.assertEquals(len(mail.outbox), 1 + len(self.staff))
self.assertItemsEqual([e.to[0] for e in mail.outbox], [self.instructor.email] + [s.email for s in self.staff])
def test_send_to_all(self):
"""
Make sure email send to all goes there.
"""
url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id})
response = self.client.post(url, {'action': 'Send email', 'to': 'all', 'subject': 'test subject for all', 'message': 'test message for all'})
self.assertContains(response, "Your email was successfully queued for sending.")
self.assertEquals(len(mail.outbox), 1 + len(self.staff) + len(self.students))
self.assertItemsEqual([e.to[0] for e in mail.outbox], [self.instructor.email] + [s.email for s in self.staff] + [s.email for s in self.students])
def test_get_course_exc(self):
"""
Make sure delegate_email_batches handles Http404 exception from get_course_by_id.
"""
with self.assertRaises(Exception):
delegate_email_batches("_", "_", "blah/blah/blah", "_", "_")
def test_no_course_email_obj(self):
"""
Make sure course_email handles CourseEmail.DoesNotExist exception.
"""
with self.assertRaises(CourseEmail.DoesNotExist):
course_email("dummy hash", [], "_", "_", False)
"""
Unit tests for handling email sending errors
"""
from django.test.utils import override_settings
from django.conf import settings
from django.core.urlresolvers import reverse
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from student.tests.factories import UserFactory, AdminFactory, CourseEnrollmentFactory
from bulk_email.tests.smtp_server_thread import FakeSMTPServerThread
from mock import patch
from smtplib import SMTPDataError, SMTPServerDisconnected, SMTPConnectError
TEST_SMTP_PORT = 1025
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE, EMAIL_BACKEND='django.core.mail.backends.smtp.EmailBackend', EMAIL_HOST='localhost', EMAIL_PORT=TEST_SMTP_PORT)
class TestEmailErrors(ModuleStoreTestCase):
def setUp(self):
self.course = CourseFactory.create()
instructor = AdminFactory.create()
self.client.login(username=instructor.username, password="test")
self.smtp_server_thread = FakeSMTPServerThread('localhost', TEST_SMTP_PORT)
self.smtp_server_thread.start()
def tearDown(self):
self.smtp_server_thread.stop()
@patch('bulk_email.tasks.course_email.retry')
def test_data_err_retry(self, retry):
"""
Test that celery handles transient SMTPDataErrors by retrying.
"""
self.smtp_server_thread.server.set_errtype("DATA", "454 Throttling failure: Daily message quota exceeded.")
url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id})
self.client.post(url, {'action': 'Send email', 'to': 'myself', 'subject': 'test subject for myself', 'message': 'test message for myself'})
self.assertTrue(retry.called)
(_, kwargs) = retry.call_args
exc = kwargs['exc']
self.assertTrue(type(exc) == SMTPDataError)
@patch('bulk_email.tasks.course_email_result')
@patch('bulk_email.tasks.course_email.retry')
def test_data_err_fail(self, retry, result):
"""
Test that celery handles permanent SMTPDataErrors by failing and not retrying.
"""
self.smtp_server_thread.server.set_errtype("DATA", "554 Message rejected: Email address is not verified.")
students = [UserFactory() for _ in xrange(settings.EMAILS_PER_TASK)]
for student in students:
CourseEnrollmentFactory.create(user=student, course_id=self.course.id)
url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id})
self.client.post(url, {'action': 'Send email', 'to': 'all', 'subject': 'test subject for all', 'message': 'test message for all'})
self.assertFalse(retry.called)
#test that after the failed email, the rest send successfully
((sent, fail), _) = result.call_args
self.assertEquals(fail, 1)
self.assertEquals(sent, settings.EMAILS_PER_TASK - 1)
@patch('bulk_email.tasks.course_email.retry')
def test_disconn_err_retry(self, retry):
"""
Test that celery handles SMTPServerDisconnected by retrying.
"""
self.smtp_server_thread.server.set_errtype("DISCONN", "Server disconnected, please try again later.")
url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id})
self.client.post(url, {'action': 'Send email', 'to': 'myself', 'subject': 'test subject for myself', 'message': 'test message for myself'})
self.assertTrue(retry.called)
(_, kwargs) = retry.call_args
exc = kwargs['exc']
self.assertTrue(type(exc) == SMTPServerDisconnected)
@patch('bulk_email.tasks.course_email.retry')
def test_conn_err_retry(self, retry):
"""
Test that celery handles SMTPConnectError by retrying.
"""
#SMTP reply is already specified in fake SMTP Channel created
self.smtp_server_thread.server.set_errtype("CONN")
url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id})
self.client.post(url, {'action': 'Send email', 'to': 'myself', 'subject': 'test subject for myself', 'message': 'test message for myself'})
self.assertTrue(retry.called)
(_, kwargs) = retry.call_args
exc = kwargs['exc']
self.assertTrue(type(exc) == SMTPConnectError)
"""
Unit tests for email feature flag in instructor dashboard
"""
from django.test.utils import override_settings
from django.conf import settings
from django.core.urlresolvers import reverse
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
from student.tests.factories import AdminFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from mock import patch
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class TestInstructorDashboardEmailView(ModuleStoreTestCase):
"""
Check for email view displayed with flag
"""
def setUp(self):
self.course = CourseFactory.create()
# Create instructor account
instructor = AdminFactory.create()
self.client.login(username=instructor.username, password="test")
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True})
def test_email_flag_true(self):
response = self.client.get(reverse('instructor_dashboard',
kwargs={'course_id': self.course.id}))
email_link = '<a href="#" onclick="goto(\'Email\')" class="None">Email</a>'
self.assertTrue(email_link in response.content)
session = self.client.session
session['idash_mode'] = 'Email'
session.save()
response = self.client.get(reverse('instructor_dashboard',
kwargs={'course_id': self.course.id}))
selected_email_link = '<a href="#" onclick="goto(\'Email\')" class="selectedmode">Email</a>'
self.assertTrue(selected_email_link in response.content)
send_to_label = '<label for="id_to">Send to:</label>'
self.assertTrue(send_to_label in response.content)
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': False})
def test_email_flag_false(self):
response = self.client.get(reverse('instructor_dashboard',
kwargs={'course_id': self.course.id}))
email_link = '<a href="#" onclick="goto(\'Email\')" class="None">Email</a>'
self.assertFalse(email_link in response.content)
......@@ -51,6 +51,11 @@ import track.views
from mitxmako.shortcuts import render_to_string
from bulk_email.models import CourseEmail
import datetime
from hashlib import md5
from bulk_email import tasks
log = logging.getLogger(__name__)
# internal commands for managing forum roles:
......@@ -76,6 +81,9 @@ def instructor_dashboard(request, course_id):
forum_admin_access = has_forum_access(request.user, course_id, FORUM_ROLE_ADMINISTRATOR)
msg = ''
to = None
subject = None
html_message = None
problems = []
plots = []
datatable = {}
......@@ -688,6 +696,31 @@ def instructor_dashboard(request, course_id):
datatable = ret['datatable']
#----------------------------------------
# email
elif action == 'Send email':
to = request.POST.get("to")
subject = request.POST.get("subject")
html_message = request.POST.get("message")
email = CourseEmail(course_id=course_id,
sender=request.user,
to=to,
subject=subject,
html_message=html_message,
hash=md5((html_message + subject + datetime.datetime.isoformat(datetime.datetime.now())).encode('utf-8')).hexdigest())
email.save()
course_url = request.build_absolute_uri(reverse('course_root', kwargs={'course_id': course_id}))
tasks.delegate_email_batches.delay(email.hash, email.to, course_id, course_url, request.user.id)
if to == "all":
msg = "<font color='green'>Your email was successfully queued for sending. Please note that for large public classe\
s (~10k), it may take 1-2 hours to send all emails.</font>"
else:
msg = "<font color='green'>Your email was successfully queued for sending.</font>"
#----------------------------------------
# psychometrics
elif action == 'Generate Histogram and IRT Plot':
......@@ -768,6 +801,9 @@ def instructor_dashboard(request, course_id):
'course_stats': course_stats,
'msg': msg,
'modeflag': {idash_mode: 'selectedmode'},
'to': to, # email
'subject': subject, # email
'message': html_message, # email
'problems': problems, # psychometrics
'plots': plots, # psychometrics
'course_errors': modulestore().get_item_errors(course.location),
......
......@@ -102,6 +102,11 @@ with open(ENV_ROOT / CONFIG_PREFIX + "env.json") as env_file:
PLATFORM_NAME = ENV_TOKENS.get('PLATFORM_NAME', PLATFORM_NAME)
EMAIL_BACKEND = ENV_TOKENS.get('EMAIL_BACKEND', EMAIL_BACKEND)
EMAIL_FILE_PATH = ENV_TOKENS.get('EMAIL_FILE_PATH', None)
EMAIL_HOST = ENV_TOKENS.get('EMAIL_HOST', 'localhost') # django default is localhost
EMAIL_PORT = ENV_TOKENS.get('EMAIL_PORT', 25) # django default is 25
EMAIL_USE_TLS = ENV_TOKENS.get('EMAIL_USE_TLS', False) # django default is False
EMAILS_PER_TASK = ENV_TOKENS.get('EMAILS_PER_TASK', 10)
SITE_NAME = ENV_TOKENS['SITE_NAME']
SESSION_ENGINE = ENV_TOKENS.get('SESSION_ENGINE', SESSION_ENGINE)
SESSION_COOKIE_DOMAIN = ENV_TOKENS.get('SESSION_COOKIE_DOMAIN')
......@@ -122,6 +127,7 @@ CACHES = ENV_TOKENS['CACHES']
#Email overrides
DEFAULT_FROM_EMAIL = ENV_TOKENS.get('DEFAULT_FROM_EMAIL', DEFAULT_FROM_EMAIL)
DEFAULT_FEEDBACK_EMAIL = ENV_TOKENS.get('DEFAULT_FEEDBACK_EMAIL', DEFAULT_FEEDBACK_EMAIL)
DEFAULT_BULK_FROM_EMAIL = ENV_TOKENS.get('DEFAULT_BULK_FROM_EMAIL', DEFAULT_BULK_FROM_EMAIL)
ADMINS = ENV_TOKENS.get('ADMINS', ADMINS)
SERVER_EMAIL = ENV_TOKENS.get('SERVER_EMAIL', SERVER_EMAIL)
TECH_SUPPORT_EMAIL = ENV_TOKENS.get('TECH_SUPPORT_EMAIL', TECH_SUPPORT_EMAIL)
......@@ -197,7 +203,7 @@ SECRET_KEY = AUTH_TOKENS['SECRET_KEY']
AWS_ACCESS_KEY_ID = AUTH_TOKENS["AWS_ACCESS_KEY_ID"]
AWS_SECRET_ACCESS_KEY = AUTH_TOKENS["AWS_SECRET_ACCESS_KEY"]
AWS_STORAGE_BUCKET_NAME = AUTH_TOKENS.get('AWS_STORAGE_BUCKET_NAME','edxuploads')
AWS_STORAGE_BUCKET_NAME = AUTH_TOKENS.get('AWS_STORAGE_BUCKET_NAME', 'edxuploads')
DATABASES = AUTH_TOKENS['DATABASES']
......@@ -211,6 +217,9 @@ CONTENTSTORE = AUTH_TOKENS.get('CONTENTSTORE', CONTENTSTORE)
OPEN_ENDED_GRADING_INTERFACE = AUTH_TOKENS.get('OPEN_ENDED_GRADING_INTERFACE',
OPEN_ENDED_GRADING_INTERFACE)
EMAIL_HOST_USER = AUTH_TOKENS.get('EMAIL_HOST_USER', '') # django default is ''
EMAIL_HOST_PASSWORD = AUTH_TOKENS.get('EMAIL_HOST_PASSWORD', '') # django default is ''
PEARSON_TEST_USER = "pearsontest"
PEARSON_TEST_PASSWORD = AUTH_TOKENS.get("PEARSON_TEST_PASSWORD")
......
......@@ -103,6 +103,8 @@ MITX_FEATURES = {
# analytics experiments
'ENABLE_INSTRUCTOR_ANALYTICS': False,
'ENABLE_INSTRUCTOR_EMAIL': False,
# enable analytics server.
# WARNING: THIS SHOULD ALWAYS BE SET TO FALSE UNDER NORMAL
# LMS OPERATION. See analytics.py for details about what
......@@ -289,11 +291,11 @@ WIKI_ENABLED = False
COURSE_DEFAULT = '6.002x_Fall_2012'
COURSE_SETTINGS = {'6.002x_Fall_2012': {'number': '6.002x',
'title': 'Circuits and Electronics',
'xmlpath': '6002x/',
'location': 'i4x://edx/6002xs12/course/6.002x_Fall_2012',
}
}
'title': 'Circuits and Electronics',
'xmlpath': '6002x/',
'location': 'i4x://edx/6002xs12/course/6.002x_Fall_2012',
}
}
# IP addresses that are allowed to reload the course, etc.
# TODO (vshnayder): Will probably need to change as we get real access control in.
......@@ -361,6 +363,8 @@ IGNORABLE_404_ENDS = ('favicon.ico')
# Email
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
DEFAULT_FROM_EMAIL = 'registration@edx.org'
DEFAULT_BULK_FROM_EMAIL = 'course-updates@edx.org'
EMAILS_PER_TASK = 10
DEFAULT_FEEDBACK_EMAIL = 'feedback@edx.org'
SERVER_EMAIL = 'devops@edx.org'
TECH_SUPPORT_EMAIL = 'technical@edx.org'
......@@ -538,17 +542,17 @@ courseware_js = (
# 'js/vendor/RequireJS.js' - Require JS wrapper.
# See https://edx-wiki.atlassian.net/wiki/display/LMS/Integration+of+Require+JS+into+the+system
main_vendor_js = [
'js/vendor/RequireJS.js',
'js/vendor/json2.js',
'js/vendor/jquery.min.js',
'js/vendor/jquery-ui.min.js',
'js/vendor/jquery.cookie.js',
'js/vendor/jquery.qtip.min.js',
'js/vendor/swfobject/swfobject.js',
'js/vendor/jquery.ba-bbq.min.js',
'js/vendor/annotator.min.js',
'js/vendor/annotator.store.min.js',
'js/vendor/annotator.tags.min.js'
'js/vendor/RequireJS.js',
'js/vendor/json2.js',
'js/vendor/jquery.min.js',
'js/vendor/jquery-ui.min.js',
'js/vendor/jquery.cookie.js',
'js/vendor/jquery.qtip.min.js',
'js/vendor/swfobject/swfobject.js',
'js/vendor/jquery.ba-bbq.min.js',
'js/vendor/annotator.min.js',
'js/vendor/annotator.store.min.js',
'js/vendor/annotator.tags.min.js'
]
discussion_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/discussion/**/*.js'))
......@@ -756,6 +760,7 @@ INSTALLED_APPS = (
'psychometrics',
'licenses',
'course_groups',
'bulk_email',
# External auth (OpenID, shib)
'external_auth',
......@@ -813,6 +818,7 @@ MKTG_URL_LINK_MAP = {
'PRIVACY': 'privacy_edx',
}
############################### THEME ################################
def enable_theme(theme_name):
"""
......
......@@ -28,6 +28,7 @@ MITX_FEATURES['ENABLE_MANUAL_GIT_RELOAD'] = True
MITX_FEATURES['ENABLE_PSYCHOMETRICS'] = False # real-time psychometrics (eg item response theory analysis in instructor dashboard)
MITX_FEATURES['ENABLE_INSTRUCTOR_ANALYTICS'] = True
MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True
MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] = True
MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True
MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = True
MITX_FEATURES['ENABLE_SHOPPING_CART'] = True
......
......@@ -65,6 +65,7 @@
// instructor
@import "course/instructor/instructor";
@import "course/instructor/instructor_2";
@import "course/instructor/email";
// discussion
@import "course/discussion/form-wmd-toolbar";
.submit-email-action {
margin-top: 10px;
line-height: 1.3;
ul {
margin-top: 0;
margin-bottom: 10px;
}
}
......@@ -570,5 +570,10 @@
color: #333;
}
}
a.email-settings {
@extend a.unenroll;
margin-right: 10px;
}
}
}
......@@ -118,6 +118,9 @@ function goto( mode)
<a href="#" onclick="goto('Enrollment');" class="${modeflag.get('Enrollment')}">${_("Enrollment")}</a> |
<a href="#" onclick="goto('Data');" class="${modeflag.get('Data')}">${_("DataDump")}</a> |
<a href="#" onclick="goto('Manage Groups');" class="${modeflag.get('Manage Groups')}">${_("Manage Groups")}</a>
%if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_EMAIL'):
| <a href="#" onclick="goto('Email')" class="${modeflag.get('Email')}">Email</a>
%endif
%if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_ANALYTICS'):
| <a href="#" onclick="goto('Analytics');" class="${modeflag.get('Analytics')}">${_("Analytics")}</a>
%endif
......@@ -431,6 +434,47 @@ function goto( mode)
%endif
%endif
##-----------------------------------------------------------------------------
%if modeflag.get('Email'):
<p>
<label for="id_to">Send to:</label>
<select id="id_to" name="to">
<option value="myself">Myself</option>
%if to == "staff":
<option value="staff" selected="selected">Staff and instructors</option>
%else:
<option value="staff">Staff and instructors</option>
%endif
%if to == "all":
<option value="all" selected="selected">All (students, staff and instructors)</option>
%else:
<option value="all">All (students, staff and instructors)</option>
%endif
</select>
<label for="id_subject">Subject: </label>
%if subject:
<input type="text" id="id_subject" name="subject" maxlength="100" size="75" value="${subject}">
%else:
<input type="text" id="id_subject" name="subject" maxlength="100" size="75">
%endif
<label for="id_message">Message:</label>
%if message:
<textarea cols="100" id="id_message" name="message">${message}</textarea>
%else:
<textarea cols="100" id="id_message" name="message"></textarea>
%endif
</p>
<div class="submit-email-action">
Please try not to email students more than once a day. Important things to consider before sending:
<ul>
<li>Have you read over the email to make sure it says everything you want to say?</li>
<li>Have you sent the email to yourself first to make sure you're happy with how it's displayed?</li>
</ul>
<input type="submit" name="action" value="Send email">
</div>
%endif
</form>
##-----------------------------------------------------------------------------
......
......@@ -16,6 +16,14 @@
<script type="text/javascript">
(function() {
$(".email-settings").click(function(event) {
$("#email_settings_course_id").val( $(event.target).data("course-id") );
$("#email_settings_course_number").text( $(event.target).data("course-number") );
if($(event.target).data("optout") == "False") {
$("#receive_emails").prop('checked', true);
}
});
$(".unenroll").click(function(event) {
$("#unenroll_course_id").val( $(event.target).data("course-id") );
$("#unenroll_course_number").text( $(event.target).data("course-number") );
......@@ -79,6 +87,24 @@
return false;
});
$("#email_settings_form").submit(function(){
$.ajax({
type: "POST",
url: '${reverse("change_email_settings")}',
data: $(this).serializeArray(),
success: function(data) {
if(data.success) {
location.href = "${reverse('dashboard')}";
}
},
error: function(xhr, textStatus, error) {
if (xhr.status == 403) {
location.href = "${reverse('signin_user')}";
}
}
});
return false;
});
})(this)
</script>
</%block>
......@@ -280,6 +306,7 @@
% endif
% endif
<a href="#unenroll-modal" class="unenroll" rel="leanModal" data-course-id="${course.id}" data-course-number="${course.number}">${_('Unregister')}</a>
<a href="#email-settings-modal" class="email-settings" rel="leanModal" data-course-id="${course.id}" data-course-number="${course.number}" data-optout="${course.id in course_optouts}">Email Settings</a>
</section>
</article>
......@@ -313,6 +340,29 @@
</section>
</section>
<section id="email-settings-modal" class="modal">
<div class="inner-wrapper">
<header>
<h2>Email Settings for <span id="email_settings_course_number"></span></h2>
<hr/>
</header>
<form id="email_settings_form" method="post">
<input name="course_id" id="email_settings_course_id" type="hidden" />
<label>Receive course emails <input type="checkbox" id="receive_emails" name="receive_emails" /></label>
<div class="submit">
<input type="submit" id="submit" value="Save Settings" />
</div>
</form>
<div class="close-modal">
<div class="inner">
<p>&#10005;</p>
</div>
</div>
</div>
</section>
<section id="unenroll-modal" class="modal unenroll-modal">
<div class="inner-wrapper">
<header>
......
<%! from django.core.urlresolvers import reverse %>
<br />
----<br />
This email was automatically sent from ${settings.PLATFORM_NAME} to ${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://${site}${reverse('dashboard')}">here</a>. <br />
<%! from django.core.urlresolvers import reverse %>
----
This email was automatically sent from ${settings.PLATFORM_NAME} to ${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://${site}${reverse('dashboard')}.
......@@ -190,6 +190,7 @@ if settings.COURSEWARE_ENABLED:
url(r'^courses/?$', 'branding.views.courses', name="courses"),
url(r'^change_enrollment$',
'student.views.change_enrollment', name="change_enrollment"),
url(r'^change_email_settings$', 'student.views.change_email_settings', name="change_email_settings"),
#About the course
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/about$',
......
......@@ -10,3 +10,4 @@ graphviz
mysql
geos
mongodb
lynx
......@@ -33,3 +33,4 @@ coffeescript
mysql-client
virtualenvwrapper
libgeos-ruby1.8
lynx-cur
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