Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,4 @@ Akshay Jagadeesh <akjags@gmail.com>
Nick Parlante <nick.parlante@cs.stanford.edu>
Marko Seric <marko.seric@math.uzh.ch>
Felipe Montoya <felipe.montoya@edunext.co>
Julia Hansbrough <julia@edx.org>
2 changes: 2 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ the top. Include a label indicating the component affected.

LMS: Disable data download buttons on the instructor dashboard for large courses

LMS: Ported bulk emailing to the beta instructor dashboard.

LMS: Refactor and clean student dashboard templates.

LMS: Fix issue with CourseMode expiration dates
Expand Down
17 changes: 16 additions & 1 deletion common/djangoapps/terrain/course_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# pylint: disable=W0621

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 xmodule.modulestore.django import editable_modulestore
from xmodule.contentstore.django import contentstore
Expand Down Expand Up @@ -50,6 +50,21 @@ def register_by_course_id(course_id, username='robot', password='test', is_staff
CourseEnrollment.enroll(user, course_id)


@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():
# Flush and initialize the module store
Expand Down
20 changes: 20 additions & 0 deletions lms/djangoapps/instructor/features/bulk_email.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
@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 |

162 changes: 162 additions & 0 deletions lms/djangoapps/instructor/features/bulk_email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
"""
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)
75 changes: 71 additions & 4 deletions lms/djangoapps/instructor/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import json
import requests
from urllib import quote
from django.conf import settings
from django.test import TestCase
from nose.tools import raises
from mock import Mock, patch
Expand Down Expand Up @@ -125,6 +124,7 @@ def test_staff_level(self):
'list_forum_members',
'update_forum_role_membership',
'proxy_legacy_analytics',
'send_email',
]
for endpoint in staff_level_endpoints:
url = reverse(endpoint, kwargs={'course_id': self.course.id})
Expand Down Expand Up @@ -280,8 +280,8 @@ class TestInstructorAPILevelsAccess(ModuleStoreTestCase, LoginEnrollmentTestCase
This test does NOT test whether the actions had an effect on the
database, that is the job of test_access.
This tests the response and action switch.
Actually, modify_access does not having a very meaningful
response yet, so only the status code is tested.
Actually, modify_access does not have a very meaningful
response yet, so only the status code is tested.
"""
def setUp(self):
self.instructor = AdminFactory.create()
Expand Down Expand Up @@ -691,7 +691,74 @@ def test_rescore_problem_all(self, act):
})
print response.content
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)
Expand Down
Loading