diff --git a/lms/djangoapps/bulk_email/admin.py b/lms/djangoapps/bulk_email/admin.py index 3b40290f5d..1505af9ce4 100644 --- a/lms/djangoapps/bulk_email/admin.py +++ b/lms/djangoapps/bulk_email/admin.py @@ -3,7 +3,8 @@ Django admin page for bulk email models """ from django.contrib import admin -from bulk_email.models import CourseEmail, Optout +from bulk_email.models import CourseEmail, Optout, CourseEmailTemplate +from bulk_email.forms import CourseEmailTemplateForm class CourseEmailAdmin(admin.ModelAdmin): @@ -16,5 +17,45 @@ class OptoutAdmin(admin.ModelAdmin): list_display = ('user', 'course_id') +class CourseEmailTemplateAdmin(admin.ModelAdmin): + form = CourseEmailTemplateForm + fieldsets = ( + (None, { + # make the HTML template display above the plain template: + 'fields': ('html_template', 'plain_template'), + 'description': ''' +Enter template to be used by course staff when sending emails to enrolled students. + +The HTML template is for HTML email, and may contain HTML markup. The plain template is +for plaintext email. Both templates should contain the string '{{message_body}}' (with +two curly braces on each side), to indicate where the email text is to be inserted. + +Other tags that may be used (surrounded by one curly brace on each side): +{platform_name} : the name of the platform +{course_title} : the name of the course +{course_url} : the course's full URL +{email} : the user's email address +{account_settings_url} : URL at which users can change email preferences +{course_image_url} : URL for the course's course image. + Will return a broken link if course doesn't have a course image set. + +Note that there is currently NO validation on tags, so be careful. Typos or use of +unsupported tags will cause email sending to fail. +''' + }), + ) + # Turn off the action bar (we have no bulk actions) + actions = None + + def has_add_permission(self, request): + """Disables the ability to add new templates, as we want to maintain a Singleton.""" + return False + + def has_delete_permission(self, request, obj=None): + """Disables the ability to remove existing templates, as we want to maintain a Singleton.""" + return False + + admin.site.register(CourseEmail, CourseEmailAdmin) admin.site.register(Optout, OptoutAdmin) +admin.site.register(CourseEmailTemplate, CourseEmailTemplateAdmin) diff --git a/lms/djangoapps/bulk_email/fixtures/course_email_template.json b/lms/djangoapps/bulk_email/fixtures/course_email_template.json new file mode 100644 index 0000000000..076dedbd14 --- /dev/null +++ b/lms/djangoapps/bulk_email/fixtures/course_email_template.json @@ -0,0 +1,10 @@ +[ + { + "pk": 1, + "model": "bulk_email.courseemailtemplate", + "fields": { + "plain_template": "{course_title}\n\n{{message_body}}\r\n----\r\nCopyright 2013 edX, All rights reserved.\r\n----\r\nConnect with edX: Facebook (http://facebook.com/edxonline)\nTwitter (http://twitter.com/edxonline)\nGoogle+ (https://plus.google.com/108235383044095082735)\nMeetup (http://www.meetup.com/edX-Communities/)\r\n----\r\n This email was automatically sent from {platform_name}.\r\nYou are receiving this email at address {email} because you are enrolled in {course_title}\r\n(URL: {course_url} ).\r\nTo stop receiving email like this, update your account settings at {account_settings_url}.\r\n", + "html_template": " Update from {course_title}

edX
Connect with edX:        

{course_title}


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


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


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

edX
Connect with edX:        

{course_title}


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


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


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

edX
Connect with edX:        

{course_title}


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


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


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

+ {course_title}

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

Your email was successfully queued for sending. Please note that for large public classes (~10k), it may take 1-2 hours to send all emails.

' diff --git a/lms/static/images/bulk_email/FacebookIcon.png b/lms/static/images/bulk_email/FacebookIcon.png new file mode 100644 index 0000000000..7d2d8c4468 Binary files /dev/null and b/lms/static/images/bulk_email/FacebookIcon.png differ diff --git a/lms/static/images/bulk_email/GooglePlusIcon.png b/lms/static/images/bulk_email/GooglePlusIcon.png new file mode 100644 index 0000000000..b270049e60 Binary files /dev/null and b/lms/static/images/bulk_email/GooglePlusIcon.png differ diff --git a/lms/static/images/bulk_email/LinkedInIcon.png b/lms/static/images/bulk_email/LinkedInIcon.png new file mode 100644 index 0000000000..612b13faf1 Binary files /dev/null and b/lms/static/images/bulk_email/LinkedInIcon.png differ diff --git a/lms/static/images/bulk_email/MeetupIcon.png b/lms/static/images/bulk_email/MeetupIcon.png new file mode 100644 index 0000000000..9af4655ff2 Binary files /dev/null and b/lms/static/images/bulk_email/MeetupIcon.png differ diff --git a/lms/static/images/bulk_email/TwitterIcon.png b/lms/static/images/bulk_email/TwitterIcon.png new file mode 100644 index 0000000000..8b9896a353 Binary files /dev/null and b/lms/static/images/bulk_email/TwitterIcon.png differ diff --git a/lms/static/images/bulk_email/VKontakteIcon.png b/lms/static/images/bulk_email/VKontakteIcon.png new file mode 100644 index 0000000000..80312af6cf Binary files /dev/null and b/lms/static/images/bulk_email/VKontakteIcon.png differ diff --git a/lms/static/images/bulk_email/edXHeaderImage.jpg b/lms/static/images/bulk_email/edXHeaderImage.jpg new file mode 100644 index 0000000000..fc5e4d68de Binary files /dev/null and b/lms/static/images/bulk_email/edXHeaderImage.jpg differ