Skip to content
Snippets Groups Projects
Commit 2efe4812 authored by ichuang's avatar ichuang
Browse files

move instructor dashboard into its own lms djangoapp; add new

functionality - grade dump and download as csv, manage staff list,
force reload of course from xml
parent 7664f910
No related branches found
No related tags found
No related merge requests found
......@@ -184,6 +184,9 @@ class CourseEnrollment(models.Model):
class Meta:
unique_together = (('user', 'course_id'), )
def __unicode__(self):
return "%s: %s (%s)" % (self.user,self.course_id,self.created)
@receiver(post_save, sender=CourseEnrollment)
def assign_default_role(sender, instance, **kwargs):
if instance.user.is_staff:
......
......@@ -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_staff_access_to_descriptor(user, course, require_instructor=True),
}
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,15 @@ 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,11 +332,13 @@ def _has_global_staff_access(user):
return False
def _has_staff_access_to_location(user, location):
def _has_staff_access_to_location(user, location, require_instructor=False):
'''
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.
If require_instructor=True, then user must be in instructor_* group.
This means that user is in the staff_* group, or is an overall admin.
TODO (vshnayder): this needs to be changed to allow per-course_id permissions, not per-course
......@@ -323,8 +356,13 @@ 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)
if not require_instructor:
if staff_group in user_groups:
debug("Allow: user in group %s", staff_group)
return True
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", staff_group)
return False
......@@ -335,11 +373,11 @@ def _has_staff_access_to_course_id(user, course_id):
return _has_staff_access_to_location(user, loc)
def _has_staff_access_to_descriptor(user, descriptor):
def _has_staff_access_to_descriptor(user, descriptor, require_instructor=False):
"""Helper method that checks whether the user has staff access to
the course of the location.
location: something that can be passed to Location
"""
return _has_staff_access_to_location(user, descriptor.location)
return _has_staff_access_to_location(user, descriptor.location, require_instructor=require_instructor)
......@@ -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):
......
......@@ -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})
# ======== Instructor views =============================================================================
import csv
import json
import logging
import urllib
import itertools
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 'Reload' 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
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
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
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
elif 'Download CSV of all student grades' in action:
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:
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
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)
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)
# 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:
gradeset = grades.grade(enrolled_students[0], request, course, keep_raw_scores=get_raw_scores) # just to construct the header
# 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)
print "hello world"
def walk(module):
children = module.get_children()
if not children:
category = module.__class__.__name__ # HtmlDescriptor, CapaDescriptor, ...
counts[category] += 1
return
for c in children:
# print c.__class__.__name__
walk(c)
walk(course)
print "course %s counts=%s" % (course.display_name,counts)
stats = dict(counts) # number of each kind of module
return stats
......@@ -604,6 +604,7 @@ INSTALLED_APPS = (
'track',
'util',
'certificates',
'instructor',
#For the wiki
'wiki', # The new django-wiki from benjaoming
......
......@@ -8,17 +8,98 @@
<%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;
}
</style>
<section class="container">
<div class="instructor-dashboard-wrapper">
<section class="instructor-dashboard-content">
<h1>Instructor Dashboard</h1>
<form method="POST">
<input type="hidden" name="csrfmiddlewaretoken" value="${ csrf_token }">
<p>
<a href="${reverse('gradebook', kwargs=dict(course_id=course.id))}">Gradebook</a>
<p>
<a href="${reverse('grade_summary', kwargs=dict(course_id=course.id))}">Grade summary</a>
<p>
<input type="submit" name="action" value="Dump list of enrolled students">
<p>
<input type="submit" name="action" value="Dump Grades for all students in this course">
<input type="submit" name="action" value="Download CSV of all student grades for this course">
<p>
<input type="submit" name="action" value="Dump all RAW grades for all students in this course">
<input type="submit" name="action" value="Download CSV of all RAW grades">
%if instructor_access:
<hr width="40%" style="align:left">
<p>
<input type="submit" name="action" value="List course staff members">
<p>
<input type="text" name="staffuser"> <input type="submit" name="action" value="Remove course staff">
<input type="submit" name="action" value="Add course staff">
<hr width="40%" style="align:left">
%endif
%if admin_access:
<p>
<input type="submit" name="action" value="Reload course from XML files">
%endif
</form>
<br/>
<br/>
<p>
<hr width="100%">
<h2>${datatable['title']}</h2>
<table class="stat_table">
<tr>
%for hname in datatable['header']:
<th>${hname}</th>
%endfor
</tr>
%for row in datatable['data']:
<tr>
%for value in row:
<td>${value}</td>
%endfor
</tr>
%endfor
</table>
</p>
%if msg:
<p>${msg}</p>
%endif
</section>
</div>
</section>
......@@ -153,14 +153,14 @@ if settings.COURSEWARE_ENABLED:
# For the instructor
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/instructor$',
'courseware.views.instructor_dashboard', name="instructor_dashboard"),
'instructor.views.instructor_dashboard', name="instructor_dashboard"),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/gradebook$',
'courseware.views.gradebook', name='gradebook'),
'instructor.views.gradebook', name='gradebook'),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/grade_summary$',
'courseware.views.grade_summary', name='grade_summary'),
'instructor.views.grade_summary', name='grade_summary'),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/enroll_students$',
'courseware.views.enroll_students', name='enroll_students'),
'instructor.views.enroll_students', name='enroll_students'),
)
# discussion forums live within courseware, so courseware must be enabled first
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment