""" Instructor Views """ from collections import defaultdict import csv import json import logging import os import re import requests from requests.status_codes import codes import urllib from collections import OrderedDict from StringIO import StringIO from django.conf import settings from django.contrib.auth.models import User, Group from django.http import HttpResponse from django_future.csrf import ensure_csrf_cookie from django.views.decorators.cache import cache_control from mitxmako.shortcuts import render_to_response from django.core.urlresolvers import reverse from courseware import grades from courseware.access import (has_access, get_access_group_name, course_beta_test_group_name) from courseware.courses import get_course_with_access from courseware.models import StudentModule from django_comment_common.models import (Role, FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA) from django_comment_client.utils import has_forum_access from psychometrics import psychoanalyze from student.models import CourseEnrollment, CourseEnrollmentAllowed from xmodule.modulestore.django import modulestore import xmodule.graders as xmgraders import track.views from .offline_gradecalc import student_grades, offline_grades_available log = logging.getLogger(__name__) # internal commands for managing forum roles: FORUM_ROLE_ADD = 'add' FORUM_ROLE_REMOVE = 'remove' def split_by_comma_and_whitespace(s): """ Return string s, split by , or whitespace """ return re.split(r'[\s,]', s) @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', depth=None) instructor_access = has_access(request.user, course, 'instructor') # an instructor can manage staff lists forum_admin_access = has_forum_access(request.user, course_id, FORUM_ROLE_ADMINISTRATOR) msg = '' 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: for field in course.fields: if getattr(field.scope, 'user', False): continue data.append([field.name, json.dumps(field.read_json(course))]) for namespace in course.namespaces: for field in getattr(course, namespace).fields: if getattr(field.scope, 'user', False): continue data.append(["{}.{}".format(namespace, field.name), json.dumps(field.read_json(course))]) datatable['data'] = data def return_csv(fn, datatable, fp=None): if fp is None: response = HttpResponse(mimetype='text/csv') response['Content-Disposition'] = 'attachment; filename={0}'.format(fn) else: response = fp 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): return get_group(course, 'staff') def get_instructor_group(course): return get_group(course, 'instructor') def get_group(course, groupname): grpname = get_access_group_name(course, groupname) try: group = Group.objects.get(name=grpname) except Group.DoesNotExist: group = Group(name=grpname) # create the group group.save() return group def get_beta_group(course): """ Get the group for beta testers of course. """ # Not using get_group because there is no access control action called # 'beta', so adding it to get_access_group_name doesn't really make # sense. name = course_beta_test_group_name(course.location) (group, _) = Group.objects.get_or_create(name=name) return group # process actions from form POST action = request.POST.get('action', '') use_offline = request.POST.get('use_offline_grades', False) if settings.MITX_FEATURES['ENABLE_MANUAL_GIT_RELOAD']: if 'GIT pull' in action: data_dir = getattr(course, 'data_dir') log.debug('git pull {0}'.format(data_dir)) gdir = settings.DATA_DIR / data_dir if not os.path.exists(gdir): msg += "====> ERROR in gitreload - no such directory {0}".format(gdir) else: cmd = "cd {0}; git reset --hard HEAD; git clean -f -d; git pull origin; chmod g+w course.xml".format(gdir) msg += "git pull on {0}:
".format(data_dir) msg += "
{0}".format(escape(os.popen(cmd).read()))
track.views.server_track(request, 'git pull {0}'.format(data_dir), {}, page='idashboard')
if 'Reload course' in action:
log.debug('reloading {0} ({1})'.format(course_id, course))
try:
data_dir = getattr(course, 'data_dir')
modulestore().try_load_course(data_dir)
msg += "Course reloaded from {0}
".format(data_dir) track.views.server_track(request, 'reload {0}'.format(data_dir), {}, page='idashboard') course_errors = modulestore().get_item_errors(course.location) msg += '{1}".format(cmsg, escape(cerr))
msg += 'Error: {0}
'.format(escape(err)) if action == 'Dump list of enrolled students' or action == 'List enrolled students': log.debug(action) datatable = get_student_grade_summary_data(request, course, course_id, get_grades=False, use_offline=use_offline) datatable['title'] = 'List of students enrolled in {0}'.format(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, use_offline=use_offline) datatable['title'] = 'Summary Grades of students enrolled in {0}'.format(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, use_offline=use_offline) datatable['title'] = 'Raw Grades of students enrolled in {0}'.format(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_{0}.csv'.format(course_id), get_student_grade_summary_data(request, course, course_id, use_offline=use_offline)) elif 'Download CSV of all RAW grades' in action: track.views.server_track(request, 'dump-grades-csv-raw', {}, page='idashboard') return return_csv('grades_{0}_raw.csv'.format(course_id), get_student_grade_summary_data(request, course, course_id, get_raw_scores=True, use_offline=use_offline)) elif 'Download CSV of answer distributions' in action: track.views.server_track(request, 'dump-answer-dist-csv', {}, page='idashboard') return return_csv('answer_dist_{0}.csv'.format(course_id), get_answers_distribution(request, course_id)) elif 'Dump description of graded assignments configuration' in action: track.views.server_track(request, action, {}, page='idashboard') msg += dump_grading_context(course) elif "Reset student's attempts" in action or "Delete student state for problem" in action: # get the form data unique_student_identifier = request.POST.get('unique_student_identifier', '') problem_to_reset = request.POST.get('problem_to_reset', '') if problem_to_reset[-4:] == ".xml": problem_to_reset = problem_to_reset[:-4] # try to uniquely id student by email address or username try: if "@" in unique_student_identifier: student_to_reset = User.objects.get(email=unique_student_identifier) else: student_to_reset = User.objects.get(username=unique_student_identifier) msg += "Found a single student to reset. " except: student_to_reset = None msg += "Couldn't find student with that email or username. " if student_to_reset is not None: # find the module in question if '/' not in problem_to_reset: # allow state of modules other than problem to be reset problem_to_reset = "problem/" + problem_to_reset # but problem is the default try: (org, course_name, _) = course_id.split("/") module_state_key = "i4x://" + org + "/" + course_name + "/" + problem_to_reset module_to_reset = StudentModule.objects.get(student_id=student_to_reset.id, course_id=course_id, module_state_key=module_state_key) msg += "Found module to reset. " except Exception: msg += "Couldn't find module with that urlname. " if "Delete student state for problem" in action: # delete the state try: module_to_reset.delete() msg += "Deleted student module state for %s!" % module_state_key except: msg += "Failed to delete module state for %s/%s" % (unique_student_identifier, problem_to_reset) else: # modify the problem's state try: # load the state json problem_state = json.loads(module_to_reset.state) old_number_of_attempts = problem_state["attempts"] problem_state["attempts"] = 0 # save module_to_reset.state = json.dumps(problem_state) module_to_reset.save() track.views.server_track(request, '{instructor} reset attempts from {old_attempts} to 0 for {student} on problem {problem} in {course}'.format( old_attempts=old_number_of_attempts, student=student_to_reset, problem=module_to_reset.module_state_key, instructor=request.user, course=course_id), {}, page='idashboard') msg += "Module state successfully reset!" except: msg += "Couldn't reset module state. " elif "Get link to student's progress page" in action: unique_student_identifier = request.POST.get('unique_student_identifier', '') try: if "@" in unique_student_identifier: student_to_reset = User.objects.get(email=unique_student_identifier) else: student_to_reset = User.objects.get(username=unique_student_identifier) progress_url = reverse('student_progress', kwargs={'course_id': course_id, 'student_id': student_to_reset.id}) track.views.server_track(request, '{instructor} requested progress page for {student} in {course}'.format( student=student_to_reset, instructor=request.user, course=course_id), {}, page='idashboard') msg += " Progress page for username: {1} with email address: {2}.".format(progress_url, student_to_reset.username, student_to_reset.email) except: msg += "Couldn't find student with that username. " #---------------------------------------- # export grades to remote gradebook elif action == 'List assignments available in remote gradebook': msg2, datatable = _do_remote_gradebook(request.user, course, 'get-assignments') msg += msg2 elif action == 'List assignments available for this course': log.debug(action) allgrades = get_student_grade_summary_data(request, course, course_id, get_grades=True, use_offline=use_offline) assignments = [[x] for x in allgrades['assignments']] datatable = {'header': ['Assignment Name']} datatable['data'] = assignments datatable['title'] = action msg += 'assignments=%s' % assignments elif action == 'List enrolled students matching remote gradebook': stud_data = get_student_grade_summary_data(request, course, course_id, get_grades=False, use_offline=use_offline) msg2, rg_stud_data = _do_remote_gradebook(request.user, course, 'get-membership') datatable = {'header': ['Student email', 'Match?']} rg_students = [x['email'] for x in rg_stud_data['retdata']] def domatch(x): return 'yes' if x.email in rg_students else 'No' datatable['data'] = [[x.email, domatch(x)] for x in stud_data['students']] datatable['title'] = action elif action in ['Display grades for assignment', 'Export grades for assignment to remote gradebook', 'Export CSV file of grades for assignment']: log.debug(action) datatable = {} aname = request.POST.get('assignment_name', '') if not aname: msg += "Please enter an assignment name" else: allgrades = get_student_grade_summary_data(request, course, course_id, get_grades=True, use_offline=use_offline) if aname not in allgrades['assignments']: msg += "Invalid assignment name '%s'" % aname else: aidx = allgrades['assignments'].index(aname) datatable = {'header': ['External email', aname]} datatable['data'] = [[x.email, x.grades[aidx]] for x in allgrades['students']] datatable['title'] = 'Grades for assignment "%s"' % aname if 'Export CSV' in action: # generate and return CSV file return return_csv('grades %s.csv' % aname, datatable) elif 'remote gradebook' in action: fp = StringIO() return_csv('', datatable, fp=fp) fp.seek(0) files = {'datafile': fp} msg2, _ = _do_remote_gradebook(request.user, course, 'post-grades', files=files) msg += msg2 #---------------------------------------- # Admin elif 'List course staff' in action: group = get_staff_group(course) msg += 'Staff group = {0}'.format(group.name) datatable = _group_members_table(group, "List of Staff", course_id) track.views.server_track(request, 'list-staff', {}, page='idashboard') elif 'List course instructors' in action and request.user.is_staff: group = get_instructor_group(course) msg += 'Instructor group = {0}'.format(group.name) log.debug('instructor grp={0}'.format(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 Instructors in course {0}'.format(course_id) track.views.server_track(request, 'list-instructors', {}, page='idashboard') elif action == 'Add course staff': uname = request.POST['staffuser'] group = get_staff_group(course) msg += add_user_to_group(request, uname, group, 'staff', 'staff') elif action == 'Add instructor' and request.user.is_staff: uname = request.POST['instructor'] try: user = User.objects.get(username=uname) except User.DoesNotExist: msg += 'Error: unknown username "{0}"'.format(uname) user = None if user is not None: group = get_instructor_group(course) msg += 'Added {0} to instructor group = {1}'.format(user, group.name) log.debug('staffgrp={0}'.format(group.name)) user.groups.add(group) track.views.server_track(request, 'add-instructor {0}'.format(user), {}, page='idashboard') elif action == 'Remove course staff': uname = request.POST['staffuser'] group = get_staff_group(course) msg += remove_user_from_group(request, uname, group, 'staff', 'staff') elif action == 'Remove instructor' and request.user.is_staff: uname = request.POST['instructor'] try: user = User.objects.get(username=uname) except User.DoesNotExist: msg += 'Error: unknown username "{0}"'.format(uname) user = None if user is not None: group = get_instructor_group(course) msg += 'Removed {0} from instructor group = {1}'.format(user, group.name) log.debug('instructorgrp={0}'.format(group.name)) user.groups.remove(group) track.views.server_track(request, 'remove-instructor {0}'.format(user), {}, page='idashboard') #---------------------------------------- # DataDump elif 'Download CSV of all student profile data' in action: enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).order_by('username').select_related("profile") profkeys = ['name', 'language', 'location', 'year_of_birth', 'gender', 'level_of_education', 'mailing_address', 'goals'] datatable = {'header': ['username', 'email'] + profkeys} def getdat(u): p = u.profile return [u.username, u.email] + [getattr(p, x, '') for x in profkeys] datatable['data'] = [getdat(u) for u in enrolled_students] datatable['title'] = 'Student profile data for course %s' % course_id return return_csv('profiledata_%s.csv' % course_id, datatable) elif 'Download CSV of all responses to problem' in action: problem_to_dump = request.POST.get('problem_to_dump','') if problem_to_dump[-4:] == ".xml": problem_to_dump = problem_to_dump[:-4] try: (org, course_name, run) = course_id.split("/") module_state_key = "i4x://" + org + "/" + course_name + "/problem/" + problem_to_dump smdat = StudentModule.objects.filter(course_id=course_id, module_state_key=module_state_key) smdat = smdat.order_by('student') msg += "Found %d records to dump " % len(smdat) except Exception as err: msg += "Couldn't find module with that urlname. " msg += "
%s" % escape(err) smdat = [] if smdat: datatable = {'header': ['username', 'state']} datatable['data'] = [ [x.student.username, x.state] for x in smdat ] datatable['title'] = 'Student state for problem %s' % problem_to_dump return return_csv('student_state_from_%s.csv' % problem_to_dump, datatable) #---------------------------------------- # Group management elif 'List beta testers' in action: group = get_beta_group(course) msg += 'Beta test group = {0}'.format(group.name) datatable = _group_members_table(group, "List of beta_testers", course_id) track.views.server_track(request, 'list-beta-testers', {}, page='idashboard') elif action == 'Add beta testers': users = request.POST['betausers'] log.debug("users: {0!r}".format(users)) group = get_beta_group(course) for username_or_email in split_by_comma_and_whitespace(users): msg += "
{0}
".format( add_user_to_group(request, username_or_email, group, 'beta testers', 'beta-tester')) elif action == 'Remove beta testers': users = request.POST['betausers'] group = get_beta_group(course) for username_or_email in split_by_comma_and_whitespace(users): msg += "{0}
".format( remove_user_from_group(request, username_or_email, group, 'beta testers', 'beta-tester')) #---------------------------------------- # forum administration elif action == 'List course forum admins': rolename = FORUM_ROLE_ADMINISTRATOR datatable = {} msg += _list_course_forum_members(course_id, rolename, datatable) track.views.server_track(request, 'list-{0}'.format(rolename), {}, page='idashboard') elif action == 'Remove forum admin': uname = request.POST['forumadmin'] msg += _update_forum_role_membership(uname, course, FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_REMOVE) track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_REMOVE, uname, FORUM_ROLE_ADMINISTRATOR, course_id), {}, page='idashboard') elif action == 'Add forum admin': uname = request.POST['forumadmin'] msg += _update_forum_role_membership(uname, course, FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_ADD) track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_ADD, uname, FORUM_ROLE_ADMINISTRATOR, course_id), {}, page='idashboard') elif action == 'List course forum moderators': rolename = FORUM_ROLE_MODERATOR datatable = {} msg += _list_course_forum_members(course_id, rolename, datatable) track.views.server_track(request, 'list-{0}'.format(rolename), {}, page='idashboard') elif action == 'Remove forum moderator': uname = request.POST['forummoderator'] msg += _update_forum_role_membership(uname, course, FORUM_ROLE_MODERATOR, FORUM_ROLE_REMOVE) track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_REMOVE, uname, FORUM_ROLE_MODERATOR, course_id), {}, page='idashboard') elif action == 'Add forum moderator': uname = request.POST['forummoderator'] msg += _update_forum_role_membership(uname, course, FORUM_ROLE_MODERATOR, FORUM_ROLE_ADD) track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_ADD, uname, FORUM_ROLE_MODERATOR, course_id), {}, page='idashboard') elif action == 'List course forum community TAs': rolename = FORUM_ROLE_COMMUNITY_TA datatable = {} msg += _list_course_forum_members(course_id, rolename, datatable) track.views.server_track(request, 'list-{0}'.format(rolename), {}, page='idashboard') elif action == 'Remove forum community TA': uname = request.POST['forummoderator'] msg += _update_forum_role_membership(uname, course, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_REMOVE) track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_REMOVE, uname, FORUM_ROLE_COMMUNITY_TA, course_id), {}, page='idashboard') elif action == 'Add forum community TA': uname = request.POST['forummoderator'] msg += _update_forum_role_membership(uname, course, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_ADD) track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_ADD, uname, FORUM_ROLE_COMMUNITY_TA, course_id), {}, page='idashboard') #---------------------------------------- # enrollment elif action == 'List students who may enroll but may not have yet signed up': ceaset = CourseEnrollmentAllowed.objects.filter(course_id=course_id) datatable = {'header': ['StudentEmail']} datatable['data'] = [[x.email] for x in ceaset] datatable['title'] = action elif action == 'Enroll student': student = request.POST.get('enstudent', '') ret = _do_enroll_students(course, course_id, student) datatable = ret['datatable'] elif action == 'Un-enroll student': student = request.POST.get('enstudent', '') datatable = {} isok = False cea = CourseEnrollmentAllowed.objects.filter(course_id=course_id, email=student) if cea: cea.delete() msg += "Un-enrolled student with email '%s'" % student isok = True try: nce = CourseEnrollment.objects.get(user=User.objects.get(email=student), course_id=course_id) nce.delete() msg += "Un-enrolled student with email '%s'" % student except Exception as err: if not isok: msg += "Error! Failed to un-enroll student with email '%s'\n" % student msg += str(err) + '\n' elif action == 'Enroll multiple students': students = request.POST.get('enroll_multiple', '') ret = _do_enroll_students(course, course_id, students) datatable = ret['datatable'] elif action == 'List sections available in remote gradebook': msg2, datatable = _do_remote_gradebook(request.user, course, 'get-sections') msg += msg2 elif action in ['List students in section in remote gradebook', 'Overload enrollment list using remote gradebook', 'Merge enrollment list with remote gradebook']: section = request.POST.get('gradebook_section', '') msg2, datatable = _do_remote_gradebook(request.user, course, 'get-membership', dict(section=section)) msg += msg2 if not 'List' in action: students = ','.join([x['email'] for x in datatable['retdata']]) overload = 'Overload' in action ret = _do_enroll_students(course, course_id, students, overload=overload) datatable = ret['datatable'] #---------------------------------------- # 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 {0}'.format(problem), {}, page='idashboard') if idash_mode == 'Psychometrics': problems = psychoanalyze.problems_with_psychometric_data(course_id) #---------------------------------------- # analytics def get_analytics_result(analytics_name): """Return data for an Analytic piece, or None if it doesn't exist. It logs and swallows errors. """ url = settings.ANALYTICS_SERVER_URL + \ "get?aname={}&course_id={}&apikey={}".format(analytics_name, course_id, settings.ANALYTICS_API_KEY) try: res = requests.get(url) except Exception: log.exception("Error trying to access analytics at %s", url) return None if res.status_code == codes.OK: # WARNING: do not use req.json because the preloaded json doesn't # preserve the order of the original record (hence OrderedDict). return json.loads(res.content, object_pairs_hook=OrderedDict) else: log.error("Error fetching %s, code: %s, msg: %s", url, res.status_code, res.content) return None analytics_results = {} if idash_mode == 'Analytics': DASHBOARD_ANALYTICS = [ # "StudentsAttemptedProblems", # num students who tried given problem "StudentsDailyActivity", # active students by day "StudentsDropoffPerDay", # active students dropoff by day # "OverallGradeDistribution", # overall point distribution for course "StudentsActive", # num students active in time period (default = 1wk) "StudentsEnrolled", # num students enrolled # "StudentsPerProblemCorrect", # foreach problem, num students correct "ProblemGradeDistribution", # foreach problem, grade distribution ] for analytic_name in DASHBOARD_ANALYTICS: analytics_results[analytic_name] = get_analytics_result(analytic_name) #---------------------------------------- # offline grades? if use_offline: msg += "%s' % retdict['msg'].replace('\n', '
%s' % msg.replace('<','<') return msg