Merge pull request #615 from MITx/feature/ichuang/instructor-dashboard-upgrade

Upgrade to instructor dashboard
This commit is contained in:
Calen Pennington
2012-09-05 08:03:55 -07:00
19 changed files with 688 additions and 126 deletions

View File

@@ -30,7 +30,7 @@ def has_access(user, obj, action):
Things this module understands:
- start dates for modules
- DISABLE_START_DATES
- different access for staff, course staff, and students.
- different access for instructor, staff, course staff, and students.
user: a Django user object. May be anonymous.
@@ -70,6 +70,20 @@ def has_access(user, obj, action):
raise TypeError("Unknown object type in has_access(): '{0}'"
.format(type(obj)))
def get_access_group_name(obj,action):
'''
Returns group name for user group which has "action" access to the given object.
Used in managing access lists.
'''
if isinstance(obj, CourseDescriptor):
return _get_access_group_name_course_desc(obj, action)
# Passing an unknown object here is a coding error, so rather than
# returning a default, complain.
raise TypeError("Unknown object type in get_access_group_name(): '{0}'"
.format(type(obj)))
# ================ Implementation helpers ================================
@@ -138,11 +152,19 @@ def _has_access_course_desc(user, course, action):
'load': can_load,
'enroll': can_enroll,
'see_exists': see_exists,
'staff': lambda: _has_staff_access_to_descriptor(user, course)
'staff': lambda: _has_staff_access_to_descriptor(user, course),
'instructor': lambda: _has_instructor_access_to_descriptor(user, course),
}
return _dispatch(checkers, action, user, course)
def _get_access_group_name_course_desc(course, action):
'''
Return name of group which gives staff access to course. Only understands action = 'staff'
'''
if not action=='staff':
return []
return _course_staff_group_name(course.location)
def _has_access_error_desc(user, descriptor, action):
"""
@@ -292,6 +314,17 @@ def _course_staff_group_name(location):
"""
return 'staff_%s' % Location(location).course
def _course_instructor_group_name(location):
"""
Get the name of the instructor group for a location. Right now, that's instructor_COURSE.
A course instructor has all staff privileges, but also can manage list of course staff (add, remove, list).
location: something that can passed to Location.
"""
return 'instructor_%s' % Location(location).course
def _has_global_staff_access(user):
if user.is_staff:
debug("Allow: user.is_staff")
@@ -301,17 +334,28 @@ def _has_global_staff_access(user):
return False
def _has_staff_access_to_location(user, location):
'''
Returns True if the given user has staff access to a location. For now this
is equivalent to having staff access to the course location.course.
def _has_instructor_access_to_location(user, location):
return _has_access_to_location(user, location, 'instructor')
This means that user is in the staff_* group, or is an overall admin.
def _has_staff_access_to_location(user, location):
return _has_access_to_location(user, location, 'staff')
def _has_access_to_location(user, location, access_level):
'''
Returns True if the given user has access_level (= staff or
instructor) access to a location. For now this is equivalent to
having staff / instructor access to the course location.course.
This means that user is in the staff_* group or instructor_* group, or is an overall admin.
TODO (vshnayder): this needs to be changed to allow per-course_id permissions, not per-course
(e.g. staff in 2012 is different from 2013, but maybe some people always have access)
course is a string: the course field of the location being accessed.
location = location
access_level = string, either "staff" or "instructor"
'''
if user is None or (not user.is_authenticated()):
debug("Deny: no user or anon user")
@@ -322,24 +366,46 @@ def _has_staff_access_to_location(user, location):
# If not global staff, is the user in the Auth group for this class?
user_groups = [g.name for g in user.groups.all()]
staff_group = _course_staff_group_name(location)
if staff_group in user_groups:
debug("Allow: user in group %s", staff_group)
return True
debug("Deny: user not in group %s", staff_group)
if access_level == 'staff':
staff_group = _course_staff_group_name(location)
if staff_group in user_groups:
debug("Allow: user in group %s", staff_group)
return True
debug("Deny: user not in group %s", staff_group)
if access_level == 'instructor' or access_level == 'staff': # instructors get staff privileges
instructor_group = _course_instructor_group_name(location)
if instructor_group in user_groups:
debug("Allow: user in group %s", instructor_group)
return True
debug("Deny: user not in group %s", instructor_group)
else:
log.debug("Error in access._has_access_to_location access_level=%s unknown" % access_level)
return False
def _has_staff_access_to_course_id(user, course_id):
"""Helper method that takes a course_id instead of a course name"""
loc = CourseDescriptor.id_to_location(course_id)
return _has_staff_access_to_location(user, loc)
def _has_instructor_access_to_descriptor(user, descriptor):
"""Helper method that checks whether the user has staff access to
the course of the location.
descriptor: something that has a location attribute
"""
return _has_instructor_access_to_location(user, descriptor.location)
def _has_staff_access_to_descriptor(user, descriptor):
"""Helper method that checks whether the user has staff access to
the course of the location.
location: something that can be passed to Location
descriptor: something that has a location attribute
"""
return _has_staff_access_to_location(user, descriptor.location)

View File

@@ -24,7 +24,7 @@ def yield_module_descendents(module):
stack.extend( next_module.get_display_items() )
yield next_module
def grade(student, request, course, student_module_cache=None):
def grade(student, request, course, student_module_cache=None, keep_raw_scores=False):
"""
This grades a student as quickly as possible. It retuns the
output from the course grader, augmented with the final letter
@@ -38,11 +38,13 @@ def grade(student, request, course, student_module_cache=None):
up the grade. (For display)
- grade_breakdown : A breakdown of the major components that
make up the final grade. (For display)
- keep_raw_scores : if True, then value for key 'raw_scores' contains scores for every graded module
More information on the format is in the docstring for CourseGrader.
"""
grading_context = course.grading_context
raw_scores = []
if student_module_cache == None:
student_module_cache = StudentModuleCache(course.id, student, grading_context['all_descriptors'])
@@ -83,7 +85,7 @@ def grade(student, request, course, student_module_cache=None):
if correct is None and total is None:
continue
if settings.GENERATE_PROFILE_SCORES:
if settings.GENERATE_PROFILE_SCORES: # for debugging!
if total > 1:
correct = random.randrange(max(total - 2, 1), total + 1)
else:
@@ -97,6 +99,8 @@ def grade(student, request, course, student_module_cache=None):
scores.append(Score(correct, total, graded, module.metadata.get('display_name')))
section_total, graded_total = graders.aggregate_scores(scores, section_name)
if keep_raw_scores:
raw_scores += scores
else:
section_total = Score(0.0, 1.0, False, section_name)
graded_total = Score(0.0, 1.0, True, section_name)
@@ -117,7 +121,10 @@ def grade(student, request, course, student_module_cache=None):
letter_grade = grade_for_percentage(course.grade_cutoffs, grade_summary['percent'])
grade_summary['grade'] = letter_grade
grade_summary['totaled_scores'] = totaled_scores # make this available, eg for instructor download & debugging
if keep_raw_scores:
grade_summary['raw_scores'] = raw_scores # way to get all RAW scores out to instructor
# so grader can be double-checked
return grade_summary
def grade_for_percentage(grade_cutoffs, percentage):

View File

@@ -361,96 +361,3 @@ def progress(request, course_id, student_id=None):
# ======== Instructor views =============================================================================
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
def gradebook(request, course_id):
"""
Show the gradebook for this course:
- only displayed to course staff
- shows students who are enrolled.
"""
course = get_course_with_access(request.user, course_id, 'staff')
enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).order_by('username')
# TODO (vshnayder): implement pagination.
enrolled_students = enrolled_students[:1000] # HACK!
student_info = [{'username': student.username,
'id': student.id,
'email': student.email,
'grade_summary': grades.grade(student, request, course),
'realname': UserProfile.objects.get(user=student).name
}
for student in enrolled_students]
return render_to_response('courseware/gradebook.html', {'students': student_info,
'course': course,
'course_id': course_id,
# Checked above
'staff_access': True,})
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
def grade_summary(request, course_id):
"""Display the grade summary for a course."""
course = get_course_with_access(request.user, course_id, 'staff')
# For now, just a static page
context = {'course': course,
'staff_access': True,}
return render_to_response('courseware/grade_summary.html', context)
@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 = get_course_with_access(request.user, course_id, 'staff')
# For now, just a static page
context = {'course': course,
'staff_access': True,}
return render_to_response('courseware/instructor_dashboard.html', context)
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
def enroll_students(request, course_id):
''' Allows a staff member to enroll students in a course.
This is a short-term hack for Berkeley courses launching fall
2012. In the long term, we would like functionality like this, but
we would like both the instructor and the student to agree. Right
now, this allows any instructor to add students to their course,
which we do not want.
It is poorly written and poorly tested, but it's designed to be
stripped out.
'''
course = get_course_with_access(request.user, course_id, 'staff')
existing_students = [ce.user.email for ce in CourseEnrollment.objects.filter(course_id = course_id)]
if 'new_students' in request.POST:
new_students = request.POST['new_students'].split('\n')
else:
new_students = []
new_students = [s.strip() for s in new_students]
added_students = []
rejected_students = []
for student in new_students:
try:
nce = CourseEnrollment(user=User.objects.get(email = student), course_id = course_id)
nce.save()
added_students.append(student)
except:
rejected_students.append(student)
return render_to_response("enroll_students.html", {'course':course_id,
'existing_students': existing_students,
'added_students': added_students,
'rejected_students': rejected_students,
'debug':new_students})

View File

View File

@@ -0,0 +1,84 @@
"""
Unit tests for instructor dashboard
Based on (and depends on) unit tests for courseware.
Notes for running by hand:
django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/instructor
"""
import courseware.tests.tests as ct
from nose import SkipTest
from mock import patch, Mock
from override_settings import override_settings
# Need access to internal func to put users in the right group
from courseware.access import _course_staff_group_name
from django.contrib.auth.models import User, Group
from django.conf import settings
from django.core.urlresolvers import reverse
import xmodule.modulestore.django
from xmodule.modulestore.django import modulestore
@override_settings(MODULESTORE=ct.TEST_DATA_XML_MODULESTORE)
class TestInstructorDashboardGradeDownloadCSV(ct.PageLoader):
'''
Check for download of csv
'''
def setUp(self):
xmodule.modulestore.django._MODULESTORES = {}
courses = modulestore().get_courses()
def find_course(name):
"""Assumes the course is present"""
return [c for c in courses if c.location.course==name][0]
self.full = find_course("full")
self.toy = find_course("toy")
# Create two accounts
self.student = 'view@test.com'
self.instructor = 'view2@test.com'
self.password = 'foo'
self.create_account('u1', self.student, self.password)
self.create_account('u2', self.instructor, self.password)
self.activate_user(self.student)
self.activate_user(self.instructor)
group_name = _course_staff_group_name(self.toy.location)
g = Group.objects.create(name=group_name)
g.user_set.add(ct.user(self.instructor))
self.logout()
self.login(self.instructor, self.password)
self.enroll(self.toy)
def test_download_grades_csv(self):
print "running test_download_grades_csv"
course = self.toy
url = reverse('instructor_dashboard', kwargs={'course_id': course.id})
msg = "url = %s\n" % url
response = self.client.post(url, {'action': 'Download CSV of all student grades for this course',
})
msg += "instructor dashboard download csv grades: response = '%s'\n" % response
self.assertEqual(response['Content-Type'],'text/csv',msg)
cdisp = response['Content-Disposition'].replace('TT_2012','2012') # jenkins course_id is TT_2012_Fall instead of 2012_Fall?
msg += "cdisp = '%s'\n" % cdisp
self.assertEqual(cdisp,'attachment; filename=grades_edX/toy/2012_Fall.csv',msg)
body = response.content.replace('\r','')
msg += "body = '%s'\n" % body
expected_body = '''"ID","Username","Full Name","edX email","External email","HW 01","HW 02","HW 03","HW 04","HW 05","HW 06","HW 07","HW 08","HW 09","HW 10","HW 11","HW 12","HW Avg","Lab 01","Lab 02","Lab 03","Lab 04","Lab 05","Lab 06","Lab 07","Lab 08","Lab 09","Lab 10","Lab 11","Lab 12","Lab Avg","Midterm","Final"
"2","u2","Fred Weasley","view2@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"
'''
self.assertEqual(body, expected_body, msg)

View File

@@ -0,0 +1,355 @@
# ======== Instructor views =============================================================================
import csv
import itertools
import json
import logging
import os
import urllib
import track.views
from functools import partial
from collections import defaultdict
from django.conf import settings
from django.core.context_processors import csrf
from django.core.urlresolvers import reverse
from django.contrib.auth.models import User, Group
from django.contrib.auth.decorators import login_required
from django.http import Http404, HttpResponse
from django.shortcuts import redirect
from mitxmako.shortcuts import render_to_response, render_to_string
#from django.views.decorators.csrf import ensure_csrf_cookie
from django_future.csrf import ensure_csrf_cookie
from django.views.decorators.cache import cache_control
from courseware import grades
from courseware.access import has_access, get_access_group_name
from courseware.courses import (get_course_with_access, get_courses_by_university)
from student.models import UserProfile
from student.models import UserTestGroup, CourseEnrollment
from util.cache import cache, cache_if_anonymous
from xmodule.course_module import CourseDescriptor
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError, NoPathToItem
from xmodule.modulestore.search import path_to_location
log = logging.getLogger("mitx.courseware")
template_imports = {'urllib': urllib}
@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 = get_course_with_access(request.user, course_id, 'staff')
instructor_access = has_access(request.user, course, 'instructor') # an instructor can manage staff lists
msg = ''
# msg += ('POST=%s' % dict(request.POST)).replace('<','&lt;')
def escape(s):
"""escape HTML special characters in string"""
return str(s).replace('<', '&lt;').replace('>', '&gt;')
# assemble some course statistics for output to instructor
datatable = {'header': ['Statistic', 'Value'],
'title': 'Course Statistics At A Glance',
}
data = [['# Enrolled', CourseEnrollment.objects.filter(course_id=course_id).count()]]
data += compute_course_stats(course).items()
if request.user.is_staff:
data.append(['metadata', escape(str(course.metadata))])
datatable['data'] = data
def return_csv(fn, datatable):
response = HttpResponse(mimetype='text/csv')
response['Content-Disposition'] = 'attachment; filename=%s' % fn
writer = csv.writer(response, dialect='excel', quotechar='"', quoting=csv.QUOTE_ALL)
writer.writerow(datatable['header'])
for datarow in datatable['data']:
writer.writerow(datarow)
return response
def get_staff_group(course):
staffgrp = get_access_group_name(course, 'staff')
try:
group = Group.objects.get(name=staffgrp)
except Group.DoesNotExist:
group = Group(name=staffgrp) # create the group
group.save()
return group
# process actions from form POST
action = request.POST.get('action', '')
if 'GIT pull' in action:
data_dir = course.metadata['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 %s" % gdir
else:
cmd = "cd %s; git reset --hard HEAD; git clean -f -d; git pull origin; chmod g+w course.xml" % gdir
msg += "git pull on %s:<p>" % data_dir
msg += "<pre>%s</pre></p>" % escape(os.popen(cmd).read())
track.views.server_track(request, 'git pull %s' % data_dir, {}, page='idashboard')
if 'Reload course' in action:
log.debug('reloading %s (%s)' % (course_id, course))
try:
data_dir = course.metadata['data_dir']
modulestore().try_load_course(data_dir)
msg += "<br/><p>Course reloaded from %s</p>" % data_dir
track.views.server_track(request, 'reload %s' % data_dir, {}, page='idashboard')
course_errors = modulestore().get_item_errors(course.location)
msg += '<ul>'
for cmsg, cerr in course_errors:
msg += "<li>%s: <pre>%s</pre>" % (cmsg,escape(cerr))
msg += '</ul>'
except Exception as err:
msg += '<br/><p>Error: %s</p>' % escape(err)
elif action == 'Dump list of enrolled students':
log.debug(action)
datatable = get_student_grade_summary_data(request, course, course_id, get_grades=False)
datatable['title'] = 'List of students enrolled in %s' % course_id
track.views.server_track(request, 'list-students', {}, page='idashboard')
elif 'Dump Grades' in action:
log.debug(action)
datatable = get_student_grade_summary_data(request, course, course_id, get_grades=True)
datatable['title'] = 'Summary Grades of students enrolled in %s' % course_id
track.views.server_track(request, 'dump-grades', {}, page='idashboard')
elif 'Dump all RAW grades' in action:
log.debug(action)
datatable = get_student_grade_summary_data(request, course, course_id, get_grades=True,
get_raw_scores=True)
datatable['title'] = 'Raw Grades of students enrolled in %s' % course_id
track.views.server_track(request, 'dump-grades-raw', {}, page='idashboard')
elif 'Download CSV of all student grades' in action:
track.views.server_track(request, 'dump-grades-csv', {}, page='idashboard')
return return_csv('grades_%s.csv' % course_id,
get_student_grade_summary_data(request, course, course_id))
elif 'Download CSV of all RAW grades' in action:
track.views.server_track(request, 'dump-grades-csv-raw', {}, page='idashboard')
return return_csv('grades_%s_raw.csv' % course_id,
get_student_grade_summary_data(request, course, course_id, get_raw_scores=True))
elif 'List course staff' in action:
group = get_staff_group(course)
msg += 'Staff group = %s' % group.name
log.debug('staffgrp=%s' % group.name)
uset = group.user_set.all()
datatable = {'header': ['Username', 'Full name']}
datatable['data'] = [[x.username, x.profile.name] for x in uset]
datatable['title'] = 'List of Staff in course %s' % course_id
track.views.server_track(request, 'list-staff', {}, page='idashboard')
elif action == 'Add course staff':
uname = request.POST['staffuser']
try:
user = User.objects.get(username=uname)
except User.DoesNotExist:
msg += '<font color="red">Error: unknown username "%s"</font>' % uname
user = None
if user is not None:
group = get_staff_group(course)
msg += '<font color="green">Added %s to staff group = %s</font>' % (user, group.name)
log.debug('staffgrp=%s' % group.name)
user.groups.add(group)
track.views.server_track(request, 'add-staff %s' % user, {}, page='idashboard')
elif action == 'Remove course staff':
uname = request.POST['staffuser']
try:
user = User.objects.get(username=uname)
except User.DoesNotExist:
msg += '<font color="red">Error: unknown username "%s"</font>' % uname
user = None
if user is not None:
group = get_staff_group(course)
msg += '<font color="green">Removed %s from staff group = %s</font>' % (user, group.name)
log.debug('staffgrp=%s' % group.name)
user.groups.remove(group)
track.views.server_track(request, 'remove-staff %s' % user, {}, page='idashboard')
# For now, mostly a static page
context = {'course': course,
'staff_access': True,
'admin_access': request.user.is_staff,
'instructor_access': instructor_access,
'datatable': datatable,
'msg': msg,
}
return render_to_response('courseware/instructor_dashboard.html', context)
def get_student_grade_summary_data(request, course, course_id, get_grades=True, get_raw_scores=False):
'''
Return data arrays with student identity and grades for specified course.
course = CourseDescriptor
course_id = 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.
'''
enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).order_by('username')
header = ['ID', 'Username', 'Full Name', 'edX email', 'External email']
if get_grades:
# just to construct the header
gradeset = grades.grade(enrolled_students[0], request, course, keep_raw_scores=get_raw_scores)
# log.debug('student %s gradeset %s' % (enrolled_students[0], gradeset))
if get_raw_scores:
header += [score.section for score in gradeset['raw_scores']]
else:
header += [x['label'] for x in gradeset['section_breakdown']]
datatable = {'header': header}
data = []
for student in enrolled_students:
datarow = [ student.id, student.username, student.profile.name, student.email ]
try:
datarow.append(student.externalauthmap.external_email)
except: # ExternalAuthMap.DoesNotExist
datarow.append('')
if get_grades:
gradeset = grades.grade(student, request, course, keep_raw_scores=get_raw_scores)
# log.debug('student=%s, gradeset=%s' % (student,gradeset))
if get_raw_scores:
datarow += [score.earned for score in gradeset['raw_scores']]
else:
datarow += [x['percent'] for x in gradeset['section_breakdown']]
data.append(datarow)
datatable['data'] = data
return datatable
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
def gradebook(request, course_id):
"""
Show the gradebook for this course:
- only displayed to course staff
- shows students who are enrolled.
"""
course = get_course_with_access(request.user, course_id, 'staff')
enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).order_by('username')
# TODO (vshnayder): implement pagination.
enrolled_students = enrolled_students[:1000] # HACK!
student_info = [{'username': student.username,
'id': student.id,
'email': student.email,
'grade_summary': grades.grade(student, request, course),
'realname': student.profile.name,
}
for student in enrolled_students]
return render_to_response('courseware/gradebook.html', {'students': student_info,
'course': course,
'course_id': course_id,
# Checked above
'staff_access': True, })
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
def grade_summary(request, course_id):
"""Display the grade summary for a course."""
course = get_course_with_access(request.user, course_id, 'staff')
# For now, just a static page
context = {'course': course,
'staff_access': True, }
return render_to_response('courseware/grade_summary.html', context)
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
def enroll_students(request, course_id):
''' Allows a staff member to enroll students in a course.
This is a short-term hack for Berkeley courses launching fall
2012. In the long term, we would like functionality like this, but
we would like both the instructor and the student to agree. Right
now, this allows any instructor to add students to their course,
which we do not want.
It is poorly written and poorly tested, but it's designed to be
stripped out.
'''
course = get_course_with_access(request.user, course_id, 'staff')
existing_students = [ce.user.email for ce in CourseEnrollment.objects.filter(course_id=course_id)]
if 'new_students' in request.POST:
new_students = request.POST['new_students'].split('\n')
else:
new_students = []
new_students = [s.strip() for s in new_students]
added_students = []
rejected_students = []
for student in new_students:
try:
nce = CourseEnrollment(user=User.objects.get(email=student), course_id=course_id)
nce.save()
added_students.append(student)
except:
rejected_students.append(student)
return render_to_response("enroll_students.html", {'course': course_id,
'existing_students': existing_students,
'added_students': added_students,
'rejected_students': rejected_students,
'debug': new_students})
#-----------------------------------------------------------------------------
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 c in children:
walk(c)
walk(course)
stats = dict(counts) # number of each kind of module
return stats

View File

@@ -67,7 +67,10 @@ class Command(BaseCommand):
password = GenPasswd(12)
# get name from kerberos
kname = os.popen("finger %s | grep 'name:'" % email).read().strip().split('name: ')[1].strip()
try:
kname = os.popen("finger %s | grep 'name:'" % email).read().strip().split('name: ')[1].strip()
except:
kname = ''
name = raw_input('Full name: [%s] ' % kname).strip()
if name=='':
name = kname