Commit 8f93051d by Brian Wilson Committed by Sarina Canelake

Add editable templates for bulk email

Adds the edX Marketing-approved template as html default.
parent d341d6d2
...@@ -3,7 +3,8 @@ Django admin page for bulk email models ...@@ -3,7 +3,8 @@ Django admin page for bulk email models
""" """
from django.contrib import admin 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): class CourseEmailAdmin(admin.ModelAdmin):
...@@ -16,5 +17,45 @@ class OptoutAdmin(admin.ModelAdmin): ...@@ -16,5 +17,45 @@ class OptoutAdmin(admin.ModelAdmin):
list_display = ('user', 'course_id') 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(CourseEmail, CourseEmailAdmin)
admin.site.register(Optout, OptoutAdmin) admin.site.register(Optout, OptoutAdmin)
admin.site.register(CourseEmailTemplate, CourseEmailTemplateAdmin)
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
# -*- 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']
# -*- 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
...@@ -10,13 +10,13 @@ file and check it in at the same time as your model changes. To do that, ...@@ -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 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/ 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.db import models
from django.contrib.auth.models import User from django.contrib.auth.models import User
log = logging.getLogger(__name__)
class Email(models.Model): class Email(models.Model):
""" """
...@@ -33,6 +33,10 @@ class Email(models.Model): ...@@ -33,6 +33,10 @@ class Email(models.Model):
class Meta: # pylint: disable=C0111 class Meta: # pylint: disable=C0111
abstract = True abstract = True
SEND_TO_MYSELF = 'myself'
SEND_TO_STAFF = 'staff'
SEND_TO_ALL = 'all'
class CourseEmail(Email, models.Model): class CourseEmail(Email, models.Model):
""" """
...@@ -48,12 +52,12 @@ class CourseEmail(Email, models.Model): ...@@ -48,12 +52,12 @@ class CourseEmail(Email, models.Model):
# (student, staff, or instructor) # (student, staff, or instructor)
# #
TO_OPTIONS = ( TO_OPTIONS = (
('myself', 'Myself'), (SEND_TO_MYSELF, 'Myself'),
('staff', 'Staff and instructors'), (SEND_TO_STAFF, 'Staff and instructors'),
('all', 'All') (SEND_TO_ALL, 'All')
) )
course_id = models.CharField(max_length=255, db_index=True) 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): def __unicode__(self):
return self.subject return self.subject
...@@ -63,8 +67,89 @@ class Optout(models.Model): ...@@ -63,8 +67,89 @@ class Optout(models.Model):
""" """
Stores users that have opted out of receiving emails from a course. 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) user = models.ForeignKey(User, db_index=True, null=True)
course_id = models.CharField(max_length=255, db_index=True) course_id = models.CharField(max_length=255, db_index=True)
class Meta: # pylint: disable=C0111 class Meta: # pylint: disable=C0111
unique_together = ('user', 'course_id') 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)
...@@ -5,7 +5,6 @@ to a course. ...@@ -5,7 +5,6 @@ to a course.
import math import math
import re import re
import time import time
import gc
from smtplib import SMTPServerDisconnected, SMTPDataError, SMTPConnectError from smtplib import SMTPServerDisconnected, SMTPDataError, SMTPConnectError
...@@ -15,11 +14,14 @@ from django.core.mail import EmailMultiAlternatives, get_connection ...@@ -15,11 +14,14 @@ from django.core.mail import EmailMultiAlternatives, get_connection
from django.http import Http404 from django.http import Http404
from celery import task, current_task from celery import task, current_task
from celery.utils.log import get_task_logger 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.access import _course_staff_group_name, _course_instructor_group_name
from courseware.courses import get_course_by_id from courseware.courses import get_course_by_id, course_image_url
from mitxmako.shortcuts import render_to_string
log = get_task_logger(__name__) log = get_task_logger(__name__)
...@@ -44,12 +46,30 @@ def delegate_email_batches(email_id, to_option, course_id, course_url, user_id): ...@@ -44,12 +46,30 @@ def delegate_email_batches(email_id, to_option, course_id, course_url, user_id):
try: try:
CourseEmail.objects.get(id=email_id) CourseEmail.objects.get(id=email_id)
except CourseEmail.DoesNotExist as exc: except CourseEmail.DoesNotExist as exc:
# The retry behavior here is necessary because of a race condition between the commit of the transaction
# 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) 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) 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_grpname = _course_staff_group_name(course.location)
staff_group, _ = Group.objects.get_or_create(name=staff_grpname) staff_group, _ = Group.objects.get_or_create(name=staff_grpname)
staff_qset = staff_group.user_set.all() 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): ...@@ -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() instructor_qset = instructor_group.user_set.all()
recipient_qset = staff_qset | instructor_qset 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, enrollment_qset = User.objects.filter(courseenrollment__course_id=course_id,
courseenrollment__is_active=True) courseenrollment__is_active=True)
recipient_qset = recipient_qset | enrollment_qset recipient_qset = recipient_qset | enrollment_qset
...@@ -67,12 +87,13 @@ def delegate_email_batches(email_id, to_option, course_id, course_url, user_id): ...@@ -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) log.error("Unexpected bulk email TO_OPTION found: %s", to_option)
raise Exception("Unexpected bulk email TO_OPTION found: {0}".format(to_option)) raise Exception("Unexpected bulk email TO_OPTION found: {0}".format(to_option))
image_url = course_image_url(course)
recipient_qset = recipient_qset.order_by('pk') recipient_qset = recipient_qset.order_by('pk')
total_num_emails = recipient_qset.count() total_num_emails = recipient_qset.count()
num_queries = int(math.ceil(float(total_num_emails) / float(settings.EMAILS_PER_QUERY))) num_queries = int(math.ceil(float(total_num_emails) / float(settings.EMAILS_PER_QUERY)))
last_pk = recipient_qset[0].pk - 1 last_pk = recipient_qset[0].pk - 1
num_workers = 0 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) recipient_sublist = list(recipient_qset.order_by('pk').filter(pk__gt=last_pk)
.values('profile__name', 'email', 'pk')[:settings.EMAILS_PER_QUERY]) .values('profile__name', 'email', 'pk')[:settings.EMAILS_PER_QUERY])
last_pk = recipient_sublist[-1]['pk'] last_pk = recipient_sublist[-1]['pk']
...@@ -81,76 +102,86 @@ def delegate_email_batches(email_id, to_option, course_id, course_url, user_id): ...@@ -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))) chunk = int(math.ceil(float(num_emails_this_query) / float(num_tasks_this_query)))
for i in range(num_tasks_this_query): for i in range(num_tasks_this_query):
to_list = recipient_sublist[i * chunk:i * chunk + chunk] 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 num_workers += num_tasks_this_query
gc.collect()
return num_workers return num_workers
@task(default_retry_delay=15, max_retries=5) # pylint: disable=E1102 @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 Takes a primary id for a CourseEmail object and a 'to_list' of recipient objects--keys are
sender to all addresses in the to_list, with each recipient 'profile__name', 'email' (address), and 'pk' (in the user table).
being the only "to". Emails are sent multipart, in both plain 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. text and html.
""" """
try: try:
msg = CourseEmail.objects.get(id=email_id) msg = CourseEmail.objects.get(id=email_id)
except CourseEmail.DoesNotExist as exc: except CourseEmail.DoesNotExist:
log.exception(exc.args[0]) log.exception("Could not find email id:{} to send.".format(email_id))
raise exc raise
# exclude optouts # exclude optouts
optouts = Optout.objects.filter(course_id=msg.course_id, optouts = (Optout.objects.filter(course_id=msg.course_id,
user__email__in=[i['email'] for i in to_list])\ user__in=[i['pk'] for i in to_list])
.values_list('user__email', flat=True) .values_list('user__email', flat=True))
num_optout = len(optouts) 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 subject = "[" + course_title + "] " + msg.subject
course_title_no_quotes = re.sub(r'"', '', course_title) 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) from_addr = '"{0}" Course Staff <{1}>'.format(course_title_no_quotes, settings.DEFAULT_BULK_FROM_EMAIL)
course_email_template = CourseEmailTemplate.get_template()
try: try:
connection = get_connection() connection = get_connection()
connection.open() connection.open()
num_sent = 0 num_sent = 0
num_error = 0 num_error = 0
# Define context values to use in all course emails:
email_context = { email_context = {
'name': '', 'name': '',
'email': '', 'email': '',
'course_title': course_title, '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: while to_list:
# Update context with user-specific values:
email = to_list[-1]['email'] email = to_list[-1]['email']
email_context['email'] = email email_context['email'] = email
email_context['name'] = to_list[-1]['profile__name'] email_context['name'] = to_list[-1]['profile__name']
html_footer = render_to_string( # Construct message content using templates and context:
'emails/email_footer.html', plaintext_msg = course_email_template.render_plaintext(msg.text_message, email_context)
email_context html_msg = course_email_template.render_htmltext(msg.html_message, email_context)
)
plain_footer = render_to_string( # Create email:
'emails/email_footer.txt',
email_context
)
email_msg = EmailMultiAlternatives( email_msg = EmailMultiAlternatives(
subject, subject,
msg.text_message + plain_footer.encode('utf-8'), plaintext_msg,
from_addr, from_addr,
[email], [email],
connection=connection 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 # Throttle if we tried a few times and got the rate limiter
if throttle or current_task.request.retries > 0: 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): ...@@ -183,6 +214,7 @@ def course_email(email_id, to_list, course_title, course_url, throttle=False):
to_list, to_list,
course_title, course_title,
course_url, course_url,
image_url,
current_task.request.retries > 0 current_task.request.retries > 0
], ],
exc=exc, exc=exc,
......
...@@ -4,6 +4,7 @@ Unit tests for student optouts from course email ...@@ -4,6 +4,7 @@ Unit tests for student optouts from course email
import json import json
from django.core import mail from django.core import mail
from django.core.management import call_command
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.conf import settings from django.conf import settings
from django.test.utils import override_settings from django.test.utils import override_settings
...@@ -30,6 +31,9 @@ class TestOptoutCourseEmails(ModuleStoreTestCase): ...@@ -30,6 +31,9 @@ class TestOptoutCourseEmails(ModuleStoreTestCase):
self.student = UserFactory.create() self.student = UserFactory.create()
CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) 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") self.client.login(username=self.student.username, password="test")
def tearDown(self): def tearDown(self):
......
...@@ -2,10 +2,11 @@ ...@@ -2,10 +2,11 @@
""" """
Unit tests for sending course email Unit tests for sending course email
""" """
from django.test.utils import override_settings
from django.conf import settings from django.conf import settings
from django.core import mail from django.core import mail
from django.core.urlresolvers import reverse 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 courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
from student.tests.factories import UserFactory, GroupFactory, CourseEnrollmentFactory from student.tests.factories import UserFactory, GroupFactory, CourseEnrollmentFactory
...@@ -63,6 +64,9 @@ class TestEmailSendFromDashboard(ModuleStoreTestCase): ...@@ -63,6 +64,9 @@ class TestEmailSendFromDashboard(ModuleStoreTestCase):
for student in self.students: for student in self.students:
CourseEnrollmentFactory.create(user=student, course_id=self.course.id) 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") self.client.login(username=self.instructor.username, password="test")
# Pull up email view on instructor dashboard # Pull up email view on instructor dashboard
...@@ -208,10 +212,8 @@ class TestEmailSendFromDashboard(ModuleStoreTestCase): ...@@ -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.instructor.email] + [s.email for s in self.staff] + [s.email for s in self.students]
) )
self.assertIn( message_body = mail.outbox[0].body
uni_message, self.assertIn(uni_message, message_body)
mail.outbox[0].body
)
def test_unicode_students_send_to_all(self): def test_unicode_students_send_to_all(self):
""" """
...@@ -273,11 +275,12 @@ class TestEmailSendFromDashboard(ModuleStoreTestCase): ...@@ -273,11 +275,12 @@ class TestEmailSendFromDashboard(ModuleStoreTestCase):
self.assertContains(response, "Your email was successfully queued for sending.") self.assertContains(response, "Your email was successfully queued for sending.")
self.assertEquals(mock_factory.emails_sent, self.assertEquals(mock_factory.emails_sent,
1 + len(self.staff) + len(self.students) + LARGE_NUM_EMAILS - len(optouts)) 1 + len(self.staff) + len(self.students) + LARGE_NUM_EMAILS - len(optouts))
self.assertItemsEqual( outbox_contents = [e.to[0] for e in mail.outbox]
[e.to[0] for e in mail.outbox], should_send_contents = ([self.instructor.email] +
[self.instructor.email] + [s.email for s in self.staff] + [s.email for s in self.students] + [s.email for s in self.staff] +
[s.email for s in added_users if s not in optouts] [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) @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
...@@ -294,4 +297,4 @@ class TestEmailSendExceptions(ModuleStoreTestCase): ...@@ -294,4 +297,4 @@ class TestEmailSendExceptions(ModuleStoreTestCase):
def test_no_course_email_obj(self): def test_no_course_email_obj(self):
# Make sure course_email handles CourseEmail.DoesNotExist exception. # Make sure course_email handles CourseEmail.DoesNotExist exception.
with self.assertRaises(CourseEmail.DoesNotExist): with self.assertRaises(CourseEmail.DoesNotExist):
course_email(101, [], "_", "_", False) course_email(101, [], "_", "_", "_", False)
...@@ -4,6 +4,7 @@ Unit tests for handling email sending errors ...@@ -4,6 +4,7 @@ Unit tests for handling email sending errors
from django.test.utils import override_settings from django.test.utils import override_settings
from django.conf import settings from django.conf import settings
from django.core.management import call_command
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
...@@ -35,6 +36,9 @@ class TestEmailErrors(ModuleStoreTestCase): ...@@ -35,6 +36,9 @@ class TestEmailErrors(ModuleStoreTestCase):
instructor = AdminFactory.create() instructor = AdminFactory.create()
self.client.login(username=instructor.username, password="test") 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 = FakeSMTPServerThread('localhost', TEST_SMTP_PORT)
self.smtp_server_thread.start() self.smtp_server_thread.start()
......
...@@ -718,7 +718,13 @@ def instructor_dashboard(request, course_id): ...@@ -718,7 +718,13 @@ def instructor_dashboard(request, course_id):
email.save() email.save()
course_url = request.build_absolute_uri(reverse('course_root', kwargs={'course_id': course_id})) 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": if to_option == "all":
email_msg = '<div class="msg msg-confirm"><p class="copy">Your email was successfully queued for sending. Please note that for large public classes (~10k), it may take 1-2 hours to send all emails.</p></div>' email_msg = '<div class="msg msg-confirm"><p class="copy">Your email was successfully queued for sending. Please note that for large public classes (~10k), it may take 1-2 hours to send all emails.</p></div>'
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment