417 lines
17 KiB
Python
417 lines
17 KiB
Python
# ======== 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 psychometrics import psychoanalyze
|
|
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('<','<')
|
|
|
|
problems = []
|
|
plots = []
|
|
|
|
# the instructor dashboard page is modal: grades, psychometrics, admin
|
|
# keep that state in request.session (defaults to grades mode)
|
|
idash_mode = request.POST.get('idash_mode','')
|
|
if idash_mode:
|
|
request.session['idash_mode'] = idash_mode
|
|
else:
|
|
idash_mode = request.session.get('idash_mode','Grades')
|
|
|
|
def escape(s):
|
|
"""escape HTML special characters in string"""
|
|
return str(s).replace('<', '<').replace('>', '>')
|
|
|
|
# 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']:
|
|
encoded_row = [unicode(s).encode('utf-8') for s in datarow]
|
|
writer.writerow(encoded_row)
|
|
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 settings.MITX_FEATURES['ENABLE_MANUAL_GIT_RELOAD']:
|
|
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)
|
|
|
|
if 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 'Download CSV of answer distributions' in action:
|
|
track.views.server_track(request, 'dump-answer-dist-csv', {}, page='idashboard')
|
|
return return_csv('answer_dist_%s.csv' % course_id, get_answers_distribution(request, course_id))
|
|
|
|
#----------------------------------------
|
|
# Admin
|
|
|
|
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')
|
|
|
|
#----------------------------------------
|
|
# psychometrics
|
|
|
|
elif action == 'Generate Histogram and IRT Plot':
|
|
problem = request.POST['Problem']
|
|
nmsg, plots = psychoanalyze.generate_plots_for_problem(problem)
|
|
msg += nmsg
|
|
track.views.server_track(request, 'psychometrics %s' % problem, {}, page='idashboard')
|
|
|
|
if idash_mode=='Psychometrics':
|
|
problems = psychoanalyze.problems_with_psychometric_data(course_id)
|
|
|
|
#----------------------------------------
|
|
# context for rendering
|
|
context = {'course': course,
|
|
'staff_access': True,
|
|
'admin_access': request.user.is_staff,
|
|
'instructor_access': instructor_access,
|
|
'datatable': datatable,
|
|
'msg': msg,
|
|
'modeflag': {idash_mode: 'selectedmode'},
|
|
'problems': problems, # psychometrics
|
|
'plots': plots, # psychometrics
|
|
'course_errors': modulestore().get_item_errors(course.location),
|
|
'djangopid' : os.getpid(),
|
|
}
|
|
|
|
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).prefetch_related("groups").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').select_related("profile")
|
|
|
|
# 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 get_answers_distribution(request, course_id):
|
|
"""
|
|
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, course_id, 'staff')
|
|
|
|
dist = grades.answer_distributions(request, course)
|
|
|
|
d = {}
|
|
d['header'] = ['url_name', 'display name', 'answer id', 'answer', 'count']
|
|
|
|
d['data'] = [[url_name, display_name, answer_id, a, answers[a]]
|
|
for (url_name, display_name, answer_id), answers in dist.items()
|
|
for a in answers]
|
|
return d
|
|
|
|
|
|
#-----------------------------------------------------------------------------
|
|
|
|
|
|
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
|