From 907bf6e1b78e375bbc9353ed58f4cca3a4711467 Mon Sep 17 00:00:00 2001 From: Kevin Luo Date: Thu, 25 Jul 2013 12:22:30 -0700 Subject: [PATCH 01/11] 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 --- AUTHORS | 2 + CHANGELOG.rst | 3 + common/djangoapps/student/views.py | 31 ++++ lms/djangoapps/bulk_email/__init__.py | 0 lms/djangoapps/bulk_email/admin.py | 11 ++ .../bulk_email/migrations/0001_initial.py | 105 +++++++++++++ .../bulk_email/migrations/__init__.py | 0 lms/djangoapps/bulk_email/models.py | 43 ++++++ lms/djangoapps/bulk_email/tasks.py | 145 ++++++++++++++++++ lms/djangoapps/bulk_email/tests/__init__.py | 0 lms/djangoapps/bulk_email/tests/fake_smtp.py | 80 ++++++++++ .../bulk_email/tests/smtp_server_thread.py | 39 +++++ .../bulk_email/tests/test_course_optout.py | 61 ++++++++ lms/djangoapps/bulk_email/tests/test_email.py | 91 +++++++++++ .../bulk_email/tests/test_err_handling.py | 91 +++++++++++ lms/djangoapps/bulk_email/tests/tests.py | 51 ++++++ lms/djangoapps/instructor/views/legacy.py | 36 +++++ lms/envs/aws.py | 11 +- lms/envs/common.py | 38 +++-- lms/envs/dev.py | 1 + lms/static/sass/course.scss.mako | 1 + lms/static/sass/course/instructor/_email.scss | 10 ++ lms/static/sass/multicourse/_dashboard.scss | 5 + .../courseware/instructor_dashboard.html | 44 ++++++ lms/templates/dashboard.html | 50 ++++++ lms/templates/emails/email_footer.html | 6 + lms/templates/emails/email_footer.txt | 7 + lms/urls.py | 1 + .../system/mac_os_x/brew-formulas.txt | 1 + requirements/system/ubuntu/apt-packages.txt | 1 + 30 files changed, 948 insertions(+), 17 deletions(-) create mode 100644 lms/djangoapps/bulk_email/__init__.py create mode 100644 lms/djangoapps/bulk_email/admin.py create mode 100644 lms/djangoapps/bulk_email/migrations/0001_initial.py create mode 100644 lms/djangoapps/bulk_email/migrations/__init__.py create mode 100644 lms/djangoapps/bulk_email/models.py create mode 100644 lms/djangoapps/bulk_email/tasks.py create mode 100644 lms/djangoapps/bulk_email/tests/__init__.py create mode 100755 lms/djangoapps/bulk_email/tests/fake_smtp.py create mode 100644 lms/djangoapps/bulk_email/tests/smtp_server_thread.py create mode 100644 lms/djangoapps/bulk_email/tests/test_course_optout.py create mode 100644 lms/djangoapps/bulk_email/tests/test_email.py create mode 100644 lms/djangoapps/bulk_email/tests/test_err_handling.py create mode 100644 lms/djangoapps/bulk_email/tests/tests.py create mode 100644 lms/static/sass/course/instructor/_email.scss create mode 100644 lms/templates/emails/email_footer.html create mode 100644 lms/templates/emails/email_footer.txt diff --git a/AUTHORS b/AUTHORS index 4b57d723d2..0391bd55f9 100644 --- a/AUTHORS +++ b/AUTHORS @@ -84,3 +84,5 @@ Mukul Goyal Robert Marks Yarko Tymciurak Miles Steele +Kevin Luo +Akshay Jagadeesh diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ab5e17357b..89f084b3f4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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). diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 4d59b5cc66..e6d328740e 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -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})) diff --git a/lms/djangoapps/bulk_email/__init__.py b/lms/djangoapps/bulk_email/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/bulk_email/admin.py b/lms/djangoapps/bulk_email/admin.py new file mode 100644 index 0000000000..fe151741bc --- /dev/null +++ b/lms/djangoapps/bulk_email/admin.py @@ -0,0 +1,11 @@ +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) diff --git a/lms/djangoapps/bulk_email/migrations/0001_initial.py b/lms/djangoapps/bulk_email/migrations/0001_initial.py new file mode 100644 index 0000000000..02de3b909c --- /dev/null +++ b/lms/djangoapps/bulk_email/migrations/0001_initial.py @@ -0,0 +1,105 @@ +# -*- 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 diff --git a/lms/djangoapps/bulk_email/migrations/__init__.py b/lms/djangoapps/bulk_email/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/bulk_email/models.py b/lms/djangoapps/bulk_email/models.py new file mode 100644 index 0000000000..2804521841 --- /dev/null +++ b/lms/djangoapps/bulk_email/models.py @@ -0,0 +1,43 @@ +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') diff --git a/lms/djangoapps/bulk_email/tasks.py b/lms/djangoapps/bulk_email/tasks.py new file mode 100644 index 0000000000..9047f4e569 --- /dev/null +++ b/lms/djangoapps/bulk_email/tasks.py @@ -0,0 +1,145 @@ +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) diff --git a/lms/djangoapps/bulk_email/tests/__init__.py b/lms/djangoapps/bulk_email/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/bulk_email/tests/fake_smtp.py b/lms/djangoapps/bulk_email/tests/fake_smtp.py new file mode 100755 index 0000000000..cf5872c333 --- /dev/null +++ b/lms/djangoapps/bulk_email/tests/fake_smtp.py @@ -0,0 +1,80 @@ +""" +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() diff --git a/lms/djangoapps/bulk_email/tests/smtp_server_thread.py b/lms/djangoapps/bulk_email/tests/smtp_server_thread.py new file mode 100644 index 0000000000..77b51509f1 --- /dev/null +++ b/lms/djangoapps/bulk_email/tests/smtp_server_thread.py @@ -0,0 +1,39 @@ +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() diff --git a/lms/djangoapps/bulk_email/tests/test_course_optout.py b/lms/djangoapps/bulk_email/tests/test_course_optout.py new file mode 100644 index 0000000000..e91b11c314 --- /dev/null +++ b/lms/djangoapps/bulk_email/tests/test_course_optout.py @@ -0,0 +1,61 @@ +""" +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) diff --git a/lms/djangoapps/bulk_email/tests/test_email.py b/lms/djangoapps/bulk_email/tests/test_email.py new file mode 100644 index 0000000000..7aebd679f3 --- /dev/null +++ b/lms/djangoapps/bulk_email/tests/test_email.py @@ -0,0 +1,91 @@ +""" +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) diff --git a/lms/djangoapps/bulk_email/tests/test_err_handling.py b/lms/djangoapps/bulk_email/tests/test_err_handling.py new file mode 100644 index 0000000000..faf6d38a87 --- /dev/null +++ b/lms/djangoapps/bulk_email/tests/test_err_handling.py @@ -0,0 +1,91 @@ +""" +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) diff --git a/lms/djangoapps/bulk_email/tests/tests.py b/lms/djangoapps/bulk_email/tests/tests.py new file mode 100644 index 0000000000..71404a1c35 --- /dev/null +++ b/lms/djangoapps/bulk_email/tests/tests.py @@ -0,0 +1,51 @@ +""" +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 = 'Email' + 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 = 'Email' + self.assertTrue(selected_email_link in response.content) + 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 = 'Email' + self.assertFalse(email_link in response.content) diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index 27ab17551c..da67444d4a 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -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 = {} @@ -687,6 +695,31 @@ def instructor_dashboard(request, course_id): ret = _do_enroll_students(course, course_id, students, overload=overload) 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 = "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." + else: + msg = "Your email was successfully queued for sending." + #---------------------------------------- # psychometrics @@ -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), diff --git a/lms/envs/aws.py b/lms/envs/aws.py index f6eb45ec51..0d44674741 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -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") diff --git a/lms/envs/common.py b/lms/envs/common.py index 0e659a1ca1..bb19a70993 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -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): """ diff --git a/lms/envs/dev.py b/lms/envs/dev.py index 554c72dd89..4bd0247694 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -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 diff --git a/lms/static/sass/course.scss.mako b/lms/static/sass/course.scss.mako index 7c04968f86..93b0b681d4 100644 --- a/lms/static/sass/course.scss.mako +++ b/lms/static/sass/course.scss.mako @@ -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"; diff --git a/lms/static/sass/course/instructor/_email.scss b/lms/static/sass/course/instructor/_email.scss new file mode 100644 index 0000000000..ed33e985ab --- /dev/null +++ b/lms/static/sass/course/instructor/_email.scss @@ -0,0 +1,10 @@ +.submit-email-action { + margin-top: 10px; + line-height: 1.3; + + ul { + margin-top: 0; + margin-bottom: 10px; + } +} + diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss index cd58d4d8e4..6b2e85b1c5 100644 --- a/lms/static/sass/multicourse/_dashboard.scss +++ b/lms/static/sass/multicourse/_dashboard.scss @@ -570,5 +570,10 @@ color: #333; } } + + a.email-settings { + @extend a.unenroll; + margin-right: 10px; + } } } diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index f045dfeb3b..71fc0646e6 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -118,6 +118,9 @@ function goto( mode) ${_("Enrollment")} | ${_("DataDump")} | ${_("Manage Groups")} + %if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_EMAIL'): + | Email + %endif %if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_ANALYTICS'): | ${_("Analytics")} %endif @@ -431,6 +434,47 @@ function goto( mode) %endif %endif +##----------------------------------------------------------------------------- + +%if modeflag.get('Email'): +

+ + + + %if subject: + + %else: + + %endif + + %if message: + + %else: + + %endif +

+
+ Please try not to email students more than once a day. Important things to consider before sending: +
    +
  • Have you read over the email to make sure it says everything you want to say?
  • +
  • Have you sent the email to yourself first to make sure you're happy with how it's displayed?
  • +
+ +
+%endif + ##----------------------------------------------------------------------------- diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 5023345376..3d0d5e52ee 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -16,6 +16,14 @@ @@ -280,6 +306,7 @@ % endif % endif ${_('Unregister')} + @@ -313,6 +340,29 @@ + + @@ -343,13 +343,13 @@ From d341d6d26da54d317f621199ca90b53a698bac39 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Thu, 22 Aug 2013 17:04:38 -0700 Subject: [PATCH 09/11] Change optout to use user.id instead of email. Includes Data + Schema migrations for optout email -> user.id change. Note that migrations should be reversible. --- common/djangoapps/student/views.py | 11 ++- lms/djangoapps/bulk_email/admin.py | 2 +- .../bulk_email/migrations/0001_initial.py | 2 - .../migrations/0002_change_field_names.py | 6 -- .../migrations/0003_add_optout_user.py | 91 +++++++++++++++++++ .../migrations/0004_migrate_optout_user.py | 91 +++++++++++++++++++ .../migrations/0005_remove_optout_email.py | 78 ++++++++++++++++ lms/djangoapps/bulk_email/models.py | 10 +- lms/djangoapps/bulk_email/tasks.py | 64 ++++++++----- .../bulk_email/tests/test_course_optout.py | 3 + lms/djangoapps/bulk_email/tests/test_email.py | 58 +++++++++++- .../bulk_email/tests/test_err_handling.py | 3 +- lms/djangoapps/instructor/views/legacy.py | 41 ++++----- lms/envs/aws.py | 2 +- lms/envs/common.py | 3 +- 15 files changed, 397 insertions(+), 68 deletions(-) create mode 100644 lms/djangoapps/bulk_email/migrations/0003_add_optout_user.py create mode 100644 lms/djangoapps/bulk_email/migrations/0004_migrate_optout_user.py create mode 100644 lms/djangoapps/bulk_email/migrations/0005_remove_optout_email.py diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index ebd067942e..b1b8f6ff1e 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -1,3 +1,6 @@ +""" +Student Views +""" import datetime import feedparser import json @@ -271,7 +274,7 @@ 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) + course_optouts = Optout.objects.filter(user=user).values_list('course_id', flat=True) message = "" if not user.is_active: @@ -1289,13 +1292,13 @@ def change_email_settings(request): course_id = request.POST.get("course_id") receive_emails = request.POST.get("receive_emails") if receive_emails: - optout_object = Optout.objects.filter(email=user.email, course_id=course_id) + optout_object = Optout.objects.filter(user=user, course_id=course_id) if optout_object: optout_object.delete() - log.info(u"User {0} ({1}) opted to receive emails from course {2}".format(user.username, user.email, course_id)) + log.info(u"User {0} ({1}) opted in 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) + Optout.objects.get_or_create(user=user, 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') diff --git a/lms/djangoapps/bulk_email/admin.py b/lms/djangoapps/bulk_email/admin.py index 40da36629c..3b40290f5d 100644 --- a/lms/djangoapps/bulk_email/admin.py +++ b/lms/djangoapps/bulk_email/admin.py @@ -13,7 +13,7 @@ class CourseEmailAdmin(admin.ModelAdmin): class OptoutAdmin(admin.ModelAdmin): """Admin for optouts.""" - list_display = ('email', 'course_id') + list_display = ('user', 'course_id') admin.site.register(CourseEmail, CourseEmailAdmin) diff --git a/lms/djangoapps/bulk_email/migrations/0001_initial.py b/lms/djangoapps/bulk_email/migrations/0001_initial.py index 99c99d4efc..c3672a6de8 100644 --- a/lms/djangoapps/bulk_email/migrations/0001_initial.py +++ b/lms/djangoapps/bulk_email/migrations/0001_initial.py @@ -1,8 +1,6 @@ # -*- coding: utf-8 -*- -import datetime from south.db import db from south.v2 import SchemaMigration -from django.db import models class Migration(SchemaMigration): diff --git a/lms/djangoapps/bulk_email/migrations/0002_change_field_names.py b/lms/djangoapps/bulk_email/migrations/0002_change_field_names.py index 95c0db339f..93fa33a544 100644 --- a/lms/djangoapps/bulk_email/migrations/0002_change_field_names.py +++ b/lms/djangoapps/bulk_email/migrations/0002_change_field_names.py @@ -1,8 +1,6 @@ # -*- coding: utf-8 -*- -import datetime from south.db import db from south.v2 import SchemaMigration -from django.db import models class Migration(SchemaMigration): @@ -19,7 +17,6 @@ class Migration(SchemaMigration): self.gf('django.db.models.fields.TextField')(null=True, blank=True), keep_default=False) - def backwards(self, orm): # Renaming field 'CourseEmail.to_option' db.rename_column('bulk_email_courseemail', 'to_option', 'to') @@ -30,9 +27,6 @@ class Migration(SchemaMigration): # Deleting field 'CourseEmail.text_message' db.delete_column('bulk_email_courseemail', 'text_message') - - - models = { 'auth.group': { 'Meta': {'object_name': 'Group'}, diff --git a/lms/djangoapps/bulk_email/migrations/0003_add_optout_user.py b/lms/djangoapps/bulk_email/migrations/0003_add_optout_user.py new file mode 100644 index 0000000000..1bf344f6e9 --- /dev/null +++ b/lms/djangoapps/bulk_email/migrations/0003_add_optout_user.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +from south.db import db +from south.v2 import SchemaMigration + + +class Migration(SchemaMigration): + + def forwards(self, orm): + + # Adding field 'Optout.user' + db.add_column('bulk_email_optout', 'user', + self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True), + keep_default=False) + + # Removing unique constraint on 'Optout', fields ['course_id', 'email'] + db.delete_unique('bulk_email_optout', ['course_id', 'email']) + + # Adding unique constraint on 'Optout', fields ['course_id', 'user'] + db.create_unique('bulk_email_optout', ['course_id', 'user_id']) + + def backwards(self, orm): + + # Removing unique constraint on 'Optout', fields ['course_id', 'user'] + db.delete_unique('bulk_email_optout', ['course_id', 'user_id']) + + # Deleting field 'Optout.email' + db.delete_column('bulk_email_optout', 'user_id') + + # Creating unique constraint on 'Optout', fields ['course_id', 'email'] + db.create_unique('bulk_email_optout', ['course_id', 'email']) + + 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'}), + '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'}), + 'slug': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'subject': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'text_message': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'to_option': ('django.db.models.fields.CharField', [], {'default': "'myself'", 'max_length': '64'}) + }, + 'bulk_email.optout': { + 'Meta': {'unique_together': "(('user', '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'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': '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'] diff --git a/lms/djangoapps/bulk_email/migrations/0004_migrate_optout_user.py b/lms/djangoapps/bulk_email/migrations/0004_migrate_optout_user.py new file mode 100644 index 0000000000..6dd2129466 --- /dev/null +++ b/lms/djangoapps/bulk_email/migrations/0004_migrate_optout_user.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +from south.db import db +from south.v2 import DataMigration +from django.core.exceptions import ObjectDoesNotExist + + +class Migration(DataMigration): + + def forwards(self, orm): + + # forwards data migration to copy over existing emails to associated ids + if not db.dry_run: + for optout in orm.Optout.objects.all(): + try: + user = orm['auth.User'].objects.get(email=optout.email) + optout.user = user + optout.save() + except ObjectDoesNotExist: + # if user is not found (because they have already changed their email) + # then delete the optout, as it's no longer useful. + optout.delete() + + def backwards(self, orm): + + # backwards data migration to copy over emails of students to old email slot + if not db.dry_run: + for optout in orm.Optout.objects.all(): + if optout.user is not None: + optout.email = optout.user.email + optout.save() + + 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'}), + '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'}), + 'slug': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'subject': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'text_message': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'to_option': ('django.db.models.fields.CharField', [], {'default': "'myself'", 'max_length': '64'}) + }, + 'bulk_email.optout': { + 'Meta': {'unique_together': "(('user', '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'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': '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'] diff --git a/lms/djangoapps/bulk_email/migrations/0005_remove_optout_email.py b/lms/djangoapps/bulk_email/migrations/0005_remove_optout_email.py new file mode 100644 index 0000000000..3639d1e473 --- /dev/null +++ b/lms/djangoapps/bulk_email/migrations/0005_remove_optout_email.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +from south.db import db +from south.v2 import SchemaMigration + + +class Migration(SchemaMigration): + + def forwards(self, orm): + + # Deleting field 'Optout.email' + db.delete_column('bulk_email_optout', 'email') + + def backwards(self, orm): + + # Adding field 'Optout.email' + db.add_column('bulk_email_optout', 'email', + self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True), + keep_default=False) + + 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'}), + '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'}), + 'slug': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'subject': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'text_message': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'to_option': ('django.db.models.fields.CharField', [], {'default': "'myself'", 'max_length': '64'}) + }, + 'bulk_email.optout': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'Optout'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': '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'] diff --git a/lms/djangoapps/bulk_email/models.py b/lms/djangoapps/bulk_email/models.py index 8fa864b955..72c9569cc1 100644 --- a/lms/djangoapps/bulk_email/models.py +++ b/lms/djangoapps/bulk_email/models.py @@ -30,7 +30,7 @@ class Email(models.Model): created = models.DateTimeField(auto_now_add=True) modified = models.DateTimeField(auto_now=True) - class Meta: + class Meta: # pylint: disable=C0111 abstract = True @@ -61,10 +61,10 @@ class CourseEmail(Email, models.Model): class Optout(models.Model): """ - Stores emails that have opted out of receiving emails from a course. + Stores users that have opted out of receiving emails from a course. """ - email = models.CharField(max_length=255, db_index=True) + user = models.ForeignKey(User, db_index=True, null=True) course_id = models.CharField(max_length=255, db_index=True) - class Meta: - unique_together = ('email', 'course_id') + class Meta: # pylint: disable=C0111 + unique_together = ('user', 'course_id') diff --git a/lms/djangoapps/bulk_email/tasks.py b/lms/djangoapps/bulk_email/tasks.py index 35c57b9dad..7afd286570 100644 --- a/lms/djangoapps/bulk_email/tasks.py +++ b/lms/djangoapps/bulk_email/tasks.py @@ -2,10 +2,10 @@ This module contains celery task functions for handling the sending of bulk email to a course. """ -import logging import math import re import time +import gc from smtplib import SMTPServerDisconnected, SMTPDataError, SMTPConnectError @@ -14,13 +14,14 @@ 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 celery.utils.log import get_task_logger 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__) +log = get_task_logger(__name__) @task(default_retry_delay=10, max_retries=5) # pylint: disable=E1102 @@ -47,37 +48,42 @@ def delegate_email_batches(email_id, to_option, course_id, course_url, user_id): raise delegate_email_batches.retry(arg=[email_id, to_option, course_id, course_url, user_id], exc=exc) if to_option == "myself": - recipient_qset = User.objects.filter(id=user_id).values('profile__name', 'email') - + recipient_qset = User.objects.filter(id=user_id) elif to_option == "all" or to_option == "staff": 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') + staff_qset = staff_group.user_set.all() 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') + instructor_qset = instructor_group.user_set.all() recipient_qset = staff_qset | instructor_qset if to_option == "all": - # Two queries are executed per performance considerations for MySQL. - # See 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') + enrollment_qset = User.objects.filter(courseenrollment__course_id=course_id, + courseenrollment__is_active=True) recipient_qset = recipient_qset | enrollment_qset recipient_qset = recipient_qset.distinct() - else: log.error("Unexpected bulk email TO_OPTION found: %s", to_option) raise Exception("Unexpected bulk email TO_OPTION found: {0}".format(to_option)) - recipient_list = list(recipient_qset) - total_num_emails = len(recipient_list) - 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(email_id, to_list, course.display_name, course_url, False) + recipient_qset = recipient_qset.order_by('pk') + total_num_emails = recipient_qset.count() + num_queries = int(math.ceil(float(total_num_emails) / float(settings.EMAILS_PER_QUERY))) + last_pk = recipient_qset[0].pk - 1 + num_workers = 0 + for j in range(num_queries): + recipient_sublist = list(recipient_qset.order_by('pk').filter(pk__gt=last_pk) + .values('profile__name', 'email', 'pk')[:settings.EMAILS_PER_QUERY]) + last_pk = recipient_sublist[-1]['pk'] + num_emails_this_query = len(recipient_sublist) + num_tasks_this_query = int(math.ceil(float(num_emails_this_query) / float(settings.EMAILS_PER_TASK))) + chunk = int(math.ceil(float(num_emails_this_query) / float(num_tasks_this_query))) + for i in range(num_tasks_this_query): + to_list = recipient_sublist[i * chunk:i * chunk + chunk] + course_email.delay(email_id, to_list, course.display_name, course_url, False) + num_workers += num_tasks_this_query + gc.collect() return num_workers @@ -89,12 +95,22 @@ def course_email(email_id, to_list, course_title, course_url, throttle=False): being the only "to". Emails are sent multipart, in both plain text and html. """ + try: msg = CourseEmail.objects.get(id=email_id) except CourseEmail.DoesNotExist as exc: log.exception(exc.args[0]) raise exc + # exclude optouts + optouts = Optout.objects.filter(course_id=msg.course_id, + user__email__in=[i['email'] for i in to_list])\ + .values_list('user__email', flat=True) + + num_optout = len(optouts) + + to_list = filter(lambda x: x['email'] not in optouts, to_list) + subject = "[" + course_title + "] " + msg.subject course_title_no_quotes = re.sub(r'"', '', course_title) @@ -114,9 +130,9 @@ def course_email(email_id, to_list, course_title, course_url, throttle=False): } while to_list: - (name, email) = to_list[-1].values() - email_context['name'] = name + email = to_list[-1]['email'] email_context['email'] = email + email_context['name'] = to_list[-1]['profile__name'] html_footer = render_to_string( 'emails/email_footer.html', @@ -157,7 +173,7 @@ def course_email(email_id, to_list, course_title, course_url, throttle=False): to_list.pop() connection.close() - return course_email_result(num_sent, num_error) + return course_email_result(num_sent, num_error, num_optout) 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 @@ -175,6 +191,6 @@ def course_email(email_id, to_list, course_title, course_url, throttle=False): # This string format code is wrapped in this function to allow mocking for a unit test -def course_email_result(num_sent, num_error): +def course_email_result(num_sent, num_error, num_optout): """Return the formatted result of course_email sending.""" - return "Sent {0}, Fail {1}".format(num_sent, num_error) + return "Sent {0}, Fail {1}, Optout {2}".format(num_sent, num_error, num_optout) diff --git a/lms/djangoapps/bulk_email/tests/test_course_optout.py b/lms/djangoapps/bulk_email/tests/test_course_optout.py index 499ccb0b95..18f04cebc1 100644 --- a/lms/djangoapps/bulk_email/tests/test_course_optout.py +++ b/lms/djangoapps/bulk_email/tests/test_course_optout.py @@ -10,6 +10,7 @@ from django.test.utils import override_settings from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE from student.tests.factories import UserFactory, AdminFactory, CourseEnrollmentFactory +from student.models import CourseEnrollment from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -94,6 +95,8 @@ class TestOptoutCourseEmails(ModuleStoreTestCase): self.client.logout() + self.assertTrue(CourseEnrollment.is_enrolled(self.student, self.course.id)) + self.client.login(username=self.instructor.username, password="test") self.navigate_to_email_view() diff --git a/lms/djangoapps/bulk_email/tests/test_email.py b/lms/djangoapps/bulk_email/tests/test_email.py index 67f49d1f51..b0cf8dc06e 100644 --- a/lms/djangoapps/bulk_email/tests/test_email.py +++ b/lms/djangoapps/bulk_email/tests/test_email.py @@ -2,7 +2,6 @@ """ Unit tests for sending course email """ - from django.test.utils import override_settings from django.conf import settings from django.core import mail @@ -14,12 +13,29 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory from bulk_email.tasks import delegate_email_batches, course_email -from bulk_email.models import CourseEmail +from bulk_email.models import CourseEmail, Optout from mock import patch STAFF_COUNT = 3 STUDENT_COUNT = 10 +LARGE_NUM_EMAILS = 137 + + +class MockCourseEmailResult(object): + """ + A small closure-like class to keep count of emails sent over all tasks, recorded + by mock object side effects + """ + emails_sent = 0 + + def get_mock_course_email_result(self): + """Wrapper for mock email function.""" + def mock_course_email_result(sent, failed, output, **kwargs): # pylint: disable=W0613 + """Increments count of number of emails sent.""" + self.emails_sent += sent + return True + return mock_course_email_result @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) @@ -110,6 +126,7 @@ class TestEmailSendFromDashboard(ModuleStoreTestCase): self.assertContains(response, "Your email was successfully queued for sending.") + # the 1 is for the instructor in this test and others self.assertEquals(len(mail.outbox), 1 + len(self.staff)) self.assertItemsEqual( [e.to[0] for e in mail.outbox], @@ -225,6 +242,43 @@ class TestEmailSendFromDashboard(ModuleStoreTestCase): [self.instructor.email] + [s.email for s in self.staff] + [s.email for s in self.students] ) + @override_settings(EMAILS_PER_TASK=3, EMAILS_PER_QUERY=7) + @patch('bulk_email.tasks.course_email_result') + def test_chunked_queries_send_numerous_emails(self, email_mock): + """ + Test sending a large number of emails, to test the chunked querying + """ + mock_factory = MockCourseEmailResult() + email_mock.side_effect = mock_factory.get_mock_course_email_result() + added_users = [] + for _ in xrange(LARGE_NUM_EMAILS): + user = UserFactory() + added_users.append(user) + CourseEnrollmentFactory.create(user=user, course_id=self.course.id) + + optouts = [] + for i in [1, 3, 9, 10, 18]: # 5 random optouts + user = added_users[i] + optouts.append(user) + optout = Optout(user=user, course_id=self.course.id) + optout.save() + + test_email = { + 'action': 'Send email', + 'to_option': 'all', + 'subject': 'test subject for all', + 'message': 'test message for all' + } + response = self.client.post(self.url, test_email) + self.assertContains(response, "Your email was successfully queued for sending.") + self.assertEquals(mock_factory.emails_sent, + 1 + len(self.staff) + len(self.students) + LARGE_NUM_EMAILS - len(optouts)) + 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] + + [s.email for s in added_users if s not in optouts] + ) + @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) class TestEmailSendExceptions(ModuleStoreTestCase): diff --git a/lms/djangoapps/bulk_email/tests/test_err_handling.py b/lms/djangoapps/bulk_email/tests/test_err_handling.py index e7d2e62d4a..606a0bef88 100644 --- a/lms/djangoapps/bulk_email/tests/test_err_handling.py +++ b/lms/djangoapps/bulk_email/tests/test_err_handling.py @@ -94,7 +94,8 @@ class TestEmailErrors(ModuleStoreTestCase): # We shouldn't retry when hitting a 5xx error self.assertFalse(retry.called) # Test that after the rejected email, the rest still successfully send - ((sent, fail), _) = result.call_args + ((sent, fail, optouts), _) = result.call_args + self.assertEquals(optouts, 0) self.assertEquals(fail, 1) self.assertEquals(sent, settings.EMAILS_PER_TASK - 1) diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index a2ccf0a5bc..15b0df59c1 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -56,7 +56,6 @@ from mitxmako.shortcuts import render_to_string from bulk_email.models import CourseEmail from html_to_text import html_to_text -import datetime from bulk_email import tasks log = logging.getLogger(__name__) @@ -66,11 +65,11 @@ FORUM_ROLE_ADD = 'add' FORUM_ROLE_REMOVE = 'remove' -def split_by_comma_and_whitespace(s): +def split_by_comma_and_whitespace(a_str): """ - Return string s, split by , or whitespace + Return string a_str, split by , or whitespace """ - return re.split(r'[\s,]', s) + return re.split(r'[\s,]', a_str) @ensure_csrf_cookie @@ -124,13 +123,13 @@ def instructor_dashboard(request, course_id): datatable['data'] = data return datatable - def return_csv(fn, datatable, fp=None): + def return_csv(func, datatable, file_pointer=None): """Outputs a CSV file from the contents of a datatable.""" - if fp is None: + if file_pointer is None: response = HttpResponse(mimetype='text/csv') - response['Content-Disposition'] = 'attachment; filename={0}'.format(fn) + response['Content-Disposition'] = 'attachment; filename={0}'.format(func) else: - response = fp + response = file_pointer writer = csv.writer(response, dialect='excel', quotechar='"', quoting=csv.QUOTE_ALL) writer.writerow(datatable['header']) for datarow in datatable['data']: @@ -279,11 +278,11 @@ def instructor_dashboard(request, course_id): msg += 'Failed to create a background task for rescoring "{0}".'.format(problem_url) else: track.views.server_track(request, "rescore-all-submissions", {"problem": problem_url, "course": course_id}, page="idashboard") - except ItemNotFoundError as e: + except ItemNotFoundError as err: msg += 'Failed to create a background task for rescoring "{0}": problem not found.'.format(problem_url) - except Exception as e: - log.error("Encountered exception from rescore: {0}".format(e)) - msg += 'Failed to create a background task for rescoring "{0}": {1}.'.format(problem_url, e.message) + except Exception as err: + log.error("Encountered exception from rescore: {0}".format(err)) + msg += 'Failed to create a background task for rescoring "{0}": {1}.'.format(problem_url, err.message) elif "Reset ALL students' attempts" in action: problem_urlname = request.POST.get('problem_for_all_students', '') @@ -294,12 +293,12 @@ def instructor_dashboard(request, course_id): msg += 'Failed to create a background task for resetting "{0}".'.format(problem_url) else: track.views.server_track(request, "reset-all-attempts", {"problem": problem_url, "course": course_id}, page="idashboard") - except ItemNotFoundError as e: - log.error('Failure to reset: unknown problem "{0}"'.format(e)) + except ItemNotFoundError as err: + log.error('Failure to reset: unknown problem "{0}"'.format(err)) msg += 'Failed to create a background task for resetting "{0}": problem not found.'.format(problem_url) - except Exception as e: - log.error("Encountered exception from reset: {0}".format(e)) - msg += 'Failed to create a background task for resetting "{0}": {1}.'.format(problem_url, e.message) + except Exception as err: + log.error("Encountered exception from reset: {0}".format(err)) + msg += 'Failed to create a background task for resetting "{0}": {1}.'.format(problem_url, err.message) elif "Show Background Task History for Student" in action: # put this before the non-student case, since the use of "in" will cause this to be missed @@ -475,10 +474,10 @@ def instructor_dashboard(request, course_id): return return_csv('grades %s.csv' % aname, datatable) elif 'remote gradebook' in action: - fp = StringIO() - return_csv('', datatable, fp=fp) - fp.seek(0) - files = {'datafile': fp} + file_pointer = StringIO() + return_csv('', datatable, file_pointer=file_pointer) + file_pointer.seek(0) + files = {'datafile': file_pointer} msg2, _ = _do_remote_gradebook(request.user, course, 'post-grades', files=files) msg += msg2 diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 45738e6d31..a22fbc5bb6 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -106,7 +106,7 @@ EMAIL_HOST = ENV_TOKENS.get('EMAIL_HOST', 'localhost') # django default is loca 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', 100) - +EMAILS_PER_QUERY = ENV_TOKENS.get('EMAILS_PER_QUERY', 1000) SITE_NAME = ENV_TOKENS['SITE_NAME'] SESSION_ENGINE = ENV_TOKENS.get('SESSION_ENGINE', SESSION_ENGINE) SESSION_COOKIE_DOMAIN = ENV_TOKENS.get('SESSION_COOKIE_DOMAIN') diff --git a/lms/envs/common.py b/lms/envs/common.py index 51fba25e69..5ad81d5f03 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -364,7 +364,8 @@ IGNORABLE_404_ENDS = ('favicon.ico') 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 +EMAILS_PER_TASK = 100 +EMAILS_PER_QUERY = 1000 DEFAULT_FEEDBACK_EMAIL = 'feedback@edx.org' SERVER_EMAIL = 'devops@edx.org' TECH_SUPPORT_EMAIL = 'technical@edx.org' From 8f93051d303d402947ca41a0c54cbeb8a17fc5ce Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Thu, 8 Aug 2013 16:44:36 -0400 Subject: [PATCH 10/11] Add editable templates for bulk email Adds the edX Marketing-approved template as html default. --- lms/djangoapps/bulk_email/admin.py | 43 ++- .../fixtures/course_email_template.json | 10 + .../plain-html-no-newlines-or-tabs.txt | 1 + .../fixtures/plain-html-no-newlines.txt | 1 + .../bulk_email/fixtures/plain-html.txt | 268 ++++++++++++++++++ lms/djangoapps/bulk_email/forms.py | 42 +++ .../0006_add_course_email_template.py | 87 ++++++ .../0007_load_course_email_template.py | 81 ++++++ lms/djangoapps/bulk_email/models.py | 99 ++++++- lms/djangoapps/bulk_email/tasks.py | 100 ++++--- .../bulk_email/tests/test_course_optout.py | 4 + lms/djangoapps/bulk_email/tests/test_email.py | 25 +- .../bulk_email/tests/test_err_handling.py | 4 + lms/djangoapps/instructor/views/legacy.py | 8 +- lms/static/images/bulk_email/FacebookIcon.png | Bin 0 -> 550 bytes .../images/bulk_email/GooglePlusIcon.png | Bin 0 -> 1286 bytes lms/static/images/bulk_email/LinkedInIcon.png | Bin 0 -> 751 bytes lms/static/images/bulk_email/MeetupIcon.png | Bin 0 -> 1283 bytes lms/static/images/bulk_email/TwitterIcon.png | Bin 0 -> 998 bytes .../images/bulk_email/VKontakteIcon.png | Bin 0 -> 2480 bytes .../images/bulk_email/edXHeaderImage.jpg | Bin 0 -> 25814 bytes 21 files changed, 719 insertions(+), 54 deletions(-) create mode 100644 lms/djangoapps/bulk_email/fixtures/course_email_template.json create mode 100644 lms/djangoapps/bulk_email/fixtures/plain-html-no-newlines-or-tabs.txt create mode 100644 lms/djangoapps/bulk_email/fixtures/plain-html-no-newlines.txt create mode 100644 lms/djangoapps/bulk_email/fixtures/plain-html.txt create mode 100644 lms/djangoapps/bulk_email/forms.py create mode 100644 lms/djangoapps/bulk_email/migrations/0006_add_course_email_template.py create mode 100644 lms/djangoapps/bulk_email/migrations/0007_load_course_email_template.py create mode 100644 lms/static/images/bulk_email/FacebookIcon.png create mode 100644 lms/static/images/bulk_email/GooglePlusIcon.png create mode 100644 lms/static/images/bulk_email/LinkedInIcon.png create mode 100644 lms/static/images/bulk_email/MeetupIcon.png create mode 100644 lms/static/images/bulk_email/TwitterIcon.png create mode 100644 lms/static/images/bulk_email/VKontakteIcon.png create mode 100644 lms/static/images/bulk_email/edXHeaderImage.jpg diff --git a/lms/djangoapps/bulk_email/admin.py b/lms/djangoapps/bulk_email/admin.py index 3b40290f5d..1505af9ce4 100644 --- a/lms/djangoapps/bulk_email/admin.py +++ b/lms/djangoapps/bulk_email/admin.py @@ -3,7 +3,8 @@ Django admin page for bulk email models """ from django.contrib import admin -from bulk_email.models import CourseEmail, Optout +from bulk_email.models import CourseEmail, Optout, CourseEmailTemplate +from bulk_email.forms import CourseEmailTemplateForm class CourseEmailAdmin(admin.ModelAdmin): @@ -16,5 +17,45 @@ class OptoutAdmin(admin.ModelAdmin): list_display = ('user', 'course_id') +class CourseEmailTemplateAdmin(admin.ModelAdmin): + form = CourseEmailTemplateForm + fieldsets = ( + (None, { + # make the HTML template display above the plain template: + 'fields': ('html_template', 'plain_template'), + 'description': ''' +Enter template to be used by course staff when sending emails to enrolled students. + +The HTML template is for HTML email, and may contain HTML markup. The plain template is +for plaintext email. Both templates should contain the string '{{message_body}}' (with +two curly braces on each side), to indicate where the email text is to be inserted. + +Other tags that may be used (surrounded by one curly brace on each side): +{platform_name} : the name of the platform +{course_title} : the name of the course +{course_url} : the course's full URL +{email} : the user's email address +{account_settings_url} : URL at which users can change email preferences +{course_image_url} : URL for the course's course image. + Will return a broken link if course doesn't have a course image set. + +Note that there is currently NO validation on tags, so be careful. Typos or use of +unsupported tags will cause email sending to fail. +''' + }), + ) + # Turn off the action bar (we have no bulk actions) + actions = None + + def has_add_permission(self, request): + """Disables the ability to add new templates, as we want to maintain a Singleton.""" + return False + + def has_delete_permission(self, request, obj=None): + """Disables the ability to remove existing templates, as we want to maintain a Singleton.""" + return False + + admin.site.register(CourseEmail, CourseEmailAdmin) admin.site.register(Optout, OptoutAdmin) +admin.site.register(CourseEmailTemplate, CourseEmailTemplateAdmin) diff --git a/lms/djangoapps/bulk_email/fixtures/course_email_template.json b/lms/djangoapps/bulk_email/fixtures/course_email_template.json new file mode 100644 index 0000000000..076dedbd14 --- /dev/null +++ b/lms/djangoapps/bulk_email/fixtures/course_email_template.json @@ -0,0 +1,10 @@ +[ + { + "pk": 1, + "model": "bulk_email.courseemailtemplate", + "fields": { + "plain_template": "{course_title}\n\n{{message_body}}\r\n----\r\nCopyright 2013 edX, All rights reserved.\r\n----\r\nConnect with edX: Facebook (http://facebook.com/edxonline)\nTwitter (http://twitter.com/edxonline)\nGoogle+ (https://plus.google.com/108235383044095082735)\nMeetup (http://www.meetup.com/edX-Communities/)\r\n----\r\n This email was automatically sent from {platform_name}.\r\nYou are receiving this email at address {email} because you are enrolled in {course_title}\r\n(URL: {course_url} ).\r\nTo stop receiving email like this, update your account settings at {account_settings_url}.\r\n", + "html_template": " Update from {course_title}

edX
Connect with edX:        

{course_title}


{{message_body}}
       
Copyright © 2013 edX, All rights reserved.


Our mailing address is:
edX
11 Cambridge Center, Suite 101
Cambridge, MA, USA 02142


This email was automatically sent from {platform_name}.
You are receiving this email at address {email} because you are enrolled in {course_title}.
To stop receiving email like this, update your course email settings here.
" + } + } +] diff --git a/lms/djangoapps/bulk_email/fixtures/plain-html-no-newlines-or-tabs.txt b/lms/djangoapps/bulk_email/fixtures/plain-html-no-newlines-or-tabs.txt new file mode 100644 index 0000000000..e1c3688e85 --- /dev/null +++ b/lms/djangoapps/bulk_email/fixtures/plain-html-no-newlines-or-tabs.txt @@ -0,0 +1 @@ + Update from {course_title}

edX
Connect with edX:        

{course_title}


{{message_body}}
       
Copyright © 2013 edX, All rights reserved.


Our mailing address is:
edX
11 Cambridge Center, Suite 101
Cambridge, MA, USA 02142


This email was automatically sent from {platform_name}.
You are receiving this email at address {email} because you are enrolled in {course_title}.
To stop receiving email like this, update your course email settings here.
\ No newline at end of file diff --git a/lms/djangoapps/bulk_email/fixtures/plain-html-no-newlines.txt b/lms/djangoapps/bulk_email/fixtures/plain-html-no-newlines.txt new file mode 100644 index 0000000000..740d390a3d --- /dev/null +++ b/lms/djangoapps/bulk_email/fixtures/plain-html-no-newlines.txt @@ -0,0 +1 @@ + Update from {course_title}

edX
Connect with edX:        

{course_title}


{{message_body}}
       
Copyright © 2013 edX, All rights reserved.


Our mailing address is:
edX
11 Cambridge Center, Suite 101
Cambridge, MA, USA 02142


This email was automatically sent from {platform_name}.
You are receiving this email at address {email} because you are enrolled in {course_title}.
To stop receiving email like this, update your course email settings here.
\ No newline at end of file diff --git a/lms/djangoapps/bulk_email/fixtures/plain-html.txt b/lms/djangoapps/bulk_email/fixtures/plain-html.txt new file mode 100644 index 0000000000..9806e51d79 --- /dev/null +++ b/lms/djangoapps/bulk_email/fixtures/plain-html.txt @@ -0,0 +1,268 @@ + + + + + + Update from {course_title} + + +
+ + + + +
+ + + + + + + + + + + + + + +
+ + + + + +
+ + + + +
+ + + + + +
+ + + + + + +
+ +
+ +
+ +
+
+ +
+ + + + + +
+ + + + +
+ + + + + +
+ + + + +
+ + + edX + + +
+
+ + + + + +
+ + + + + + +
+ +
+ Connect with edX:        
+ +
+ +
+
+ +
+ + + + + +
+ + + + +
+ + + + + +
+ + + + + + +
+ + + + +
+ + + + + +
+ + + + +
+

+ {course_title}

+ +
+
+
+ + + + + +
+ + + + + +
+ + + + + + +
+ {{message_body}} + +
+ +
+ + + + + +
+ + + + +
+ +
+
+ + + + + +
+ + + + + + +
+ +
+        
+ +
+ +
+
+ +
+ + + + + +
+ + + + +
+ + + + + +
+ + + + + + +
+ + Copyright © 2013 edX, All rights reserved.
+
+
+ Our mailing address is:
+ edX
+ 11 Cambridge Center, Suite 101
+ Cambridge, MA, USA 02142
+
+
+This email was automatically sent from {platform_name}.
+You are receiving this email at address {email} because you are enrolled in {course_title}.
+To stop receiving email like this, update your course email settings here.
+
+ +
+
+ +
+ +
+
+ diff --git a/lms/djangoapps/bulk_email/forms.py b/lms/djangoapps/bulk_email/forms.py new file mode 100644 index 0000000000..2ccdd72d16 --- /dev/null +++ b/lms/djangoapps/bulk_email/forms.py @@ -0,0 +1,42 @@ +import logging + +from django import forms +from django.core.exceptions import ValidationError + +from bulk_email.models import CourseEmailTemplate, COURSE_EMAIL_MESSAGE_BODY_TAG + +log = logging.getLogger(__name__) + + +class CourseEmailTemplateForm(forms.ModelForm): + """Form providing validation of CourseEmail templates.""" + + class Meta: + model = CourseEmailTemplate + + def _validate_template(self, template): + """Check the template for required tags.""" + index = template.find(COURSE_EMAIL_MESSAGE_BODY_TAG) + if index < 0: + msg = 'Missing tag: "{}"'.format(COURSE_EMAIL_MESSAGE_BODY_TAG) + log.warning(msg) + raise ValidationError(msg) + if template.find(COURSE_EMAIL_MESSAGE_BODY_TAG, index + 1) >= 0: + msg = 'Multiple instances of tag: "{}"'.format(COURSE_EMAIL_MESSAGE_BODY_TAG) + log.warning(msg) + raise ValidationError(msg) + # TODO: add more validation here, including the set of known tags + # for which values will be supplied. (Email will fail if the template + # uses tags for which values are not supplied.) + + def clean_html_template(self): + """Validate the HTML template.""" + template = self.cleaned_data["html_template"] + self._validate_template(template) + return template + + def clean_plain_template(self): + """Validate the plaintext template.""" + template = self.cleaned_data["plain_template"] + self._validate_template(template) + return template diff --git a/lms/djangoapps/bulk_email/migrations/0006_add_course_email_template.py b/lms/djangoapps/bulk_email/migrations/0006_add_course_email_template.py new file mode 100644 index 0000000000..69ec3fe3b3 --- /dev/null +++ b/lms/djangoapps/bulk_email/migrations/0006_add_course_email_template.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'CourseEmailTemplate' + db.create_table('bulk_email_courseemailtemplate', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('html_template', self.gf('django.db.models.fields.TextField')(null=True, blank=True)), + ('plain_template', self.gf('django.db.models.fields.TextField')(null=True, blank=True)), + )) + db.send_create_signal('bulk_email', ['CourseEmailTemplate']) + + def backwards(self, orm): + # Deleting model 'CourseEmailTemplate' + db.delete_table('bulk_email_courseemailtemplate') + + + 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'}), + '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'}), + 'slug': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'subject': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'text_message': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'to_option': ('django.db.models.fields.CharField', [], {'default': "'myself'", 'max_length': '64'}) + }, + 'bulk_email.courseemailtemplate': { + 'Meta': {'object_name': 'CourseEmailTemplate'}, + 'html_template': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'plain_template': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}) + }, + 'bulk_email.optout': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'Optout'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': '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'] diff --git a/lms/djangoapps/bulk_email/migrations/0007_load_course_email_template.py b/lms/djangoapps/bulk_email/migrations/0007_load_course_email_template.py new file mode 100644 index 0000000000..7ccaaf07f9 --- /dev/null +++ b/lms/djangoapps/bulk_email/migrations/0007_load_course_email_template.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +from south.v2 import DataMigration + + +class Migration(DataMigration): + + def forwards(self, orm): + "Load data from fixture." + from django.core.management import call_command + call_command("loaddata", "course_email_template.json") + + def backwards(self, orm): + "Perform a no-op to go backwards." + pass + + 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'}), + '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'}), + 'slug': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'subject': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'text_message': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'to_option': ('django.db.models.fields.CharField', [], {'default': "'myself'", 'max_length': '64'}) + }, + 'bulk_email.courseemailtemplate': { + 'Meta': {'object_name': 'CourseEmailTemplate'}, + 'html_template': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'plain_template': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}) + }, + 'bulk_email.optout': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'Optout'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': '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'] + symmetrical = True diff --git a/lms/djangoapps/bulk_email/models.py b/lms/djangoapps/bulk_email/models.py index 72c9569cc1..9d32dbd70c 100644 --- a/lms/djangoapps/bulk_email/models.py +++ b/lms/djangoapps/bulk_email/models.py @@ -10,13 +10,13 @@ file and check it in at the same time as your model changes. To do that, 2. ./manage.py lms schemamigration bulk_email --auto description_of_your_change 3. Add the migration file created in edx-platform/lms/djangoapps/bulk_email/migrations/ - -ASSUMPTIONS: modules have unique IDs, even across different module_types - """ +import logging from django.db import models from django.contrib.auth.models import User +log = logging.getLogger(__name__) + class Email(models.Model): """ @@ -33,6 +33,10 @@ class Email(models.Model): class Meta: # pylint: disable=C0111 abstract = True +SEND_TO_MYSELF = 'myself' +SEND_TO_STAFF = 'staff' +SEND_TO_ALL = 'all' + class CourseEmail(Email, models.Model): """ @@ -48,12 +52,12 @@ class CourseEmail(Email, models.Model): # (student, staff, or instructor) # TO_OPTIONS = ( - ('myself', 'Myself'), - ('staff', 'Staff and instructors'), - ('all', 'All') + (SEND_TO_MYSELF, 'Myself'), + (SEND_TO_STAFF, 'Staff and instructors'), + (SEND_TO_ALL, 'All') ) course_id = models.CharField(max_length=255, db_index=True) - to_option = models.CharField(max_length=64, choices=TO_OPTIONS, default='myself') + to_option = models.CharField(max_length=64, choices=TO_OPTIONS, default=SEND_TO_MYSELF) def __unicode__(self): return self.subject @@ -63,8 +67,89 @@ class Optout(models.Model): """ Stores users that have opted out of receiving emails from a course. """ + # Allowing null=True to support data migration from email->user. + # We need to first create the 'user' column with some sort of default in order to run the data migration, + # and given the unique index, 'null' is the best default value. user = models.ForeignKey(User, db_index=True, null=True) course_id = models.CharField(max_length=255, db_index=True) class Meta: # pylint: disable=C0111 unique_together = ('user', 'course_id') + + +# Defines the tag that must appear in a template, to indicate +# the location where the email message body is to be inserted. +COURSE_EMAIL_MESSAGE_BODY_TAG = '{{message_body}}' + + +class CourseEmailTemplate(models.Model): + """ + Stores templates for all emails to a course to use. + + This is expected to be a singleton, to be shared across all courses. + Initialization takes place in a migration that in turn loads a fixture. + The admin console interface disables add and delete operations. + Validation is handled in the CourseEmailTemplateForm class. + """ + html_template = models.TextField(null=True, blank=True) + plain_template = models.TextField(null=True, blank=True) + + @staticmethod + def get_template(): + """ + Fetch the current template + + If one isn't stored, an exception is thrown. + """ + return CourseEmailTemplate.objects.get() + + @staticmethod + def _render(format_string, message_body, context): + """ + Create a text message using a template, message body and context. + + Convert message body (`message_body`) into an email message + using the provided template. The template is a format string, + which is rendered using format() with the provided `context` dict. + + This doesn't insert user's text into template, until such time we can + support proper error handling due to errors in the message body + (e.g. due to the use of curly braces). + + Instead, for now, we insert the message body *after* the substitutions + have been performed, so that anything in the message body that might + interfere will be innocently returned as-is. + + Output is returned as a unicode string. It is not encoded as utf-8. + Such encoding is left to the email code, which will use the value + of settings.DEFAULT_CHARSET to encode the message. + """ + # If we wanted to support substitution, we'd call: + # format_string = format_string.replace(COURSE_EMAIL_MESSAGE_BODY_TAG, message_body) + result = format_string.format(**context) + # Note that the body tag in the template will now have been + # "formatted", so we need to do the same to the tag being + # searched for. + message_body_tag = COURSE_EMAIL_MESSAGE_BODY_TAG.format() + result = result.replace(message_body_tag, message_body, 1) + + # finally, return the result, without converting to an encoded byte array. + return result + + def render_plaintext(self, plaintext, context): + """ + Create plain text message. + + Convert plain text body (`plaintext`) into plaintext email message using the + stored plain template and the provided `context` dict. + """ + return CourseEmailTemplate._render(self.plain_template, plaintext, context) + + def render_htmltext(self, htmltext, context): + """ + Create HTML text message. + + Convert HTML text body (`htmltext`) into HTML email message using the + stored HTML template and the provided `context` dict. + """ + return CourseEmailTemplate._render(self.html_template, htmltext, context) diff --git a/lms/djangoapps/bulk_email/tasks.py b/lms/djangoapps/bulk_email/tasks.py index 7afd286570..2153090844 100644 --- a/lms/djangoapps/bulk_email/tasks.py +++ b/lms/djangoapps/bulk_email/tasks.py @@ -5,7 +5,6 @@ to a course. import math import re import time -import gc from smtplib import SMTPServerDisconnected, SMTPDataError, SMTPConnectError @@ -15,11 +14,14 @@ from django.core.mail import EmailMultiAlternatives, get_connection from django.http import Http404 from celery import task, current_task from celery.utils.log import get_task_logger +from django.core.urlresolvers import reverse -from bulk_email.models import CourseEmail, Optout +from bulk_email.models import ( + CourseEmail, Optout, CourseEmailTemplate, + SEND_TO_MYSELF, SEND_TO_STAFF, SEND_TO_ALL, +) 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 +from courseware.courses import get_course_by_id, course_image_url log = get_task_logger(__name__) @@ -44,12 +46,30 @@ def delegate_email_batches(email_id, to_option, course_id, course_url, user_id): try: CourseEmail.objects.get(id=email_id) except CourseEmail.DoesNotExist as exc: + # 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. + # We might possibly want to move the blocking into the view function rather than have it in this task. log.warning("Failed to get CourseEmail with id %s, retry %d", email_id, current_task.request.retries) - raise delegate_email_batches.retry(arg=[email_id, to_option, course_id, course_url, user_id], exc=exc) + raise delegate_email_batches.retry(arg=[email_id, user_id], exc=exc) - if to_option == "myself": + to_option = email_obj.to_option + course_id = email_obj.course_id + + try: + course = get_course_by_id(course_id, depth=1) + except Http404 as exc: + log.exception("get_course_by_id failed: %s", exc.args[0]) + raise Exception("get_course_by_id failed: " + exc.args[0]) + + course_url = 'https://{}{}'.format( + settings.SITE_NAME, + reverse('course_root', kwargs={'course_id': course_id}) + ) + image_url = 'https://{}{}'.format(settings.SITE_NAME, course_image_url(course)) + + if to_option == SEND_TO_MYSELF: recipient_qset = User.objects.filter(id=user_id) - elif to_option == "all" or to_option == "staff": + elif to_option == SEND_TO_ALL or to_option == SEND_TO_STAFF: staff_grpname = _course_staff_group_name(course.location) staff_group, _ = Group.objects.get_or_create(name=staff_grpname) staff_qset = staff_group.user_set.all() @@ -58,7 +78,7 @@ def delegate_email_batches(email_id, to_option, course_id, course_url, user_id): instructor_qset = instructor_group.user_set.all() recipient_qset = staff_qset | instructor_qset - if to_option == "all": + if to_option == SEND_TO_ALL: enrollment_qset = User.objects.filter(courseenrollment__course_id=course_id, courseenrollment__is_active=True) recipient_qset = recipient_qset | enrollment_qset @@ -67,12 +87,13 @@ 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) 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') total_num_emails = recipient_qset.count() num_queries = int(math.ceil(float(total_num_emails) / float(settings.EMAILS_PER_QUERY))) last_pk = recipient_qset[0].pk - 1 num_workers = 0 - for j in range(num_queries): + for _ in range(num_queries): recipient_sublist = list(recipient_qset.order_by('pk').filter(pk__gt=last_pk) .values('profile__name', 'email', 'pk')[:settings.EMAILS_PER_QUERY]) last_pk = recipient_sublist[-1]['pk'] @@ -81,76 +102,86 @@ def delegate_email_batches(email_id, to_option, course_id, course_url, user_id): chunk = int(math.ceil(float(num_emails_this_query) / float(num_tasks_this_query))) for i in range(num_tasks_this_query): to_list = recipient_sublist[i * chunk:i * chunk + chunk] - course_email.delay(email_id, to_list, course.display_name, course_url, False) + course_email.delay( + email_id, + to_list, + course.display_name, + course_url, + image_url, + False + ) num_workers += num_tasks_this_query - gc.collect() return num_workers @task(default_retry_delay=15, max_retries=5) # pylint: disable=E1102 -def course_email(email_id, to_list, course_title, course_url, throttle=False): +def course_email(email_id, to_list, course_title, course_url, image_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 + Takes a primary id for a CourseEmail object and a 'to_list' of recipient objects--keys are + 'profile__name', 'email' (address), and 'pk' (in the user table). + course_title, course_url, and image_url are to memoize course properties and save lookups. + + Sends to all addresses contained in to_list. Emails are sent multi-part, in both plain text and html. """ - try: msg = CourseEmail.objects.get(id=email_id) - except CourseEmail.DoesNotExist as exc: - log.exception(exc.args[0]) - raise exc + except CourseEmail.DoesNotExist: + log.exception("Could not find email id:{} to send.".format(email_id)) + raise # exclude optouts - optouts = Optout.objects.filter(course_id=msg.course_id, - user__email__in=[i['email'] for i in to_list])\ - .values_list('user__email', flat=True) + optouts = (Optout.objects.filter(course_id=msg.course_id, + user__in=[i['pk'] for i in to_list]) + .values_list('user__email', flat=True)) num_optout = len(optouts) - to_list = filter(lambda x: x['email'] not in optouts, to_list) + to_list = filter(lambda x: x['email'] not in set(optouts), to_list) subject = "[" + course_title + "] " + msg.subject course_title_no_quotes = re.sub(r'"', '', course_title) from_addr = '"{0}" Course Staff <{1}>'.format(course_title_no_quotes, settings.DEFAULT_BULK_FROM_EMAIL) + course_email_template = CourseEmailTemplate.get_template() + try: connection = get_connection() connection.open() num_sent = 0 num_error = 0 + # Define context values to use in all course emails: email_context = { 'name': '', 'email': '', 'course_title': course_title, - 'course_url': course_url + 'course_url': course_url, + 'course_image_url': image_url, + 'account_settings_url': 'https://{}{}'.format(settings.SITE_NAME, reverse('dashboard')), + 'platform_name': settings.PLATFORM_NAME, } while to_list: + # Update context with user-specific values: email = to_list[-1]['email'] email_context['email'] = email email_context['name'] = to_list[-1]['profile__name'] - html_footer = render_to_string( - 'emails/email_footer.html', - email_context - ) + # Construct message content using templates and context: + plaintext_msg = course_email_template.render_plaintext(msg.text_message, email_context) + html_msg = course_email_template.render_htmltext(msg.html_message, email_context) - plain_footer = render_to_string( - 'emails/email_footer.txt', - email_context - ) + # Create email: email_msg = EmailMultiAlternatives( subject, - msg.text_message + plain_footer.encode('utf-8'), + plaintext_msg, from_addr, [email], connection=connection ) - email_msg.attach_alternative(msg.html_message + html_footer.encode('utf-8'), 'text/html') + email_msg.attach_alternative(html_msg, 'text/html') # Throttle if we tried a few times and got the rate limiter if throttle or current_task.request.retries > 0: @@ -183,6 +214,7 @@ def course_email(email_id, to_list, course_title, course_url, throttle=False): to_list, course_title, course_url, + image_url, current_task.request.retries > 0 ], exc=exc, diff --git a/lms/djangoapps/bulk_email/tests/test_course_optout.py b/lms/djangoapps/bulk_email/tests/test_course_optout.py index 18f04cebc1..0adf119527 100644 --- a/lms/djangoapps/bulk_email/tests/test_course_optout.py +++ b/lms/djangoapps/bulk_email/tests/test_course_optout.py @@ -4,6 +4,7 @@ Unit tests for student optouts from course email import json from django.core import mail +from django.core.management import call_command from django.core.urlresolvers import reverse from django.conf import settings from django.test.utils import override_settings @@ -30,6 +31,9 @@ class TestOptoutCourseEmails(ModuleStoreTestCase): self.student = UserFactory.create() CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) + # load initial content (since we don't run migrations as part of tests): + call_command("loaddata", "course_email_template.json") + self.client.login(username=self.student.username, password="test") def tearDown(self): diff --git a/lms/djangoapps/bulk_email/tests/test_email.py b/lms/djangoapps/bulk_email/tests/test_email.py index b0cf8dc06e..ba2633f263 100644 --- a/lms/djangoapps/bulk_email/tests/test_email.py +++ b/lms/djangoapps/bulk_email/tests/test_email.py @@ -2,10 +2,11 @@ """ Unit tests for sending course email """ -from django.test.utils import override_settings from django.conf import settings from django.core import mail from django.core.urlresolvers import reverse +from django.core.management import call_command +from django.test.utils import override_settings from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE from student.tests.factories import UserFactory, GroupFactory, CourseEnrollmentFactory @@ -63,6 +64,9 @@ class TestEmailSendFromDashboard(ModuleStoreTestCase): for student in self.students: CourseEnrollmentFactory.create(user=student, course_id=self.course.id) + # load initial content (since we don't run migrations as part of tests): + call_command("loaddata", "course_email_template.json") + self.client.login(username=self.instructor.username, password="test") # Pull up email view on instructor dashboard @@ -208,10 +212,8 @@ class TestEmailSendFromDashboard(ModuleStoreTestCase): [self.instructor.email] + [s.email for s in self.staff] + [s.email for s in self.students] ) - self.assertIn( - uni_message, - mail.outbox[0].body - ) + message_body = mail.outbox[0].body + self.assertIn(uni_message, message_body) def test_unicode_students_send_to_all(self): """ @@ -273,11 +275,12 @@ class TestEmailSendFromDashboard(ModuleStoreTestCase): self.assertContains(response, "Your email was successfully queued for sending.") self.assertEquals(mock_factory.emails_sent, 1 + len(self.staff) + len(self.students) + LARGE_NUM_EMAILS - len(optouts)) - 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] + - [s.email for s in added_users if s not in optouts] - ) + outbox_contents = [e.to[0] for e in mail.outbox] + should_send_contents = ([self.instructor.email] + + [s.email for s in self.staff] + + [s.email for s in self.students] + + [s.email for s in added_users if s not in optouts]) + self.assertItemsEqual(outbox_contents, should_send_contents) @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) @@ -294,4 +297,4 @@ class TestEmailSendExceptions(ModuleStoreTestCase): def test_no_course_email_obj(self): # Make sure course_email handles CourseEmail.DoesNotExist exception. with self.assertRaises(CourseEmail.DoesNotExist): - course_email(101, [], "_", "_", False) + course_email(101, [], "_", "_", "_", False) diff --git a/lms/djangoapps/bulk_email/tests/test_err_handling.py b/lms/djangoapps/bulk_email/tests/test_err_handling.py index 606a0bef88..e8874ea18e 100644 --- a/lms/djangoapps/bulk_email/tests/test_err_handling.py +++ b/lms/djangoapps/bulk_email/tests/test_err_handling.py @@ -4,6 +4,7 @@ Unit tests for handling email sending errors from django.test.utils import override_settings from django.conf import settings +from django.core.management import call_command from django.core.urlresolvers import reverse from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE @@ -35,6 +36,9 @@ class TestEmailErrors(ModuleStoreTestCase): instructor = AdminFactory.create() self.client.login(username=instructor.username, password="test") + # load initial content (since we don't run migrations as part of tests): + call_command("loaddata", "course_email_template.json") + self.smtp_server_thread = FakeSMTPServerThread('localhost', TEST_SMTP_PORT) self.smtp_server_thread.start() diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index 15b0df59c1..7326fe2e98 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -718,7 +718,13 @@ def instructor_dashboard(request, course_id): email.save() course_url = request.build_absolute_uri(reverse('course_root', kwargs={'course_id': course_id})) - tasks.delegate_email_batches.delay(email.id, email.to_option, course_id, course_url, request.user.id) + tasks.delegate_email_batches.delay( + email.id, + email.to_option, + course_id, + course_url, + request.user.id + ) if to_option == "all": email_msg = '

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.

' diff --git a/lms/static/images/bulk_email/FacebookIcon.png b/lms/static/images/bulk_email/FacebookIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..7d2d8c4468ae2d1c1c9c7d2922d23dabf38b063c GIT binary patch literal 550 zcmeAS@N?(olHy`uVBq!ia0vp^J|N7&1|*M957Y)yk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*9U+7*BY*IEGZ*dUNN!Z?l8M@sEcs7}i&|N|!L-=1pNrY+5E9 zp`J0Pp^V9jDbGP&;QohQ%0K2Ls6;)~>-zKi-=y7JGM#7n_nkic^+4cP6<3YFQMa5X z_Rm==yEHfC!Q8o0VLG)P{>pbwt=zEfl6}IZS$70=!sGUM6o1S(A8H!kn98`Vb>HGJ_>9xPZg06ULZr8dcUd(jagZDu4gNkMC#8y))6 z(Z%xMgyu~-Td%Mx)<)0z1rf=tr+*)k(vG+yeof+F&_OTFn6sZfmgXCmf3?|lEG+Cs zvTCdT-?>@w3SXCVlx;e~wY2|vjaS+EBR9&E}Ns+DElwx=Q5kVpBpD7rE?wA%&gR8m6&Xo(7f`R8B0`K%L=ly9$mdtt$F2mjGKg09N{!=eRe1rSSnQ0Rq#Y<~Nu8dKQx7 zZTaU|2bhW^S!e}N!7w@dV~r1R!%tK+tSkqK`1Wi?W7I$mgjV^KOj54KjIBASRB{s| zVHP8yc`Q%r48s};SR-ma?9?|+e`pjwX~@$ar9Lk{hGOj;2Pg8~f*8Tp*;!zZ2G6lk+;5## z3C;mSbMy?J!xNc$m6oM0XqimIY$S?m8XF9wle*LylQiBI5m8q~Ua5C0&Y@wsmPHEt zy)k*KTrDA1m0&nArD3-6p|R-hR>{saleFnWR8owTvLBNHvstPqe{7R8chuq!*Gms0W8SPo|pqt4GbgZ!~nEu$>_n> zm0D7*_(s3e1RBg~yq3TkTo$lzXm>S}+Ei%P{1RZ*1C|%&_@?}xmA@8)Krzn%W8jdb z56-~?>1A^e&_|cun4QvR;fjMV(l8ua!vPY{GOU%$uue@9%;Ym$;9vt>v*8v}CRsEb z3(ozWnV4(E2k_yd&pMTxiXzw-H(XQ$dJJdFs%p;g5@1yUJC(6C8mjf@|F8>@rf-d2 z*p*3BV0**+T|BVl+BC5#Ftx!IoG@H>!0Pm`u%3*r9az2S7YwHi`)gMVEUaA>V9k2( zY9KH%j8eZ3kCnqcBR&ge3z#q3HFcq-VhV75ZNNY_q9MpMz|CFqYgMIDDsruS^=+Pe zr3L#kO=uAZ4#y?mRFnaWj=B`*bIjn^;scmy%m}}3%iB9(PS31pQRz7a7glT;v4y3_ zz!sTSsb~|X5F@euI=U&@G#@zgrPc!=46UaC$QKFfSi`^U8NM~zX+ry?=F@rw7YlGq z{%Dcjq6Cb*A9R7CMs1j#Y3D>}TT&C+j1d}AIsSF0P+XX86OgYhFNd~eL`K%z`gJGp zX@PP;Ko_y40Sq;3)55t^0d41!mO*Ls*wVQ%bx>axNTO@oCvXiLMQFPYSRH7XSnoSv z^*SCMuoH1&D45dP#Ibf??To002w~1^@s6B}+P} z0u>lKkP3_yXeuzNK&=2%fpid3L44vhq=Mv*JCzZIJA_g0{(olCNgTj0yLY?G^e=Jua!)1Ul7*S~w7(JbK)MNu^>-yjg-ptvpMF z-w-V7Wnmy!g9QN=U;!3j0Ty6El6q*V;7p2{E?||#08RH5d7&WRcP=Kn73EFCkX7M+ z2^3S{WYp&Q@443*4T}K?b0}2`9PW9B!&=pXIs#ki(1E>&Uou+Cr0Z6Z%hFf1p0z9lSnbG~F!%5Hjl zp~&{7uNb9&Cy=IR+e625$A_XnN{3>09-9`1ELHITah-TWGUy*H+4_IgASx-%C)pWw@87THV@|*BwoikyJ{AW=*Z!mUcdcxu`78T>nOc|KsSZMVG zlqbBXP7@KP2E*b@0c2}Ym0IvuM_4KVRGud7bcCg%Az{Q(5GZPua9Dw}0kCRN)coTB&v*BDBn_54YpfmN hYv$Si3uXTT3;>e$qS@nD4OsvH002ovPDHLkV1nW0MT-Cc literal 0 HcmV?d00001 diff --git a/lms/static/images/bulk_email/MeetupIcon.png b/lms/static/images/bulk_email/MeetupIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..9af4655ff2e6f9703ec62efe735c2da855c06a05 GIT binary patch literal 1283 zcmV+e1^oJnP)002w~1^@s6B}+Pe34~EebXu-E2nqmg(&7sjH?SPmo0wg_ zbWp241Vz345RGaFOB{L&SRw*TUcw*XEjwP4zu&W0@Fit`85Dr4_&1tS zpPAaCkiZ%<3z+tfm=+J(!U`%P;IP76jNdBT{@O8x3E4HD74;S&gFiHN1jLLs#EkMl zp|U45=34F+X6V>!&Ahuu9urv1>y_Dm-(ognhsjb61tlY|Y2iwZiwenqXt^qcKe$jaP@7p|*5}YgeCi za!{&o)O)hzVD$b8@jn`!y52Tk#e*#@T9-RF`pMdVUH}CqXmTxTDG#pXw5`utT73^x zuHyGSRtchl78y9v9@?%Q5qV7Ro0K%jfa(|eSFwG;9SzH&+DFw-91UHubAdBO$Wse5 z-C2_k-j{N)ChJz^Xy&tA>H#Boz}m+_O%7S2iOBmiHflmNPj5SG&aGI3C!`aQpQ$YG zF;k0=?AUZ9zh<6(_L$Ar%sGW&Kkw9MMO$fpHYfgSg@ZDvNQY*1pz0^mquzz(GXT#JNVL(JiiaBo;@TAh^WpHq3X2TeuVgv_r*(J8y1c-&zcE}T1n`Sx{f`w+eLn>)B?)e{;2B? t7)%&pZ_`kqWdgwuwmdz-j-EdP3;>D#lVm#t=oSC~002ovPDHLkV1jiXNdN!< literal 0 HcmV?d00001 diff --git a/lms/static/images/bulk_email/TwitterIcon.png b/lms/static/images/bulk_email/TwitterIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..8b9896a353eb22509e5c253a14792291815ea82d GIT binary patch literal 998 zcmV2Dm06p=nRFK@>?RlhxFG=?Wv;UdH7#KMHZg+3B zchZ2>mF=GS<2`R7BPo@<{orlI;!!m~Pyv73@D>rDgDQBN4)LEQ!8$46lMT3af%|QV z$9Vz^n-PX~zyiY14j74m5ikNqzz7(LfDtePM!*Od0VB21rwi^o6!dc5e%D^R<84B~ zno4;7q~->qiw^Y#vIhcL0F)8RPpxApWPyqx+(+?@0Bhs zY=NWsY7o{`z&Ka(vr}$yOLW_uzm}sFLb*9{1q?=XjU8bIpjEA5HNidZaoNs&9BOsP zcm(GDpc&X>XIT5=TYYt`Z~^S+KO>roSR*Z?uS)zr9k3>7Sp^R2Z5Lo>X32O|@D7f# zgMFs?7ma<&84md+Taz`;SKB5w5!?$Vkd{rJW_x{zOY&{tc& z=lp;xjB)Qwm3aR%pFak!v z2pCBKs|Z6oU~9tA2AI6yd_^eEQm$ectmqWP07L%^ U){vp+yZ`_I07*qoM6N<$f@l`a^8f$< literal 0 HcmV?d00001 diff --git a/lms/static/images/bulk_email/VKontakteIcon.png b/lms/static/images/bulk_email/VKontakteIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..80312af6cfd9a99e5c7a41fd940a73c8712ab443 GIT binary patch literal 2480 zcmb7`YdjMQAIDd6kFqim=5oSxaZQ=a+}T26E(f_BXDp=^N_IAvYMDmJD#|4^5vIr` ztBrGph~rXiWwg_@D3_TmquGx0e4h8ud0za#zu$}B`|m$Dz~5U@%B|-(qrq8k{e|UANgcGeWQF_6 zrvfkz8>bANKAFh|SQZ9OwnhC})NDGDV+A!A&YL{&ZyW^fY{Z<{9cx_Q5Y%hDM-G>}39QMHH zUxIv|?f2^4e@rdB{N_&nM@m<(AknxFF<7kA@8QK&#pvzF!{QC{p4_~9t1h!q$!bEm zA2%~PXiV5U7Xz}-jSkOU?|h$gJz*k$H_QriYHjh#)@JVMC-AE}q7sWj|pU3ymWF*xaT>nF%ebGpN zhOU`)l;u`d*+fl(M6Bmgj3Nze_F?wA@Q_*C&+97K}rf zf0QkpfE?tDOmtd7y&bl7`^p5w+18TuR{v*vWUlKKRnMcSbN4NYw?em+e))hrg|+aw zujJrUzckgnTY4x$8pFm?M37wC`@k-g|S_jqSP>>>UTdHEkXG{$nQTUud3NF)s^OCa3p=?^-Jx%nrx5rbmO3Sx;vT=sDNUDqys>F*EJ>Rd*Cyc2$y1oo*A44;CA< z|DhfR#Hw|n{;|2)hymf1~( zl3h#hONa%M<45tH+CLqx2@W0BH*#La%m{MEA;5^cCuo!eqQG0~TFXr-XPccKjG z4Dv@i%A5W;ProNUFfW{}e#HIS7!0!3b|--#j;)sE3wtW86V_CZ&#f3pzdd7k$m+*b zcJfNh*Md5yrwlW~6!vO%2iiKSmVFNs%&Z0q??J(^ZQ~S^ImSrba0=UD)ZOn3bCie6 zh^7ODtKQXgM>F9u&Xy}m^#Lb?)E}i{Vtm(&nhpGzt0|^ooeQ*i5nqXJaY>}|W{=U} zvw_!dRKZvC%7|1XdJXZ*ipx(%2Kw{nR; z;3*N1ngmNvM-(-(cyT}&LiK0K20tECjq$$&$n zF`z>es};oNWiIVx&T_Uw%SDc&sT3i7GF4#Fmc8hQu3mY8Smzqiy*Ao8Q^2*V*m0Qh zY}1_#PH{Pb%2qW3+Bf;=(rwbt~Ci=Ixh_ q7zE`}Y>a-Cb3SuU4W~#X{Q@n@( literal 0 HcmV?d00001 diff --git a/lms/static/images/bulk_email/edXHeaderImage.jpg b/lms/static/images/bulk_email/edXHeaderImage.jpg new file mode 100644 index 0000000000000000000000000000000000000000..fc5e4d68de92d4f4c06b10cf2a623f4c2c2bf7af GIT binary patch literal 25814 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 Date: Tue, 27 Aug 2013 10:22:25 -0700 Subject: [PATCH 11/11] Bulk email - final tweaks and cleanup --- .../fixtures/course_email_template.json | 2 +- lms/djangoapps/bulk_email/tasks.py | 24 +++--- .../bulk_email/tests/test_err_handling.py | 77 ++++++++++++++++++- .../tests/test_legacy_email.py} | 13 ++-- lms/djangoapps/instructor/views/legacy.py | 30 ++++---- lms/templates/emails/email_footer.html | 6 -- lms/templates/emails/email_footer.txt | 7 -- 7 files changed, 107 insertions(+), 52 deletions(-) rename lms/djangoapps/{bulk_email/tests/tests.py => instructor/tests/test_legacy_email.py} (92%) delete mode 100644 lms/templates/emails/email_footer.html delete mode 100644 lms/templates/emails/email_footer.txt diff --git a/lms/djangoapps/bulk_email/fixtures/course_email_template.json b/lms/djangoapps/bulk_email/fixtures/course_email_template.json index 076dedbd14..bea136df38 100644 --- a/lms/djangoapps/bulk_email/fixtures/course_email_template.json +++ b/lms/djangoapps/bulk_email/fixtures/course_email_template.json @@ -3,7 +3,7 @@ "pk": 1, "model": "bulk_email.courseemailtemplate", "fields": { - "plain_template": "{course_title}\n\n{{message_body}}\r\n----\r\nCopyright 2013 edX, All rights reserved.\r\n----\r\nConnect with edX: Facebook (http://facebook.com/edxonline)\nTwitter (http://twitter.com/edxonline)\nGoogle+ (https://plus.google.com/108235383044095082735)\nMeetup (http://www.meetup.com/edX-Communities/)\r\n----\r\n This email was automatically sent from {platform_name}.\r\nYou are receiving this email at address {email} because you are enrolled in {course_title}\r\n(URL: {course_url} ).\r\nTo stop receiving email like this, update your account settings at {account_settings_url}.\r\n", + "plain_template": "{course_title}\n\n{{message_body}}\r\n----\r\nCopyright 2013 edX, All rights reserved.\r\n----\r\nConnect with edX:\r\nFacebook (http://facebook.com/edxonline)\r\nTwitter (http://twitter.com/edxonline)\r\nGoogle+ (https://plus.google.com/108235383044095082735)\r\nMeetup (http://www.meetup.com/edX-Communities/)\r\n----\r\nThis email was automatically sent from {platform_name}.\r\nYou are receiving this email at address {email} because you are enrolled in {course_title}\r\n(URL: {course_url} ).\r\nTo stop receiving email like this, update your account settings at {account_settings_url}.\r\n", "html_template": " Update from {course_title}

edX
Connect with edX:        

{course_title}


{{message_body}}
       
Copyright © 2013 edX, All rights reserved.


Our mailing address is:
edX
11 Cambridge Center, Suite 101
Cambridge, MA, USA 02142


This email was automatically sent from {platform_name}.
You are receiving this email at address {email} because you are enrolled in {course_title}.
To stop receiving email like this, update your course email settings here.
" } } diff --git a/lms/djangoapps/bulk_email/tasks.py b/lms/djangoapps/bulk_email/tasks.py index 2153090844..c9be4f5347 100644 --- a/lms/djangoapps/bulk_email/tasks.py +++ b/lms/djangoapps/bulk_email/tasks.py @@ -27,24 +27,16 @@ log = get_task_logger(__name__) @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 get the mail, chopping up into batches of settings.EMAILS_PER_TASK size, and queueing up worker jobs. - `to_option` is {'myself', '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: %s", exc.args[0]) - raise Exception("get_course_by_id failed: " + exc.args[0]) - - try: - CourseEmail.objects.get(id=email_id) + email_obj = CourseEmail.objects.get(id=email_id) except CourseEmail.DoesNotExist as exc: # 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. @@ -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) 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') total_num_emails = recipient_qset.count() 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 user__in=[i['pk'] for i in to_list]) .values_list('user__email', flat=True)) + optouts = set(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 @@ -208,6 +200,9 @@ def course_email(email_id, to_list, course_title, course_url, image_url, throttl 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 + # 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( arg=[ email_id, @@ -220,6 +215,11 @@ def course_email(email_id, to_list, course_title, course_url, image_url, throttl exc=exc, 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 diff --git a/lms/djangoapps/bulk_email/tests/test_err_handling.py b/lms/djangoapps/bulk_email/tests/test_err_handling.py index e8874ea18e..61bdd315e9 100644 --- a/lms/djangoapps/bulk_email/tests/test_err_handling.py +++ b/lms/djangoapps/bulk_email/tests/test_err_handling.py @@ -12,14 +12,20 @@ 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.models import CourseEmail +from bulk_email.tasks import delegate_email_batches from bulk_email.tests.smtp_server_thread import FakeSMTPServerThread -from mock import patch +from mock import patch, Mock from smtplib import SMTPDataError, SMTPServerDisconnected, SMTPConnectError TEST_SMTP_PORT = 1025 +class EmailTestException(Exception): + pass + + @override_settings( MODULESTORE=TEST_DATA_MONGO_MODULESTORE, EMAIL_BACKEND='django.core.mail.backends.smtp.EmailBackend', @@ -33,8 +39,8 @@ class TestEmailErrors(ModuleStoreTestCase): def setUp(self): self.course = CourseFactory.create() - instructor = AdminFactory.create() - self.client.login(username=instructor.username, password="test") + self.instructor = AdminFactory.create() + self.client.login(username=self.instructor.username, password="test") # load initial content (since we don't run migrations as part of tests): call_command("loaddata", "course_email_template.json") @@ -145,3 +151,68 @@ class TestEmailErrors(ModuleStoreTestCase): (_, kwargs) = retry.call_args exc = kwargs['exc'] 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) diff --git a/lms/djangoapps/bulk_email/tests/tests.py b/lms/djangoapps/instructor/tests/test_legacy_email.py similarity index 92% rename from lms/djangoapps/bulk_email/tests/tests.py rename to lms/djangoapps/instructor/tests/test_legacy_email.py index 210ae63df0..d8761466b0 100644 --- a/lms/djangoapps/bulk_email/tests/tests.py +++ b/lms/djangoapps/instructor/tests/test_legacy_email.py @@ -99,12 +99,12 @@ class TestStudentDashboardEmailView(ModuleStoreTestCase): # URL for dashboard self.url = reverse('dashboard') # URL for email settings modal - self.email_modal_link = ''.format( - self.course.org, - self.course.number, - self.course.display_name.replace(' ', '_') - ) - + self.email_modal_link = (('') + .format(self.course.org, + self.course.number, + self.course.display_name.replace(' ', '_'))) def tearDown(self): """ @@ -118,7 +118,6 @@ class TestStudentDashboardEmailView(ModuleStoreTestCase): response = self.client.get(self.url) self.assertTrue(self.email_modal_link in response.content) - @patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': False}) def test_email_flag_false(self): # Assert that the URL for the email view is not in the response diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index 7326fe2e98..b9295557d7 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -84,8 +84,8 @@ def instructor_dashboard(request, course_id): msg = '' email_msg = '' - to_option = None - subject = None + email_to_option = None + email_subject = None html_message = '' show_email_tab = False problems = [] @@ -703,30 +703,26 @@ def instructor_dashboard(request, course_id): # email elif action == 'Send email': - to_option = request.POST.get("to_option") - subject = request.POST.get("subject") + email_to_option = request.POST.get("to_option") + email_subject = request.POST.get("subject") html_message = request.POST.get("message") text_message = html_to_text(html_message) email = CourseEmail(course_id=course_id, sender=request.user, - to_option=to_option, - subject=subject, + to_option=email_to_option, + subject=email_subject, html_message=html_message, text_message=text_message) email.save() - course_url = request.build_absolute_uri(reverse('course_root', kwargs={'course_id': course_id})) tasks.delegate_email_batches.delay( email.id, - email.to_option, - course_id, - course_url, request.user.id ) - if to_option == "all": + if email_to_option == "all": email_msg = '

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.

' else: email_msg = '

Your email was successfully queued for sending.

' @@ -799,9 +795,9 @@ def instructor_dashboard(request, course_id): # HTML editor for email if idash_mode == 'Email': 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: - editor = None + email_editor = None # Flag for whether or not we display the email tab (depending upon # what backing store this course using (Mongo vs. XML)) @@ -825,11 +821,13 @@ def instructor_dashboard(request, course_id): 'course_stats': course_stats, 'msg': msg, 'modeflag': {idash_mode: 'selectedmode'}, - 'to_option': to_option, # email - 'subject': subject, # email - 'editor': editor, # email + + 'to_option': email_to_option, # email + 'subject': email_subject, # email + 'editor': email_editor, # email 'email_msg': email_msg, # email 'show_email_tab': show_email_tab, # email + 'problems': problems, # psychometrics 'plots': plots, # psychometrics 'course_errors': modulestore().get_item_errors(course.location), diff --git a/lms/templates/emails/email_footer.html b/lms/templates/emails/email_footer.html deleted file mode 100644 index ad9813c601..0000000000 --- a/lms/templates/emails/email_footer.html +++ /dev/null @@ -1,6 +0,0 @@ -<%! 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 }.
-To stop receiving email like this, update your course email settings here.
diff --git a/lms/templates/emails/email_footer.txt b/lms/templates/emails/email_footer.txt deleted file mode 100644 index 95dffc218e..0000000000 --- a/lms/templates/emails/email_footer.txt +++ /dev/null @@ -1,7 +0,0 @@ -<%! 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')}.