Move code to instructor_task Django app.
This commit is contained in:
@@ -1,514 +0,0 @@
|
||||
import hashlib
|
||||
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, SUCCESS, FAILURE, REVOKED
|
||||
|
||||
from courseware.models import CourseTask
|
||||
from courseware.module_render import get_xqueue_callback_url_prefix
|
||||
from courseware.tasks import (PROGRESS, rescore_problem,
|
||||
reset_problem_attempts, delete_problem_state)
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# define a "state" used in CourseTask
|
||||
QUEUING = 'QUEUING'
|
||||
|
||||
class AlreadyRunningError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def get_running_course_tasks(course_id):
|
||||
"""
|
||||
Returns a query of CourseTask objects of running tasks for a given course.
|
||||
|
||||
Used to generate a list of tasks to display on the instructor dashboard.
|
||||
"""
|
||||
course_tasks = CourseTask.objects.filter(course_id=course_id)
|
||||
# exclude states that are "ready" (i.e. not "running", e.g. failure, success, revoked):
|
||||
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 CourseTask 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 = CourseTask.objects.filter(course_id=course_id, task_key=task_key)
|
||||
return course_tasks.order_by('-id')
|
||||
|
||||
|
||||
def course_task_status(request):
|
||||
"""
|
||||
View method that returns the status of a course-related task or tasks.
|
||||
|
||||
Status is returned as a JSON-serialized dict, wrapped as the content of a HTTPResponse.
|
||||
|
||||
The task_id can be specified to this view in one of three ways:
|
||||
|
||||
* by making a 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 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' in request.REQUEST:
|
||||
task_id = request.REQUEST['task_id']
|
||||
output = _get_course_task_status(task_id)
|
||||
elif 'task_ids[]' in request.REQUEST:
|
||||
tasks = request.REQUEST.getlist('task_ids[]')
|
||||
for task_id in tasks:
|
||||
task_output = _get_course_task_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 = CourseTask.objects.filter(course_id=course_id, task_type=task_type, task_key=task_key)
|
||||
# exclude states that are "ready" (i.e. not "running", e.g. failure, success, revoked):
|
||||
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.
|
||||
|
||||
Throws AlreadyRunningError 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 = CourseTask.objects.create(**tasklog_args)
|
||||
return course_task
|
||||
|
||||
|
||||
@transaction.autocommit
|
||||
def _update_task(course_task, 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 ALWAYS_EAGER mode,
|
||||
# we update other status as well. (For non-ALWAYS_EAGER modes, the entry
|
||||
# should not have changed except for setting PENDING state and the
|
||||
# addition of the task_id.)
|
||||
_update_course_task(course_task, task_result)
|
||||
course_task.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_url_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(course_task, task_result):
|
||||
"""
|
||||
Updates and possibly saves a CourseTask entry based on a task Result.
|
||||
|
||||
Used when a task initially returns, as well as when updated status is
|
||||
requested.
|
||||
|
||||
The `course_task` that is passed in is updated in-place, but
|
||||
is usually not saved. In general, tasks that have finished (either with
|
||||
success or failure) should have their entries updated by the task itself,
|
||||
so are not updated here. Tasks that are still running are not updated
|
||||
while they run. So the one exception to the no-save rule are tasks that
|
||||
are in a "revoked" state. This may mean that the task never had the
|
||||
opportunity to update the CourseTask entry.
|
||||
|
||||
Calculates json to store in "task_output" field of the `course_task`,
|
||||
as well as updating the task_state and task_id (which may not yet be set
|
||||
if this is the first call after the task is submitted).
|
||||
|
||||
Returns a dict, with the following keys:
|
||||
'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.
|
||||
|
||||
"""
|
||||
# Pull values out of the result object as close to each other as possible.
|
||||
# If we wait and check the values later, the values for the state and result
|
||||
# are more likely to have changed. Pull the state out first, and
|
||||
# then code assuming that the result may not exactly match the state.
|
||||
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 CourseTask 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:
|
||||
# it needs to go back with the entry passed in.
|
||||
course_task.task_output = json.dumps(returned_result)
|
||||
output['task_progress'] = returned_result
|
||||
log.info("background task (%s), succeeded: %s", task_id, 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
|
||||
# truncate any traceback that goes into the CourseTask model:
|
||||
task_progress['traceback'] = result_traceback[:700]
|
||||
# save progress into the entry, even if it's not being saved:
|
||||
# when celery is run in "ALWAYS_EAGER" mode, progress needs to go back
|
||||
# with the entry passed in.
|
||||
course_task.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.task_output = json.dumps(task_progress)
|
||||
output['task_progress'] = task_progress
|
||||
|
||||
# Always update the local version of the entry if the state has changed.
|
||||
# This is important for getting the task_id into the initial version
|
||||
# of the course_task, and also for development environments
|
||||
# when this code is executed when celery is run in "ALWAYS_EAGER" mode.
|
||||
if result_state != course_task.task_state:
|
||||
course_task.task_state = result_state
|
||||
course_task.task_id = task_id
|
||||
|
||||
if entry_needs_saving:
|
||||
course_task.save()
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def _get_course_task_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.
|
||||
|
||||
If task has been REVOKED, the CourseTask entry will be updated.
|
||||
"""
|
||||
# First check if the task_id is known
|
||||
try:
|
||||
course_task = CourseTask.objects.get(task_id=task_id)
|
||||
except CourseTask.DoesNotExist:
|
||||
log.warning("query for CourseTask status failed: task_id=(%s) not found", task_id)
|
||||
return None
|
||||
|
||||
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.task_state not in READY_STATES:
|
||||
result = AsyncResult(task_id)
|
||||
status.update(_update_course_task(course_task, result))
|
||||
elif course_task.task_output is not None:
|
||||
# task is already known to have finished, but report on its status:
|
||||
status['task_progress'] = json.loads(course_task.task_output)
|
||||
|
||||
# status basic information matching what's stored in CourseTask:
|
||||
status['task_id'] = course_task.task_id
|
||||
status['task_state'] = course_task.task_state
|
||||
status['in_progress'] = course_task.task_state not in READY_STATES
|
||||
|
||||
if course_task.task_state in READY_STATES:
|
||||
succeeded, message = get_task_completion_info(course_task)
|
||||
status['message'] = message
|
||||
status['succeeded'] = succeeded
|
||||
|
||||
return status
|
||||
|
||||
|
||||
def get_task_completion_info(course_task):
|
||||
"""
|
||||
Construct progress message from progress information in CourseTask entry.
|
||||
|
||||
Returns (boolean, message string) duple, where the boolean indicates
|
||||
whether the task completed without incident. (It is possible for a
|
||||
task to attempt many sub-tasks, such as rescoring many students' problem
|
||||
responses, and while the task runs to completion, some of the students'
|
||||
responses could not be rescored.)
|
||||
|
||||
Used for providing messages to course_task_status(), as well as
|
||||
external calls for providing course task submission history information.
|
||||
"""
|
||||
succeeded = False
|
||||
|
||||
if course_task.task_output is None:
|
||||
log.warning("No task_output information found for course_task {0}".format(course_task.task_id))
|
||||
return (succeeded, "No status information available")
|
||||
|
||||
task_output = json.loads(course_task.task_output)
|
||||
if course_task.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.task_input is None:
|
||||
log.warning("No task_input information found for course_task {0}".format(course_task.task_id))
|
||||
return (succeeded, "No status information available")
|
||||
task_input = json.loads(course_task.task_input)
|
||||
problem_url = task_input.get('problem_url')
|
||||
student = task_input.get('student')
|
||||
if student is not None:
|
||||
if num_attempted == 0:
|
||||
msg_format = "Unable to find submission to be {action} for student '{student}'"
|
||||
elif num_updated == 0:
|
||||
msg_format = "Problem failed to be {action} for student '{student}'"
|
||||
else:
|
||||
succeeded = True
|
||||
msg_format = "Problem successfully {action} for student '{student}'"
|
||||
elif num_attempted == 0:
|
||||
msg_format = "Unable to find any students with submissions to be {action}"
|
||||
elif num_updated == 0:
|
||||
msg_format = "Problem failed to be {action} for any of {attempted} students"
|
||||
elif num_updated == num_attempted:
|
||||
succeeded = True
|
||||
msg_format = "Problem successfully {action} for {attempted} students"
|
||||
else: # num_updated < num_attempted
|
||||
msg_format = "Problem {action} for {updated} of {attempted} students"
|
||||
|
||||
if student is not None and num_attempted != num_total:
|
||||
msg_format += " (out of {total})"
|
||||
|
||||
# Update status in task result object itself:
|
||||
message = msg_format.format(action=action_name, updated=num_updated, attempted=num_attempted, total=num_total,
|
||||
student=student, problem=problem_url)
|
||||
return (succeeded, message)
|
||||
|
||||
|
||||
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 raised if the
|
||||
corresponding module doesn't support rescoring calls.
|
||||
"""
|
||||
descriptor = modulestore().get_instance(course_id, problem_url)
|
||||
if not hasattr(descriptor, 'module_class') or not hasattr(descriptor.module_class, 'rescore_problem'):
|
||||
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_stub = "{student}_{problem}".format(student=student.id, problem=problem_url)
|
||||
else:
|
||||
task_input = {'problem_url': problem_url}
|
||||
task_key_stub = "{student}_{problem}".format(student="", problem=problem_url)
|
||||
|
||||
# create the key value by using MD5 hash:
|
||||
task_key = hashlib.md5(task_key_stub).hexdigest()
|
||||
|
||||
return task_input, task_key
|
||||
|
||||
|
||||
def _submit_task(request, task_type, task_class, course_id, task_input, task_key):
|
||||
"""
|
||||
Helper method to submit a task.
|
||||
|
||||
Reserves the requested task, based on the `course_id`, `task_type`, and `task_key`,
|
||||
checking to see if the task is already running. The `task_input` is also passed so that
|
||||
it can be stored in the resulting CourseTask entry. Arguments are extracted from
|
||||
the `request` provided by the originating server request. Then the task is submitted to run
|
||||
asynchronously, using the specified `task_class`. Finally the CourseTask entry is
|
||||
updated in order to store the task_id.
|
||||
|
||||
`AlreadyRunningError` is raised if the task is already running.
|
||||
"""
|
||||
# check to see if task is already running, and reserve it otherwise:
|
||||
course_task = _reserve_task(course_id, task_type, task_key, task_input, request.user)
|
||||
|
||||
# submit task:
|
||||
task_args = [course_task.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, task_result)
|
||||
|
||||
return course_task
|
||||
|
||||
|
||||
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.
|
||||
|
||||
ItemNotFoundException is raised if the problem doesn't exist, or AlreadyRunningError
|
||||
if the problem is already being rescored for this student, or NotImplementedError if
|
||||
the problem doesn't support rescoring.
|
||||
"""
|
||||
# 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.
|
||||
|
||||
ItemNotFoundException is raised if the problem doesn't exist, or AlreadyRunningError
|
||||
if the problem is already being rescored, or NotImplementedError if the problem doesn't
|
||||
support rescoring.
|
||||
"""
|
||||
# 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.
|
||||
|
||||
ItemNotFoundException is raised if the problem doesn't exist, or AlreadyRunningError
|
||||
if the 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.
|
||||
|
||||
ItemNotFoundException is raised if the problem doesn't exist, or AlreadyRunningError
|
||||
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)
|
||||
@@ -25,7 +25,7 @@ from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
|
||||
from courseware import grades
|
||||
from courseware import task_submit
|
||||
from instructor_task import api as task_api
|
||||
from courseware.access import (has_access, get_access_group_name,
|
||||
course_beta_test_group_name)
|
||||
from courseware.courses import get_course_with_access
|
||||
@@ -70,7 +70,7 @@ def instructor_dashboard(request, course_id):
|
||||
problems = []
|
||||
plots = []
|
||||
datatable = None
|
||||
|
||||
|
||||
# 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', '')
|
||||
@@ -250,7 +250,7 @@ def instructor_dashboard(request, course_id):
|
||||
problem_urlname = request.POST.get('problem_for_all_students', '')
|
||||
problem_url = get_module_url(problem_urlname)
|
||||
try:
|
||||
course_task = task_submit.submit_rescore_problem_for_all_students(request, course_id, problem_url)
|
||||
course_task = task_api.submit_rescore_problem_for_all_students(request, course_id, problem_url)
|
||||
if course_task is None:
|
||||
msg += '<font color="red">Failed to create a background task for rescoring "{0}".</font>'.format(problem_url)
|
||||
else:
|
||||
@@ -266,7 +266,7 @@ def instructor_dashboard(request, course_id):
|
||||
problem_urlname = request.POST.get('problem_for_all_students', '')
|
||||
problem_url = get_module_url(problem_urlname)
|
||||
try:
|
||||
course_task = task_submit.submit_reset_problem_attempts_for_all_students(request, course_id, problem_url)
|
||||
course_task = task_api.submit_reset_problem_attempts_for_all_students(request, course_id, problem_url)
|
||||
if course_task is None:
|
||||
msg += '<font color="red">Failed to create a background task for resetting "{0}".</font>'.format(problem_url)
|
||||
else:
|
||||
@@ -357,7 +357,7 @@ def instructor_dashboard(request, course_id):
|
||||
else:
|
||||
# "Rescore student's problem submission" case
|
||||
try:
|
||||
course_task = task_submit.submit_rescore_problem_for_student(request, course_id, module_state_key, student)
|
||||
course_task = task_api.submit_rescore_problem_for_student(request, course_id, module_state_key, student)
|
||||
if course_task is None:
|
||||
msg += '<font color="red">Failed to create a background task for rescoring "{0}" for student {1}.</font>'.format(module_state_key, unique_student_identifier)
|
||||
else:
|
||||
@@ -722,7 +722,7 @@ def instructor_dashboard(request, course_id):
|
||||
|
||||
# generate list of pending background tasks
|
||||
if settings.MITX_FEATURES.get('ENABLE_COURSE_BACKGROUND_TASKS'):
|
||||
course_tasks = task_submit.get_running_course_tasks(course_id)
|
||||
course_tasks = task_api.get_running_course_tasks(course_id)
|
||||
else:
|
||||
course_tasks = None
|
||||
|
||||
@@ -1299,7 +1299,7 @@ def get_background_task_table(course_id, problem_url, student=None):
|
||||
Returns a tuple of (msg, datatable), where the msg is a possible error message,
|
||||
and the datatable is the datatable to be used for display.
|
||||
"""
|
||||
history_entries = task_submit.get_course_task_history(course_id, problem_url, student)
|
||||
history_entries = task_api.get_instructor_task_history(course_id, problem_url, student)
|
||||
datatable = None
|
||||
msg = ""
|
||||
# first check to see if there is any history at all
|
||||
|
||||
0
lms/djangoapps/instructor_task/__init__.py
Normal file
0
lms/djangoapps/instructor_task/__init__.py
Normal file
133
lms/djangoapps/instructor_task/api.py
Normal file
133
lms/djangoapps/instructor_task/api.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
API for submitting background tasks by an instructor for a course.
|
||||
|
||||
TODO:
|
||||
|
||||
"""
|
||||
|
||||
from celery.states import READY_STATES
|
||||
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
from instructor_task.models import InstructorTask
|
||||
from instructor_task.tasks import (rescore_problem,
|
||||
reset_problem_attempts,
|
||||
delete_problem_state)
|
||||
|
||||
from instructor_task.api_helper import (check_arguments_for_rescoring,
|
||||
encode_problem_and_student_input,
|
||||
submit_task)
|
||||
|
||||
|
||||
def get_running_instructor_tasks(course_id):
|
||||
"""
|
||||
Returns a query of InstructorTask objects of running tasks for a given course.
|
||||
|
||||
Used to generate a list of tasks to display on the instructor dashboard.
|
||||
"""
|
||||
instructor_tasks = InstructorTask.objects.filter(course_id=course_id)
|
||||
# exclude states that are "ready" (i.e. not "running", e.g. failure, success, revoked):
|
||||
for state in READY_STATES:
|
||||
instructor_tasks = instructor_tasks.exclude(task_state=state)
|
||||
return instructor_tasks
|
||||
|
||||
|
||||
def get_instructor_task_history(course_id, problem_url, student=None):
|
||||
"""
|
||||
Returns a query of InstructorTask 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)
|
||||
|
||||
instructor_tasks = InstructorTask.objects.filter(course_id=course_id, task_key=task_key)
|
||||
return instructor_tasks.order_by('-id')
|
||||
|
||||
|
||||
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.
|
||||
|
||||
ItemNotFoundException is raised if the problem doesn't exist, or AlreadyRunningError
|
||||
if the problem is already being rescored for this student, or NotImplementedError if
|
||||
the problem doesn't support rescoring.
|
||||
"""
|
||||
# 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.
|
||||
|
||||
ItemNotFoundException is raised if the problem doesn't exist, or AlreadyRunningError
|
||||
if the problem is already being rescored, or NotImplementedError if the problem doesn't
|
||||
support rescoring.
|
||||
"""
|
||||
# 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.
|
||||
|
||||
ItemNotFoundException is raised if the problem doesn't exist, or AlreadyRunningError
|
||||
if the 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.
|
||||
|
||||
ItemNotFoundException is raised if the problem doesn't exist, or AlreadyRunningError
|
||||
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)
|
||||
330
lms/djangoapps/instructor_task/api_helper.py
Normal file
330
lms/djangoapps/instructor_task/api_helper.py
Normal file
@@ -0,0 +1,330 @@
|
||||
import hashlib
|
||||
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, SUCCESS, FAILURE, REVOKED
|
||||
|
||||
from courseware.module_render import get_xqueue_callback_url_prefix
|
||||
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from instructor_task.models import InstructorTask
|
||||
# from instructor_task.views import get_task_completion_info
|
||||
from instructor_task.tasks_helper import PROGRESS
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# define a "state" used in InstructorTask
|
||||
QUEUING = 'QUEUING'
|
||||
|
||||
|
||||
class AlreadyRunningError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _task_is_running(course_id, task_type, task_key):
|
||||
"""Checks if a particular task is already running"""
|
||||
runningTasks = InstructorTask.objects.filter(course_id=course_id, task_type=task_type, task_key=task_key)
|
||||
# exclude states that are "ready" (i.e. not "running", e.g. failure, success, revoked):
|
||||
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.
|
||||
|
||||
Throws AlreadyRunningError 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}
|
||||
|
||||
instructor_task = InstructorTask.objects.create(**tasklog_args)
|
||||
return instructor_task
|
||||
|
||||
|
||||
@transaction.autocommit
|
||||
def _update_task(instructor_task, 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 ALWAYS_EAGER mode,
|
||||
# we update other status as well. (For non-ALWAYS_EAGER modes, the entry
|
||||
# should not have changed except for setting PENDING state and the
|
||||
# addition of the task_id.)
|
||||
_update_instructor_task(instructor_task, task_result)
|
||||
instructor_task.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_url_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_instructor_task(instructor_task, task_result):
|
||||
"""
|
||||
Updates and possibly saves a InstructorTask entry based on a task Result.
|
||||
|
||||
Used when a task initially returns, as well as when updated status is
|
||||
requested.
|
||||
|
||||
The `instructor_task` that is passed in is updated in-place, but
|
||||
is usually not saved. In general, tasks that have finished (either with
|
||||
success or failure) should have their entries updated by the task itself,
|
||||
so are not updated here. Tasks that are still running are not updated
|
||||
while they run. So the one exception to the no-save rule are tasks that
|
||||
are in a "revoked" state. This may mean that the task never had the
|
||||
opportunity to update the InstructorTask entry.
|
||||
|
||||
Calculates json to store in "task_output" field of the `instructor_task`,
|
||||
as well as updating the task_state and task_id (which may not yet be set
|
||||
if this is the first call after the task is submitted).
|
||||
|
||||
TODO: Update -- no longer return anything, or maybe the resulting instructor_task.
|
||||
|
||||
Returns a dict, with the following keys:
|
||||
'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.
|
||||
|
||||
"""
|
||||
# Pull values out of the result object as close to each other as possible.
|
||||
# If we wait and check the values later, the values for the state and result
|
||||
# are more likely to have changed. Pull the state out first, and
|
||||
# then code assuming that the result may not exactly match the state.
|
||||
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 InstructorTask entry if we don't have to:
|
||||
entry_needs_saving = False
|
||||
output = {}
|
||||
|
||||
if result_state in [PROGRESS, SUCCESS]:
|
||||
# construct a status message directly from the task result's result:
|
||||
# it needs to go back with the entry passed in.
|
||||
instructor_task.task_output = json.dumps(returned_result)
|
||||
# output['task_progress'] = returned_result
|
||||
log.info("background task (%s), succeeded: %s", task_id, 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
|
||||
# truncate any traceback that goes into the InstructorTask model:
|
||||
task_progress['traceback'] = result_traceback[:700]
|
||||
# save progress into the entry, even if it's not being saved:
|
||||
# when celery is run in "ALWAYS_EAGER" mode, progress needs to go back
|
||||
# with the entry passed in.
|
||||
instructor_task.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}
|
||||
instructor_task.task_output = json.dumps(task_progress)
|
||||
# output['task_progress'] = task_progress
|
||||
|
||||
# Always update the local version of the entry if the state has changed.
|
||||
# This is important for getting the task_id into the initial version
|
||||
# of the instructor_task, and also for development environments
|
||||
# when this code is executed when celery is run in "ALWAYS_EAGER" mode.
|
||||
if result_state != instructor_task.task_state:
|
||||
instructor_task.task_state = result_state
|
||||
instructor_task.task_id = task_id
|
||||
|
||||
if entry_needs_saving:
|
||||
instructor_task.save()
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def _get_updated_instructor_task(task_id):
|
||||
# First check if the task_id is known
|
||||
try:
|
||||
instructor_task = InstructorTask.objects.get(task_id=task_id)
|
||||
except InstructorTask.DoesNotExist:
|
||||
log.warning("query for InstructorTask status failed: task_id=(%s) not found", task_id)
|
||||
return None
|
||||
|
||||
# if the task is not already known to be done, then we need to query
|
||||
# the underlying task's result object:
|
||||
if instructor_task.task_state not in READY_STATES:
|
||||
result = AsyncResult(task_id)
|
||||
_update_instructor_task(instructor_task, result)
|
||||
|
||||
return instructor_task
|
||||
|
||||
|
||||
# def _get_instructor_task_status(task_id):
|
||||
def _get_instructor_task_status(instructor_task):
|
||||
"""
|
||||
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.
|
||||
|
||||
If task has been REVOKED, the InstructorTask entry will be updated.
|
||||
"""
|
||||
# # First check if the task_id is known
|
||||
# try:
|
||||
# instructor_task = InstructorTask.objects.get(task_id=task_id)
|
||||
# except InstructorTask.DoesNotExist:
|
||||
# log.warning("query for InstructorTask status failed: task_id=(%s) not found", task_id)
|
||||
# return None
|
||||
|
||||
status = {}
|
||||
|
||||
# if the task is not already known to be done, then we need to query
|
||||
# the underlying task's result object:
|
||||
# if instructor_task.task_state not in READY_STATES:
|
||||
# result = AsyncResult(task_id)
|
||||
# status.update(_update_instructor_task(instructor_task, result))
|
||||
|
||||
# elif instructor_task.task_output is not None:
|
||||
# task is already known to have finished, but report on its status:
|
||||
if instructor_task.task_output is not None:
|
||||
status['task_progress'] = json.loads(instructor_task.task_output)
|
||||
|
||||
# status basic information matching what's stored in InstructorTask:
|
||||
status['task_id'] = instructor_task.task_id
|
||||
status['task_state'] = instructor_task.task_state
|
||||
status['in_progress'] = instructor_task.task_state not in READY_STATES
|
||||
|
||||
# if instructor_task.task_state in READY_STATES:
|
||||
# succeeded, message = get_task_completion_info(instructor_task)
|
||||
# status['message'] = message
|
||||
# status['succeeded'] = succeeded
|
||||
|
||||
return status
|
||||
|
||||
|
||||
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 raised if the
|
||||
corresponding module doesn't support rescoring calls.
|
||||
"""
|
||||
descriptor = modulestore().get_instance(course_id, problem_url)
|
||||
if not hasattr(descriptor, 'module_class') or not hasattr(descriptor.module_class, 'rescore_problem'):
|
||||
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_stub = "{student}_{problem}".format(student=student.id, problem=problem_url)
|
||||
else:
|
||||
task_input = {'problem_url': problem_url}
|
||||
task_key_stub = "{student}_{problem}".format(student="", problem=problem_url)
|
||||
|
||||
# create the key value by using MD5 hash:
|
||||
task_key = hashlib.md5(task_key_stub).hexdigest()
|
||||
|
||||
return task_input, task_key
|
||||
|
||||
|
||||
def submit_task(request, task_type, task_class, course_id, task_input, task_key):
|
||||
"""
|
||||
Helper method to submit a task.
|
||||
|
||||
Reserves the requested task, based on the `course_id`, `task_type`, and `task_key`,
|
||||
checking to see if the task is already running. The `task_input` is also passed so that
|
||||
it can be stored in the resulting InstructorTask entry. Arguments are extracted from
|
||||
the `request` provided by the originating server request. Then the task is submitted to run
|
||||
asynchronously, using the specified `task_class`. Finally the InstructorTask entry is
|
||||
updated in order to store the task_id.
|
||||
|
||||
`AlreadyRunningError` is raised if the task is already running.
|
||||
"""
|
||||
# check to see if task is already running, and reserve it otherwise:
|
||||
instructor_task = _reserve_task(course_id, task_type, task_key, task_input, request.user)
|
||||
|
||||
# submit task:
|
||||
task_args = [instructor_task.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(instructor_task, task_result)
|
||||
|
||||
return instructor_task
|
||||
86
lms/djangoapps/instructor_task/migrations/0001_initial.py
Normal file
86
lms/djangoapps/instructor_task/migrations/0001_initial.py
Normal file
@@ -0,0 +1,86 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
# Adding model 'InstructorTask'
|
||||
db.create_table('instructor_task_instructortask', (
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('task_type', self.gf('django.db.models.fields.CharField')(max_length=50, db_index=True)),
|
||||
('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
|
||||
('task_key', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
|
||||
('task_input', self.gf('django.db.models.fields.CharField')(max_length=255)),
|
||||
('task_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
|
||||
('task_state', self.gf('django.db.models.fields.CharField')(max_length=50, null=True, db_index=True)),
|
||||
('task_output', self.gf('django.db.models.fields.CharField')(max_length=1024, null=True)),
|
||||
('requester', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])),
|
||||
('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, null=True, blank=True)),
|
||||
('updated', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)),
|
||||
))
|
||||
db.send_create_signal('instructor_task', ['InstructorTask'])
|
||||
|
||||
|
||||
def backwards(self, orm):
|
||||
# Deleting model 'InstructorTask'
|
||||
db.delete_table('instructor_task_instructortask')
|
||||
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
'instructor_task.instructortask': {
|
||||
'Meta': {'object_name': 'InstructorTask'},
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'requester': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
|
||||
'task_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'task_input': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
|
||||
'task_key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'task_output': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'null': 'True'}),
|
||||
'task_state': ('django.db.models.fields.CharField', [], {'max_length': '50', 'null': 'True', 'db_index': 'True'}),
|
||||
'task_type': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
|
||||
'updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['instructor_task']
|
||||
63
lms/djangoapps/instructor_task/models.py
Normal file
63
lms/djangoapps/instructor_task/models.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
WE'RE USING MIGRATIONS!
|
||||
|
||||
If you make changes to this model, be sure to create an appropriate migration
|
||||
file and check it in at the same time as your model changes. To do that,
|
||||
|
||||
1. Go to the edx-platform dir
|
||||
2. ./manage.py schemamigration courseware --auto description_of_your_change
|
||||
3. Add the migration file created in edx-platform/lms/djangoapps/instructor_task/migrations/
|
||||
|
||||
|
||||
ASSUMPTIONS: modules have unique IDs, even across different module_types
|
||||
|
||||
"""
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import models
|
||||
|
||||
|
||||
class InstructorTask(models.Model):
|
||||
"""
|
||||
Stores information about background tasks that have been submitted to
|
||||
perform work by an instructor (or course staff).
|
||||
Examples include grading and rescoring.
|
||||
|
||||
`task_type` identifies the kind of task being performed, e.g. rescoring.
|
||||
`course_id` uses the course run's unique id to identify the course.
|
||||
`task_input` stores input arguments as JSON-serialized dict, for reporting purposes.
|
||||
Examples include url of problem being rescored, id of student if only one student being rescored.
|
||||
`task_key` stores relevant input arguments encoded into key value for testing to see
|
||||
if the task is already running (together with task_type and course_id).
|
||||
|
||||
`task_id` stores the id used by celery for the background task.
|
||||
`task_state` stores the last known state of the celery task
|
||||
`task_output` stores the output of the celery task.
|
||||
Format is a JSON-serialized dict. Content varies by task_type and task_state.
|
||||
|
||||
`requester` stores id of user who submitted the task
|
||||
`created` stores date that entry was first created
|
||||
`updated` stores date that entry was last modified
|
||||
"""
|
||||
task_type = models.CharField(max_length=50, db_index=True)
|
||||
course_id = models.CharField(max_length=255, db_index=True)
|
||||
task_key = models.CharField(max_length=255, db_index=True)
|
||||
task_input = models.CharField(max_length=255)
|
||||
task_id = models.CharField(max_length=255, db_index=True) # max_length from celery_taskmeta
|
||||
task_state = models.CharField(max_length=50, null=True, db_index=True) # max_length from celery_taskmeta
|
||||
task_output = models.CharField(max_length=1024, null=True)
|
||||
requester = models.ForeignKey(User, db_index=True)
|
||||
created = models.DateTimeField(auto_now_add=True, null=True)
|
||||
updated = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __repr__(self):
|
||||
return 'InstructorTask<%r>' % ({
|
||||
'task_type': self.task_type,
|
||||
'course_id': self.course_id,
|
||||
'task_input': self.task_input,
|
||||
'task_id': self.task_id,
|
||||
'task_state': self.task_state,
|
||||
'task_output': self.task_output,
|
||||
},)
|
||||
|
||||
def __unicode__(self):
|
||||
return unicode(repr(self))
|
||||
82
lms/djangoapps/instructor_task/tasks.py
Normal file
82
lms/djangoapps/instructor_task/tasks.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
This file contains tasks that are designed to perform background operations on the
|
||||
running state of a course.
|
||||
|
||||
|
||||
|
||||
"""
|
||||
from celery import task
|
||||
from instructor_task.tasks_helper import (_update_problem_module_state,
|
||||
_rescore_problem_module_state,
|
||||
_reset_problem_attempts_module_state,
|
||||
_delete_problem_module_state)
|
||||
|
||||
|
||||
@task
|
||||
def rescore_problem(entry_id, course_id, task_input, xmodule_instance_args):
|
||||
"""Rescores problem in `course_id`.
|
||||
|
||||
`entry_id` is the id value of the InstructorTask entry that corresponds to this task.
|
||||
`course_id` identifies the course.
|
||||
`task_input` should be a dict with the following entries:
|
||||
|
||||
'problem_url': the full URL to the problem to be rescored. (required)
|
||||
'student': the identifier (username or email) of a particular user whose
|
||||
problem submission should be rescored. If not specified, all problem
|
||||
submissions will be rescored.
|
||||
|
||||
`xmodule_instance_args` provides information needed by _get_module_instance_for_task()
|
||||
to instantiate an xmodule instance.
|
||||
"""
|
||||
action_name = 'rescored'
|
||||
update_fcn = _rescore_problem_module_state
|
||||
filter_fcn = lambda(modules_to_update): modules_to_update.filter(state__contains='"done": true')
|
||||
problem_url = task_input.get('problem_url')
|
||||
student_ident = None
|
||||
if 'student' in task_input:
|
||||
student_ident = task_input['student']
|
||||
return _update_problem_module_state(entry_id, course_id, problem_url, student_ident,
|
||||
update_fcn, action_name, filter_fcn=filter_fcn,
|
||||
xmodule_instance_args=xmodule_instance_args)
|
||||
|
||||
|
||||
@task
|
||||
def reset_problem_attempts(entry_id, course_id, task_input, xmodule_instance_args):
|
||||
"""Resets problem attempts to zero for `problem_url` in `course_id` for all students.
|
||||
|
||||
`entry_id` is the id value of the InstructorTask entry that corresponds to this task.
|
||||
`course_id` identifies the course.
|
||||
`task_input` should be a dict with the following entries:
|
||||
|
||||
'problem_url': the full URL to the problem to be rescored. (required)
|
||||
|
||||
`xmodule_instance_args` provides information needed by _get_module_instance_for_task()
|
||||
to instantiate an xmodule instance.
|
||||
"""
|
||||
action_name = 'reset'
|
||||
update_fcn = _reset_problem_attempts_module_state
|
||||
problem_url = task_input.get('problem_url')
|
||||
return _update_problem_module_state(entry_id, course_id, problem_url, None,
|
||||
update_fcn, action_name, filter_fcn=None,
|
||||
xmodule_instance_args=xmodule_instance_args)
|
||||
|
||||
|
||||
@task
|
||||
def delete_problem_state(entry_id, course_id, task_input, xmodule_instance_args):
|
||||
"""Deletes problem state entirely for `problem_url` in `course_id` for all students.
|
||||
|
||||
`entry_id` is the id value of the InstructorTask entry that corresponds to this task.
|
||||
`course_id` identifies the course.
|
||||
`task_input` should be a dict with the following entries:
|
||||
|
||||
'problem_url': the full URL to the problem to be rescored. (required)
|
||||
|
||||
`xmodule_instance_args` provides information needed by _get_module_instance_for_task()
|
||||
to instantiate an xmodule instance.
|
||||
"""
|
||||
action_name = 'deleted'
|
||||
update_fcn = _delete_problem_module_state
|
||||
problem_url = task_input.get('problem_url')
|
||||
return _update_problem_module_state(entry_id, course_id, problem_url, None,
|
||||
update_fcn, action_name, filter_fcn=None,
|
||||
xmodule_instance_args=xmodule_instance_args)
|
||||
@@ -11,7 +11,7 @@ from time import time
|
||||
from sys import exc_info
|
||||
from traceback import format_exc
|
||||
|
||||
from celery import task, current_task
|
||||
from celery import current_task
|
||||
from celery.utils.log import get_task_logger
|
||||
from celery.states import SUCCESS, FAILURE
|
||||
|
||||
@@ -24,10 +24,10 @@ from xmodule.modulestore.django import modulestore
|
||||
import mitxmako.middleware as middleware
|
||||
from track.views import task_track
|
||||
|
||||
from courseware.models import StudentModule, CourseTask
|
||||
from courseware.models import StudentModule
|
||||
from courseware.model_data import ModelDataCache
|
||||
from courseware.module_render import get_module_for_descriptor_internal
|
||||
|
||||
from instructor_task.models import InstructorTask
|
||||
|
||||
# define different loggers for use within tasks and on client side
|
||||
TASK_LOG = get_task_logger(__name__)
|
||||
@@ -78,7 +78,7 @@ def _perform_module_state_update(course_id, module_state_key, student_identifier
|
||||
'duration_ms': how long the task has (or had) been running.
|
||||
|
||||
Because this is run internal to a task, it does not catch exceptions. These are allowed to pass up to the
|
||||
next level, so that it can set the failure modes and capture the error trace in the CourseTask and the
|
||||
next level, so that it can set the failure modes and capture the error trace in the InstructorTask and the
|
||||
result object.
|
||||
|
||||
"""
|
||||
@@ -157,7 +157,7 @@ def _perform_module_state_update(course_id, module_state_key, student_identifier
|
||||
|
||||
@transaction.autocommit
|
||||
def _save_course_task(course_task):
|
||||
"""Writes CourseTask course_task immediately, ensuring the transaction is committed."""
|
||||
"""Writes InstructorTask course_task immediately, ensuring the transaction is committed."""
|
||||
course_task.save()
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ def _update_problem_module_state(entry_id, course_id, module_state_key, student_
|
||||
"""
|
||||
Performs generic update by visiting StudentModule instances with the update_fcn provided.
|
||||
|
||||
The `entry_id` is the primary key for the CourseTask entry representing the task. This function
|
||||
The `entry_id` is the primary key for the InstructorTask entry representing the task. This function
|
||||
updates the entry on success and failure of the _perform_module_state_update function it
|
||||
wraps. It is setting the entry's value for task_state based on what Celery would set it to once
|
||||
the task returns to Celery: FAILURE if an exception is encountered, and SUCCESS if it returns normally.
|
||||
@@ -181,9 +181,9 @@ def _update_problem_module_state(entry_id, course_id, module_state_key, student_
|
||||
Pass-through of input `action_name`.
|
||||
'duration_ms': how long the task has (or had) been running.
|
||||
|
||||
Before returning, this is also JSON-serialized and stored in the task_output column of the CourseTask entry.
|
||||
Before returning, this is also JSON-serialized and stored in the task_output column of the InstructorTask entry.
|
||||
|
||||
If exceptions were raised internally, they are caught and recorded in the CourseTask entry.
|
||||
If exceptions were raised internally, they are caught and recorded in the InstructorTask entry.
|
||||
This is also a JSON-serialized dict, stored in the task_output column, containing the following keys:
|
||||
|
||||
'exception': type of exception object
|
||||
@@ -199,9 +199,9 @@ def _update_problem_module_state(entry_id, course_id, module_state_key, student_
|
||||
fmt = 'Starting to update problem modules as task "{task_id}": course "{course_id}" problem "{state_key}": nothing {action} yet'
|
||||
TASK_LOG.info(fmt.format(task_id=task_id, course_id=course_id, state_key=module_state_key, action=action_name))
|
||||
|
||||
# get the CourseTask to be updated. If this fails, then let the exception return to Celery.
|
||||
# get the InstructorTask to be updated. If this fails, then let the exception return to Celery.
|
||||
# There's no point in catching it here.
|
||||
entry = CourseTask.objects.get(pk=entry_id)
|
||||
entry = InstructorTask.objects.get(pk=entry_id)
|
||||
entry.task_id = task_id
|
||||
_save_course_task(entry)
|
||||
|
||||
@@ -228,7 +228,7 @@ def _update_problem_module_state(entry_id, course_id, module_state_key, student_
|
||||
_save_course_task(entry)
|
||||
raise
|
||||
|
||||
# if we get here, we assume we've succeeded, so update the CourseTask entry in anticipation:
|
||||
# if we get here, we assume we've succeeded, so update the InstructorTask entry in anticipation:
|
||||
entry.task_output = json.dumps(task_progress)
|
||||
entry.task_state = SUCCESS
|
||||
_save_course_task(entry)
|
||||
@@ -329,39 +329,6 @@ def _rescore_problem_module_state(module_descriptor, student_module, xmodule_ins
|
||||
return True
|
||||
|
||||
|
||||
def _filter_module_state_for_done(modules_to_update):
|
||||
"""Filter to apply for rescoring, to limit module instances to those marked as done"""
|
||||
return modules_to_update.filter(state__contains='"done": true')
|
||||
|
||||
|
||||
@task
|
||||
def rescore_problem(entry_id, course_id, task_input, xmodule_instance_args):
|
||||
"""Rescores problem in `course_id`.
|
||||
|
||||
`entry_id` is the id value of the CourseTask entry that corresponds to this task.
|
||||
`course_id` identifies the course.
|
||||
`task_input` should be a dict with the following entries:
|
||||
|
||||
'problem_url': the full URL to the problem to be rescored. (required)
|
||||
'student': the identifier (username or email) of a particular user whose
|
||||
problem submission should be rescored. If not specified, all problem
|
||||
submissions will be rescored.
|
||||
|
||||
`xmodule_instance_args` provides information needed by _get_module_instance_for_task()
|
||||
to instantiate an xmodule instance.
|
||||
"""
|
||||
action_name = 'rescored'
|
||||
update_fcn = _rescore_problem_module_state
|
||||
filter_fcn = lambda(modules_to_update): modules_to_update.filter(state__contains='"done": true')
|
||||
problem_url = task_input.get('problem_url')
|
||||
student_ident = None
|
||||
if 'student' in task_input:
|
||||
student_ident = task_input['student']
|
||||
return _update_problem_module_state(entry_id, course_id, problem_url, student_ident,
|
||||
update_fcn, action_name, filter_fcn=filter_fcn,
|
||||
xmodule_instance_args=xmodule_instance_args)
|
||||
|
||||
|
||||
@transaction.autocommit
|
||||
def _reset_problem_attempts_module_state(_module_descriptor, student_module, xmodule_instance_args=None):
|
||||
"""
|
||||
@@ -388,27 +355,6 @@ def _reset_problem_attempts_module_state(_module_descriptor, student_module, xmo
|
||||
return True
|
||||
|
||||
|
||||
@task
|
||||
def reset_problem_attempts(entry_id, course_id, task_input, xmodule_instance_args):
|
||||
"""Resets problem attempts to zero for `problem_url` in `course_id` for all students.
|
||||
|
||||
`entry_id` is the id value of the CourseTask entry that corresponds to this task.
|
||||
`course_id` identifies the course.
|
||||
`task_input` should be a dict with the following entries:
|
||||
|
||||
'problem_url': the full URL to the problem to be rescored. (required)
|
||||
|
||||
`xmodule_instance_args` provides information needed by _get_module_instance_for_task()
|
||||
to instantiate an xmodule instance.
|
||||
"""
|
||||
action_name = 'reset'
|
||||
update_fcn = _reset_problem_attempts_module_state
|
||||
problem_url = task_input.get('problem_url')
|
||||
return _update_problem_module_state(entry_id, course_id, problem_url, None,
|
||||
update_fcn, action_name, filter_fcn=None,
|
||||
xmodule_instance_args=xmodule_instance_args)
|
||||
|
||||
|
||||
@transaction.autocommit
|
||||
def _delete_problem_module_state(_module_descriptor, student_module, xmodule_instance_args=None):
|
||||
"""
|
||||
@@ -423,24 +369,3 @@ def _delete_problem_module_state(_module_descriptor, student_module, xmodule_ins
|
||||
task_info = {"student": student_module.student.username, "task_id": _get_task_id_from_xmodule_args(xmodule_instance_args)}
|
||||
task_track(request_info, task_info, 'problem_delete_state', {}, page='x_module_task')
|
||||
return True
|
||||
|
||||
|
||||
@task
|
||||
def delete_problem_state(entry_id, course_id, task_input, xmodule_instance_args):
|
||||
"""Deletes problem state entirely for `problem_url` in `course_id` for all students.
|
||||
|
||||
`entry_id` is the id value of the CourseTask entry that corresponds to this task.
|
||||
`course_id` identifies the course.
|
||||
`task_input` should be a dict with the following entries:
|
||||
|
||||
'problem_url': the full URL to the problem to be rescored. (required)
|
||||
|
||||
`xmodule_instance_args` provides information needed by _get_module_instance_for_task()
|
||||
to instantiate an xmodule instance.
|
||||
"""
|
||||
action_name = 'deleted'
|
||||
update_fcn = _delete_problem_module_state
|
||||
problem_url = task_input.get('problem_url')
|
||||
return _update_problem_module_state(entry_id, course_id, problem_url, None,
|
||||
update_fcn, action_name, filter_fcn=None,
|
||||
xmodule_instance_args=xmodule_instance_args)
|
||||
0
lms/djangoapps/instructor_task/tests/__init__.py
Normal file
0
lms/djangoapps/instructor_task/tests/__init__.py
Normal file
19
lms/djangoapps/instructor_task/tests/factories.py
Normal file
19
lms/djangoapps/instructor_task/tests/factories.py
Normal file
@@ -0,0 +1,19 @@
|
||||
import json
|
||||
|
||||
from factory import DjangoModelFactory, SubFactory
|
||||
from student.tests.factories import UserFactory as StudentUserFactory
|
||||
from instructor_task.models import InstructorTask
|
||||
from celery.states import PENDING
|
||||
|
||||
|
||||
class InstructorTaskFactory(DjangoModelFactory):
|
||||
FACTORY_FOR = InstructorTask
|
||||
|
||||
task_type = 'rescore_problem'
|
||||
course_id = "MITx/999/Robot_Super_Course"
|
||||
task_input = json.dumps({})
|
||||
task_key = None
|
||||
task_id = None
|
||||
task_state = PENDING
|
||||
task_output = None
|
||||
requester = SubFactory(StudentUserFactory)
|
||||
@@ -13,17 +13,20 @@ from django.test.testcases import TestCase
|
||||
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
|
||||
from courseware.tests.factories import UserFactory, CourseTaskFactory
|
||||
from courseware.tasks import PROGRESS
|
||||
from courseware.task_submit import (QUEUING,
|
||||
get_running_course_tasks,
|
||||
course_task_status,
|
||||
_encode_problem_and_student_input,
|
||||
AlreadyRunningError,
|
||||
submit_rescore_problem_for_all_students,
|
||||
submit_rescore_problem_for_student,
|
||||
submit_reset_problem_attempts_for_all_students,
|
||||
submit_delete_problem_state_for_all_students)
|
||||
from courseware.tests.factories import UserFactory
|
||||
from instructor_task.tests.factories import InstructorTaskFactory
|
||||
from instructor_task.tasks_helper import PROGRESS
|
||||
from instructor_task.views import instructor_task_status
|
||||
from instructor_task.api import (get_running_instructor_tasks,
|
||||
submit_rescore_problem_for_all_students,
|
||||
submit_rescore_problem_for_student,
|
||||
submit_reset_problem_attempts_for_all_students,
|
||||
submit_delete_problem_state_for_all_students)
|
||||
|
||||
from instructor_task.api_helper import (QUEUING,
|
||||
AlreadyRunningError,
|
||||
encode_problem_and_student_input,
|
||||
)
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -52,12 +55,12 @@ class TaskSubmitTestCase(TestCase):
|
||||
problem_url_name=problem_url_name)
|
||||
|
||||
def _create_entry(self, task_state=QUEUING, task_output=None, student=None):
|
||||
"""Creates a CourseTask entry for testing."""
|
||||
"""Creates a InstructorTask entry for testing."""
|
||||
task_id = str(uuid4())
|
||||
progress_json = json.dumps(task_output)
|
||||
task_input, task_key = _encode_problem_and_student_input(self.problem_url, student)
|
||||
task_input, task_key = encode_problem_and_student_input(self.problem_url, student)
|
||||
|
||||
course_task = CourseTaskFactory.create(course_id=TEST_COURSE_ID,
|
||||
course_task = InstructorTaskFactory.create(course_id=TEST_COURSE_ID,
|
||||
requester=self.instructor,
|
||||
task_input=json.dumps(task_input),
|
||||
task_key=task_key,
|
||||
@@ -67,7 +70,7 @@ class TaskSubmitTestCase(TestCase):
|
||||
return course_task
|
||||
|
||||
def _create_failure_entry(self):
|
||||
"""Creates a CourseTask entry representing a failed task."""
|
||||
"""Creates a InstructorTask entry representing a failed task."""
|
||||
# view task entry for task failure
|
||||
progress = {'message': TEST_FAILURE_MESSAGE,
|
||||
'exception': 'RandomCauseError',
|
||||
@@ -75,11 +78,11 @@ class TaskSubmitTestCase(TestCase):
|
||||
return self._create_entry(task_state=FAILURE, task_output=progress)
|
||||
|
||||
def _create_success_entry(self, student=None):
|
||||
"""Creates a CourseTask entry representing a successful task."""
|
||||
"""Creates a InstructorTask entry representing a successful task."""
|
||||
return self._create_progress_entry(student, task_state=SUCCESS)
|
||||
|
||||
def _create_progress_entry(self, student=None, task_state=PROGRESS):
|
||||
"""Creates a CourseTask entry representing a task in progress."""
|
||||
"""Creates a InstructorTask entry representing a task in progress."""
|
||||
progress = {'attempted': 3,
|
||||
'updated': 2,
|
||||
'total': 10,
|
||||
@@ -88,26 +91,26 @@ class TaskSubmitTestCase(TestCase):
|
||||
}
|
||||
return self._create_entry(task_state=task_state, task_output=progress, student=student)
|
||||
|
||||
def test_fetch_running_tasks(self):
|
||||
def test_get_running_instructor_tasks(self):
|
||||
# when fetching running tasks, we get all running tasks, and only running tasks
|
||||
for _ in range(1, 5):
|
||||
self._create_failure_entry()
|
||||
self._create_success_entry()
|
||||
progress_task_ids = [self._create_progress_entry().task_id for _ in range(1, 5)]
|
||||
task_ids = [course_task.task_id for course_task in get_running_course_tasks(TEST_COURSE_ID)]
|
||||
task_ids = [course_task.task_id for course_task in get_running_instructor_tasks(TEST_COURSE_ID)]
|
||||
self.assertEquals(set(task_ids), set(progress_task_ids))
|
||||
|
||||
def _get_course_task_status(self, task_id):
|
||||
request = Mock()
|
||||
request.REQUEST = {'task_id': task_id}
|
||||
return course_task_status(request)
|
||||
return instructor_task_status(request)
|
||||
|
||||
def test_course_task_status(self):
|
||||
course_task = self._create_failure_entry()
|
||||
task_id = course_task.task_id
|
||||
request = Mock()
|
||||
request.REQUEST = {'task_id': task_id}
|
||||
response = course_task_status(request)
|
||||
response = instructor_task_status(request)
|
||||
output = json.loads(response.content)
|
||||
self.assertEquals(output['task_id'], task_id)
|
||||
|
||||
@@ -118,7 +121,7 @@ class TaskSubmitTestCase(TestCase):
|
||||
task_ids = [(self._create_failure_entry()).task_id for _ in range(1, 5)]
|
||||
request = Mock()
|
||||
request.REQUEST = MultiValueDict({'task_ids[]': task_ids})
|
||||
response = course_task_status(request)
|
||||
response = instructor_task_status(request)
|
||||
output = json.loads(response.content)
|
||||
self.assertEquals(len(output), len(task_ids))
|
||||
for task_id in task_ids:
|
||||
@@ -221,7 +224,7 @@ class TaskSubmitTestCase(TestCase):
|
||||
self.assertEquals(output['task_state'], SUCCESS)
|
||||
self.assertFalse(output['in_progress'])
|
||||
|
||||
def teBROKENst_success_messages(self):
|
||||
def test_success_messages(self):
|
||||
_, output = self._get_output_for_task_success(0, 0, 10)
|
||||
self.assertTrue("Unable to find any students with submissions to be rescored" in output['message'])
|
||||
self.assertFalse(output['succeeded'])
|
||||
@@ -266,15 +269,16 @@ class TaskSubmitTestCase(TestCase):
|
||||
with self.assertRaises(ItemNotFoundError):
|
||||
submit_delete_problem_state_for_all_students(request, course_id, problem_url)
|
||||
|
||||
def test_submit_when_running(self):
|
||||
# get exception when trying to submit a task that is already running
|
||||
course_task = self._create_progress_entry()
|
||||
problem_url = json.loads(course_task.task_input).get('problem_url')
|
||||
course_id = course_task.course_id
|
||||
# requester doesn't have to be the same when determining if a task is already running
|
||||
request = Mock()
|
||||
request.user = self.student
|
||||
with self.assertRaises(AlreadyRunningError):
|
||||
# just skip making the argument check, so we don't have to fake it deeper down
|
||||
with patch('courseware.task_submit._check_arguments_for_rescoring'):
|
||||
submit_rescore_problem_for_all_students(request, course_id, problem_url)
|
||||
# def test_submit_when_running(self):
|
||||
# # get exception when trying to submit a task that is already running
|
||||
# course_task = self._create_progress_entry()
|
||||
# problem_url = json.loads(course_task.task_input).get('problem_url')
|
||||
# course_id = course_task.course_id
|
||||
# # requester doesn't have to be the same when determining if a task is already running
|
||||
# request = Mock()
|
||||
# request.user = self.instructor
|
||||
# with self.assertRaises(AlreadyRunningError):
|
||||
# # just skip making the argument check, so we don't have to fake it deeper down
|
||||
# with patch('instructor_task.api_helper.check_arguments_for_rescoring') as mock_check:
|
||||
# mock_check.return_value = None
|
||||
# submit_rescore_problem_for_all_students(request, course_id, problem_url)
|
||||
@@ -22,13 +22,14 @@ from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from student.tests.factories import CourseEnrollmentFactory, UserFactory, AdminFactory
|
||||
|
||||
from courseware.model_data import StudentModule
|
||||
from courseware.task_submit import (submit_rescore_problem_for_all_students,
|
||||
submit_rescore_problem_for_student,
|
||||
course_task_status,
|
||||
submit_reset_problem_attempts_for_all_students,
|
||||
submit_delete_problem_state_for_all_students)
|
||||
from instructor_task.api import (submit_rescore_problem_for_all_students,
|
||||
submit_rescore_problem_for_student,
|
||||
submit_reset_problem_attempts_for_all_students,
|
||||
submit_delete_problem_state_for_all_students)
|
||||
from instructor_task.views import instructor_task_status
|
||||
|
||||
from courseware.tests.tests import LoginEnrollmentTestCase, TEST_DATA_MONGO_MODULESTORE
|
||||
from courseware.tests.factories import CourseTaskFactory
|
||||
from instructor_task.tests.factories import InstructorTaskFactory
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -197,10 +198,10 @@ class TestRescoringBase(LoginEnrollmentTestCase, ModuleStoreTestCase):
|
||||
student)
|
||||
|
||||
def _create_course_task(self, task_state="QUEUED", task_input=None, student=None):
|
||||
"""Creates a CourseTask entry for testing."""
|
||||
"""Creates a InstructorTask entry for testing."""
|
||||
task_id = str(uuid4())
|
||||
task_key = "dummy value"
|
||||
course_task = CourseTaskFactory.create(requester=self.instructor,
|
||||
course_task = InstructorTaskFactory.create(requester=self.instructor,
|
||||
task_input=json.dumps(task_input),
|
||||
task_key=task_key,
|
||||
task_id=task_id,
|
||||
@@ -321,7 +322,7 @@ class TestRescoring(TestRescoringBase):
|
||||
# check status returned:
|
||||
mock_request = Mock()
|
||||
mock_request.REQUEST = {'task_id': course_task.task_id}
|
||||
response = course_task_status(mock_request)
|
||||
response = instructor_task_status(mock_request)
|
||||
status = json.loads(response.content)
|
||||
self.assertEqual(status['message'], expected_message)
|
||||
|
||||
@@ -371,7 +372,7 @@ class TestRescoring(TestRescoringBase):
|
||||
|
||||
mock_request = Mock()
|
||||
mock_request.REQUEST = {'task_id': course_task.task_id}
|
||||
response = course_task_status(mock_request)
|
||||
response = instructor_task_status(mock_request)
|
||||
status = json.loads(response.content)
|
||||
self.assertEqual(status['message'], "Problem's definition does not support rescoring")
|
||||
|
||||
@@ -532,7 +533,7 @@ class TestResetAttempts(TestRescoringBase):
|
||||
# check status returned:
|
||||
mock_request = Mock()
|
||||
mock_request.REQUEST = {'task_id': course_task.task_id}
|
||||
response = course_task_status(mock_request)
|
||||
response = instructor_task_status(mock_request)
|
||||
status = json.loads(response.content)
|
||||
self.assertEqual(status['message'], expected_message)
|
||||
|
||||
@@ -610,7 +611,7 @@ class TestDeleteProblem(TestRescoringBase):
|
||||
# check status returned:
|
||||
mock_request = Mock()
|
||||
mock_request.REQUEST = {'task_id': course_task.task_id}
|
||||
response = course_task_status(mock_request)
|
||||
response = instructor_task_status(mock_request)
|
||||
status = json.loads(response.content)
|
||||
self.assertEqual(status['message'], expected_message)
|
||||
|
||||
116
lms/djangoapps/instructor_task/views.py
Normal file
116
lms/djangoapps/instructor_task/views.py
Normal file
@@ -0,0 +1,116 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.http import HttpResponse
|
||||
|
||||
from celery.states import FAILURE, REVOKED, READY_STATES
|
||||
|
||||
from instructor_task.api_helper import (_get_instructor_task_status,
|
||||
_get_updated_instructor_task)
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def instructor_task_status(request):
|
||||
"""
|
||||
View method that returns the status of a course-related task or tasks.
|
||||
|
||||
Status is returned as a JSON-serialized dict, wrapped as the content of a HTTPResponse.
|
||||
|
||||
The task_id can be specified to this view in one of three ways:
|
||||
|
||||
* by making a 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 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.
|
||||
|
||||
"""
|
||||
def get_instructor_task_status(task_id):
|
||||
instructor_task = _get_updated_instructor_task(task_id)
|
||||
status = _get_instructor_task_status(instructor_task)
|
||||
if instructor_task.task_state in READY_STATES:
|
||||
succeeded, message = get_task_completion_info(instructor_task)
|
||||
status['message'] = message
|
||||
status['succeeded'] = succeeded
|
||||
return status
|
||||
|
||||
output = {}
|
||||
if 'task_id' in request.REQUEST:
|
||||
task_id = request.REQUEST['task_id']
|
||||
output = get_instructor_task_status(task_id)
|
||||
elif 'task_ids[]' in request.REQUEST:
|
||||
tasks = request.REQUEST.getlist('task_ids[]')
|
||||
for task_id in tasks:
|
||||
task_output = get_instructor_task_status(task_id)
|
||||
if task_output is not None:
|
||||
output[task_id] = task_output
|
||||
|
||||
return HttpResponse(json.dumps(output, indent=4))
|
||||
|
||||
|
||||
def get_task_completion_info(instructor_task):
|
||||
"""
|
||||
Construct progress message from progress information in InstructorTask entry.
|
||||
|
||||
Returns (boolean, message string) duple, where the boolean indicates
|
||||
whether the task completed without incident. (It is possible for a
|
||||
task to attempt many sub-tasks, such as rescoring many students' problem
|
||||
responses, and while the task runs to completion, some of the students'
|
||||
responses could not be rescored.)
|
||||
|
||||
Used for providing messages to instructor_task_status(), as well as
|
||||
external calls for providing course task submission history information.
|
||||
"""
|
||||
succeeded = False
|
||||
|
||||
if instructor_task.task_output is None:
|
||||
log.warning("No task_output information found for instructor_task {0}".format(instructor_task.task_id))
|
||||
return (succeeded, "No status information available")
|
||||
|
||||
task_output = json.loads(instructor_task.task_output)
|
||||
if instructor_task.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 instructor_task.task_input is None:
|
||||
log.warning("No task_input information found for instructor_task {0}".format(instructor_task.task_id))
|
||||
return (succeeded, "No status information available")
|
||||
task_input = json.loads(instructor_task.task_input)
|
||||
problem_url = task_input.get('problem_url')
|
||||
student = task_input.get('student')
|
||||
if student is not None:
|
||||
if num_attempted == 0:
|
||||
msg_format = "Unable to find submission to be {action} for student '{student}'"
|
||||
elif num_updated == 0:
|
||||
msg_format = "Problem failed to be {action} for student '{student}'"
|
||||
else:
|
||||
succeeded = True
|
||||
msg_format = "Problem successfully {action} for student '{student}'"
|
||||
elif num_attempted == 0:
|
||||
msg_format = "Unable to find any students with submissions to be {action}"
|
||||
elif num_updated == 0:
|
||||
msg_format = "Problem failed to be {action} for any of {attempted} students"
|
||||
elif num_updated == num_attempted:
|
||||
succeeded = True
|
||||
msg_format = "Problem successfully {action} for {attempted} students"
|
||||
else: # num_updated < num_attempted
|
||||
msg_format = "Problem {action} for {updated} of {attempted} students"
|
||||
|
||||
if student is not None and num_attempted != num_total:
|
||||
msg_format += " (out of {total})"
|
||||
|
||||
# Update status in task result object itself:
|
||||
message = msg_format.format(action=action_name, updated=num_updated, attempted=num_attempted, total=num_total,
|
||||
student=student, problem=problem_url)
|
||||
return (succeeded, message)
|
||||
@@ -124,8 +124,8 @@ MITX_FEATURES = {
|
||||
# Do autoplay videos for students
|
||||
'AUTOPLAY_VIDEOS': True,
|
||||
|
||||
# Enable instructor dash to submit course-level background tasks
|
||||
'ENABLE_COURSE_BACKGROUND_TASKS': True,
|
||||
# Enable instructor dash to submit background tasks
|
||||
'ENABLE_INSTRUCTOR_BACKGROUND_TASKS': True,
|
||||
}
|
||||
|
||||
# Used for A/B testing
|
||||
@@ -694,6 +694,7 @@ INSTALLED_APPS = (
|
||||
'util',
|
||||
'certificates',
|
||||
'instructor',
|
||||
'instructor_task',
|
||||
'open_ended_grading',
|
||||
'psychometrics',
|
||||
'licenses',
|
||||
|
||||
@@ -58,7 +58,6 @@ urlpatterns = ('', # nopep8
|
||||
name='auth_password_reset_done'),
|
||||
|
||||
url(r'^heartbeat$', include('heartbeat.urls')),
|
||||
url(r'^course_task_status/$', 'courseware.task_submit.course_task_status', name='course_task_status'),
|
||||
)
|
||||
|
||||
# University profiles only make sense in the default edX context
|
||||
@@ -395,6 +394,11 @@ if settings.MITX_FEATURES.get('ENABLE_SERVICE_STATUS'):
|
||||
url(r'^status/', include('service_status.urls')),
|
||||
)
|
||||
|
||||
if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS'):
|
||||
urlpatterns += (
|
||||
url(r'^instructor_task_status/$', 'instructor_task.views.instructor_task_status', name='instructor_task_status'),
|
||||
)
|
||||
|
||||
# FoldIt views
|
||||
urlpatterns += (
|
||||
# The path is hardcoded into their app...
|
||||
|
||||
Reference in New Issue
Block a user