Merge pull request #14118 from edx/efischer/hackathon_emails
Bulk Email for course modes
This commit is contained in:
@@ -192,7 +192,7 @@ def add_enrollment(user_id, course_id, mode=None, is_active=True):
|
||||
"""
|
||||
if mode is None:
|
||||
mode = _default_course_mode(course_id)
|
||||
_validate_course_mode(course_id, mode, is_active=is_active)
|
||||
validate_course_mode(course_id, mode, is_active=is_active)
|
||||
return _data_api().create_course_enrollment(user_id, course_id, mode, is_active)
|
||||
|
||||
|
||||
@@ -252,7 +252,7 @@ def update_enrollment(user_id, course_id, mode=None, is_active=None, enrollment_
|
||||
mode=mode,
|
||||
))
|
||||
if mode is not None:
|
||||
_validate_course_mode(course_id, mode, is_active=is_active, include_expired=include_expired)
|
||||
validate_course_mode(course_id, mode, is_active=is_active, include_expired=include_expired)
|
||||
enrollment = _data_api().update_course_enrollment(user_id, course_id, mode=mode, is_active=is_active)
|
||||
if enrollment is None:
|
||||
msg = u"Course Enrollment not found for user {user} in course {course}".format(user=user_id, course=course_id)
|
||||
@@ -409,7 +409,7 @@ def _default_course_mode(course_id):
|
||||
return CourseMode.DEFAULT_MODE_SLUG
|
||||
|
||||
|
||||
def _validate_course_mode(course_id, mode, is_active=None, include_expired=False):
|
||||
def validate_course_mode(course_id, mode, is_active=None, include_expired=False):
|
||||
"""Checks to see if the specified course mode is valid for the course.
|
||||
|
||||
If the requested course mode is not available for the course, raise an error with corresponding
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('course_modes', '0007_coursemode_bulk_sku'),
|
||||
('bulk_email', '0005_move_target_data'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CourseModeTarget',
|
||||
fields=[
|
||||
('target_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='bulk_email.Target')),
|
||||
('track', models.ForeignKey(to='course_modes.CourseMode')),
|
||||
],
|
||||
bases=('bulk_email.target',),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='target',
|
||||
name='target_type',
|
||||
field=models.CharField(max_length=64, choices=[(b'myself', b'Myself'), (b'staff', b'Staff and instructors'), (b'learners', b'All students'), (b'cohort', b'Specific cohort'), (b'track', b'Specific course mode')]),
|
||||
),
|
||||
]
|
||||
@@ -13,6 +13,9 @@ from openedx.core.lib.html_to_text import html_to_text
|
||||
from openedx.core.lib.mail_utils import wrap_message
|
||||
|
||||
from config_models.models import ConfigurationModel
|
||||
from course_modes.models import CourseMode
|
||||
from enrollment.api import validate_course_mode
|
||||
from enrollment.errors import CourseModeNotFoundError
|
||||
from student.roles import CourseStaffRole, CourseInstructorRole
|
||||
|
||||
from openedx.core.djangoapps.xmodule_django.models import CourseKeyField
|
||||
@@ -45,9 +48,10 @@ SEND_TO_MYSELF = 'myself'
|
||||
SEND_TO_STAFF = 'staff'
|
||||
SEND_TO_LEARNERS = 'learners'
|
||||
SEND_TO_COHORT = 'cohort'
|
||||
SEND_TO_TRACK = 'track'
|
||||
EMAIL_TARGET_CHOICES = zip(
|
||||
[SEND_TO_MYSELF, SEND_TO_STAFF, SEND_TO_LEARNERS, SEND_TO_COHORT],
|
||||
['Myself', 'Staff and instructors', 'All students', 'Specific cohort']
|
||||
[SEND_TO_MYSELF, SEND_TO_STAFF, SEND_TO_LEARNERS, SEND_TO_COHORT, SEND_TO_TRACK],
|
||||
['Myself', 'Staff and instructors', 'All students', 'Specific cohort', 'Specific course mode']
|
||||
)
|
||||
EMAIL_TARGETS = {target[0] for target in EMAIL_TARGET_CHOICES}
|
||||
|
||||
@@ -78,6 +82,8 @@ class Target(models.Model):
|
||||
"""
|
||||
if self.target_type == SEND_TO_COHORT:
|
||||
return self.cohorttarget.short_display() # pylint: disable=no-member
|
||||
elif self.target_type == SEND_TO_TRACK:
|
||||
return self.coursemodetarget.short_display() # pylint: disable=no-member
|
||||
else:
|
||||
return self.target_type
|
||||
|
||||
@@ -87,6 +93,8 @@ class Target(models.Model):
|
||||
"""
|
||||
if self.target_type == SEND_TO_COHORT:
|
||||
return self.cohorttarget.long_display() # pylint: disable=no-member
|
||||
elif self.target_type == SEND_TO_TRACK:
|
||||
return self.coursemodetarget.long_display() # pylint: disable=no-member
|
||||
else:
|
||||
return self.get_target_type_display()
|
||||
|
||||
@@ -115,6 +123,12 @@ class Target(models.Model):
|
||||
return use_read_replica_if_available(enrollment_qset.exclude(id__in=staff_instructor_qset))
|
||||
elif self.target_type == SEND_TO_COHORT:
|
||||
return self.cohorttarget.cohort.users.filter(id__in=enrollment_qset) # pylint: disable=no-member
|
||||
elif self.target_type == SEND_TO_TRACK:
|
||||
return use_read_replica_if_available(
|
||||
enrollment_qset.filter(
|
||||
courseenrollment__mode=self.coursemodetarget.track.mode_slug
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise ValueError("Unrecognized target type {}".format(self.target_type))
|
||||
|
||||
@@ -132,6 +146,9 @@ class CohortTarget(Target):
|
||||
kwargs['target_type'] = SEND_TO_COHORT
|
||||
super(CohortTarget, self).__init__(*args, **kwargs)
|
||||
|
||||
def __unicode__(self):
|
||||
return self.short_display()
|
||||
|
||||
def short_display(self):
|
||||
return "{}-{}".format(self.target_type, self.cohort.name)
|
||||
|
||||
@@ -159,6 +176,53 @@ class CohortTarget(Target):
|
||||
return cohort
|
||||
|
||||
|
||||
class CourseModeTarget(Target):
|
||||
"""
|
||||
Subclass of Target, specifically for course modes.
|
||||
"""
|
||||
track = models.ForeignKey('course_modes.CourseMode')
|
||||
|
||||
class Meta:
|
||||
app_label = "bulk_email"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs['target_type'] = SEND_TO_TRACK
|
||||
super(CourseModeTarget, self).__init__(*args, **kwargs)
|
||||
|
||||
def __unicode__(self):
|
||||
return self.short_display()
|
||||
|
||||
def short_display(self):
|
||||
return "{}-{}".format(self.target_type, self.track.mode_slug) # pylint: disable=no-member
|
||||
|
||||
def long_display(self):
|
||||
all_modes = CourseMode.objects.filter(
|
||||
course_id=self.track.course_id,
|
||||
mode_slug=self.track.mode_slug, # pylint: disable=no-member
|
||||
)
|
||||
return "Course mode: {}, Currencies: {}".format(
|
||||
self.track.mode_display_name, # pylint: disable=no-member
|
||||
", ".join([mode.currency for mode in all_modes])
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def ensure_valid_mode(cls, mode_slug, course_id):
|
||||
"""
|
||||
Ensures mode_slug is a valid mode for course_id. Will raise an error if invalid.
|
||||
"""
|
||||
if mode_slug is None:
|
||||
raise ValueError("Cannot create a CourseModeTarget without specifying a mode_slug.")
|
||||
try:
|
||||
validate_course_mode(unicode(course_id), mode_slug)
|
||||
except CourseModeNotFoundError:
|
||||
raise ValueError(
|
||||
"Track {track} does not exist in course {course_id}".format(
|
||||
track=mode_slug,
|
||||
course_id=course_id
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class CourseEmail(Email):
|
||||
"""
|
||||
Stores information for an email to a course.
|
||||
@@ -179,7 +243,7 @@ class CourseEmail(Email):
|
||||
@classmethod
|
||||
def create(
|
||||
cls, course_id, sender, targets, subject, html_message,
|
||||
text_message=None, template_name=None, from_addr=None, cohort_name=None):
|
||||
text_message=None, template_name=None, from_addr=None):
|
||||
"""
|
||||
Create an instance of CourseEmail.
|
||||
"""
|
||||
@@ -189,7 +253,7 @@ class CourseEmail(Email):
|
||||
|
||||
new_targets = []
|
||||
for target in targets:
|
||||
# split target, to handle cohort:cohort_name
|
||||
# split target, to handle cohort:cohort_name and track:mode_slug
|
||||
target_split = target.split(':', 1)
|
||||
# Ensure our desired target exists
|
||||
if target_split[0] not in EMAIL_TARGETS:
|
||||
@@ -200,6 +264,14 @@ class CourseEmail(Email):
|
||||
# target_split[1] will contain the cohort name
|
||||
cohort = CohortTarget.ensure_valid_cohort(target_split[1], course_id)
|
||||
new_target, _ = CohortTarget.objects.get_or_create(target_type=target_split[0], cohort=cohort)
|
||||
elif target_split[0] == SEND_TO_TRACK:
|
||||
# target_split[1] contains the desired mode slug
|
||||
CourseModeTarget.ensure_valid_mode(target_split[1], course_id)
|
||||
|
||||
# There could exist multiple CourseModes that match this query, due to differing currency types.
|
||||
# The currencies do not affect user lookup though, so we can just use the first result.
|
||||
mode = CourseMode.objects.filter(course_id=course_id, mode_slug=target_split[1])[0]
|
||||
new_target, _ = CourseModeTarget.objects.get_or_create(target_type=target_split[0], track=mode)
|
||||
else:
|
||||
new_target, _ = Target.objects.get_or_create(target_type=target_split[0])
|
||||
new_targets.append(new_target)
|
||||
|
||||
@@ -20,7 +20,9 @@ from bulk_email.models import Optout, BulkEmailFlag
|
||||
from bulk_email.tasks import _get_source_address, _get_course_email_context
|
||||
from openedx.core.djangoapps.course_groups.models import CourseCohort
|
||||
from openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort
|
||||
from course_modes.models import CourseMode
|
||||
from courseware.tests.factories import StaffFactory, InstructorFactory
|
||||
from enrollment.api import update_enrollment
|
||||
from lms.djangoapps.instructor_task.subtasks import update_subtask_status
|
||||
from student.roles import CourseStaffRole
|
||||
from student.models import CourseEnrollment
|
||||
@@ -239,6 +241,27 @@ class TestEmailSendFromDashboardMockedHtmlToText(EmailSendFromDashboardTestCase)
|
||||
self.assertEquals(len(mail.outbox), len(self.students) - 1)
|
||||
self.assertNotIn(self.students[-1].email, [e.to[0] for e in mail.outbox])
|
||||
|
||||
def test_send_to_track(self):
|
||||
"""
|
||||
Make sure email sent to a registration track goes there.
|
||||
"""
|
||||
CourseMode.objects.create(mode_slug='test', course_id=self.course.id)
|
||||
for student in self.students:
|
||||
update_enrollment(student, unicode(self.course.id), 'test')
|
||||
test_email = {
|
||||
'action': 'Send email',
|
||||
'send_to': '["track:test"]',
|
||||
'subject': 'test subject for test track',
|
||||
'message': 'test message for test track',
|
||||
}
|
||||
response = self.client.post(self.send_mail_url, test_email)
|
||||
self.assertEquals(json.loads(response.content), self.success_content)
|
||||
|
||||
self.assertItemsEqual(
|
||||
[e.to[0] for e in mail.outbox],
|
||||
[s.email for s in self.students]
|
||||
)
|
||||
|
||||
def test_send_to_all(self):
|
||||
"""
|
||||
Make sure email send to all goes there.
|
||||
|
||||
@@ -5,6 +5,7 @@ Unit tests for handling email sending errors
|
||||
from itertools import cycle
|
||||
|
||||
from celery.states import SUCCESS, RETRY # pylint: disable=no-name-in-module, import-error
|
||||
import ddt
|
||||
from django.conf import settings
|
||||
from django.core.management import call_command
|
||||
from django.core.urlresolvers import reverse
|
||||
@@ -36,6 +37,7 @@ class EmailTestException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@attr(shard=1)
|
||||
@patch('bulk_email.models.html_to_text', Mock(return_value='Mocking CourseEmail.text_message', autospec=True))
|
||||
class TestEmailErrors(ModuleStoreTestCase):
|
||||
@@ -213,15 +215,16 @@ class TestEmailErrors(ModuleStoreTestCase):
|
||||
"dummy body goes here"
|
||||
)
|
||||
|
||||
def test_nonexistent_cohort(self):
|
||||
@ddt.data('track', 'cohort')
|
||||
def test_nonexistent_grouping(self, target_type):
|
||||
"""
|
||||
Tests exception when the cohort doesn't exist
|
||||
Tests exception when the cohort or course mode doesn't exist
|
||||
"""
|
||||
with self.assertRaisesRegexp(ValueError, 'Cohort IDONTEXIST does not exist *'):
|
||||
with self.assertRaisesRegexp(ValueError, '.* IDONTEXIST does not exist .*'):
|
||||
email = CourseEmail.create( # pylint: disable=unused-variable
|
||||
self.course.id,
|
||||
self.instructor,
|
||||
["cohort:IDONTEXIST"],
|
||||
["{}:IDONTEXIST".format(target_type)],
|
||||
"re: subject",
|
||||
"dummy body goes here"
|
||||
)
|
||||
|
||||
@@ -12,11 +12,13 @@ from nose.plugins.attrib import attr
|
||||
from bulk_email.models import (
|
||||
CourseEmail,
|
||||
SEND_TO_COHORT,
|
||||
SEND_TO_TRACK,
|
||||
SEND_TO_STAFF,
|
||||
CourseEmailTemplate,
|
||||
CourseAuthorization,
|
||||
BulkEmailFlag
|
||||
)
|
||||
from course_modes.models import CourseMode
|
||||
from openedx.core.djangoapps.course_groups.models import CourseCohort
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
@@ -67,6 +69,22 @@ class CourseEmailTest(TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
CourseEmail.create(course_id, sender, to_option, subject, html_message)
|
||||
|
||||
def test_track_target(self):
|
||||
course_id = CourseKey.from_string('abc/123/doremi')
|
||||
sender = UserFactory.create()
|
||||
to_option = 'track:test'
|
||||
subject = "dummy subject"
|
||||
html_message = "<html>dummy message</html>"
|
||||
CourseMode.objects.create(mode_slug='test', mode_display_name='Test', course_id=course_id)
|
||||
with patch('bulk_email.models.validate_course_mode'):
|
||||
# we don't have a real course, so validation will fail. Mock it out!
|
||||
email = CourseEmail.create(course_id, sender, [to_option], subject, html_message)
|
||||
self.assertEqual(len(email.targets.all()), 1)
|
||||
target = email.targets.all()[0]
|
||||
self.assertEqual(target.target_type, SEND_TO_TRACK)
|
||||
self.assertEqual(target.short_display(), 'track-test')
|
||||
self.assertEqual(target.long_display(), 'Course mode: Test, Currencies: usd')
|
||||
|
||||
def test_cohort_target(self):
|
||||
course_id = CourseKey.from_string('abc/123/doremi')
|
||||
sender = UserFactory.create()
|
||||
|
||||
@@ -633,6 +633,10 @@ def _section_send_email(course, access):
|
||||
cohorts = []
|
||||
if is_course_cohorted(course_key):
|
||||
cohorts = get_course_cohorts(course)
|
||||
course_modes = []
|
||||
from verified_track_content.models import VerifiedTrackCohortedCourse
|
||||
if not VerifiedTrackCohortedCourse.is_verified_track_cohort_enabled(course_key):
|
||||
course_modes = CourseMode.modes_for_course(course_key)
|
||||
email_editor = fragment.content
|
||||
section_data = {
|
||||
'section_key': 'send_email',
|
||||
@@ -641,6 +645,7 @@ def _section_send_email(course, access):
|
||||
'send_email': reverse('send_email', kwargs={'course_id': unicode(course_key)}),
|
||||
'editor': email_editor,
|
||||
'cohorts': cohorts,
|
||||
'course_modes': course_modes,
|
||||
'default_cohort_name': DEFAULT_COHORT_NAME,
|
||||
'list_instructor_tasks_url': reverse(
|
||||
'list_instructor_tasks', kwargs={'course_id': unicode(course_key)}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
this.$emailEditor = XBlock.initializeBlock($('.xblock-studio_view'));
|
||||
this.$send_to = this.$container.find("input[name='send_to']");
|
||||
this.$cohort_targets = this.$send_to.filter('[value^="cohort:"]');
|
||||
this.$course_mode_targets = this.$send_to.filter('[value^="track:"]');
|
||||
this.$subject = this.$container.find("input[name='subject']");
|
||||
this.$btn_send = this.$container.find("input[name='send']");
|
||||
this.$task_response = this.$container.find('.request-response');
|
||||
@@ -85,9 +86,12 @@
|
||||
return gettext('Everyone who has staff privileges in this course');
|
||||
} else if (value === 'learners') {
|
||||
return gettext('All learners who are enrolled in this course');
|
||||
} else {
|
||||
} else if (value.startsWith('cohort')) {
|
||||
return gettext('All learners in the {cohort_name} cohort')
|
||||
.replace('{cohort_name}', value.slice(value.indexOf(':') + 1));
|
||||
} else if (value.startsWith('track')) {
|
||||
return gettext('All learners in the {track_name} track')
|
||||
.replace('{track_name}', value.slice(value.indexOf(':') + 1));
|
||||
}
|
||||
};
|
||||
successMessage = gettext('Your email message was successfully queued for sending. In courses with a large number of learners, email messages to learners might take up to an hour to be sent.'); // eslint-disable-line max-len
|
||||
@@ -179,17 +183,21 @@
|
||||
});
|
||||
this.$send_to.change(function() {
|
||||
var targets;
|
||||
var inputDisable = function() {
|
||||
this.checked = false;
|
||||
this.disabled = true;
|
||||
return true;
|
||||
};
|
||||
var inputEnable = function() {
|
||||
this.disabled = false;
|
||||
return true;
|
||||
};
|
||||
if ($('input#target_learners:checked').length) {
|
||||
sendemail.$cohort_targets.each(function() {
|
||||
this.checked = false;
|
||||
this.disabled = true;
|
||||
return true;
|
||||
});
|
||||
sendemail.$cohort_targets.each(inputDisable);
|
||||
sendemail.$course_mode_targets.each(inputDisable);
|
||||
} else {
|
||||
sendemail.$cohort_targets.each(function() {
|
||||
this.disabled = false;
|
||||
return true;
|
||||
});
|
||||
sendemail.$cohort_targets.each(inputEnable);
|
||||
sendemail.$cohort_targets.each(inputEnable);
|
||||
}
|
||||
targets = [];
|
||||
$('input[name="send_to"]:checked+label').each(function() {
|
||||
|
||||
@@ -45,6 +45,19 @@ from openedx.core.djangolib.markup import HTML
|
||||
%endfor
|
||||
</ul>
|
||||
%endif
|
||||
## If there's only 1 course mode, everyone is in it and you should just use "All Learners"
|
||||
%if len(section_data['course_modes']) > 1:
|
||||
<ul role="group" class="email-targets-secondary">
|
||||
%for track in section_data['course_modes']:
|
||||
<li>
|
||||
<input type="checkbox" name="send_to" value="track:${track.slug}" id="target_track_${track.slug}">
|
||||
<label for="target_track_${track.slug}">
|
||||
${_("Learners in the {track_name} Track").format(track_name=track.name)}
|
||||
</label>
|
||||
</li>
|
||||
%endfor
|
||||
</ul>
|
||||
%endif
|
||||
</fieldset>
|
||||
</li>
|
||||
<li class="field">
|
||||
|
||||
Reference in New Issue
Block a user