Merge pull request #1215 from edx/flowerhack/feature/bulkemailnewdash_testingcoverage
Flowerhack/feature/bulkemailnewdash testingcoverage
This commit is contained in:
1
AUTHORS
1
AUTHORS
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
20
lms/djangoapps/instructor/features/bulk_email.feature
Normal file
20
lms/djangoapps/instructor/features/bulk_email.feature
Normal file
@@ -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
lms/djangoapps/instructor/features/bulk_email.py
Normal file
162
lms/djangoapps/instructor/features/bulk_email.py
Normal file
@@ -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)
|
||||
@@ -6,7 +6,6 @@ import unittest
|
||||
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
|
||||
@@ -125,6 +124,7 @@ class TestInstructorAPIDenyLevels(ModuleStoreTestCase, LoginEnrollmentTestCase):
|
||||
'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})
|
||||
@@ -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()
|
||||
@@ -691,7 +691,74 @@ class TestInstructorAPIRegradeTask(ModuleStoreTestCase, LoginEnrollmentTestCase)
|
||||
})
|
||||
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)
|
||||
|
||||
92
lms/djangoapps/instructor/tests/test_email.py
Normal file
92
lms/djangoapps/instructor/tests/test_email.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Unit tests for email feature flag in new instructor dashboard.
|
||||
Additionally tests that bulk email is always disabled for
|
||||
non-Mongo backed courses, regardless of email feature flag.
|
||||
"""
|
||||
|
||||
from django.test.utils import override_settings
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
|
||||
from student.tests.factories import AdminFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE
|
||||
|
||||
from mock import patch
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
|
||||
class TestNewInstructorDashboardEmailViewMongoBacked(ModuleStoreTestCase):
|
||||
"""
|
||||
Check for email view on the new instructor dashboard
|
||||
for Mongo-backed courses
|
||||
"""
|
||||
def setUp(self):
|
||||
self.course = CourseFactory.create()
|
||||
|
||||
# Create instructor account
|
||||
instructor = AdminFactory.create()
|
||||
self.client.login(username=instructor.username, password="test")
|
||||
|
||||
# URL for instructor dash
|
||||
self.url = reverse('instructor_dashboard_2', kwargs={'course_id': self.course.id})
|
||||
# URL for email view
|
||||
self.email_link = '<a href="" data-section="send_email">Email</a>'
|
||||
|
||||
def tearDown(self):
|
||||
"""
|
||||
Undo all patches.
|
||||
"""
|
||||
patch.stopall()
|
||||
|
||||
# In order for bulk email to work, we must have both the ENABLE_INSTRUCTOR_EMAIL_FLAG
|
||||
# set to True and for the course to be Mongo-backed.
|
||||
# The flag is enabled and the course is Mongo-backed (should work)
|
||||
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True})
|
||||
def test_email_flag_true_mongo_true(self):
|
||||
# Assert that the URL for the email view is in the response
|
||||
response = self.client.get(self.url)
|
||||
self.assertIn(self.email_link, response.content)
|
||||
|
||||
send_to_label = '<label for="id_to">Send to:</label>'
|
||||
self.assertTrue(send_to_label in response.content)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# The course is Mongo-backed but the flag is disabled (should not work)
|
||||
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': False})
|
||||
def test_email_flag_false_mongo_true(self):
|
||||
# Assert that the URL for the email view is not in the response
|
||||
response = self.client.get(self.url)
|
||||
self.assertFalse(self.email_link in response.content)
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
|
||||
class TestNewInstructorDashboardEmailViewXMLBacked(ModuleStoreTestCase):
|
||||
"""
|
||||
Check for email view on the new instructor dashboard
|
||||
"""
|
||||
def setUp(self):
|
||||
self.course_name = 'edX/toy/2012_Fall'
|
||||
|
||||
# Create instructor account
|
||||
instructor = AdminFactory.create()
|
||||
self.client.login(username=instructor.username, password="test")
|
||||
|
||||
# URL for instructor dash
|
||||
self.url = reverse('instructor_dashboard_2', kwargs={'course_id': self.course_name})
|
||||
# URL for email view
|
||||
self.email_link = '<a href="" data-section="send_email">Email</a>'
|
||||
|
||||
# The flag is enabled but the course is not Mongo-backed (should not work)
|
||||
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True})
|
||||
def test_email_flag_true_mongo_false(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertFalse(self.email_link in response.content)
|
||||
|
||||
# The flag is disabled and the course is not Mongo-backed (should not work)
|
||||
@patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': False})
|
||||
def test_email_flag_false_mongo_false(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertFalse(self.email_link in response.content)
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Unit tests for email feature flag in instructor dashboard
|
||||
Unit tests for email feature flag in legacy instructor dashboard
|
||||
and student dashboard. Additionally tests that bulk email
|
||||
is always disabled for non-Mongo backed courses, regardless
|
||||
of email feature flag.
|
||||
|
||||
@@ -9,7 +9,6 @@ Many of these GETs may become PUTs in the future.
|
||||
import re
|
||||
import logging
|
||||
import requests
|
||||
from collections import OrderedDict
|
||||
from django.conf import settings
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from django.views.decorators.cache import cache_control
|
||||
@@ -40,6 +39,10 @@ import analytics.distributions
|
||||
import analytics.csvs
|
||||
import csv
|
||||
|
||||
from bulk_email.models import CourseEmail
|
||||
from html_to_text import html_to_text
|
||||
from bulk_email import tasks
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -95,7 +98,44 @@ def require_query_params(*args, **kwargs):
|
||||
for (param, extra) in required_params:
|
||||
default = object()
|
||||
if request.GET.get(param, default) == default:
|
||||
error_response_data['parameters'] += [param]
|
||||
error_response_data['parameters'].append(param)
|
||||
error_response_data['info'][param] = extra
|
||||
|
||||
if len(error_response_data['parameters']) > 0:
|
||||
return JsonResponse(error_response_data, status=400)
|
||||
else:
|
||||
return func(*args, **kwargs)
|
||||
return wrapped
|
||||
return decorator
|
||||
|
||||
|
||||
def require_post_params(*args, **kwargs):
|
||||
"""
|
||||
Checks for required parameters or renders a 400 error.
|
||||
(decorator with arguments)
|
||||
|
||||
Functions like 'require_query_params', but checks for
|
||||
POST parameters rather than GET parameters.
|
||||
"""
|
||||
required_params = []
|
||||
required_params += [(arg, None) for arg in args]
|
||||
required_params += [(key, kwargs[key]) for key in kwargs]
|
||||
# required_params = e.g. [('action', 'enroll or unenroll'), ['emails', None]]
|
||||
|
||||
def decorator(func): # pylint: disable=C0111
|
||||
def wrapped(*args, **kwargs): # pylint: disable=C0111
|
||||
request = args[0]
|
||||
|
||||
error_response_data = {
|
||||
'error': 'Missing required query parameter(s)',
|
||||
'parameters': [],
|
||||
'info': {},
|
||||
}
|
||||
|
||||
for (param, extra) in required_params:
|
||||
default = object()
|
||||
if request.POST.get(param, default) == default:
|
||||
error_response_data['parameters'].append(param)
|
||||
error_response_data['info'][param] = extra
|
||||
|
||||
if len(error_response_data['parameters']) > 0:
|
||||
@@ -397,7 +437,7 @@ def get_anon_ids(request, course_id): # pylint: disable=W0613
|
||||
students = User.objects.filter(
|
||||
courseenrollment__course_id=course_id,
|
||||
).order_by('id')
|
||||
header =['User ID', 'Anonymized user ID']
|
||||
header = ['User ID', 'Anonymized user ID']
|
||||
rows = [[s.id, unique_id_for_user(s)] for s in students]
|
||||
return csv_response(course_id.replace('/', '-') + '-anon-ids.csv', header, rows)
|
||||
|
||||
@@ -706,6 +746,38 @@ def list_forum_members(request, course_id):
|
||||
return JsonResponse(response_payload)
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
@require_level('staff')
|
||||
@require_post_params(send_to="sending to whom", subject="subject line", message="message text")
|
||||
def send_email(request, course_id):
|
||||
"""
|
||||
Send an email to self, staff, or everyone involved in a course.
|
||||
Query Parameters:
|
||||
- 'send_to' specifies what group the email should be sent to
|
||||
Options are defined by the Email model in
|
||||
lms/djangoapps/bulk_email/models.py
|
||||
- 'subject' specifies email's subject
|
||||
- 'message' specifies email's content
|
||||
"""
|
||||
send_to = request.POST.get("send_to")
|
||||
subject = request.POST.get("subject")
|
||||
message = request.POST.get("message")
|
||||
text_message = html_to_text(message)
|
||||
email = CourseEmail(
|
||||
course_id=course_id,
|
||||
sender=request.user,
|
||||
to_option=send_to,
|
||||
subject=subject,
|
||||
html_message=message,
|
||||
text_message=text_message,
|
||||
)
|
||||
email.save()
|
||||
tasks.delegate_email_batches.delay(email.id, request.user.id) # pylint: disable=E1101
|
||||
response_payload = {'course_id': course_id}
|
||||
return JsonResponse(response_payload)
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
@require_level('staff')
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
Instructor API endpoint urls.
|
||||
"""
|
||||
|
||||
|
||||
from django.conf.urls import patterns, url
|
||||
|
||||
urlpatterns = patterns('', # nopep8
|
||||
@@ -34,4 +33,6 @@ urlpatterns = patterns('', # nopep8
|
||||
'instructor.views.api.update_forum_role_membership', name="update_forum_role_membership"),
|
||||
url(r'^proxy_legacy_analytics$',
|
||||
'instructor.views.api.proxy_legacy_analytics', name="proxy_legacy_analytics"),
|
||||
url(r'^send_email$',
|
||||
'instructor.views.api.send_email', name="send_email")
|
||||
)
|
||||
|
||||
@@ -11,20 +11,25 @@ from django.utils.html import escape
|
||||
from django.http import Http404
|
||||
from django.conf import settings
|
||||
|
||||
from xmodule_modifiers import wrap_xmodule
|
||||
from xmodule.html_module import HtmlDescriptor
|
||||
from xmodule.modulestore import MONGO_MODULESTORE_TYPE
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xblock.field_data import DictFieldData
|
||||
from xblock.fields import ScopeIds
|
||||
from courseware.access import has_access
|
||||
from courseware.courses import get_course_by_id
|
||||
from django_comment_client.utils import has_forum_access
|
||||
from django_comment_common.models import FORUM_ROLE_ADMINISTRATOR
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from student.models import CourseEnrollment
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def instructor_dashboard_2(request, course_id):
|
||||
""" Display the instructor dashboard for a course. """
|
||||
|
||||
course = get_course_by_id(course_id, depth=None)
|
||||
is_studio_course = (modulestore().get_modulestore_type(course_id) == MONGO_MODULESTORE_TYPE)
|
||||
|
||||
access = {
|
||||
'admin': request.user.is_staff,
|
||||
@@ -47,12 +52,13 @@ def instructor_dashboard_2(request, course_id):
|
||||
]
|
||||
|
||||
enrollment_count = sections[0]['enrollment_count']
|
||||
|
||||
disable_buttons = False
|
||||
max_enrollment_for_buttons = settings.MITX_FEATURES.get("MAX_ENROLLMENT_INSTR_BUTTONS")
|
||||
if max_enrollment_for_buttons is not None:
|
||||
disable_buttons = enrollment_count > max_enrollment_for_buttons
|
||||
|
||||
if settings.MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] and is_studio_course:
|
||||
sections.append(_section_send_email(course_id, access, course))
|
||||
|
||||
context = {
|
||||
'course': course,
|
||||
@@ -131,6 +137,7 @@ def _section_student_admin(course_id, access):
|
||||
'section_display_name': _('Student Admin'),
|
||||
'access': access,
|
||||
'get_student_progress_url_url': reverse('get_student_progress_url', kwargs={'course_id': course_id}),
|
||||
'enrollment_url': reverse('students_update_enrollment', kwargs={'course_id': course_id}),
|
||||
'reset_student_attempts_url': reverse('reset_student_attempts', kwargs={'course_id': course_id}),
|
||||
'rescore_problem_url': reverse('rescore_problem', kwargs={'course_id': course_id}),
|
||||
'list_instructor_tasks_url': reverse('list_instructor_tasks', kwargs={'course_id': course_id}),
|
||||
@@ -150,6 +157,22 @@ def _section_data_download(course_id):
|
||||
return section_data
|
||||
|
||||
|
||||
def _section_send_email(course_id, access, course):
|
||||
""" Provide data for the corresponding bulk email section """
|
||||
html_module = HtmlDescriptor(course.system, DictFieldData({'data': ''}), ScopeIds(None, None, None, None))
|
||||
fragment = course.system.render(html_module, 'studio_view')
|
||||
fragment = wrap_xmodule('xmodule_edit.html', html_module, 'studio_view', fragment, None)
|
||||
email_editor = fragment.content
|
||||
section_data = {
|
||||
'section_key': 'send_email',
|
||||
'section_display_name': _('Email'),
|
||||
'access': access,
|
||||
'send_email': reverse('send_email', kwargs={'course_id': course_id}),
|
||||
'editor': email_editor
|
||||
}
|
||||
return section_data
|
||||
|
||||
|
||||
def _section_analytics(course_id):
|
||||
""" Provide data for the corresponding dashboard section """
|
||||
section_data = {
|
||||
|
||||
@@ -62,7 +62,6 @@ from bulk_email.models import CourseEmail
|
||||
from html_to_text import html_to_text
|
||||
from bulk_email import tasks
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# internal commands for managing forum roles:
|
||||
|
||||
@@ -80,6 +80,8 @@ TRACKING_BACKENDS.update({
|
||||
}
|
||||
})
|
||||
|
||||
DEFAULT_BULK_FROM_EMAIL = "test@test.org"
|
||||
|
||||
# Forums are disabled in test.py to speed up unit tests, but we do not have
|
||||
# per-test control for acceptance tests
|
||||
MITX_FEATURES['ENABLE_DISCUSSION_SERVICE'] = True
|
||||
@@ -94,6 +96,9 @@ MITX_FEATURES['AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'] = True
|
||||
# Enable fake payment processing page
|
||||
MITX_FEATURES['ENABLE_PAYMENT_FAKE'] = True
|
||||
|
||||
# Enable email on the instructor dash
|
||||
MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] = True
|
||||
|
||||
# Configure the payment processor to use the fake processing page
|
||||
# Since both the fake payment page and the shoppingcart app are using
|
||||
# the same settings, we can generate this randomly and guarantee
|
||||
|
||||
@@ -29,6 +29,7 @@ MITX_FEATURES['ENABLE_MANUAL_GIT_RELOAD'] = True
|
||||
MITX_FEATURES['ENABLE_PSYCHOMETRICS'] = False # real-time psychometrics (eg item response theory analysis in instructor dashboard)
|
||||
MITX_FEATURES['ENABLE_INSTRUCTOR_ANALYTICS'] = True
|
||||
MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True
|
||||
MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] = True
|
||||
MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True
|
||||
MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = True
|
||||
MITX_FEATURES['MULTIPLE_ENROLLMENT_ROLES'] = True
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# Analytics Section
|
||||
###
|
||||
Analytics Section
|
||||
|
||||
imports from other modules.
|
||||
wrap in (-> ... apply) to defer evaluation
|
||||
such that the value can be defined later than this assignment (file load order).
|
||||
###
|
||||
|
||||
# imports from other modules.
|
||||
# wrap in (-> ... apply) to defer evaluation
|
||||
# such that the value can be defined later than this assignment (file load order).
|
||||
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
|
||||
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
# Course Info Section
|
||||
# This is the implementation of the simplest section
|
||||
# of the instructor dashboard.
|
||||
###
|
||||
Course Info Section
|
||||
This is the implementation of the simplest section
|
||||
of the instructor dashboard.
|
||||
|
||||
imports from other modules.
|
||||
wrap in (-> ... apply) to defer evaluation
|
||||
such that the value can be defined later than this assignment (file load order).
|
||||
###
|
||||
|
||||
# imports from other modules.
|
||||
# wrap in (-> ... apply) to defer evaluation
|
||||
# such that the value can be defined later than this assignment (file load order).
|
||||
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
|
||||
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# Data Download Section
|
||||
###
|
||||
Data Download Section
|
||||
|
||||
imports from other modules.
|
||||
wrap in (-> ... apply) to defer evaluation
|
||||
such that the value can be defined later than this assignment (file load order).
|
||||
###
|
||||
|
||||
# imports from other modules.
|
||||
# wrap in (-> ... apply) to defer evaluation
|
||||
# such that the value can be defined later than this assignment (file load order).
|
||||
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
|
||||
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
|
||||
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
# Instructor Dashboard Tab Manager
|
||||
# The instructor dashboard is broken into sections.
|
||||
# Only one section is visible at a time,
|
||||
# and is responsible for its own functionality.
|
||||
#
|
||||
# NOTE: plantTimeout (which is just setTimeout from util.coffee)
|
||||
# is used frequently in the instructor dashboard to isolate
|
||||
# failures. If one piece of code under a plantTimeout fails
|
||||
# then it will not crash the rest of the dashboard.
|
||||
#
|
||||
# NOTE: The instructor dashboard currently does not
|
||||
# use backbone. Just lots of jquery. This should be fixed.
|
||||
#
|
||||
# NOTE: Server endpoints in the dashboard are stored in
|
||||
# the 'data-endpoint' attribute of relevant html elements.
|
||||
# The urls are rendered there by a template.
|
||||
#
|
||||
# NOTE: For an example of what a section object should look like
|
||||
# see course_info.coffee
|
||||
###
|
||||
Instructor Dashboard Tab Manager
|
||||
|
||||
The instructor dashboard is broken into sections.
|
||||
|
||||
Only one section is visible at a time,
|
||||
and is responsible for its own functionality.
|
||||
|
||||
NOTE: plantTimeout (which is just setTimeout from util.coffee)
|
||||
is used frequently in the instructor dashboard to isolate
|
||||
failures. If one piece of code under a plantTimeout fails
|
||||
then it will not crash the rest of the dashboard.
|
||||
|
||||
NOTE: The instructor dashboard currently does not
|
||||
use backbone. Just lots of jquery. This should be fixed.
|
||||
|
||||
NOTE: Server endpoints in the dashboard are stored in
|
||||
the 'data-endpoint' attribute of relevant html elements.
|
||||
The urls are rendered there by a template.
|
||||
|
||||
NOTE: For an example of what a section object should look like
|
||||
see course_info.coffee
|
||||
|
||||
imports from other modules
|
||||
wrap in (-> ... apply) to defer evaluation
|
||||
such that the value can be defined later than this assignment (file load order).
|
||||
###
|
||||
|
||||
# imports from other modules
|
||||
# wrap in (-> ... apply) to defer evaluation
|
||||
# such that the value can be defined later than this assignment (file load order).
|
||||
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
|
||||
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
|
||||
|
||||
@@ -156,6 +161,9 @@ setup_instructor_dashboard_sections = (idash_content) ->
|
||||
,
|
||||
constructor: window.InstructorDashboard.sections.StudentAdmin
|
||||
$element: idash_content.find ".#{CSS_IDASH_SECTION}#student_admin"
|
||||
,
|
||||
constructor: window.InstructorDashboard.sections.Email
|
||||
$element: idash_content.find ".#{CSS_IDASH_SECTION}#send_email"
|
||||
,
|
||||
constructor: window.InstructorDashboard.sections.Analytics
|
||||
$element: idash_content.find ".#{CSS_IDASH_SECTION}#analytics"
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# Membership Section
|
||||
###
|
||||
Membership Section
|
||||
|
||||
imports from other modules.
|
||||
wrap in (-> ... apply) to defer evaluation
|
||||
such that the value can be defined later than this assignment (file load order).
|
||||
###
|
||||
|
||||
# imports from other modules.
|
||||
# wrap in (-> ... apply) to defer evaluation
|
||||
# such that the value can be defined later than this assignment (file load order).
|
||||
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
|
||||
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
|
||||
|
||||
|
||||
101
lms/static/coffee/src/instructor_dashboard/send_email.coffee
Normal file
101
lms/static/coffee/src/instructor_dashboard/send_email.coffee
Normal file
@@ -0,0 +1,101 @@
|
||||
###
|
||||
Email Section
|
||||
|
||||
imports from other modules.
|
||||
wrap in (-> ... apply) to defer evaluation
|
||||
such that the value can be defined later than this assignment (file load order).
|
||||
###
|
||||
|
||||
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
|
||||
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
|
||||
|
||||
class SendEmail
|
||||
constructor: (@$container) ->
|
||||
# gather elements
|
||||
@$emailEditor = XModule.loadModule($('.xmodule_edit'));
|
||||
@$send_to = @$container.find("select[name='send_to']'")
|
||||
@$subject = @$container.find("input[name='subject']'")
|
||||
@$btn_send = @$container.find("input[name='send']'")
|
||||
@$task_response = @$container.find(".request-response")
|
||||
@$request_response_error = @$container.find(".request-response-error")
|
||||
|
||||
# attach click handlers
|
||||
|
||||
@$btn_send.click =>
|
||||
if @$subject.val() == ""
|
||||
alert gettext("Your message must have a subject.")
|
||||
else if @$emailEditor.save()['data'] == ""
|
||||
alert gettext("Your message cannot be blank.")
|
||||
else
|
||||
success_message = gettext("Your email was successfully queued for sending.")
|
||||
send_to = @$send_to.val().toLowerCase()
|
||||
if send_to == "myself"
|
||||
send_to = gettext("yourself")
|
||||
else if send_to == "staff"
|
||||
send_to = gettext("everyone who is staff or instructor on this course")
|
||||
else
|
||||
send_to = gettext("ALL (everyone who is enrolled in this course as student, staff, or instructor)")
|
||||
success_message = gettext("Your email was successfully queued for sending. Please note that for large public classes (~10k), it may take 1-2 hours to send all emails.")
|
||||
subject = gettext(@$subject.val())
|
||||
confirm_message = gettext("You are about to send an email titled \"#{subject}\" to #{send_to}. Is this OK?")
|
||||
if confirm confirm_message
|
||||
|
||||
send_data =
|
||||
action: 'send'
|
||||
send_to: @$send_to.val()
|
||||
subject: @$subject.val()
|
||||
message: @$emailEditor.save()['data']
|
||||
|
||||
$.ajax
|
||||
type: 'POST'
|
||||
dataType: 'json'
|
||||
url: @$btn_send.data 'endpoint'
|
||||
data: send_data
|
||||
success: (data) =>
|
||||
@display_response success_message
|
||||
|
||||
error: std_ajax_err =>
|
||||
@fail_with_error gettext('Error sending email.')
|
||||
|
||||
else
|
||||
@$task_response.empty()
|
||||
@$request_response_error.empty()
|
||||
|
||||
fail_with_error: (msg) ->
|
||||
console.warn msg
|
||||
@$task_response.empty()
|
||||
@$request_response_error.empty()
|
||||
@$request_response_error.text gettext(msg)
|
||||
$(".msg-confirm").css({"display":"none"})
|
||||
|
||||
display_response: (data_from_server) ->
|
||||
@$task_response.empty()
|
||||
@$request_response_error.empty()
|
||||
@$task_response.text(data_from_server)
|
||||
$(".msg-confirm").css({"display":"block"})
|
||||
|
||||
|
||||
# Email Section
|
||||
class Email
|
||||
# enable subsections.
|
||||
constructor: (@$section) ->
|
||||
# attach self to html
|
||||
# so that instructor_dashboard.coffee can find this object
|
||||
# to call event handlers like 'onClickTitle'
|
||||
@$section.data 'wrapper', @
|
||||
|
||||
# isolate # initialize SendEmail subsection
|
||||
plantTimeout 0, => new SendEmail @$section.find '.send-email'
|
||||
|
||||
# handler for when the section title is clicked.
|
||||
onClickTitle: ->
|
||||
|
||||
|
||||
# export for use
|
||||
# create parent namespaces if they do not already exist.
|
||||
# abort if underscore can not be found.
|
||||
if _?
|
||||
_.defaults window, InstructorDashboard: {}
|
||||
_.defaults window.InstructorDashboard, sections: {}
|
||||
_.defaults window.InstructorDashboard.sections,
|
||||
Email: Email
|
||||
@@ -1,8 +1,11 @@
|
||||
# Student Admin Section
|
||||
###
|
||||
Student Admin Section
|
||||
|
||||
imports from other modules.
|
||||
wrap in (-> ... apply) to defer evaluation
|
||||
such that the value can be defined later than this assignment (file load order).
|
||||
###
|
||||
|
||||
# imports from other modules.
|
||||
# wrap in (-> ... apply) to defer evaluation
|
||||
# such that the value can be defined later than this assignment (file load order).
|
||||
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
|
||||
plantInterval = -> window.InstructorDashboard.util.plantInterval.apply this, arguments
|
||||
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
|
||||
|
||||
@@ -22,8 +22,9 @@
|
||||
// system feedback - messages
|
||||
.msg {
|
||||
border-radius: 1px;
|
||||
padding: 10px 15px;
|
||||
margin-bottom: 20px;
|
||||
padding: $baseline/2 $baseline*0.75;
|
||||
margin-bottom: $baseline;
|
||||
font-weight: 600;
|
||||
|
||||
.copy {
|
||||
font-weight: 600;
|
||||
@@ -44,10 +45,8 @@
|
||||
.msg-confirm {
|
||||
border-top: 2px solid $confirm-color;
|
||||
background: tint($confirm-color,95%);
|
||||
|
||||
.copy {
|
||||
color: $confirm-color;
|
||||
}
|
||||
display: none;
|
||||
color: $confirm-color;
|
||||
}
|
||||
|
||||
// TYPE: confirm
|
||||
@@ -60,8 +59,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// inline copy
|
||||
.copy-confirm {
|
||||
color: $confirm-color;
|
||||
@@ -78,7 +75,7 @@
|
||||
.list-advice {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 20px 0;
|
||||
margin: $baseline 0;
|
||||
|
||||
.item {
|
||||
font-weight: 600;
|
||||
@@ -181,7 +178,6 @@ section.instructor-dashboard-content-2 {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.instructor-dashboard-wrapper-2 section.idash-section#course_info {
|
||||
.course-errors-wrapper {
|
||||
margin-top: 2em;
|
||||
@@ -244,6 +240,24 @@ section.instructor-dashboard-content-2 {
|
||||
}
|
||||
}
|
||||
|
||||
.instructor-dashboard-wrapper-2 section.idash-section#send_email {
|
||||
// form fields
|
||||
.list-fields {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
.field {
|
||||
margin-bottom: $baseline;
|
||||
padding: 0;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.instructor-dashboard-wrapper-2 section.idash-section#membership {
|
||||
$half_width: $baseline * 20;
|
||||
@@ -541,3 +555,7 @@ section.instructor-dashboard-content-2 {
|
||||
right: $baseline;
|
||||
}
|
||||
}
|
||||
|
||||
input[name="subject"] {
|
||||
width:600px;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@
|
||||
<script type="text/javascript" src="${static.url('js/vendor/slick.grid.js')}"></script>
|
||||
<link rel="stylesheet" href="${static.url('css/vendor/slickgrid/smoothness/jquery-ui-1.8.16.custom.css')}">
|
||||
<link rel="stylesheet" href="${static.url('css/vendor/slickgrid/slick.grid.css')}">
|
||||
<script type="text/javascript" src="${static.url('js/vendor/CodeMirror/htmlmixed.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/CodeMirror/css.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/codemirror-compressed.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/tiny_mce/tiny_mce.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/tiny_mce/jquery.tinymce.js')}"></script>
|
||||
<%static:js group='module-descriptor-js'/>
|
||||
</%block>
|
||||
|
||||
## NOTE that instructor is set as the active page so that the instructor button lights up, even though this is the instructor_2 page.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
<%page args="section_data"/>
|
||||
|
||||
<div class="vert-left send-email" id="section-send-email">
|
||||
<h2> ${_("Send Email")} </h2>
|
||||
<div class="request-response msg msg-confirm copy" id="request-response"></div>
|
||||
<ul class="list-fields">
|
||||
<li class="field">
|
||||
<label for="id_to">${_("Send to:")}</label><br/>
|
||||
<select id="id_to" name="send_to">
|
||||
<option value="myself">${_("Myself")}</option>
|
||||
%if to_option == "staff":
|
||||
<option value="staff" selected="selected">${_("Staff and instructors")}</option>
|
||||
%else:
|
||||
<option value="staff">${_("Staff and instructors")}</option>
|
||||
%endif
|
||||
%if to_option == "all":
|
||||
<option value="all" selected="selected">${_("All (students, staff and instructors)")}</option>
|
||||
%else:
|
||||
<option value="all">${_("All (students, staff and instructors)")}</option>
|
||||
%endif
|
||||
</select>
|
||||
</li>
|
||||
<br/>
|
||||
<li class="field">
|
||||
<label for="id_subject">${_("Subject: ")}</label><br/>
|
||||
<input type="text" id="id_subject" name="subject">
|
||||
</li>
|
||||
<li class="field">
|
||||
<label>Message:</label>
|
||||
<div class="email-editor">
|
||||
${ section_data['editor'] }
|
||||
</div>
|
||||
<input type="hidden" name="message" value="">
|
||||
</li>
|
||||
</ul>
|
||||
<div class="submit-email-action">
|
||||
${_("Please try not to email students more than once a day. Before sending your email, consider:")}
|
||||
<ul class="list-advice">
|
||||
<li class="item">${_("Have you read over the email to make sure it says everything you want to say?")}</li>
|
||||
<li class="item">${_("Have you sent the email to yourself first to make sure you're happy with how it's displayed?")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<input type="button" name="send" value="${_("Send Email")}" data-endpoint="${ section_data['send_email'] }" >
|
||||
<div class="request-response-error"></div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user