From c48dabc38376edeed5b1da90aaa9e204998c387e Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Mon, 10 Jun 2013 18:16:18 -0400 Subject: [PATCH] Move code to instructor_task Django app. --- lms/djangoapps/courseware/task_submit.py | 514 ------------------ lms/djangoapps/instructor/views.py | 14 +- lms/djangoapps/instructor_task/__init__.py | 0 lms/djangoapps/instructor_task/api.py | 133 +++++ lms/djangoapps/instructor_task/api_helper.py | 330 +++++++++++ .../migrations/0001_initial.py | 86 +++ .../instructor_task/migrations/__init__.py | 0 lms/djangoapps/instructor_task/models.py | 63 +++ lms/djangoapps/instructor_task/tasks.py | 82 +++ .../tasks_helper.py} | 97 +--- .../instructor_task/tests/__init__.py | 0 .../instructor_task/tests/factories.py | 19 + .../tests/test_api.py} | 74 +-- .../tests/test_tasks.py | 25 +- lms/djangoapps/instructor_task/views.py | 116 ++++ lms/envs/common.py | 5 +- lms/urls.py | 6 +- 17 files changed, 907 insertions(+), 657 deletions(-) delete mode 100644 lms/djangoapps/courseware/task_submit.py create mode 100644 lms/djangoapps/instructor_task/__init__.py create mode 100644 lms/djangoapps/instructor_task/api.py create mode 100644 lms/djangoapps/instructor_task/api_helper.py create mode 100644 lms/djangoapps/instructor_task/migrations/0001_initial.py create mode 100644 lms/djangoapps/instructor_task/migrations/__init__.py create mode 100644 lms/djangoapps/instructor_task/models.py create mode 100644 lms/djangoapps/instructor_task/tasks.py rename lms/djangoapps/{courseware/tasks.py => instructor_task/tasks_helper.py} (80%) create mode 100644 lms/djangoapps/instructor_task/tests/__init__.py create mode 100644 lms/djangoapps/instructor_task/tests/factories.py rename lms/djangoapps/{courseware/tests/test_task_submit.py => instructor_task/tests/test_api.py} (82%) rename lms/djangoapps/{courseware => instructor_task}/tests/test_tasks.py (97%) create mode 100644 lms/djangoapps/instructor_task/views.py diff --git a/lms/djangoapps/courseware/task_submit.py b/lms/djangoapps/courseware/task_submit.py deleted file mode 100644 index 4064b709d2..0000000000 --- a/lms/djangoapps/courseware/task_submit.py +++ /dev/null @@ -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) diff --git a/lms/djangoapps/instructor/views.py b/lms/djangoapps/instructor/views.py index b0cad6ec68..d94d31700b 100644 --- a/lms/djangoapps/instructor/views.py +++ b/lms/djangoapps/instructor/views.py @@ -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 += 'Failed to create a background task for rescoring "{0}".'.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 += 'Failed to create a background task for resetting "{0}".'.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 += 'Failed to create a background task for rescoring "{0}" for student {1}.'.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 diff --git a/lms/djangoapps/instructor_task/__init__.py b/lms/djangoapps/instructor_task/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/instructor_task/api.py b/lms/djangoapps/instructor_task/api.py new file mode 100644 index 0000000000..a79e574937 --- /dev/null +++ b/lms/djangoapps/instructor_task/api.py @@ -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) diff --git a/lms/djangoapps/instructor_task/api_helper.py b/lms/djangoapps/instructor_task/api_helper.py new file mode 100644 index 0000000000..13bb9af87c --- /dev/null +++ b/lms/djangoapps/instructor_task/api_helper.py @@ -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 diff --git a/lms/djangoapps/instructor_task/migrations/0001_initial.py b/lms/djangoapps/instructor_task/migrations/0001_initial.py new file mode 100644 index 0000000000..4e12f292c1 --- /dev/null +++ b/lms/djangoapps/instructor_task/migrations/0001_initial.py @@ -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'] \ No newline at end of file diff --git a/lms/djangoapps/instructor_task/migrations/__init__.py b/lms/djangoapps/instructor_task/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/instructor_task/models.py b/lms/djangoapps/instructor_task/models.py new file mode 100644 index 0000000000..4f70615450 --- /dev/null +++ b/lms/djangoapps/instructor_task/models.py @@ -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)) diff --git a/lms/djangoapps/instructor_task/tasks.py b/lms/djangoapps/instructor_task/tasks.py new file mode 100644 index 0000000000..ba5acc6f43 --- /dev/null +++ b/lms/djangoapps/instructor_task/tasks.py @@ -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) diff --git a/lms/djangoapps/courseware/tasks.py b/lms/djangoapps/instructor_task/tasks_helper.py similarity index 80% rename from lms/djangoapps/courseware/tasks.py rename to lms/djangoapps/instructor_task/tasks_helper.py index 6d86f35d81..a5a2d758ac 100644 --- a/lms/djangoapps/courseware/tasks.py +++ b/lms/djangoapps/instructor_task/tasks_helper.py @@ -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) diff --git a/lms/djangoapps/instructor_task/tests/__init__.py b/lms/djangoapps/instructor_task/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/instructor_task/tests/factories.py b/lms/djangoapps/instructor_task/tests/factories.py new file mode 100644 index 0000000000..e54c007a81 --- /dev/null +++ b/lms/djangoapps/instructor_task/tests/factories.py @@ -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) diff --git a/lms/djangoapps/courseware/tests/test_task_submit.py b/lms/djangoapps/instructor_task/tests/test_api.py similarity index 82% rename from lms/djangoapps/courseware/tests/test_task_submit.py rename to lms/djangoapps/instructor_task/tests/test_api.py index d9afabacbf..80a1701cc2 100644 --- a/lms/djangoapps/courseware/tests/test_task_submit.py +++ b/lms/djangoapps/instructor_task/tests/test_api.py @@ -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) diff --git a/lms/djangoapps/courseware/tests/test_tasks.py b/lms/djangoapps/instructor_task/tests/test_tasks.py similarity index 97% rename from lms/djangoapps/courseware/tests/test_tasks.py rename to lms/djangoapps/instructor_task/tests/test_tasks.py index 0baea0f429..b3552f0239 100644 --- a/lms/djangoapps/courseware/tests/test_tasks.py +++ b/lms/djangoapps/instructor_task/tests/test_tasks.py @@ -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) diff --git a/lms/djangoapps/instructor_task/views.py b/lms/djangoapps/instructor_task/views.py new file mode 100644 index 0000000000..5af0d46d46 --- /dev/null +++ b/lms/djangoapps/instructor_task/views.py @@ -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) diff --git a/lms/envs/common.py b/lms/envs/common.py index 3b795b6089..076528e91e 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -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', diff --git a/lms/urls.py b/lms/urls.py index 1b4d22c1b0..1d34ebf3af 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -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...