diff --git a/lms/djangoapps/bulk_email/admin.py b/lms/djangoapps/bulk_email/admin.py index 3b40290f5d3c..9d6926c91642 100644 --- a/lms/djangoapps/bulk_email/admin.py +++ b/lms/djangoapps/bulk_email/admin.py @@ -3,7 +3,8 @@ """ 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,33 @@ 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. +''' + }), + ) + # 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 000000000000..427dfbb8aab4 --- /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": "{{message_body}}\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": "{{message_body}}\r\n
\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\nTo stop receiving email like this, update your course email settings here.
\r\n" + } + } +] diff --git a/lms/djangoapps/bulk_email/forms.py b/lms/djangoapps/bulk_email/forms.py new file mode 100644 index 000000000000..2ccdd72d1668 --- /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 000000000000..e41234fcff3a --- /dev/null +++ b/lms/djangoapps/bulk_email/migrations/0006_add_course_email_template.py @@ -0,0 +1,93 @@ +# -*- 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): + # Rename 'to' field to 'to_option' + db.rename_column('bulk_email_courseemail', 'to', 'to_option') + + # 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): + # Rename 'to_option' field back to 'to' + db.rename_column('bulk_email_courseemail', 'to_option', 'to') + + # 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 000000000000..7ccaaf07f9bd --- /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 72c9569cc1ec..c7937fab5c6d 100644 --- a/lms/djangoapps/bulk_email/models.py +++ b/lms/djangoapps/bulk_email/models.py @@ -10,13 +10,13 @@ 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 @@ -68,3 +72,82 @@ class Optout(models.Model): 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. Output is encoded as UTF-8 by default. + + 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 7afd286570dd..a0001d37fd77 100644 --- a/lms/djangoapps/bulk_email/tasks.py +++ b/lms/djangoapps/bulk_email/tasks.py @@ -15,11 +15,14 @@ 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 log = get_task_logger(__name__) @@ -47,9 +50,9 @@ def delegate_email_batches(email_id, to_option, course_id, course_url, user_id): 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) - if to_option == "myself": + 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 +61,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 @@ -72,7 +75,7 @@ def delegate_email_batches(email_id, to_option, course_id, course_url, user_id): 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'] @@ -95,7 +98,6 @@ 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: @@ -116,41 +118,43 @@ def course_email(email_id, to_list, course_title, course_url, throttle=False): 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, + '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: diff --git a/lms/djangoapps/bulk_email/tests/test_course_optout.py b/lms/djangoapps/bulk_email/tests/test_course_optout.py index 18f04cebc149..0adf11952701 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 @@ 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 @@ def setUp(self): 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 b0cf8dc06e2d..86bcba7100cc 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 @@ def setUp(self): 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 @@ def test_unicode_message_send_to_all(self): [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): """ diff --git a/lms/djangoapps/bulk_email/tests/test_err_handling.py b/lms/djangoapps/bulk_email/tests/test_err_handling.py index 606a0bef8888..e8874ea18eb9 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 @@ 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 @@ def setUp(self): 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()