483 lines
20 KiB
Python
483 lines
20 KiB
Python
import json
|
|
import logging
|
|
from django.http import HttpResponse
|
|
from django.db import transaction
|
|
|
|
from celery.result import AsyncResult
|
|
from celery.states import READY_STATES
|
|
|
|
from courseware.models import CourseTaskLog
|
|
from courseware.module_render import get_xqueue_callback_url_prefix
|
|
from courseware.tasks import (rescore_problem,
|
|
reset_problem_attempts, delete_problem_state)
|
|
from xmodule.modulestore.django import modulestore
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
class AlreadyRunningError(Exception):
|
|
pass
|
|
|
|
|
|
def get_running_course_tasks(course_id):
|
|
"""
|
|
Returns a query of CourseTaskLog objects of running tasks for a given course.
|
|
|
|
Used to generate a list of tasks to display on the instructor dashboard.
|
|
"""
|
|
course_tasks = CourseTaskLog.objects.filter(course_id=course_id)
|
|
for state in READY_STATES:
|
|
course_tasks = course_tasks.exclude(task_state=state)
|
|
return course_tasks
|
|
|
|
|
|
def get_course_task_history(course_id, problem_url, student=None):
|
|
"""
|
|
Returns a query of CourseTaskLog objects of historical tasks for a given course,
|
|
that match a particular problem and optionally a student.
|
|
"""
|
|
_, task_key = _encode_problem_and_student_input(problem_url, student)
|
|
|
|
course_tasks = CourseTaskLog.objects.filter(course_id=course_id, task_key=task_key)
|
|
return course_tasks.order_by('-id')
|
|
|
|
|
|
def course_task_log_status(request, task_id=None):
|
|
"""
|
|
This returns the status of a course-related task as a JSON-serialized dict.
|
|
|
|
The task_id can be specified in one of three ways:
|
|
|
|
* explicitly as an argument to the method (by specifying in the url)
|
|
Returns a dict containing status information for the specified task_id
|
|
|
|
* by making a post request containing 'task_id' as a parameter with a single value
|
|
Returns a dict containing status information for the specified task_id
|
|
|
|
* by making a post request containing 'task_ids' as a parameter,
|
|
with a list of task_id values.
|
|
Returns a dict of dicts, with the task_id as key, and the corresponding
|
|
dict containing status information for the specified task_id
|
|
|
|
Task_id values that are unrecognized are skipped.
|
|
|
|
"""
|
|
output = {}
|
|
if task_id is not None:
|
|
output = _get_course_task_log_status(task_id)
|
|
elif 'task_id' in request.POST:
|
|
task_id = request.POST['task_id']
|
|
output = _get_course_task_log_status(task_id)
|
|
elif 'task_ids[]' in request.POST:
|
|
tasks = request.POST.getlist('task_ids[]')
|
|
for task_id in tasks:
|
|
task_output = _get_course_task_log_status(task_id)
|
|
if task_output is not None:
|
|
output[task_id] = task_output
|
|
|
|
return HttpResponse(json.dumps(output, indent=4))
|
|
|
|
|
|
def _task_is_running(course_id, task_type, task_key):
|
|
"""Checks if a particular task is already running"""
|
|
runningTasks = CourseTaskLog.objects.filter(course_id=course_id, task_type=task_type, task_key=task_key)
|
|
for state in READY_STATES:
|
|
runningTasks = runningTasks.exclude(task_state=state)
|
|
return len(runningTasks) > 0
|
|
|
|
|
|
@transaction.autocommit
|
|
def _reserve_task(course_id, task_type, task_key, task_input, requester):
|
|
"""
|
|
Creates a database entry to indicate that a task is in progress.
|
|
|
|
An exception is thrown if the task is already in progress.
|
|
|
|
Autocommit annotation makes sure the database entry is committed.
|
|
"""
|
|
|
|
if _task_is_running(course_id, task_type, task_key):
|
|
raise AlreadyRunningError("requested task is already running")
|
|
|
|
# Create log entry now, so that future requests won't: no task_id yet....
|
|
tasklog_args = {'course_id': course_id,
|
|
'task_type': task_type,
|
|
'task_key': task_key,
|
|
'task_input': json.dumps(task_input),
|
|
'task_state': 'QUEUING',
|
|
'requester': requester}
|
|
|
|
course_task_log = CourseTaskLog.objects.create(**tasklog_args)
|
|
return course_task_log
|
|
|
|
|
|
@transaction.autocommit
|
|
def _update_task(course_task_log, task_result):
|
|
"""
|
|
Updates a database entry with information about the submitted task.
|
|
|
|
Autocommit annotation makes sure the database entry is committed.
|
|
"""
|
|
# we at least update the entry with the task_id, and for EAGER mode,
|
|
# we update other status as well. (For non-EAGER modes, the entry
|
|
# should not have changed except for setting PENDING state and the
|
|
# addition of the task_id.)
|
|
_update_course_task_log(course_task_log, task_result)
|
|
course_task_log.save()
|
|
|
|
|
|
def _get_xmodule_instance_args(request):
|
|
"""
|
|
Calculate parameters needed for instantiating xmodule instances.
|
|
|
|
The `request_info` will be passed to a tracking log function, to provide information
|
|
about the source of the task request. The `xqueue_callback_urul_prefix` is used to
|
|
permit old-style xqueue callbacks directly to the appropriate module in the LMS.
|
|
"""
|
|
request_info = {'username': request.user.username,
|
|
'ip': request.META['REMOTE_ADDR'],
|
|
'agent': request.META.get('HTTP_USER_AGENT', ''),
|
|
'host': request.META['SERVER_NAME'],
|
|
}
|
|
|
|
xmodule_instance_args = {'xqueue_callback_url_prefix': get_xqueue_callback_url_prefix(request),
|
|
'request_info': request_info,
|
|
}
|
|
return xmodule_instance_args
|
|
|
|
|
|
def _update_course_task_log(course_task_log_entry, task_result):
|
|
"""
|
|
Updates and possibly saves a CourseTaskLog entry based on a task Result.
|
|
|
|
Used when a task initially returns, as well as when updated status is
|
|
requested.
|
|
|
|
Calculates json to store in task_progress field.
|
|
"""
|
|
# Just pull values out of the result object once. If we check them later,
|
|
# the state and result may have changed.
|
|
task_id = task_result.task_id
|
|
result_state = task_result.state
|
|
returned_result = task_result.result
|
|
result_traceback = task_result.traceback
|
|
|
|
# Assume we don't always update the CourseTaskLog entry if we don't have to:
|
|
entry_needs_saving = False
|
|
output = {}
|
|
|
|
if result_state == 'PROGRESS':
|
|
# construct a status message directly from the task result's result:
|
|
if hasattr(task_result, 'result') and 'attempted' in returned_result:
|
|
fmt = "Attempted {attempted} of {total}, {action_name} {updated}"
|
|
message = fmt.format(attempted=returned_result['attempted'],
|
|
updated=returned_result['updated'],
|
|
total=returned_result['total'],
|
|
action_name=returned_result['action_name'])
|
|
output['message'] = message
|
|
log.info("task progress: %s", message)
|
|
else:
|
|
log.info("still making progress... ")
|
|
output['task_progress'] = returned_result
|
|
|
|
elif result_state == 'SUCCESS':
|
|
# save progress into the entry, even if it's not being saved here -- for EAGER,
|
|
# it needs to go back with the entry passed in.
|
|
course_task_log_entry.task_output = json.dumps(returned_result)
|
|
output['task_progress'] = returned_result
|
|
log.info("task succeeded: %s", returned_result)
|
|
|
|
elif result_state == 'FAILURE':
|
|
# on failure, the result's result contains the exception that caused the failure
|
|
exception = returned_result
|
|
traceback = result_traceback if result_traceback is not None else ''
|
|
task_progress = {'exception': type(exception).__name__, 'message': str(exception.message)}
|
|
output['message'] = exception.message
|
|
log.warning("background task (%s) failed: %s %s", task_id, returned_result, traceback)
|
|
if result_traceback is not None:
|
|
output['task_traceback'] = result_traceback
|
|
task_progress['traceback'] = result_traceback
|
|
# save progress into the entry, even if it's not being saved -- for EAGER,
|
|
# it needs to go back with the entry passed in.
|
|
course_task_log_entry.task_output = json.dumps(task_progress)
|
|
output['task_progress'] = task_progress
|
|
|
|
elif result_state == 'REVOKED':
|
|
# on revocation, the result's result doesn't contain anything
|
|
# but we cannot rely on the worker thread to set this status,
|
|
# so we set it here.
|
|
entry_needs_saving = True
|
|
message = 'Task revoked before running'
|
|
output['message'] = message
|
|
log.warning("background task (%s) revoked.", task_id)
|
|
task_progress = {'message': message}
|
|
course_task_log_entry.task_output = json.dumps(task_progress)
|
|
output['task_progress'] = task_progress
|
|
|
|
# always update the entry if the state has changed:
|
|
if result_state != course_task_log_entry.task_state:
|
|
course_task_log_entry.task_state = result_state
|
|
course_task_log_entry.task_id = task_id
|
|
|
|
if entry_needs_saving:
|
|
course_task_log_entry.save()
|
|
|
|
return output
|
|
|
|
|
|
def _get_course_task_log_status(task_id):
|
|
"""
|
|
Get the status for a given task_id.
|
|
|
|
Returns a dict, with the following keys:
|
|
'task_id'
|
|
'task_state'
|
|
'in_progress': boolean indicating if the task is still running.
|
|
'message': status message reporting on progress, or providing exception message if failed.
|
|
'task_progress': dict containing progress information. This includes:
|
|
'attempted': number of attempts made
|
|
'updated': number of attempts that "succeeded"
|
|
'total': number of possible subtasks to attempt
|
|
'action_name': user-visible verb to use in status messages. Should be past-tense.
|
|
'duration_ms': how long the task has (or had) been running.
|
|
'task_traceback': optional, returned if task failed and produced a traceback.
|
|
'succeeded': on complete tasks, indicates if the task outcome was successful:
|
|
did it achieve what it set out to do.
|
|
This is in contrast with a successful task_state, which indicates that the
|
|
task merely completed.
|
|
|
|
If task doesn't exist, returns None.
|
|
"""
|
|
# First check if the task_id is known
|
|
try:
|
|
course_task_log_entry = CourseTaskLog.objects.get(task_id=task_id)
|
|
except CourseTaskLog.DoesNotExist:
|
|
# TODO: log a message here
|
|
return None
|
|
|
|
# define ajax return value:
|
|
status = {}
|
|
|
|
# if the task is not already known to be done, then we need to query
|
|
# the underlying task's result object:
|
|
if course_task_log_entry.task_state not in READY_STATES:
|
|
result = AsyncResult(task_id)
|
|
status.update(_update_course_task_log(course_task_log_entry, result))
|
|
elif course_task_log_entry.task_output is not None:
|
|
# task is already known to have finished, but report on its status:
|
|
status['task_progress'] = json.loads(course_task_log_entry.task_output)
|
|
|
|
# status basic information matching what's stored in CourseTaskLog:
|
|
status['task_id'] = course_task_log_entry.task_id
|
|
status['task_state'] = course_task_log_entry.task_state
|
|
status['in_progress'] = course_task_log_entry.task_state not in READY_STATES
|
|
|
|
if course_task_log_entry.task_state in READY_STATES:
|
|
succeeded, message = get_task_completion_message(course_task_log_entry)
|
|
status['message'] = message
|
|
status['succeeded'] = succeeded
|
|
|
|
return status
|
|
|
|
|
|
def get_task_completion_message(course_task_log_entry):
|
|
"""
|
|
Construct progress message from progress information in CourseTaskLog entry.
|
|
|
|
Returns (boolean, message string) duple.
|
|
|
|
Used for providing messages to course_task_log_status(), as well as
|
|
external calls for providing course task submission history information.
|
|
"""
|
|
succeeded = False
|
|
|
|
if course_task_log_entry.task_output is None:
|
|
log.warning("No task_output information found for course_task {0}".format(course_task_log_entry.task_id))
|
|
return (succeeded, "No status information available")
|
|
|
|
task_output = json.loads(course_task_log_entry.task_output)
|
|
if course_task_log_entry.task_state in ['FAILURE', 'REVOKED']:
|
|
return(succeeded, task_output['message'])
|
|
|
|
action_name = task_output['action_name']
|
|
num_attempted = task_output['attempted']
|
|
num_updated = task_output['updated']
|
|
num_total = task_output['total']
|
|
|
|
if course_task_log_entry.task_input is None:
|
|
log.warning("No task_input information found for course_task {0}".format(course_task_log_entry.task_id))
|
|
return (succeeded, "No status information available")
|
|
task_input = json.loads(course_task_log_entry.task_input)
|
|
problem_url = task_input.get('problem_url', None)
|
|
student = task_input.get('student', None)
|
|
if student is not None:
|
|
if num_attempted == 0:
|
|
msg = "Unable to find submission to be {action} for student '{student}'"
|
|
elif num_updated == 0:
|
|
msg = "Problem failed to be {action} for student '{student}'"
|
|
else:
|
|
succeeded = True
|
|
msg = "Problem successfully {action} for student '{student}'"
|
|
elif num_attempted == 0:
|
|
msg = "Unable to find any students with submissions to be {action}"
|
|
elif num_updated == 0:
|
|
msg = "Problem failed to be {action} for any of {attempted} students"
|
|
elif num_updated == num_attempted:
|
|
succeeded = True
|
|
msg = "Problem successfully {action} for {attempted} students"
|
|
elif num_updated < num_attempted:
|
|
msg = "Problem {action} for {updated} of {attempted} students"
|
|
|
|
if student is not None and num_attempted != num_total:
|
|
msg += " (out of {total})"
|
|
|
|
# Update status in task result object itself:
|
|
message = msg.format(action=action_name, updated=num_updated, attempted=num_attempted, total=num_total,
|
|
student=student, problem=problem_url)
|
|
return (succeeded, message)
|
|
|
|
|
|
########### Add task-submission methods here:
|
|
|
|
def _check_arguments_for_rescoring(course_id, problem_url):
|
|
"""
|
|
Do simple checks on the descriptor to confirm that it supports rescoring.
|
|
|
|
Confirms first that the problem_url is defined (since that's currently typed
|
|
in). An ItemNotFoundException is raised if the corresponding module
|
|
descriptor doesn't exist. NotImplementedError is returned if the
|
|
corresponding module doesn't support rescoring calls.
|
|
"""
|
|
descriptor = modulestore().get_instance(course_id, problem_url)
|
|
supports_rescore = False
|
|
if hasattr(descriptor, 'module_class'):
|
|
module_class = descriptor.module_class
|
|
if hasattr(module_class, 'rescore_problem'):
|
|
supports_rescore = True
|
|
|
|
if not supports_rescore:
|
|
msg = "Specified module does not support rescoring."
|
|
raise NotImplementedError(msg)
|
|
|
|
|
|
def _encode_problem_and_student_input(problem_url, student=None):
|
|
"""
|
|
Encode problem_url and optional student into task_key and task_input values.
|
|
|
|
`problem_url` is full URL of the problem.
|
|
`student` is the user object of the student
|
|
"""
|
|
if student is not None:
|
|
task_input = {'problem_url': problem_url, 'student': student.username}
|
|
task_key = "{student}_{problem}".format(student=student.id, problem=problem_url)
|
|
else:
|
|
task_input = {'problem_url': problem_url}
|
|
task_key = "{student}_{problem}".format(student="", problem=problem_url)
|
|
|
|
return task_input, task_key
|
|
|
|
|
|
def _submit_task(request, task_type, task_class, course_id, task_input, task_key):
|
|
"""
|
|
"""
|
|
# check to see if task is already running, and reserve it otherwise:
|
|
course_task_log = _reserve_task(course_id, task_type, task_key, task_input, request.user)
|
|
|
|
# submit task:
|
|
task_args = [course_task_log.id, course_id, task_input, _get_xmodule_instance_args(request)]
|
|
task_result = task_class.apply_async(task_args)
|
|
|
|
# Update info in table with the resulting task_id (and state).
|
|
_update_task(course_task_log, task_result)
|
|
|
|
return course_task_log
|
|
|
|
|
|
def submit_rescore_problem_for_student(request, course_id, problem_url, student):
|
|
"""
|
|
Request a problem to be rescored as a background task.
|
|
|
|
The problem will be rescored for the specified student only. Parameters are the `course_id`,
|
|
the `problem_url`, and the `student` as a User object.
|
|
The url must specify the location of the problem, using i4x-type notation.
|
|
|
|
An exception is thrown if the problem doesn't exist, or if the particular
|
|
problem is already being rescored for this student.
|
|
"""
|
|
# check arguments: let exceptions return up to the caller.
|
|
_check_arguments_for_rescoring(course_id, problem_url)
|
|
|
|
task_type = 'rescore_problem'
|
|
task_class = rescore_problem
|
|
task_input, task_key = _encode_problem_and_student_input(problem_url, student)
|
|
return _submit_task(request, task_type, task_class, course_id, task_input, task_key)
|
|
|
|
|
|
def submit_rescore_problem_for_all_students(request, course_id, problem_url):
|
|
"""
|
|
Request a problem to be rescored as a background task.
|
|
|
|
The problem will be rescored for all students who have accessed the
|
|
particular problem in a course and have provided and checked an answer.
|
|
Parameters are the `course_id` and the `problem_url`.
|
|
The url must specify the location of the problem, using i4x-type notation.
|
|
|
|
An exception is thrown if the problem doesn't exist, or if the particular
|
|
problem is already being rescored.
|
|
"""
|
|
# check arguments: let exceptions return up to the caller.
|
|
_check_arguments_for_rescoring(course_id, problem_url)
|
|
|
|
# check to see if task is already running, and reserve it otherwise
|
|
task_type = 'rescore_problem'
|
|
task_class = rescore_problem
|
|
task_input, task_key = _encode_problem_and_student_input(problem_url)
|
|
return _submit_task(request, task_type, task_class, course_id, task_input, task_key)
|
|
|
|
|
|
def submit_reset_problem_attempts_for_all_students(request, course_id, problem_url):
|
|
"""
|
|
Request to have attempts reset for a problem as a background task.
|
|
|
|
The problem's attempts will be reset for all students who have accessed the
|
|
particular problem in a course. Parameters are the `course_id` and
|
|
the `problem_url`. The url must specify the location of the problem,
|
|
using i4x-type notation.
|
|
|
|
An exception is thrown if the problem doesn't exist, or if the particular
|
|
problem is already being reset.
|
|
"""
|
|
# check arguments: make sure that the problem_url is defined
|
|
# (since that's currently typed in). If the corresponding module descriptor doesn't exist,
|
|
# an exception will be raised. Let it pass up to the caller.
|
|
modulestore().get_instance(course_id, problem_url)
|
|
|
|
task_type = 'reset_problem_attempts'
|
|
task_class = reset_problem_attempts
|
|
task_input, task_key = _encode_problem_and_student_input(problem_url)
|
|
return _submit_task(request, task_type, task_class, course_id, task_input, task_key)
|
|
|
|
|
|
def submit_delete_problem_state_for_all_students(request, course_id, problem_url):
|
|
"""
|
|
Request to have state deleted for a problem as a background task.
|
|
|
|
The problem's state will be deleted for all students who have accessed the
|
|
particular problem in a course. Parameters are the `course_id` and
|
|
the `problem_url`. The url must specify the location of the problem,
|
|
using i4x-type notation.
|
|
|
|
An exception is thrown if the problem doesn't exist, or if the particular
|
|
problem is already being deleted.
|
|
"""
|
|
# check arguments: make sure that the problem_url is defined
|
|
# (since that's currently typed in). If the corresponding module descriptor doesn't exist,
|
|
# an exception will be raised. Let it pass up to the caller.
|
|
modulestore().get_instance(course_id, problem_url)
|
|
|
|
task_type = 'delete_problem_state'
|
|
task_class = delete_problem_state
|
|
task_input, task_key = _encode_problem_and_student_input(problem_url)
|
|
return _submit_task(request, task_type, task_class, course_id, task_input, task_key)
|