Commit a9fc7aca by Sarina Canelake

Remove Legacy Instructor Dashboard

parent 029e1397
#!/usr/bin/python
"""
django management command: dump grades to csv files
for use by batch processes
"""
import csv
from instructor.views.legacy import get_student_grade_summary_data
from courseware.courses import get_course_by_id
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from django.core.management.base import BaseCommand
from instructor.utils import DummyRequest
class Command(BaseCommand):
help = "dump grades to CSV file. Usage: dump_grades course_id_or_dir filename dump_type\n"
help += " course_id_or_dir: either course_id or course_dir\n"
help += " filename: where the output CSV is to be stored\n"
# help += " start_date: end date as M/D/Y H:M (defaults to end of available data)"
help += " dump_type: 'all' or 'raw' (see instructor dashboard)"
def handle(self, *args, **options):
# current grading logic and data schema doesn't handle dates
# datetime.strptime("21/11/06 16:30", "%m/%d/%y %H:%M")
print "args = ", args
course_id = 'MITx/8.01rq_MW/Classical_Mechanics_Reading_Questions_Fall_2012_MW_Section'
fn = "grades.csv"
get_raw_scores = False
if len(args) > 0:
course_id = args[0]
if len(args) > 1:
fn = args[1]
if len(args) > 2:
get_raw_scores = args[2].lower() == 'raw'
request = DummyRequest()
# parse out the course into a coursekey
try:
course_key = CourseKey.from_string(course_id)
# if it's not a new-style course key, parse it from an old-style
# course key
except InvalidKeyError:
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
try:
course = get_course_by_id(course_key)
# Ok with catching general exception here because this is run as a management command
# and the exception is exposed right away to the user.
except Exception as err: # pylint: disable=broad-except
print "-----------------------------------------------------------------------------"
print "Sorry, cannot find course with id {}".format(course_id)
print "Got exception {}".format(err)
print "Please provide a course ID or course data directory name, eg content-mit-801rq"
return
print "-----------------------------------------------------------------------------"
print "Dumping grades from {} to file {} (get_raw_scores={})".format(course.id, fn, get_raw_scores)
datatable = get_student_grade_summary_data(request, course, get_raw_scores=get_raw_scores)
fp = open(fn, 'w')
writer = csv.writer(fp, dialect='excel', quotechar='"', quoting=csv.QUOTE_ALL)
writer.writerow([unicode(s).encode('utf-8') for s in datatable['header']])
for datarow in datatable['data']:
encoded_row = [unicode(s).encode('utf-8') for s in datarow]
writer.writerow(encoded_row)
fp.close()
print "Done: {} records dumped".format(len(datatable['data']))
"""
Unit tests for enrollment methods in views.py
"""
import ddt
from mock import patch
from nose.plugins.attrib import attr
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from courseware.tests.helpers import LoginEnrollmentTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from student.tests.factories import UserFactory, CourseEnrollmentFactory, AdminFactory
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from student.models import CourseEnrollment, CourseEnrollmentAllowed
from instructor.views.legacy import get_and_clean_student_list, send_mail_to_student
from django.core import mail
USER_COUNT = 4
@attr('shard_1')
@ddt.ddt
class TestInstructorEnrollsStudent(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
"""
Check Enrollment/Unenrollment with/without auto-enrollment on activation and with/without email notification
"""
@classmethod
def setUpClass(cls):
super(TestInstructorEnrollsStudent, cls).setUpClass()
cls.course = CourseFactory.create()
def setUp(self):
super(TestInstructorEnrollsStudent, self).setUp()
instructor = AdminFactory.create()
self.client.login(username=instructor.username, password='test')
self.users = [
UserFactory.create(username="student%d" % i, email="student%d@test.com" % i)
for i in xrange(USER_COUNT)
]
for user in self.users:
CourseEnrollmentFactory.create(user=user, course_id=self.course.id)
# Empty the test outbox
mail.outbox = []
def test_unenrollment_email_off(self):
"""
Do un-enrollment email off test
"""
course = self.course
# Run the Un-enroll students command
url = reverse('instructor_dashboard_legacy', kwargs={'course_id': course.id.to_deprecated_string()})
response = self.client.post(
url,
{
'action': 'Unenroll multiple students',
'multiple_students': 'student0@test.com student1@test.com'
}
)
# Check the page output
self.assertContains(response, '<td>student0@test.com</td>')
self.assertContains(response, '<td>student1@test.com</td>')
self.assertContains(response, '<td>un-enrolled</td>')
# Check the enrollment table
user = User.objects.get(email='student0@test.com')
self.assertFalse(CourseEnrollment.is_enrolled(user, course.id))
user = User.objects.get(email='student1@test.com')
self.assertFalse(CourseEnrollment.is_enrolled(user, course.id))
# Check the outbox
self.assertEqual(len(mail.outbox), 0)
def test_enrollment_new_student_autoenroll_on_email_off(self):
"""
Do auto-enroll on, email off test
"""
course = self.course
# Run the Enroll students command
url = reverse('instructor_dashboard_legacy', kwargs={'course_id': course.id.to_deprecated_string()})
response = self.client.post(url, {'action': 'Enroll multiple students', 'multiple_students': 'student1_1@test.com, student1_2@test.com', 'auto_enroll': 'on'})
# Check the page output
self.assertContains(response, '<td>student1_1@test.com</td>')
self.assertContains(response, '<td>student1_2@test.com</td>')
self.assertContains(response, '<td>user does not exist, enrollment allowed, pending with auto enrollment on</td>')
# Check the outbox
self.assertEqual(len(mail.outbox), 0)
# Check the enrollmentallowed db entries
cea = CourseEnrollmentAllowed.objects.filter(email='student1_1@test.com', course_id=course.id)
self.assertEqual(1, cea[0].auto_enroll)
cea = CourseEnrollmentAllowed.objects.filter(email='student1_2@test.com', course_id=course.id)
self.assertEqual(1, cea[0].auto_enroll)
# Check there is no enrollment db entry other than for the other students
ce = CourseEnrollment.objects.filter(course_id=course.id, is_active=1)
self.assertEqual(4, len(ce))
# Create and activate student accounts with same email
self.student1 = 'student1_1@test.com'
self.password = 'bar'
self.create_account('s1_1', self.student1, self.password)
self.activate_user(self.student1)
self.student2 = 'student1_2@test.com'
self.create_account('s1_2', self.student2, self.password)
self.activate_user(self.student2)
# Check students are enrolled
user = User.objects.get(email='student1_1@test.com')
self.assertTrue(CourseEnrollment.is_enrolled(user, course.id))
user = User.objects.get(email='student1_2@test.com')
self.assertTrue(CourseEnrollment.is_enrolled(user, course.id))
def test_repeat_enroll(self):
"""
Try to enroll an already enrolled student
"""
course = self.course
url = reverse('instructor_dashboard_legacy', kwargs={'course_id': course.id.to_deprecated_string()})
response = self.client.post(url, {'action': 'Enroll multiple students', 'multiple_students': 'student0@test.com', 'auto_enroll': 'on'})
self.assertContains(response, '<td>student0@test.com</td>')
self.assertContains(response, '<td>already enrolled</td>')
def test_enrollmemt_new_student_autoenroll_off_email_off(self):
"""
Do auto-enroll off, email off test
"""
course = self.course
# Run the Enroll students command
url = reverse('instructor_dashboard_legacy', kwargs={'course_id': course.id.to_deprecated_string()})
response = self.client.post(url, {'action': 'Enroll multiple students', 'multiple_students': 'student2_1@test.com, student2_2@test.com'})
# Check the page output
self.assertContains(response, '<td>student2_1@test.com</td>')
self.assertContains(response, '<td>student2_2@test.com</td>')
self.assertContains(response, '<td>user does not exist, enrollment allowed, pending with auto enrollment off</td>')
# Check the outbox
self.assertEqual(len(mail.outbox), 0)
# Check the enrollmentallowed db entries
cea = CourseEnrollmentAllowed.objects.filter(email='student2_1@test.com', course_id=course.id)
self.assertEqual(0, cea[0].auto_enroll)
cea = CourseEnrollmentAllowed.objects.filter(email='student2_2@test.com', course_id=course.id)
self.assertEqual(0, cea[0].auto_enroll)
# Check there is no enrollment db entry other than for the setup instructor and students
ce = CourseEnrollment.objects.filter(course_id=course.id, is_active=1)
self.assertEqual(4, len(ce))
# Create and activate student accounts with same email
self.student = 'student2_1@test.com'
self.password = 'bar'
self.create_account('s2_1', self.student, self.password)
self.activate_user(self.student)
self.student = 'student2_2@test.com'
self.create_account('s2_2', self.student, self.password)
self.activate_user(self.student)
# Check students are not enrolled
user = User.objects.get(email='student2_1@test.com')
self.assertFalse(CourseEnrollment.is_enrolled(user, course.id))
user = User.objects.get(email='student2_2@test.com')
self.assertFalse(CourseEnrollment.is_enrolled(user, course.id))
def test_get_and_clean_student_list(self):
"""
Clean user input test
"""
string = "abc@test.com, def@test.com ghi@test.com \n \n jkl@test.com \n mno@test.com "
cleaned_string, _cleaned_string_lc = get_and_clean_student_list(string)
self.assertEqual(cleaned_string, ['abc@test.com', 'def@test.com', 'ghi@test.com', 'jkl@test.com', 'mno@test.com'])
@ddt.data('http', 'https')
def test_enrollment_email_on(self, protocol):
"""
Do email on enroll test
"""
course = self.course
# Create activated, but not enrolled, user
UserFactory.create(username="student3_0", email="student3_0@test.com", first_name='Autoenrolled')
url = reverse('instructor_dashboard_legacy', kwargs={'course_id': course.id.to_deprecated_string()})
params = {'action': 'Enroll multiple students', 'multiple_students': 'student3_0@test.com, student3_1@test.com, student3_2@test.com', 'auto_enroll': 'on', 'email_students': 'on'}
environ = {'wsgi.url_scheme': protocol}
response = self.client.post(url, params, **environ)
# Check the page output
self.assertContains(response, '<td>student3_0@test.com</td>')
self.assertContains(response, '<td>student3_1@test.com</td>')
self.assertContains(response, '<td>student3_2@test.com</td>')
self.assertContains(response, '<td>added, email sent</td>')
self.assertContains(response, '<td>user does not exist, enrollment allowed, pending with auto enrollment on, email sent</td>')
# Check the outbox
self.assertEqual(len(mail.outbox), 3)
self.assertEqual(
mail.outbox[0].subject,
'You have been enrolled in {}'.format(course.display_name)
)
self.assertEqual(
mail.outbox[0].body,
"Dear Autoenrolled Test\n\nYou have been enrolled in {} "
"at edx.org by a member of the course staff. "
"The course should now appear on your edx.org dashboard.\n\n"
"To start accessing course materials, please visit "
"{}://edx.org/courses/{}/\n\n"
"----\nThis email was automatically sent from edx.org to Autoenrolled Test".format(
course.display_name, protocol, unicode(course.id)
)
)
self.assertEqual(
mail.outbox[1].subject,
'You have been invited to register for {}'.format(course.display_name)
)
self.assertEqual(
mail.outbox[1].body,
"Dear student,\n\nYou have been invited to join "
"{display_name} at edx.org by a member of the "
"course staff.\n\n"
"To finish your registration, please visit "
"{}://edx.org/register and fill out the registration form "
"making sure to use student3_1@test.com in the E-mail field.\n"
"Once you have registered and activated your account, you will "
"see {display_name} listed on your dashboard.\n\n"
"----\nThis email was automatically sent from edx.org to "
"student3_1@test.com".format(protocol, display_name=course.display_name)
)
def test_unenrollment_email_on(self):
"""
Do email on unenroll test
"""
course = self.course
# Create invited, but not registered, user
cea = CourseEnrollmentAllowed(email='student4_0@test.com', course_id=course.id)
cea.save()
url = reverse('instructor_dashboard_legacy', kwargs={'course_id': course.id.to_deprecated_string()})
response = self.client.post(url, {'action': 'Unenroll multiple students', 'multiple_students': 'student4_0@test.com, student2@test.com, student3@test.com', 'email_students': 'on'})
# Check the page output
self.assertContains(response, '<td>student2@test.com</td>')
self.assertContains(response, '<td>student3@test.com</td>')
self.assertContains(response, '<td>un-enrolled, email sent</td>')
# Check the outbox
self.assertEqual(len(mail.outbox), 3)
self.assertEqual(
mail.outbox[0].subject,
'You have been un-enrolled from {}'.format(course.display_name)
)
self.assertEqual(
mail.outbox[0].body,
"Dear Student,\n\nYou have been un-enrolled from course "
"{} by a member of the course staff. "
"Please disregard the invitation previously sent.\n\n"
"----\nThis email was automatically sent from edx.org "
"to student4_0@test.com".format(course.display_name)
)
self.assertEqual(
mail.outbox[1].subject,
'You have been un-enrolled from {}'.format(course.display_name)
)
def test_send_mail_to_student(self):
"""
Do invalid mail template test
"""
d = {'message': 'message_type_that_doesn\'t_exist'}
send_mail_ret = send_mail_to_student('student0@test.com', d)
self.assertFalse(send_mail_ret)
@ddt.data('http', 'https')
@patch('instructor.views.legacy.uses_shib')
def test_enrollment_email_on_shib_on(self, protocol, mock_uses_shib):
# Do email on enroll, shibboleth on test
course = self.course
mock_uses_shib.return_value = True
# Create activated, but not enrolled, user
UserFactory.create(username="student5_0", email="student5_0@test.com", first_name="ShibTest", last_name="Enrolled")
url = reverse('instructor_dashboard_legacy', kwargs={'course_id': course.id.to_deprecated_string()})
params = {'action': 'Enroll multiple students', 'multiple_students': 'student5_0@test.com, student5_1@test.com', 'auto_enroll': 'on', 'email_students': 'on'}
environ = {'wsgi.url_scheme': protocol}
response = self.client.post(url, params, **environ)
# Check the page output
self.assertContains(response, '<td>student5_0@test.com</td>')
self.assertContains(response, '<td>student5_1@test.com</td>')
self.assertContains(response, '<td>added, email sent</td>')
self.assertContains(response, '<td>user does not exist, enrollment allowed, pending with auto enrollment on, email sent</td>')
# Check the outbox
self.assertEqual(len(mail.outbox), 2)
self.assertEqual(
mail.outbox[0].subject,
'You have been enrolled in {}'.format(course.display_name)
)
self.assertEqual(
mail.outbox[0].body,
"Dear ShibTest Enrolled\n\nYou have been enrolled in {} "
"at edx.org by a member of the course staff. "
"The course should now appear on your edx.org dashboard.\n\n"
"To start accessing course materials, please visit "
"{}://edx.org/courses/{}/\n\n"
"----\nThis email was automatically sent from edx.org to ShibTest Enrolled".format(
course.display_name, protocol, unicode(course.id)
)
)
self.assertEqual(
mail.outbox[1].subject,
'You have been invited to register for {}'.format(course.display_name)
)
self.assertEqual(
mail.outbox[1].body,
"Dear student,\n\nYou have been invited to join "
"{} at edx.org by a member of the "
"course staff.\n\n"
"To access the course visit {}://edx.org/courses/{}/ and login.\n\n"
"----\nThis email was automatically sent from edx.org to "
"student5_1@test.com".format(
course.display_name, protocol, course.id
)
)
# -*- coding: utf-8 -*-
"""
Create course and answer a problem to test raw grade CSV
"""
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from instructor.utils import DummyRequest
from instructor.views.legacy import get_student_grade_summary_data
from nose.plugins.attrib import attr
from courseware.tests.test_submitting_problems import TestSubmittingProblems
from student.roles import CourseStaffRole
@attr('shard_1')
class TestRawGradeCSV(TestSubmittingProblems):
"""
Tests around the instructor dashboard raw grade CSV
"""
def setUp(self):
"""
Set up a simple course for testing basic grading functionality.
"""
super(TestRawGradeCSV, self).setUp()
self.instructor = 'view2@test.com'
self.student_user2 = self.create_account('u2', self.instructor, self.password)
self.activate_user(self.instructor)
CourseStaffRole(self.course.id).add_users(User.objects.get(email=self.instructor))
self.logout()
self.login(self.instructor, self.password)
self.enroll(self.course)
# set up a simple course with four problems
self.homework = self.add_graded_section_to_course('homework', late=False, reset=False, showanswer=False)
self.add_dropdown_to_section(self.homework.location, 'p1', 1)
self.add_dropdown_to_section(self.homework.location, 'p2', 1)
self.add_dropdown_to_section(self.homework.location, 'p3', 1)
self.refresh_course()
def answer_question(self):
"""
Answer a question correctly in the course
"""
self.login(self.instructor, self.password)
resp = self.submit_question_answer('p2', {'2_1': 'Correct'})
self.assertEqual(resp.status_code, 200)
def test_download_raw_grades_dump(self):
"""
Grab raw grade report and make sure all grades are reported.
"""
# Answer second problem correctly with 2nd user to expose bug
self.answer_question()
url = reverse('instructor_dashboard_legacy', kwargs={'course_id': self.course.id.to_deprecated_string()})
msg = "url = {0}\n".format(url)
response = self.client.post(url, {'action': 'Download CSV of all RAW grades'})
msg += "instructor dashboard download raw csv grades: response = '{0}'\n".format(response)
body = response.content.replace('\r', '')
msg += "body = '{0}'\n".format(body)
expected_csv = '''"ID","Username","Full Name","edX email","External email","p3","p2","p1"
"1","u1","username","view@test.com","","None","None","None"
"2","u2","username","view2@test.com","","0.0","1.0","0.0"
'''
self.assertEqual(body, expected_csv, msg)
def get_expected_grade_data(
self, get_grades=True, get_raw_scores=False,
use_offline=False, get_score_max=False):
"""
Return expected results from the get_student_grade_summary_data call
with any options selected.
Note that the kwargs accepted by get_expected_grade_data (and their
default values) must be identical to those in
get_student_grade_summary_data for this function to be accurate.
If kwargs are added or removed, or the functionality triggered by
them changes, this function must be updated to match.
If get_score_max is True, instead of a single score between 0 and 1,
the actual score and total possible are returned. For example, if the
student got one out of two possible points, the values (1, 2) will be
returned instead of 0.5.
"""
expected_data = {
'students': [self.student_user, self.student_user2],
'header': [
u'ID', u'Username', u'Full Name', u'edX email', u'External email',
u'HW 01', u'HW 02', u'HW 03', u'HW 04', u'HW 05', u'HW 06', u'HW 07',
u'HW 08', u'HW 09', u'HW 10', u'HW 11', u'HW 12', u'HW Avg', u'Lab 01',
u'Lab 02', u'Lab 03', u'Lab 04', u'Lab 05', u'Lab 06', u'Lab 07',
u'Lab 08', u'Lab 09', u'Lab 10', u'Lab 11', u'Lab 12', u'Lab Avg', u'Midterm',
u'Final'
],
'data': [
[
1, u'u1', u'username', u'view@test.com', '', 0.0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0.0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
],
[
2, u'u2', u'username', u'view2@test.com', '', 0.3333333333333333, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0.03333333333333333, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0
]
],
'assignments': [
u'HW 01', u'HW 02', u'HW 03', u'HW 04', u'HW 05', u'HW 06', u'HW 07', u'HW 08',
u'HW 09', u'HW 10', u'HW 11', u'HW 12', u'HW Avg', u'Lab 01', u'Lab 02',
u'Lab 03', u'Lab 04', u'Lab 05', u'Lab 06', u'Lab 07', u'Lab 08', u'Lab 09',
u'Lab 10', u'Lab 11', u'Lab 12', u'Lab Avg', u'Midterm', u'Final'
]
}
# The first five columns contain the student ID, username,
# full name, and e-mail addresses.
non_grade_columns = 5
# If the following 'if' is triggered, the
# get_student_grade_summary_data function will not return any
# grade data. Only the "non_grade_columns."
# So strip out the headers beyond the "non_grade_columns," and
# strip out all the grades in the 'data' key.
if not get_grades or use_offline:
expected_data["header"] = expected_data["header"][:non_grade_columns]
# This iterates over the lists of grades in the 'data' key
# of the return dictionary and strips out everything after
# the non_grade_columns.
for index, rec in enumerate(expected_data["data"]):
expected_data["data"][index] = rec[:non_grade_columns]
# Wipe out all data in the 'assignments' key if use_offline
# is True; no assignment data is returned.
if use_offline:
expected_data['assignments'] = []
# If get_grades is False, get_student_grade_summary_data doesn't
# even return an 'assignments' key, so delete it.
if get_grades is False:
del expected_data['assignments']
# If get_raw_scores is true, get_student_grade_summary_data returns
# the raw score per assignment. For example, the "0.3333333333333333"
# in the data above is for getting one out of three possible
# answers correct. Getting raw scores means the actual score (1) is
# return instead of: 1.0/3.0
# For some reason, this also causes it to not to return any assignments
# without attempts, so most of the headers are removed.
elif get_raw_scores:
expected_data["data"] = [
[
1, u'u1', u'username', u'view@test.com',
'', None, None, None
],
[
2, u'u2', u'username', u'view2@test.com',
'', 0.0, 1.0, 0.0
],
]
expected_data["assignments"] = [u'p3', u'p2', u'p1']
expected_data["header"] = [
u'ID', u'Username', u'Full Name', u'edX email',
u'External email', u'p3', u'p2', u'p1'
]
# Strip out the single-value float scores and replace them
# with two-tuples of actual and possible scores (see docstring).
if get_score_max:
expected_data["data"][-1][-3:] = (0.0, 1), (1.0, 1.0), (0.0, 1)
return expected_data
def test_grade_summary_data_defaults(self):
"""
Test grade summary data report generation with all default kwargs.
This test compares the output of the get_student_grade_summary_data
with a dictionary of exected values. The purpose of this test is
to ensure that future changes to the get_student_grade_summary_data
function (for example, mitocw/edx-platform #95).
"""
request = DummyRequest()
self.answer_question()
data = get_student_grade_summary_data(request, self.course)
expected_data = self.get_expected_grade_data()
self.compare_data(data, expected_data)
def test_grade_summary_data_raw_scores(self):
"""
Test grade summary data report generation with get_raw_scores True.
"""
request = DummyRequest()
self.answer_question()
data = get_student_grade_summary_data(
request, self.course, get_raw_scores=True,
)
expected_data = self.get_expected_grade_data(get_raw_scores=True)
self.compare_data(data, expected_data)
def test_grade_summary_data_no_grades(self):
"""
Test grade summary data report generation with
get_grades set to False.
"""
request = DummyRequest()
self.answer_question()
data = get_student_grade_summary_data(
request, self.course, get_grades=False
)
expected_data = self.get_expected_grade_data(get_grades=False)
# if get_grades is False, get_expected_grade_data does not
# add an "assignments" key.
self.assertNotIn("assignments", expected_data)
self.compare_data(data, expected_data)
def test_grade_summary_data_use_offline(self):
"""
Test grade summary data report generation with use_offline True.
"""
request = DummyRequest()
self.answer_question()
data = get_student_grade_summary_data(
request, self.course, use_offline=True)
expected_data = self.get_expected_grade_data(use_offline=True)
self.compare_data(data, expected_data)
def test_grade_summary_data_use_offline_and_raw_scores(self):
"""
Test grade summary data report generation with use_offline
and get_raw_scores both True.
"""
request = DummyRequest()
self.answer_question()
data = get_student_grade_summary_data(
request, self.course, use_offline=True, get_raw_scores=True
)
expected_data = self.get_expected_grade_data(
use_offline=True, get_raw_scores=True
)
self.compare_data(data, expected_data)
def test_grade_summary_data_get_score_max(self):
"""
Test grade summary data report generation with get_score_max set
to True (also requires get_raw_scores to be True).
"""
request = DummyRequest()
self.answer_question()
data = get_student_grade_summary_data(
request, self.course, use_offline=True, get_raw_scores=True,
get_score_max=True,
)
expected_data = self.get_expected_grade_data(
use_offline=True, get_raw_scores=True, get_score_max=True,
)
self.compare_data(data, expected_data)
def compare_data(self, data, expected_data):
"""
Compare the output of the get_student_grade_summary_data
function to the expected_data data.
"""
# Currently, all kwargs to get_student_grade_summary_data
# return a dictionary with the same keys, except for
# get_grades=False, which results in no 'assignments' key.
# This is explicitly checked for above in
# test_grade_summary_data_no_grades. This is a backup which
# will catch future changes.
self.assertListEqual(
expected_data.keys(),
data.keys(),
)
# Ensure the student info and assignment names are as expected.
for key in ['assignments', 'header']:
self.assertListEqual(
expected_data.get(key, []),
data.get(key, []),
)
# Ensure each student's grades are as expected for each assignment.
for index, student in enumerate(expected_data['students']):
self.assertEqual(
student.username,
data['students'][index].username
)
self.assertListEqual(
expected_data['data'][index],
data['data'][index]
)
"""
Tests of various instructor dashboard features that include lists of students
"""
from django.conf import settings
from django.test.client import RequestFactory
from markupsafe import escape
from nose.plugins.attrib import attr
from student.tests.factories import UserFactory, CourseEnrollmentFactory
from edxmako.tests import mako_middleware_process_request
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from instructor.views import legacy
# pylint: disable=missing-docstring
@attr('shard_1')
class TestXss(SharedModuleStoreTestCase):
@classmethod
def setUpClass(cls):
super(TestXss, cls).setUpClass()
cls._course = CourseFactory.create()
def setUp(self):
super(TestXss, self).setUp()
self._request_factory = RequestFactory()
self._evil_student = UserFactory.create(
email="robot+evil@edx.org",
username="evil-robot",
profile__name='<span id="evil">Evil Robot</span>',
)
self._instructor = UserFactory.create(
email="robot+instructor@edx.org",
username="instructor",
is_staff=True
)
CourseEnrollmentFactory.create(
user=self._evil_student,
course_id=self._course.id
)
def _test_action(self, action):
"""
Test for XSS vulnerability in the given action
Build a request with the given action, call the instructor dashboard
view, and check that HTML code in a user's name is properly escaped.
"""
req = self._request_factory.post(
"dummy_url",
data={"action": action}
)
req.user = self._instructor
req.session = {}
mako_middleware_process_request(req)
resp = legacy.instructor_dashboard(req, self._course.id.to_deprecated_string())
respUnicode = resp.content.decode(settings.DEFAULT_CHARSET)
self.assertNotIn(self._evil_student.profile.name, respUnicode)
self.assertIn(escape(self._evil_student.profile.name), respUnicode)
def test_list_enrolled(self):
self._test_action("List enrolled students")
def test_dump_list_of_enrolled(self):
self._test_action("Dump list of enrolled students")
...@@ -195,8 +195,6 @@ def instructor_dashboard_2(request, course_id): ...@@ -195,8 +195,6 @@ def instructor_dashboard_2(request, course_id):
'generate_bulk_certificate_exceptions_url': generate_bulk_certificate_exceptions_url, 'generate_bulk_certificate_exceptions_url': generate_bulk_certificate_exceptions_url,
'certificate_exception_view_url': certificate_exception_view_url 'certificate_exception_view_url': certificate_exception_view_url
} }
if settings.FEATURES['ENABLE_INSTRUCTOR_LEGACY_DASHBOARD']:
context['old_dashboard_url'] = reverse('instructor_dashboard_legacy', kwargs={'course_id': unicode(course_key)})
return render_to_response('instructor/instructor_dashboard_2/instructor_dashboard_2.html', context) return render_to_response('instructor/instructor_dashboard_2/instructor_dashboard_2.html', context)
......
"""
Instructor Views
"""
## NOTE: This is the code for the legacy instructor dashboard
## We are no longer supporting this file or accepting changes into it.
# pylint: disable=line-too-long, missing-docstring
from contextlib import contextmanager
import csv
import json
import logging
import os
import re
import requests
import urllib
from collections import defaultdict, OrderedDict
from markupsafe import escape
from requests.status_codes import codes
from StringIO import StringIO
from django.conf import settings
from django.contrib.auth.models import User
from django.db import transaction
from django.http import HttpResponse
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.decorators.cache import cache_control
from django.core.urlresolvers import reverse
from django.core.mail import send_mail
from django.utils import timezone
import xmodule.graders as xmgraders
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from courseware import grades
from courseware.access import has_access
from courseware.courses import get_course_with_access, get_cms_course_link
from courseware.models import StudentModule
from django_comment_common.models import FORUM_ROLE_ADMINISTRATOR
from django_comment_client.utils import has_forum_access
from instructor.offline_gradecalc import student_grades, offline_grades_available
from instructor.views.tools import strip_if_string, bulk_email_is_enabled_for_course, add_block_ids
from instructor_task.api import (
get_running_instructor_tasks,
get_instructor_task_history,
)
from instructor_task.views import get_task_completion_info
from edxmako.shortcuts import render_to_response, render_to_string
from class_dashboard import dashboard_data
from student.models import (
CourseEnrollment,
CourseEnrollmentAllowed,
)
import track.views
from django.utils.translation import ugettext as _
from microsite_configuration import microsite
from opaque_keys.edx.locations import i4xEncoder
from openedx.core.djangoapps.course_groups.cohorts import is_course_cohorted
log = logging.getLogger(__name__)
# internal commands for managing forum roles:
FORUM_ROLE_ADD = 'add'
FORUM_ROLE_REMOVE = 'remove'
# For determining if a shibboleth course
SHIBBOLETH_DOMAIN_PREFIX = 'shib:'
def split_by_comma_and_whitespace(a_str):
"""
Return string a_str, split by , or whitespace
"""
return re.split(r'[\s,]', a_str)
# Grades can potentially be written - if so, let grading manage the transaction.
@transaction.non_atomic_requests
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
def instructor_dashboard(request, course_id):
"""Display the instructor dashboard for a course."""
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
course = get_course_with_access(request.user, 'staff', course_key, depth=None)
instructor_access = bool(has_access(request.user, 'instructor', course)) # an instructor can manage staff lists
forum_admin_access = has_forum_access(request.user, course_key, FORUM_ROLE_ADMINISTRATOR)
msg = ''
show_email_tab = False
problems = []
plots = []
datatable = {}
# the instructor dashboard page is modal: grades, admin
# keep that state in request.session (defaults to grades mode)
idash_mode = request.POST.get('idash_mode', '')
idash_mode_key = u'idash_mode:{0}'.format(course_id)
if idash_mode:
request.session[idash_mode_key] = idash_mode
else:
idash_mode = request.session.get(idash_mode_key, 'Grades')
enrollment_number = CourseEnrollment.objects.num_enrolled_in(course_key)
# assemble some course statistics for output to instructor
def get_course_stats_table():
datatable = {
'header': ['Statistic', 'Value'],
'title': _('Course Statistics At A Glance'),
}
data = [['Date', timezone.now().isoformat()]]
data += compute_course_stats(course).items()
if request.user.is_staff:
for field in course.fields.values():
if getattr(field.scope, 'user', False):
continue
data.append([
field.name,
json.dumps(field.read_json(course), cls=i4xEncoder)
])
datatable['data'] = data
return datatable
def return_csv(func, datatable, file_pointer=None):
"""Outputs a CSV file from the contents of a datatable."""
if file_pointer is None:
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = (u'attachment; filename={0}'.format(func)).encode('utf-8')
else:
response = file_pointer
writer = csv.writer(response, dialect='excel', quotechar='"', quoting=csv.QUOTE_ALL)
encoded_row = [unicode(s).encode('utf-8') for s in datatable['header']]
writer.writerow(encoded_row)
for datarow in datatable['data']:
# 's' here may be an integer, float (eg score) or string (eg student name)
encoded_row = [
# If s is already a UTF-8 string, trying to make a unicode
# object out of it will fail unless we pass in an encoding to
# the constructor. But we can't do that across the board,
# because s is often a numeric type. So just do this.
s if isinstance(s, str) else unicode(s).encode('utf-8')
for s in datarow
]
writer.writerow(encoded_row)
return response
# process actions from form POST
action = request.POST.get('action', '')
use_offline = request.POST.get('use_offline_grades', False)
if settings.FEATURES['ENABLE_MANUAL_GIT_RELOAD']:
if 'GIT pull' in action:
data_dir = course.data_dir
log.debug('git pull %s', data_dir)
gdir = settings.DATA_DIR / data_dir
if not os.path.exists(gdir):
msg += "====> ERROR in gitreload - no such directory {0}".format(gdir)
else:
cmd = "cd {0}; git reset --hard HEAD; git clean -f -d; git pull origin; chmod g+w course.xml".format(gdir)
msg += "git pull on {0}:<p>".format(data_dir)
msg += "<pre>{0}</pre></p>".format(escape(os.popen(cmd).read()))
track.views.server_track(request, "git-pull", {"directory": data_dir}, page="idashboard")
if 'Reload course' in action:
log.debug('reloading %s (%s)', course_key, course)
try:
data_dir = course.data_dir
modulestore().try_load_course(data_dir)
msg += "<br/><p>Course reloaded from {0}</p>".format(data_dir)
track.views.server_track(request, "reload", {"directory": data_dir}, page="idashboard")
course_errors = modulestore().get_course_errors(course.id)
msg += '<ul>'
for cmsg, cerr in course_errors:
msg += "<li>{0}: <pre>{1}</pre>".format(cmsg, escape(cerr))
msg += '</ul>'
except Exception as err: # pylint: disable=broad-except
msg += '<br/><p>Error: {0}</p>'.format(escape(err))
if action == 'Dump list of enrolled students' or action == 'List enrolled students':
log.debug(action)
datatable = get_student_grade_summary_data(request, course, get_grades=False, use_offline=use_offline)
datatable['title'] = _('List of students enrolled in {course_key}').format(course_key=course_key.to_deprecated_string())
track.views.server_track(request, "list-students", {}, page="idashboard")
elif 'Dump all RAW grades' in action:
log.debug(action)
datatable = get_student_grade_summary_data(request, course, get_grades=True,
get_raw_scores=True, use_offline=use_offline)
datatable['title'] = _('Raw Grades of students enrolled in {course_key}').format(course_key=course_key)
track.views.server_track(request, "dump-grades-raw", {}, page="idashboard")
elif 'Download CSV of all RAW grades' in action:
track.views.server_track(request, "dump-grades-csv-raw", {}, page="idashboard")
return return_csv('grades_{0}_raw.csv'.format(course_key.to_deprecated_string()),
get_student_grade_summary_data(request, course, get_raw_scores=True, use_offline=use_offline))
elif 'Download CSV of answer distributions' in action:
track.views.server_track(request, "dump-answer-dist-csv", {}, page="idashboard")
return return_csv('answer_dist_{0}.csv'.format(course_key.to_deprecated_string()), get_answers_distribution(request, course_key))
#----------------------------------------
# export grades to remote gradebook
elif action == 'List assignments available in remote gradebook':
msg2, datatable = _do_remote_gradebook(request.user, course, 'get-assignments')
msg += msg2
elif action == 'List assignments available for this course':
log.debug(action)
allgrades = get_student_grade_summary_data(request, course, get_grades=True, use_offline=use_offline)
assignments = [[x] for x in allgrades['assignments']]
datatable = {'header': [_('Assignment Name')]}
datatable['data'] = assignments
datatable['title'] = action
msg += 'assignments=<pre>%s</pre>' % assignments
elif action == 'List enrolled students matching remote gradebook':
stud_data = get_student_grade_summary_data(request, course, get_grades=False, use_offline=use_offline)
msg2, rg_stud_data = _do_remote_gradebook(request.user, course, 'get-membership')
datatable = {'header': ['Student email', 'Match?']}
rg_students = [x['email'] for x in rg_stud_data['retdata']]
def domatch(student):
"""Returns 'yes' if student is pressent in the remote gradebook student list, else returns 'No'"""
return 'yes' if student.email in rg_students else 'No'
datatable['data'] = [[x.email, domatch(x)] for x in stud_data['students']]
datatable['title'] = action
elif action in ['Display grades for assignment', 'Export grades for assignment to remote gradebook',
'Export CSV file of grades for assignment']:
log.debug(action)
datatable = {}
aname = request.POST.get('assignment_name', '')
if not aname:
msg += "<font color='red'>{text}</font>".format(text=_("Please enter an assignment name"))
else:
allgrades = get_student_grade_summary_data(request, course, get_grades=True, use_offline=use_offline)
if aname not in allgrades['assignments']:
msg += "<font color='red'>{text}</font>".format(
text=_("Invalid assignment name '{name}'").format(name=aname)
)
else:
aidx = allgrades['assignments'].index(aname)
datatable = {'header': [_('External email'), aname]}
ddata = []
for student in allgrades['students']: # do one by one in case there is a student who has only partial grades
try:
ddata.append([student.email, student.grades[aidx]])
except IndexError:
log.debug(u'No grade for assignment %(idx)s (%(name)s) for student %(email)s', {
"idx": aidx,
"name": aname,
"email": student.email,
})
datatable['data'] = ddata
datatable['title'] = _('Grades for assignment "{name}"').format(name=aname)
if 'Export CSV' in action:
# generate and return CSV file
return return_csv('grades {name}.csv'.format(name=aname), datatable)
elif 'remote gradebook' in action:
file_pointer = StringIO()
return_csv('', datatable, file_pointer=file_pointer)
file_pointer.seek(0)
files = {'datafile': file_pointer}
msg2, __ = _do_remote_gradebook(request.user, course, 'post-grades', files=files)
msg += msg2
#----------------------------------------
# enrollment
elif action == 'Enroll multiple students':
is_shib_course = uses_shib(course)
students = request.POST.get('multiple_students', '')
auto_enroll = bool(request.POST.get('auto_enroll'))
email_students = bool(request.POST.get('email_students'))
secure = request.is_secure()
ret = _do_enroll_students(course, course_key, students, secure=secure, auto_enroll=auto_enroll, email_students=email_students, is_shib_course=is_shib_course)
datatable = ret['datatable']
elif action == 'Unenroll multiple students':
students = request.POST.get('multiple_students', '')
email_students = bool(request.POST.get('email_students'))
ret = _do_unenroll_students(course_key, students, email_students=email_students)
datatable = ret['datatable']
elif action == 'List sections available in remote gradebook':
msg2, datatable = _do_remote_gradebook(request.user, course, 'get-sections')
msg += msg2
elif action in ['List students in section in remote gradebook',
'Overload enrollment list using remote gradebook',
'Merge enrollment list with remote gradebook']:
section = request.POST.get('gradebook_section', '')
msg2, datatable = _do_remote_gradebook(request.user, course, 'get-membership', dict(section=section))
msg += msg2
if 'List' not in action:
students = ','.join([x['email'] for x in datatable['retdata']])
overload = 'Overload' in action
secure = request.is_secure()
ret = _do_enroll_students(course, course_key, students, secure=secure, overload=overload)
datatable = ret['datatable']
#----------------------------------------
# analytics
def get_analytics_result(analytics_name):
"""Return data for an Analytic piece, or None if it doesn't exist. It
logs and swallows errors.
"""
url = settings.ANALYTICS_SERVER_URL + \
u"get?aname={}&course_id={}&apikey={}".format(
analytics_name, urllib.quote(unicode(course_key)), settings.ANALYTICS_API_KEY
)
try:
res = requests.get(url)
except Exception: # pylint: disable=broad-except
log.exception("Error trying to access analytics at %s", url)
return None
if res.status_code == codes.OK:
# WARNING: do not use req.json because the preloaded json doesn't
# preserve the order of the original record (hence OrderedDict).
payload = json.loads(res.content, object_pairs_hook=OrderedDict)
add_block_ids(payload)
return payload
else:
log.error("Error fetching %s, code: %s, msg: %s",
url, res.status_code, res.content)
return None
analytics_results = {}
if idash_mode == 'Analytics':
dashboard_analytics = [
# "StudentsAttemptedProblems", # num students who tried given problem
"StudentsDailyActivity", # active students by day
"StudentsDropoffPerDay", # active students dropoff by day
# "OverallGradeDistribution", # overall point distribution for course
# "StudentsPerProblemCorrect", # foreach problem, num students correct
"ProblemGradeDistribution", # foreach problem, grade distribution
]
for analytic_name in dashboard_analytics:
analytics_results[analytic_name] = get_analytics_result(analytic_name)
#----------------------------------------
# Metrics
metrics_results = {}
if settings.FEATURES.get('CLASS_DASHBOARD') and idash_mode == 'Metrics':
metrics_results['section_display_name'] = dashboard_data.get_section_display_name(course_key)
metrics_results['section_has_problem'] = dashboard_data.get_array_section_has_problem(course_key)
#----------------------------------------
# offline grades?
if use_offline:
msg += "<br/><font color='orange'>{text}</font>".format(
text=_("Grades from {course_id}").format(
course_id=offline_grades_available(course_key)
)
)
# generate list of pending background tasks
if settings.FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS'):
instructor_tasks = get_running_instructor_tasks(course_key)
else:
instructor_tasks = None
# determine if this is a studio-backed course so we can provide a link to edit this course in studio
is_studio_course = modulestore().get_modulestore_type(course_key) != ModuleStoreEnum.Type.xml
studio_url = None
if is_studio_course:
studio_url = get_cms_course_link(course)
if bulk_email_is_enabled_for_course(course_key):
show_email_tab = True
# display course stats only if there is no other table to display:
course_stats = None
if not datatable:
course_stats = get_course_stats_table()
# disable buttons for large courses
disable_buttons = False
max_enrollment_for_buttons = settings.FEATURES.get("MAX_ENROLLMENT_INSTR_BUTTONS")
if max_enrollment_for_buttons is not None:
disable_buttons = enrollment_number > max_enrollment_for_buttons
#----------------------------------------
# context for rendering
context = {
'course': course,
'course_is_cohorted': is_course_cohorted(course.id),
'staff_access': True,
'admin_access': request.user.is_staff,
'instructor_access': instructor_access,
'forum_admin_access': forum_admin_access,
'datatable': datatable,
'course_stats': course_stats,
'msg': msg,
'modeflag': {idash_mode: 'selectedmode'},
'studio_url': studio_url,
'show_email_tab': show_email_tab, # email
'course_errors': modulestore().get_course_errors(course.id),
'instructor_tasks': instructor_tasks,
'offline_grade_log': offline_grades_available(course_key),
'analytics_results': analytics_results,
'disable_buttons': disable_buttons,
'metrics_results': metrics_results,
}
context['standard_dashboard_url'] = reverse('instructor_dashboard', kwargs={'course_id': course_key.to_deprecated_string()})
return render_to_response('courseware/legacy_instructor_dashboard.html', context)
def _do_remote_gradebook(user, course, action, args=None, files=None):
'''
Perform remote gradebook action. Returns msg, datatable.
'''
rgb = course.remote_gradebook
if not rgb:
msg = _("No remote gradebook defined in course metadata")
return msg, {}
rgburl = settings.FEATURES.get('REMOTE_GRADEBOOK_URL', '')
if not rgburl:
msg = _("No remote gradebook url defined in settings.FEATURES")
return msg, {}
rgbname = rgb.get('name', '')
if not rgbname:
msg = _("No gradebook name defined in course remote_gradebook metadata")
return msg, {}
if args is None:
args = {}
data = dict(submit=action, gradebook=rgbname, user=user.email)
data.update(args)
try:
resp = requests.post(rgburl, data=data, verify=False, files=files)
retdict = json.loads(resp.content)
except Exception as err: # pylint: disable=broad-except
msg = _("Failed to communicate with gradebook server at {url}").format(url=rgburl) + "<br/>"
msg += _("Error: {err}").format(err=err)
msg += "<br/>resp={resp}".format(resp=resp.content)
msg += "<br/>data={data}".format(data=data)
return msg, {}
msg = '<pre>{msg}</pre>'.format(msg=retdict['msg'].replace('\n', '<br/>'))
retdata = retdict['data'] # a list of dicts
if retdata:
datatable = {'header': retdata[0].keys()}
datatable['data'] = [x.values() for x in retdata]
datatable['title'] = _('Remote gradebook response for {action}').format(action=action)
datatable['retdata'] = retdata
else:
datatable = {}
return msg, datatable
def _role_members_table(role, title, course_key):
"""
Return a data table of usernames and names of users in group_name.
Arguments:
role -- a student.roles.AccessRole
title -- a descriptive title to show the user
Returns:
a dictionary with keys
'header': ['Username', 'Full name'],
'data': [[username, name] for all users]
'title': "{title} in course {course}"
"""
uset = role.users_with_role()
datatable = {'header': [_('Username'), _('Full name')]}
datatable['data'] = [[x.username, x.profile.name] for x in uset]
datatable['title'] = _('{title} in course {course_key}').format(title=title, course_key=course_key.to_deprecated_string())
return datatable
def _user_from_name_or_email(username_or_email):
"""
Return the `django.contrib.auth.User` with the supplied username or email.
If `username_or_email` contains an `@` it is treated as an email, otherwise
it is treated as the username
"""
username_or_email = strip_if_string(username_or_email)
if '@' in username_or_email:
return User.objects.get(email=username_or_email)
else:
return User.objects.get(username=username_or_email)
def add_user_to_role(request, username_or_email, role, group_title, event_name):
"""
Look up the given user by username (if no '@') or email (otherwise), and add them to group.
Arguments:
request: django request--used for tracking log
username_or_email: who to add. Decide if it's an email by presense of an '@'
group: A group name
group_title: what to call this group in messages to user--e.g. "beta-testers".
event_name: what to call this event when logging to tracking logs.
Returns:
html to insert in the message field
"""
username_or_email = strip_if_string(username_or_email)
try:
user = _user_from_name_or_email(username_or_email)
except User.DoesNotExist:
return u'<font color="red">Error: unknown username or email "{0}"</font>'.format(username_or_email)
role.add_users(user)
# Deal with historical event names
if event_name in ('staff', 'beta-tester'):
track.views.server_track(
request,
"add-or-remove-user-group",
{
"event_name": event_name,
"user": unicode(user),
"event": "add"
},
page="idashboard"
)
else:
track.views.server_track(request, "add-instructor", {"instructor": unicode(user)}, page="idashboard")
return '<font color="green">Added {0} to {1}</font>'.format(user, group_title)
def remove_user_from_role(request, username_or_email, role, group_title, event_name):
"""
Look up the given user by username (if no '@') or email (otherwise), and remove them from the supplied role.
Arguments:
request: django request--used for tracking log
username_or_email: who to remove. Decide if it's an email by presense of an '@'
role: A student.roles.AccessRole
group_title: what to call this group in messages to user--e.g. "beta-testers".
event_name: what to call this event when logging to tracking logs.
Returns:
html to insert in the message field
"""
username_or_email = strip_if_string(username_or_email)
try:
user = _user_from_name_or_email(username_or_email)
except User.DoesNotExist:
return u'<font color="red">Error: unknown username or email "{0}"</font>'.format(username_or_email)
role.remove_users(user)
# Deal with historical event names
if event_name in ('staff', 'beta-tester'):
track.views.server_track(
request,
"add-or-remove-user-group",
{
"event_name": event_name,
"user": unicode(user),
"event": "remove"
},
page="idashboard"
)
else:
track.views.server_track(request, "remove-instructor", {"instructor": unicode(user)}, page="idashboard")
return '<font color="green">Removed {0} from {1}</font>'.format(user, group_title)
class GradeTable(object):
"""
Keep track of grades, by student, for all graded assignment
components. Each student's grades are stored in a list. The
index of this list specifies the assignment component. Not
all lists have the same length, because at the start of going
through the set of grades, it is unknown what assignment
compoments exist. This is because some students may not do
all the assignment components.
The student grades are then stored in a dict, with the student
id as the key.
"""
def __init__(self):
self.components = OrderedDict()
self.grades = {}
self._current_row = {}
def _add_grade_to_row(self, component, score, possible=None):
"""Creates component if needed, and assigns score
Args:
component (str): Course component being graded
score (float): Score of student on component
possible (float): Max possible score for the component
Returns:
None
"""
component_index = self.components.setdefault(component, len(self.components))
if possible is not None:
# send a tuple instead of a single value
score = (score, possible)
self._current_row[component_index] = score
@contextmanager
def add_row(self, student_id):
"""Context management for a row of grades
Uses a new dictionary to get all grades of a specified student
and closes by adding that dict to the internal table.
Args:
student_id (str): Student id that is having grades set
"""
self._current_row = {}
yield self._add_grade_to_row
self.grades[student_id] = self._current_row
def get_grade(self, student_id):
"""Retrieves padded list of grades for specified student
Args:
student_id (str): Student ID for desired grades
Returns:
list: Ordered list of grades for student
"""
row = self.grades.get(student_id, [])
ncomp = len(self.components)
return [row.get(comp, None) for comp in range(ncomp)]
def get_graded_components(self):
"""
Return a list of components that have been
discovered so far.
"""
return self.components.keys()
def get_student_grade_summary_data(
request, course, get_grades=True, get_raw_scores=False,
use_offline=False, get_score_max=False
):
"""
Return data arrays with student identity and grades for specified course.
course = CourseDescriptor
course_key = course ID
Note: both are passed in, only because instructor_dashboard already has them already.
returns datatable = dict(header=header, data=data)
where
header = list of strings labeling the data fields
data = list (one per student) of lists of data corresponding to the fields
If get_raw_scores=True, then instead of grade summaries, the raw grades for all graded modules are returned.
If get_score_max is True, two values will be returned for each grade -- the
total number of points earned and the total number of points possible. For
example, if two points are possible and one is earned, (1, 2) will be
returned instead of 0.5 (the default).
"""
course_key = course.id
enrolled_students = User.objects.filter(
courseenrollment__course_id=course_key,
courseenrollment__is_active=1,
).prefetch_related("groups").order_by('username')
header = [_('ID'), _('Username'), _('Full Name'), _('edX email'), _('External email')]
datatable = {'header': header, 'students': enrolled_students}
data = []
gtab = GradeTable()
for student in enrolled_students:
datarow = [student.id, student.username, student.profile.name, student.email]
try:
datarow.append(student.externalauthmap.external_email)
except Exception: # pylint: disable=broad-except
datarow.append('')
if get_grades:
gradeset = student_grades(student, request, course, keep_raw_scores=get_raw_scores, use_offline=use_offline)
log.debug(u'student=%s, gradeset=%s', student, gradeset)
with gtab.add_row(student.id) as add_grade:
if get_raw_scores:
# The following code calls add_grade, which is an alias
# for the add_row method on the GradeTable class. This adds
# a grade for each assignment. Depending on whether
# get_score_max is True, it will return either a single
# value as a float between 0 and 1, or a two-tuple
# containing the earned score and possible score for
# the assignment (see docstring).
for score in gradeset['raw_scores']:
if get_score_max is True:
add_grade(score.section, score.earned, score.possible)
else:
add_grade(score.section, score.earned)
else:
for grade_item in gradeset['section_breakdown']:
add_grade(grade_item['label'], grade_item['percent'])
student.grades = gtab.get_grade(student.id)
data.append(datarow)
# if getting grades, need to do a second pass, and add grades to each datarow;
# on the first pass we don't know all the graded components
if get_grades:
for datarow in data:
# get grades for student
sgrades = gtab.get_grade(datarow[0])
datarow += sgrades
# get graded components and add to table header
assignments = gtab.get_graded_components()
header += assignments
datatable['assignments'] = assignments
datatable['data'] = data
return datatable
#-----------------------------------------------------------------------------
# Gradebook has moved to instructor.api.spoc_gradebook #
#-----------------------------------------------------------------------------
# enrollment
def _do_enroll_students(course, course_key, students, secure=False, overload=False, auto_enroll=False, email_students=False, is_shib_course=False):
"""
Do the actual work of enrolling multiple students, presented as a string
of emails separated by commas or returns
`course` is course object
`course_key` id of course (a CourseKey)
`students` string of student emails separated by commas or returns (a `str`)
`overload` un-enrolls all existing students (a `boolean`)
`auto_enroll` is user input preference (a `boolean`)
`email_students` is user input preference (a `boolean`)
"""
new_students, new_students_lc = get_and_clean_student_list(students)
status = dict([x, 'unprocessed'] for x in new_students)
if overload: # delete all but staff
todelete = CourseEnrollment.objects.filter(course_id=course_key)
for enrollee in todelete:
if not has_access(enrollee.user, 'staff', course) and enrollee.user.email.lower() not in new_students_lc:
status[enrollee.user.email] = 'deleted'
enrollee.deactivate()
else:
status[enrollee.user.email] = 'is staff'
ceaset = CourseEnrollmentAllowed.objects.filter(course_id=course_key)
for cea in ceaset:
status[cea.email] = 'removed from pending enrollment list'
ceaset.delete()
if email_students:
protocol = 'https' if secure else 'http'
stripped_site_name = microsite.get_value(
'SITE_NAME',
settings.SITE_NAME
)
# TODO: Use request.build_absolute_uri rather than '{proto}://{site}{path}'.format
# and check with the Services team that this works well with microsites
registration_url = '{proto}://{site}{path}'.format(
proto=protocol,
site=stripped_site_name,
path=reverse('register_user')
)
course_url = '{proto}://{site}{path}'.format(
proto=protocol,
site=stripped_site_name,
path=reverse('course_root', kwargs={'course_id': course_key.to_deprecated_string()})
)
# We can't get the url to the course's About page if the marketing site is enabled.
course_about_url = None
if not settings.FEATURES.get('ENABLE_MKTG_SITE', False):
course_about_url = u'{proto}://{site}{path}'.format(
proto=protocol,
site=stripped_site_name,
path=reverse('about_course', kwargs={'course_id': course_key.to_deprecated_string()})
)
# Composition of email
email_data = {
'site_name': stripped_site_name,
'registration_url': registration_url,
'course': course,
'auto_enroll': auto_enroll,
'course_url': course_url,
'course_about_url': course_about_url,
'is_shib_course': is_shib_course
}
for student in new_students:
try:
user = User.objects.get(email=student)
except User.DoesNotExist:
# Student not signed up yet, put in pending enrollment allowed table
cea = CourseEnrollmentAllowed.objects.filter(email=student, course_id=course_key)
# If enrollmentallowed already exists, update auto_enroll flag to however it was set in UI
# Will be 0 or 1 records as there is a unique key on email + course_id
if cea:
cea[0].auto_enroll = auto_enroll
cea[0].save()
status[student] = 'user does not exist, enrollment already allowed, pending with auto enrollment ' \
+ ('on' if auto_enroll else 'off')
continue
# EnrollmentAllowed doesn't exist so create it
cea = CourseEnrollmentAllowed(email=student, course_id=course_key, auto_enroll=auto_enroll)
cea.save()
status[student] = 'user does not exist, enrollment allowed, pending with auto enrollment ' \
+ ('on' if auto_enroll else 'off')
if email_students:
# User is allowed to enroll but has not signed up yet
email_data['email_address'] = student
email_data['message'] = 'allowed_enroll'
send_mail_ret = send_mail_to_student(student, email_data)
status[student] += (', email sent' if send_mail_ret else '')
continue
# Student has already registered
if CourseEnrollment.is_enrolled(user, course_key):
status[student] = 'already enrolled'
continue
try:
# Not enrolled yet
CourseEnrollment.enroll(user, course_key)
status[student] = 'added'
if email_students:
# User enrolled for first time, populate dict with user specific info
email_data['email_address'] = student
email_data['full_name'] = user.profile.name
email_data['message'] = 'enrolled_enroll'
send_mail_ret = send_mail_to_student(student, email_data)
status[student] += (', email sent' if send_mail_ret else '')
except Exception: # pylint: disable=broad-except
status[student] = 'rejected'
datatable = {'header': ['StudentEmail', 'action']}
datatable['data'] = [[x, status[x]] for x in sorted(status)]
datatable['title'] = _('Enrollment of students')
def sf(stat): # pylint: disable=invalid-name
return [x for x in status if status[x] == stat]
data = dict(added=sf('added'), rejected=sf('rejected') + sf('exists'),
deleted=sf('deleted'), datatable=datatable)
return data
#Unenrollment
def _do_unenroll_students(course_key, students, email_students=False):
"""
Do the actual work of un-enrolling multiple students, presented as a string
of emails separated by commas or returns
`course_key` is id of course (a `str`)
`students` is string of student emails separated by commas or returns (a `str`)
`email_students` is user input preference (a `boolean`)
"""
old_students, __ = get_and_clean_student_list(students)
status = dict([x, 'unprocessed'] for x in old_students)
stripped_site_name = microsite.get_value(
'SITE_NAME',
settings.SITE_NAME
)
if email_students:
course = modulestore().get_course(course_key)
# Composition of email
data = {
'site_name': stripped_site_name,
'course': course
}
for student in old_students:
isok = False
cea = CourseEnrollmentAllowed.objects.filter(course_id=course_key, email=student)
# Will be 0 or 1 records as there is a unique key on email + course_id
if cea:
cea[0].delete()
status[student] = "un-enrolled"
isok = True
try:
user = User.objects.get(email=student)
except User.DoesNotExist:
if isok and email_students:
# User was allowed to join but had not signed up yet
data['email_address'] = student
data['message'] = 'allowed_unenroll'
send_mail_ret = send_mail_to_student(student, data)
status[student] += (', email sent' if send_mail_ret else '')
continue
# Will be 0 or 1 records as there is a unique key on user + course_id
if CourseEnrollment.is_enrolled(user, course_key):
try:
CourseEnrollment.unenroll(user, course_key)
status[student] = "un-enrolled"
if email_students:
# User was enrolled
data['email_address'] = student
data['full_name'] = user.profile.name
data['message'] = 'enrolled_unenroll'
send_mail_ret = send_mail_to_student(student, data)
status[student] += (', email sent' if send_mail_ret else '')
except Exception: # pylint: disable=broad-except
if not isok:
status[student] = "Error! Failed to un-enroll"
datatable = {'header': ['StudentEmail', 'action']}
datatable['data'] = [[x, status[x]] for x in sorted(status)]
datatable['title'] = _('Un-enrollment of students')
return dict(datatable=datatable)
def send_mail_to_student(student, param_dict):
"""
Construct the email using templates and then send it.
`student` is the student's email address (a `str`),
`param_dict` is a `dict` with keys [
`site_name`: name given to edX instance (a `str`)
`registration_url`: url for registration (a `str`)
`course_key`: id of course (a CourseKey)
`auto_enroll`: user input option (a `str`)
`course_url`: url of course (a `str`)
`email_address`: email of student (a `str`)
`full_name`: student full name (a `str`)
`message`: type of email to send and template to use (a `str`)
`is_shib_course`: (a `boolean`)
]
Returns a boolean indicating whether the email was sent successfully.
"""
# add some helpers and microconfig subsitutions
if 'course' in param_dict:
param_dict['course_name'] = param_dict['course'].display_name_with_default
param_dict['site_name'] = microsite.get_value(
'SITE_NAME',
param_dict.get('site_name', '')
)
subject = None
message = None
message_type = param_dict['message']
email_template_dict = {
'allowed_enroll': ('emails/enroll_email_allowedsubject.txt', 'emails/enroll_email_allowedmessage.txt'),
'enrolled_enroll': ('emails/enroll_email_enrolledsubject.txt', 'emails/enroll_email_enrolledmessage.txt'),
'allowed_unenroll': ('emails/unenroll_email_subject.txt', 'emails/unenroll_email_allowedmessage.txt'),
'enrolled_unenroll': ('emails/unenroll_email_subject.txt', 'emails/unenroll_email_enrolledmessage.txt'),
}
subject_template, message_template = email_template_dict.get(message_type, (None, None))
if subject_template is not None and message_template is not None:
subject = render_to_string(subject_template, param_dict)
message = render_to_string(message_template, param_dict)
if subject and message:
# Remove leading and trailing whitespace from body
message = message.strip()
# Email subject *must not* contain newlines
subject = ''.join(subject.splitlines())
from_address = microsite.get_value(
'email_from_address',
settings.DEFAULT_FROM_EMAIL
)
send_mail(subject, message, from_address, [student], fail_silently=False)
return True
else:
return False
def get_and_clean_student_list(students):
"""
Separate out individual student email from the comma, or space separated string.
`students` is string of student emails separated by commas or returns (a `str`)
Returns:
students: list of cleaned student emails
students_lc: list of lower case cleaned student emails
"""
students = split_by_comma_and_whitespace(students)
students = [unicode(s.strip()) for s in students]
students = [s for s in students if s != '']
students_lc = [x.lower() for x in students]
return students, students_lc
#-----------------------------------------------------------------------------
# answer distribution
def get_answers_distribution(request, course_key):
"""
Get the distribution of answers for all graded problems in the course.
Return a dict with two keys:
'header': a header row
'data': a list of rows
"""
course = get_course_with_access(request.user, 'staff', course_key)
course_answer_distributions = grades.answer_distributions(course.id)
dist = {}
dist['header'] = ['url_name', 'display name', 'answer id', 'answer', 'count']
dist['data'] = [
[url_name, display_name, answer_id, a, answers[a]]
for (url_name, display_name, answer_id), answers in sorted(course_answer_distributions.items())
for a in answers
]
return dist
#-----------------------------------------------------------------------------
def compute_course_stats(course):
"""
Compute course statistics, including number of problems, videos, html.
course is a CourseDescriptor from the xmodule system.
"""
# walk the course by using get_children() until we come to the leaves; count the
# number of different leaf types
counts = defaultdict(int)
def walk(module):
children = module.get_children()
category = module.__class__.__name__ # HtmlDescriptor, CapaDescriptor, ...
counts[category] += 1
for child in children:
walk(child)
walk(course)
stats = dict(counts) # number of each kind of module
return stats
def dump_grading_context(course):
"""
Dump information about course grading context (eg which problems are graded in what assignments)
Very useful for debugging grading_policy.json and policy.json
"""
msg = "-----------------------------------------------------------------------------\n"
msg += "Course grader:\n"
msg += '%s\n' % course.grader.__class__
graders = {}
if isinstance(course.grader, xmgraders.WeightedSubsectionsGrader):
msg += '\n'
msg += "Graded sections:\n"
for subgrader, category, weight in course.grader.sections:
msg += " subgrader=%s, type=%s, category=%s, weight=%s\n" % (subgrader.__class__, subgrader.type, category, weight)
subgrader.index = 1
graders[subgrader.type] = subgrader
msg += "-----------------------------------------------------------------------------\n"
msg += "Listing grading context for course %s\n" % course.id
gcontext = course.grading_context
msg += "graded sections:\n"
msg += '%s\n' % gcontext['graded_sections'].keys()
for (gsections, gsvals) in gcontext['graded_sections'].items():
msg += "--> Section %s:\n" % (gsections)
for sec in gsvals:
sdesc = sec['section_descriptor']
grade_format = getattr(sdesc, 'grade_format', None)
aname = ''
if grade_format in graders:
gfmt = graders[grade_format]
aname = '%s %02d' % (gfmt.short_label, gfmt.index)
gfmt.index += 1
elif sdesc.display_name in graders:
gfmt = graders[sdesc.display_name]
aname = '%s' % gfmt.short_label
notes = ''
if getattr(sdesc, 'score_by_attempt', False):
notes = ', score by attempt!'
msg += " %s (grade_format=%s, Assignment=%s%s)\n" % (sdesc.display_name, grade_format, aname, notes)
msg += "all descriptors:\n"
msg += "length=%d\n" % len(gcontext['all_descriptors'])
msg = '<pre>%s</pre>' % msg.replace('<', '&lt;')
return msg
def get_background_task_table(course_key, problem_url=None, student=None, task_type=None):
"""
Construct the "datatable" structure to represent background task history.
Filters the background task history to the specified course and problem.
If a student is provided, filters to only those tasks for which that student
was specified.
Returns a tuple of (msg, datatable), where the msg is a possible error message,
and the datatable is the datatable to be used for display.
"""
history_entries = get_instructor_task_history(course_key, problem_url, student, task_type)
datatable = {}
msg = ""
# first check to see if there is any history at all
# (note that we don't have to check that the arguments are valid; it
# just won't find any entries.)
if (history_entries.count()) == 0:
if problem_url is None:
msg += '<font color="red">Failed to find any background tasks for course "{course}".</font>'.format(
course=course_key.to_deprecated_string()
)
elif student is not None:
template = '<font color="red">' + _('Failed to find any background tasks for course "{course}", module "{problem}" and student "{student}".') + '</font>'
msg += template.format(course=course_key.to_deprecated_string(), problem=problem_url, student=student.username)
else:
msg += '<font color="red">' + _('Failed to find any background tasks for course "{course}" and module "{problem}".').format(
course=course_key.to_deprecated_string(), problem=problem_url
) + '</font>'
else:
datatable['header'] = ["Task Type",
"Task Id",
"Requester",
"Submitted",
"Duration (sec)",
"Task State",
"Task Status",
"Task Output"]
datatable['data'] = []
for instructor_task in history_entries:
# get duration info, if known:
duration_sec = 'unknown'
if hasattr(instructor_task, 'task_output') and instructor_task.task_output is not None:
task_output = json.loads(instructor_task.task_output)
if 'duration_ms' in task_output:
duration_sec = int(task_output['duration_ms'] / 1000.0)
# get progress status message:
success, task_message = get_task_completion_info(instructor_task)
status = "Complete" if success else "Incomplete"
# generate row for this task:
row = [
str(instructor_task.task_type),
str(instructor_task.task_id),
str(instructor_task.requester),
instructor_task.created.isoformat(' '),
duration_sec,
str(instructor_task.task_state),
status,
task_message
]
datatable['data'].append(row)
if problem_url is None:
datatable['title'] = "{course_id}".format(course_id=course_key.to_deprecated_string())
elif student is not None:
datatable['title'] = "{course_id} > {location} > {student}".format(
course_id=course_key.to_deprecated_string(),
location=problem_url,
student=student.username
)
else:
datatable['title'] = "{course_id} > {location}".format(
course_id=course_key.to_deprecated_string(), location=problem_url
)
return msg, datatable
def uses_shib(course):
"""
Used to return whether course has Shibboleth as the enrollment domain
Returns a boolean indicating if Shibboleth authentication is set for this course.
"""
return course.enrollment_domain and course.enrollment_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX)
...@@ -205,9 +205,6 @@ FEATURES = { ...@@ -205,9 +205,6 @@ FEATURES = {
# Enable Custom Courses for EdX # Enable Custom Courses for EdX
'CUSTOM_COURSES_EDX': False, 'CUSTOM_COURSES_EDX': False,
# Enable legacy instructor dashboard
'ENABLE_INSTRUCTOR_LEGACY_DASHBOARD': False,
# Is this an edX-owned domain? (used for edX specific messaging and images) # Is this an edX-owned domain? (used for edX specific messaging and images)
'IS_EDX_DOMAIN': False, 'IS_EDX_DOMAIN': False,
......
...@@ -28,7 +28,6 @@ FEATURES['ENABLE_SERVICE_STATUS'] = True ...@@ -28,7 +28,6 @@ FEATURES['ENABLE_SERVICE_STATUS'] = True
FEATURES['ENABLE_INSTRUCTOR_EMAIL'] = True # Enable email for all Studio courses 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['REQUIRE_COURSE_EMAIL_AUTH'] = False # Give all courses email (don't require django-admin perms)
FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True
FEATURES['ENABLE_INSTRUCTOR_LEGACY_DASHBOARD'] = False
FEATURES['MULTIPLE_ENROLLMENT_ROLES'] = True FEATURES['MULTIPLE_ENROLLMENT_ROLES'] = True
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
......
...@@ -62,8 +62,6 @@ FEATURES['ENABLE_SERVICE_STATUS'] = True ...@@ -62,8 +62,6 @@ FEATURES['ENABLE_SERVICE_STATUS'] = True
FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True
FEATURES['ENABLE_INSTRUCTOR_LEGACY_DASHBOARD'] = True
FEATURES['ENABLE_SHOPPING_CART'] = True FEATURES['ENABLE_SHOPPING_CART'] = True
FEATURES['ENABLE_VERIFIED_CERTIFICATES'] = True FEATURES['ENABLE_VERIFIED_CERTIFICATES'] = True
......
...@@ -49,7 +49,6 @@ ...@@ -49,7 +49,6 @@
@import "views/teams"; @import "views/teams";
// course - instructor-only views // course - instructor-only views
@import "course/instructor/instructor";
@import "course/instructor/instructor_2"; @import "course/instructor/instructor_2";
@import "course/instructor/email"; @import "course/instructor/email";
@import "xmodule/descriptors/css/module-styles.scss"; @import "xmodule/descriptors/css/module-styles.scss";
......
.instructor-dashboard-wrapper {
display: table;
position: relative;
.beta-button-wrapper {
position: absolute;
top: 2em;
right: 2em;
}
.studio-edit-link{
position: absolute;
top: 3.5em;
right: 2em;
}
section.instructor-dashboard-content {
@extend .content;
padding: 40px;
width: 100%;
h1 {
@extend .top-header;
}
}
// form fields
.list-fields {
@extend %ui-no-list;
.field {
margin-bottom: $baseline;
&:last-child {
margin-bottom: 0;
}
.tip {
display: block;
margin-top: ($baseline/4);
color: tint(rgb(127,127,127),50%);
@include font-size(12);
}
}
}
// ====================
// system feedback - messages
.msg {
border-radius: 1px;
padding: 10px 15px;
margin-bottom: $baseline;
.copy {
font-weight: 600;
}
}
// TYPE: warning
.msg-warning {
border-top: 2px solid $warning-color;
background: tint($warning-color,95%);
.copy {
color: $warning-color;
}
}
// TYPE: confirm
.msg-confirm {
border-top: 2px solid $confirm-color;
background: tint($confirm-color,95%);
.copy {
color: $confirm-color;
}
}
// TYPE: confirm
.msg-error {
border-top: 2px solid $error-color;
background: tint($error-color,95%);
.copy {
color: $error-color;
}
}
// ====================
// inline copy
.copy-confirm {
color: $confirm-color;
}
.copy-warning {
color: $warning-color;
}
.copy-error {
color: $error-color;
}
.list-advice {
list-style: none;
padding: 0;
margin: 20px 0;
.item {
font-weight: 600;
margin-bottom: ($baseline/2);
&:last-child {
margin-bottom: 0;
}
}
}
//Metrics tab
.metrics-container {
position: relative;
width: 100%;
float: left;
clear: both;
margin-top: 25px;
}
.metrics-left {
position: relative;
width: 30%;
height: 640px;
float: left;
margin-right: 2.5%;
}
.metrics-right {
position: relative;
width: 65%;
height: 295px;
float: left;
margin-left: 2.5%;
margin-bottom: 25px;
}
.metrics-tooltip {
width: 250px;
background-color: lightgray;
padding: 3px;
}
.stacked-bar-graph-legend {
fill: white;
}
p.loading {
padding-top: 100px;
text-align: center;
}
p.nothing {
padding-top: 25px;
}
h3.attention {
padding: 10px;
border: 1px solid #999;
border-radius: 5px;
margin-top: 25px;
}
.wrapper-msg {
margin-bottom: ($baseline*1.5);
.msg {
margin-bottom: 0;
}
.note {
margin: 0;
}
}
}
.rtl .instructor-dashboard-wrapper .beta-button-wrapper,
.rtl .instructor-dashboard-wrapper .studio-edit-link {
left: 2em;
right: auto;
}
## NOTE: This is the template for the LEGACY instructor dashboard ##
## We are no longer supporting this file or accepting changes into it. ##
## Please see lms/templates/instructor for instructor dashboard templates ##
<%inherit file="../main.html" />
<%namespace name='static' file='/static_content.html'/>
<%!
from django.utils.translation import ugettext as _
from django.core.urlresolvers import reverse
%>
<%block name="pagetitle">${_("Legacy Instructor Dashboard")}</%block>
<%block name="nav_skip">#instructor-dashboard-content</%block>
<%block name="headextra">
<%static:css group='style-course-vendor'/>
<%static:css group='style-vendor-tinymce-content'/>
<%static:css group='style-vendor-tinymce-skin'/>
<%static:css group='style-course'/>
<script type="text/javascript">
// This is a hack to get tinymce to work correctly in Firefox until the annotator tool is refactored to not include
// tinymce globally.
if(typeof window.Range.prototype === "undefined") {
window.Range.prototype = { };
}
</script>
<script type="text/javascript" src="${static.url('js/vendor/flot/jquery.flot.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/flot/jquery.flot.axislabels.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/jquery-jvectormap-1.1.1/jquery-jvectormap-1.1.1.min.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/jquery-jvectormap-1.1.1/jquery-jvectormap-world-mill-en.js')}"></script>
<script type="text/javascript" src="${static.url('js/course_groups/cohorts.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/codemirror-compressed.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/tinymce/js/tinymce/tinymce.full.min.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/tinymce/js/tinymce/jquery.tinymce.min.js')}"></script>
<script type="text/javascript">
(function() {window.baseUrl = "${settings.STATIC_URL}";})(this);
</script>
<%static:js group='module-descriptor-js'/>
%if instructor_tasks is not None:
<script type="text/javascript" src="${static.url('js/pending_tasks.js')}"></script>
%endif
</%block>
<%include file="/courseware/course_navigation.html" args="active_page='instructor'" />
<style type="text/css">
table.stat_table {
font-family: verdana,arial,sans-serif;
font-size:11px;
color:#333333;
border-width: 1px;
border-color: #666666;
border-collapse: collapse;
}
table.stat_table th {
border-width: 1px;
padding: 8px;
border-style: solid;
border-color: #666666;
background-color: #dedede;
}
table.stat_table td {
border-width: 1px;
padding: 8px;
border-style: solid;
border-color: #666666;
background-color: #ffffff;
}
.divScroll {
height: 200px;
overflow: scroll;
}
a.selectedmode { background-color: yellow; }
textarea {
height: 200px;
}
.jvectormap-label {
position: absolute;
display: none;
border: solid 1px #CDCDCD;
-webkit-border-radius: 3px;
-moz-border-radius: 3px;
border-radius: 3px;
background: #292929;
color: white;
font-family: sans-serif, Verdana;
font-size: smaller;
padding: 3px;
}
.jvectormap-zoomin, .jvectormap-zoomout {
position: absolute;
left: 10px;
-webkit-border-radius: 3px;
-moz-border-radius: 3px;
border-radius: 3px;
background: #292929;
padding: 3px;
color: white;
width: 10px;
height: 10px;
cursor: pointer;
line-height: 10px;
text-align: center;
}
.jvectormap-zoomin {
top: 10px;
}
.jvectormap-zoomout {
top: 30px;
}
</style>
<script language="JavaScript" type="text/javascript">
function goto( mode)
{
document.idashform.idash_mode.value = mode;
document.idashform.submit() ;
}
</script>
<section class="container">
<div class="instructor-dashboard-wrapper">
<section class="instructor-dashboard-content" id="instructor-dashboard-content">
<div class="wrap-instructor-info studio-view beta-button-wrapper">
%if studio_url:
<a class="instructor-info-action" href="${studio_url}">${_("View Course in Studio")}</a>
%endif
<a class="instructor-info-action beta-button" href="${ standard_dashboard_url }">${_("Back To Instructor Dashboard")}</a>
</div>
<h1>${_("Legacy Instructor Dashboard")}</h1>
%if settings.FEATURES.get('IS_EDX_DOMAIN', False):
## Only show this banner on the edx.org website (other sites may choose to show this if they wish)
<div class="wrapper-msg urgency-low msg-warning is-shown">
<p>${_("You are using the legacy instructor dashboard, which we will retire in the near future.")} <a href="${ standard_dashboard_url }">${_("Return to the Instructor Dashboard")} <i class="icon fa fa-double-angle-right"></i></a></p>
<p class="note">${_("If the Instructor Dashboard is missing functionality, please contact your PM to let us know.")}</p>
</div>
%endif
<h2 class="navbar">[ <a href="#" onclick="goto('Grades');" class="${modeflag.get('Grades')}">Grades</a> |
<a href="#" onclick="goto('Admin');" class="${modeflag.get('Admin')}">${_("Admin")}</a> |
<a href="#" onclick="goto('Forum Admin');" class="${modeflag.get('Forum Admin')}">${_("Forum Admin")}</a> |
<a href="#" onclick="goto('Enrollment');" class="${modeflag.get('Enrollment')}">${_("Enrollment")}</a> |
<a href="#" onclick="goto('Data');" class="${modeflag.get('Data')}">${_("DataDump")}</a> |
<a href="#" onclick="goto('Manage Groups');" class="${modeflag.get('Manage Groups')}">${_("Manage Groups")}</a>
%if show_email_tab:
| <a href="#" onclick="goto('Email')" class="${modeflag.get('Email')}">${_("Email")}</a>
%endif
%if settings.FEATURES.get('CLASS_DASHBOARD'):
| <a href="#" onclick="goto('Metrics');" class="${modeflag.get('Metrics')}">${_("Metrics")}</a>
%endif
]
</h2>
<form name="idashform" method="POST">
<input type="hidden" name="csrfmiddlewaretoken" value="${ csrf_token }">
<input type="hidden" name="idash_mode" value="">
##-----------------------------------------------------------------------------
%if modeflag.get('Grades'):
%if offline_grade_log:
<p>
<span class="copy-warning">Pre-computed grades ${offline_grade_log} available: Use?
<input type='checkbox' name='use_offline_grades' value="yes">
</span>
</p>
%endif
<hr width="40%" style="align:left">
<h2>${_("Grade Downloads")}</h2>
% if disable_buttons:
<div class="msg msg-warning">
<div class="copy">
<p>
${_("Note: some of these buttons are known to time out for larger "
"courses. We have disabled those features for courses "
"with more than {max_enrollment} students.").format(
max_enrollment=settings.FEATURES['MAX_ENROLLMENT_INSTR_BUTTONS']
)}
</p>
</div>
</div>
% endif
<p>
<input type="submit" name="action" value="Dump list of enrolled students" class="${'is-disabled' if disable_buttons else ''}" aria-disabled="${'true' if disable_buttons else 'false'}">
</p>
<p>
<input type="submit" name="action" value="Dump all RAW grades for all students in this course" class="${'is-disabled' if disable_buttons else ''}" aria-disabled="${'true' if disable_buttons else 'false'}">
<input type="submit" name="action" value="Download CSV of all RAW grades" class="${'is-disabled' if disable_buttons else ''}" aria-disabled="${'true' if disable_buttons else 'false'}" >
</p>
<p>
%if not settings.FEATURES.get('ENABLE_ASYNC_ANSWER_DISTRIBUTION'):
<input type="submit" name="action" value="Download CSV of answer distributions" class="${'is-disabled' if disable_buttons else ''}" aria-disabled="${'true' if disable_buttons else 'false'}" >
%endif
<p class="is-deprecated">
${_("To download student grades and view the grading configuration for your course, visit the Data Download section of the Instructor Dashboard.")}
</p>
<p class="is-deprecated">
${_("To view the Gradebook (only available for courses with a small number of enrolled students), visit the Student Admin section of the Instructor Dashboard.")}
</p>
</p>
<hr width="40%" style="align:left">
%if settings.FEATURES.get('REMOTE_GRADEBOOK_URL','') and instructor_access:
<%
rg = course.remote_gradebook
%>
<h3>${_("Export grades to remote gradebook")}</h3>
<p>${_("The assignments defined for this course should match the ones stored in the gradebook, for this to work properly!")}</p>
<ul>
<li>${_("Gradebook name:")} <span class="copy-confirm">${rg.get('name','None defined!')}</span>
<br/>
<br/>
<input type="submit" name="action" value="List assignments available in remote gradebook">
<input type="submit" name="action" value="List enrolled students matching remote gradebook">
<br/>
<br/>
</li>
<li><input type="submit" name="action" value="List assignments available for this course">
<br/>
<br/>
</li>
<li>${_("Assignment name:")} <input type="text" name="assignment_name" size=40 >
<br/>
<br/>
<input type="submit" name="action" value="Display grades for assignment">
<input type="submit" name="action" value="Export grades for assignment to remote gradebook">
<input type="submit" name="action" value="Export CSV file of grades for assignment">
</li>
</ul>
<hr width="40%" style="align:left">
%endif
%if settings.FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS'):
<H2>${_("Course-specific grade adjustment")}</h2>
<p class="is-deprecated">${_("To perform these actions, visit the Student Admin section of the Instructor Dashboard.")}</p>
%endif
<h2>${_("Student-specific grade inspection and adjustment")}</h2>
<p class="is-deprecated">${_("To perform these actions, visit the Student Admin section of the Instructor Dashboard.")}</p>
%endif
##-----------------------------------------------------------------------------
%if modeflag.get('Admin'):
%if instructor_access or admin_access:
<p class="is-deprecated">${_("To add or remove course staff or instructors, visit the Membership section of the Instructor Dashboard.")}</p>
%endif
%if settings.FEATURES['ENABLE_MANUAL_GIT_RELOAD'] and admin_access:
<p>
<input type="submit" name="action" value="Reload course from XML files">
<input type="submit" name="action" value="GIT pull and Reload course">
%endif
%endif
##-----------------------------------------------------------------------------
%if modeflag.get('Forum Admin'):
<p class="is-deprecated">${_("To manage forum roles, visit the Membership section of the Instructor Dashboard.")}</p>
%endif
##-----------------------------------------------------------------------------
%if modeflag.get('Enrollment'):
<hr width="40%" style="align:left">
<h2>${_("Enrollment Data")}</h2>
% if disable_buttons:
<div class="msg msg-warning">
<div class="copy">
<p>
${_("Note: some of these buttons are known to time out for larger "
"courses. We have disabled those features for courses "
"with more than {max_enrollment} students.").format(
max_enrollment=settings.FEATURES['MAX_ENROLLMENT_INSTR_BUTTONS']
)}
</p>
</div>
</div>
% endif
<p class="is-deprecated">
${_("To download a CSV file containing profile information for students who are enrolled in this course, visit the Data Download section of the Instructor Dashboard.")}
</p>
<p class="is-deprecated">
${_("To download a list of students who may enroll in this course but have not yet signed up for it, visit the Data Download section of the Instructor Dashboard.")}
</p>
<hr width="40%" style="align:left">
%if settings.FEATURES.get('REMOTE_GRADEBOOK_URL','') and instructor_access:
<%
rg = course.remote_gradebook
%>
<p>${_("Pull enrollment from remote gradebook")}</p>
<ul>
<li>${_("Gradebook name:")} <span class="copy-confirm">${rg.get('name','None defined!')}</span>
<li>${_("Section:")} <input type="text" name="gradebook_section" size=40 value="${rg.get('section','')}"></li>
</ul>
<input type="submit" name="action" value="List sections available in remote gradebook">
<input type="submit" name="action" value="List students in section in remote gradebook">
<input type="submit" name="action" value="Overload enrollment list using remote gradebook">
<input type="submit" name="action" value="Merge enrollment list with remote gradebook">
<hr width="40%" style="align:left">
%endif
%endif
##-----------------------------------------------------------------------------
%if modeflag.get('Data'):
<hr width="40%" style="align:left">
<p class="is-deprecated">
${_("To download a CSV listing student responses to a given problem, visit the Data Download section of the Instructor Dashboard.")}
</p>
<p class="is-deprecated">
${_("To download student profile data and anonymized IDs, visit the Data Download section of the Instructor Dashboard.")}
</p>
<hr width="40%" style="align:left">
%endif
##-----------------------------------------------------------------------------
%if modeflag.get('Manage Groups'):
%if instructor_access:
%if course_is_cohorted:
<p class="is-deprecated">${_("To manage beta tester roles and cohorts, visit the Membership section of the Instructor Dashboard.")}</p>
%else:
<p class="is-deprecated">${_("To manage beta tester roles, visit the Membership section of the Instructor Dashboard.")}</p>
%endif
%endif
%endif
##-----------------------------------------------------------------------------
%if modeflag.get('Email'):
<p class="is-deprecated">${_("To send email, visit the Email section of the Instructor Dashboard.")}</p>
%endif
</form>
##-----------------------------------------------------------------------------
%if msg:
<p></p><p id="idash_msg">${msg}</p>
%endif
##-----------------------------------------------------------------------------
%if datatable:
<br/>
<br/>
<p>
<hr width="100%">
<h2>${datatable['title'] | h}</h2>
<table class="stat_table">
<tr>
%for hname in datatable['header']:
<th>${hname | h}</th>
%endfor
</tr>
%for row in datatable['data']:
<tr>
%for value in row:
<td>${value | h}</td>
%endfor
</tr>
%endfor
</table>
</p>
%endif
## Output tasks in progress
%if instructor_tasks is not None and len(instructor_tasks) > 0:
<hr width="100%">
<h2>${_("Pending Instructor Tasks")}</h2>
<div id="task-progress-wrapper">
<table class="stat_table">
<tr>
<th>${_("Task Type")}</th>
<th>${_("Task inputs")}</th>
<th>${_("Task Id")}</th>
<th>${_("Requester")}</th>
<th>${_("Submitted")}</th>
<th>${_("Task State")}</th>
<th>${_("Duration (sec)")}</th>
<th>${_("Task Progress")}</th>
</tr>
%for tasknum, instructor_task in enumerate(instructor_tasks):
<tr id="task-progress-entry-${tasknum}" class="task-progress-entry"
data-task-id="${instructor_task.task_id}"
data-in-progress="true">
<td>${instructor_task.task_type}</td>
<td>${instructor_task.task_input}</td>
<td class="task-id">${instructor_task.task_id}</td>
<td>${instructor_task.requester}</td>
<td>${instructor_task.created}</td>
<td class="task-state">${instructor_task.task_state}</td>
<td class="task-duration">${_("unknown")}</td>
<td class="task-progress">${_("unknown")}</td>
</tr>
%endfor
</table>
</div>
<br/>
%endif
##-----------------------------------------------------------------------------
%if modeflag.get('Admin') and course_stats:
<br/>
<br/>
<p>
<hr width="100%">
<h2>${course_stats['title'] | h}</h2>
<table class="stat_table">
<tr>
%for hname in course_stats['header']:
<th>${hname | h}</th>
%endfor
</tr>
%for row in course_stats['data']:
<tr>
%for value in row:
<td>${value | h}</td>
%endfor
</tr>
%endfor
</table>
</p>
%else:
<br/>
<br/>
<h2>${_("Course Statistics At A Glance")}</h2>
<p class="is-deprecated">
${_("View course statistics in the Admin section of this legacy instructor dashboard.")}
</p>
%endif
##-----------------------------------------------------------------------------
%if modeflag.get('Admin'):
% if course_errors is not UNDEFINED:
<h2>${_("Course errors")}</h2>
<div id="course-errors">
%if not course_errors:
None
%else:
<ul>
% for (summary, err) in course_errors:
<li>${summary | h}
% if err:
<ul><li><pre>${err | h}</pre></li></ul>
% else:
<p>&nbsp;</p>
% endif
</li>
% endfor
</ul>
%endif
</div>
% endif
%endif
</section>
</div>
</section>
...@@ -91,9 +91,6 @@ from django.core.urlresolvers import reverse ...@@ -91,9 +91,6 @@ from django.core.urlresolvers import reverse
%if studio_url: %if studio_url:
<a class="instructor-info-action" href="${studio_url}">${_("View Course in Studio")}</a> <a class="instructor-info-action" href="${studio_url}">${_("View Course in Studio")}</a>
%endif %endif
%if settings.FEATURES.get('ENABLE_INSTRUCTOR_LEGACY_DASHBOARD'):
<a class="instructor-info-action" href="${ old_dashboard_url }"> ${_("Revert to Legacy Dashboard")} </a>
%endif
</div> </div>
<h1>${_("Instructor Dashboard")}</h1> <h1>${_("Instructor Dashboard")}</h1>
......
...@@ -547,7 +547,6 @@ urlpatterns += ( ...@@ -547,7 +547,6 @@ urlpatterns += (
), ),
include(COURSE_URLS) include(COURSE_URLS)
), ),
# see ENABLE_INSTRUCTOR_LEGACY_DASHBOARD section for legacy dash urls
# Cohorts management # Cohorts management
url( url(
...@@ -753,13 +752,6 @@ if settings.FEATURES.get('ENABLE_STUDENT_HISTORY_VIEW'): ...@@ -753,13 +752,6 @@ if settings.FEATURES.get('ENABLE_STUDENT_HISTORY_VIEW'):
), ),
) )
if settings.FEATURES.get('ENABLE_INSTRUCTOR_LEGACY_DASHBOARD'):
urlpatterns += (
url(r'^courses/{}/legacy_instructor_dash$'.format(settings.COURSE_ID_PATTERN),
'instructor.views.legacy.instructor_dashboard', name="instructor_dashboard_legacy"),
)
if settings.FEATURES.get('CLASS_DASHBOARD'): if settings.FEATURES.get('CLASS_DASHBOARD'):
urlpatterns += ( urlpatterns += (
url(r'^class_dashboard/', include('class_dashboard.urls')), url(r'^class_dashboard/', include('class_dashboard.urls')),
......
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