Commit 0cf3e39c by Eric Fischer

Replace bulk email settings with admin config models

Moves ENABLE_INSTRUCTOR_EMAIL and REQUIRE_COURSE_EMAIL_AUTH from settings files
to admin-accessible configuration models. This allows for the bulk email settings
to be modified without a new AMI deploy. See TNL-4504.

Also updates tests:
    -python tests mock out the new configurations in place of the old settings
    -lettuce test has been moved to bokchoy
        (note that there was some loss of coverage here - the lettuce tests had
        been doing some voodoo to allow for cross-process inspection of emails
        messages being "sent" by the server, from the client! In discussion with
        testeng, this seems outside the realm of a visual acceptance test. So,
        the bokchoy test simply confirm the successful queueing of the message,
        and leaves the validation of sending messages to the relevant unit tests.)
    -bok choy fixture has been added, to replace the settings in acceptance.py
    -lettuce and bok choy databases have been updated to reflect the backend changes

The new default is to have bulk_email disabled, we'll need to call this out in the
next OpenEdx release to ensure administrators enable this feature if needed.
parent 140fd85e
...@@ -8,7 +8,6 @@ import unittest ...@@ -8,7 +8,6 @@ import unittest
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from mock import patch
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
from student.tests.factories import UserFactory, CourseEnrollmentFactory from student.tests.factories import UserFactory, CourseEnrollmentFactory
...@@ -18,7 +17,7 @@ from xmodule.modulestore.tests.factories import CourseFactory ...@@ -18,7 +17,7 @@ from xmodule.modulestore.tests.factories import CourseFactory
# This import is for an lms djangoapp. # This import is for an lms djangoapp.
# Its testcases are only run under lms. # Its testcases are only run under lms.
from bulk_email.models import CourseAuthorization # pylint: disable=import-error from bulk_email.models import CourseAuthorization, BulkEmailFlag # pylint: disable=import-error
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
...@@ -51,34 +50,38 @@ class TestStudentDashboardEmailView(SharedModuleStoreTestCase): ...@@ -51,34 +50,38 @@ class TestStudentDashboardEmailView(SharedModuleStoreTestCase):
name=self.course.display_name.replace(' ', '_'), name=self.course.display_name.replace(' ', '_'),
) )
@patch.dict(settings.FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': False}) def tearDown(self):
super(TestStudentDashboardEmailView, self).tearDown()
BulkEmailFlag.objects.all().delete()
def test_email_flag_true(self): def test_email_flag_true(self):
BulkEmailFlag.objects.create(enabled=True, require_course_email_auth=False)
# 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)
self.assertTrue(self.email_modal_link in response.content) self.assertTrue(self.email_modal_link in response.content)
@patch.dict(settings.FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': False})
def test_email_flag_false(self): def test_email_flag_false(self):
BulkEmailFlag.objects.create(enabled=False)
# Assert that the URL for the email view is not in the response # Assert that the URL for the email view is not in the response
response = self.client.get(self.url) response = self.client.get(self.url)
self.assertFalse(self.email_modal_link in response.content) self.assertNotIn(self.email_modal_link, response.content)
@patch.dict(settings.FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': True})
def test_email_unauthorized(self): def test_email_unauthorized(self):
BulkEmailFlag.objects.create(enabled=True, require_course_email_auth=True)
# Assert that instructor email is not enabled for this course # Assert that instructor email is not enabled for this course
self.assertFalse(CourseAuthorization.instructor_email_enabled(self.course.id)) self.assertFalse(BulkEmailFlag.feature_enabled(self.course.id))
# Assert that the URL for the email view is not in the response # Assert that the URL for the email view is not in the response
# if this course isn't authorized # if this course isn't authorized
response = self.client.get(self.url) response = self.client.get(self.url)
self.assertFalse(self.email_modal_link in response.content) self.assertNotIn(self.email_modal_link, response.content)
@patch.dict(settings.FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': True})
def test_email_authorized(self): def test_email_authorized(self):
BulkEmailFlag.objects.create(enabled=True, require_course_email_auth=True)
# Authorize the course to use email # Authorize the course to use email
cauth = CourseAuthorization(course_id=self.course.id, email_enabled=True) cauth = CourseAuthorization(course_id=self.course.id, email_enabled=True)
cauth.save() cauth.save()
# Assert that instructor email is enabled for this course # Assert that instructor email is enabled for this course
self.assertTrue(CourseAuthorization.instructor_email_enabled(self.course.id)) self.assertTrue(BulkEmailFlag.feature_enabled(self.course.id))
# Assert that the URL for the email view is not in the response # Assert that the URL for the email view is not in the response
# if this course isn't authorized # if this course isn't authorized
response = self.client.get(self.url) response = self.client.get(self.url)
...@@ -117,15 +120,15 @@ class TestStudentDashboardEmailViewXMLBacked(SharedModuleStoreTestCase): ...@@ -117,15 +120,15 @@ class TestStudentDashboardEmailViewXMLBacked(SharedModuleStoreTestCase):
name='2012_Fall', name='2012_Fall',
) )
@patch.dict(settings.FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': False})
def test_email_flag_true_xml_store(self): def test_email_flag_true_xml_store(self):
BulkEmailFlag.objects.create(enabled=True, require_course_email_auth=False)
# The flag is enabled, and since REQUIRE_COURSE_EMAIL_AUTH is False, all courses should # 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) # be authorized to use email. But the course is not Mongo-backed (should not work)
response = self.client.get(self.url) response = self.client.get(self.url)
self.assertFalse(self.email_modal_link in response.content) self.assertFalse(self.email_modal_link in response.content)
@patch.dict(settings.FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': False, 'REQUIRE_COURSE_EMAIL_AUTH': False})
def test_email_flag_false_xml_store(self): def test_email_flag_false_xml_store(self):
BulkEmailFlag.objects.create(enabled=False, require_course_email_auth=False)
# Email disabled, shouldn't see link. # Email disabled, shouldn't see link.
response = self.client.get(self.url) response = self.client.get(self.url)
self.assertFalse(self.email_modal_link in response.content) self.assertFalse(self.email_modal_link in response.content)
...@@ -56,6 +56,7 @@ from student.models import ( ...@@ -56,6 +56,7 @@ from student.models import (
from student.forms import AccountCreationForm, PasswordResetFormNoActive, get_registration_extension_form from student.forms import AccountCreationForm, PasswordResetFormNoActive, get_registration_extension_form
from lms.djangoapps.commerce.utils import EcommerceService # pylint: disable=import-error from lms.djangoapps.commerce.utils import EcommerceService # pylint: disable=import-error
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification # pylint: disable=import-error from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification # pylint: disable=import-error
from bulk_email.models import Optout, BulkEmailFlag # pylint: disable=import-error
from certificates.models import CertificateStatuses, certificate_status_for_student from certificates.models import CertificateStatuses, certificate_status_for_student
from certificates.api import ( # pylint: disable=import-error from certificates.api import ( # pylint: disable=import-error
get_certificate_url, get_certificate_url,
...@@ -83,7 +84,6 @@ from external_auth.login_and_register import ( ...@@ -83,7 +84,6 @@ from external_auth.login_and_register import (
register as external_auth_register register as external_auth_register
) )
from bulk_email.models import Optout, CourseAuthorization
from lang_pref import LANGUAGE_KEY from lang_pref import LANGUAGE_KEY
import track.views import track.views
...@@ -649,8 +649,7 @@ def dashboard(request): ...@@ -649,8 +649,7 @@ def dashboard(request):
# only show email settings for Mongo course and when bulk email is turned on # only show email settings for Mongo course and when bulk email is turned on
show_email_settings_for = frozenset( show_email_settings_for = frozenset(
enrollment.course_id for enrollment in course_enrollments if ( enrollment.course_id for enrollment in course_enrollments if (
settings.FEATURES['ENABLE_INSTRUCTOR_EMAIL'] and BulkEmailFlag.feature_enabled(enrollment.course_id)
CourseAuthorization.instructor_email_enabled(enrollment.course_id)
) )
) )
......
...@@ -75,6 +75,15 @@ class InstructorDashboardPage(CoursePage): ...@@ -75,6 +75,15 @@ class InstructorDashboardPage(CoursePage):
timed_exam_section.wait_for_page() timed_exam_section.wait_for_page()
return timed_exam_section return timed_exam_section
def select_bulk_email(self):
"""
Selects the email tab and returns the bulk email section
"""
self.q(css='a[data-section=send_email]').first.click()
email_section = BulkEmailPage(self.browser)
email_section.wait_for_page()
return email_section
@staticmethod @staticmethod
def get_asset_path(file_name): def get_asset_path(file_name):
""" """
...@@ -98,6 +107,62 @@ class InstructorDashboardPage(CoursePage): ...@@ -98,6 +107,62 @@ class InstructorDashboardPage(CoursePage):
return os.sep.join(folders_list_in_path) return os.sep.join(folders_list_in_path)
class BulkEmailPage(PageObject):
"""
Bulk email section of the instructor dashboard.
This feature is controlled by an admin panel feature flag, which is turned on via database fixture for testing.
"""
url = None
def is_browser_on_page(self):
return self.q(css='a[data-section=send_email].active-section').present
def _bounded_selector(self, selector):
"""
Return `selector`, but limited to the bulk-email context.
"""
return '.send-email {}'.format(selector)
def _select_recipient(self, recipient):
"""
Selects the specified recipient from the selector. Assumes that recipient is not None.
"""
recipient_selector_css = "select[name='send_to']"
select_option_by_text(
self.q(css=self._bounded_selector(recipient_selector_css)), recipient
)
def send_message(self, recipient):
"""
Send a test message to the specified recipient.
"""
send_css = "input[name='send']"
test_subject = "Hello"
test_body = "This is a test email"
self._select_recipient(recipient)
self.q(css=self._bounded_selector("input[name='subject']")).fill(test_subject)
self.q(css=self._bounded_selector("iframe#mce_0_ifr"))[0].click()
self.q(css=self._bounded_selector("iframe#mce_0_ifr"))[0].send_keys(test_body)
with self.handle_alert(confirm=True):
self.q(css=self._bounded_selector(send_css)).click()
def verify_message_queued_successfully(self):
"""
Verifies that the "you email was queued" message appears.
Note that this does NOT ensure the message gets sent successfully, that functionality
is covered by the bulk_email unit tests.
"""
confirmation_selector = self._bounded_selector(".msg-confirm")
expected_text = u"Your email was successfully queued for sending."
EmptyPromise(
lambda: expected_text in self.q(css=confirmation_selector)[0].text,
"Message Queued Confirmation"
).fulfill()
class MembershipPage(PageObject): class MembershipPage(PageObject):
""" """
Membership section of the Instructor dashboard. Membership section of the Instructor dashboard.
......
...@@ -46,6 +46,25 @@ class BaseInstructorDashboardTest(EventsTestMixin, UniqueCourseTest): ...@@ -46,6 +46,25 @@ class BaseInstructorDashboardTest(EventsTestMixin, UniqueCourseTest):
return instructor_dashboard_page return instructor_dashboard_page
@ddt.ddt
class BulkEmailTest(BaseInstructorDashboardTest):
"""
End-to-end tests for bulk emailing from instructor dash.
"""
def setUp(self):
super(BulkEmailTest, self).setUp()
self.course_fixture = CourseFixture(**self.course_info).install()
self.log_in_as_instructor()
instructor_dashboard_page = self.visit_instructor_dashboard()
self.send_email_page = instructor_dashboard_page.select_bulk_email()
@ddt.data("Myself", "Staff and admins", "All (students, staff, and admins)")
def test_email_queued_for_sending(self, recipient):
self.assertTrue(self.send_email_page.is_browser_on_page())
self.send_email_page.send_message(recipient)
self.send_email_page.verify_message_queued_successfully()
@attr('shard_7') @attr('shard_7')
class AutoEnrollmentWithCSVTest(BaseInstructorDashboardTest): class AutoEnrollmentWithCSVTest(BaseInstructorDashboardTest):
""" """
......
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -35,7 +35,7 @@ CREATE TABLE `django_migrations` ( ...@@ -35,7 +35,7 @@ CREATE TABLE `django_migrations` (
`name` varchar(255) NOT NULL, `name` varchar(255) NOT NULL,
`applied` datetime(6) NOT NULL, `applied` datetime(6) NOT NULL,
PRIMARY KEY (`id`) PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=139 DEFAULT CHARSET=utf8; ) ENGINE=InnoDB AUTO_INCREMENT=155 DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; /*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
......
[
{
"pk": 1,
"model": "bulk_email.bulkemailflag",
"fields": {
"enabled": true,
"require_course_email_auth": false,
"change_date": "2016-05-01"
}
}
]
...@@ -3,7 +3,9 @@ Django admin page for bulk email models ...@@ -3,7 +3,9 @@ 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, CourseAuthorization from config_models.admin import ConfigurationModelAdmin
from bulk_email.models import CourseEmail, Optout, CourseEmailTemplate, CourseAuthorization, BulkEmailFlag
from bulk_email.forms import CourseEmailTemplateForm, CourseAuthorizationAdminForm from bulk_email.forms import CourseEmailTemplateForm, CourseAuthorizationAdminForm
...@@ -80,3 +82,4 @@ admin.site.register(CourseEmail, CourseEmailAdmin) ...@@ -80,3 +82,4 @@ 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) admin.site.register(CourseAuthorization, CourseAuthorizationAdmin)
admin.site.register(BulkEmailFlag, ConfigurationModelAdmin)
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('bulk_email', '0002_data__load_course_email_template'),
]
operations = [
migrations.CreateModel(
name='BulkEmailFlag',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')),
('enabled', models.BooleanField(default=False, verbose_name='Enabled')),
('require_course_email_auth', models.BooleanField(default=True)),
('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')),
],
),
]
...@@ -20,6 +20,8 @@ from django.db import models ...@@ -20,6 +20,8 @@ from django.db import models
from openedx.core.lib.html_to_text import html_to_text from openedx.core.lib.html_to_text import html_to_text
from openedx.core.lib.mail_utils import wrap_message from openedx.core.lib.mail_utils import wrap_message
from config_models.models import ConfigurationModel
from xmodule_django.models import CourseKeyField from xmodule_django.models import CourseKeyField
from util.keyword_substitution import substitute_keywords_with_data from util.keyword_substitution import substitute_keywords_with_data
...@@ -240,14 +242,7 @@ class CourseAuthorization(models.Model): ...@@ -240,14 +242,7 @@ class CourseAuthorization(models.Model):
def instructor_email_enabled(cls, course_id): def instructor_email_enabled(cls, course_id):
""" """
Returns whether or not email is enabled for the given 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.FEATURES['REQUIRE_COURSE_EMAIL_AUTH'] is
# set to False, then we enable email for every course.
if not settings.FEATURES['REQUIRE_COURSE_EMAIL_AUTH']:
return True
try: try:
record = cls.objects.get(course_id=course_id) record = cls.objects.get(course_id=course_id)
return record.email_enabled return record.email_enabled
...@@ -260,3 +255,47 @@ class CourseAuthorization(models.Model): ...@@ -260,3 +255,47 @@ class CourseAuthorization(models.Model):
not_en = "" not_en = ""
# pylint: disable=no-member # pylint: disable=no-member
return u"Course '{}': Instructor Email {}Enabled".format(self.course_id.to_deprecated_string(), not_en) return u"Course '{}': Instructor Email {}Enabled".format(self.course_id.to_deprecated_string(), not_en)
class BulkEmailFlag(ConfigurationModel):
"""
Enables site-wide configuration for the bulk_email feature.
Staff can only send bulk email for a course if all the following conditions are true:
1. BulkEmailFlag is enabled.
2. Course-specific authorization not required, or course authorized to use bulk email.
"""
# boolean field 'enabled' inherited from parent ConfigurationModel
require_course_email_auth = models.BooleanField(default=True)
@classmethod
def feature_enabled(cls, course_id=None):
"""
Looks at the currently active configuration model to determine whether the bulk email feature is available.
If the flag is not enabled, the feature is not available.
If the flag is enabled, course-specific authorization is required, and the course_id is either not provided
or not authorixed, the feature is not available.
If the flag is enabled, course-specific authorization is required, and the provided course_id is authorized,
the feature is available.
If the flag is enabled and course-specific authorization is not required, the feature is available.
"""
if not BulkEmailFlag.is_enabled():
return False
elif BulkEmailFlag.current().require_course_email_auth:
if course_id is None:
return False
else:
return CourseAuthorization.instructor_email_enabled(course_id)
else: # implies enabled == True and require_course_email == False, so email is globally enabled
return True
class Meta(object):
app_label = "bulk_email"
def __unicode__(self):
current_model = BulkEmailFlag.current()
return u"<BulkEmailFlag: enabled {}, require_course_email_auth: {}>".format(
current_model.is_enabled(),
current_model.require_course_email_auth
)
...@@ -15,6 +15,7 @@ from student.tests.factories import UserFactory, AdminFactory, CourseEnrollmentF ...@@ -15,6 +15,7 @@ from student.tests.factories import UserFactory, AdminFactory, CourseEnrollmentF
from student.models import CourseEnrollment from student.models import CourseEnrollment
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 BulkEmailFlag
@attr('shard_1') @attr('shard_1')
...@@ -42,6 +43,11 @@ class TestOptoutCourseEmails(ModuleStoreTestCase): ...@@ -42,6 +43,11 @@ class TestOptoutCourseEmails(ModuleStoreTestCase):
'course_id': self.course.id.to_deprecated_string(), 'course_id': self.course.id.to_deprecated_string(),
'success': True, 'success': True,
} }
BulkEmailFlag.objects.create(enabled=True, require_course_email_auth=False)
def tearDown(self):
super(TestOptoutCourseEmails, self).tearDown()
BulkEmailFlag.objects.all().delete()
def navigate_to_email_view(self): def navigate_to_email_view(self):
"""Navigate to the instructor dash's email view""" """Navigate to the instructor dash's email view"""
...@@ -49,10 +55,9 @@ class TestOptoutCourseEmails(ModuleStoreTestCase): ...@@ -49,10 +55,9 @@ class TestOptoutCourseEmails(ModuleStoreTestCase):
url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id.to_deprecated_string()}) url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id.to_deprecated_string()})
response = self.client.get(url) response = self.client.get(url)
email_section = '<div class="vert-left send-email" id="section-send-email">' email_section = '<div class="vert-left send-email" id="section-send-email">'
# If this fails, it is likely because ENABLE_INSTRUCTOR_EMAIL is set to False # If this fails, it is likely because BulkEmailFlag.is_enabled() is set to False
self.assertTrue(email_section in response.content) self.assertTrue(email_section in response.content)
@patch.dict(settings.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.
...@@ -80,7 +85,6 @@ class TestOptoutCourseEmails(ModuleStoreTestCase): ...@@ -80,7 +85,6 @@ 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.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.
......
...@@ -15,7 +15,7 @@ from django.core.urlresolvers import reverse ...@@ -15,7 +15,7 @@ from django.core.urlresolvers import reverse
from django.core.management import call_command from django.core.management import call_command
from django.test.utils import override_settings from django.test.utils import override_settings
from bulk_email.models import Optout from bulk_email.models import Optout, BulkEmailFlag
from bulk_email.tasks import _get_source_address from bulk_email.tasks import _get_source_address
from courseware.tests.factories import StaffFactory, InstructorFactory from courseware.tests.factories import StaffFactory, InstructorFactory
from instructor_task.subtasks import update_subtask_status from instructor_task.subtasks import update_subtask_status
...@@ -79,7 +79,6 @@ class EmailSendFromDashboardTestCase(SharedModuleStoreTestCase): ...@@ -79,7 +79,6 @@ class EmailSendFromDashboardTestCase(SharedModuleStoreTestCase):
""" """
self.client.login(username=user.username, password="test") self.client.login(username=user.username, password="test")
@patch.dict(settings.FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': False})
def goto_instructor_dash_email_view(self): def goto_instructor_dash_email_view(self):
""" """
Goes to the instructor dashboard to verify that the email section is Goes to the instructor dashboard to verify that the email section is
...@@ -90,7 +89,7 @@ class EmailSendFromDashboardTestCase(SharedModuleStoreTestCase): ...@@ -90,7 +89,7 @@ class EmailSendFromDashboardTestCase(SharedModuleStoreTestCase):
# navigate to a particular email section # navigate to a particular email section
response = self.client.get(url) response = self.client.get(url)
email_section = '<div class="vert-left send-email" id="section-send-email">' email_section = '<div class="vert-left send-email" id="section-send-email">'
# If this fails, it is likely because ENABLE_INSTRUCTOR_EMAIL is set to False # If this fails, it is likely because BulkEmailFlag.is_enabled() is set to False
self.assertIn(email_section, response.content) self.assertIn(email_section, response.content)
@classmethod @classmethod
...@@ -104,6 +103,7 @@ class EmailSendFromDashboardTestCase(SharedModuleStoreTestCase): ...@@ -104,6 +103,7 @@ class EmailSendFromDashboardTestCase(SharedModuleStoreTestCase):
def setUp(self): def setUp(self):
super(EmailSendFromDashboardTestCase, self).setUp() super(EmailSendFromDashboardTestCase, self).setUp()
BulkEmailFlag.objects.create(enabled=True, require_course_email_auth=False)
self.create_staff_and_instructor() self.create_staff_and_instructor()
self.create_students() self.create_students()
...@@ -121,19 +121,22 @@ class EmailSendFromDashboardTestCase(SharedModuleStoreTestCase): ...@@ -121,19 +121,22 @@ class EmailSendFromDashboardTestCase(SharedModuleStoreTestCase):
'success': True, 'success': True,
} }
def tearDown(self):
super(EmailSendFromDashboardTestCase, self).tearDown()
BulkEmailFlag.objects.all().delete()
@attr('shard_1') @attr('shard_1')
@patch.dict(settings.FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': False})
@patch('bulk_email.models.html_to_text', Mock(return_value='Mocking CourseEmail.text_message', autospec=True)) @patch('bulk_email.models.html_to_text', Mock(return_value='Mocking CourseEmail.text_message', autospec=True))
class TestEmailSendFromDashboardMockedHtmlToText(EmailSendFromDashboardTestCase): class TestEmailSendFromDashboardMockedHtmlToText(EmailSendFromDashboardTestCase):
""" """
Tests email sending with mocked html_to_text. Tests email sending with mocked html_to_text.
""" """
@patch.dict(settings.FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': True})
def test_email_disabled(self): def test_email_disabled(self):
""" """
Test response when email is disabled for course. Test response when email is disabled for course.
""" """
BulkEmailFlag.objects.create(enabled=True, require_course_email_auth=True)
test_email = { test_email = {
'action': 'Send email', 'action': 'Send email',
'send_to': 'myself', 'send_to': 'myself',
...@@ -402,7 +405,6 @@ class TestEmailSendFromDashboardMockedHtmlToText(EmailSendFromDashboardTestCase) ...@@ -402,7 +405,6 @@ class TestEmailSendFromDashboardMockedHtmlToText(EmailSendFromDashboardTestCase)
@attr('shard_1') @attr('shard_1')
@patch.dict(settings.FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': False})
@skipIf(os.environ.get("TRAVIS") == 'true', "Skip this test in Travis CI.") @skipIf(os.environ.get("TRAVIS") == 'true', "Skip this test in Travis CI.")
class TestEmailSendFromDashboard(EmailSendFromDashboardTestCase): class TestEmailSendFromDashboard(EmailSendFromDashboardTestCase):
""" """
......
...@@ -14,7 +14,7 @@ from mock import patch, Mock ...@@ -14,7 +14,7 @@ from mock import patch, Mock
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from smtplib import SMTPDataError, SMTPServerDisconnected, SMTPConnectError from smtplib import SMTPDataError, SMTPServerDisconnected, SMTPConnectError
from bulk_email.models import CourseEmail, SEND_TO_ALL from bulk_email.models import CourseEmail, SEND_TO_ALL, BulkEmailFlag
from bulk_email.tasks import perform_delegate_email_batches, send_course_email from bulk_email.tasks import perform_delegate_email_batches, send_course_email
from instructor_task.models import InstructorTask from instructor_task.models import InstructorTask
from instructor_task.subtasks import ( from instructor_task.subtasks import (
...@@ -38,7 +38,6 @@ class EmailTestException(Exception): ...@@ -38,7 +38,6 @@ class EmailTestException(Exception):
@attr('shard_1') @attr('shard_1')
@patch('bulk_email.models.html_to_text', Mock(return_value='Mocking CourseEmail.text_message', autospec=True)) @patch('bulk_email.models.html_to_text', Mock(return_value='Mocking CourseEmail.text_message', autospec=True))
@patch.dict(settings.FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': False})
class TestEmailErrors(ModuleStoreTestCase): class TestEmailErrors(ModuleStoreTestCase):
""" """
Test that errors from sending email are handled properly. Test that errors from sending email are handled properly.
...@@ -61,6 +60,11 @@ class TestEmailErrors(ModuleStoreTestCase): ...@@ -61,6 +60,11 @@ class TestEmailErrors(ModuleStoreTestCase):
'course_id': self.course.id.to_deprecated_string(), 'course_id': self.course.id.to_deprecated_string(),
'success': True, 'success': True,
} }
BulkEmailFlag.objects.create(enabled=True, require_course_email_auth=False)
def tearDown(self):
super(TestEmailErrors, self).tearDown()
BulkEmailFlag.objects.all().delete()
@patch('bulk_email.tasks.get_connection', autospec=True) @patch('bulk_email.tasks.get_connection', autospec=True)
@patch('bulk_email.tasks.send_course_email.retry') @patch('bulk_email.tasks.send_course_email.retry')
......
...@@ -3,10 +3,9 @@ ...@@ -3,10 +3,9 @@
Unit tests for bulk-email-related forms. Unit tests for bulk-email-related forms.
""" """
from django.conf import settings from django.conf import settings
from mock import patch
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from bulk_email.models import CourseAuthorization, CourseEmailTemplate from bulk_email.models import CourseEmailTemplate, BulkEmailFlag
from bulk_email.forms import CourseAuthorizationAdminForm, CourseEmailTemplateForm from bulk_email.forms import CourseAuthorizationAdminForm, CourseEmailTemplateForm
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
...@@ -23,11 +22,15 @@ class CourseAuthorizationFormTest(ModuleStoreTestCase): ...@@ -23,11 +22,15 @@ class CourseAuthorizationFormTest(ModuleStoreTestCase):
super(CourseAuthorizationFormTest, self).setUp() super(CourseAuthorizationFormTest, self).setUp()
course_title = u"ẗëṡẗ title イ乇丂イ ᄊ乇丂丂ムg乇 キo尺 ムレレ тэѕт мэѕѕаБэ" course_title = u"ẗëṡẗ title イ乇丂イ ᄊ乇丂丂ムg乇 キo尺 ムレレ тэѕт мэѕѕаБэ"
self.course = CourseFactory.create(display_name=course_title) self.course = CourseFactory.create(display_name=course_title)
BulkEmailFlag.objects.create(enabled=True, require_course_email_auth=True)
def tearDown(self):
super(CourseAuthorizationFormTest, self).tearDown()
BulkEmailFlag.objects.all().delete()
@patch.dict(settings.FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': True})
def test_authorize_mongo_course(self): def test_authorize_mongo_course(self):
# Initially course shouldn't be authorized # Initially course shouldn't be authorized
self.assertFalse(CourseAuthorization.instructor_email_enabled(self.course.id)) self.assertFalse(BulkEmailFlag.feature_enabled(self.course.id))
# Test authorizing the course, which should totally work # Test authorizing the course, which should totally work
form_data = {'course_id': self.course.id.to_deprecated_string(), 'email_enabled': True} form_data = {'course_id': self.course.id.to_deprecated_string(), 'email_enabled': True}
form = CourseAuthorizationAdminForm(data=form_data) form = CourseAuthorizationAdminForm(data=form_data)
...@@ -35,12 +38,11 @@ class CourseAuthorizationFormTest(ModuleStoreTestCase): ...@@ -35,12 +38,11 @@ class CourseAuthorizationFormTest(ModuleStoreTestCase):
self.assertTrue(form.is_valid()) self.assertTrue(form.is_valid())
form.save() form.save()
# Check that this course is authorized # Check that this course is authorized
self.assertTrue(CourseAuthorization.instructor_email_enabled(self.course.id)) self.assertTrue(BulkEmailFlag.feature_enabled(self.course.id))
@patch.dict(settings.FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': True})
def test_repeat_course(self): def test_repeat_course(self):
# Initially course shouldn't be authorized # Initially course shouldn't be authorized
self.assertFalse(CourseAuthorization.instructor_email_enabled(self.course.id)) self.assertFalse(BulkEmailFlag.feature_enabled(self.course.id))
# Test authorizing the course, which should totally work # Test authorizing the course, which should totally work
form_data = {'course_id': self.course.id.to_deprecated_string(), 'email_enabled': True} form_data = {'course_id': self.course.id.to_deprecated_string(), 'email_enabled': True}
form = CourseAuthorizationAdminForm(data=form_data) form = CourseAuthorizationAdminForm(data=form_data)
...@@ -48,7 +50,7 @@ class CourseAuthorizationFormTest(ModuleStoreTestCase): ...@@ -48,7 +50,7 @@ class CourseAuthorizationFormTest(ModuleStoreTestCase):
self.assertTrue(form.is_valid()) self.assertTrue(form.is_valid())
form.save() form.save()
# Check that this course is authorized # Check that this course is authorized
self.assertTrue(CourseAuthorization.instructor_email_enabled(self.course.id)) self.assertTrue(BulkEmailFlag.feature_enabled(self.course.id))
# Now make a new course authorization with the same course id that tries to turn email off # Now make a new course authorization with the same course id that tries to turn email off
form_data = {'course_id': self.course.id.to_deprecated_string(), 'email_enabled': False} form_data = {'course_id': self.course.id.to_deprecated_string(), 'email_enabled': False}
...@@ -66,9 +68,8 @@ class CourseAuthorizationFormTest(ModuleStoreTestCase): ...@@ -66,9 +68,8 @@ class CourseAuthorizationFormTest(ModuleStoreTestCase):
form.save() form.save()
# Course should still be authorized (invalid attempt had no effect) # Course should still be authorized (invalid attempt had no effect)
self.assertTrue(CourseAuthorization.instructor_email_enabled(self.course.id)) self.assertTrue(BulkEmailFlag.feature_enabled(self.course.id))
@patch.dict(settings.FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': True})
def test_form_typo(self): def test_form_typo(self):
# Munge course id # Munge course id
bad_id = SlashSeparatedCourseKey(u'Broken{}'.format(self.course.id.org), 'hello', self.course.id.run + '_typo') bad_id = SlashSeparatedCourseKey(u'Broken{}'.format(self.course.id.org), 'hello', self.course.id.run + '_typo')
...@@ -89,7 +90,6 @@ class CourseAuthorizationFormTest(ModuleStoreTestCase): ...@@ -89,7 +90,6 @@ class CourseAuthorizationFormTest(ModuleStoreTestCase):
): ):
form.save() form.save()
@patch.dict(settings.FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': True})
def test_form_invalid_key(self): def test_form_invalid_key(self):
form_data = {'course_id': "asd::**!@#$%^&*())//foobar!!", 'email_enabled': True} form_data = {'course_id': "asd::**!@#$%^&*())//foobar!!", 'email_enabled': True}
form = CourseAuthorizationAdminForm(data=form_data) form = CourseAuthorizationAdminForm(data=form_data)
...@@ -107,7 +107,6 @@ class CourseAuthorizationFormTest(ModuleStoreTestCase): ...@@ -107,7 +107,6 @@ class CourseAuthorizationFormTest(ModuleStoreTestCase):
): ):
form.save() form.save()
@patch.dict(settings.FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': True})
def test_course_name_only(self): def test_course_name_only(self):
# Munge course id - common # Munge course id - common
form_data = {'course_id': self.course.id.run, 'email_enabled': True} form_data = {'course_id': self.course.id.run, 'email_enabled': True}
......
...@@ -10,7 +10,7 @@ from student.tests.factories import UserFactory ...@@ -10,7 +10,7 @@ from student.tests.factories import UserFactory
from mock import patch, Mock from mock import patch, Mock
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from bulk_email.models import CourseEmail, SEND_TO_STAFF, CourseEmailTemplate, CourseAuthorization from bulk_email.models import CourseEmail, SEND_TO_STAFF, CourseEmailTemplate, CourseAuthorization, BulkEmailFlag
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
...@@ -173,17 +173,21 @@ class CourseEmailTemplateTest(TestCase): ...@@ -173,17 +173,21 @@ class CourseEmailTemplateTest(TestCase):
class CourseAuthorizationTest(TestCase): class CourseAuthorizationTest(TestCase):
"""Test the CourseAuthorization model.""" """Test the CourseAuthorization model."""
@patch.dict(settings.FEATURES, {'REQUIRE_COURSE_EMAIL_AUTH': True}) def tearDown(self):
super(CourseAuthorizationTest, self).tearDown()
BulkEmailFlag.objects.all().delete()
def test_creation_auth_on(self): def test_creation_auth_on(self):
BulkEmailFlag.objects.create(enabled=True, require_course_email_auth=True)
course_id = SlashSeparatedCourseKey('abc', '123', 'doremi') course_id = SlashSeparatedCourseKey('abc', '123', 'doremi')
# Test that course is not authorized by default # Test that course is not authorized by default
self.assertFalse(CourseAuthorization.instructor_email_enabled(course_id)) self.assertFalse(BulkEmailFlag.feature_enabled(course_id))
# Authorize # Authorize
cauth = CourseAuthorization(course_id=course_id, email_enabled=True) cauth = CourseAuthorization(course_id=course_id, email_enabled=True)
cauth.save() cauth.save()
# Now, course should be authorized # Now, course should be authorized
self.assertTrue(CourseAuthorization.instructor_email_enabled(course_id)) self.assertTrue(BulkEmailFlag.feature_enabled(course_id))
self.assertEquals( self.assertEquals(
cauth.__unicode__(), cauth.__unicode__(),
"Course 'abc/123/doremi': Instructor Email Enabled" "Course 'abc/123/doremi': Instructor Email Enabled"
...@@ -193,21 +197,21 @@ class CourseAuthorizationTest(TestCase): ...@@ -193,21 +197,21 @@ class CourseAuthorizationTest(TestCase):
cauth.email_enabled = False cauth.email_enabled = False
cauth.save() cauth.save()
# Test that course is now unauthorized # Test that course is now unauthorized
self.assertFalse(CourseAuthorization.instructor_email_enabled(course_id)) self.assertFalse(BulkEmailFlag.feature_enabled(course_id))
self.assertEquals( self.assertEquals(
cauth.__unicode__(), cauth.__unicode__(),
"Course 'abc/123/doremi': Instructor Email Not Enabled" "Course 'abc/123/doremi': Instructor Email Not Enabled"
) )
@patch.dict(settings.FEATURES, {'REQUIRE_COURSE_EMAIL_AUTH': False})
def test_creation_auth_off(self): def test_creation_auth_off(self):
BulkEmailFlag.objects.create(enabled=True, require_course_email_auth=False)
course_id = SlashSeparatedCourseKey('blahx', 'blah101', 'ehhhhhhh') course_id = SlashSeparatedCourseKey('blahx', 'blah101', 'ehhhhhhh')
# Test that course is authorized by default, since auth is turned off # Test that course is authorized by default, since auth is turned off
self.assertTrue(CourseAuthorization.instructor_email_enabled(course_id)) self.assertTrue(BulkEmailFlag.feature_enabled(course_id))
# Use the admin interface to unauthorize the course # Use the admin interface to unauthorize the course
cauth = CourseAuthorization(course_id=course_id, email_enabled=False) cauth = CourseAuthorization(course_id=course_id, email_enabled=False)
cauth.save() cauth.save()
# Now, course should STILL be authorized! # Now, course should STILL be authorized!
self.assertTrue(CourseAuthorization.instructor_email_enabled(course_id)) self.assertTrue(BulkEmailFlag.feature_enabled(course_id))
@shard_2
Feature: LMS.Instructor Dash 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 there is a course with a staff, instructor and student
And I am logged in to the course as "<Role>"
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=missing-docstring
# pylint: disable=redefined-outer-name
from lettuce import world, step
from lettuce.django import mail
from nose.tools import assert_in, assert_equal
from django.core.management import call_command
from django.conf import settings
from courseware.tests.factories import StaffFactory, InstructorFactory
@step(u'Given there is a course with a staff, instructor and student')
def make_populated_course(step): # pylint: disable=unused-argument
## This is different than the function defined in common.py because it enrolls
## a staff, instructor, and student member regardless of what `role` is, then
## logs `role` in. This is to ensure we have 3 class participants to email.
# Clear existing courses to avoid conflicts
world.clear_courses()
# Create a new course
course = world.CourseFactory.create(
org='edx',
number='888',
display_name='Bulk Email Test Course'
)
world.bulk_email_course_key = course.id
try:
# See if we've defined the instructor & staff user yet
world.bulk_email_instructor
except AttributeError:
# Make & register an instructor for the course
world.bulk_email_instructor = InstructorFactory(course_key=world.bulk_email_course_key)
world.enroll_user(world.bulk_email_instructor, world.bulk_email_course_key)
# Make & register a staff member
world.bulk_email_staff = StaffFactory(course_key=course.id)
world.enroll_user(world.bulk_email_staff, world.bulk_email_course_key)
# Make & register a student
world.register_by_course_key(
course.id,
username='student',
password='test',
is_staff=False
)
# Store the expected recipients
# given each "send to" option
staff_emails = [world.bulk_email_staff.email, world.bulk_email_instructor.email]
world.expected_addresses = {
'course staff': staff_emails,
'students, staff, and instructors': staff_emails + ['student@edx.org']
}
# 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 am logged in to the course as "([^"]*)"')
def log_into_the_course(step, role): # pylint: disable=unused-argument
# Store the role
assert_in(role, ['instructor', 'staff'])
# Log in as the an instructor or staff for the course
my_email = world.bulk_email_instructor.email
if role == 'instructor':
world.log_in(
username=world.bulk_email_instructor.username,
password='test',
email=my_email,
name=world.bulk_email_instructor.profile.name
)
else:
my_email = world.bulk_email_staff.email
world.log_in(
username=world.bulk_email_staff.username,
password='test',
email=my_email,
name=world.bulk_email_staff.profile.name
)
# Store the "myself" send to option
world.expected_addresses['myself'] = [my_email]
@step(u'I send email to "([^"]*)"')
def when_i_send_an_email(step, recipient): # pylint: disable=unused-argument
# Check that the recipient is valid
assert_in(
recipient, SEND_TO_OPTIONS,
msg="Invalid recipient: {}".format(recipient)
)
# Clear the queue of existing emails
while not mail.queue.empty(): # pylint: disable=no-member
mail.queue.get() # pylint: disable=no-member
# 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
url = '/courses/{}'.format(world.bulk_email_course_key)
world.visit(url)
world.css_click('a[href="{}/instructor"]'.format(url))
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"]', dismiss_alert=True)
# Expect to see a message that the email was sent
expected_msg = "Your email was successfully queued for sending."
world.wait_for_visible('#request-response')
assert_in(
expected_msg, world.css_text('#request-response'),
msg="Could not find email success message."
)
UNSUBSCRIBE_MSG = 'To stop receiving email like this'
@step(u'Email is sent to "([^"]*)"')
def then_the_email_is_sent(step, recipient): # pylint: disable=unused-argument
# 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=no-member
messages.append(mail.queue.get()) # pylint: disable=no-member
# Check that we got the right number of messages
assert_equal(
len(messages), len(world.expected_addresses[recipient]),
msg="Received {0} instead of {1} messages for {2}".format(
len(messages), len(world.expected_addresses[recipient]), recipient
)
)
# Check that the message properties were correct
recipients = []
for msg in messages:
assert_in('Hello', msg.subject)
assert_in(settings.BULK_EMAIL_DEFAULT_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, mime_type = msg.alternatives[0]
assert_equal(mime_type, 'text/html')
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
# Because "myself" can vary based on who sent the message,
# we use the world.expected_addresses dict we configured
# in an earlier step.
for addr in world.expected_addresses[recipient]:
assert_in(addr, recipients)
...@@ -31,6 +31,7 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey ...@@ -31,6 +31,7 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey
from opaque_keys.edx.locator import UsageKey from opaque_keys.edx.locator import UsageKey
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
from bulk_email.models import BulkEmailFlag
from course_modes.models import CourseMode from course_modes.models import CourseMode
from courseware.models import StudentModule from courseware.models import StudentModule
from courseware.tests.factories import StaffFactory, InstructorFactory, BetaTesterFactory, UserProfileFactory from courseware.tests.factories import StaffFactory, InstructorFactory, BetaTesterFactory, UserProfileFactory
...@@ -192,7 +193,6 @@ class TestCommonExceptions400(TestCase): ...@@ -192,7 +193,6 @@ class TestCommonExceptions400(TestCase):
@attr('shard_1') @attr('shard_1')
@patch('bulk_email.models.html_to_text', Mock(return_value='Mocking CourseEmail.text_message', autospec=True)) @patch('bulk_email.models.html_to_text', Mock(return_value='Mocking CourseEmail.text_message', autospec=True))
@patch.dict(settings.FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': False})
class TestInstructorAPIDenyLevels(SharedModuleStoreTestCase, LoginEnrollmentTestCase): class TestInstructorAPIDenyLevels(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
""" """
Ensure that users cannot access endpoints they shouldn't be able to. Ensure that users cannot access endpoints they shouldn't be able to.
...@@ -207,6 +207,12 @@ class TestInstructorAPIDenyLevels(SharedModuleStoreTestCase, LoginEnrollmentTest ...@@ -207,6 +207,12 @@ class TestInstructorAPIDenyLevels(SharedModuleStoreTestCase, LoginEnrollmentTest
'robot-some-problem-urlname' 'robot-some-problem-urlname'
) )
cls.problem_urlname = cls.problem_location.to_deprecated_string() cls.problem_urlname = cls.problem_location.to_deprecated_string()
BulkEmailFlag.objects.create(enabled=True, require_course_email_auth=False)
@classmethod
def tearDownClass(cls):
super(TestInstructorAPIDenyLevels, cls).tearDownClass()
BulkEmailFlag.objects.all().delete()
def setUp(self): def setUp(self):
super(TestInstructorAPIDenyLevels, self).setUp() super(TestInstructorAPIDenyLevels, self).setUp()
...@@ -3391,7 +3397,6 @@ class TestEntranceExamInstructorAPIRegradeTask(SharedModuleStoreTestCase, LoginE ...@@ -3391,7 +3397,6 @@ class TestEntranceExamInstructorAPIRegradeTask(SharedModuleStoreTestCase, LoginE
@attr('shard_1') @attr('shard_1')
@patch('bulk_email.models.html_to_text', Mock(return_value='Mocking CourseEmail.text_message', autospec=True)) @patch('bulk_email.models.html_to_text', Mock(return_value='Mocking CourseEmail.text_message', autospec=True))
@patch.dict(settings.FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': False})
class TestInstructorSendEmail(SharedModuleStoreTestCase, LoginEnrollmentTestCase): class TestInstructorSendEmail(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
""" """
Checks that only instructors have access to email endpoints, and that Checks that only instructors have access to email endpoints, and that
...@@ -3409,6 +3414,12 @@ class TestInstructorSendEmail(SharedModuleStoreTestCase, LoginEnrollmentTestCase ...@@ -3409,6 +3414,12 @@ class TestInstructorSendEmail(SharedModuleStoreTestCase, LoginEnrollmentTestCase
'subject': test_subject, 'subject': test_subject,
'message': test_message, 'message': test_message,
} }
BulkEmailFlag.objects.create(enabled=True, require_course_email_auth=False)
@classmethod
def tearDownClass(cls):
super(TestInstructorSendEmail, cls).tearDownClass()
BulkEmailFlag.objects.all().delete()
def setUp(self): def setUp(self):
super(TestInstructorSendEmail, self).setUp() super(TestInstructorSendEmail, self).setUp()
......
...@@ -6,11 +6,10 @@ that the view is conditionally available when Course Auth is turned on. ...@@ -6,11 +6,10 @@ that the view is conditionally available when Course Auth is turned on.
""" """
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from mock import patch
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
from bulk_email.models import CourseAuthorization from bulk_email.models import CourseAuthorization, BulkEmailFlag
from xmodule.modulestore.tests.django_utils import ( from xmodule.modulestore.tests.django_utils import (
TEST_DATA_MIXED_MODULESTORE, SharedModuleStoreTestCase TEST_DATA_MIXED_MODULESTORE, SharedModuleStoreTestCase
) )
...@@ -41,14 +40,18 @@ class TestNewInstructorDashboardEmailViewMongoBacked(SharedModuleStoreTestCase): ...@@ -41,14 +40,18 @@ class TestNewInstructorDashboardEmailViewMongoBacked(SharedModuleStoreTestCase):
instructor = AdminFactory.create() instructor = AdminFactory.create()
self.client.login(username=instructor.username, password="test") self.client.login(username=instructor.username, password="test")
# In order for bulk email to work, we must have both the ENABLE_INSTRUCTOR_EMAIL_FLAG def tearDown(self):
super(TestNewInstructorDashboardEmailViewMongoBacked, self).tearDown()
BulkEmailFlag.objects.all().delete()
# In order for bulk email to work, we must have both the BulkEmailFlag.is_enabled()
# set to True and for the course to be Mongo-backed. # set to True and for the course to be Mongo-backed.
# The flag is enabled and the course is Mongo-backed (should work) # The flag is enabled and the course is Mongo-backed (should work)
@patch.dict(settings.FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': False})
def test_email_flag_true_mongo_true(self): def test_email_flag_true_mongo_true(self):
BulkEmailFlag.objects.create(enabled=True, require_course_email_auth=False)
# Assert that instructor email is enabled for this course - since REQUIRE_COURSE_EMAIL_AUTH is False, # Assert that instructor email is enabled for this course - since REQUIRE_COURSE_EMAIL_AUTH is False,
# all courses should be authorized to use email. # all courses should be authorized to use email.
self.assertTrue(CourseAuthorization.instructor_email_enabled(self.course.id)) self.assertTrue(BulkEmailFlag.feature_enabled(self.course.id))
# 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)
self.assertIn(self.email_link, response.content) self.assertIn(self.email_link, response.content)
...@@ -58,26 +61,26 @@ class TestNewInstructorDashboardEmailViewMongoBacked(SharedModuleStoreTestCase): ...@@ -58,26 +61,26 @@ class TestNewInstructorDashboardEmailViewMongoBacked(SharedModuleStoreTestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# The course is Mongo-backed but the flag is disabled (should not work) # The course is Mongo-backed but the flag is disabled (should not work)
@patch.dict(settings.FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': False})
def test_email_flag_false_mongo_true(self): def test_email_flag_false_mongo_true(self):
BulkEmailFlag.objects.create(enabled=False)
# Assert that the URL for the email view is not in the response # Assert that the URL for the email view is not in the response
response = self.client.get(self.url) response = self.client.get(self.url)
self.assertFalse(self.email_link in response.content) 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 # Flag is enabled, but we require course auth and haven't turned it on for this course
@patch.dict(settings.FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': True})
def test_course_not_authorized(self): def test_course_not_authorized(self):
BulkEmailFlag.objects.create(enabled=True, require_course_email_auth=True)
# Assert that instructor email is not enabled for this course # Assert that instructor email is not enabled for this course
self.assertFalse(CourseAuthorization.instructor_email_enabled(self.course.id)) self.assertFalse(BulkEmailFlag.feature_enabled(self.course.id))
# Assert that the URL for the email view is not in the response # Assert that the URL for the email view is not in the response
response = self.client.get(self.url) response = self.client.get(self.url)
self.assertFalse(self.email_link in response.content) self.assertFalse(self.email_link in response.content)
# Flag is enabled, we require course auth and turn it on for this course # Flag is enabled, we require course auth and turn it on for this course
@patch.dict(settings.FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': True})
def test_course_authorized(self): def test_course_authorized(self):
BulkEmailFlag.objects.create(enabled=True, require_course_email_auth=True)
# Assert that instructor email is not enabled for this course # Assert that instructor email is not enabled for this course
self.assertFalse(CourseAuthorization.instructor_email_enabled(self.course.id)) self.assertFalse(BulkEmailFlag.feature_enabled(self.course.id))
# Assert that the URL for the email view is not in the response # Assert that the URL for the email view is not in the response
response = self.client.get(self.url) response = self.client.get(self.url)
self.assertFalse(self.email_link in response.content) self.assertFalse(self.email_link in response.content)
...@@ -87,19 +90,20 @@ class TestNewInstructorDashboardEmailViewMongoBacked(SharedModuleStoreTestCase): ...@@ -87,19 +90,20 @@ class TestNewInstructorDashboardEmailViewMongoBacked(SharedModuleStoreTestCase):
cauth.save() cauth.save()
# Assert that instructor email is enabled for this course # Assert that instructor email is enabled for this course
self.assertTrue(CourseAuthorization.instructor_email_enabled(self.course.id)) self.assertTrue(BulkEmailFlag.feature_enabled(self.course.id))
# 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)
self.assertTrue(self.email_link in response.content) self.assertTrue(self.email_link in response.content)
# Flag is disabled, but course is authorized # Flag is disabled, but course is authorized
@patch.dict(settings.FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': False, 'REQUIRE_COURSE_EMAIL_AUTH': True})
def test_course_authorized_feature_off(self): def test_course_authorized_feature_off(self):
BulkEmailFlag.objects.create(enabled=False, require_course_email_auth=True)
# Authorize the course to use email # Authorize the course to use email
cauth = CourseAuthorization(course_id=self.course.id, email_enabled=True) cauth = CourseAuthorization(course_id=self.course.id, email_enabled=True)
cauth.save() cauth.save()
# Assert that instructor email IS enabled for this course # Assert that this course is authorized for instructor email, but the feature is not enabled
self.assertFalse(BulkEmailFlag.feature_enabled(self.course.id))
self.assertTrue(CourseAuthorization.instructor_email_enabled(self.course.id)) self.assertTrue(CourseAuthorization.instructor_email_enabled(self.course.id))
# Assert that the URL for the email view IS NOT in the response # Assert that the URL for the email view IS NOT in the response
response = self.client.get(self.url) response = self.client.get(self.url)
...@@ -136,15 +140,19 @@ class TestNewInstructorDashboardEmailViewXMLBacked(SharedModuleStoreTestCase): ...@@ -136,15 +140,19 @@ class TestNewInstructorDashboardEmailViewXMLBacked(SharedModuleStoreTestCase):
# URL for email view # URL for email view
self.email_link = '<a href="" data-section="send_email">Email</a>' self.email_link = '<a href="" data-section="send_email">Email</a>'
def tearDown(self):
super(TestNewInstructorDashboardEmailViewXMLBacked, self).tearDown()
BulkEmailFlag.objects.all().delete()
# The flag is enabled, and since REQUIRE_COURSE_EMAIL_AUTH is False, all courses should # 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) # be authorized to use email. But the course is not Mongo-backed (should not work)
@patch.dict(settings.FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': False})
def test_email_flag_true_mongo_false(self): def test_email_flag_true_mongo_false(self):
BulkEmailFlag.objects.create(enabled=True, require_course_email_auth=False)
response = self.client.get(self.url) response = self.client.get(self.url)
self.assertFalse(self.email_link in response.content) self.assertFalse(self.email_link in response.content)
# The flag is disabled and the course is not Mongo-backed (should not work) # The flag is disabled and the course is not Mongo-backed (should not work)
@patch.dict(settings.FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': False, 'REQUIRE_COURSE_EMAIL_AUTH': False})
def test_email_flag_false_mongo_false(self): def test_email_flag_false_mongo_false(self):
BulkEmailFlag.objects.create(enabled=False, require_course_email_auth=False)
response = self.client.get(self.url) response = self.client.get(self.url)
self.assertFalse(self.email_link in response.content) self.assertFalse(self.email_link in response.content)
...@@ -91,7 +91,7 @@ from submissions import api as sub_api # installed from the edx-submissions rep ...@@ -91,7 +91,7 @@ from submissions import api as sub_api # installed from the edx-submissions rep
from certificates import api as certs_api from certificates import api as certs_api
from certificates.models import CertificateWhitelist, GeneratedCertificate, CertificateStatuses, CertificateInvalidation from certificates.models import CertificateWhitelist, GeneratedCertificate, CertificateStatuses, CertificateInvalidation
from bulk_email.models import CourseEmail from bulk_email.models import CourseEmail, BulkEmailFlag
from student.models import get_user_by_username_or_email from student.models import get_user_by_username_or_email
from .tools import ( from .tools import (
...@@ -104,7 +104,6 @@ from .tools import ( ...@@ -104,7 +104,6 @@ from .tools import (
parse_datetime, parse_datetime,
set_due_date_extension, set_due_date_extension,
strip_if_string, strip_if_string,
bulk_email_is_enabled_for_course,
) )
from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.keys import CourseKey, UsageKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
...@@ -2487,7 +2486,7 @@ def send_email(request, course_id): ...@@ -2487,7 +2486,7 @@ def send_email(request, course_id):
""" """
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id) course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
if not bulk_email_is_enabled_for_course(course_id): if not BulkEmailFlag.feature_enabled(course_id):
return HttpResponseForbidden("Email is not enabled for this course.") return HttpResponseForbidden("Email is not enabled for this course.")
send_to = request.POST.get("send_to") send_to = request.POST.get("send_to")
......
...@@ -46,10 +46,11 @@ from certificates.models import ( ...@@ -46,10 +46,11 @@ from certificates.models import (
CertificateInvalidation, CertificateInvalidation,
) )
from certificates import api as certs_api from certificates import api as certs_api
from bulk_email.models import BulkEmailFlag
from util.date_utils import get_default_time_display from util.date_utils import get_default_time_display
from class_dashboard.dashboard_data import get_section_display_name, get_array_section_has_problem from class_dashboard.dashboard_data import get_section_display_name, get_array_section_has_problem
from .tools import get_units_with_due_date, title_or_url, bulk_email_is_enabled_for_course from .tools import get_units_with_due_date, title_or_url
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
from openedx.core.djangolib.markup import Text, HTML from openedx.core.djangolib.markup import Text, HTML
...@@ -140,7 +141,7 @@ def instructor_dashboard_2(request, course_id): ...@@ -140,7 +141,7 @@ def instructor_dashboard_2(request, course_id):
sections.insert(3, _section_extensions(course)) sections.insert(3, _section_extensions(course))
# Gate access to course email by feature flag & by course-specific authorization # Gate access to course email by feature flag & by course-specific authorization
if bulk_email_is_enabled_for_course(course_key): if BulkEmailFlag.feature_enabled(course_key):
sections.append(_section_send_email(course, access)) sections.append(_section_send_email(course, access))
# Gate access to Metrics tab by featue flag and staff authorization # Gate access to Metrics tab by featue flag and staff authorization
......
...@@ -22,8 +22,6 @@ from xmodule.modulestore import ModuleStoreEnum ...@@ -22,8 +22,6 @@ from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from opaque_keys.edx.keys import UsageKey from opaque_keys.edx.keys import UsageKey
from bulk_email.models import CourseAuthorization
DATE_FIELD = Date() DATE_FIELD = Date()
...@@ -57,23 +55,6 @@ def handle_dashboard_error(view): ...@@ -57,23 +55,6 @@ def handle_dashboard_error(view):
return wrapper return wrapper
def bulk_email_is_enabled_for_course(course_id):
"""
Staff can only send bulk email for a course if all the following conditions are true:
1. Bulk email feature flag is on.
2. It is a studio course.
3. Bulk email is enabled for the course.
"""
bulk_email_enabled_globally = (settings.FEATURES['ENABLE_INSTRUCTOR_EMAIL'] is True)
bulk_email_enabled_for_course = CourseAuthorization.instructor_email_enabled(course_id)
if bulk_email_enabled_globally and bulk_email_enabled_for_course:
return True
return False
def strip_if_string(value): def strip_if_string(value):
if isinstance(value, basestring): if isinstance(value, basestring):
return value.strip() return value.strip()
......
...@@ -127,10 +127,7 @@ THIRD_PARTY_AUTH = { ...@@ -127,10 +127,7 @@ THIRD_PARTY_AUTH = {
# Enable fake payment processing page # Enable fake payment processing page
FEATURES['ENABLE_PAYMENT_FAKE'] = True FEATURES['ENABLE_PAYMENT_FAKE'] = True
# Enable email on the instructor dash # Enable special exams
FEATURES['ENABLE_INSTRUCTOR_EMAIL'] = True
FEATURES['REQUIRE_COURSE_EMAIL_AUTH'] = False
FEATURES['ENABLE_SPECIAL_EXAMS'] = True FEATURES['ENABLE_SPECIAL_EXAMS'] = True
# Don't actually send any requests to Software Secure for student identity # Don't actually send any requests to Software Secure for student identity
......
...@@ -61,7 +61,7 @@ ...@@ -61,7 +61,7 @@
"CONTACT_EMAIL": "info@example.com", "CONTACT_EMAIL": "info@example.com",
"DEFAULT_FEEDBACK_EMAIL": "feedback@example.com", "DEFAULT_FEEDBACK_EMAIL": "feedback@example.com",
"DEFAULT_FROM_EMAIL": "registration@example.com", "DEFAULT_FROM_EMAIL": "registration@example.com",
"EMAIL_BACKEND": "django.core.mail.backends.smtp.EmailBackend", "EMAIL_BACKEND": "django.core.mail.backends.dummy.EmailBackend",
"SOCIAL_SHARING_SETTINGS": { "SOCIAL_SHARING_SETTINGS": {
"CUSTOM_COURSE_URLS": true, "CUSTOM_COURSE_URLS": true,
"DASHBOARD_FACEBOOK": true, "DASHBOARD_FACEBOOK": true,
......
...@@ -130,14 +130,6 @@ FEATURES = { ...@@ -130,14 +130,6 @@ FEATURES = {
# Enables ability to restrict enrollment in specific courses by the user account login method # Enables ability to restrict enrollment in specific courses by the user account login method
'RESTRICT_ENROLL_BY_REG_METHOD': False, 'RESTRICT_ENROLL_BY_REG_METHOD': 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 Mongo-backed 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
# LMS OPERATION. See analytics.py for details about what # LMS OPERATION. See analytics.py for details about what
......
...@@ -22,8 +22,6 @@ FEATURES['DISABLE_START_DATES'] = False ...@@ -22,8 +22,6 @@ FEATURES['DISABLE_START_DATES'] = False
FEATURES['ENABLE_SQL_TRACKING_LOGS'] = True FEATURES['ENABLE_SQL_TRACKING_LOGS'] = True
FEATURES['ENABLE_MANUAL_GIT_RELOAD'] = True FEATURES['ENABLE_MANUAL_GIT_RELOAD'] = True
FEATURES['ENABLE_SERVICE_STATUS'] = True FEATURES['ENABLE_SERVICE_STATUS'] = True
FEATURES['ENABLE_INSTRUCTOR_EMAIL'] = True # Enable email for all Studio courses
FEATURES['REQUIRE_COURSE_EMAIL_AUTH'] = False # Give all courses email (don't require django-admin perms)
FEATURES['ENABLE_SHOPPING_CART'] = True FEATURES['ENABLE_SHOPPING_CART'] = True
FEATURES['AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'] = True FEATURES['AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'] = True
FEATURES['ENABLE_S3_GRADE_DOWNLOADS'] = True FEATURES['ENABLE_S3_GRADE_DOWNLOADS'] = True
......
...@@ -36,9 +36,6 @@ for log_name, log_level in LOG_OVERRIDES: ...@@ -36,9 +36,6 @@ for log_name, log_level in LOG_OVERRIDES:
################################ EMAIL ######################################## ################################ EMAIL ########################################
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
FEATURES['ENABLE_INSTRUCTOR_EMAIL'] = True # Enable email for all Studio courses
FEATURES['REQUIRE_COURSE_EMAIL_AUTH'] = False # Give all courses email (don't require django-admin perms)
########################## ANALYTICS TESTING ######################## ########################## ANALYTICS TESTING ########################
......
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