Commit c48dabc3 by Brian Wilson

Move code to instructor_task Django app.

parent d2b3977f
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 ...@@ -25,7 +25,7 @@ from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
from courseware import grades 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, from courseware.access import (has_access, get_access_group_name,
course_beta_test_group_name) course_beta_test_group_name)
from courseware.courses import get_course_with_access from courseware.courses import get_course_with_access
...@@ -70,7 +70,7 @@ def instructor_dashboard(request, course_id): ...@@ -70,7 +70,7 @@ def instructor_dashboard(request, course_id):
problems = [] problems = []
plots = [] plots = []
datatable = None datatable = None
# the instructor dashboard page is modal: grades, psychometrics, admin # the instructor dashboard page is modal: grades, psychometrics, admin
# keep that state in request.session (defaults to grades mode) # keep that state in request.session (defaults to grades mode)
idash_mode = request.POST.get('idash_mode', '') idash_mode = request.POST.get('idash_mode', '')
...@@ -250,7 +250,7 @@ def instructor_dashboard(request, course_id): ...@@ -250,7 +250,7 @@ def instructor_dashboard(request, course_id):
problem_urlname = request.POST.get('problem_for_all_students', '') problem_urlname = request.POST.get('problem_for_all_students', '')
problem_url = get_module_url(problem_urlname) problem_url = get_module_url(problem_urlname)
try: 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: if course_task is None:
msg += '<font color="red">Failed to create a background task for rescoring "{0}".</font>'.format(problem_url) msg += '<font color="red">Failed to create a background task for rescoring "{0}".</font>'.format(problem_url)
else: else:
...@@ -266,7 +266,7 @@ def instructor_dashboard(request, course_id): ...@@ -266,7 +266,7 @@ def instructor_dashboard(request, course_id):
problem_urlname = request.POST.get('problem_for_all_students', '') problem_urlname = request.POST.get('problem_for_all_students', '')
problem_url = get_module_url(problem_urlname) problem_url = get_module_url(problem_urlname)
try: 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: if course_task is None:
msg += '<font color="red">Failed to create a background task for resetting "{0}".</font>'.format(problem_url) msg += '<font color="red">Failed to create a background task for resetting "{0}".</font>'.format(problem_url)
else: else:
...@@ -357,7 +357,7 @@ def instructor_dashboard(request, course_id): ...@@ -357,7 +357,7 @@ def instructor_dashboard(request, course_id):
else: else:
# "Rescore student's problem submission" case # "Rescore student's problem submission" case
try: 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: 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) 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: else:
...@@ -722,7 +722,7 @@ def instructor_dashboard(request, course_id): ...@@ -722,7 +722,7 @@ def instructor_dashboard(request, course_id):
# generate list of pending background tasks # generate list of pending background tasks
if settings.MITX_FEATURES.get('ENABLE_COURSE_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: else:
course_tasks = None course_tasks = None
...@@ -1299,7 +1299,7 @@ def get_background_task_table(course_id, problem_url, student=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, 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. 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 datatable = None
msg = "" msg = ""
# first check to see if there is any history at all # first check to see if there is any history at all
......
"""
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)
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
# -*- 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']
\ No newline at end of file
"""
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))
"""
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 ...@@ -11,7 +11,7 @@ from time import time
from sys import exc_info from sys import exc_info
from traceback import format_exc 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.utils.log import get_task_logger
from celery.states import SUCCESS, FAILURE from celery.states import SUCCESS, FAILURE
...@@ -24,10 +24,10 @@ from xmodule.modulestore.django import modulestore ...@@ -24,10 +24,10 @@ from xmodule.modulestore.django import modulestore
import mitxmako.middleware as middleware import mitxmako.middleware as middleware
from track.views import task_track 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.model_data import ModelDataCache
from courseware.module_render import get_module_for_descriptor_internal 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 # define different loggers for use within tasks and on client side
TASK_LOG = get_task_logger(__name__) TASK_LOG = get_task_logger(__name__)
...@@ -78,7 +78,7 @@ def _perform_module_state_update(course_id, module_state_key, student_identifier ...@@ -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. '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 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. result object.
""" """
...@@ -157,7 +157,7 @@ def _perform_module_state_update(course_id, module_state_key, student_identifier ...@@ -157,7 +157,7 @@ def _perform_module_state_update(course_id, module_state_key, student_identifier
@transaction.autocommit @transaction.autocommit
def _save_course_task(course_task): 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() course_task.save()
...@@ -166,7 +166,7 @@ def _update_problem_module_state(entry_id, course_id, module_state_key, student_ ...@@ -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. 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 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 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. 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_ ...@@ -181,9 +181,9 @@ def _update_problem_module_state(entry_id, course_id, module_state_key, student_
Pass-through of input `action_name`. Pass-through of input `action_name`.
'duration_ms': how long the task has (or had) been running. '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: This is also a JSON-serialized dict, stored in the task_output column, containing the following keys:
'exception': type of exception object 'exception': type of exception object
...@@ -199,9 +199,9 @@ def _update_problem_module_state(entry_id, course_id, module_state_key, student_ ...@@ -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' 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)) 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. # 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 entry.task_id = task_id
_save_course_task(entry) _save_course_task(entry)
...@@ -228,7 +228,7 @@ def _update_problem_module_state(entry_id, course_id, module_state_key, student_ ...@@ -228,7 +228,7 @@ def _update_problem_module_state(entry_id, course_id, module_state_key, student_
_save_course_task(entry) _save_course_task(entry)
raise 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_output = json.dumps(task_progress)
entry.task_state = SUCCESS entry.task_state = SUCCESS
_save_course_task(entry) _save_course_task(entry)
...@@ -329,39 +329,6 @@ def _rescore_problem_module_state(module_descriptor, student_module, xmodule_ins ...@@ -329,39 +329,6 @@ def _rescore_problem_module_state(module_descriptor, student_module, xmodule_ins
return True 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 @transaction.autocommit
def _reset_problem_attempts_module_state(_module_descriptor, student_module, xmodule_instance_args=None): 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 ...@@ -388,27 +355,6 @@ def _reset_problem_attempts_module_state(_module_descriptor, student_module, xmo
return True 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 @transaction.autocommit
def _delete_problem_module_state(_module_descriptor, student_module, xmodule_instance_args=None): 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 ...@@ -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_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') task_track(request_info, task_info, 'problem_delete_state', {}, page='x_module_task')
return True 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)
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 ...@@ -13,17 +13,20 @@ from django.test.testcases import TestCase
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
from courseware.tests.factories import UserFactory, CourseTaskFactory from courseware.tests.factories import UserFactory
from courseware.tasks import PROGRESS from instructor_task.tests.factories import InstructorTaskFactory
from courseware.task_submit import (QUEUING, from instructor_task.tasks_helper import PROGRESS
get_running_course_tasks, from instructor_task.views import instructor_task_status
course_task_status, from instructor_task.api import (get_running_instructor_tasks,
_encode_problem_and_student_input, submit_rescore_problem_for_all_students,
AlreadyRunningError, submit_rescore_problem_for_student,
submit_rescore_problem_for_all_students, submit_reset_problem_attempts_for_all_students,
submit_rescore_problem_for_student, submit_delete_problem_state_for_all_students)
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__) log = logging.getLogger(__name__)
...@@ -52,12 +55,12 @@ class TaskSubmitTestCase(TestCase): ...@@ -52,12 +55,12 @@ class TaskSubmitTestCase(TestCase):
problem_url_name=problem_url_name) problem_url_name=problem_url_name)
def _create_entry(self, task_state=QUEUING, task_output=None, student=None): 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()) task_id = str(uuid4())
progress_json = json.dumps(task_output) 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, requester=self.instructor,
task_input=json.dumps(task_input), task_input=json.dumps(task_input),
task_key=task_key, task_key=task_key,
...@@ -67,7 +70,7 @@ class TaskSubmitTestCase(TestCase): ...@@ -67,7 +70,7 @@ class TaskSubmitTestCase(TestCase):
return course_task return course_task
def _create_failure_entry(self): 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 # view task entry for task failure
progress = {'message': TEST_FAILURE_MESSAGE, progress = {'message': TEST_FAILURE_MESSAGE,
'exception': 'RandomCauseError', 'exception': 'RandomCauseError',
...@@ -75,11 +78,11 @@ class TaskSubmitTestCase(TestCase): ...@@ -75,11 +78,11 @@ class TaskSubmitTestCase(TestCase):
return self._create_entry(task_state=FAILURE, task_output=progress) return self._create_entry(task_state=FAILURE, task_output=progress)
def _create_success_entry(self, student=None): 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) return self._create_progress_entry(student, task_state=SUCCESS)
def _create_progress_entry(self, student=None, task_state=PROGRESS): 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, progress = {'attempted': 3,
'updated': 2, 'updated': 2,
'total': 10, 'total': 10,
...@@ -88,26 +91,26 @@ class TaskSubmitTestCase(TestCase): ...@@ -88,26 +91,26 @@ class TaskSubmitTestCase(TestCase):
} }
return self._create_entry(task_state=task_state, task_output=progress, student=student) 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 # when fetching running tasks, we get all running tasks, and only running tasks
for _ in range(1, 5): for _ in range(1, 5):
self._create_failure_entry() self._create_failure_entry()
self._create_success_entry() self._create_success_entry()
progress_task_ids = [self._create_progress_entry().task_id for _ in range(1, 5)] 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)) self.assertEquals(set(task_ids), set(progress_task_ids))
def _get_course_task_status(self, task_id): def _get_course_task_status(self, task_id):
request = Mock() request = Mock()
request.REQUEST = {'task_id': task_id} request.REQUEST = {'task_id': task_id}
return course_task_status(request) return instructor_task_status(request)
def test_course_task_status(self): def test_course_task_status(self):
course_task = self._create_failure_entry() course_task = self._create_failure_entry()
task_id = course_task.task_id task_id = course_task.task_id
request = Mock() request = Mock()
request.REQUEST = {'task_id': task_id} request.REQUEST = {'task_id': task_id}
response = course_task_status(request) response = instructor_task_status(request)
output = json.loads(response.content) output = json.loads(response.content)
self.assertEquals(output['task_id'], task_id) self.assertEquals(output['task_id'], task_id)
...@@ -118,7 +121,7 @@ class TaskSubmitTestCase(TestCase): ...@@ -118,7 +121,7 @@ class TaskSubmitTestCase(TestCase):
task_ids = [(self._create_failure_entry()).task_id for _ in range(1, 5)] task_ids = [(self._create_failure_entry()).task_id for _ in range(1, 5)]
request = Mock() request = Mock()
request.REQUEST = MultiValueDict({'task_ids[]': task_ids}) request.REQUEST = MultiValueDict({'task_ids[]': task_ids})
response = course_task_status(request) response = instructor_task_status(request)
output = json.loads(response.content) output = json.loads(response.content)
self.assertEquals(len(output), len(task_ids)) self.assertEquals(len(output), len(task_ids))
for task_id in task_ids: for task_id in task_ids:
...@@ -221,7 +224,7 @@ class TaskSubmitTestCase(TestCase): ...@@ -221,7 +224,7 @@ class TaskSubmitTestCase(TestCase):
self.assertEquals(output['task_state'], SUCCESS) self.assertEquals(output['task_state'], SUCCESS)
self.assertFalse(output['in_progress']) 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) _, 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.assertTrue("Unable to find any students with submissions to be rescored" in output['message'])
self.assertFalse(output['succeeded']) self.assertFalse(output['succeeded'])
...@@ -266,15 +269,16 @@ class TaskSubmitTestCase(TestCase): ...@@ -266,15 +269,16 @@ class TaskSubmitTestCase(TestCase):
with self.assertRaises(ItemNotFoundError): with self.assertRaises(ItemNotFoundError):
submit_delete_problem_state_for_all_students(request, course_id, problem_url) submit_delete_problem_state_for_all_students(request, course_id, problem_url)
def test_submit_when_running(self): # def test_submit_when_running(self):
# get exception when trying to submit a task that is already running # # get exception when trying to submit a task that is already running
course_task = self._create_progress_entry() # course_task = self._create_progress_entry()
problem_url = json.loads(course_task.task_input).get('problem_url') # problem_url = json.loads(course_task.task_input).get('problem_url')
course_id = course_task.course_id # course_id = course_task.course_id
# requester doesn't have to be the same when determining if a task is already running # # requester doesn't have to be the same when determining if a task is already running
request = Mock() # request = Mock()
request.user = self.student # request.user = self.instructor
with self.assertRaises(AlreadyRunningError): # with self.assertRaises(AlreadyRunningError):
# just skip making the argument check, so we don't have to fake it deeper down # # 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'): # with patch('instructor_task.api_helper.check_arguments_for_rescoring') as mock_check:
submit_rescore_problem_for_all_students(request, course_id, problem_url) # 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 ...@@ -22,13 +22,14 @@ from xmodule.modulestore.exceptions import ItemNotFoundError
from student.tests.factories import CourseEnrollmentFactory, UserFactory, AdminFactory from student.tests.factories import CourseEnrollmentFactory, UserFactory, AdminFactory
from courseware.model_data import StudentModule from courseware.model_data import StudentModule
from courseware.task_submit import (submit_rescore_problem_for_all_students, from instructor_task.api import (submit_rescore_problem_for_all_students,
submit_rescore_problem_for_student, submit_rescore_problem_for_student,
course_task_status, submit_reset_problem_attempts_for_all_students,
submit_reset_problem_attempts_for_all_students, submit_delete_problem_state_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.tests import LoginEnrollmentTestCase, TEST_DATA_MONGO_MODULESTORE
from courseware.tests.factories import CourseTaskFactory from instructor_task.tests.factories import InstructorTaskFactory
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -197,10 +198,10 @@ class TestRescoringBase(LoginEnrollmentTestCase, ModuleStoreTestCase): ...@@ -197,10 +198,10 @@ class TestRescoringBase(LoginEnrollmentTestCase, ModuleStoreTestCase):
student) student)
def _create_course_task(self, task_state="QUEUED", task_input=None, student=None): 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_id = str(uuid4())
task_key = "dummy value" 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_input=json.dumps(task_input),
task_key=task_key, task_key=task_key,
task_id=task_id, task_id=task_id,
...@@ -321,7 +322,7 @@ class TestRescoring(TestRescoringBase): ...@@ -321,7 +322,7 @@ class TestRescoring(TestRescoringBase):
# check status returned: # check status returned:
mock_request = Mock() mock_request = Mock()
mock_request.REQUEST = {'task_id': course_task.task_id} 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) status = json.loads(response.content)
self.assertEqual(status['message'], expected_message) self.assertEqual(status['message'], expected_message)
...@@ -371,7 +372,7 @@ class TestRescoring(TestRescoringBase): ...@@ -371,7 +372,7 @@ class TestRescoring(TestRescoringBase):
mock_request = Mock() mock_request = Mock()
mock_request.REQUEST = {'task_id': course_task.task_id} 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) status = json.loads(response.content)
self.assertEqual(status['message'], "Problem's definition does not support rescoring") self.assertEqual(status['message'], "Problem's definition does not support rescoring")
...@@ -532,7 +533,7 @@ class TestResetAttempts(TestRescoringBase): ...@@ -532,7 +533,7 @@ class TestResetAttempts(TestRescoringBase):
# check status returned: # check status returned:
mock_request = Mock() mock_request = Mock()
mock_request.REQUEST = {'task_id': course_task.task_id} 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) status = json.loads(response.content)
self.assertEqual(status['message'], expected_message) self.assertEqual(status['message'], expected_message)
...@@ -610,7 +611,7 @@ class TestDeleteProblem(TestRescoringBase): ...@@ -610,7 +611,7 @@ class TestDeleteProblem(TestRescoringBase):
# check status returned: # check status returned:
mock_request = Mock() mock_request = Mock()
mock_request.REQUEST = {'task_id': course_task.task_id} 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) status = json.loads(response.content)
self.assertEqual(status['message'], expected_message) self.assertEqual(status['message'], expected_message)
......
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 = { ...@@ -124,8 +124,8 @@ MITX_FEATURES = {
# Do autoplay videos for students # Do autoplay videos for students
'AUTOPLAY_VIDEOS': True, 'AUTOPLAY_VIDEOS': True,
# Enable instructor dash to submit course-level background tasks # Enable instructor dash to submit background tasks
'ENABLE_COURSE_BACKGROUND_TASKS': True, 'ENABLE_INSTRUCTOR_BACKGROUND_TASKS': True,
} }
# Used for A/B testing # Used for A/B testing
...@@ -694,6 +694,7 @@ INSTALLED_APPS = ( ...@@ -694,6 +694,7 @@ INSTALLED_APPS = (
'util', 'util',
'certificates', 'certificates',
'instructor', 'instructor',
'instructor_task',
'open_ended_grading', 'open_ended_grading',
'psychometrics', 'psychometrics',
'licenses', 'licenses',
......
...@@ -58,7 +58,6 @@ urlpatterns = ('', # nopep8 ...@@ -58,7 +58,6 @@ urlpatterns = ('', # nopep8
name='auth_password_reset_done'), name='auth_password_reset_done'),
url(r'^heartbeat$', include('heartbeat.urls')), 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 # University profiles only make sense in the default edX context
...@@ -395,6 +394,11 @@ if settings.MITX_FEATURES.get('ENABLE_SERVICE_STATUS'): ...@@ -395,6 +394,11 @@ if settings.MITX_FEATURES.get('ENABLE_SERVICE_STATUS'):
url(r'^status/', include('service_status.urls')), 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 # FoldIt views
urlpatterns += ( urlpatterns += (
# The path is hardcoded into their app... # The path is hardcoded into their app...
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment