Commit 97e01be1 by brianhw

Merge pull request #1359 from edx/brian/bulk-email-rc

Bulk Email improvements for release
parents 291db0ab 86c4a03e
...@@ -89,3 +89,4 @@ Akshay Jagadeesh <akjags@gmail.com> ...@@ -89,3 +89,4 @@ Akshay Jagadeesh <akjags@gmail.com>
Nick Parlante <nick.parlante@cs.stanford.edu> Nick Parlante <nick.parlante@cs.stanford.edu>
Marko Seric <marko.seric@math.uzh.ch> Marko Seric <marko.seric@math.uzh.ch>
Felipe Montoya <felipe.montoya@edunext.co> Felipe Montoya <felipe.montoya@edunext.co>
Julia Hansbrough <julia@edx.org>
...@@ -5,10 +5,14 @@ These are notable changes in edx-platform. This is a rolling list of changes, ...@@ -5,10 +5,14 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected. the top. Include a label indicating the component affected.
LMS: Fix issue with CourseMode expiration dates LMS: Fix issue with CourseMode expiration dates
LMS: Add PaidCourseRegistration mode, where payment is required before course registration. LMS: Ported bulk emailing to the beta instructor dashboard.
LMS: Add monitoring of bulk email subtasks to display progress on instructor dash.
LMS: Add PaidCourseRegistration mode, where payment is required before course
registration.
LMS: Add split testing functionality for internal use. LMS: Add split testing functionality for internal use.
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
# pylint: disable=W0621 # pylint: disable=W0621
from lettuce import world from lettuce import world
from django.contrib.auth.models import User from django.contrib.auth.models import User, Group
from student.models import CourseEnrollment from student.models import CourseEnrollment
from xmodule.modulestore.django import editable_modulestore from xmodule.modulestore.django import editable_modulestore
from xmodule.contentstore.django import contentstore from xmodule.contentstore.django import contentstore
...@@ -41,9 +41,9 @@ def log_in(username='robot', password='test', email='robot@edx.org', name='Robot ...@@ -41,9 +41,9 @@ def log_in(username='robot', password='test', email='robot@edx.org', name='Robot
@world.absorb @world.absorb
def register_by_course_id(course_id, is_staff=False): def register_by_course_id(course_id, username='robot', password='test', is_staff=False):
create_user('robot', 'password') create_user(username, password)
u = User.objects.get(username='robot') user = User.objects.get(username=username)
if is_staff: if is_staff:
u.is_staff = True u.is_staff = True
u.save() u.save()
...@@ -51,6 +51,21 @@ def register_by_course_id(course_id, is_staff=False): ...@@ -51,6 +51,21 @@ def register_by_course_id(course_id, is_staff=False):
@world.absorb @world.absorb
def add_to_course_staff(username, course_num):
"""
Add the user with `username` to the course staff group
for `course_num`.
"""
# Based on code in lms/djangoapps/courseware/access.py
group_name = "instructor_{}".format(course_num)
group, _ = Group.objects.get_or_create(name=group_name)
group.save()
user = User.objects.get(username=username)
user.groups.add(group)
@world.absorb
def clear_courses(): def clear_courses():
# Flush and initialize the module store # Flush and initialize the module store
# Note that if your test module gets in some weird state # Note that if your test module gets in some weird state
......
...@@ -45,8 +45,46 @@ def is_css_not_present(css_selector, wait_time=5): ...@@ -45,8 +45,46 @@ def is_css_not_present(css_selector, wait_time=5):
world.browser.driver.implicitly_wait(world.IMPLICIT_WAIT) world.browser.driver.implicitly_wait(world.IMPLICIT_WAIT)
@world.absorb @world.absorb
def css_has_text(css_selector, text, index=0): def css_has_text(css_selector, text, index=0, strip=False):
return world.css_text(css_selector, index=index) == text """
Return a boolean indicating whether the element with `css_selector`
has `text`.
If `strip` is True, strip whitespace at beginning/end of both
strings before comparing.
If there are multiple elements matching the css selector,
use `index` to indicate which one.
"""
# If we're expecting a non-empty string, give the page
# a chance to fill in text fields.
if text:
world.wait_for(lambda _: world.css_text(css_selector, index=index))
actual_text = world.css_text(css_selector, index=index)
if strip:
actual_text = actual_text.strip()
text = text.strip()
return actual_text == text
@world.absorb
def css_has_value(css_selector, value, index=0):
"""
Return a boolean indicating whether the element with
`css_selector` has the specified `value`.
If there are multiple elements matching the css selector,
use `index` to indicate which one.
"""
# If we're expecting a non-empty string, give the page
# a chance to fill in values
if value:
world.wait_for(lambda _: world.css_value(css_selector, index=index))
return world.css_value(css_selector, index=index) == value
@world.absorb @world.absorb
......
...@@ -3,8 +3,8 @@ Django admin page for bulk email models ...@@ -3,8 +3,8 @@ Django admin page for bulk email models
""" """
from django.contrib import admin from django.contrib import admin
from bulk_email.models import CourseEmail, Optout, CourseEmailTemplate from bulk_email.models import CourseEmail, Optout, CourseEmailTemplate, CourseAuthorization
from bulk_email.forms import CourseEmailTemplateForm from bulk_email.forms import CourseEmailTemplateForm, CourseAuthorizationAdminForm
class CourseEmailAdmin(admin.ModelAdmin): class CourseEmailAdmin(admin.ModelAdmin):
...@@ -57,6 +57,23 @@ unsupported tags will cause email sending to fail. ...@@ -57,6 +57,23 @@ unsupported tags will cause email sending to fail.
return False return False
class CourseAuthorizationAdmin(admin.ModelAdmin):
"""Admin for enabling email on a course-by-course basis."""
form = CourseAuthorizationAdminForm
fieldsets = (
(None, {
'fields': ('course_id', 'email_enabled'),
'description': '''
Enter a course id in the following form: Org/Course/CourseRun, eg MITx/6.002x/2012_Fall
Do not enter leading or trailing slashes. There is no need to surround the course ID with quotes.
Validation will be performed on the course name, and if it is invalid, an error message will display.
To enable email for the course, check the "Email enabled" box, then click "Save".
'''
}),
)
admin.site.register(CourseEmail, CourseEmailAdmin) admin.site.register(CourseEmail, CourseEmailAdmin)
admin.site.register(Optout, OptoutAdmin) admin.site.register(Optout, OptoutAdmin)
admin.site.register(CourseEmailTemplate, CourseEmailTemplateAdmin) admin.site.register(CourseEmailTemplate, CourseEmailTemplateAdmin)
admin.site.register(CourseAuthorization, CourseAuthorizationAdmin)
...@@ -6,12 +6,16 @@ import logging ...@@ -6,12 +6,16 @@ import logging
from django import forms from django import forms
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from bulk_email.models import CourseEmailTemplate, COURSE_EMAIL_MESSAGE_BODY_TAG from bulk_email.models import CourseEmailTemplate, COURSE_EMAIL_MESSAGE_BODY_TAG, CourseAuthorization
from courseware.courses import get_course_by_id
from xmodule.modulestore import MONGO_MODULESTORE_TYPE
from xmodule.modulestore.django import modulestore
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class CourseEmailTemplateForm(forms.ModelForm): class CourseEmailTemplateForm(forms.ModelForm): # pylint: disable=R0924
"""Form providing validation of CourseEmail templates.""" """Form providing validation of CourseEmail templates."""
class Meta: # pylint: disable=C0111 class Meta: # pylint: disable=C0111
...@@ -43,3 +47,32 @@ class CourseEmailTemplateForm(forms.ModelForm): ...@@ -43,3 +47,32 @@ class CourseEmailTemplateForm(forms.ModelForm):
template = self.cleaned_data["plain_template"] template = self.cleaned_data["plain_template"]
self._validate_template(template) self._validate_template(template)
return template return template
class CourseAuthorizationAdminForm(forms.ModelForm): # pylint: disable=R0924
"""Input form for email enabling, allowing us to verify data."""
class Meta: # pylint: disable=C0111
model = CourseAuthorization
def clean_course_id(self):
"""Validate the course id"""
course_id = self.cleaned_data["course_id"]
try:
# Just try to get the course descriptor.
# If we can do that, it's a real course.
get_course_by_id(course_id, depth=1)
except Exception as exc:
msg = 'Error encountered ({0})'.format(str(exc).capitalize())
msg += ' --- Entered course id was: "{0}". '.format(course_id)
msg += 'Please recheck that you have supplied a course id in the format: ORG/COURSE/RUN'
raise forms.ValidationError(msg)
# Now, try and discern if it is a Studio course - HTML editor doesn't work with XML courses
is_studio_course = modulestore().get_modulestore_type(course_id) == MONGO_MODULESTORE_TYPE
if not is_studio_course:
msg = "Course Email feature is only available for courses authored in Studio. "
msg += '"{0}" appears to be an XML backed course.'.format(course_id)
raise forms.ValidationError(msg)
return course_id
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'CourseAuthorization'
db.create_table('bulk_email_courseauthorization', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
('email_enabled', self.gf('django.db.models.fields.BooleanField')(default=False)),
))
db.send_create_signal('bulk_email', ['CourseAuthorization'])
def backwards(self, orm):
# Deleting model 'CourseAuthorization'
db.delete_table('bulk_email_courseauthorization')
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.courseauthorization': {
'Meta': {'object_name': 'CourseAuthorization'},
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'email_enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
},
'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']
\ No newline at end of file
...@@ -12,11 +12,21 @@ file and check it in at the same time as your model changes. To do that, ...@@ -12,11 +12,21 @@ file and check it in at the same time as your model changes. To do that,
""" """
import logging import logging
from django.db import models from django.db import models, transaction
from django.contrib.auth.models import User from django.contrib.auth.models import User
from html_to_text import html_to_text
from django.conf import settings
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Bulk email to_options - the send to options that users can
# select from when they send email.
SEND_TO_MYSELF = 'myself'
SEND_TO_STAFF = 'staff'
SEND_TO_ALL = 'all'
TO_OPTIONS = [SEND_TO_MYSELF, SEND_TO_STAFF, SEND_TO_ALL]
class Email(models.Model): class Email(models.Model):
""" """
...@@ -33,12 +43,8 @@ class Email(models.Model): ...@@ -33,12 +43,8 @@ class Email(models.Model):
class Meta: # pylint: disable=C0111 class Meta: # pylint: disable=C0111
abstract = True abstract = True
SEND_TO_MYSELF = 'myself'
SEND_TO_STAFF = 'staff'
SEND_TO_ALL = 'all'
class CourseEmail(Email, models.Model): class CourseEmail(Email):
""" """
Stores information for an email to a course. Stores information for an email to a course.
""" """
...@@ -51,17 +57,66 @@ class CourseEmail(Email, models.Model): ...@@ -51,17 +57,66 @@ class CourseEmail(Email, models.Model):
# * All: This sends an email to anyone enrolled in the course, with any role # * All: This sends an email to anyone enrolled in the course, with any role
# (student, staff, or instructor) # (student, staff, or instructor)
# #
TO_OPTIONS = ( TO_OPTION_CHOICES = (
(SEND_TO_MYSELF, 'Myself'), (SEND_TO_MYSELF, 'Myself'),
(SEND_TO_STAFF, 'Staff and instructors'), (SEND_TO_STAFF, 'Staff and instructors'),
(SEND_TO_ALL, 'All') (SEND_TO_ALL, 'All')
) )
course_id = models.CharField(max_length=255, db_index=True) course_id = models.CharField(max_length=255, db_index=True)
to_option = models.CharField(max_length=64, choices=TO_OPTIONS, default=SEND_TO_MYSELF) to_option = models.CharField(max_length=64, choices=TO_OPTION_CHOICES, default=SEND_TO_MYSELF)
def __unicode__(self): def __unicode__(self):
return self.subject return self.subject
@classmethod
def create(cls, course_id, sender, to_option, subject, html_message, text_message=None):
"""
Create an instance of CourseEmail.
The CourseEmail.save_now method makes sure the CourseEmail entry is committed.
When called from any view that is wrapped by TransactionMiddleware,
and thus in a "commit-on-success" transaction, an autocommit buried within here
will cause any pending transaction to be committed by a successful
save here. Any future database operations will take place in a
separate transaction.
"""
# automatically generate the stripped version of the text from the HTML markup:
if text_message is None:
text_message = html_to_text(html_message)
# perform some validation here:
if to_option not in TO_OPTIONS:
fmt = 'Course email being sent to unrecognized to_option: "{to_option}" for "{course}", subject "{subject}"'
msg = fmt.format(to_option=to_option, course=course_id, subject=subject)
raise ValueError(msg)
# create the task, then save it immediately:
course_email = cls(
course_id=course_id,
sender=sender,
to_option=to_option,
subject=subject,
html_message=html_message,
text_message=text_message,
)
course_email.save_now()
return course_email
@transaction.autocommit
def save_now(self):
"""
Writes CourseEmail immediately, ensuring the transaction is committed.
Autocommit annotation makes sure the database entry is committed.
When called from any view that is wrapped by TransactionMiddleware,
and thus in a "commit-on-success" transaction, this autocommit here
will cause any pending transaction to be committed by a successful
save here. Any future database operations will take place in a
separate transaction.
"""
self.save()
class Optout(models.Model): class Optout(models.Model):
""" """
...@@ -101,7 +156,11 @@ class CourseEmailTemplate(models.Model): ...@@ -101,7 +156,11 @@ class CourseEmailTemplate(models.Model):
If one isn't stored, an exception is thrown. If one isn't stored, an exception is thrown.
""" """
return CourseEmailTemplate.objects.get() try:
return CourseEmailTemplate.objects.get()
except CourseEmailTemplate.DoesNotExist:
log.exception("Attempting to fetch a non-existent course email template")
raise
@staticmethod @staticmethod
def _render(format_string, message_body, context): def _render(format_string, message_body, context):
...@@ -153,3 +212,38 @@ class CourseEmailTemplate(models.Model): ...@@ -153,3 +212,38 @@ class CourseEmailTemplate(models.Model):
stored HTML template and the provided `context` dict. stored HTML template and the provided `context` dict.
""" """
return CourseEmailTemplate._render(self.html_template, htmltext, context) return CourseEmailTemplate._render(self.html_template, htmltext, context)
class CourseAuthorization(models.Model):
"""
Enable the course email feature on a course-by-course basis.
"""
# The course that these features are attached to.
course_id = models.CharField(max_length=255, db_index=True)
# Whether or not to enable instructor email
email_enabled = models.BooleanField(default=False)
@classmethod
def instructor_email_enabled(cls, course_id):
"""
Returns whether or not email is enabled for the given course id.
If email has not been explicitly enabled, returns False.
"""
# If settings.MITX_FEATURES['REQUIRE_COURSE_EMAIL_AUTH'] is
# set to False, then we enable email for every course.
if not settings.MITX_FEATURES['REQUIRE_COURSE_EMAIL_AUTH']:
return True
try:
record = cls.objects.get(course_id=course_id)
return record.email_enabled
except cls.DoesNotExist:
return False
def __unicode__(self):
not_en = "Not "
if self.email_enabled:
not_en = ""
return u"Course '{}': Instructor Email {}Enabled".format(self.course_id, not_en)
...@@ -59,7 +59,7 @@ class TestOptoutCourseEmails(ModuleStoreTestCase): ...@@ -59,7 +59,7 @@ class TestOptoutCourseEmails(ModuleStoreTestCase):
selected_email_link = '<a href="#" onclick="goto(\'Email\')" class="selectedmode">Email</a>' selected_email_link = '<a href="#" onclick="goto(\'Email\')" class="selectedmode">Email</a>'
self.assertTrue(selected_email_link in response.content) self.assertTrue(selected_email_link in response.content)
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True}) @patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': False})
def test_optout_course(self): def test_optout_course(self):
""" """
Make sure student does not receive course email after opting out. Make sure student does not receive course email after opting out.
...@@ -88,7 +88,7 @@ class TestOptoutCourseEmails(ModuleStoreTestCase): ...@@ -88,7 +88,7 @@ class TestOptoutCourseEmails(ModuleStoreTestCase):
# Assert that self.student.email not in mail.to, outbox should be empty # Assert that self.student.email not in mail.to, outbox should be empty
self.assertEqual(len(mail.outbox), 0) self.assertEqual(len(mail.outbox), 0)
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True}) @patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': False})
def test_optin_course(self): def test_optin_course(self):
""" """
Make sure student receives course email after opting in. Make sure student receives course email after opting in.
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
""" """
Unit tests for sending course email Unit tests for sending course email
""" """
from mock import patch
from django.conf import settings from django.conf import settings
from django.core import mail from django.core import mail
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
...@@ -12,11 +14,8 @@ from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE ...@@ -12,11 +14,8 @@ from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
from student.tests.factories import UserFactory, GroupFactory, CourseEnrollmentFactory from student.tests.factories import UserFactory, GroupFactory, CourseEnrollmentFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from bulk_email.models import Optout
from bulk_email.tasks import delegate_email_batches, course_email from instructor_task.subtasks import increment_subtask_status
from bulk_email.models import CourseEmail, Optout
from mock import patch
STAFF_COUNT = 3 STAFF_COUNT = 3
STUDENT_COUNT = 10 STUDENT_COUNT = 10
...@@ -30,13 +29,13 @@ class MockCourseEmailResult(object): ...@@ -30,13 +29,13 @@ class MockCourseEmailResult(object):
""" """
emails_sent = 0 emails_sent = 0
def get_mock_course_email_result(self): def get_mock_increment_subtask_status(self):
"""Wrapper for mock email function.""" """Wrapper for mock email function."""
def mock_course_email_result(sent, failed, output, **kwargs): # pylint: disable=W0613 def mock_increment_subtask_status(original_status, **kwargs): # pylint: disable=W0613
"""Increments count of number of emails sent.""" """Increments count of number of emails sent."""
self.emails_sent += sent self.emails_sent += kwargs.get('succeeded', 0)
return True return increment_subtask_status(original_status, **kwargs)
return mock_course_email_result return mock_increment_subtask_status
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
...@@ -45,7 +44,7 @@ class TestEmailSendFromDashboard(ModuleStoreTestCase): ...@@ -45,7 +44,7 @@ class TestEmailSendFromDashboard(ModuleStoreTestCase):
Test that emails send correctly. Test that emails send correctly.
""" """
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True}) @patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': False})
def setUp(self): def setUp(self):
self.course = CourseFactory.create() self.course = CourseFactory.create()
self.instructor = UserFactory.create(username="instructor", email="robot+instructor@edx.org") self.instructor = UserFactory.create(username="instructor", email="robot+instructor@edx.org")
...@@ -244,14 +243,14 @@ class TestEmailSendFromDashboard(ModuleStoreTestCase): ...@@ -244,14 +243,14 @@ class TestEmailSendFromDashboard(ModuleStoreTestCase):
[self.instructor.email] + [s.email for s in self.staff] + [s.email for s in self.students] [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) @override_settings(BULK_EMAIL_EMAILS_PER_TASK=3, BULK_EMAIL_EMAILS_PER_QUERY=7)
@patch('bulk_email.tasks.course_email_result') @patch('bulk_email.tasks.increment_subtask_status')
def test_chunked_queries_send_numerous_emails(self, email_mock): def test_chunked_queries_send_numerous_emails(self, email_mock):
""" """
Test sending a large number of emails, to test the chunked querying Test sending a large number of emails, to test the chunked querying
""" """
mock_factory = MockCourseEmailResult() mock_factory = MockCourseEmailResult()
email_mock.side_effect = mock_factory.get_mock_course_email_result() email_mock.side_effect = mock_factory.get_mock_increment_subtask_status()
added_users = [] added_users = []
for _ in xrange(LARGE_NUM_EMAILS): for _ in xrange(LARGE_NUM_EMAILS):
user = UserFactory() user = UserFactory()
...@@ -281,14 +280,3 @@ class TestEmailSendFromDashboard(ModuleStoreTestCase): ...@@ -281,14 +280,3 @@ class TestEmailSendFromDashboard(ModuleStoreTestCase):
[s.email for s in self.students] + [s.email for s in self.students] +
[s.email for s in added_users if s not in optouts]) [s.email for s in added_users if s not in optouts])
self.assertItemsEqual(outbox_contents, should_send_contents) 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_no_course_email_obj(self):
# Make sure course_email handles CourseEmail.DoesNotExist exception.
with self.assertRaises(CourseEmail.DoesNotExist):
course_email(101, [], "_", "_", "_", False)
"""
Unit tests for bulk-email-related forms.
"""
from django.test.utils import override_settings
from django.conf import settings
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE
from xmodule.modulestore.django import modulestore
from xmodule.modulestore import MONGO_MODULESTORE_TYPE
from mock import patch
from bulk_email.models import CourseAuthorization
from bulk_email.forms import CourseAuthorizationAdminForm
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class CourseAuthorizationFormTest(ModuleStoreTestCase):
"""Test the CourseAuthorizationAdminForm form for Mongo-backed courses."""
def setUp(self):
# Make a mongo course
self.course = CourseFactory.create()
def tearDown(self):
"""
Undo all patches.
"""
patch.stopall()
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': True})
def test_authorize_mongo_course(self):
# Initially course shouldn't be authorized
self.assertFalse(CourseAuthorization.instructor_email_enabled(self.course.id))
# Test authorizing the course, which should totally work
form_data = {'course_id': self.course.id, 'email_enabled': True}
form = CourseAuthorizationAdminForm(data=form_data)
# Validation should work
self.assertTrue(form.is_valid())
form.save()
# Check that this course is authorized
self.assertTrue(CourseAuthorization.instructor_email_enabled(self.course.id))
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': True})
def test_form_typo(self):
# Munge course id
bad_id = self.course.id + '_typo'
form_data = {'course_id': bad_id, 'email_enabled': True}
form = CourseAuthorizationAdminForm(data=form_data)
# Validation shouldn't work
self.assertFalse(form.is_valid())
msg = u'Error encountered (Course not found.)'
msg += ' --- Entered course id was: "{0}". '.format(bad_id)
msg += 'Please recheck that you have supplied a course id in the format: ORG/COURSE/RUN'
self.assertEquals(msg, form._errors['course_id'][0]) # pylint: disable=protected-access
with self.assertRaisesRegexp(ValueError, "The CourseAuthorization could not be created because the data didn't validate."):
form.save()
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': True})
def test_course_name_only(self):
# Munge course id - common
bad_id = self.course.id.split('/')[-1]
form_data = {'course_id': bad_id, 'email_enabled': True}
form = CourseAuthorizationAdminForm(data=form_data)
# Validation shouldn't work
self.assertFalse(form.is_valid())
msg = u'Error encountered (Need more than 1 value to unpack)'
msg += ' --- Entered course id was: "{0}". '.format(bad_id)
msg += 'Please recheck that you have supplied a course id in the format: ORG/COURSE/RUN'
self.assertEquals(msg, form._errors['course_id'][0]) # pylint: disable=protected-access
with self.assertRaisesRegexp(ValueError, "The CourseAuthorization could not be created because the data didn't validate."):
form.save()
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
class CourseAuthorizationXMLFormTest(ModuleStoreTestCase):
"""Check that XML courses cannot be authorized for email."""
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': True})
def test_xml_course_authorization(self):
course_id = 'edX/toy/2012_Fall'
# Assert this is an XML course
self.assertTrue(modulestore().get_modulestore_type(course_id) != MONGO_MODULESTORE_TYPE)
form_data = {'course_id': course_id, 'email_enabled': True}
form = CourseAuthorizationAdminForm(data=form_data)
# Validation shouldn't work
self.assertFalse(form.is_valid())
msg = u"Course Email feature is only available for courses authored in Studio. "
msg += '"{0}" appears to be an XML backed course.'.format(course_id)
self.assertEquals(msg, form._errors['course_id'][0]) # pylint: disable=protected-access
with self.assertRaisesRegexp(ValueError, "The CourseAuthorization could not be created because the data didn't validate."):
form.save()
"""
Unit tests for bulk-email-related models.
"""
from django.test import TestCase
from django.core.management import call_command
from django.conf import settings
from student.tests.factories import UserFactory
from mock import patch
from bulk_email.models import CourseEmail, SEND_TO_STAFF, CourseEmailTemplate, CourseAuthorization
class CourseEmailTest(TestCase):
"""Test the CourseEmail model."""
def test_creation(self):
course_id = 'abc/123/doremi'
sender = UserFactory.create()
to_option = SEND_TO_STAFF
subject = "dummy subject"
html_message = "<html>dummy message</html>"
email = CourseEmail.create(course_id, sender, to_option, subject, html_message)
self.assertEquals(email.course_id, course_id)
self.assertEquals(email.to_option, SEND_TO_STAFF)
self.assertEquals(email.subject, subject)
self.assertEquals(email.html_message, html_message)
self.assertEquals(email.sender, sender)
def test_bad_to_option(self):
course_id = 'abc/123/doremi'
sender = UserFactory.create()
to_option = "fake"
subject = "dummy subject"
html_message = "<html>dummy message</html>"
with self.assertRaises(ValueError):
CourseEmail.create(course_id, sender, to_option, subject, html_message)
class NoCourseEmailTemplateTest(TestCase):
"""Test the CourseEmailTemplate model without loading the template data."""
def test_get_missing_template(self):
with self.assertRaises(CourseEmailTemplate.DoesNotExist):
CourseEmailTemplate.get_template()
class CourseEmailTemplateTest(TestCase):
"""Test the CourseEmailTemplate model."""
def setUp(self):
# load initial content (since we don't run migrations as part of tests):
call_command("loaddata", "course_email_template.json")
def _get_sample_plain_context(self):
"""Provide sample context sufficient for rendering plaintext template"""
context = {
'course_title': "Bogus Course Title",
'course_url': "/location/of/course/url",
'account_settings_url': "/location/of/account/settings/url",
'platform_name': 'edX',
'email': 'your-email@test.com',
}
return context
def _get_sample_html_context(self):
"""Provide sample context sufficient for rendering HTML template"""
context = self._get_sample_plain_context()
context['course_image_url'] = "/location/of/course/image/url"
return context
def test_get_template(self):
template = CourseEmailTemplate.get_template()
self.assertIsNotNone(template.html_template)
self.assertIsNotNone(template.plain_template)
def test_render_html_without_context(self):
template = CourseEmailTemplate.get_template()
base_context = self._get_sample_html_context()
for keyname in base_context:
context = dict(base_context)
del context[keyname]
with self.assertRaises(KeyError):
template.render_htmltext("My new html text.", context)
def test_render_plaintext_without_context(self):
template = CourseEmailTemplate.get_template()
base_context = self._get_sample_plain_context()
for keyname in base_context:
context = dict(base_context)
del context[keyname]
with self.assertRaises(KeyError):
template.render_plaintext("My new plain text.", context)
def test_render_html(self):
template = CourseEmailTemplate.get_template()
context = self._get_sample_html_context()
template.render_htmltext("My new html text.", context)
def test_render_plain(self):
template = CourseEmailTemplate.get_template()
context = self._get_sample_plain_context()
template.render_plaintext("My new plain text.", context)
class CourseAuthorizationTest(TestCase):
"""Test the CourseAuthorization model."""
@patch.dict(settings.MITX_FEATURES, {'REQUIRE_COURSE_EMAIL_AUTH': True})
def test_creation_auth_on(self):
course_id = 'abc/123/doremi'
# Test that course is not authorized by default
self.assertFalse(CourseAuthorization.instructor_email_enabled(course_id))
# Authorize
cauth = CourseAuthorization(course_id=course_id, email_enabled=True)
cauth.save()
# Now, course should be authorized
self.assertTrue(CourseAuthorization.instructor_email_enabled(course_id))
self.assertEquals(
cauth.__unicode__(),
"Course 'abc/123/doremi': Instructor Email Enabled"
)
# Unauthorize by explicitly setting email_enabled to False
cauth.email_enabled = False
cauth.save()
# Test that course is now unauthorized
self.assertFalse(CourseAuthorization.instructor_email_enabled(course_id))
self.assertEquals(
cauth.__unicode__(),
"Course 'abc/123/doremi': Instructor Email Not Enabled"
)
@patch.dict(settings.MITX_FEATURES, {'REQUIRE_COURSE_EMAIL_AUTH': False})
def test_creation_auth_off(self):
course_id = 'blahx/blah101/ehhhhhhh'
# Test that course is authorized by default, since auth is turned off
self.assertTrue(CourseAuthorization.instructor_email_enabled(course_id))
# Use the admin interface to unauthorize the course
cauth = CourseAuthorization(course_id=course_id, email_enabled=False)
cauth.save()
# Now, course should STILL be authorized!
self.assertTrue(CourseAuthorization.instructor_email_enabled(course_id))
...@@ -36,11 +36,31 @@ def get_request_for_thread(): ...@@ -36,11 +36,31 @@ def get_request_for_thread():
del frame del frame
def get_course(course_id, depth=0):
"""
Given a course id, return the corresponding course descriptor.
If course_id is not valid, raises a ValueError. This is appropriate
for internal use.
depth: The number of levels of children for the modulestore to cache.
None means infinite depth. Default is to fetch no children.
"""
try:
course_loc = CourseDescriptor.id_to_location(course_id)
return modulestore().get_instance(course_id, course_loc, depth=depth)
except (KeyError, ItemNotFoundError):
raise ValueError("Course not found: {}".format(course_id))
except InvalidLocationError:
raise ValueError("Invalid location: {}".format(course_id))
def get_course_by_id(course_id, depth=0): def get_course_by_id(course_id, depth=0):
""" """
Given a course id, return the corresponding course descriptor. Given a course id, return the corresponding course descriptor.
If course_id is not valid, raises a 404. If course_id is not valid, raises a 404.
depth: The number of levels of children for the modulestore to cache. None means infinite depth depth: The number of levels of children for the modulestore to cache. None means infinite depth
""" """
try: try:
...@@ -51,6 +71,7 @@ def get_course_by_id(course_id, depth=0): ...@@ -51,6 +71,7 @@ def get_course_by_id(course_id, depth=0):
except InvalidLocationError: except InvalidLocationError:
raise Http404("Invalid location") raise Http404("Invalid location")
def get_course_with_access(user, course_id, action, depth=0): def get_course_with_access(user, course_id, action, depth=0):
""" """
Given a course_id, look up the corresponding course descriptor, Given a course_id, look up the corresponding course descriptor,
...@@ -182,7 +203,6 @@ def get_course_about_section(course, section_key): ...@@ -182,7 +203,6 @@ def get_course_about_section(course, section_key):
raise KeyError("Invalid about key " + str(section_key)) raise KeyError("Invalid about key " + str(section_key))
def get_course_info_section(request, course, section_key): def get_course_info_section(request, course, section_key):
""" """
This returns the snippet of html to be rendered on the course info page, This returns the snippet of html to be rendered on the course info page,
...@@ -194,8 +214,6 @@ def get_course_info_section(request, course, section_key): ...@@ -194,8 +214,6 @@ def get_course_info_section(request, course, section_key):
- updates - updates
- guest_updates - guest_updates
""" """
loc = Location(course.location.tag, course.location.org, course.location.course, 'course_info', section_key) loc = Location(course.location.tag, course.location.org, course.location.course, 'course_info', section_key)
# Use an empty cache # Use an empty cache
......
...@@ -2,15 +2,18 @@ ...@@ -2,15 +2,18 @@
from django.test import TestCase from django.test import TestCase
from django.http import Http404 from django.http import Http404
from django.test.utils import override_settings from django.test.utils import override_settings
from courseware.courses import get_course_by_id, get_cms_course_link_by_id from courseware.courses import get_course_by_id, get_course, get_cms_course_link_by_id
CMS_BASE_TEST = 'testcms' CMS_BASE_TEST = 'testcms'
class CoursesTest(TestCase): class CoursesTest(TestCase):
"""Test methods related to fetching courses."""
def test_get_course_by_id_invalid_chars(self): def test_get_course_by_id_invalid_chars(self):
""" """
Test that `get_course_by_id` throws a 404, rather than Test that `get_course_by_id` throws a 404, rather than
an exception, when faced with unexpected characters an exception, when faced with unexpected characters
(such as unicode characters, and symbols such as = and ' ') (such as unicode characters, and symbols such as = and ' ')
""" """
with self.assertRaises(Http404): with self.assertRaises(Http404):
...@@ -18,6 +21,17 @@ class CoursesTest(TestCase): ...@@ -18,6 +21,17 @@ class CoursesTest(TestCase):
get_course_by_id('MITx/foobar/business and management') get_course_by_id('MITx/foobar/business and management')
get_course_by_id('MITx/foobar/NiñøJoséMaríáßç') get_course_by_id('MITx/foobar/NiñøJoséMaríáßç')
def test_get_course_invalid_chars(self):
"""
Test that `get_course` throws a ValueError, rather than
a 404, when faced with unexpected characters
(such as unicode characters, and symbols such as = and ' ')
"""
with self.assertRaises(ValueError):
get_course('MITx/foobar/statistics=introduction')
get_course('MITx/foobar/business and management')
get_course('MITx/foobar/NiñøJoséMaríáßç')
@override_settings(CMS_BASE=CMS_BASE_TEST) @override_settings(CMS_BASE=CMS_BASE_TEST)
def test_get_cms_course_link_by_id(self): def test_get_cms_course_link_by_id(self):
""" """
......
@shard_2
Feature: Bulk Email
As an instructor or course staff,
In order to communicate with students and staff
I want to send email to staff and students in a course.
Scenario: Send bulk email
Given I am "<Role>" for a course
When I send email to "<Recipient>"
Then Email is sent to "<Recipient>"
Examples:
| Role | Recipient |
| instructor | myself |
| instructor | course staff |
| instructor | students, staff, and instructors |
| staff | myself |
| staff | course staff |
| staff | students, staff, and instructors |
"""
Define steps for bulk email acceptance test.
"""
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step
from lettuce.django import mail
from nose.tools import assert_in, assert_true, assert_equal # pylint: disable=E0611
from django.core.management import call_command
from django.conf import settings
@step(u'I am an instructor for a course')
def i_am_an_instructor(step): # pylint: disable=W0613
# Clear existing courses to avoid conflicts
world.clear_courses()
# Register the instructor as staff for the course
world.register_by_course_id(
'edx/999/Test_Course',
username='instructor',
password='password',
is_staff=True
)
world.add_to_course_staff('instructor', '999')
# Register another staff member
world.register_by_course_id(
'edx/999/Test_Course',
username='staff',
password='password',
is_staff=True
)
world.add_to_course_staff('staff', '999')
# Register a student
world.register_by_course_id(
'edx/999/Test_Course',
username='student',
password='password',
is_staff=False
)
# Log in as the instructor for the course
world.log_in(
username='instructor',
password='password',
email="instructor@edx.org",
name="Instructor"
)
# Dictionary mapping a description of the email recipient
# to the corresponding <option> value in the UI.
SEND_TO_OPTIONS = {
'myself': 'myself',
'course staff': 'staff',
'students, staff, and instructors': 'all'
}
@step(u'I send email to "([^"]*)"')
def when_i_send_an_email(recipient):
# Check that the recipient is valid
assert_in(
recipient, SEND_TO_OPTIONS,
msg="Invalid recipient: {}".format(recipient)
)
# Because we flush the database before each run,
# we need to ensure that the email template fixture
# is re-loaded into the database
call_command('loaddata', 'course_email_template.json')
# Go to the email section of the instructor dash
world.visit('/courses/edx/999/Test_Course')
world.css_click('a[href="/courses/edx/999/Test_Course/instructor"]')
world.css_click('div.beta-button-wrapper>a')
world.css_click('a[data-section="send_email"]')
# Select the recipient
world.select_option('send_to', SEND_TO_OPTIONS[recipient])
# Enter subject and message
world.css_fill('input#id_subject', 'Hello')
with world.browser.get_iframe('mce_0_ifr') as iframe:
editor = iframe.find_by_id('tinymce')[0]
editor.fill('test message')
# Click send
world.css_click('input[name="send"]')
# Expect to see a message that the email was sent
expected_msg = "Your email was successfully queued for sending."
assert_true(
world.css_has_text('div.request-response', expected_msg, '#request-response', allow_blank=False),
msg="Could not find email success message."
)
# Dictionaries mapping description of email recipient
# to the expected recipient email addresses
EXPECTED_ADDRESSES = {
'myself': ['instructor@edx.org'],
'course staff': ['instructor@edx.org', 'staff@edx.org'],
'students, staff, and instructors': ['instructor@edx.org', 'staff@edx.org', 'student@edx.org']
}
UNSUBSCRIBE_MSG = 'To stop receiving email like this'
@step(u'Email is sent to "([^"]*)"')
def then_the_email_is_sent(recipient):
# Check that the recipient is valid
assert_in(
recipient, SEND_TO_OPTIONS,
msg="Invalid recipient: {}".format(recipient)
)
# Retrieve messages. Because we are using celery in "always eager"
# mode, we expect all messages to be sent by this point.
messages = []
while not mail.queue.empty(): # pylint: disable=E1101
messages.append(mail.queue.get()) # pylint: disable=E1101
# Check that we got the right number of messages
assert_equal(
len(messages), len(EXPECTED_ADDRESSES[recipient]),
msg="Received {0} instead of {1} messages for {2}".format(
len(messages), len(EXPECTED_ADDRESSES[recipient]), recipient
)
)
# Check that the message properties were correct
recipients = []
for msg in messages:
assert_in('Hello', msg.subject)
assert_in(settings.DEFAULT_BULK_FROM_EMAIL, msg.from_email)
# Message body should have the message we sent
# and an unsubscribe message
assert_in('test message', msg.body)
assert_in(UNSUBSCRIBE_MSG, msg.body)
# Should have alternative HTML form
assert_equal(len(msg.alternatives), 1)
content = msg.alternatives[0]
assert_in('test message', content)
assert_in(UNSUBSCRIBE_MSG, content)
# Store the recipient address so we can verify later
recipients.extend(msg.recipients())
# Check that the messages were sent to the right people
for addr in EXPECTED_ADDRESSES[recipient]:
assert_in(addr, recipients)
...@@ -6,7 +6,6 @@ import unittest ...@@ -6,7 +6,6 @@ import unittest
import json import json
import requests import requests
from urllib import quote from urllib import quote
from django.conf import settings
from django.test import TestCase from django.test import TestCase
from nose.tools import raises from nose.tools import raises
from mock import Mock, patch from mock import Mock, patch
...@@ -125,6 +124,7 @@ class TestInstructorAPIDenyLevels(ModuleStoreTestCase, LoginEnrollmentTestCase): ...@@ -125,6 +124,7 @@ class TestInstructorAPIDenyLevels(ModuleStoreTestCase, LoginEnrollmentTestCase):
'list_forum_members', 'list_forum_members',
'update_forum_role_membership', 'update_forum_role_membership',
'proxy_legacy_analytics', 'proxy_legacy_analytics',
'send_email',
] ]
for endpoint in staff_level_endpoints: for endpoint in staff_level_endpoints:
url = reverse(endpoint, kwargs={'course_id': self.course.id}) url = reverse(endpoint, kwargs={'course_id': self.course.id})
...@@ -280,8 +280,8 @@ class TestInstructorAPILevelsAccess(ModuleStoreTestCase, LoginEnrollmentTestCase ...@@ -280,8 +280,8 @@ class TestInstructorAPILevelsAccess(ModuleStoreTestCase, LoginEnrollmentTestCase
This test does NOT test whether the actions had an effect on the This test does NOT test whether the actions had an effect on the
database, that is the job of test_access. database, that is the job of test_access.
This tests the response and action switch. This tests the response and action switch.
Actually, modify_access does not having a very meaningful Actually, modify_access does not have a very meaningful
response yet, so only the status code is tested. response yet, so only the status code is tested.
""" """
def setUp(self): def setUp(self):
self.instructor = AdminFactory.create() self.instructor = AdminFactory.create()
...@@ -691,7 +691,74 @@ class TestInstructorAPIRegradeTask(ModuleStoreTestCase, LoginEnrollmentTestCase) ...@@ -691,7 +691,74 @@ class TestInstructorAPIRegradeTask(ModuleStoreTestCase, LoginEnrollmentTestCase)
}) })
print response.content print response.content
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertTrue(act.called)
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
class TestInstructorSendEmail(ModuleStoreTestCase, LoginEnrollmentTestCase):
"""
Checks that only instructors have access to email endpoints, and that
these endpoints are only accessible with courses that actually exist,
only with valid email messages.
"""
def setUp(self):
self.instructor = AdminFactory.create()
self.course = CourseFactory.create()
self.client.login(username=self.instructor.username, password='test')
test_subject = u'\u1234 test subject'
test_message = u'\u6824 test message'
self.full_test_message = {
'send_to': 'staff',
'subject': test_subject,
'message': test_message,
}
def test_send_email_as_logged_in_instructor(self):
url = reverse('send_email', kwargs={'course_id': self.course.id})
response = self.client.post(url, self.full_test_message)
self.assertEqual(response.status_code, 200)
def test_send_email_but_not_logged_in(self):
self.client.logout()
url = reverse('send_email', kwargs={'course_id': self.course.id})
response = self.client.post(url, self.full_test_message)
self.assertEqual(response.status_code, 403)
def test_send_email_but_not_staff(self):
self.client.logout()
student = UserFactory()
self.client.login(username=student.username, password='test')
url = reverse('send_email', kwargs={'course_id': self.course.id})
response = self.client.post(url, self.full_test_message)
self.assertEqual(response.status_code, 403)
def test_send_email_but_course_not_exist(self):
url = reverse('send_email', kwargs={'course_id': 'GarbageCourse/DNE/NoTerm'})
response = self.client.post(url, self.full_test_message)
self.assertNotEqual(response.status_code, 200)
def test_send_email_no_sendto(self):
url = reverse('send_email', kwargs={'course_id': self.course.id})
response = self.client.post(url, {
'subject': 'test subject',
'message': 'test message',
})
self.assertEqual(response.status_code, 400)
def test_send_email_no_subject(self):
url = reverse('send_email', kwargs={'course_id': self.course.id})
response = self.client.post(url, {
'send_to': 'staff',
'message': 'test message',
})
self.assertEqual(response.status_code, 400)
def test_send_email_no_message(self):
url = reverse('send_email', kwargs={'course_id': self.course.id})
response = self.client.post(url, {
'send_to': 'staff',
'subject': 'test subject',
})
self.assertEqual(response.status_code, 400)
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) @override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
......
"""
Unit tests for email feature flag in new instructor 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
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE
from mock import patch
from bulk_email.models import CourseAuthorization
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class TestNewInstructorDashboardEmailViewMongoBacked(ModuleStoreTestCase):
"""
Check for email view on the new instructor dashboard
for Mongo-backed courses
"""
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_2', kwargs={'course_id': self.course.id})
# URL for email view
self.email_link = '<a href="" data-section="send_email">Email</a>'
def tearDown(self):
"""
Undo all patches.
"""
patch.stopall()
# In order for bulk email to work, we must have both the ENABLE_INSTRUCTOR_EMAIL_FLAG
# set to True and for the course to be Mongo-backed.
# The flag is enabled and the course is Mongo-backed (should work)
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': False})
def test_email_flag_true_mongo_true(self):
# Assert that instructor email is enabled for this course - since REQUIRE_COURSE_EMAIL_AUTH is False,
# all courses should be authorized to use email.
self.assertTrue(CourseAuthorization.instructor_email_enabled(self.course.id))
# Assert that the URL for the email view is in the response
response = self.client.get(self.url)
self.assertIn(self.email_link, response.content)
send_to_label = '<label for="id_to">Send to:</label>'
self.assertTrue(send_to_label in response.content)
self.assertEqual(response.status_code, 200)
# The course is Mongo-backed but the flag is disabled (should not work)
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': False})
def test_email_flag_false_mongo_true(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)
# Flag is enabled, but we require course auth and haven't turned it on for this course
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': True})
def test_course_not_authorized(self):
# Assert that instructor email is not enabled for this course
self.assertFalse(CourseAuthorization.instructor_email_enabled(self.course.id))
# 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)
# Flag is enabled, we require course auth and turn it on for this course
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': True})
def test_course_authorized(self):
# Assert that instructor email is not enabled for this course
self.assertFalse(CourseAuthorization.instructor_email_enabled(self.course.id))
# 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)
# Authorize the course to use email
cauth = CourseAuthorization(course_id=self.course.id, email_enabled=True)
cauth.save()
# Assert that instructor email is enabled for this course
self.assertTrue(CourseAuthorization.instructor_email_enabled(self.course.id))
# Assert that the URL for the email view is not in the response
response = self.client.get(self.url)
self.assertTrue(self.email_link in response.content)
# Flag is disabled, but course is authorized
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': False, 'REQUIRE_COURSE_EMAIL_AUTH': True})
def test_course_authorized_feature_off(self):
# Authorize the course to use email
cauth = CourseAuthorization(course_id=self.course.id, email_enabled=True)
cauth.save()
# Assert that instructor email IS enabled for this course
self.assertTrue(CourseAuthorization.instructor_email_enabled(self.course.id))
# 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_MIXED_MODULESTORE)
class TestNewInstructorDashboardEmailViewXMLBacked(ModuleStoreTestCase):
"""
Check for email view on the new instructor dashboard
"""
def setUp(self):
self.course_name = 'edX/toy/2012_Fall'
# Create instructor account
instructor = AdminFactory.create()
self.client.login(username=instructor.username, password="test")
# URL for instructor dash
self.url = reverse('instructor_dashboard_2', kwargs={'course_id': self.course_name})
# URL for email view
self.email_link = '<a href="" data-section="send_email">Email</a>'
# The flag is enabled, and since REQUIRE_COURSE_EMAIL_AUTH is False, all courses should
# be authorized to use email. But the course is not Mongo-backed (should not work)
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': False})
def test_email_flag_true_mongo_false(self):
response = self.client.get(self.url)
self.assertFalse(self.email_link in response.content)
# The flag is disabled and the course is not Mongo-backed (should not work)
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': False, 'REQUIRE_COURSE_EMAIL_AUTH': False})
def test_email_flag_false_mongo_false(self):
response = self.client.get(self.url)
self.assertFalse(self.email_link in response.content)
""" """
Unit tests for email feature flag in instructor dashboard Unit tests for email feature flag in legacy instructor dashboard
and student dashboard. Additionally tests that bulk email and student dashboard. Additionally tests that bulk email
is always disabled for non-Mongo backed courses, regardless is always disabled for non-Mongo backed courses, regardless
of email feature flag. of email feature flag.
...@@ -41,7 +41,7 @@ class TestInstructorDashboardEmailView(ModuleStoreTestCase): ...@@ -41,7 +41,7 @@ class TestInstructorDashboardEmailView(ModuleStoreTestCase):
""" """
patch.stopall() patch.stopall()
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True}) @patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': False})
def test_email_flag_true(self): def test_email_flag_true(self):
# Assert that the URL for the email view is in the response # Assert that the URL for the email view is in the response
response = self.client.get(self.url) response = self.client.get(self.url)
......
...@@ -9,7 +9,6 @@ Many of these GETs may become PUTs in the future. ...@@ -9,7 +9,6 @@ Many of these GETs may become PUTs in the future.
import re import re
import logging import logging
import requests import requests
from collections import OrderedDict
from django.conf import settings from django.conf import settings
from django_future.csrf import ensure_csrf_cookie from django_future.csrf import ensure_csrf_cookie
from django.views.decorators.cache import cache_control from django.views.decorators.cache import cache_control
...@@ -40,6 +39,8 @@ import analytics.distributions ...@@ -40,6 +39,8 @@ import analytics.distributions
import analytics.csvs import analytics.csvs
import csv import csv
from bulk_email.models import CourseEmail
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -95,7 +96,44 @@ def require_query_params(*args, **kwargs): ...@@ -95,7 +96,44 @@ def require_query_params(*args, **kwargs):
for (param, extra) in required_params: for (param, extra) in required_params:
default = object() default = object()
if request.GET.get(param, default) == default: if request.GET.get(param, default) == default:
error_response_data['parameters'] += [param] error_response_data['parameters'].append(param)
error_response_data['info'][param] = extra
if len(error_response_data['parameters']) > 0:
return JsonResponse(error_response_data, status=400)
else:
return func(*args, **kwargs)
return wrapped
return decorator
def require_post_params(*args, **kwargs):
"""
Checks for required parameters or renders a 400 error.
(decorator with arguments)
Functions like 'require_query_params', but checks for
POST parameters rather than GET parameters.
"""
required_params = []
required_params += [(arg, None) for arg in args]
required_params += [(key, kwargs[key]) for key in kwargs]
# required_params = e.g. [('action', 'enroll or unenroll'), ['emails', None]]
def decorator(func): # pylint: disable=C0111
def wrapped(*args, **kwargs): # pylint: disable=C0111
request = args[0]
error_response_data = {
'error': 'Missing required query parameter(s)',
'parameters': [],
'info': {},
}
for (param, extra) in required_params:
default = object()
if request.POST.get(param, default) == default:
error_response_data['parameters'].append(param)
error_response_data['info'][param] = extra error_response_data['info'][param] = extra
if len(error_response_data['parameters']) > 0: if len(error_response_data['parameters']) > 0:
...@@ -397,7 +435,7 @@ def get_anon_ids(request, course_id): # pylint: disable=W0613 ...@@ -397,7 +435,7 @@ def get_anon_ids(request, course_id): # pylint: disable=W0613
students = User.objects.filter( students = User.objects.filter(
courseenrollment__course_id=course_id, courseenrollment__course_id=course_id,
).order_by('id') ).order_by('id')
header =['User ID', 'Anonymized user ID'] header = ['User ID', 'Anonymized user ID']
rows = [[s.id, unique_id_for_user(s)] for s in students] rows = [[s.id, unique_id_for_user(s)] for s in students]
return csv_response(course_id.replace('/', '-') + '-anon-ids.csv', header, rows) return csv_response(course_id.replace('/', '-') + '-anon-ids.csv', header, rows)
...@@ -709,6 +747,36 @@ def list_forum_members(request, course_id): ...@@ -709,6 +747,36 @@ def list_forum_members(request, course_id):
@ensure_csrf_cookie @ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True) @cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff') @require_level('staff')
@require_post_params(send_to="sending to whom", subject="subject line", message="message text")
def send_email(request, course_id):
"""
Send an email to self, staff, or everyone involved in a course.
Query Parameters:
- 'send_to' specifies what group the email should be sent to
Options are defined by the CourseEmail model in
lms/djangoapps/bulk_email/models.py
- 'subject' specifies email's subject
- 'message' specifies email's content
"""
send_to = request.POST.get("send_to")
subject = request.POST.get("subject")
message = request.POST.get("message")
# Create the CourseEmail object. This is saved immediately, so that
# any transaction that has been pending up to this point will also be
# committed.
email = CourseEmail.create(course_id, request.user, send_to, subject, message)
# Submit the task, so that the correct InstructorTask object gets created (for monitoring purposes)
instructor_task.api.submit_bulk_course_email(request, course_id, email.id) # pylint: disable=E1101
response_payload = {'course_id': course_id}
return JsonResponse(response_payload)
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
@require_query_params( @require_query_params(
email="the target users email", email="the target users email",
rolename="the forum role", rolename="the forum role",
......
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
Instructor API endpoint urls. Instructor API endpoint urls.
""" """
from django.conf.urls import patterns, url from django.conf.urls import patterns, url
urlpatterns = patterns('', # nopep8 urlpatterns = patterns('', # nopep8
...@@ -34,4 +33,6 @@ urlpatterns = patterns('', # nopep8 ...@@ -34,4 +33,6 @@ urlpatterns = patterns('', # nopep8
'instructor.views.api.update_forum_role_membership', name="update_forum_role_membership"), 'instructor.views.api.update_forum_role_membership', name="update_forum_role_membership"),
url(r'^proxy_legacy_analytics$', url(r'^proxy_legacy_analytics$',
'instructor.views.api.proxy_legacy_analytics', name="proxy_legacy_analytics"), 'instructor.views.api.proxy_legacy_analytics', name="proxy_legacy_analytics"),
url(r'^send_email$',
'instructor.views.api.send_email', name="send_email")
) )
...@@ -9,14 +9,20 @@ from mitxmako.shortcuts import render_to_response ...@@ -9,14 +9,20 @@ from mitxmako.shortcuts import render_to_response
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.utils.html import escape from django.utils.html import escape
from django.http import Http404 from django.http import Http404
from django.conf import settings
from xmodule_modifiers import wrap_xmodule
from xmodule.html_module import HtmlDescriptor
from xmodule.modulestore import MONGO_MODULESTORE_TYPE
from xmodule.modulestore.django import modulestore
from xblock.field_data import DictFieldData
from xblock.fields import ScopeIds
from courseware.access import has_access from courseware.access import has_access
from courseware.courses import get_course_by_id from courseware.courses import get_course_by_id
from django_comment_client.utils import has_forum_access from django_comment_client.utils import has_forum_access
from django_comment_common.models import FORUM_ROLE_ADMINISTRATOR from django_comment_common.models import FORUM_ROLE_ADMINISTRATOR
from xmodule.modulestore.django import modulestore
from student.models import CourseEnrollment from student.models import CourseEnrollment
from bulk_email.models import CourseAuthorization
@ensure_csrf_cookie @ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True) @cache_control(no_cache=True, no_store=True, must_revalidate=True)
...@@ -24,6 +30,7 @@ def instructor_dashboard_2(request, course_id): ...@@ -24,6 +30,7 @@ def instructor_dashboard_2(request, course_id):
""" Display the instructor dashboard for a course. """ """ Display the instructor dashboard for a course. """
course = get_course_by_id(course_id, depth=None) course = get_course_by_id(course_id, depth=None)
is_studio_course = (modulestore().get_modulestore_type(course_id) == MONGO_MODULESTORE_TYPE)
access = { access = {
'admin': request.user.is_staff, 'admin': request.user.is_staff,
...@@ -45,6 +52,11 @@ def instructor_dashboard_2(request, course_id): ...@@ -45,6 +52,11 @@ def instructor_dashboard_2(request, course_id):
_section_analytics(course_id), _section_analytics(course_id),
] ]
# Gate access by feature flag & by course-specific authorization
if settings.MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] and \
is_studio_course and CourseAuthorization.instructor_email_enabled(course_id):
sections.append(_section_send_email(course_id, access, course))
context = { context = {
'course': course, 'course': course,
'old_dashboard_url': reverse('instructor_dashboard', kwargs={'course_id': course_id}), 'old_dashboard_url': reverse('instructor_dashboard', kwargs={'course_id': course_id}),
...@@ -141,6 +153,20 @@ def _section_data_download(course_id): ...@@ -141,6 +153,20 @@ def _section_data_download(course_id):
return section_data return section_data
def _section_send_email(course_id, access, course):
""" Provide data for the corresponding bulk email section """
html_module = HtmlDescriptor(course.system, DictFieldData({'data': ''}), ScopeIds(None, None, None, None))
email_editor = wrap_xmodule(html_module.get_html, html_module, 'xmodule_edit.html')()
section_data = {
'section_key': 'send_email',
'section_display_name': _('Email'),
'access': access,
'send_email': reverse('send_email', kwargs={'course_id': course_id}),
'editor': email_editor
}
return section_data
def _section_analytics(course_id): def _section_analytics(course_id):
""" Provide data for the corresponding dashboard section """ """ Provide data for the corresponding dashboard section """
section_data = { section_data = {
......
...@@ -30,6 +30,7 @@ from xmodule.modulestore.django import modulestore ...@@ -30,6 +30,7 @@ from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.html_module import HtmlDescriptor from xmodule.html_module import HtmlDescriptor
from bulk_email.models import CourseEmail, CourseAuthorization
from courseware import grades from courseware import grades
from courseware.access import (has_access, get_access_group_name, from courseware.access import (has_access, get_access_group_name,
course_beta_test_group_name) course_beta_test_group_name)
...@@ -46,7 +47,8 @@ from instructor_task.api import (get_running_instructor_tasks, ...@@ -46,7 +47,8 @@ from instructor_task.api import (get_running_instructor_tasks,
get_instructor_task_history, get_instructor_task_history,
submit_rescore_problem_for_all_students, submit_rescore_problem_for_all_students,
submit_rescore_problem_for_student, submit_rescore_problem_for_student,
submit_reset_problem_attempts_for_all_students) submit_reset_problem_attempts_for_all_students,
submit_bulk_course_email)
from instructor_task.views import get_task_completion_info from instructor_task.views import get_task_completion_info
from mitxmako.shortcuts import render_to_response from mitxmako.shortcuts import render_to_response
from psychometrics import psychoanalyze from psychometrics import psychoanalyze
...@@ -58,11 +60,6 @@ from xblock.field_data import DictFieldData ...@@ -58,11 +60,6 @@ from xblock.field_data import DictFieldData
from xblock.fields import ScopeIds from xblock.fields import ScopeIds
from django.utils.translation import ugettext as _u from django.utils.translation import ugettext as _u
from bulk_email.models import CourseEmail
from html_to_text import html_to_text
from bulk_email import tasks
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# internal commands for managing forum roles: # internal commands for managing forum roles:
...@@ -718,29 +715,24 @@ def instructor_dashboard(request, course_id): ...@@ -718,29 +715,24 @@ def instructor_dashboard(request, course_id):
email_to_option = request.POST.get("to_option") email_to_option = request.POST.get("to_option")
email_subject = request.POST.get("subject") email_subject = request.POST.get("subject")
html_message = request.POST.get("message") 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() # Create the CourseEmail object. This is saved immediately, so that
# any transaction that has been pending up to this point will also be
# committed.
email = CourseEmail.create(course_id, request.user, email_to_option, email_subject, html_message)
tasks.delegate_email_batches.delay( # Submit the task, so that the correct InstructorTask object gets created (for monitoring purposes)
email.id, submit_bulk_course_email(request, course_id, email.id) # pylint: disable=E1101
request.user.id
)
if email_to_option == "all": if email_to_option == "all":
email_msg = '<div class="msg msg-confirm"><p class="copy">Your email was successfully queued for sending. Please note that for large public classes (~10k), it may take 1-2 hours to send all emails.</p></div>' email_msg = '<div class="msg msg-confirm"><p class="copy">Your email was successfully queued for sending. Please note that for large public classes (~10k), it may take 1-2 hours to send all emails.</p></div>'
else: else:
email_msg = '<div class="msg msg-confirm"><p class="copy">Your email was successfully queued for sending.</p></div>' email_msg = '<div class="msg msg-confirm"><p class="copy">Your email was successfully queued for sending.</p></div>'
elif "Show Background Email Task History" in action:
message, datatable = get_background_task_table(course_id, task_type='bulk_course_email')
msg += message
#---------------------------------------- #----------------------------------------
# psychometrics # psychometrics
...@@ -806,23 +798,25 @@ def instructor_dashboard(request, course_id): ...@@ -806,23 +798,25 @@ def instructor_dashboard(request, course_id):
else: else:
instructor_tasks = None instructor_tasks = None
# determine if this is a studio-backed course so we can 1) provide a link to edit this course in studio # determine if this is a studio-backed course so we can provide a link to edit this course in studio
# 2) enable course email
is_studio_course = modulestore().get_modulestore_type(course_id) == MONGO_MODULESTORE_TYPE is_studio_course = modulestore().get_modulestore_type(course_id) == MONGO_MODULESTORE_TYPE
studio_url = None
if is_studio_course:
studio_url = get_cms_course_link_by_id(course_id)
email_editor = None email_editor = None
# HTML editor for email # HTML editor for email
if idash_mode == 'Email' and is_studio_course: if idash_mode == 'Email' and is_studio_course:
html_module = HtmlDescriptor(course.system, DictFieldData({'data': html_message}), ScopeIds(None, None, None, None)) html_module = HtmlDescriptor(course.system, DictFieldData({'data': html_message}), ScopeIds(None, None, None, None))
email_editor = wrap_xmodule(html_module.get_html, html_module, 'xmodule_edit.html')() email_editor = wrap_xmodule(html_module.get_html, html_module, 'xmodule_edit.html')()
studio_url = None # Enable instructor email only if the following conditions are met:
if is_studio_course: # 1. Feature flag is on
studio_url = get_cms_course_link_by_id(course_id) # 2. We have explicitly enabled email for the given course via django-admin
# 3. It is NOT an XML course
# Flag for whether or not we display the email tab (depending upon if settings.MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] and \
# what backing store this course using (Mongo vs. XML)) CourseAuthorization.instructor_email_enabled(course_id) and is_studio_course:
if settings.MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] and is_studio_course:
show_email_tab = True show_email_tab = True
# display course stats only if there is no other table to display: # display course stats only if there is no other table to display:
...@@ -865,6 +859,7 @@ def instructor_dashboard(request, course_id): ...@@ -865,6 +859,7 @@ def instructor_dashboard(request, course_id):
return render_to_response('courseware/instructor_dashboard.html', context) return render_to_response('courseware/instructor_dashboard.html', context)
def _do_remote_gradebook(user, course, action, args=None, files=None): def _do_remote_gradebook(user, course, action, args=None, files=None):
''' '''
Perform remote gradebook action. Returns msg, datatable. Perform remote gradebook action. Returns msg, datatable.
...@@ -1515,7 +1510,7 @@ def dump_grading_context(course): ...@@ -1515,7 +1510,7 @@ def dump_grading_context(course):
return msg return msg
def get_background_task_table(course_id, problem_url, student=None): def get_background_task_table(course_id, problem_url=None, student=None, task_type=None):
""" """
Construct the "datatable" structure to represent background task history. Construct the "datatable" structure to represent background task history.
...@@ -1526,14 +1521,16 @@ def get_background_task_table(course_id, problem_url, student=None): ...@@ -1526,14 +1521,16 @@ def get_background_task_table(course_id, problem_url, student=None):
Returns a tuple of (msg, datatable), where the msg is a possible error message, Returns a tuple of (msg, datatable), where the msg is a possible error message,
and the datatable is the datatable to be used for display. and the datatable is the datatable to be used for display.
""" """
history_entries = get_instructor_task_history(course_id, problem_url, student) history_entries = get_instructor_task_history(course_id, problem_url, student, task_type)
datatable = {} datatable = {}
msg = "" msg = ""
# first check to see if there is any history at all # first check to see if there is any history at all
# (note that we don't have to check that the arguments are valid; it # (note that we don't have to check that the arguments are valid; it
# just won't find any entries.) # just won't find any entries.)
if (history_entries.count()) == 0: if (history_entries.count()) == 0:
if student is not None: if problem_url is None:
msg += '<font color="red">Failed to find any background tasks for course "{course}".</font>'.format(course=course_id)
elif student is not None:
template = '<font color="red">Failed to find any background tasks for course "{course}", module "{problem}" and student "{student}".</font>' template = '<font color="red">Failed to find any background tasks for course "{course}", module "{problem}" and student "{student}".</font>'
msg += template.format(course=course_id, problem=problem_url, student=student.username) msg += template.format(course=course_id, problem=problem_url, student=student.username)
else: else:
...@@ -1570,7 +1567,9 @@ def get_background_task_table(course_id, problem_url, student=None): ...@@ -1570,7 +1567,9 @@ def get_background_task_table(course_id, problem_url, student=None):
task_message] task_message]
datatable['data'].append(row) datatable['data'].append(row)
if student is not None: if problem_url is None:
datatable['title'] = "{course_id}".format(course_id=course_id)
elif student is not None:
datatable['title'] = "{course_id} > {location} > {student}".format(course_id=course_id, datatable['title'] = "{course_id} > {location} > {student}".format(course_id=course_id,
location=problem_url, location=problem_url,
student=student.username) student=student.username)
......
...@@ -6,6 +6,7 @@ already been submitted, filtered either by running state or input ...@@ -6,6 +6,7 @@ already been submitted, filtered either by running state or input
arguments. arguments.
""" """
import hashlib
from celery.states import READY_STATES from celery.states import READY_STATES
...@@ -14,11 +15,13 @@ from xmodule.modulestore.django import modulestore ...@@ -14,11 +15,13 @@ from xmodule.modulestore.django import modulestore
from instructor_task.models import InstructorTask from instructor_task.models import InstructorTask
from instructor_task.tasks import (rescore_problem, from instructor_task.tasks import (rescore_problem,
reset_problem_attempts, reset_problem_attempts,
delete_problem_state) delete_problem_state,
send_bulk_course_email)
from instructor_task.api_helper import (check_arguments_for_rescoring, from instructor_task.api_helper import (check_arguments_for_rescoring,
encode_problem_and_student_input, encode_problem_and_student_input,
submit_task) submit_task)
from bulk_email.models import CourseEmail
def get_running_instructor_tasks(course_id): def get_running_instructor_tasks(course_id):
...@@ -34,14 +37,18 @@ def get_running_instructor_tasks(course_id): ...@@ -34,14 +37,18 @@ def get_running_instructor_tasks(course_id):
return instructor_tasks.order_by('-id') return instructor_tasks.order_by('-id')
def get_instructor_task_history(course_id, problem_url, student=None): def get_instructor_task_history(course_id, problem_url=None, student=None, task_type=None):
""" """
Returns a query of InstructorTask objects of historical tasks for a given course, Returns a query of InstructorTask objects of historical tasks for a given course,
that match a particular problem and optionally a student. that optionally match a particular problem, a student, and/or a task type.
""" """
_, task_key = encode_problem_and_student_input(problem_url, student) instructor_tasks = InstructorTask.objects.filter(course_id=course_id)
if problem_url is not None or student is not None:
_, task_key = encode_problem_and_student_input(problem_url, student)
instructor_tasks = instructor_tasks.filter(task_key=task_key)
if task_type is not None:
instructor_tasks = instructor_tasks.filter(task_type=task_type)
instructor_tasks = InstructorTask.objects.filter(course_id=course_id, task_key=task_key)
return instructor_tasks.order_by('-id') return instructor_tasks.order_by('-id')
...@@ -162,3 +169,40 @@ def submit_delete_problem_state_for_all_students(request, course_id, problem_url ...@@ -162,3 +169,40 @@ def submit_delete_problem_state_for_all_students(request, course_id, problem_url
task_class = delete_problem_state task_class = delete_problem_state
task_input, task_key = encode_problem_and_student_input(problem_url) task_input, task_key = encode_problem_and_student_input(problem_url)
return submit_task(request, task_type, task_class, course_id, task_input, task_key) return submit_task(request, task_type, task_class, course_id, task_input, task_key)
def submit_bulk_course_email(request, course_id, email_id):
"""
Request to have bulk email sent as a background task.
The specified CourseEmail object will be sent be updated for all students who have enrolled
in a course. Parameters are the `course_id` and the `email_id`, the id of the CourseEmail object.
AlreadyRunningError is raised if the same recipients are already being emailed with the same
CourseEmail object.
This method makes sure the InstructorTask entry is committed.
When called from any view that is wrapped by TransactionMiddleware,
and thus in a "commit-on-success" transaction, an autocommit buried within here
will cause any pending transaction to be committed by a successful
save here. Any future database operations will take place in a
separate transaction.
"""
# Assume that the course is defined, and that the user has already been verified to have
# appropriate access to the course. But make sure that the email exists.
# We also pull out the To argument here, so that is displayed in
# the InstructorTask status.
email_obj = CourseEmail.objects.get(id=email_id)
to_option = email_obj.to_option
task_type = 'bulk_course_email'
task_class = send_bulk_course_email
# Pass in the to_option as a separate argument, even though it's (currently)
# in the CourseEmail. That way it's visible in the progress status.
# (At some point in the future, we might take the recipient out of the CourseEmail,
# so that the same saved email can be sent to different recipients, as it is tested.)
task_input = {'email_id': email_id, 'to_option': to_option}
task_key_stub = "{email_id}_{to_option}".format(email_id=email_id, to_option=to_option)
# create the key value by using MD5 hash:
task_key = hashlib.md5(task_key_stub).hexdigest()
return submit_task(request, task_type, task_class, course_id, task_input, task_key)
...@@ -58,13 +58,14 @@ def _reserve_task(course_id, task_type, task_key, task_input, requester): ...@@ -58,13 +58,14 @@ def _reserve_task(course_id, task_type, task_key, task_input, requester):
return InstructorTask.create(course_id, task_type, task_key, task_input, requester) return InstructorTask.create(course_id, task_type, task_key, task_input, requester)
def _get_xmodule_instance_args(request): def _get_xmodule_instance_args(request, task_id):
""" """
Calculate parameters needed for instantiating xmodule instances. Calculate parameters needed for instantiating xmodule instances.
The `request_info` will be passed to a tracking log function, to provide information The `request_info` will be passed to a tracking log function, to provide information
about the source of the task request. The `xqueue_callback_url_prefix` is used to about the source of the task request. The `xqueue_callback_url_prefix` is used to
permit old-style xqueue callbacks directly to the appropriate module in the LMS. permit old-style xqueue callbacks directly to the appropriate module in the LMS.
The `task_id` is also passed to the tracking log function.
""" """
request_info = {'username': request.user.username, request_info = {'username': request.user.username,
'ip': request.META['REMOTE_ADDR'], 'ip': request.META['REMOTE_ADDR'],
...@@ -74,6 +75,7 @@ def _get_xmodule_instance_args(request): ...@@ -74,6 +75,7 @@ def _get_xmodule_instance_args(request):
xmodule_instance_args = {'xqueue_callback_url_prefix': get_xqueue_callback_url_prefix(request), xmodule_instance_args = {'xqueue_callback_url_prefix': get_xqueue_callback_url_prefix(request),
'request_info': request_info, 'request_info': request_info,
'task_id': task_id,
} }
return xmodule_instance_args return xmodule_instance_args
...@@ -88,10 +90,16 @@ def _update_instructor_task(instructor_task, task_result): ...@@ -88,10 +90,16 @@ def _update_instructor_task(instructor_task, task_result):
is usually not saved. In general, tasks that have finished (either with is usually not saved. In general, tasks that have finished (either with
success or failure) should have their entries updated by the task itself, success or failure) should have their entries updated by the task itself,
so are not updated here. Tasks that are still running are not updated so are not updated here. Tasks that are still running are not updated
while they run. So the one exception to the no-save rule are tasks that and saved while they run. The one exception to the no-save rule are tasks that
are in a "revoked" state. This may mean that the task never had the are in a "revoked" state. This may mean that the task never had the
opportunity to update the InstructorTask entry. opportunity to update the InstructorTask entry.
Tasks that are in progress and have subtasks doing the processing do not look
to the task's AsyncResult object. When subtasks are running, the
InstructorTask object itself is updated with the subtasks' progress,
not any AsyncResult object. In this case, the InstructorTask is
not updated at all.
Calculates json to store in "task_output" field of the `instructor_task`, Calculates json to store in "task_output" field of the `instructor_task`,
as well as updating the task_state. as well as updating the task_state.
...@@ -108,11 +116,21 @@ def _update_instructor_task(instructor_task, task_result): ...@@ -108,11 +116,21 @@ def _update_instructor_task(instructor_task, task_result):
returned_result = task_result.result returned_result = task_result.result
result_traceback = task_result.traceback result_traceback = task_result.traceback
# Assume we don't always update the InstructorTask entry if we don't have to: # Assume we don't always save the InstructorTask entry if we don't have to,
# but that in most cases we will update the InstructorTask in-place with its
# current progress.
entry_needs_updating = True
entry_needs_saving = False entry_needs_saving = False
task_output = None task_output = None
if result_state in [PROGRESS, SUCCESS]: if instructor_task.task_state == PROGRESS and len(instructor_task.subtasks) > 0:
# This happens when running subtasks: the result object is marked with SUCCESS,
# meaning that the subtasks have successfully been defined. However, the InstructorTask
# will be marked as in PROGRESS, until the last subtask completes and marks it as SUCCESS.
# We want to ignore the parent SUCCESS if subtasks are still running, and just trust the
# contents of the InstructorTask.
entry_needs_updating = False
elif result_state in [PROGRESS, SUCCESS]:
# construct a status message directly from the task result's result: # construct a status message directly from the task result's result:
# it needs to go back with the entry passed in. # it needs to go back with the entry passed in.
log.info("background task (%s), state %s: result: %s", task_id, result_state, returned_result) log.info("background task (%s), state %s: result: %s", task_id, result_state, returned_result)
...@@ -134,12 +152,13 @@ def _update_instructor_task(instructor_task, task_result): ...@@ -134,12 +152,13 @@ def _update_instructor_task(instructor_task, task_result):
# save progress and state into the entry, even if it's not being saved: # save progress and state into the entry, even if it's not being saved:
# when celery is run in "ALWAYS_EAGER" mode, progress needs to go back # when celery is run in "ALWAYS_EAGER" mode, progress needs to go back
# with the entry passed in. # with the entry passed in.
instructor_task.task_state = result_state if entry_needs_updating:
if task_output is not None: instructor_task.task_state = result_state
instructor_task.task_output = task_output if task_output is not None:
instructor_task.task_output = task_output
if entry_needs_saving: if entry_needs_saving:
instructor_task.save() instructor_task.save()
def get_updated_instructor_task(task_id): def get_updated_instructor_task(task_id):
...@@ -175,7 +194,7 @@ def get_status_from_instructor_task(instructor_task): ...@@ -175,7 +194,7 @@ def get_status_from_instructor_task(instructor_task):
'in_progress': boolean indicating if task is still running. 'in_progress': boolean indicating if task is still running.
'task_progress': dict containing progress information. This includes: 'task_progress': dict containing progress information. This includes:
'attempted': number of attempts made 'attempted': number of attempts made
'updated': number of attempts that "succeeded" 'succeeded': number of attempts that "succeeded"
'total': number of possible subtasks to attempt 'total': number of possible subtasks to attempt
'action_name': user-visible verb to use in status messages. Should be past-tense. 'action_name': user-visible verb to use in status messages. Should be past-tense.
'duration_ms': how long the task has (or had) been running. 'duration_ms': how long the task has (or had) been running.
...@@ -214,7 +233,7 @@ def check_arguments_for_rescoring(course_id, problem_url): ...@@ -214,7 +233,7 @@ def check_arguments_for_rescoring(course_id, problem_url):
def encode_problem_and_student_input(problem_url, student=None): def encode_problem_and_student_input(problem_url, student=None):
""" """
Encode problem_url and optional student into task_key and task_input values. Encode optional problem_url and optional student into task_key and task_input values.
`problem_url` is full URL of the problem. `problem_url` is full URL of the problem.
`student` is the user object of the student `student` is the user object of the student
...@@ -257,7 +276,7 @@ def submit_task(request, task_type, task_class, course_id, task_input, task_key) ...@@ -257,7 +276,7 @@ def submit_task(request, task_type, task_class, course_id, task_input, task_key)
# submit task: # submit task:
task_id = instructor_task.task_id task_id = instructor_task.task_id
task_args = [instructor_task.id, _get_xmodule_instance_args(request)] task_args = [instructor_task.id, _get_xmodule_instance_args(request, task_id)] # pylint: disable=E1101
task_class.apply_async(task_args, task_id=task_id) task_class.apply_async(task_args, task_id=task_id)
return instructor_task return instructor_task
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding field 'InstructorTask.subtasks'
db.add_column('instructor_task_instructortask', 'subtasks',
self.gf('django.db.models.fields.TextField')(default='', blank=True),
keep_default=False)
def backwards(self, orm):
# Deleting field 'InstructorTask.subtasks'
db.delete_column('instructor_task_instructortask', 'subtasks')
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'})
},
'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'})
},
'instructor_task.instructortask': {
'Meta': {'object_name': 'InstructorTask'},
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'requester': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
'subtasks': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'task_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'task_input': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'task_key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'task_output': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'null': 'True'}),
'task_state': ('django.db.models.fields.CharField', [], {'max_length': '50', 'null': 'True', 'db_index': 'True'}),
'task_type': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
'updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'})
}
}
complete_apps = ['instructor_task']
\ No newline at end of file
...@@ -56,6 +56,7 @@ class InstructorTask(models.Model): ...@@ -56,6 +56,7 @@ class InstructorTask(models.Model):
requester = models.ForeignKey(User, db_index=True) requester = models.ForeignKey(User, db_index=True)
created = models.DateTimeField(auto_now_add=True, null=True) created = models.DateTimeField(auto_now_add=True, null=True)
updated = models.DateTimeField(auto_now=True) updated = models.DateTimeField(auto_now=True)
subtasks = models.TextField(blank=True) # JSON dictionary
def __repr__(self): def __repr__(self):
return 'InstructorTask<%r>' % ({ return 'InstructorTask<%r>' % ({
......
...@@ -19,14 +19,21 @@ a problem URL and optionally a student. These are used to set up the initial va ...@@ -19,14 +19,21 @@ a problem URL and optionally a student. These are used to set up the initial va
of the query for traversing StudentModule objects. of the query for traversing StudentModule objects.
""" """
from django.utils.translation import ugettext_noop
from celery import task from celery import task
from instructor_task.tasks_helper import (update_problem_module_state, from functools import partial
rescore_problem_module_state, from instructor_task.tasks_helper import (
reset_attempts_module_state, run_main_task,
delete_problem_module_state) BaseInstructorTask,
perform_module_state_update,
rescore_problem_module_state,
@task reset_attempts_module_state,
delete_problem_module_state,
)
from bulk_email.tasks import perform_delegate_email_batches
@task(base=BaseInstructorTask) # pylint: disable=E1102
def rescore_problem(entry_id, xmodule_instance_args): def rescore_problem(entry_id, xmodule_instance_args):
"""Rescores a problem in a course, for all students or one specific student. """Rescores a problem in a course, for all students or one specific student.
...@@ -45,15 +52,19 @@ def rescore_problem(entry_id, xmodule_instance_args): ...@@ -45,15 +52,19 @@ def rescore_problem(entry_id, xmodule_instance_args):
`xmodule_instance_args` provides information needed by _get_module_instance_for_task() `xmodule_instance_args` provides information needed by _get_module_instance_for_task()
to instantiate an xmodule instance. to instantiate an xmodule instance.
""" """
action_name = 'rescored' # Translators: This is a past-tense verb that is inserted into task progress messages as {action}.
update_fcn = rescore_problem_module_state action_name = ugettext_noop('rescored')
filter_fcn = lambda(modules_to_update): modules_to_update.filter(state__contains='"done": true') update_fcn = partial(rescore_problem_module_state, xmodule_instance_args)
return update_problem_module_state(entry_id,
update_fcn, action_name, filter_fcn=filter_fcn, def filter_fcn(modules_to_update):
xmodule_instance_args=xmodule_instance_args) """Filter that matches problems which are marked as being done"""
return modules_to_update.filter(state__contains='"done": true')
visit_fcn = partial(perform_module_state_update, update_fcn, filter_fcn)
return run_main_task(entry_id, visit_fcn, action_name)
@task @task(base=BaseInstructorTask) # pylint: disable=E1102
def reset_problem_attempts(entry_id, xmodule_instance_args): def reset_problem_attempts(entry_id, xmodule_instance_args):
"""Resets problem attempts to zero for a particular problem for all students in a course. """Resets problem attempts to zero for a particular problem for all students in a course.
...@@ -68,14 +79,14 @@ def reset_problem_attempts(entry_id, xmodule_instance_args): ...@@ -68,14 +79,14 @@ def reset_problem_attempts(entry_id, xmodule_instance_args):
`xmodule_instance_args` provides information needed by _get_module_instance_for_task() `xmodule_instance_args` provides information needed by _get_module_instance_for_task()
to instantiate an xmodule instance. to instantiate an xmodule instance.
""" """
action_name = 'reset' # Translators: This is a past-tense verb that is inserted into task progress messages as {action}.
update_fcn = reset_attempts_module_state action_name = ugettext_noop('reset')
return update_problem_module_state(entry_id, update_fcn = partial(reset_attempts_module_state, xmodule_instance_args)
update_fcn, action_name, filter_fcn=None, visit_fcn = partial(perform_module_state_update, update_fcn, None)
xmodule_instance_args=xmodule_instance_args) return run_main_task(entry_id, visit_fcn, action_name)
@task @task(base=BaseInstructorTask) # pylint: disable=E1102
def delete_problem_state(entry_id, xmodule_instance_args): def delete_problem_state(entry_id, xmodule_instance_args):
"""Deletes problem state entirely for all students on a particular problem in a course. """Deletes problem state entirely for all students on a particular problem in a course.
...@@ -90,8 +101,29 @@ def delete_problem_state(entry_id, xmodule_instance_args): ...@@ -90,8 +101,29 @@ def delete_problem_state(entry_id, xmodule_instance_args):
`xmodule_instance_args` provides information needed by _get_module_instance_for_task() `xmodule_instance_args` provides information needed by _get_module_instance_for_task()
to instantiate an xmodule instance. to instantiate an xmodule instance.
""" """
action_name = 'deleted' # Translators: This is a past-tense verb that is inserted into task progress messages as {action}.
update_fcn = delete_problem_module_state action_name = ugettext_noop('deleted')
return update_problem_module_state(entry_id, update_fcn = partial(delete_problem_module_state, xmodule_instance_args)
update_fcn, action_name, filter_fcn=None, visit_fcn = partial(perform_module_state_update, update_fcn, None)
xmodule_instance_args=xmodule_instance_args) return run_main_task(entry_id, visit_fcn, action_name)
@task(base=BaseInstructorTask) # pylint: disable=E1102
def send_bulk_course_email(entry_id, _xmodule_instance_args):
"""Sends emails to recipients enrolled in a course.
`entry_id` is the id value of the InstructorTask entry that corresponds to this task.
The entry contains the `course_id` that identifies the course, as well as the
`task_input`, which contains task-specific input.
The task_input should be a dict with the following entries:
'email_id': the full URL to the problem to be rescored. (required)
`_xmodule_instance_args` provides information needed by _get_module_instance_for_task()
to instantiate an xmodule instance. This is unused here.
"""
# Translators: This is a past-tense verb that is inserted into task progress messages as {action}.
action_name = ugettext_noop('emailed')
visit_fcn = perform_delegate_email_batches
return run_main_task(entry_id, visit_fcn, action_name)
...@@ -6,16 +6,21 @@ from xmodule.modulestore.exceptions import ItemNotFoundError ...@@ -6,16 +6,21 @@ from xmodule.modulestore.exceptions import ItemNotFoundError
from courseware.tests.factories import UserFactory from courseware.tests.factories import UserFactory
from instructor_task.api import (get_running_instructor_tasks, from bulk_email.models import CourseEmail, SEND_TO_ALL
get_instructor_task_history, from instructor_task.api import (
submit_rescore_problem_for_all_students, get_running_instructor_tasks,
submit_rescore_problem_for_student, get_instructor_task_history,
submit_reset_problem_attempts_for_all_students, submit_rescore_problem_for_all_students,
submit_delete_problem_state_for_all_students) submit_rescore_problem_for_student,
submit_reset_problem_attempts_for_all_students,
submit_delete_problem_state_for_all_students,
submit_bulk_course_email,
)
from instructor_task.api_helper import AlreadyRunningError from instructor_task.api_helper import AlreadyRunningError
from instructor_task.models import InstructorTask, PROGRESS from instructor_task.models import InstructorTask, PROGRESS
from instructor_task.tests.test_base import (InstructorTaskTestCase, from instructor_task.tests.test_base import (InstructorTaskTestCase,
InstructorTaskCourseTestCase,
InstructorTaskModuleTestCase, InstructorTaskModuleTestCase,
TEST_COURSE_ID) TEST_COURSE_ID)
...@@ -42,12 +47,28 @@ class InstructorTaskReportTest(InstructorTaskTestCase): ...@@ -42,12 +47,28 @@ class InstructorTaskReportTest(InstructorTaskTestCase):
expected_ids.append(self._create_success_entry().task_id) expected_ids.append(self._create_success_entry().task_id)
expected_ids.append(self._create_progress_entry().task_id) expected_ids.append(self._create_progress_entry().task_id)
task_ids = [instructor_task.task_id for instructor_task task_ids = [instructor_task.task_id for instructor_task
in get_instructor_task_history(TEST_COURSE_ID, self.problem_url)] in get_instructor_task_history(TEST_COURSE_ID, problem_url=self.problem_url)]
self.assertEquals(set(task_ids), set(expected_ids)) self.assertEquals(set(task_ids), set(expected_ids))
# make the same call using explicit task_type:
task_ids = [instructor_task.task_id for instructor_task
in get_instructor_task_history(
TEST_COURSE_ID,
problem_url=self.problem_url,
task_type='rescore_problem'
)]
self.assertEquals(set(task_ids), set(expected_ids))
# make the same call using a non-existent task_type:
task_ids = [instructor_task.task_id for instructor_task
in get_instructor_task_history(
TEST_COURSE_ID,
problem_url=self.problem_url,
task_type='dummy_type'
)]
self.assertEquals(set(task_ids), set())
class InstructorTaskSubmitTest(InstructorTaskModuleTestCase): class InstructorTaskModuleSubmitTest(InstructorTaskModuleTestCase):
"""Tests API methods that involve the submission of background tasks.""" """Tests API methods that involve the submission of module-based background tasks."""
def setUp(self): def setUp(self):
self.initialize_course() self.initialize_course()
...@@ -136,3 +157,29 @@ class InstructorTaskSubmitTest(InstructorTaskModuleTestCase): ...@@ -136,3 +157,29 @@ class InstructorTaskSubmitTest(InstructorTaskModuleTestCase):
def test_submit_delete_all(self): def test_submit_delete_all(self):
self._test_submit_task(submit_delete_problem_state_for_all_students) self._test_submit_task(submit_delete_problem_state_for_all_students)
class InstructorTaskCourseSubmitTest(InstructorTaskCourseTestCase):
"""Tests API methods that involve the submission of course-based background tasks."""
def setUp(self):
self.initialize_course()
self.student = UserFactory.create(username="student", email="student@edx.org")
self.instructor = UserFactory.create(username="instructor", email="instructor@edx.org")
def _define_course_email(self):
"""Create CourseEmail object for testing."""
course_email = CourseEmail.create(self.course.id, self.instructor, SEND_TO_ALL, "Test Subject", "<p>This is a test message</p>")
return course_email.id # pylint: disable=E1101
def test_submit_bulk_email_all(self):
email_id = self._define_course_email()
instructor_task = submit_bulk_course_email(self.create_task_request(self.instructor), self.course.id, email_id)
# test resubmitting, by updating the existing record:
instructor_task = InstructorTask.objects.get(id=instructor_task.id) # pylint: disable=E1101
instructor_task.task_state = PROGRESS
instructor_task.save()
with self.assertRaises(AlreadyRunningError):
instructor_task = submit_bulk_course_email(self.create_task_request(self.instructor), self.course.id, email_id)
...@@ -88,7 +88,7 @@ class InstructorTaskTestCase(TestCase): ...@@ -88,7 +88,7 @@ class InstructorTaskTestCase(TestCase):
def _create_progress_entry(self, student=None, task_state=PROGRESS): def _create_progress_entry(self, student=None, task_state=PROGRESS):
"""Creates a InstructorTask entry representing a task in progress.""" """Creates a InstructorTask entry representing a task in progress."""
progress = {'attempted': 3, progress = {'attempted': 3,
'updated': 2, 'succeeded': 2,
'total': 5, 'total': 5,
'action_name': 'rescored', 'action_name': 'rescored',
} }
...@@ -96,10 +96,10 @@ class InstructorTaskTestCase(TestCase): ...@@ -96,10 +96,10 @@ class InstructorTaskTestCase(TestCase):
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) @override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
class InstructorTaskModuleTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): class InstructorTaskCourseTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase):
""" """
Base test class for InstructorTask-related tests that require Base test class for InstructorTask-related tests that require
the setup of a course and problem in order to access StudentModule state. the setup of a course.
""" """
course = None course = None
current_user = None current_user = None
...@@ -120,6 +120,7 @@ class InstructorTaskModuleTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase) ...@@ -120,6 +120,7 @@ class InstructorTaskModuleTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase)
# add a sequence to the course to which the problems can be added # add a sequence to the course to which the problems can be added
self.problem_section = ItemFactory.create(parent_location=chapter.location, self.problem_section = ItemFactory.create(parent_location=chapter.location,
category='sequential', category='sequential',
metadata={'graded': True, 'format': 'Homework'},
display_name=TEST_SECTION_NAME) display_name=TEST_SECTION_NAME)
@staticmethod @staticmethod
...@@ -130,12 +131,12 @@ class InstructorTaskModuleTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase) ...@@ -130,12 +131,12 @@ class InstructorTaskModuleTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase)
def login_username(self, username): def login_username(self, username):
"""Login the user, given the `username`.""" """Login the user, given the `username`."""
if self.current_user != username: if self.current_user != username:
self.login(InstructorTaskModuleTestCase.get_user_email(username), "test") self.login(InstructorTaskCourseTestCase.get_user_email(username), "test")
self.current_user = username self.current_user = username
def _create_user(self, username, is_staff=False): def _create_user(self, username, is_staff=False):
"""Creates a user and enrolls them in the test course.""" """Creates a user and enrolls them in the test course."""
email = InstructorTaskModuleTestCase.get_user_email(username) email = InstructorTaskCourseTestCase.get_user_email(username)
thisuser = UserFactory.create(username=username, email=email, is_staff=is_staff) thisuser = UserFactory.create(username=username, email=email, is_staff=is_staff)
CourseEnrollmentFactory.create(user=thisuser, course_id=self.course.id) CourseEnrollmentFactory.create(user=thisuser, course_id=self.course.id)
return thisuser return thisuser
...@@ -149,6 +150,31 @@ class InstructorTaskModuleTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase) ...@@ -149,6 +150,31 @@ class InstructorTaskModuleTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase)
return self._create_user(username, is_staff=False) return self._create_user(username, is_staff=False)
@staticmethod @staticmethod
def get_task_status(task_id):
"""Use api method to fetch task status, using mock request."""
mock_request = Mock()
mock_request.REQUEST = {'task_id': task_id}
response = instructor_task_status(mock_request)
status = json.loads(response.content)
return status
def create_task_request(self, requester_username):
"""Generate request that can be used for submitting tasks"""
request = Mock()
request.user = User.objects.get(username=requester_username)
request.get_host = Mock(return_value="testhost")
request.META = {'REMOTE_ADDR': '0:0:0:0', 'SERVER_NAME': 'testhost'}
request.is_secure = Mock(return_value=False)
return request
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
class InstructorTaskModuleTestCase(InstructorTaskCourseTestCase):
"""
Base test class for InstructorTask-related tests that require
the setup of a course and problem in order to access StudentModule state.
"""
@staticmethod
def problem_location(problem_url_name): def problem_location(problem_url_name):
""" """
Create an internal location for a test problem. Create an internal location for a test problem.
...@@ -191,21 +217,3 @@ class InstructorTaskModuleTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase) ...@@ -191,21 +217,3 @@ class InstructorTaskModuleTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase)
module_type=descriptor.location.category, module_type=descriptor.location.category,
module_state_key=descriptor.location.url(), module_state_key=descriptor.location.url(),
) )
@staticmethod
def get_task_status(task_id):
"""Use api method to fetch task status, using mock request."""
mock_request = Mock()
mock_request.REQUEST = {'task_id': task_id}
response = instructor_task_status(mock_request)
status = json.loads(response.content)
return status
def create_task_request(self, requester_username):
"""Generate request that can be used for submitting tasks"""
request = Mock()
request.user = User.objects.get(username=requester_username)
request.get_host = Mock(return_value="testhost")
request.META = {'REMOTE_ADDR': '0:0:0:0', 'SERVER_NAME': 'testhost'}
request.is_secure = Mock(return_value=False)
return request
...@@ -227,7 +227,7 @@ class TestRescoringTask(TestIntegrationTask): ...@@ -227,7 +227,7 @@ class TestRescoringTask(TestIntegrationTask):
self.assertEqual(task_input['problem_url'], InstructorTaskModuleTestCase.problem_location(problem_url_name)) self.assertEqual(task_input['problem_url'], InstructorTaskModuleTestCase.problem_location(problem_url_name))
status = json.loads(instructor_task.task_output) status = json.loads(instructor_task.task_output)
self.assertEqual(status['attempted'], 1) self.assertEqual(status['attempted'], 1)
self.assertEqual(status['updated'], 0) self.assertEqual(status['succeeded'], 0)
self.assertEqual(status['total'], 1) self.assertEqual(status['total'], 1)
def define_code_response_problem(self, problem_url_name): def define_code_response_problem(self, problem_url_name):
......
...@@ -3,6 +3,7 @@ import json ...@@ -3,6 +3,7 @@ import json
import logging import logging
from django.http import HttpResponse from django.http import HttpResponse
from django.utils.translation import ugettext as _
from celery.states import FAILURE, REVOKED, READY_STATES from celery.states import FAILURE, REVOKED, READY_STATES
...@@ -40,7 +41,7 @@ def instructor_task_status(request): ...@@ -40,7 +41,7 @@ def instructor_task_status(request):
Status is returned as a JSON-serialized dict, wrapped as the content of a HTTPResponse. Status is returned as a JSON-serialized dict, wrapped as the content of a HTTPResponse.
The task_id can be specified to this view in one of three ways: The task_id can be specified to this view in one of two ways:
* by making a request containing 'task_id' as a parameter with a single value * by making a request containing 'task_id' as a parameter with a single value
Returns a dict containing status information for the specified task_id Returns a dict containing status information for the specified task_id
...@@ -65,7 +66,7 @@ def instructor_task_status(request): ...@@ -65,7 +66,7 @@ def instructor_task_status(request):
'in_progress': boolean indicating if task is still running. 'in_progress': boolean indicating if task is still running.
'task_progress': dict containing progress information. This includes: 'task_progress': dict containing progress information. This includes:
'attempted': number of attempts made 'attempted': number of attempts made
'updated': number of attempts that "succeeded" 'succeeded': number of attempts that "succeeded"
'total': number of possible subtasks to attempt 'total': number of possible subtasks to attempt
'action_name': user-visible verb to use in status messages. Should be past-tense. 'action_name': user-visible verb to use in status messages. Should be past-tense.
'duration_ms': how long the task has (or had) been running. 'duration_ms': how long the task has (or had) been running.
...@@ -105,68 +106,118 @@ def get_task_completion_info(instructor_task): ...@@ -105,68 +106,118 @@ def get_task_completion_info(instructor_task):
succeeded = False succeeded = False
if instructor_task.task_state not in STATES_WITH_STATUS: if instructor_task.task_state not in STATES_WITH_STATUS:
return (succeeded, "No status information available") return (succeeded, _("No status information available"))
# we're more surprised if there is no output for a completed task, but just warn: # we're more surprised if there is no output for a completed task, but just warn:
if instructor_task.task_output is None: if instructor_task.task_output is None:
log.warning("No task_output information found for instructor_task {0}".format(instructor_task.task_id)) log.warning(_("No task_output information found for instructor_task {0}").format(instructor_task.task_id))
return (succeeded, "No status information available") return (succeeded, _("No status information available"))
try: try:
task_output = json.loads(instructor_task.task_output) task_output = json.loads(instructor_task.task_output)
except ValueError: except ValueError:
fmt = "No parsable task_output information found for instructor_task {0}: {1}" fmt = _("No parsable task_output information found for instructor_task {0}: {1}")
log.warning(fmt.format(instructor_task.task_id, instructor_task.task_output)) log.warning(fmt.format(instructor_task.task_id, instructor_task.task_output))
return (succeeded, "No parsable status information available") return (succeeded, _("No parsable status information available"))
if instructor_task.task_state in [FAILURE, REVOKED]: if instructor_task.task_state in [FAILURE, REVOKED]:
return (succeeded, task_output.get('message', 'No message provided')) return (succeeded, task_output.get('message', _('No message provided')))
if any([key not in task_output for key in ['action_name', 'attempted', 'updated', 'total']]): if any([key not in task_output for key in ['action_name', 'attempted', 'total']]):
fmt = "Invalid task_output information found for instructor_task {0}: {1}" fmt = _("Invalid task_output information found for instructor_task {0}: {1}")
log.warning(fmt.format(instructor_task.task_id, instructor_task.task_output)) log.warning(fmt.format(instructor_task.task_id, instructor_task.task_output))
return (succeeded, "No progress status information available") return (succeeded, _("No progress status information available"))
action_name = task_output['action_name'] action_name = _(task_output['action_name'])
num_attempted = task_output['attempted'] num_attempted = task_output['attempted']
num_updated = task_output['updated']
num_total = task_output['total'] num_total = task_output['total']
# In earlier versions of this code, the key 'updated' was used instead of
# (the more general) 'succeeded'. In order to support history that may contain
# output with the old key, we check for values with both the old and the current
# key, and simply sum them.
num_succeeded = task_output.get('updated', 0) + task_output.get('succeeded', 0)
num_skipped = task_output.get('skipped', 0)
student = None student = None
problem_url = None
email_id = None
try: try:
task_input = json.loads(instructor_task.task_input) task_input = json.loads(instructor_task.task_input)
except ValueError: except ValueError:
fmt = "No parsable task_input information found for instructor_task {0}: {1}" fmt = _("No parsable task_input information found for instructor_task {0}: {1}")
log.warning(fmt.format(instructor_task.task_id, instructor_task.task_input)) log.warning(fmt.format(instructor_task.task_id, instructor_task.task_input))
else: else:
student = task_input.get('student') student = task_input.get('student')
problem_url = task_input.get('problem_url')
email_id = task_input.get('email_id')
if instructor_task.task_state == PROGRESS: if instructor_task.task_state == PROGRESS:
# special message for providing progress updates: # special message for providing progress updates:
msg_format = "Progress: {action} {updated} of {attempted} so far" # Translators: {action} is a past-tense verb that is localized separately. {attempted} and {succeeded} are counts.
elif student is not None: msg_format = _("Progress: {action} {succeeded} of {attempted} so far")
elif student is not None and problem_url is not None:
# this reports on actions on problems for a particular student:
if num_attempted == 0: if num_attempted == 0:
msg_format = "Unable to find submission to be {action} for student '{student}'" # Translators: {action} is a past-tense verb that is localized separately. {student} is a student identifier.
elif num_updated == 0: msg_format = _("Unable to find submission to be {action} for student '{student}'")
msg_format = "Problem failed to be {action} for student '{student}'" elif num_succeeded == 0:
# Translators: {action} is a past-tense verb that is localized separately. {student} is a student identifier.
msg_format = _("Problem failed to be {action} for student '{student}'")
else: else:
succeeded = True succeeded = True
msg_format = "Problem successfully {action} for student '{student}'" # Translators: {action} is a past-tense verb that is localized separately. {student} is a student identifier.
elif num_attempted == 0: msg_format = _("Problem successfully {action} for student '{student}'")
msg_format = "Unable to find any students with submissions to be {action}" elif student is None and problem_url is not None:
elif num_updated == 0: # this reports on actions on problems for all students:
msg_format = "Problem failed to be {action} for any of {attempted} students" if num_attempted == 0:
elif num_updated == num_attempted: # Translators: {action} is a past-tense verb that is localized separately.
succeeded = True msg_format = _("Unable to find any students with submissions to be {action}")
msg_format = "Problem successfully {action} for {attempted} students" elif num_succeeded == 0:
else: # num_updated < num_attempted # Translators: {action} is a past-tense verb that is localized separately. {attempted} is a count.
msg_format = "Problem {action} for {updated} of {attempted} students" msg_format = _("Problem failed to be {action} for any of {attempted} students")
elif num_succeeded == num_attempted:
succeeded = True
# Translators: {action} is a past-tense verb that is localized separately. {attempted} is a count.
msg_format = _("Problem successfully {action} for {attempted} students")
else: # num_succeeded < num_attempted
# Translators: {action} is a past-tense verb that is localized separately. {succeeded} and {attempted} are counts.
msg_format = _("Problem {action} for {succeeded} of {attempted} students")
elif email_id is not None:
# this reports on actions on bulk emails
if num_attempted == 0:
# Translators: {action} is a past-tense verb that is localized separately.
msg_format = _("Unable to find any recipients to be {action}")
elif num_succeeded == 0:
# Translators: {action} is a past-tense verb that is localized separately. {attempted} is a count.
msg_format = _("Message failed to be {action} for any of {attempted} recipients ")
elif num_succeeded == num_attempted:
succeeded = True
# Translators: {action} is a past-tense verb that is localized separately. {attempted} is a count.
msg_format = _("Message successfully {action} for {attempted} recipients")
else: # num_succeeded < num_attempted
# Translators: {action} is a past-tense verb that is localized separately. {succeeded} and {attempted} are counts.
msg_format = _("Message {action} for {succeeded} of {attempted} recipients")
else:
# provide a default:
# Translators: {action} is a past-tense verb that is localized separately. {succeeded} and {attempted} are counts.
msg_format = _("Status: {action} {succeeded} of {attempted}")
if num_skipped > 0:
# Translators: {skipped} is a count. This message is appended to task progress status messages.
msg_format += _(" (skipping {skipped})")
if student is None and num_attempted != num_total: if student is None and num_attempted != num_total:
msg_format += " (out of {total})" # Translators: {total} is a count. This message is appended to task progress status messages.
msg_format += _(" (out of {total})")
# Update status in task result object itself: # Update status in task result object itself:
message = msg_format.format(action=action_name, updated=num_updated, message = msg_format.format(
attempted=num_attempted, total=num_total, action=action_name,
student=student) succeeded=num_succeeded,
attempted=num_attempted,
total=num_total,
skipped=num_skipped,
student=student
)
return (succeeded, message) return (succeeded, message)
...@@ -74,6 +74,14 @@ DATABASES = { ...@@ -74,6 +74,14 @@ DATABASES = {
} }
} }
TRACKING_BACKENDS.update({
'mongo': {
'ENGINE': 'track.backends.mongodb.MongoBackend'
}
})
DEFAULT_BULK_FROM_EMAIL = "test@test.org"
# Forums are disabled in test.py to speed up unit tests, but we do not have # Forums are disabled in test.py to speed up unit tests, but we do not have
# per-test control for acceptance tests # per-test control for acceptance tests
MITX_FEATURES['ENABLE_DISCUSSION_SERVICE'] = True MITX_FEATURES['ENABLE_DISCUSSION_SERVICE'] = True
...@@ -84,6 +92,9 @@ MITX_FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True ...@@ -84,6 +92,9 @@ MITX_FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True
# Enable fake payment processing page # Enable fake payment processing page
MITX_FEATURES['ENABLE_PAYMENT_FAKE'] = True MITX_FEATURES['ENABLE_PAYMENT_FAKE'] = True
# Enable email on the instructor dash
MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] = True
# Configure the payment processor to use the fake processing page # Configure the payment processor to use the fake processing page
# Since both the fake payment page and the shoppingcart app are using # Since both the fake payment page and the shoppingcart app are using
# the same settings, we can generate this randomly and guarantee # the same settings, we can generate this randomly and guarantee
......
...@@ -107,8 +107,6 @@ EMAIL_FILE_PATH = ENV_TOKENS.get('EMAIL_FILE_PATH', None) ...@@ -107,8 +107,6 @@ EMAIL_FILE_PATH = ENV_TOKENS.get('EMAIL_FILE_PATH', None)
EMAIL_HOST = ENV_TOKENS.get('EMAIL_HOST', 'localhost') # django default is localhost 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_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 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'] SITE_NAME = ENV_TOKENS['SITE_NAME']
SESSION_ENGINE = ENV_TOKENS.get('SESSION_ENGINE', SESSION_ENGINE) SESSION_ENGINE = ENV_TOKENS.get('SESSION_ENGINE', SESSION_ENGINE)
SESSION_COOKIE_DOMAIN = ENV_TOKENS.get('SESSION_COOKIE_DOMAIN') SESSION_COOKIE_DOMAIN = ENV_TOKENS.get('SESSION_COOKIE_DOMAIN')
...@@ -128,10 +126,9 @@ LOG_DIR = ENV_TOKENS['LOG_DIR'] ...@@ -128,10 +126,9 @@ LOG_DIR = ENV_TOKENS['LOG_DIR']
CACHES = ENV_TOKENS['CACHES'] CACHES = ENV_TOKENS['CACHES']
#Email overrides # Email overrides
DEFAULT_FROM_EMAIL = ENV_TOKENS.get('DEFAULT_FROM_EMAIL', DEFAULT_FROM_EMAIL) 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_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) ADMINS = ENV_TOKENS.get('ADMINS', ADMINS)
SERVER_EMAIL = ENV_TOKENS.get('SERVER_EMAIL', SERVER_EMAIL) SERVER_EMAIL = ENV_TOKENS.get('SERVER_EMAIL', SERVER_EMAIL)
TECH_SUPPORT_EMAIL = ENV_TOKENS.get('TECH_SUPPORT_EMAIL', TECH_SUPPORT_EMAIL) TECH_SUPPORT_EMAIL = ENV_TOKENS.get('TECH_SUPPORT_EMAIL', TECH_SUPPORT_EMAIL)
...@@ -141,7 +138,21 @@ PAYMENT_SUPPORT_EMAIL = ENV_TOKENS.get('PAYMENT_SUPPORT_EMAIL', PAYMENT_SUPPORT_ ...@@ -141,7 +138,21 @@ PAYMENT_SUPPORT_EMAIL = ENV_TOKENS.get('PAYMENT_SUPPORT_EMAIL', PAYMENT_SUPPORT_
PAID_COURSE_REGISTRATION_CURRENCY = ENV_TOKENS.get('PAID_COURSE_REGISTRATION_CURRENCY', PAID_COURSE_REGISTRATION_CURRENCY = ENV_TOKENS.get('PAID_COURSE_REGISTRATION_CURRENCY',
PAID_COURSE_REGISTRATION_CURRENCY) PAID_COURSE_REGISTRATION_CURRENCY)
#Theme overrides # Bulk Email overrides
BULK_EMAIL_DEFAULT_FROM_EMAIL = ENV_TOKENS.get('BULK_EMAIL_DEFAULT_FROM_EMAIL', BULK_EMAIL_DEFAULT_FROM_EMAIL)
BULK_EMAIL_EMAILS_PER_TASK = ENV_TOKENS.get('BULK_EMAIL_EMAILS_PER_TASK', BULK_EMAIL_EMAILS_PER_TASK)
BULK_EMAIL_EMAILS_PER_QUERY = ENV_TOKENS.get('BULK_EMAIL_EMAILS_PER_QUERY', BULK_EMAIL_EMAILS_PER_QUERY)
BULK_EMAIL_DEFAULT_RETRY_DELAY = ENV_TOKENS.get('BULK_EMAIL_DEFAULT_RETRY_DELAY', BULK_EMAIL_DEFAULT_RETRY_DELAY)
BULK_EMAIL_MAX_RETRIES = ENV_TOKENS.get('BULK_EMAIL_MAX_RETRIES', BULK_EMAIL_MAX_RETRIES)
BULK_EMAIL_INFINITE_RETRY_CAP = ENV_TOKENS.get('BULK_EMAIL_INFINITE_RETRY_CAP', BULK_EMAIL_INFINITE_RETRY_CAP)
BULK_EMAIL_LOG_SENT_EMAILS = ENV_TOKENS.get('BULK_EMAIL_LOG_SENT_EMAILS', BULK_EMAIL_LOG_SENT_EMAILS)
BULK_EMAIL_RETRY_DELAY_BETWEEN_SENDS = ENV_TOKENS.get('BULK_EMAIL_RETRY_DELAY_BETWEEN_SENDS', BULK_EMAIL_RETRY_DELAY_BETWEEN_SENDS)
# We want Bulk Email running on the high-priority queue, so we define the
# routing key that points to it. At the moment, the name is the same.
# We have to reset the value here, since we have changed the value of the queue name.
BULK_EMAIL_ROUTING_KEY = HIGH_PRIORITY_QUEUE
# Theme overrides
THEME_NAME = ENV_TOKENS.get('THEME_NAME', None) THEME_NAME = ENV_TOKENS.get('THEME_NAME', None)
if not THEME_NAME is None: if not THEME_NAME is None:
enable_theme(THEME_NAME) enable_theme(THEME_NAME)
...@@ -150,10 +161,10 @@ if not THEME_NAME is None: ...@@ -150,10 +161,10 @@ if not THEME_NAME is None:
# Marketing link overrides # Marketing link overrides
MKTG_URL_LINK_MAP.update(ENV_TOKENS.get('MKTG_URL_LINK_MAP', {})) MKTG_URL_LINK_MAP.update(ENV_TOKENS.get('MKTG_URL_LINK_MAP', {}))
#Timezone overrides # Timezone overrides
TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE) TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE)
#Additional installed apps # Additional installed apps
for app in ENV_TOKENS.get('ADDL_INSTALLED_APPS', []): for app in ENV_TOKENS.get('ADDL_INSTALLED_APPS', []):
INSTALLED_APPS += (app,) INSTALLED_APPS += (app,)
......
...@@ -114,7 +114,12 @@ MITX_FEATURES = { ...@@ -114,7 +114,12 @@ MITX_FEATURES = {
# analytics experiments # analytics experiments
'ENABLE_INSTRUCTOR_ANALYTICS': False, 'ENABLE_INSTRUCTOR_ANALYTICS': False,
'ENABLE_INSTRUCTOR_EMAIL': False, # Enables the LMS bulk email feature for course staff
'ENABLE_INSTRUCTOR_EMAIL': True,
# If True and ENABLE_INSTRUCTOR_EMAIL: Forces email to be explicitly turned on
# for each course via django-admin interface.
# If False and ENABLE_INSTRUCTOR_EMAIL: Email will be turned on by default for all courses.
'REQUIRE_COURSE_EMAIL_AUTH': True,
# enable analytics server. # enable analytics server.
# WARNING: THIS SHOULD ALWAYS BE SET TO FALSE UNDER NORMAL # WARNING: THIS SHOULD ALWAYS BE SET TO FALSE UNDER NORMAL
...@@ -333,7 +338,7 @@ TRACKING_BACKENDS = { ...@@ -333,7 +338,7 @@ TRACKING_BACKENDS = {
} }
} }
# Backawrds compatibility with ENABLE_SQL_TRACKING_LOGS feature flag. # Backwards compatibility with ENABLE_SQL_TRACKING_LOGS feature flag.
# In the future, adding the backend to TRACKING_BACKENDS enough. # In the future, adding the backend to TRACKING_BACKENDS enough.
if MITX_FEATURES.get('ENABLE_SQL_TRACKING_LOGS'): if MITX_FEATURES.get('ENABLE_SQL_TRACKING_LOGS'):
TRACKING_BACKENDS.update({ TRACKING_BACKENDS.update({
...@@ -414,12 +419,9 @@ HTTPS = 'on' ...@@ -414,12 +419,9 @@ HTTPS = 'on'
ROOT_URLCONF = 'lms.urls' ROOT_URLCONF = 'lms.urls'
IGNORABLE_404_ENDS = ('favicon.ico') IGNORABLE_404_ENDS = ('favicon.ico')
# Email # Platform Email
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
DEFAULT_FROM_EMAIL = 'registration@edx.org' 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' DEFAULT_FEEDBACK_EMAIL = 'feedback@edx.org'
SERVER_EMAIL = 'devops@edx.org' SERVER_EMAIL = 'devops@edx.org'
TECH_SUPPORT_EMAIL = 'technical@edx.org' TECH_SUPPORT_EMAIL = 'technical@edx.org'
...@@ -819,6 +821,45 @@ CELERY_QUEUES = { ...@@ -819,6 +821,45 @@ CELERY_QUEUES = {
DEFAULT_PRIORITY_QUEUE: {} DEFAULT_PRIORITY_QUEUE: {}
} }
# let logging work as configured:
CELERYD_HIJACK_ROOT_LOGGER = False
################################ Bulk Email ###################################
# Suffix used to construct 'from' email address for bulk emails.
# A course-specific identifier is prepended.
BULK_EMAIL_DEFAULT_FROM_EMAIL = 'no-reply@courseupdates.edx.org'
# Parameters for breaking down course enrollment into subtasks.
BULK_EMAIL_EMAILS_PER_TASK = 100
BULK_EMAIL_EMAILS_PER_QUERY = 1000
# Initial delay used for retrying tasks. Additional retries use
# longer delays. Value is in seconds.
BULK_EMAIL_DEFAULT_RETRY_DELAY = 30
# Maximum number of retries per task for errors that are not related
# to throttling.
BULK_EMAIL_MAX_RETRIES = 5
# Maximum number of retries per task for errors that are related to
# throttling. If this is not set, then there is no cap on such retries.
BULK_EMAIL_INFINITE_RETRY_CAP = 1000
# We want Bulk Email running on the high-priority queue, so we define the
# routing key that points to it. At the moment, the name is the same.
BULK_EMAIL_ROUTING_KEY = HIGH_PRIORITY_QUEUE
# Flag to indicate if individual email addresses should be logged as they are sent
# a bulk email message.
BULK_EMAIL_LOG_SENT_EMAILS = False
# Delay in seconds to sleep between individual mail messages being sent,
# when a bulk email task is retried for rate-related reasons. Choose this
# value depending on the number of workers that might be sending email in
# parallel, and what the SES rate is.
BULK_EMAIL_RETRY_DELAY_BETWEEN_SENDS = 0.02
################################### APPS ###################################### ################################### APPS ######################################
INSTALLED_APPS = ( INSTALLED_APPS = (
# Standard ones that are always installed... # Standard ones that are always installed...
......
...@@ -28,6 +28,7 @@ MITX_FEATURES['ENABLE_MANUAL_GIT_RELOAD'] = True ...@@ -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_PSYCHOMETRICS'] = False # real-time psychometrics (eg item response theory analysis in instructor dashboard)
MITX_FEATURES['ENABLE_INSTRUCTOR_ANALYTICS'] = True MITX_FEATURES['ENABLE_INSTRUCTOR_ANALYTICS'] = True
MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True
MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] = True
MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True
MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = True MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = True
MITX_FEATURES['MULTIPLE_ENROLLMENT_ROLES'] = True MITX_FEATURES['MULTIPLE_ENROLLMENT_ROLES'] = True
......
# Analytics Section ###
Analytics Section
imports from other modules.
wrap in (-> ... apply) to defer evaluation
such that the value can be defined later than this assignment (file load order).
###
# imports from other modules.
# wrap in (-> ... apply) to defer evaluation
# such that the value can be defined later than this assignment (file load order).
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
......
# Course Info Section ###
# This is the implementation of the simplest section Course Info Section
# of the instructor dashboard. This is the implementation of the simplest section
of the instructor dashboard.
imports from other modules.
wrap in (-> ... apply) to defer evaluation
such that the value can be defined later than this assignment (file load order).
###
# imports from other modules.
# wrap in (-> ... apply) to defer evaluation
# such that the value can be defined later than this assignment (file load order).
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
......
# Data Download Section ###
Data Download Section
imports from other modules.
wrap in (-> ... apply) to defer evaluation
such that the value can be defined later than this assignment (file load order).
###
# imports from other modules.
# wrap in (-> ... apply) to defer evaluation
# such that the value can be defined later than this assignment (file load order).
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
......
# Instructor Dashboard Tab Manager ###
# The instructor dashboard is broken into sections. Instructor Dashboard Tab Manager
# Only one section is visible at a time,
# and is responsible for its own functionality. The instructor dashboard is broken into sections.
#
# NOTE: plantTimeout (which is just setTimeout from util.coffee) Only one section is visible at a time,
# is used frequently in the instructor dashboard to isolate and is responsible for its own functionality.
# failures. If one piece of code under a plantTimeout fails
# then it will not crash the rest of the dashboard. NOTE: plantTimeout (which is just setTimeout from util.coffee)
# is used frequently in the instructor dashboard to isolate
# NOTE: The instructor dashboard currently does not failures. If one piece of code under a plantTimeout fails
# use backbone. Just lots of jquery. This should be fixed. then it will not crash the rest of the dashboard.
#
# NOTE: Server endpoints in the dashboard are stored in NOTE: The instructor dashboard currently does not
# the 'data-endpoint' attribute of relevant html elements. use backbone. Just lots of jquery. This should be fixed.
# The urls are rendered there by a template.
# NOTE: Server endpoints in the dashboard are stored in
# NOTE: For an example of what a section object should look like the 'data-endpoint' attribute of relevant html elements.
# see course_info.coffee The urls are rendered there by a template.
# imports from other modules NOTE: For an example of what a section object should look like
# wrap in (-> ... apply) to defer evaluation see course_info.coffee
# such that the value can be defined later than this assignment (file load order).
imports from other modules
wrap in (-> ... apply) to defer evaluation
such that the value can be defined later than this assignment (file load order).
###
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
...@@ -157,6 +162,9 @@ setup_instructor_dashboard_sections = (idash_content) -> ...@@ -157,6 +162,9 @@ setup_instructor_dashboard_sections = (idash_content) ->
constructor: window.InstructorDashboard.sections.StudentAdmin constructor: window.InstructorDashboard.sections.StudentAdmin
$element: idash_content.find ".#{CSS_IDASH_SECTION}#student_admin" $element: idash_content.find ".#{CSS_IDASH_SECTION}#student_admin"
, ,
constructor: window.InstructorDashboard.sections.Email
$element: idash_content.find ".#{CSS_IDASH_SECTION}#send_email"
,
constructor: window.InstructorDashboard.sections.Analytics constructor: window.InstructorDashboard.sections.Analytics
$element: idash_content.find ".#{CSS_IDASH_SECTION}#analytics" $element: idash_content.find ".#{CSS_IDASH_SECTION}#analytics"
] ]
......
# Membership Section ###
Membership Section
imports from other modules.
wrap in (-> ... apply) to defer evaluation
such that the value can be defined later than this assignment (file load order).
###
# imports from other modules.
# wrap in (-> ... apply) to defer evaluation
# such that the value can be defined later than this assignment (file load order).
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
......
###
Email Section
imports from other modules.
wrap in (-> ... apply) to defer evaluation
such that the value can be defined later than this assignment (file load order).
###
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
class SendEmail
constructor: (@$container) ->
# gather elements
@$emailEditor = XModule.loadModule($('.xmodule_edit'));
@$send_to = @$container.find("select[name='send_to']'")
@$subject = @$container.find("input[name='subject']'")
@$btn_send = @$container.find("input[name='send']'")
@$task_response = @$container.find(".request-response")
@$request_response_error = @$container.find(".request-response-error")
# attach click handlers
@$btn_send.click =>
if @$subject.val() == ""
alert gettext("Your message must have a subject.")
else if @$emailEditor.save()['data'] == ""
alert gettext("Your message cannot be blank.")
else
success_message = gettext("Your email was successfully queued for sending.")
send_to = @$send_to.val().toLowerCase()
if send_to == "myself"
send_to = gettext("yourself")
else if send_to == "staff"
send_to = gettext("everyone who is staff or instructor on this course")
else
send_to = gettext("ALL (everyone who is enrolled in this course as student, staff, or instructor)")
success_message = gettext("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.")
subject = gettext(@$subject.val())
confirm_message = gettext("You are about to send an email titled \"#{subject}\" to #{send_to}. Is this OK?")
if confirm confirm_message
send_data =
action: 'send'
send_to: @$send_to.val()
subject: @$subject.val()
message: @$emailEditor.save()['data']
$.ajax
type: 'POST'
dataType: 'json'
url: @$btn_send.data 'endpoint'
data: send_data
success: (data) =>
@display_response success_message
error: std_ajax_err =>
@fail_with_error gettext('Error sending email.')
else
@$task_response.empty()
@$request_response_error.empty()
fail_with_error: (msg) ->
console.warn msg
@$task_response.empty()
@$request_response_error.empty()
@$request_response_error.text gettext(msg)
$(".msg-confirm").css({"display":"none"})
display_response: (data_from_server) ->
@$task_response.empty()
@$request_response_error.empty()
@$task_response.text(data_from_server)
$(".msg-confirm").css({"display":"block"})
# Email Section
class Email
# enable subsections.
constructor: (@$section) ->
# attach self to html
# so that instructor_dashboard.coffee can find this object
# to call event handlers like 'onClickTitle'
@$section.data 'wrapper', @
# isolate # initialize SendEmail subsection
plantTimeout 0, => new SendEmail @$section.find '.send-email'
# handler for when the section title is clicked.
onClickTitle: ->
# export for use
# create parent namespaces if they do not already exist.
# abort if underscore can not be found.
if _?
_.defaults window, InstructorDashboard: {}
_.defaults window.InstructorDashboard, sections: {}
_.defaults window.InstructorDashboard.sections,
Email: Email
# Student Admin Section ###
Student Admin Section
imports from other modules.
wrap in (-> ... apply) to defer evaluation
such that the value can be defined later than this assignment (file load order).
###
# imports from other modules.
# wrap in (-> ... apply) to defer evaluation
# such that the value can be defined later than this assignment (file load order).
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
plantInterval = -> window.InstructorDashboard.util.plantInterval.apply this, arguments plantInterval = -> window.InstructorDashboard.util.plantInterval.apply this, arguments
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
......
...@@ -219,6 +219,11 @@ $action-secondary-disabled-fg: $white; ...@@ -219,6 +219,11 @@ $action-secondary-disabled-fg: $white;
$header-graphic-super-color: $m-blue-d1; $header-graphic-super-color: $m-blue-d1;
$header-graphic-sub-color: $m-gray-d2; $header-graphic-sub-color: $m-gray-d2;
// State-based colors
$error-color: $error-red;
$warning-color: $m-pink;
$confirm-color: $m-green;
// ==================== // ====================
// MISC: visual horizontal rules // MISC: visual horizontal rules
......
...@@ -18,6 +18,74 @@ ...@@ -18,6 +18,74 @@
right: 15px; right: 15px;
font-size: 11pt; font-size: 11pt;
} }
// system feedback - messages
.msg {
border-radius: 1px;
padding: $baseline/2 $baseline*0.75;
margin-bottom: $baseline;
font-weight: 600;
.copy {
font-weight: 600;
}
}
// TYPE: warning
.msg-warning {
border-top: 2px solid $warning-color;
background: tint($warning-color,95%);
.copy {
color: $warning-color;
}
}
// TYPE: confirm
.msg-confirm {
border-top: 2px solid $confirm-color;
background: tint($confirm-color,95%);
display: none;
color: $confirm-color;
}
// TYPE: confirm
.msg-error {
border-top: 2px solid $error-color;
background: tint($error-color,95%);
.copy {
color: $error-color;
}
}
// inline copy
.copy-confirm {
color: $confirm-color;
}
.copy-warning {
color: $warning-color;
}
.copy-error {
color: $error-color;
}
.list-advice {
list-style: none;
padding: 0;
margin: $baseline 0;
.item {
font-weight: 600;
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
}
}
} }
section.instructor-dashboard-content-2 { section.instructor-dashboard-content-2 {
...@@ -110,7 +178,6 @@ section.instructor-dashboard-content-2 { ...@@ -110,7 +178,6 @@ section.instructor-dashboard-content-2 {
} }
} }
.instructor-dashboard-wrapper-2 section.idash-section#course_info { .instructor-dashboard-wrapper-2 section.idash-section#course_info {
.course-errors-wrapper { .course-errors-wrapper {
margin-top: 2em; margin-top: 2em;
...@@ -173,6 +240,24 @@ section.instructor-dashboard-content-2 { ...@@ -173,6 +240,24 @@ section.instructor-dashboard-content-2 {
} }
} }
.instructor-dashboard-wrapper-2 section.idash-section#send_email {
// form fields
.list-fields {
list-style: none;
margin: 0;
padding: 0;
.field {
margin-bottom: $baseline;
padding: 0;
&:last-child {
margin-bottom: 0;
}
}
}
}
.instructor-dashboard-wrapper-2 section.idash-section#membership { .instructor-dashboard-wrapper-2 section.idash-section#membership {
$half_width: $baseline * 20; $half_width: $baseline * 20;
...@@ -470,3 +555,7 @@ section.instructor-dashboard-content-2 { ...@@ -470,3 +555,7 @@ section.instructor-dashboard-content-2 {
right: $baseline; right: $baseline;
} }
} }
input[name="subject"] {
width:600px;
}
...@@ -371,7 +371,8 @@ function goto( mode) ...@@ -371,7 +371,8 @@ function goto( mode)
%if modeflag.get('Enrollment'): %if modeflag.get('Enrollment'):
<hr width="40%" style="align:left"> <hr width="40%" style="align:left">
<p> <h2>${_("Enrollment Data")}</h2>
<input type="submit" name="action" value="List enrolled students"> <input type="submit" name="action" value="List enrolled students">
<input type="submit" name="action" value="List students who may enroll but may not have yet signed up"> <input type="submit" name="action" value="List students who may enroll but may not have yet signed up">
<hr width="40%" style="align:left"> <hr width="40%" style="align:left">
...@@ -508,6 +509,13 @@ function goto( mode) ...@@ -508,6 +509,13 @@ function goto( mode)
return true; return true;
} }
</script> </script>
<p>These email actions run in the background, and status for active email tasks will appear in a table below.
To see status for all bulk email tasks submitted for this course, click on this button:
</p>
<p>
<input type="submit" name="action" value="Show Background Email Task History">
</p>
%endif %endif
</form> </form>
......
...@@ -32,6 +32,12 @@ ...@@ -32,6 +32,12 @@
<script type="text/javascript" src="${static.url('js/vendor/slick.grid.js')}"></script> <script type="text/javascript" src="${static.url('js/vendor/slick.grid.js')}"></script>
<link rel="stylesheet" href="${static.url('css/vendor/slickgrid/smoothness/jquery-ui-1.8.16.custom.css')}"> <link rel="stylesheet" href="${static.url('css/vendor/slickgrid/smoothness/jquery-ui-1.8.16.custom.css')}">
<link rel="stylesheet" href="${static.url('css/vendor/slickgrid/slick.grid.css')}"> <link rel="stylesheet" href="${static.url('css/vendor/slickgrid/slick.grid.css')}">
<script type="text/javascript" src="${static.url('js/vendor/CodeMirror/htmlmixed.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/CodeMirror/css.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/codemirror-compressed.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/tiny_mce/tiny_mce.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/tiny_mce/jquery.tinymce.js')}"></script>
<%static:js group='module-descriptor-js'/>
</%block> </%block>
## NOTE that instructor is set as the active page so that the instructor button lights up, even though this is the instructor_2 page. ## NOTE that instructor is set as the active page so that the instructor button lights up, even though this is the instructor_2 page.
......
<%! from django.utils.translation import ugettext as _ %>
<%page args="section_data"/>
<div class="vert-left send-email" id="section-send-email">
<h2> ${_("Send Email")} </h2>
<div class="request-response msg msg-confirm copy" id="request-response"></div>
<ul class="list-fields">
<li class="field">
<label for="id_to">${_("Send to:")}</label><br/>
<select id="id_to" name="send_to">
<option value="myself">${_("Myself")}</option>
%if to_option == "staff":
<option value="staff" selected="selected">${_("Staff and instructors")}</option>
%else:
<option value="staff">${_("Staff and instructors")}</option>
%endif
%if to_option == "all":
<option value="all" selected="selected">${_("All (students, staff and instructors)")}</option>
%else:
<option value="all">${_("All (students, staff and instructors)")}</option>
%endif
</select>
</li>
<br/>
<li class="field">
<label for="id_subject">${_("Subject: ")}</label><br/>
<input type="text" id="id_subject" name="subject">
</li>
<li class="field">
<label>Message:</label>
<div class="email-editor">
${ section_data['editor'] }
</div>
<input type="hidden" name="message" value="">
</li>
</ul>
<div class="submit-email-action">
${_("Please try not to email students more than once a day. Before sending your email, consider:")}
<ul class="list-advice">
<li class="item">${_("Have you read over the email to make sure it says everything you want to say?")}</li>
<li class="item">${_("Have you sent the email to yourself first to make sure you're happy with how it's displayed?")}</li>
</ul>
</div>
<input type="button" name="send" value="${_("Send Email")}" data-endpoint="${ section_data['send_email'] }" >
<div class="request-response-error"></div>
</div>
...@@ -16,6 +16,6 @@ ...@@ -16,6 +16,6 @@
# Our libraries: # Our libraries:
-e git+https://github.com/edx/XBlock.git@8a66ca3#egg=XBlock -e git+https://github.com/edx/XBlock.git@8a66ca3#egg=XBlock
-e git+https://github.com/edx/codejail.git@0a1b468#egg=codejail -e git+https://github.com/edx/codejail.git@0a1b468#egg=codejail
-e git+https://github.com/edx/diff-cover.git@v0.2.4#egg=diff_cover -e git+https://github.com/edx/diff-cover.git@v0.2.6#egg=diff_cover
-e git+https://github.com/edx/js-test-tool.git@v0.0.7#egg=js_test_tool -e git+https://github.com/edx/js-test-tool.git@v0.1.1#egg=js_test_tool
-e git+https://github.com/edx/django-waffle.git@823a102e48#egg=django-waffle -e git+https://github.com/edx/django-waffle.git@823a102e48#egg=django-waffle
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment