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'" /> + +

Instructor Dashboard

+
+ +

Gradebook

Grade summary +

+ + +

+ + + +

+ + + +%if instructor_access: +


+

+ +

+ + +


+ %endif + +%if admin_access: +

+ +%endif + +

+ +
+
+

+


+

${datatable['title']}

+ + + %for hname in datatable['header']: + + %endfor + + %for row in datatable['data']: + + %for value in row: + + %endfor + + %endfor +
${hname}
${value}
+

+ +%if msg: +

${msg}

+%endif +
diff --git a/lms/urls.py b/lms/urls.py index 278239751b..26aa10a3f4 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -153,14 +153,14 @@ if settings.COURSEWARE_ENABLED: # For the instructor url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor$', - 'courseware.views.instructor_dashboard', name="instructor_dashboard"), + 'instructor.views.instructor_dashboard', name="instructor_dashboard"), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/gradebook$', - 'courseware.views.gradebook', name='gradebook'), + 'instructor.views.gradebook', name='gradebook'), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/grade_summary$', - 'courseware.views.grade_summary', name='grade_summary'), + 'instructor.views.grade_summary', name='grade_summary'), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/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