From 8f93051d303d402947ca41a0c54cbeb8a17fc5ce Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Thu, 8 Aug 2013 16:44:36 -0400 Subject: [PATCH] Add editable templates for bulk email Adds the edX Marketing-approved template as html default. --- lms/djangoapps/bulk_email/admin.py | 43 ++- .../fixtures/course_email_template.json | 10 + .../plain-html-no-newlines-or-tabs.txt | 1 + .../fixtures/plain-html-no-newlines.txt | 1 + .../bulk_email/fixtures/plain-html.txt | 268 ++++++++++++++++++ lms/djangoapps/bulk_email/forms.py | 42 +++ .../0006_add_course_email_template.py | 87 ++++++ .../0007_load_course_email_template.py | 81 ++++++ lms/djangoapps/bulk_email/models.py | 99 ++++++- lms/djangoapps/bulk_email/tasks.py | 100 ++++--- .../bulk_email/tests/test_course_optout.py | 4 + lms/djangoapps/bulk_email/tests/test_email.py | 25 +- .../bulk_email/tests/test_err_handling.py | 4 + lms/djangoapps/instructor/views/legacy.py | 8 +- lms/static/images/bulk_email/FacebookIcon.png | Bin 0 -> 550 bytes .../images/bulk_email/GooglePlusIcon.png | Bin 0 -> 1286 bytes lms/static/images/bulk_email/LinkedInIcon.png | Bin 0 -> 751 bytes lms/static/images/bulk_email/MeetupIcon.png | Bin 0 -> 1283 bytes lms/static/images/bulk_email/TwitterIcon.png | Bin 0 -> 998 bytes .../images/bulk_email/VKontakteIcon.png | Bin 0 -> 2480 bytes .../images/bulk_email/edXHeaderImage.jpg | Bin 0 -> 25814 bytes 21 files changed, 719 insertions(+), 54 deletions(-) create mode 100644 lms/djangoapps/bulk_email/fixtures/course_email_template.json create mode 100644 lms/djangoapps/bulk_email/fixtures/plain-html-no-newlines-or-tabs.txt create mode 100644 lms/djangoapps/bulk_email/fixtures/plain-html-no-newlines.txt create mode 100644 lms/djangoapps/bulk_email/fixtures/plain-html.txt create mode 100644 lms/djangoapps/bulk_email/forms.py create mode 100644 lms/djangoapps/bulk_email/migrations/0006_add_course_email_template.py create mode 100644 lms/djangoapps/bulk_email/migrations/0007_load_course_email_template.py create mode 100644 lms/static/images/bulk_email/FacebookIcon.png create mode 100644 lms/static/images/bulk_email/GooglePlusIcon.png create mode 100644 lms/static/images/bulk_email/LinkedInIcon.png create mode 100644 lms/static/images/bulk_email/MeetupIcon.png create mode 100644 lms/static/images/bulk_email/TwitterIcon.png create mode 100644 lms/static/images/bulk_email/VKontakteIcon.png create mode 100644 lms/static/images/bulk_email/edXHeaderImage.jpg diff --git a/lms/djangoapps/bulk_email/admin.py b/lms/djangoapps/bulk_email/admin.py index 3b40290f5d..1505af9ce4 100644 --- a/lms/djangoapps/bulk_email/admin.py +++ b/lms/djangoapps/bulk_email/admin.py @@ -3,7 +3,8 @@ Django admin page for bulk email models """ from django.contrib import admin -from bulk_email.models import CourseEmail, Optout +from bulk_email.models import CourseEmail, Optout, CourseEmailTemplate +from bulk_email.forms import CourseEmailTemplateForm class CourseEmailAdmin(admin.ModelAdmin): @@ -16,5 +17,45 @@ class OptoutAdmin(admin.ModelAdmin): list_display = ('user', 'course_id') +class CourseEmailTemplateAdmin(admin.ModelAdmin): + form = CourseEmailTemplateForm + fieldsets = ( + (None, { + # make the HTML template display above the plain template: + 'fields': ('html_template', 'plain_template'), + 'description': ''' +Enter template to be used by course staff when sending emails to enrolled students. + +The HTML template is for HTML email, and may contain HTML markup. The plain template is +for plaintext email. Both templates should contain the string '{{message_body}}' (with +two curly braces on each side), to indicate where the email text is to be inserted. + +Other tags that may be used (surrounded by one curly brace on each side): +{platform_name} : the name of the platform +{course_title} : the name of the course +{course_url} : the course's full URL +{email} : the user's email address +{account_settings_url} : URL at which users can change email preferences +{course_image_url} : URL for the course's course image. + Will return a broken link if course doesn't have a course image set. + +Note that there is currently NO validation on tags, so be careful. Typos or use of +unsupported tags will cause email sending to fail. +''' + }), + ) + # Turn off the action bar (we have no bulk actions) + actions = None + + def has_add_permission(self, request): + """Disables the ability to add new templates, as we want to maintain a Singleton.""" + return False + + def has_delete_permission(self, request, obj=None): + """Disables the ability to remove existing templates, as we want to maintain a Singleton.""" + return False + + admin.site.register(CourseEmail, CourseEmailAdmin) admin.site.register(Optout, OptoutAdmin) +admin.site.register(CourseEmailTemplate, CourseEmailTemplateAdmin) diff --git a/lms/djangoapps/bulk_email/fixtures/course_email_template.json b/lms/djangoapps/bulk_email/fixtures/course_email_template.json new file mode 100644 index 0000000000..076dedbd14 --- /dev/null +++ b/lms/djangoapps/bulk_email/fixtures/course_email_template.json @@ -0,0 +1,10 @@ +[ + { + "pk": 1, + "model": "bulk_email.courseemailtemplate", + "fields": { + "plain_template": "{course_title}\n\n{{message_body}}\r\n----\r\nCopyright 2013 edX, All rights reserved.\r\n----\r\nConnect with edX: Facebook (http://facebook.com/edxonline)\nTwitter (http://twitter.com/edxonline)\nGoogle+ (https://plus.google.com/108235383044095082735)\nMeetup (http://www.meetup.com/edX-Communities/)\r\n----\r\n This email was automatically sent from {platform_name}.\r\nYou are receiving this email at address {email} because you are enrolled in {course_title}\r\n(URL: {course_url} ).\r\nTo stop receiving email like this, update your account settings at {account_settings_url}.\r\n", + "html_template": " Update from {course_title}

edX
Connect with edX:        

{course_title}


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


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


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

edX
Connect with edX:        

{course_title}


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


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


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

edX
Connect with edX:        

{course_title}


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


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


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

+ {course_title}

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

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

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