diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py
index 9cde878d21..c494035104 100644
--- a/common/djangoapps/student/models.py
+++ b/common/djangoapps/student/models.py
@@ -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:
diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py
index 91c769f90a..4f35aa98fa 100644
--- a/lms/djangoapps/courseware/access.py
+++ b/lms/djangoapps/courseware/access.py
@@ -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)
diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py
index 7f28f3ca5c..f32da532df 100644
--- a/lms/djangoapps/courseware/grades.py
+++ b/lms/djangoapps/courseware/grades.py
@@ -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):
diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py
index 71ec687cf6..aa3444b193 100644
--- a/lms/djangoapps/courseware/views.py
+++ b/lms/djangoapps/courseware/views.py
@@ -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})
diff --git a/lms/djangoapps/instructor/__init__.py b/lms/djangoapps/instructor/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/lms/djangoapps/instructor/views.py b/lms/djangoapps/instructor/views.py
new file mode 100644
index 0000000000..ccf40cd5e5
--- /dev/null
+++ b/lms/djangoapps/instructor/views.py
@@ -0,0 +1,330 @@
+# ======== 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('<','<')
+
+ 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']:
+ 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 += "
Course reloaded from %s
" % data_dir + except Exception as err: + msg += 'Error: %s
' % 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 += 'Error: unknown username "%s"' % uname + user = None + if user is not None: + group = get_staff_group(course) + msg += 'Added %s to staff group = %s' % (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 += 'Error: unknown username "%s"' % uname + user = None + if user is not None: + group = get_staff_group(course) + msg += 'Removed %s from staff group = %s' % (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 + diff --git a/lms/envs/common.py b/lms/envs/common.py index 5cd28d24d9..cf9a767d9f 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -604,6 +604,7 @@ INSTALLED_APPS = ( 'track', 'util', 'certificates', + 'instructor', #For the wiki 'wiki', # The new django-wiki from benjaoming diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index 9508624f9b..49b16cf122 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -8,17 +8,98 @@ <%include file="/courseware/course_navigation.html" args="active_page='instructor'" /> + ++
| ${hname} | + %endfor +
|---|
| ${value} | + %endfor +
${msg}
+%endif +