Remove Legacy Instructor Dashboard
This commit is contained in:
@@ -1,76 +0,0 @@
|
||||
#!/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']))
|
||||
@@ -1,357 +0,0 @@
|
||||
"""
|
||||
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
|
||||
)
|
||||
)
|
||||
@@ -1,289 +0,0 @@
|
||||
# -*- 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]
|
||||
)
|
||||
@@ -1,70 +0,0 @@
|
||||
"""
|
||||
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):
|
||||
'generate_bulk_certificate_exceptions_url': generate_bulk_certificate_exceptions_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)
|
||||
|
||||
|
||||
@@ -1,1237 +0,0 @@
|
||||
"""
|
||||
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('<', '<')
|
||||
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 = {
|
||||
# Enable Custom Courses for EdX
|
||||
'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_EDX_DOMAIN': False,
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ FEATURES['ENABLE_SERVICE_STATUS'] = True
|
||||
FEATURES['ENABLE_INSTRUCTOR_EMAIL'] = True # Enable email for all Studio courses
|
||||
FEATURES['REQUIRE_COURSE_EMAIL_AUTH'] = False # Give all courses email (don't require django-admin perms)
|
||||
FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True
|
||||
FEATURES['ENABLE_INSTRUCTOR_LEGACY_DASHBOARD'] = False
|
||||
FEATURES['MULTIPLE_ENROLLMENT_ROLES'] = True
|
||||
FEATURES['ENABLE_SHOPPING_CART'] = True
|
||||
FEATURES['AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'] = True
|
||||
|
||||
@@ -62,8 +62,6 @@ FEATURES['ENABLE_SERVICE_STATUS'] = True
|
||||
|
||||
FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True
|
||||
|
||||
FEATURES['ENABLE_INSTRUCTOR_LEGACY_DASHBOARD'] = True
|
||||
|
||||
FEATURES['ENABLE_SHOPPING_CART'] = True
|
||||
|
||||
FEATURES['ENABLE_VERIFIED_CERTIFICATES'] = True
|
||||
|
||||
@@ -49,7 +49,6 @@
|
||||
@import "views/teams";
|
||||
|
||||
// course - instructor-only views
|
||||
@import "course/instructor/instructor";
|
||||
@import "course/instructor/instructor_2";
|
||||
@import "course/instructor/email";
|
||||
@import "xmodule/descriptors/css/module-styles.scss";
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,496 +0,0 @@
|
||||
## 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> </p>
|
||||
% endif
|
||||
</li>
|
||||
% endfor
|
||||
</ul>
|
||||
%endif
|
||||
</div>
|
||||
% endif
|
||||
%endif
|
||||
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
@@ -91,9 +91,6 @@ from django.core.urlresolvers import reverse
|
||||
%if studio_url:
|
||||
<a class="instructor-info-action" href="${studio_url}">${_("View Course in Studio")}</a>
|
||||
%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>
|
||||
|
||||
<h1>${_("Instructor Dashboard")}</h1>
|
||||
|
||||
@@ -547,7 +547,6 @@ urlpatterns += (
|
||||
),
|
||||
include(COURSE_URLS)
|
||||
),
|
||||
# see ENABLE_INSTRUCTOR_LEGACY_DASHBOARD section for legacy dash urls
|
||||
|
||||
# Cohorts management
|
||||
url(
|
||||
@@ -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'):
|
||||
urlpatterns += (
|
||||
url(r'^class_dashboard/', include('class_dashboard.urls')),
|
||||
|
||||
Reference in New Issue
Block a user