Commit f397e661 by Julia Hansbrough

Merge pull request #1215 from edx/flowerhack/feature/bulkemailnewdash_testingcoverage

Flowerhack/feature/bulkemailnewdash testingcoverage
parents 92a7d75b c18f4509
......@@ -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
......@@ -51,6 +51,21 @@ def register_by_course_id(course_id, username='robot', password='test', is_staff
@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
# Note that if your test module gets in some weird state
......
@shard_2
Feature: Bulk Email
As an instructor or course staff,
In order to communicate with students and staff
I want to send email to staff and students in a course.
Scenario: Send bulk email
Given I am "<Role>" for a course
When I send email to "<Recipient>"
Then Email is sent to "<Recipient>"
Examples:
| Role | Recipient |
| instructor | myself |
| instructor | course staff |
| instructor | students, staff, and instructors |
| staff | myself |
| staff | course staff |
| staff | students, staff, and instructors |
"""
Define steps for bulk email acceptance test.
"""
#pylint: disable=C0111
#pylint: disable=W0621
from lettuce import world, step
from lettuce.django import mail
from nose.tools import assert_in, assert_true, assert_equal # pylint: disable=E0611
from django.core.management import call_command
from django.conf import settings
@step(u'I am an instructor for a course')
def i_am_an_instructor(step): # pylint: disable=W0613
# Clear existing courses to avoid conflicts
world.clear_courses()
# Register the instructor as staff for the course
world.register_by_course_id(
'edx/999/Test_Course',
username='instructor',
password='password',
is_staff=True
)
world.add_to_course_staff('instructor', '999')
# Register another staff member
world.register_by_course_id(
'edx/999/Test_Course',
username='staff',
password='password',
is_staff=True
)
world.add_to_course_staff('staff', '999')
# Register a student
world.register_by_course_id(
'edx/999/Test_Course',
username='student',
password='password',
is_staff=False
)
# Log in as the instructor for the course
world.log_in(
username='instructor',
password='password',
email="instructor@edx.org",
name="Instructor"
)
# Dictionary mapping a description of the email recipient
# to the corresponding <option> value in the UI.
SEND_TO_OPTIONS = {
'myself': 'myself',
'course staff': 'staff',
'students, staff, and instructors': 'all'
}
@step(u'I send email to "([^"]*)"')
def when_i_send_an_email(recipient):
# Check that the recipient is valid
assert_in(
recipient, SEND_TO_OPTIONS,
msg="Invalid recipient: {}".format(recipient)
)
# Because we flush the database before each run,
# we need to ensure that the email template fixture
# is re-loaded into the database
call_command('loaddata', 'course_email_template.json')
# Go to the email section of the instructor dash
world.visit('/courses/edx/999/Test_Course')
world.css_click('a[href="/courses/edx/999/Test_Course/instructor"]')
world.css_click('div.beta-button-wrapper>a')
world.css_click('a[data-section="send_email"]')
# Select the recipient
world.select_option('send_to', SEND_TO_OPTIONS[recipient])
# Enter subject and message
world.css_fill('input#id_subject', 'Hello')
with world.browser.get_iframe('mce_0_ifr') as iframe:
editor = iframe.find_by_id('tinymce')[0]
editor.fill('test message')
# Click send
world.css_click('input[name="send"]')
# Expect to see a message that the email was sent
expected_msg = "Your email was successfully queued for sending."
assert_true(
world.css_has_text('div.request-response', expected_msg, '#request-response', allow_blank=False),
msg="Could not find email success message."
)
# Dictionaries mapping description of email recipient
# to the expected recipient email addresses
EXPECTED_ADDRESSES = {
'myself': ['instructor@edx.org'],
'course staff': ['instructor@edx.org', 'staff@edx.org'],
'students, staff, and instructors': ['instructor@edx.org', 'staff@edx.org', 'student@edx.org']
}
UNSUBSCRIBE_MSG = 'To stop receiving email like this'
@step(u'Email is sent to "([^"]*)"')
def then_the_email_is_sent(recipient):
# Check that the recipient is valid
assert_in(
recipient, SEND_TO_OPTIONS,
msg="Invalid recipient: {}".format(recipient)
)
# Retrieve messages. Because we are using celery in "always eager"
# mode, we expect all messages to be sent by this point.
messages = []
while not mail.queue.empty(): # pylint: disable=E1101
messages.append(mail.queue.get()) # pylint: disable=E1101
# Check that we got the right number of messages
assert_equal(
len(messages), len(EXPECTED_ADDRESSES[recipient]),
msg="Received {0} instead of {1} messages for {2}".format(
len(messages), len(EXPECTED_ADDRESSES[recipient]), recipient
)
)
# Check that the message properties were correct
recipients = []
for msg in messages:
assert_in('Hello', msg.subject)
assert_in(settings.DEFAULT_BULK_FROM_EMAIL, msg.from_email)
# Message body should have the message we sent
# and an unsubscribe message
assert_in('test message', msg.body)
assert_in(UNSUBSCRIBE_MSG, msg.body)
# Should have alternative HTML form
assert_equal(len(msg.alternatives), 1)
content = msg.alternatives[0]
assert_in('test message', content)
assert_in(UNSUBSCRIBE_MSG, content)
# Store the recipient address so we can verify later
recipients.extend(msg.recipients())
# Check that the messages were sent to the right people
for addr in EXPECTED_ADDRESSES[recipient]:
assert_in(addr, recipients)
......@@ -6,7 +6,6 @@ import unittest
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)
......
"""
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)
"""
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)
......@@ -709,6 +749,38 @@ def list_forum_members(request, course_id):
@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')
@require_query_params(
email="the target users email",
rolename="the forum role",
......
......@@ -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
......
# 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
......
# 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
......
# 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
......
# 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).
###
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).
###
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
......@@ -157,6 +162,9 @@ setup_instructor_dashboard_sections = (idash_content) ->
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"
]
......
# 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
......
###
Email Section
imports from other modules.
wrap in (-> ... apply) to defer evaluation
such that the value can be defined later than this assignment (file load order).
###
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments
class SendEmail
constructor: (@$container) ->
# gather elements
@$emailEditor = XModule.loadModule($('.xmodule_edit'));
@$send_to = @$container.find("select[name='send_to']'")
@$subject = @$container.find("input[name='subject']'")
@$btn_send = @$container.find("input[name='send']'")
@$task_response = @$container.find(".request-response")
@$request_response_error = @$container.find(".request-response-error")
# attach click handlers
@$btn_send.click =>
if @$subject.val() == ""
alert gettext("Your message must have a subject.")
else if @$emailEditor.save()['data'] == ""
alert gettext("Your message cannot be blank.")
else
success_message = gettext("Your email was successfully queued for sending.")
send_to = @$send_to.val().toLowerCase()
if send_to == "myself"
send_to = gettext("yourself")
else if send_to == "staff"
send_to = gettext("everyone who is staff or instructor on this course")
else
send_to = gettext("ALL (everyone who is enrolled in this course as student, staff, or instructor)")
success_message = gettext("Your email was successfully queued for sending. Please note that for large public classes (~10k), it may take 1-2 hours to send all emails.")
subject = gettext(@$subject.val())
confirm_message = gettext("You are about to send an email titled \"#{subject}\" to #{send_to}. Is this OK?")
if confirm confirm_message
send_data =
action: 'send'
send_to: @$send_to.val()
subject: @$subject.val()
message: @$emailEditor.save()['data']
$.ajax
type: 'POST'
dataType: 'json'
url: @$btn_send.data 'endpoint'
data: send_data
success: (data) =>
@display_response success_message
error: std_ajax_err =>
@fail_with_error gettext('Error sending email.')
else
@$task_response.empty()
@$request_response_error.empty()
fail_with_error: (msg) ->
console.warn msg
@$task_response.empty()
@$request_response_error.empty()
@$request_response_error.text gettext(msg)
$(".msg-confirm").css({"display":"none"})
display_response: (data_from_server) ->
@$task_response.empty()
@$request_response_error.empty()
@$task_response.text(data_from_server)
$(".msg-confirm").css({"display":"block"})
# Email Section
class Email
# enable subsections.
constructor: (@$section) ->
# attach self to html
# so that instructor_dashboard.coffee can find this object
# to call event handlers like 'onClickTitle'
@$section.data 'wrapper', @
# isolate # initialize SendEmail subsection
plantTimeout 0, => new SendEmail @$section.find '.send-email'
# handler for when the section title is clicked.
onClickTitle: ->
# export for use
# create parent namespaces if they do not already exist.
# abort if underscore can not be found.
if _?
_.defaults window, InstructorDashboard: {}
_.defaults window.InstructorDashboard, sections: {}
_.defaults window.InstructorDashboard.sections,
Email: Email
# Student Admin Section
###
Student Admin Section
imports from other modules.
wrap in (-> ... apply) to defer evaluation
such that the value can be defined later than this assignment (file load order).
###
# imports from other modules.
# wrap in (-> ... apply) to defer evaluation
# such that the value can be defined later than this assignment (file load order).
plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments
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.
......
<%! 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>
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