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
"""
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)
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,
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)
......@@ -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,
......
......@@ -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):
......
......@@ -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)
......@@ -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()
......
......@@ -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 = '<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