diff --git a/AUTHORS b/AUTHORS index 4b57d723d2..0391bd55f9 100644 --- a/AUTHORS +++ b/AUTHORS @@ -84,3 +84,5 @@ Mukul Goyal Robert Marks Yarko Tymciurak Miles Steele +Kevin Luo +Akshay Jagadeesh diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ab5e17357b..89f084b3f4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -33,6 +33,9 @@ logic has been consolidated into the model -- you should use new class methods to `enroll()`, `unenroll()`, and to check `is_enrolled()`, instead of creating CourseEnrollment objects or querying them directly. +LMS: Added bulk email for course feature, with option to optout of individual +course emails. + Studio: Email will be sent to admin address when a user requests course creator privileges for Studio (edge only). diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 4d59b5cc66..b1b8f6ff1e 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -1,3 +1,6 @@ +""" +Student Views +""" import datetime import feedparser import json @@ -27,6 +30,7 @@ from django_future.csrf import ensure_csrf_cookie from django.utils.http import cookie_date from django.utils.http import base36_to_int from django.utils.translation import ugettext as _ +from django.views.decorators.http import require_POST from ratelimitbackend.exceptions import RateLimitException @@ -54,6 +58,10 @@ from courseware.access import has_access from external_auth.models import ExternalAuthMap +from bulk_email.models import Optout + +import track.views + from statsd import statsd from pytz import UTC @@ -64,8 +72,7 @@ Article = namedtuple('Article', 'title url author image deck publication publish def csrf_token(context): - ''' A csrf token that can be included in a form. - ''' + """A csrf token that can be included in a form.""" csrf_token = context.get('csrf_token', '') if csrf_token == 'NOTPROVIDED': return '' @@ -78,12 +85,12 @@ def csrf_token(context): # This means that it should always return the same thing for anon # users. (in particular, no switching based on query params allowed) def index(request, extra_context={}, user=None): - ''' + """ Render the edX main page. extra_context is used to allow immediate display of certain modal windows, eg signup, as used by external_auth. - ''' + """ # The course selection work is done in courseware.courses. domain = settings.MITX_FEATURES.get('FORCE_UNIVERSITY_DOMAIN') # normally False @@ -267,6 +274,8 @@ def dashboard(request): log.error("User {0} enrolled in non-existent course {1}" .format(user.username, enrollment.course_id)) + course_optouts = Optout.objects.filter(user=user).values_list('course_id', flat=True) + message = "" if not user.is_active: message = render_to_string('registration/activate_account_notice.html', {'email': user.email}) @@ -294,6 +303,7 @@ def dashboard(request): pass context = {'courses': courses, + 'course_optouts': course_optouts, 'message': message, 'external_auth_map': external_auth_map, 'staff_access': staff_access, @@ -404,7 +414,7 @@ def accounts_login(request, error=""): # Need different levels of logging @ensure_csrf_cookie def login_user(request, error=""): - ''' AJAX request to log in the user. ''' + """AJAX request to log in the user.""" if 'email' not in request.POST or 'password' not in request.POST: return HttpResponse(json.dumps({'success': False, 'value': _('There was an error receiving your login information. Please email us.')})) # TODO: User error message @@ -487,11 +497,11 @@ def login_user(request, error=""): @ensure_csrf_cookie def logout_user(request): - ''' + """ HTTP request to log out the user. Redirects to marketing page. Deletes both the CSRF and sessionid cookies so the marketing site can determine the logged in state of the user - ''' + """ # We do not log here, because we have a handler registered # to perform logging on successful logouts. logout(request) @@ -505,8 +515,7 @@ def logout_user(request): @login_required @ensure_csrf_cookie def change_setting(request): - ''' JSON call to change a profile setting: Right now, location - ''' + """JSON call to change a profile setting: Right now, location""" # TODO (vshnayder): location is no longer used up = UserProfile.objects.get(user=request.user) # request.user.profile_cache if 'location' in request.POST: @@ -574,10 +583,10 @@ def _do_create_account(post_vars): @ensure_csrf_cookie def create_account(request, post_override=None): - ''' + """ JSON call to create new edX account. Used by form in signup_modal.html, which is included into navigation.html - ''' + """ js = {'success': False} post_vars = post_override if post_override else request.POST @@ -811,10 +820,10 @@ def begin_exam_registration(request, course_id): @ensure_csrf_cookie def create_exam_registration(request, post_override=None): - ''' + """ JSON call to create a test center exam registration. Called by form in test_center_register.html - ''' + """ post_vars = post_override if post_override else request.POST # first determine if we need to create a new TestCenterUser, or if we are making any update @@ -967,8 +976,7 @@ def auto_auth(request): @ensure_csrf_cookie def activate_account(request, key): - ''' When link in activation e-mail is clicked - ''' + """When link in activation e-mail is clicked""" r = Registration.objects.filter(activation_key=key) if len(r) == 1: user_logged_in = request.user.is_authenticated() @@ -1003,7 +1011,7 @@ def activate_account(request, key): @ensure_csrf_cookie def password_reset(request): - ''' Attempts to send a password reset e-mail. ''' + """ Attempts to send a password reset e-mail. """ if request.method != "POST": raise Http404 @@ -1025,9 +1033,9 @@ def password_reset_confirm_wrapper( uidb36=None, token=None, ): - ''' A wrapper around django.contrib.auth.views.password_reset_confirm. + """ A wrapper around django.contrib.auth.views.password_reset_confirm. Needed because we want to set the user as active at this step. - ''' + """ # cribbed from django.contrib.auth.views.password_reset_confirm try: uid_int = base36_to_int(uidb36) @@ -1069,8 +1077,8 @@ def reactivation_email_for_user(user): @ensure_csrf_cookie def change_email_request(request): - ''' AJAX call from the profile page. User wants a new e-mail. - ''' + """ AJAX call from the profile page. User wants a new e-mail. + """ ## Make sure it checks for existing e-mail conflicts if not request.user.is_authenticated: raise Http404 @@ -1125,9 +1133,9 @@ def change_email_request(request): @ensure_csrf_cookie @transaction.commit_manually def confirm_email_change(request, key): - ''' User requested a new e-mail. This is called when the activation + """ User requested a new e-mail. This is called when the activation link is clicked. We confirm with the old e-mail, and update - ''' + """ try: try: pec = PendingEmailChange.objects.get(activation_key=key) @@ -1184,7 +1192,7 @@ def confirm_email_change(request, key): @ensure_csrf_cookie def change_name_request(request): - ''' Log a request for a new name. ''' + """ Log a request for a new name. """ if not request.user.is_authenticated: raise Http404 @@ -1208,7 +1216,7 @@ def change_name_request(request): @ensure_csrf_cookie def pending_name_changes(request): - ''' Web page which allows staff to approve or reject name changes. ''' + """ Web page which allows staff to approve or reject name changes. """ if not request.user.is_staff: raise Http404 @@ -1224,7 +1232,7 @@ def pending_name_changes(request): @ensure_csrf_cookie def reject_name_change(request): - ''' JSON: Name change process. Course staff clicks 'reject' on a given name change ''' + """ JSON: Name change process. Course staff clicks 'reject' on a given name change """ if not request.user.is_staff: raise Http404 @@ -1262,13 +1270,36 @@ def accept_name_change_by_id(id): @ensure_csrf_cookie def accept_name_change(request): - ''' JSON: Name change process. Course staff clicks 'accept' on a given name change + """ JSON: Name change process. Course staff clicks 'accept' on a given name change We used this during the prototype but now we simply record name changes instead of manually approving them. Still keeping this around in case we want to go back to this approval method. - ''' + """ if not request.user.is_staff: raise Http404 return accept_name_change_by_id(int(request.POST['id'])) + + +@require_POST +@login_required +@ensure_csrf_cookie +def change_email_settings(request): + """Modify logged-in user's setting for receiving emails from a course.""" + user = request.user + + course_id = request.POST.get("course_id") + receive_emails = request.POST.get("receive_emails") + if receive_emails: + optout_object = Optout.objects.filter(user=user, course_id=course_id) + if optout_object: + optout_object.delete() + log.info(u"User {0} ({1}) opted in to receive emails from course {2}".format(user.username, user.email, course_id)) + track.views.server_track(request, "change-email-settings", {"receive_emails": "yes", "course": course_id}, page='dashboard') + else: + Optout.objects.get_or_create(user=user, course_id=course_id) + log.info(u"User {0} ({1}) opted out of receiving emails from course {2}".format(user.username, user.email, course_id)) + track.views.server_track(request, "change-email-settings", {"receive_emails": "no", "course": course_id}, page='dashboard') + + return HttpResponse(json.dumps({'success': True})) diff --git a/common/lib/html_to_text.py b/common/lib/html_to_text.py new file mode 100644 index 0000000000..c1f1993c17 --- /dev/null +++ b/common/lib/html_to_text.py @@ -0,0 +1,27 @@ +"""Provides a function to convert html to plaintext.""" +import logging +from subprocess import Popen, PIPE + +log = logging.getLogger(__name__) + + +def html_to_text(html_message): + """ + Converts an html message to plaintext. + Currently uses lynx in a subprocess; should be refactored to + use something more pythonic. + """ + process = Popen( + ['lynx', '-stdin', '-display_charset=UTF-8', '-assume_charset=UTF-8', '-dump'], + stdin=PIPE, + stdout=PIPE + ) + # use lynx to get plaintext + (plaintext, err_from_stderr) = process.communicate( + input=html_message.encode('utf-8') + ) + + if err_from_stderr: + log.info(err_from_stderr) + + return plaintext diff --git a/cms/static/css/tiny-mce.css b/common/static/css/tiny-mce.css similarity index 100% rename from cms/static/css/tiny-mce.css rename to common/static/css/tiny-mce.css diff --git a/lms/djangoapps/bulk_email/__init__.py b/lms/djangoapps/bulk_email/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/bulk_email/admin.py b/lms/djangoapps/bulk_email/admin.py new file mode 100644 index 0000000000..1505af9ce4 --- /dev/null +++ b/lms/djangoapps/bulk_email/admin.py @@ -0,0 +1,61 @@ +""" +Django admin page for bulk email models +""" +from django.contrib import admin + +from bulk_email.models import CourseEmail, Optout, CourseEmailTemplate +from bulk_email.forms import CourseEmailTemplateForm + + +class CourseEmailAdmin(admin.ModelAdmin): + """Admin for course email.""" + readonly_fields = ('sender',) + + +class OptoutAdmin(admin.ModelAdmin): + """Admin for optouts.""" + 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..bea136df38 --- /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:\r\nFacebook (http://facebook.com/edxonline)\r\nTwitter (http://twitter.com/edxonline)\r\nGoogle+ (https://plus.google.com/108235383044095082735)\r\nMeetup (http://www.meetup.com/edX-Communities/)\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": " 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/0001_initial.py b/lms/djangoapps/bulk_email/migrations/0001_initial.py new file mode 100644 index 0000000000..c3672a6de8 --- /dev/null +++ b/lms/djangoapps/bulk_email/migrations/0001_initial.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +from south.db import db +from south.v2 import SchemaMigration + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'CourseEmail' + db.create_table('bulk_email_courseemail', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('sender', self.gf('django.db.models.fields.related.ForeignKey')(default=1, to=orm['auth.User'], null=True, blank=True)), + ('hash', self.gf('django.db.models.fields.CharField')(max_length=128, db_index=True)), + ('subject', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)), + ('html_message', self.gf('django.db.models.fields.TextField')(null=True, blank=True)), + ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + ('modified', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)), + ('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + ('to', self.gf('django.db.models.fields.CharField')(default='myself', max_length=64)), + )) + db.send_create_signal('bulk_email', ['CourseEmail']) + + # Adding model 'Optout' + db.create_table('bulk_email_optout', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('email', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + ('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + )) + db.send_create_signal('bulk_email', ['Optout']) + + # Adding unique constraint on 'Optout', fields ['email', 'course_id'] + db.create_unique('bulk_email_optout', ['email', 'course_id']) + + def backwards(self, orm): + # Removing unique constraint on 'Optout', fields ['email', 'course_id'] + db.delete_unique('bulk_email_optout', ['email', 'course_id']) + + # Deleting model 'CourseEmail' + db.delete_table('bulk_email_courseemail') + + # Deleting model 'Optout' + db.delete_table('bulk_email_optout') + + 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'}), + 'hash': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': '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'}), + 'subject': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'to': ('django.db.models.fields.CharField', [], {'default': "'myself'", 'max_length': '64'}) + }, + 'bulk_email.optout': { + 'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'Optout'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': '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/0002_change_field_names.py b/lms/djangoapps/bulk_email/migrations/0002_change_field_names.py new file mode 100644 index 0000000000..93fa33a544 --- /dev/null +++ b/lms/djangoapps/bulk_email/migrations/0002_change_field_names.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +from south.db import db +from south.v2 import SchemaMigration + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Renaming field 'CourseEmail.to' + db.rename_column('bulk_email_courseemail', 'to', 'to_option') + + # Renaming field 'CourseEmail.hash' + db.rename_column('bulk_email_courseemail', 'hash', 'slug') + + # Adding field 'CourseEmail.text_message' + db.add_column('bulk_email_courseemail', 'text_message', + self.gf('django.db.models.fields.TextField')(null=True, blank=True), + keep_default=False) + + def backwards(self, orm): + # Renaming field 'CourseEmail.to_option' + db.rename_column('bulk_email_courseemail', 'to_option', 'to') + + # Renaming field 'CourseEmail.slug' + db.rename_column('bulk_email_courseemail', 'slug', 'hash') + + # Deleting field 'CourseEmail.text_message' + db.delete_column('bulk_email_courseemail', 'text_message') + + 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.optout': { + 'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'Optout'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': '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/0003_add_optout_user.py b/lms/djangoapps/bulk_email/migrations/0003_add_optout_user.py new file mode 100644 index 0000000000..1bf344f6e9 --- /dev/null +++ b/lms/djangoapps/bulk_email/migrations/0003_add_optout_user.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +from south.db import db +from south.v2 import SchemaMigration + + +class Migration(SchemaMigration): + + def forwards(self, orm): + + # Adding field 'Optout.user' + db.add_column('bulk_email_optout', 'user', + self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True), + keep_default=False) + + # Removing unique constraint on 'Optout', fields ['course_id', 'email'] + db.delete_unique('bulk_email_optout', ['course_id', 'email']) + + # Adding unique constraint on 'Optout', fields ['course_id', 'user'] + db.create_unique('bulk_email_optout', ['course_id', 'user_id']) + + def backwards(self, orm): + + # Removing unique constraint on 'Optout', fields ['course_id', 'user'] + db.delete_unique('bulk_email_optout', ['course_id', 'user_id']) + + # Deleting field 'Optout.email' + db.delete_column('bulk_email_optout', 'user_id') + + # Creating unique constraint on 'Optout', fields ['course_id', 'email'] + db.create_unique('bulk_email_optout', ['course_id', 'email']) + + 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.optout': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'Optout'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'email': ('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/0004_migrate_optout_user.py b/lms/djangoapps/bulk_email/migrations/0004_migrate_optout_user.py new file mode 100644 index 0000000000..6dd2129466 --- /dev/null +++ b/lms/djangoapps/bulk_email/migrations/0004_migrate_optout_user.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +from south.db import db +from south.v2 import DataMigration +from django.core.exceptions import ObjectDoesNotExist + + +class Migration(DataMigration): + + def forwards(self, orm): + + # forwards data migration to copy over existing emails to associated ids + if not db.dry_run: + for optout in orm.Optout.objects.all(): + try: + user = orm['auth.User'].objects.get(email=optout.email) + optout.user = user + optout.save() + except ObjectDoesNotExist: + # if user is not found (because they have already changed their email) + # then delete the optout, as it's no longer useful. + optout.delete() + + def backwards(self, orm): + + # backwards data migration to copy over emails of students to old email slot + if not db.dry_run: + for optout in orm.Optout.objects.all(): + if optout.user is not None: + optout.email = optout.user.email + optout.save() + + 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.optout': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'Optout'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'email': ('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/0005_remove_optout_email.py b/lms/djangoapps/bulk_email/migrations/0005_remove_optout_email.py new file mode 100644 index 0000000000..3639d1e473 --- /dev/null +++ b/lms/djangoapps/bulk_email/migrations/0005_remove_optout_email.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +from south.db import db +from south.v2 import SchemaMigration + + +class Migration(SchemaMigration): + + def forwards(self, orm): + + # Deleting field 'Optout.email' + db.delete_column('bulk_email_optout', 'email') + + def backwards(self, orm): + + # Adding field 'Optout.email' + db.add_column('bulk_email_optout', 'email', + self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True), + keep_default=False) + + 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.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/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/migrations/__init__.py b/lms/djangoapps/bulk_email/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/bulk_email/models.py b/lms/djangoapps/bulk_email/models.py new file mode 100644 index 0000000000..9d32dbd70c --- /dev/null +++ b/lms/djangoapps/bulk_email/models.py @@ -0,0 +1,155 @@ +""" +Models for bulk email + +WE'RE USING MIGRATIONS! + +If you make changes to this model, be sure to create an appropriate migration +file and check it in at the same time as your model changes. To do that, + +1. Go to the edx-platform dir +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/ + +""" +import logging +from django.db import models +from django.contrib.auth.models import User + +log = logging.getLogger(__name__) + + +class Email(models.Model): + """ + Abstract base class for common information for an email. + """ + sender = models.ForeignKey(User, default=1, blank=True, null=True) + slug = models.CharField(max_length=128, db_index=True) + subject = models.CharField(max_length=128, blank=True) + html_message = models.TextField(null=True, blank=True) + text_message = models.TextField(null=True, blank=True) + created = models.DateTimeField(auto_now_add=True) + modified = models.DateTimeField(auto_now=True) + + class Meta: # pylint: disable=C0111 + abstract = True + +SEND_TO_MYSELF = 'myself' +SEND_TO_STAFF = 'staff' +SEND_TO_ALL = 'all' + + +class CourseEmail(Email, models.Model): + """ + Stores information for an email to a course. + """ + # Three options for sending that we provide from the instructor dashboard: + # * Myself: This sends an email to the staff member that is composing the email. + # + # * Staff and instructors: This sends an email to anyone in the staff group and + # anyone in the instructor group + # + # * All: This sends an email to anyone enrolled in the course, with any role + # (student, staff, or instructor) + # + TO_OPTIONS = ( + (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=SEND_TO_MYSELF) + + def __unicode__(self): + return self.subject + + +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 new file mode 100644 index 0000000000..c9be4f5347 --- /dev/null +++ b/lms/djangoapps/bulk_email/tasks.py @@ -0,0 +1,228 @@ +""" +This module contains celery task functions for handling the sending of bulk email +to a course. +""" +import math +import re +import time + +from smtplib import SMTPServerDisconnected, SMTPDataError, SMTPConnectError + +from django.conf import settings +from django.contrib.auth.models import User, Group +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, 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, course_image_url + +log = get_task_logger(__name__) + + +@task(default_retry_delay=10, max_retries=5) # pylint: disable=E1102 +def delegate_email_batches(email_id, user_id): + """ + Delegates emails by querying for the list of recipients who should + get the mail, chopping up into batches of settings.EMAILS_PER_TASK size, + and queueing up worker jobs. + + Returns the number of batches (workers) kicked off. + """ + try: + email_obj = 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, user_id], exc=exc) + + 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 == 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() + instructor_grpname = _course_instructor_group_name(course.location) + instructor_group, _ = Group.objects.get_or_create(name=instructor_grpname) + instructor_qset = instructor_group.user_set.all() + recipient_qset = staff_qset | instructor_qset + + 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 + recipient_qset = recipient_qset.distinct() + else: + log.error("Unexpected bulk email TO_OPTION found: %s", to_option) + raise Exception("Unexpected bulk email TO_OPTION found: {0}".format(to_option)) + + 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 _ 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'] + num_emails_this_query = len(recipient_sublist) + num_tasks_this_query = int(math.ceil(float(num_emails_this_query) / float(settings.EMAILS_PER_TASK))) + 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, + image_url, + False + ) + num_workers += num_tasks_this_query + 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, image_url, throttle=False): + """ + 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: + 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__in=[i['pk'] for i in to_list]) + .values_list('user__email', flat=True)) + + optouts = set(optouts) + num_optout = len(optouts) + + to_list = filter(lambda x: x['email'] not in 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_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'] + + # 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) + + # Create email: + email_msg = EmailMultiAlternatives( + subject, + plaintext_msg, + from_addr, + [email], + connection=connection + ) + 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: + time.sleep(0.2) + + try: + connection.send_messages([email_msg]) + log.info('Email with id %s sent to %s', email_id, email) + num_sent += 1 + except SMTPDataError as exc: + # According to SMTP spec, we'll retry error codes in the 4xx range. 5xx range indicates hard failure + if exc.smtp_code >= 400 and exc.smtp_code < 500: + # This will cause the outer handler to catch the exception and retry the entire task + raise exc + else: + # This will fall through and not retry the message, since it will be popped + log.warning('Email with id %s not delivered to %s due to error %s', email_id, email, exc.smtp_error) + num_error += 1 + + to_list.pop() + + connection.close() + return course_email_result(num_sent, num_error, num_optout) + + except (SMTPDataError, SMTPConnectError, SMTPServerDisconnected) as exc: + # Error caught here cause the email to be retried. The entire task is actually retried without popping the list + # Reasoning is that all of these errors may be temporary condition. + log.warning('Email with id %d not delivered due to temporary error %s, retrying send to %d recipients', + email_id, exc, len(to_list)) + raise course_email.retry( + arg=[ + email_id, + to_list, + course_title, + course_url, + image_url, + current_task.request.retries > 0 + ], + exc=exc, + countdown=(2 ** current_task.request.retries) * 15 + ) + except: + log.exception('Email with id %d caused course_email task to fail with uncaught exception. To list: %s', + email_id, + [i['email'] for i in to_list]) + raise + + +# This string format code is wrapped in this function to allow mocking for a unit test +def course_email_result(num_sent, num_error, num_optout): + """Return the formatted result of course_email sending.""" + return "Sent {0}, Fail {1}, Optout {2}".format(num_sent, num_error, num_optout) diff --git a/lms/djangoapps/bulk_email/tests/__init__.py b/lms/djangoapps/bulk_email/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/bulk_email/tests/fake_smtp.py b/lms/djangoapps/bulk_email/tests/fake_smtp.py new file mode 100755 index 0000000000..b6fb9b92a5 --- /dev/null +++ b/lms/djangoapps/bulk_email/tests/fake_smtp.py @@ -0,0 +1,88 @@ +""" +Fake SMTP Server used for testing error handling for sending email. +We could have mocked smptlib to raise connection errors, but this simulates +connection errors from an SMTP server. +""" +import smtpd +import socket +import asyncore +import asynchat +import errno + + +class FakeSMTPChannel(smtpd.SMTPChannel): + """ + A fake SMTPChannel for sending fake error response through socket. + This causes smptlib to raise an SMTPConnectError. + + Adapted from http://hg.python.org/cpython/file/2.7/Lib/smtpd.py + """ + # Disable pylint warnings that arise from subclassing SMTPChannel + # and calling init -- overriding SMTPChannel's init to return error + # message but keeping the rest of the class. + # pylint: disable=W0231, W0233 + def __init__(self, server, conn, addr): + asynchat.async_chat.__init__(self, conn) + self.__server = server + self.__conn = conn + self.__addr = addr + self.__line = [] + self.__state = self.COMMAND + self.__greeting = 0 + self.__mailfrom = None + self.__rcpttos = [] + self.__data = '' + self.__fqdn = socket.getfqdn() + try: + self.__peer = conn.getpeername() + except socket.error, err: + # a race condition may occur if the other end is closing + # before we can get the peername + self.close() + if err[0] != errno.ENOTCONN: + raise + return + self.push('421 SMTP Server error: too many concurrent sessions, please try again later.') + self.set_terminator('\r\n') + + +class FakeSMTPServer(smtpd.SMTPServer): + """A fake SMTP server for generating different smptlib exceptions.""" + def __init__(self, *args, **kwargs): + smtpd.SMTPServer.__init__(self, *args, **kwargs) + self.errtype = None + self.response = None + + def set_errtype(self, errtype, response=''): + """Specify the type of error to cause smptlib to raise, with optional response string. + + `errtype` -- "DATA": The server will cause smptlib to throw SMTPDataError. + "CONN": The server will cause smptlib to throw SMTPConnectError. + "DISCONN": The server will cause smptlib to throw SMTPServerDisconnected. + + """ + self.errtype = errtype + self.response = response + + def handle_accept(self): + if self.errtype == "DISCONN": + self.accept() + elif self.errtype == "CONN": + pair = self.accept() + if pair is not None: + conn, addr = pair + _channel = FakeSMTPChannel(self, conn, addr) + else: + smtpd.SMTPServer.handle_accept(self) + + def process_message(self, *_args, **_kwargs): + if self.errtype == "DATA": + # After failing on the first email, succeed on the rest. + self.errtype = None + return self.response + else: + return None + + def serve_forever(self): + """Start the server running until close() is called on the server.""" + asyncore.loop() diff --git a/lms/djangoapps/bulk_email/tests/smtp_server_thread.py b/lms/djangoapps/bulk_email/tests/smtp_server_thread.py new file mode 100644 index 0000000000..713cd9ca64 --- /dev/null +++ b/lms/djangoapps/bulk_email/tests/smtp_server_thread.py @@ -0,0 +1,47 @@ +""" +Defines a class for a thread that runs a Fake SMTP server, used for testing +error handling from sending email. +""" +import threading +from bulk_email.tests.fake_smtp import FakeSMTPServer + + +class FakeSMTPServerThread(threading.Thread): + """ + Thread for running a fake SMTP server + """ + def __init__(self, host, port): + self.host = host + self.port = port + self.is_ready = threading.Event() + self.error = None + self.server = None + super(FakeSMTPServerThread, self).__init__() + + def start(self): + self.daemon = True + super(FakeSMTPServerThread, self).start() + self.is_ready.wait() + if self.error: + raise self.error # pylint: disable=E0702 + + def stop(self): + """ + Stop the thread by closing the server instance. + Wait for the server thread to terminate. + """ + if hasattr(self, 'server'): + self.server.close() + self.join() + + def run(self): + """ + Sets up the test smtp server and handle requests. + """ + try: + self.server = FakeSMTPServer((self.host, self.port), None) + self.is_ready.set() + self.server.serve_forever() + except Exception, exc: # pylint: disable=W0703 + self.error = exc + self.is_ready.set() diff --git a/lms/djangoapps/bulk_email/tests/test_course_optout.py b/lms/djangoapps/bulk_email/tests/test_course_optout.py new file mode 100644 index 0000000000..0adf119527 --- /dev/null +++ b/lms/djangoapps/bulk_email/tests/test_course_optout.py @@ -0,0 +1,121 @@ +""" +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 + +from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from student.tests.factories import UserFactory, AdminFactory, CourseEnrollmentFactory +from student.models import CourseEnrollment +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + +from mock import patch + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class TestOptoutCourseEmails(ModuleStoreTestCase): + + """ + Test that optouts are referenced in sending course email. + """ + + def setUp(self): + self.course = CourseFactory.create() + self.instructor = AdminFactory.create() + 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): + """ + Undo all patches. + """ + patch.stopall() + + def navigate_to_email_view(self): + """Navigate to the instructor dash's email view""" + # Pull up email view on instructor dashboard + url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) + response = self.client.get(url) + email_link = 'Email' + # If this fails, it is likely because ENABLE_INSTRUCTOR_EMAIL is set to False + self.assertTrue(email_link in response.content) + + # Select the Email view of the instructor dash + session = self.client.session + session['idash_mode'] = 'Email' + session.save() + response = self.client.get(url) + selected_email_link = 'Email' + self.assertTrue(selected_email_link in response.content) + + @patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True}) + def test_optout_course(self): + """ + Make sure student does not receive course email after opting out. + """ + url = reverse('change_email_settings') + # This is a checkbox, so on the post of opting out (that is, an Un-check of the box), + # the Post that is sent will not contain 'receive_emails' + response = self.client.post(url, {'course_id': self.course.id}) + self.assertEquals(json.loads(response.content), {'success': True}) + + self.client.logout() + + self.client.login(username=self.instructor.username, password="test") + self.navigate_to_email_view() + + url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) + test_email = { + 'action': 'Send email', + 'to_option': 'all', + 'subject': 'test subject for all', + 'message': 'test message for all' + } + response = self.client.post(url, test_email) + self.assertContains(response, "Your email was successfully queued for sending.") + + # Assert that self.student.email not in mail.to, outbox should be empty + self.assertEqual(len(mail.outbox), 0) + + @patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True}) + def test_optin_course(self): + """ + Make sure student receives course email after opting in. + """ + url = reverse('change_email_settings') + response = self.client.post(url, {'course_id': self.course.id, 'receive_emails': 'on'}) + self.assertEquals(json.loads(response.content), {'success': True}) + + self.client.logout() + + self.assertTrue(CourseEnrollment.is_enrolled(self.student, self.course.id)) + + self.client.login(username=self.instructor.username, password="test") + self.navigate_to_email_view() + + url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) + test_email = { + 'action': 'Send email', + 'to_option': 'all', + 'subject': 'test subject for all', + 'message': 'test message for all' + } + response = self.client.post(url, test_email) + + self.assertContains(response, "Your email was successfully queued for sending.") + + # Assert that self.student.email in mail.to + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(len(mail.outbox[0].to), 1) + self.assertEquals(mail.outbox[0].to[0], self.student.email) diff --git a/lms/djangoapps/bulk_email/tests/test_email.py b/lms/djangoapps/bulk_email/tests/test_email.py new file mode 100644 index 0000000000..ba2633f263 --- /dev/null +++ b/lms/djangoapps/bulk_email/tests/test_email.py @@ -0,0 +1,300 @@ +# -*- coding: utf-8 -*- +""" +Unit tests for sending course email +""" +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 +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + +from bulk_email.tasks import delegate_email_batches, course_email +from bulk_email.models import CourseEmail, Optout + +from mock import patch + +STAFF_COUNT = 3 +STUDENT_COUNT = 10 +LARGE_NUM_EMAILS = 137 + + +class MockCourseEmailResult(object): + """ + A small closure-like class to keep count of emails sent over all tasks, recorded + by mock object side effects + """ + emails_sent = 0 + + def get_mock_course_email_result(self): + """Wrapper for mock email function.""" + def mock_course_email_result(sent, failed, output, **kwargs): # pylint: disable=W0613 + """Increments count of number of emails sent.""" + self.emails_sent += sent + return True + return mock_course_email_result + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class TestEmailSendFromDashboard(ModuleStoreTestCase): + """ + Test that emails send correctly. + """ + + @patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True}) + def setUp(self): + self.course = CourseFactory.create() + self.instructor = UserFactory.create(username="instructor", email="robot+instructor@edx.org") + # Create instructor group for course + instructor_group = GroupFactory.create(name="instructor_MITx/999/Robot_Super_Course") + instructor_group.user_set.add(self.instructor) + + # Create staff + self.staff = [UserFactory() for _ in xrange(STAFF_COUNT)] + staff_group = GroupFactory() + for staff in self.staff: + staff_group.user_set.add(staff) # pylint: disable=E1101 + + # Create students + self.students = [UserFactory() for _ in xrange(STUDENT_COUNT)] + 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 + self.url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) + response = self.client.get(self.url) + email_link = 'Email' + # If this fails, it is likely because ENABLE_INSTRUCTOR_EMAIL is set to False + self.assertTrue(email_link in response.content) + + # Select the Email view of the instructor dash + session = self.client.session + session['idash_mode'] = 'Email' + session.save() + response = self.client.get(self.url) + selected_email_link = 'Email' + self.assertTrue(selected_email_link in response.content) + + def tearDown(self): + """ + Undo all patches. + """ + patch.stopall() + + def test_send_to_self(self): + """ + Make sure email send to myself goes to myself. + """ + # Now we know we have pulled up the instructor dash's email view + # (in the setUp method), we can test sending an email. + test_email = { + 'action': 'Send email', + 'to_option': 'myself', + 'subject': 'test subject for myself', + 'message': 'test message for myself' + } + response = self.client.post(self.url, test_email) + + self.assertContains(response, "Your email was successfully queued for sending.") + + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(len(mail.outbox[0].to), 1) + self.assertEquals(mail.outbox[0].to[0], self.instructor.email) + self.assertEquals( + mail.outbox[0].subject, + '[' + self.course.display_name + ']' + ' test subject for myself' + ) + + def test_send_to_staff(self): + """ + Make sure email send to staff and instructors goes there. + """ + # Now we know we have pulled up the instructor dash's email view + # (in the setUp method), we can test sending an email. + test_email = { + 'action': 'Send email', + 'to_option': 'staff', + 'subject': 'test subject for staff', + 'message': 'test message for subject' + } + response = self.client.post(self.url, test_email) + + self.assertContains(response, "Your email was successfully queued for sending.") + + # the 1 is for the instructor in this test and others + self.assertEquals(len(mail.outbox), 1 + len(self.staff)) + self.assertItemsEqual( + [e.to[0] for e in mail.outbox], + [self.instructor.email] + [s.email for s in self.staff] + ) + + def test_send_to_all(self): + """ + Make sure email send to all goes there. + """ + # Now we know we have pulled up the instructor dash's email view + # (in the setUp method), we can test sending an email. + + test_email = { + 'action': 'Send email', + 'to_option': 'all', + 'subject': 'test subject for all', + 'message': 'test message for all' + } + response = self.client.post(self.url, test_email) + + self.assertContains(response, "Your email was successfully queued for sending.") + + self.assertEquals(len(mail.outbox), 1 + len(self.staff) + len(self.students)) + 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] + ) + + def test_unicode_subject_send_to_all(self): + """ + Make sure email (with Unicode characters) send to all goes there. + """ + # Now we know we have pulled up the instructor dash's email view + # (in the setUp method), we can test sending an email. + + uni_subject = u'téśt śúbjéćt főŕ áĺĺ' + test_email = { + 'action': 'Send email', + 'to_option': 'all', + 'subject': uni_subject, + 'message': 'test message for all' + } + response = self.client.post(self.url, test_email) + + self.assertContains(response, "Your email was successfully queued for sending.") + + self.assertEquals(len(mail.outbox), 1 + len(self.staff) + len(self.students)) + 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] + ) + self.assertEquals( + mail.outbox[0].subject, + '[' + self.course.display_name + '] ' + uni_subject + ) + + def test_unicode_message_send_to_all(self): + """ + Make sure email (with Unicode characters) send to all goes there. + """ + # Now we know we have pulled up the instructor dash's email view + # (in the setUp method), we can test sending an email. + + uni_message = u'ẗëṡẗ ṁëṡṡäġë ḟöṛ äḷḷ イ乇丂イ ᄊ乇丂丂ムg乇 キo尺 ムレレ тэѕт мэѕѕаБэ fоѓ аll' + test_email = { + 'action': 'Send email', + 'to_option': 'all', + 'subject': 'test subject for all', + 'message': uni_message + } + response = self.client.post(self.url, test_email) + + self.assertContains(response, "Your email was successfully queued for sending.") + + self.assertEquals(len(mail.outbox), 1 + len(self.staff) + len(self.students)) + 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] + ) + + message_body = mail.outbox[0].body + self.assertIn(uni_message, message_body) + + def test_unicode_students_send_to_all(self): + """ + Make sure email (with Unicode characters) send to all goes there. + """ + # Now we know we have pulled up the instructor dash's email view + # (in the setUp method), we can test sending an email. + + # Create a student with Unicode in their first & last names + unicode_user = UserFactory(first_name=u'Ⓡⓞⓑⓞⓣ', last_name=u'ՇﻉรՇ') + CourseEnrollmentFactory.create(user=unicode_user, course_id=self.course.id) + self.students.append(unicode_user) + + test_email = { + 'action': 'Send email', + 'to_option': 'all', + 'subject': 'test subject for all', + 'message': 'test message for all' + } + response = self.client.post(self.url, test_email) + + self.assertContains(response, "Your email was successfully queued for sending.") + + self.assertEquals(len(mail.outbox), 1 + len(self.staff) + len(self.students)) + + 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] + ) + + @override_settings(EMAILS_PER_TASK=3, EMAILS_PER_QUERY=7) + @patch('bulk_email.tasks.course_email_result') + def test_chunked_queries_send_numerous_emails(self, email_mock): + """ + Test sending a large number of emails, to test the chunked querying + """ + mock_factory = MockCourseEmailResult() + email_mock.side_effect = mock_factory.get_mock_course_email_result() + added_users = [] + for _ in xrange(LARGE_NUM_EMAILS): + user = UserFactory() + added_users.append(user) + CourseEnrollmentFactory.create(user=user, course_id=self.course.id) + + optouts = [] + for i in [1, 3, 9, 10, 18]: # 5 random optouts + user = added_users[i] + optouts.append(user) + optout = Optout(user=user, course_id=self.course.id) + optout.save() + + test_email = { + 'action': 'Send email', + 'to_option': 'all', + 'subject': 'test subject for all', + 'message': 'test message for all' + } + response = self.client.post(self.url, test_email) + 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)) + 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) +class TestEmailSendExceptions(ModuleStoreTestCase): + """ + Test that exceptions are handled correctly. + """ + + def test_get_course_exc(self): + # Make sure delegate_email_batches handles Http404 exception from get_course_by_id. + with self.assertRaises(Exception): + delegate_email_batches("_", "_", "blah/blah/blah", "_", "_") + + def test_no_course_email_obj(self): + # Make sure course_email handles CourseEmail.DoesNotExist exception. + with self.assertRaises(CourseEmail.DoesNotExist): + 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 new file mode 100644 index 0000000000..61bdd315e9 --- /dev/null +++ b/lms/djangoapps/bulk_email/tests/test_err_handling.py @@ -0,0 +1,218 @@ +""" +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 +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory +from student.tests.factories import UserFactory, AdminFactory, CourseEnrollmentFactory + +from bulk_email.models import CourseEmail +from bulk_email.tasks import delegate_email_batches +from bulk_email.tests.smtp_server_thread import FakeSMTPServerThread + +from mock import patch, Mock +from smtplib import SMTPDataError, SMTPServerDisconnected, SMTPConnectError + +TEST_SMTP_PORT = 1025 + + +class EmailTestException(Exception): + pass + + +@override_settings( + MODULESTORE=TEST_DATA_MONGO_MODULESTORE, + EMAIL_BACKEND='django.core.mail.backends.smtp.EmailBackend', + EMAIL_HOST='localhost', + EMAIL_PORT=TEST_SMTP_PORT +) +class TestEmailErrors(ModuleStoreTestCase): + """ + Test that errors from sending email are handled properly. + """ + + def setUp(self): + self.course = CourseFactory.create() + self.instructor = AdminFactory.create() + self.client.login(username=self.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() + + self.url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) + + def tearDown(self): + self.smtp_server_thread.stop() + patch.stopall() + + @patch('bulk_email.tasks.course_email.retry') + def test_data_err_retry(self, retry): + """ + Test that celery handles transient SMTPDataErrors by retrying. + """ + self.smtp_server_thread.server.set_errtype( + "DATA", + "454 Throttling failure: Daily message quota exceeded." + ) + + test_email = { + 'action': 'Send email', + 'to_option': 'myself', + 'subject': 'test subject for myself', + 'message': 'test message for myself' + } + self.client.post(self.url, test_email) + + # Test that we retry upon hitting a 4xx error + self.assertTrue(retry.called) + (_, kwargs) = retry.call_args + exc = kwargs['exc'] + self.assertTrue(type(exc) == SMTPDataError) + + @patch('bulk_email.tasks.course_email_result') + @patch('bulk_email.tasks.course_email.retry') + def test_data_err_fail(self, retry, result): + """ + Test that celery handles permanent SMTPDataErrors by failing and not retrying. + """ + self.smtp_server_thread.server.set_errtype( + "DATA", + "554 Message rejected: Email address is not verified." + ) + + students = [UserFactory() for _ in xrange(settings.EMAILS_PER_TASK)] + for student in students: + CourseEnrollmentFactory.create(user=student, course_id=self.course.id) + + test_email = { + 'action': 'Send email', + 'to_option': 'all', + 'subject': 'test subject for all', + 'message': 'test message for all' + } + self.client.post(self.url, test_email) + + # We shouldn't retry when hitting a 5xx error + self.assertFalse(retry.called) + # Test that after the rejected email, the rest still successfully send + ((sent, fail, optouts), _) = result.call_args + self.assertEquals(optouts, 0) + self.assertEquals(fail, 1) + self.assertEquals(sent, settings.EMAILS_PER_TASK - 1) + + @patch('bulk_email.tasks.course_email.retry') + def test_disconn_err_retry(self, retry): + """ + Test that celery handles SMTPServerDisconnected by retrying. + """ + self.smtp_server_thread.server.set_errtype( + "DISCONN", + "Server disconnected, please try again later." + ) + test_email = { + 'action': 'Send email', + 'to_option': 'myself', + 'subject': 'test subject for myself', + 'message': 'test message for myself' + } + self.client.post(self.url, test_email) + + self.assertTrue(retry.called) + (_, kwargs) = retry.call_args + exc = kwargs['exc'] + self.assertTrue(type(exc) == SMTPServerDisconnected) + + @patch('bulk_email.tasks.course_email.retry') + def test_conn_err_retry(self, retry): + """ + Test that celery handles SMTPConnectError by retrying. + """ + # SMTP reply is already specified in fake SMTP Channel created + self.smtp_server_thread.server.set_errtype("CONN") + + test_email = { + 'action': 'Send email', + 'to_option': 'myself', + 'subject': 'test subject for myself', + 'message': 'test message for myself' + } + self.client.post(self.url, test_email) + + self.assertTrue(retry.called) + (_, kwargs) = retry.call_args + exc = kwargs['exc'] + self.assertTrue(type(exc) == SMTPConnectError) + + @patch('bulk_email.tasks.course_email_result') + @patch('bulk_email.tasks.course_email.retry') + @patch('bulk_email.tasks.log') + @patch('bulk_email.tasks.get_connection', Mock(return_value=EmailTestException)) + def test_general_exception(self, mock_log, retry, result): + """ + Tests the if the error is not SMTP-related, we log and reraise + """ + test_email = { + 'action': 'Send email', + 'to_option': 'myself', + 'subject': 'test subject for myself', + 'message': 'test message for myself' + } + # For some reason (probably the weirdness of testing with celery tasks) assertRaises doesn't work here + # so we assert on the arguments of log.exception + self.client.post(self.url, test_email) + ((log_str, email_id, to_list), _) = mock_log.exception.call_args + self.assertTrue(mock_log.exception.called) + self.assertIn('caused course_email task to fail with uncaught exception.', log_str) + self.assertEqual(email_id, 1) + self.assertEqual(to_list, [self.instructor.email]) + self.assertFalse(retry.called) + self.assertFalse(result.called) + + @patch('bulk_email.tasks.course_email_result') + @patch('bulk_email.tasks.delegate_email_batches.retry') + @patch('bulk_email.tasks.log') + def test_nonexist_email(self, mock_log, retry, result): + """ + Tests retries when the email doesn't exist + """ + delegate_email_batches.delay(-1, self.instructor.id) + ((log_str, email_id, num_retries), _) = mock_log.warning.call_args + self.assertTrue(mock_log.warning.called) + self.assertIn('Failed to get CourseEmail with id', log_str) + self.assertEqual(email_id, -1) + self.assertTrue(retry.called) + self.assertFalse(result.called) + + @patch('bulk_email.tasks.log') + def test_nonexist_course(self, mock_log): + """ + Tests exception when the course in the email doesn't exist + """ + email = CourseEmail(course_id="I/DONT/EXIST") + email.save() + delegate_email_batches.delay(email.id, self.instructor.id) + ((log_str, _), _) = mock_log.exception.call_args + self.assertTrue(mock_log.exception.called) + self.assertIn('get_course_by_id failed:', log_str) + + @patch('bulk_email.tasks.log') + def test_nonexist_to_option(self, mock_log): + """ + Tests exception when the to_option in the email doesn't exist + """ + email = CourseEmail(course_id=self.course.id, to_option="IDONTEXIST") + email.save() + delegate_email_batches.delay(email.id, self.instructor.id) + ((log_str, opt_str), _) = mock_log.error.call_args + self.assertTrue(mock_log.error.called) + self.assertIn('Unexpected bulk email TO_OPTION found', log_str) + self.assertEqual("IDONTEXIST", opt_str) diff --git a/lms/djangoapps/instructor/tests/test_legacy_email.py b/lms/djangoapps/instructor/tests/test_legacy_email.py new file mode 100644 index 0000000000..d8761466b0 --- /dev/null +++ b/lms/djangoapps/instructor/tests/test_legacy_email.py @@ -0,0 +1,142 @@ +""" +Unit tests for email feature flag in instructor dashboard +and student dashboard. Additionally tests that bulk email +is always disabled for non-Mongo backed courses, regardless +of email feature flag. +""" + +from django.test.utils import override_settings +from django.conf import settings +from django.core.urlresolvers import reverse + +from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from student.tests.factories import AdminFactory, UserFactory, CourseEnrollmentFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore import XML_MODULESTORE_TYPE + +from mock import patch + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class TestInstructorDashboardEmailView(ModuleStoreTestCase): + """ + Check for email view displayed with flag + """ + def setUp(self): + self.course = CourseFactory.create() + + # Create instructor account + instructor = AdminFactory.create() + self.client.login(username=instructor.username, password="test") + + # URL for instructor dash + self.url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) + # URL for email view + self.email_link = 'Email' + + def tearDown(self): + """ + Undo all patches. + """ + patch.stopall() + + @patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True}) + def test_email_flag_true(self): + # Assert that the URL for the email view is in the response + response = self.client.get(self.url) + self.assertTrue(self.email_link in response.content) + + # Select the Email view of the instructor dash + session = self.client.session + session['idash_mode'] = 'Email' + session.save() + response = self.client.get(self.url) + + # Ensure we've selected the view properly and that the send_to field is present. + selected_email_link = 'Email' + self.assertTrue(selected_email_link in response.content) + send_to_label = '' + self.assertTrue(send_to_label in response.content) + + @patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': False}) + def test_email_flag_false(self): + # Assert that the URL for the email view is not in the response + response = self.client.get(self.url) + self.assertFalse(self.email_link in response.content) + + @patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True}) + def test_email_flag_true_xml_store(self): + # If the enable email setting is enabled, but this is an XML backed course, + # the email view shouldn't be available on the instructor dashboard. + + # The course factory uses a MongoModuleStore backing, so patch the + # `get_modulestore_type` method to pretend to be XML-backed. + # This is OK; we're simply testing that the `is_mongo_modulestore_type` flag + # in `instructor/views/legacy.py` is doing the correct thing. + + with patch('xmodule.modulestore.mongo.base.MongoModuleStore.get_modulestore_type') as mock_modulestore: + mock_modulestore.return_value = XML_MODULESTORE_TYPE + + # Assert that the URL for the email view is not in the response + response = self.client.get(self.url) + self.assertFalse(self.email_link in response.content) + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class TestStudentDashboardEmailView(ModuleStoreTestCase): + """ + Check for email view displayed with flag + """ + def setUp(self): + self.course = CourseFactory.create() + + # Create student account + student = UserFactory.create() + CourseEnrollmentFactory.create(user=student, course_id=self.course.id) + self.client.login(username=student.username, password="test") + + # URL for dashboard + self.url = reverse('dashboard') + # URL for email settings modal + self.email_modal_link = (('') + .format(self.course.org, + self.course.number, + self.course.display_name.replace(' ', '_'))) + + def tearDown(self): + """ + Undo all patches. + """ + patch.stopall() + + @patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True}) + def test_email_flag_true(self): + # Assert that the URL for the email view is in the response + response = self.client.get(self.url) + self.assertTrue(self.email_modal_link in response.content) + + @patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': False}) + def test_email_flag_false(self): + # Assert that the URL for the email view is not in the response + response = self.client.get(self.url) + self.assertFalse(self.email_modal_link in response.content) + + @patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True}) + def test_email_flag_true_xml_store(self): + # If the enable email setting is enabled, but this is an XML backed course, + # the email view shouldn't be available on the instructor dashboard. + + # The course factory uses a MongoModuleStore backing, so patch the + # `get_modulestore_type` method to pretend to be XML-backed. + # This is OK; we're simply testing that the `is_mongo_modulestore_type` flag + # in `instructor/views/legacy.py` is doing the correct thing. + + with patch('xmodule.modulestore.mongo.base.MongoModuleStore.get_modulestore_type') as mock_modulestore: + mock_modulestore.return_value = XML_MODULESTORE_TYPE + + # Assert that the URL for the email view is not in the response + response = self.client.get(self.url) + self.assertFalse(self.email_modal_link in response.content) diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index 27ab17551c..b9295557d7 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -23,9 +23,12 @@ from django.core.urlresolvers import reverse from django.core.mail import send_mail from django.utils import timezone +from xmodule_modifiers import wrap_xmodule import xmodule.graders as xmgraders +from xmodule.modulestore import MONGO_MODULESTORE_TYPE from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.html_module import HtmlDescriptor from courseware import grades from courseware.access import (has_access, get_access_group_name, @@ -51,6 +54,10 @@ import track.views from mitxmako.shortcuts import render_to_string +from bulk_email.models import CourseEmail +from html_to_text import html_to_text +from bulk_email import tasks + log = logging.getLogger(__name__) # internal commands for managing forum roles: @@ -58,11 +65,11 @@ FORUM_ROLE_ADD = 'add' FORUM_ROLE_REMOVE = 'remove' -def split_by_comma_and_whitespace(s): +def split_by_comma_and_whitespace(a_str): """ - Return string s, split by , or whitespace + Return string a_str, split by , or whitespace """ - return re.split(r'[\s,]', s) + return re.split(r'[\s,]', a_str) @ensure_csrf_cookie @@ -76,6 +83,11 @@ def instructor_dashboard(request, course_id): forum_admin_access = has_forum_access(request.user, course_id, FORUM_ROLE_ADMINISTRATOR) msg = '' + email_msg = '' + email_to_option = None + email_subject = None + html_message = '' + show_email_tab = False problems = [] plots = [] datatable = {} @@ -111,13 +123,13 @@ def instructor_dashboard(request, course_id): datatable['data'] = data return datatable - def return_csv(fn, datatable, fp=None): + def return_csv(func, datatable, file_pointer=None): """Outputs a CSV file from the contents of a datatable.""" - if fp is None: + if file_pointer is None: response = HttpResponse(mimetype='text/csv') - response['Content-Disposition'] = 'attachment; filename={0}'.format(fn) + response['Content-Disposition'] = 'attachment; filename={0}'.format(func) else: - response = fp + response = file_pointer writer = csv.writer(response, dialect='excel', quotechar='"', quoting=csv.QUOTE_ALL) writer.writerow(datatable['header']) for datarow in datatable['data']: @@ -266,11 +278,11 @@ def instructor_dashboard(request, course_id): msg += 'Failed to create a background task for rescoring "{0}".'.format(problem_url) else: track.views.server_track(request, "rescore-all-submissions", {"problem": problem_url, "course": course_id}, page="idashboard") - except ItemNotFoundError as e: + except ItemNotFoundError as err: msg += 'Failed to create a background task for rescoring "{0}": problem not found.'.format(problem_url) - except Exception as e: - log.error("Encountered exception from rescore: {0}".format(e)) - msg += 'Failed to create a background task for rescoring "{0}": {1}.'.format(problem_url, e.message) + except Exception as err: + log.error("Encountered exception from rescore: {0}".format(err)) + msg += 'Failed to create a background task for rescoring "{0}": {1}.'.format(problem_url, err.message) elif "Reset ALL students' attempts" in action: problem_urlname = request.POST.get('problem_for_all_students', '') @@ -281,12 +293,12 @@ def instructor_dashboard(request, course_id): msg += 'Failed to create a background task for resetting "{0}".'.format(problem_url) else: track.views.server_track(request, "reset-all-attempts", {"problem": problem_url, "course": course_id}, page="idashboard") - except ItemNotFoundError as e: - log.error('Failure to reset: unknown problem "{0}"'.format(e)) + except ItemNotFoundError as err: + log.error('Failure to reset: unknown problem "{0}"'.format(err)) msg += 'Failed to create a background task for resetting "{0}": problem not found.'.format(problem_url) - except Exception as e: - log.error("Encountered exception from reset: {0}".format(e)) - msg += 'Failed to create a background task for resetting "{0}": {1}.'.format(problem_url, e.message) + except Exception as err: + log.error("Encountered exception from reset: {0}".format(err)) + msg += 'Failed to create a background task for resetting "{0}": {1}.'.format(problem_url, err.message) elif "Show Background Task History for Student" in action: # put this before the non-student case, since the use of "in" will cause this to be missed @@ -462,10 +474,10 @@ def instructor_dashboard(request, course_id): return return_csv('grades %s.csv' % aname, datatable) elif 'remote gradebook' in action: - fp = StringIO() - return_csv('', datatable, fp=fp) - fp.seek(0) - files = {'datafile': fp} + file_pointer = StringIO() + return_csv('', datatable, file_pointer=file_pointer) + file_pointer.seek(0) + files = {'datafile': file_pointer} msg2, _ = _do_remote_gradebook(request.user, course, 'post-grades', files=files) msg += msg2 @@ -687,6 +699,34 @@ def instructor_dashboard(request, course_id): ret = _do_enroll_students(course, course_id, students, overload=overload) datatable = ret['datatable'] + #---------------------------------------- + # email + + elif action == 'Send email': + email_to_option = request.POST.get("to_option") + email_subject = request.POST.get("subject") + html_message = request.POST.get("message") + text_message = html_to_text(html_message) + + email = CourseEmail(course_id=course_id, + sender=request.user, + to_option=email_to_option, + subject=email_subject, + html_message=html_message, + text_message=text_message) + + email.save() + + tasks.delegate_email_batches.delay( + email.id, + request.user.id + ) + + if email_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.

' + else: + email_msg = '

Your email was successfully queued for sending.

' + #---------------------------------------- # psychometrics @@ -752,6 +792,19 @@ def instructor_dashboard(request, course_id): else: instructor_tasks = None + # HTML editor for email + if idash_mode == 'Email': + html_module = HtmlDescriptor(course.system, {'data': html_message}) + email_editor = wrap_xmodule(html_module.get_html, html_module, 'xmodule_edit.html')() + else: + email_editor = None + + # Flag for whether or not we display the email tab (depending upon + # what backing store this course using (Mongo vs. XML)) + if settings.MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] and \ + modulestore().get_modulestore_type(course_id) == MONGO_MODULESTORE_TYPE: + show_email_tab = True + # display course stats only if there is no other table to display: course_stats = None if not datatable: @@ -768,6 +821,13 @@ def instructor_dashboard(request, course_id): 'course_stats': course_stats, 'msg': msg, 'modeflag': {idash_mode: 'selectedmode'}, + + 'to_option': email_to_option, # email + 'subject': email_subject, # email + 'editor': email_editor, # email + 'email_msg': email_msg, # email + 'show_email_tab': show_email_tab, # email + 'problems': problems, # psychometrics 'plots': plots, # psychometrics 'course_errors': modulestore().get_item_errors(course.location), diff --git a/lms/envs/aws.py b/lms/envs/aws.py index f6eb45ec51..a22fbc5bb6 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -102,6 +102,11 @@ with open(ENV_ROOT / CONFIG_PREFIX + "env.json") as env_file: PLATFORM_NAME = ENV_TOKENS.get('PLATFORM_NAME', PLATFORM_NAME) EMAIL_BACKEND = ENV_TOKENS.get('EMAIL_BACKEND', EMAIL_BACKEND) EMAIL_FILE_PATH = ENV_TOKENS.get('EMAIL_FILE_PATH', None) +EMAIL_HOST = ENV_TOKENS.get('EMAIL_HOST', 'localhost') # django default is localhost +EMAIL_PORT = ENV_TOKENS.get('EMAIL_PORT', 25) # django default is 25 +EMAIL_USE_TLS = ENV_TOKENS.get('EMAIL_USE_TLS', False) # django default is False +EMAILS_PER_TASK = ENV_TOKENS.get('EMAILS_PER_TASK', 100) +EMAILS_PER_QUERY = ENV_TOKENS.get('EMAILS_PER_QUERY', 1000) SITE_NAME = ENV_TOKENS['SITE_NAME'] SESSION_ENGINE = ENV_TOKENS.get('SESSION_ENGINE', SESSION_ENGINE) SESSION_COOKIE_DOMAIN = ENV_TOKENS.get('SESSION_COOKIE_DOMAIN') @@ -122,6 +127,7 @@ CACHES = ENV_TOKENS['CACHES'] #Email overrides DEFAULT_FROM_EMAIL = ENV_TOKENS.get('DEFAULT_FROM_EMAIL', DEFAULT_FROM_EMAIL) DEFAULT_FEEDBACK_EMAIL = ENV_TOKENS.get('DEFAULT_FEEDBACK_EMAIL', DEFAULT_FEEDBACK_EMAIL) +DEFAULT_BULK_FROM_EMAIL = ENV_TOKENS.get('DEFAULT_BULK_FROM_EMAIL', DEFAULT_BULK_FROM_EMAIL) ADMINS = ENV_TOKENS.get('ADMINS', ADMINS) SERVER_EMAIL = ENV_TOKENS.get('SERVER_EMAIL', SERVER_EMAIL) TECH_SUPPORT_EMAIL = ENV_TOKENS.get('TECH_SUPPORT_EMAIL', TECH_SUPPORT_EMAIL) @@ -197,7 +203,7 @@ SECRET_KEY = AUTH_TOKENS['SECRET_KEY'] AWS_ACCESS_KEY_ID = AUTH_TOKENS["AWS_ACCESS_KEY_ID"] AWS_SECRET_ACCESS_KEY = AUTH_TOKENS["AWS_SECRET_ACCESS_KEY"] -AWS_STORAGE_BUCKET_NAME = AUTH_TOKENS.get('AWS_STORAGE_BUCKET_NAME','edxuploads') +AWS_STORAGE_BUCKET_NAME = AUTH_TOKENS.get('AWS_STORAGE_BUCKET_NAME', 'edxuploads') DATABASES = AUTH_TOKENS['DATABASES'] @@ -211,6 +217,9 @@ CONTENTSTORE = AUTH_TOKENS.get('CONTENTSTORE', CONTENTSTORE) OPEN_ENDED_GRADING_INTERFACE = AUTH_TOKENS.get('OPEN_ENDED_GRADING_INTERFACE', OPEN_ENDED_GRADING_INTERFACE) +EMAIL_HOST_USER = AUTH_TOKENS.get('EMAIL_HOST_USER', '') # django default is '' +EMAIL_HOST_PASSWORD = AUTH_TOKENS.get('EMAIL_HOST_PASSWORD', '') # django default is '' + PEARSON_TEST_USER = "pearsontest" PEARSON_TEST_PASSWORD = AUTH_TOKENS.get("PEARSON_TEST_PASSWORD") diff --git a/lms/envs/common.py b/lms/envs/common.py index 0e659a1ca1..5ad81d5f03 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -103,6 +103,8 @@ MITX_FEATURES = { # analytics experiments 'ENABLE_INSTRUCTOR_ANALYTICS': False, + 'ENABLE_INSTRUCTOR_EMAIL': False, + # enable analytics server. # WARNING: THIS SHOULD ALWAYS BE SET TO FALSE UNDER NORMAL # LMS OPERATION. See analytics.py for details about what @@ -289,11 +291,11 @@ WIKI_ENABLED = False COURSE_DEFAULT = '6.002x_Fall_2012' COURSE_SETTINGS = {'6.002x_Fall_2012': {'number': '6.002x', - 'title': 'Circuits and Electronics', - 'xmlpath': '6002x/', - 'location': 'i4x://edx/6002xs12/course/6.002x_Fall_2012', - } - } + 'title': 'Circuits and Electronics', + 'xmlpath': '6002x/', + 'location': 'i4x://edx/6002xs12/course/6.002x_Fall_2012', + } + } # IP addresses that are allowed to reload the course, etc. # TODO (vshnayder): Will probably need to change as we get real access control in. @@ -361,6 +363,9 @@ IGNORABLE_404_ENDS = ('favicon.ico') # Email EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' DEFAULT_FROM_EMAIL = 'registration@edx.org' +DEFAULT_BULK_FROM_EMAIL = 'course-updates@edx.org' +EMAILS_PER_TASK = 100 +EMAILS_PER_QUERY = 1000 DEFAULT_FEEDBACK_EMAIL = 'feedback@edx.org' SERVER_EMAIL = 'devops@edx.org' TECH_SUPPORT_EMAIL = 'technical@edx.org' @@ -538,17 +543,17 @@ courseware_js = ( # 'js/vendor/RequireJS.js' - Require JS wrapper. # See https://edx-wiki.atlassian.net/wiki/display/LMS/Integration+of+Require+JS+into+the+system main_vendor_js = [ - 'js/vendor/RequireJS.js', - 'js/vendor/json2.js', - 'js/vendor/jquery.min.js', - 'js/vendor/jquery-ui.min.js', - 'js/vendor/jquery.cookie.js', - 'js/vendor/jquery.qtip.min.js', - 'js/vendor/swfobject/swfobject.js', - 'js/vendor/jquery.ba-bbq.min.js', - 'js/vendor/annotator.min.js', - 'js/vendor/annotator.store.min.js', - 'js/vendor/annotator.tags.min.js' + 'js/vendor/RequireJS.js', + 'js/vendor/json2.js', + 'js/vendor/jquery.min.js', + 'js/vendor/jquery-ui.min.js', + 'js/vendor/jquery.cookie.js', + 'js/vendor/jquery.qtip.min.js', + 'js/vendor/swfobject/swfobject.js', + 'js/vendor/jquery.ba-bbq.min.js', + 'js/vendor/annotator.min.js', + 'js/vendor/annotator.store.min.js', + 'js/vendor/annotator.tags.min.js' ] discussion_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/discussion/**/*.js')) @@ -612,6 +617,11 @@ PIPELINE_JS = { 'output_filename': 'js/lms-main_vendor.js', 'test_order': 0, }, + 'module-descriptor-js': { + 'source_filenames': rooted_glob(COMMON_ROOT / 'static/', 'xmodule/descriptors/js/*.js'), + 'output_filename': 'js/lms-module-descriptors.js', + 'test_order': 8, + }, 'module-js': { 'source_filenames': rooted_glob(COMMON_ROOT / 'static', 'xmodule/modules/js/*.js'), 'output_filename': 'js/lms-modules.js', @@ -756,6 +766,7 @@ INSTALLED_APPS = ( 'psychometrics', 'licenses', 'course_groups', + 'bulk_email', # External auth (OpenID, shib) 'external_auth', @@ -813,6 +824,7 @@ MKTG_URL_LINK_MAP = { 'PRIVACY': 'privacy_edx', } + ############################### THEME ################################ def enable_theme(theme_name): """ diff --git a/lms/envs/dev.py b/lms/envs/dev.py index 554c72dd89..4bd0247694 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -28,6 +28,7 @@ MITX_FEATURES['ENABLE_MANUAL_GIT_RELOAD'] = True MITX_FEATURES['ENABLE_PSYCHOMETRICS'] = False # real-time psychometrics (eg item response theory analysis in instructor dashboard) MITX_FEATURES['ENABLE_INSTRUCTOR_ANALYTICS'] = True MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True +MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] = True MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = True MITX_FEATURES['ENABLE_SHOPPING_CART'] = True 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 diff --git a/lms/static/img/problem-editor-icons.png b/lms/static/img/problem-editor-icons.png new file mode 100644 index 0000000000..27eb57b668 Binary files /dev/null and b/lms/static/img/problem-editor-icons.png differ diff --git a/lms/static/sass/base/_variables.scss b/lms/static/sass/base/_variables.scss index 93297f4043..ef9ac2a5b3 100644 --- a/lms/static/sass/base/_variables.scss +++ b/lms/static/sass/base/_variables.scss @@ -41,6 +41,15 @@ $green: rgb(37, 184, 90); $light-gray: #ddd; $dark-gray: #333; +// used by descriptor css +$lightGrey: #edf1f5; +$darkGrey: #8891a1; +$blue-d1: shade($blue,20%); +$blue-d2: shade($blue,40%); +$blue-d4: shade($blue,80%); +$shadow: rgba($black, 0.2); +$shadow-l1: rgba($black, 0.1); + // edx.org marketing site variables $m-gray: #8A8C8F; $m-gray-l1: #97999B; @@ -197,4 +206,4 @@ $homepage-bg-image: '../images/homepage-bg.jpg'; $login-banner-image: url(../images/bg-banner-login.png); $register-banner-image: url(../images/bg-banner-register.png); -$video-thumb-url: '../images/courses/video-thumb.jpg'; \ No newline at end of file +$video-thumb-url: '../images/courses/video-thumb.jpg'; diff --git a/lms/static/sass/course.scss.mako b/lms/static/sass/course.scss.mako index 7c04968f86..bef3afa9cd 100644 --- a/lms/static/sass/course.scss.mako +++ b/lms/static/sass/course.scss.mako @@ -65,6 +65,8 @@ // instructor @import "course/instructor/instructor"; @import "course/instructor/instructor_2"; +@import "course/instructor/email"; +@import "xmodule/descriptors/css/module-styles.scss"; // discussion @import "course/discussion/form-wmd-toolbar"; diff --git a/lms/static/sass/course/instructor/_email.scss b/lms/static/sass/course/instructor/_email.scss new file mode 100644 index 0000000000..bc38d64394 --- /dev/null +++ b/lms/static/sass/course/instructor/_email.scss @@ -0,0 +1,28 @@ +.email-editor { + border: 1px solid #c8c8c8; +} + +.xmodule_edit { + ul { + margin: 0; + padding: 0; + margin-bottom: 10px; + list-style: none; + } + + a { + line-height: (16*1.48) + px; + line-height: 1.48rem; + } +} + +.submit-email-action { + margin-top: 10px; + line-height: 1.3; + + ul { + margin-top: 0; + margin-bottom: 10px; + } +} + diff --git a/lms/static/sass/course/instructor/_instructor.scss b/lms/static/sass/course/instructor/_instructor.scss index f5f75e2be9..eee4afffc6 100644 --- a/lms/static/sass/course/instructor/_instructor.scss +++ b/lms/static/sass/course/instructor/_instructor.scss @@ -17,5 +17,56 @@ @extend .top-header; } } + + // form fields + .list-fields { + list-style: none; + margin: 0; + padding: 0; + + .field { + margin-bottom: 20px; + padding: 0; + + &:last-child { + margin-bottom: 0; + } + } + } + + // system feedback - messages + .msg { + border-radius: 1px; + padding: 10px 15px; + margin-bottom: 20px; + + .copy { + font-weight: 600; + } + } + + .msg-confirm { + border-top: 2px solid green; + background: tint(green,90%); + + .copy { + color: green; + } + } + + .list-advice { + list-style: none; + padding: 0; + margin: 20px 0; + + .item { + font-weight: 600; + margin-bottom: 10px; + + &:last-child { + margin-bottom: 0; + } + } + } } diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss index cd58d4d8e4..6b2e85b1c5 100644 --- a/lms/static/sass/multicourse/_dashboard.scss +++ b/lms/static/sass/multicourse/_dashboard.scss @@ -570,5 +570,10 @@ color: #333; } } + + a.email-settings { + @extend a.unenroll; + margin-right: 10px; + } } } diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index f045dfeb3b..fae76cca93 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -10,6 +10,12 @@ + + + + + + <%static:js group='module-descriptor-js'/> %if instructor_tasks is not None: %endif @@ -118,6 +124,9 @@ function goto( mode) ${_("Enrollment")} | ${_("DataDump")} | ${_("Manage Groups")} + %if show_email_tab: + | Email + %endif %if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_ANALYTICS'): | ${_("Analytics")} %endif @@ -431,6 +440,66 @@ function goto( mode) %endif %endif +##----------------------------------------------------------------------------- + +%if modeflag.get('Email'): + %if email_msg: +

${email_msg}

+ %endif + +
    +
  • + + +
  • + +
  • + + %if subject: + + %else: + + %endif +
  • + +
  • + + + +
  • +
+ +
+ ${_("Please try not to email students more than once a day. Important things to consider before sending:")} +
    +
  • ${_("Have you read over the email to make sure it says everything you want to say?")}
  • +
  • ${_("Have you sent the email to yourself first to make sure you're happy with how it's displayed?")}
  • +
+ +
+ +%endif + ##----------------------------------------------------------------------------- @@ -464,7 +533,7 @@ function goto( mode) %if analytics_results.get("StudentsDropoffPerDay"):

- ${_("Student activity day by day")} + ${_("Student activity day by day")} (${analytics_results["StudentsDropoffPerDay"]['time']})

diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 5023345376..7b077aec43 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -5,6 +5,8 @@ from courseware.courses import course_image_url, get_course_about_section from courseware.access import has_access from certificates.models import CertificateStatuses + from xmodule.modulestore import MONGO_MODULESTORE_TYPE + from xmodule.modulestore.django import modulestore %> <%inherit file="main.html" /> @@ -16,6 +18,14 @@ @@ -280,6 +308,10 @@ % endif % endif ${_('Unregister')} + % if settings.MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] and modulestore().get_modulestore_type(course.id) == MONGO_MODULESTORE_TYPE: + + + % endif @@ -313,6 +345,29 @@ + +