665 lines
22 KiB
Python
665 lines
22 KiB
Python
"""
|
|
Instructor Dashboard API views
|
|
|
|
Non-html views which the instructor dashboard requests.
|
|
|
|
TODO add tracking
|
|
TODO a lot of these GETs should be PUTs
|
|
"""
|
|
|
|
import re
|
|
import json
|
|
from django_future.csrf import ensure_csrf_cookie
|
|
from django.views.decorators.cache import cache_control
|
|
from django.core.urlresolvers import reverse
|
|
from django.http import HttpResponse, HttpResponseBadRequest
|
|
|
|
from courseware.courses import get_course_with_access
|
|
from django.contrib.auth.models import User
|
|
from django_comment_common.models import (Role,
|
|
FORUM_ROLE_ADMINISTRATOR,
|
|
FORUM_ROLE_MODERATOR,
|
|
FORUM_ROLE_COMMUNITY_TA)
|
|
|
|
from courseware.models import StudentModule
|
|
import instructor_task.api
|
|
import instructor.enrollment as enrollment
|
|
from instructor.enrollment import enroll_email, unenroll_email
|
|
import instructor.access as access
|
|
import analytics.basic
|
|
import analytics.distributions
|
|
import analytics.csvs
|
|
|
|
|
|
def common_exceptions_400(func):
|
|
""" Catches common exceptions and renders matching 400 errors. (decorator) """
|
|
def wrapped(*args, **kwargs):
|
|
try:
|
|
return func(*args, **kwargs)
|
|
except User.DoesNotExist:
|
|
return HttpResponseBadRequest("User does not exist.")
|
|
return wrapped
|
|
|
|
|
|
def require_query_params(*args, **kwargs):
|
|
"""
|
|
Checks for required paremters or renders a 400 error.
|
|
`args` is a *list of required GET parameter names.
|
|
`kwargs` is a **dict of required GET parameter names
|
|
to string explanations of the parameter
|
|
"""
|
|
required_params = []
|
|
required_params += [(arg, None) for arg in args]
|
|
required_params += [(key, kwargs[key]) for key in kwargs]
|
|
# required_params = e.g. [('action', 'enroll or unenroll'), ['emails', None]]
|
|
|
|
def decorator(func):
|
|
def wrapped(*args, **kwargs):
|
|
request = args[0]
|
|
|
|
error_response_data = {
|
|
'error': 'Missing required query parameter(s)',
|
|
'parameters': [],
|
|
'info': {},
|
|
}
|
|
|
|
for (param, extra) in required_params:
|
|
default = object()
|
|
if request.GET.get(param, default) == default:
|
|
error_response_data['parameters'] += [param]
|
|
error_response_data['info'][param] = extra
|
|
|
|
if len(error_response_data['parameters']) > 0:
|
|
return HttpResponseBadRequest(
|
|
json.dumps(error_response_data),
|
|
mimetype="application/json",
|
|
)
|
|
else:
|
|
return func(*args, **kwargs)
|
|
return wrapped
|
|
return decorator
|
|
|
|
|
|
@ensure_csrf_cookie
|
|
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
|
@require_query_params(action="enroll or unenroll", emails="stringified list of emails")
|
|
def students_update_enrollment(request, course_id):
|
|
"""
|
|
Enroll or unenroll students by email.
|
|
Requires staff access.
|
|
|
|
Query Parameters:
|
|
- action in ['enroll', 'unenroll']
|
|
- emails is string containing a list of emails separated by anything split_input_list can handle.
|
|
- auto_enroll is a boolean (defaults to false)
|
|
"""
|
|
course = get_course_with_access(
|
|
request.user, course_id, 'staff', depth=None
|
|
)
|
|
|
|
action = request.GET.get('action')
|
|
emails_raw = request.GET.get('emails')
|
|
print "@@@@"
|
|
print type(emails_raw)
|
|
emails = _split_input_list(emails_raw)
|
|
auto_enroll = request.GET.get('auto_enroll') in ['true', 'True', True]
|
|
|
|
def format_result(func, email):
|
|
""" Act on a single email and format response or errors. """
|
|
try:
|
|
before, after = func()
|
|
return {
|
|
'email': email,
|
|
'before': before.to_dict(),
|
|
'after': after.to_dict(),
|
|
}
|
|
except Exception:
|
|
return {
|
|
'email': email,
|
|
'error': True,
|
|
}
|
|
|
|
if action == 'enroll':
|
|
results = [format_result(
|
|
lambda: enroll_email(course_id, email, auto_enroll),
|
|
email
|
|
) for email in emails]
|
|
elif action == 'unenroll':
|
|
results = [format_result(
|
|
lambda: unenroll_email(course_id, email),
|
|
email
|
|
) for email in emails]
|
|
else:
|
|
return HttpResponseBadRequest("Unrecognized action '{}'".format(action))
|
|
|
|
response_payload = {
|
|
'action': action,
|
|
'results': results,
|
|
'auto_enroll': auto_enroll,
|
|
}
|
|
response = HttpResponse(
|
|
json.dumps(response_payload), content_type="application/json"
|
|
)
|
|
return response
|
|
|
|
|
|
@ensure_csrf_cookie
|
|
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
|
@common_exceptions_400
|
|
@require_query_params(
|
|
email="user email",
|
|
rolename="'instructor', 'staff', or 'beta'",
|
|
mode="'allow' or 'revoke'"
|
|
)
|
|
def access_allow_revoke(request, course_id):
|
|
"""
|
|
Modify staff/instructor access.
|
|
Requires instructor access.
|
|
|
|
Query parameters:
|
|
email is the target users email
|
|
rolename is one of ['instructor', 'staff', 'beta']
|
|
mode is one of ['allow', 'revoke']
|
|
"""
|
|
course = get_course_with_access(
|
|
request.user, course_id, 'instructor', depth=None
|
|
)
|
|
|
|
email = request.GET.get('email')
|
|
rolename = request.GET.get('rolename')
|
|
mode = request.GET.get('mode')
|
|
|
|
user = User.objects.get(email=email)
|
|
|
|
if mode == 'allow':
|
|
access.allow_access(course, user, rolename)
|
|
elif mode == 'revoke':
|
|
access.revoke_access(course, user, rolename)
|
|
else:
|
|
raise ValueError("unrecognized mode '{}'".format(mode))
|
|
|
|
response_payload = {
|
|
'DONE': 'YES',
|
|
}
|
|
response = HttpResponse(
|
|
json.dumps(response_payload), content_type="application/json"
|
|
)
|
|
return response
|
|
|
|
|
|
@ensure_csrf_cookie
|
|
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
|
@require_query_params(rolename="'instructor', 'staff', or 'beta'")
|
|
def list_course_role_members(request, course_id):
|
|
"""
|
|
List instructors and staff.
|
|
Requires staff access.
|
|
|
|
rolename is one of ['instructor', 'staff', 'beta']
|
|
"""
|
|
course = get_course_with_access(
|
|
request.user, course_id, 'staff', depth=None
|
|
)
|
|
|
|
rolename = request.GET.get('rolename')
|
|
|
|
if not rolename in ['instructor', 'staff', 'beta']:
|
|
return HttpResponseBadRequest()
|
|
|
|
def extract_user_info(user):
|
|
return {
|
|
'username': user.username,
|
|
'email': user.email,
|
|
'first_name': user.first_name,
|
|
'last_name': user.last_name,
|
|
}
|
|
|
|
response_payload = {
|
|
'course_id': course_id,
|
|
rolename: map(extract_user_info, access.list_with_level(
|
|
course, rolename
|
|
)),
|
|
}
|
|
response = HttpResponse(
|
|
json.dumps(response_payload), content_type="application/json"
|
|
)
|
|
return response
|
|
|
|
|
|
@ensure_csrf_cookie
|
|
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
|
def grading_config(request, course_id):
|
|
"""
|
|
Respond with json which contains a html formatted grade summary.
|
|
|
|
TODO maybe this shouldn't be html already
|
|
"""
|
|
course = get_course_with_access(
|
|
request.user, course_id, 'staff', depth=None
|
|
)
|
|
grading_config_summary = analytics.basic.dump_grading_context(course)
|
|
|
|
response_payload = {
|
|
'course_id': course_id,
|
|
'grading_config_summary': grading_config_summary,
|
|
}
|
|
response = HttpResponse(
|
|
json.dumps(response_payload), content_type="application/json"
|
|
)
|
|
return response
|
|
|
|
|
|
@ensure_csrf_cookie
|
|
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
|
def enrolled_students_features(request, course_id, csv=False):
|
|
"""
|
|
Respond with json which contains a summary of all enrolled students profile information.
|
|
|
|
Response {"students": [{-student-info-}, ...]}
|
|
|
|
TODO accept requests for different attribute sets
|
|
"""
|
|
course = get_course_with_access(
|
|
request.user, course_id, 'staff', depth=None
|
|
)
|
|
|
|
available_features = analytics.basic.AVAILABLE_FEATURES
|
|
query_features = ['username', 'name', 'email', 'language', 'location', 'year_of_birth', 'gender',
|
|
'level_of_education', 'mailing_address', 'goals']
|
|
|
|
student_data = analytics.basic.enrolled_students_features(course_id, query_features)
|
|
|
|
if not csv:
|
|
response_payload = {
|
|
'course_id': course_id,
|
|
'students': student_data,
|
|
'students_count': len(student_data),
|
|
'queried_features': query_features,
|
|
'available_features': available_features,
|
|
}
|
|
response = HttpResponse(
|
|
json.dumps(response_payload), content_type="application/json"
|
|
)
|
|
return response
|
|
else:
|
|
header, datarows = analytics.csvs.format_dictlist(student_data)
|
|
return analytics.csvs.create_csv_response("enrolled_profiles.csv", header, datarows)
|
|
|
|
|
|
@ensure_csrf_cookie
|
|
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
|
def profile_distribution(request, course_id):
|
|
"""
|
|
Respond with json of the distribution of students over selected fields which have choices.
|
|
|
|
Ask for features through the 'features' query parameter.
|
|
The features query parameter can be either a single feature name, or a json string of feature names.
|
|
e.g.
|
|
http://localhost:8000/courses/MITx/6.002x/2013_Spring/instructor_dashboard/api/profile_distribution?features=level_of_education
|
|
http://localhost:8000/courses/MITx/6.002x/2013_Spring/instructor_dashboard/api/profile_distribution?features=%5B%22year_of_birth%22%2C%22gender%22%5D
|
|
|
|
Example js query:
|
|
$.get("http://localhost:8000/courses/MITx/6.002x/2013_Spring/instructor_dashboard/api/profile_distribution",
|
|
{'features': JSON.stringify(['year_of_birth', 'gender'])},
|
|
function(){console.log(arguments[0])})
|
|
|
|
TODO how should query parameter interpretation work?
|
|
TODO respond to csv requests as well
|
|
"""
|
|
course = get_course_with_access(
|
|
request.user, course_id, 'staff', depth=None
|
|
)
|
|
|
|
try:
|
|
features = json.loads(request.GET.get('features'))
|
|
except Exception:
|
|
features = [request.GET.get('features')]
|
|
|
|
feature_results = {}
|
|
|
|
for feature in features:
|
|
try:
|
|
feature_results[feature] = analytics.distributions.profile_distribution(course_id, feature)
|
|
except Exception as e:
|
|
feature_results[feature] = {'error': "Error finding distribution for distribution for '{}'.".format(feature)}
|
|
raise e
|
|
|
|
response_payload = {
|
|
'course_id': course_id,
|
|
'queried_features': features,
|
|
'available_features': analytics.distributions.AVAILABLE_PROFILE_FEATURES,
|
|
'display_names': {
|
|
'gender': 'Gender',
|
|
'level_of_education': 'Level of Education',
|
|
'year_of_birth': 'Year Of Birth',
|
|
},
|
|
'feature_results': feature_results,
|
|
}
|
|
response = HttpResponse(
|
|
json.dumps(response_payload), content_type="application/json"
|
|
)
|
|
return response
|
|
|
|
|
|
@ensure_csrf_cookie
|
|
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
|
@require_query_params(
|
|
student_email="email of student for whom to get progress url"
|
|
)
|
|
def get_student_progress_url(request, course_id):
|
|
"""
|
|
Get the progress url of a student.
|
|
Limited to staff access.
|
|
|
|
Takes query paremeter student_email and if the student exists
|
|
returns e.g. {
|
|
'progress_url': '/../...'
|
|
}
|
|
"""
|
|
course = get_course_with_access(
|
|
request.user, course_id, 'staff', depth=None
|
|
)
|
|
|
|
student_email = request.GET.get('student_email')
|
|
user = User.objects.get(email=student_email)
|
|
|
|
progress_url = reverse('student_progress', kwargs={'course_id': course_id, 'student_id': user.id})
|
|
|
|
response_payload = {
|
|
'course_id': course_id,
|
|
'progress_url': progress_url,
|
|
}
|
|
response = HttpResponse(
|
|
json.dumps(response_payload), content_type="application/json"
|
|
)
|
|
return response
|
|
|
|
|
|
@ensure_csrf_cookie
|
|
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
|
@common_exceptions_400
|
|
@require_query_params(student_email="student email")
|
|
def redirect_to_student_progress(request, course_id):
|
|
"""
|
|
Redirects to the specified students progress page
|
|
Limited to staff access.
|
|
|
|
Takes query parameter student_email
|
|
"""
|
|
course = get_course_with_access(
|
|
request.user, course_id, 'staff', depth=None
|
|
)
|
|
|
|
student_email = request.GET.get('student_email')
|
|
user = User.objects.get(email=student_email)
|
|
|
|
progress_url = reverse('student_progress', kwargs={'course_id': course_id, 'student_id': user.id})
|
|
|
|
response_payload = {
|
|
'course_id': course_id,
|
|
'progress_url': progress_url,
|
|
}
|
|
response = HttpResponse(
|
|
json.dumps(response_payload), content_type="application/json"
|
|
)
|
|
return response
|
|
|
|
|
|
@ensure_csrf_cookie
|
|
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
|
@common_exceptions_400
|
|
def reset_student_attempts(request, course_id):
|
|
"""
|
|
Resets a students attempts counter or starts a task to reset all students attempts counters. Optionally deletes student state for a problem.
|
|
Limited to staff access.
|
|
|
|
Takes either of the following query paremeters
|
|
- problem_to_reset is a urlname of a problem
|
|
- student_email is an email
|
|
- all_students is a boolean
|
|
- delete_module is a boolean
|
|
"""
|
|
course = get_course_with_access(
|
|
request.user, course_id, 'staff', depth=None
|
|
)
|
|
|
|
problem_to_reset = request.GET.get('problem_to_reset')
|
|
student_email = request.GET.get('student_email')
|
|
all_students = request.GET.get('all_students', False) in ['true', 'True', True]
|
|
will_delete_module = request.GET.get('delete_module', False) in ['true', 'True', True]
|
|
|
|
if not (problem_to_reset and (all_students or student_email)):
|
|
return HttpResponseBadRequest()
|
|
if will_delete_module and all_students:
|
|
return HttpResponseBadRequest()
|
|
|
|
module_state_key = _msk_from_problem_urlname(course_id, problem_to_reset)
|
|
|
|
response_payload = {}
|
|
response_payload['problem_to_reset'] = problem_to_reset
|
|
|
|
if student_email:
|
|
try:
|
|
student = User.objects.get(email=student_email)
|
|
enrollment.reset_student_attempts(course_id, student, module_state_key, delete_module=will_delete_module)
|
|
except StudentModule.DoesNotExist:
|
|
return HttpResponseBadRequest("Module does not exist.")
|
|
elif all_students:
|
|
task = instructor_task.api.submit_reset_problem_attempts_for_all_students(request, course_id, module_state_key)
|
|
response_payload['task'] = 'created'
|
|
else:
|
|
return HttpResponseBadRequest()
|
|
|
|
response = HttpResponse(
|
|
json.dumps(response_payload), content_type="application/json"
|
|
)
|
|
return response
|
|
|
|
|
|
@ensure_csrf_cookie
|
|
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
|
@common_exceptions_400
|
|
def rescore_problem(request, course_id):
|
|
"""
|
|
Starts a background process a students attempts counter. Optionally deletes student state for a problem.
|
|
Limited to staff access.
|
|
|
|
Takes either of the following query paremeters
|
|
- problem_to_reset is a urlname of a problem
|
|
- student_email is an email
|
|
- all_students is a boolean
|
|
|
|
all_students will be ignored if student_email is present
|
|
"""
|
|
course = get_course_with_access(
|
|
request.user, course_id, 'staff', depth=None
|
|
)
|
|
|
|
problem_to_reset = request.GET.get('problem_to_reset')
|
|
student_email = request.GET.get('student_email', False)
|
|
all_students = request.GET.get('all_students') in ['true', 'True', True]
|
|
|
|
if not (problem_to_reset and (all_students or student_email)):
|
|
return HttpResponseBadRequest()
|
|
|
|
module_state_key = _msk_from_problem_urlname(course_id, problem_to_reset)
|
|
|
|
response_payload = {}
|
|
response_payload['problem_to_reset'] = problem_to_reset
|
|
|
|
if student_email:
|
|
response_payload['student_email'] = student_email
|
|
student = User.objects.get(email=student_email)
|
|
task = instructor_task.api.submit_rescore_problem_for_student(request, course_id, module_state_key, student)
|
|
response_payload['task'] = 'created'
|
|
elif all_students:
|
|
task = instructor_task.api.submit_rescore_problem_for_all_students(request, course_id, module_state_key)
|
|
response_payload['task'] = 'created'
|
|
else:
|
|
return HttpResponseBadRequest()
|
|
|
|
response = HttpResponse(
|
|
json.dumps(response_payload), content_type="application/json"
|
|
)
|
|
return response
|
|
|
|
|
|
@ensure_csrf_cookie
|
|
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
|
def list_instructor_tasks(request, course_id):
|
|
"""
|
|
List instructor tasks.
|
|
Limited to instructor access.
|
|
|
|
Takes either of the following query paremeters
|
|
- (optional) problem_urlname (same format as problem_to_reset in other api methods)
|
|
- (optional) student_email
|
|
"""
|
|
course = get_course_with_access(
|
|
request.user, course_id, 'instructor', depth=None
|
|
)
|
|
|
|
problem_urlname = request.GET.get('problem_urlname', False)
|
|
student_email = request.GET.get('student_email', False)
|
|
|
|
if student_email and not problem_urlname:
|
|
return HttpResponseBadRequest()
|
|
|
|
if problem_urlname:
|
|
module_state_key = _msk_from_problem_urlname(course_id, problem_urlname)
|
|
if student_email:
|
|
student = User.objects.get(email=student_email)
|
|
tasks = instructor_task.api.get_instructor_task_history(course_id, module_state_key, student)
|
|
else:
|
|
tasks = instructor_task.api.get_instructor_task_history(course_id, module_state_key)
|
|
else:
|
|
tasks = instructor_task.api.get_running_instructor_tasks(course_id)
|
|
|
|
def extract_task_features(task):
|
|
FEATURES = ['task_type', 'task_input', 'task_id', 'requester', 'created', 'task_state']
|
|
return dict((feature, str(getattr(task, feature))) for feature in FEATURES)
|
|
|
|
response_payload = {
|
|
'tasks': map(extract_task_features, tasks),
|
|
}
|
|
response = HttpResponse(
|
|
json.dumps(response_payload), content_type="application/json"
|
|
)
|
|
return response
|
|
|
|
|
|
@ensure_csrf_cookie
|
|
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
|
def list_forum_members(request, course_id):
|
|
"""
|
|
Resets a students attempts counter. Optionally deletes student state for a problem.
|
|
Limited to staff access.
|
|
|
|
Takes query parameter rolename
|
|
"""
|
|
course = get_course_with_access(
|
|
request.user, course_id, 'staff', depth=None
|
|
)
|
|
|
|
rolename = request.GET.get('rolename')
|
|
|
|
if not rolename in [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA]:
|
|
return HttpResponseBadRequest()
|
|
|
|
try:
|
|
role = Role.objects.get(name=rolename, course_id=course_id)
|
|
users = role.users.all().order_by('username')
|
|
except Role.DoesNotExist:
|
|
users = []
|
|
|
|
def extract_user_info(user):
|
|
return {
|
|
'username': user.username,
|
|
'email': user.email,
|
|
'first_name': user.first_name,
|
|
'last_name': user.last_name,
|
|
}
|
|
|
|
response_payload = {
|
|
'course_id': course_id,
|
|
rolename: map(extract_user_info, users),
|
|
}
|
|
response = HttpResponse(
|
|
json.dumps(response_payload), content_type="application/json"
|
|
)
|
|
return response
|
|
|
|
|
|
@ensure_csrf_cookie
|
|
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
|
@common_exceptions_400
|
|
def update_forum_role_membership(request, course_id):
|
|
"""
|
|
Modify forum role access.
|
|
|
|
Query parameters:
|
|
email is the target users email
|
|
rolename is one of [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA]
|
|
mode is one of ['allow', 'revoke']
|
|
"""
|
|
course = get_course_with_access(
|
|
request.user, course_id, 'instructor', depth=None
|
|
)
|
|
|
|
email = request.GET.get('email')
|
|
rolename = request.GET.get('rolename')
|
|
mode = request.GET.get('mode')
|
|
|
|
if not rolename in [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA]:
|
|
return HttpResponseBadRequest()
|
|
|
|
try:
|
|
user = User.objects.get(email=email)
|
|
access.update_forum_role_membership(course_id, user, rolename, mode)
|
|
except Role.DoesNotExist:
|
|
return HttpResponseBadRequest("Role does not exist.")
|
|
|
|
response_payload = {
|
|
'course_id': course_id,
|
|
'mode': mode,
|
|
'DONE': 'YES',
|
|
}
|
|
response = HttpResponse(
|
|
json.dumps(response_payload), content_type="application/json"
|
|
)
|
|
return response
|
|
|
|
|
|
def _split_input_list(str_list):
|
|
"""
|
|
Separate out individual student email from the comma, or space separated string.
|
|
|
|
e.g.
|
|
in: "Lorem@ipsum.dolor, sit@amet.consectetur\nadipiscing@elit.Aenean\r convallis@at.lacus\r, ut@lacinia.Sed"
|
|
out: ['Lorem@ipsum.dolor', 'sit@amet.consectetur', 'adipiscing@elit.Aenean', 'convallis@at.lacus', 'ut@lacinia.Sed']
|
|
|
|
`str_list` is a string coming from an input text area
|
|
returns a list of separated values
|
|
"""
|
|
|
|
new_list = re.split(r'[\n\r\s,]', str_list)
|
|
new_list = [s.strip() for s in new_list]
|
|
new_list = [s for s in new_list if s != '']
|
|
|
|
return new_list
|
|
|
|
|
|
def _msk_from_problem_urlname(course_id, urlname):
|
|
"""
|
|
Convert a 'problem urlname' (instructor input name)
|
|
to a module state key (db field)
|
|
"""
|
|
if urlname.endswith(".xml"):
|
|
urlname = urlname[:-4]
|
|
|
|
urlname = "problem/" + urlname
|
|
|
|
(org, course_name, _) = course_id.split("/")
|
|
module_state_key = "i4x://" + org + "/" + course_name + "/" + urlname
|
|
return module_state_key
|