377 lines
18 KiB
Python
377 lines
18 KiB
Python
|
|
import json
|
|
from time import time
|
|
from sys import exc_info
|
|
from traceback import format_exc
|
|
|
|
from django.contrib.auth.models import User
|
|
from django.db import transaction
|
|
from celery import task, current_task
|
|
from celery.utils.log import get_task_logger
|
|
|
|
from xmodule.modulestore.django import modulestore
|
|
|
|
import mitxmako.middleware as middleware
|
|
from track.views import task_track
|
|
|
|
from courseware.models import StudentModule, CourseTaskLog
|
|
from courseware.model_data import ModelDataCache
|
|
from courseware.module_render import get_module_for_descriptor_internal
|
|
|
|
|
|
# define different loggers for use within tasks and on client side
|
|
task_log = get_task_logger(__name__)
|
|
|
|
|
|
class UpdateProblemModuleStateError(Exception):
|
|
"""
|
|
Error signaling a fatal condition while updating problem modules.
|
|
|
|
Used when the current module cannot be processed and that no more
|
|
modules should be attempted.
|
|
"""
|
|
pass
|
|
|
|
|
|
def _update_problem_module_state_internal(course_id, module_state_key, student_identifier, update_fcn, action_name, filter_fcn,
|
|
xmodule_instance_args):
|
|
"""
|
|
Performs generic update by visiting StudentModule instances with the update_fcn provided.
|
|
|
|
StudentModule instances are those that match the specified `course_id` and `module_state_key`.
|
|
If `student_identifier` is not None, it is used as an additional filter to limit the modules to those belonging
|
|
to that student. If `student_identifier` is None, performs update on modules for all students on the specified problem.
|
|
|
|
If a `filter_fcn` is not None, it is applied to the query that has been constructed. It takes one
|
|
argument, which is the query being filtered.
|
|
|
|
The `update_fcn` is called on each StudentModule that passes the resulting filtering.
|
|
It is passed three arguments: the module_descriptor for the module pointed to by the
|
|
module_state_key, the particular StudentModule to update, and the xmodule_instance_args being
|
|
passed through.
|
|
|
|
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 CourseTaskLog and the
|
|
result object.
|
|
"""
|
|
# get start time for task:
|
|
start_time = time()
|
|
|
|
# Hack to get mako templates to work on celery worker server's worker thread.
|
|
# The initialization of Mako templating is usually done when Django is
|
|
# initializing middleware packages as part of processing a server request.
|
|
# When this is run on a celery worker server, no such initialization is
|
|
# called. Using @worker_ready.connect doesn't run in the right container.
|
|
# So we look for the result: the defining of the lookup paths
|
|
# for templates.
|
|
if 'main' not in middleware.lookup:
|
|
task_log.info("Initializing Mako middleware explicitly")
|
|
middleware.MakoMiddleware()
|
|
|
|
# find the problem descriptor:
|
|
module_descriptor = modulestore().get_instance(course_id, module_state_key)
|
|
|
|
# find the module in question
|
|
modules_to_update = StudentModule.objects.filter(course_id=course_id,
|
|
module_state_key=module_state_key)
|
|
|
|
# give the option of rescoring an individual student. If not specified,
|
|
# then rescores all students who have responded to a problem so far
|
|
student = None
|
|
if student_identifier is not None:
|
|
# if an identifier is supplied, then look for the student,
|
|
# and let it throw an exception if none is found.
|
|
if "@" in student_identifier:
|
|
student = User.objects.get(email=student_identifier)
|
|
elif student_identifier is not None:
|
|
student = User.objects.get(username=student_identifier)
|
|
|
|
if student is not None:
|
|
modules_to_update = modules_to_update.filter(student_id=student.id)
|
|
|
|
if filter_fcn is not None:
|
|
modules_to_update = filter_fcn(modules_to_update)
|
|
|
|
# perform the main loop
|
|
num_updated = 0
|
|
num_attempted = 0
|
|
num_total = modules_to_update.count()
|
|
|
|
def get_task_progress():
|
|
"""Return a dict containing info about current task"""
|
|
current_time = time()
|
|
progress = {'action_name': action_name,
|
|
'attempted': num_attempted,
|
|
'updated': num_updated,
|
|
'total': num_total,
|
|
'start_ms': int(start_time * 1000),
|
|
'duration_ms': int((current_time - start_time) * 1000),
|
|
}
|
|
return progress
|
|
|
|
for module_to_update in modules_to_update:
|
|
num_attempted += 1
|
|
# There is no try here: if there's an error, we let it throw, and the task will
|
|
# be marked as FAILED, with a stack trace.
|
|
if update_fcn(module_descriptor, module_to_update, xmodule_instance_args):
|
|
# If the update_fcn returns true, then it performed some kind of work.
|
|
num_updated += 1
|
|
|
|
# update task status:
|
|
current_task.update_state(state='PROGRESS', meta=get_task_progress())
|
|
|
|
task_progress = get_task_progress()
|
|
# update progress without updating the state
|
|
current_task.update_state(state='PROGRESS', meta=task_progress)
|
|
return task_progress
|
|
|
|
|
|
@transaction.autocommit
|
|
def _save_course_task_log_entry(entry):
|
|
"""Writes CourseTaskLog entry immediately."""
|
|
entry.save()
|
|
|
|
|
|
def _update_problem_module_state(entry_id, course_id, module_state_key, student_ident, update_fcn, action_name, filter_fcn,
|
|
xmodule_instance_args):
|
|
"""
|
|
Performs generic update by visiting StudentModule instances with the update_fcn provided.
|
|
|
|
See _update_problem_module_state_internal function for more details on arguments.
|
|
|
|
The `entry_id` is the primary key for the CourseTaskLog entry representing the task. This function
|
|
updates the entry on SUCCESS and FAILURE of the _update_problem_module_state_internal function it
|
|
wraps.
|
|
|
|
Once exceptions are caught and recorded in the CourseTaskLog entry, they are allowed to pass up to the
|
|
task-running level, so that it can also set the failure modes and capture the error trace in the result object.
|
|
"""
|
|
task_id = current_task.request.id
|
|
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 CourseTaskLog to be updated. If this fails, then let the exception return to Celery.
|
|
# There's no point in catching it here.
|
|
entry = CourseTaskLog.objects.get(pk=entry_id)
|
|
entry.task_id = task_id
|
|
_save_course_task_log_entry(entry)
|
|
|
|
# add task_id to xmodule_instance_args, so that it can be output with tracking info:
|
|
xmodule_instance_args['task_id'] = task_id
|
|
|
|
# now that we have an entry we can try to catch failures:
|
|
task_progress = None
|
|
try:
|
|
task_progress = _update_problem_module_state_internal(course_id, module_state_key, student_ident, update_fcn,
|
|
action_name, filter_fcn, xmodule_instance_args)
|
|
except Exception:
|
|
# try to write out the failure to the entry before failing
|
|
exception_type, exception, traceback = exc_info()
|
|
traceback_string = format_exc(traceback) if traceback is not None else ''
|
|
task_progress = {'exception': exception_type.__name__, 'message': str(exception.message)}
|
|
task_log.warning("background task (%s) failed: %s %s", task_id, exception, traceback_string)
|
|
if traceback is not None:
|
|
task_progress['traceback'] = traceback_string
|
|
entry.task_output = json.dumps(task_progress)
|
|
entry.task_state = 'FAILURE'
|
|
_save_course_task_log_entry(entry)
|
|
raise
|
|
|
|
# if we get here, we assume we've succeeded, so update the CourseTaskLog entry in anticipation:
|
|
entry.task_output = json.dumps(task_progress)
|
|
entry.task_state = 'SUCCESS'
|
|
_save_course_task_log_entry(entry)
|
|
|
|
# log and exit, returning task_progress info as task result:
|
|
fmt = 'Finishing task "{task_id}": course "{course_id}" problem "{state_key}": final: {progress}'
|
|
task_log.info(fmt.format(task_id=task_id, course_id=course_id, state_key=module_state_key, progress=task_progress))
|
|
return task_progress
|
|
|
|
|
|
def _update_problem_module_state_for_student(entry_id, course_id, problem_url, student_identifier,
|
|
update_fcn, action_name, filter_fcn=None, xmodule_instance_args=None):
|
|
"""
|
|
Update the StudentModule for a given student. See _update_problem_module_state().
|
|
"""
|
|
msg = ''
|
|
success = False
|
|
# try to uniquely id student by email address or username
|
|
try:
|
|
if "@" in student_identifier:
|
|
student_to_update = User.objects.get(email=student_identifier)
|
|
elif student_identifier is not None:
|
|
student_to_update = User.objects.get(username=student_identifier)
|
|
return _update_problem_module_state(entry_id, course_id, problem_url, student_to_update, update_fcn,
|
|
action_name, filter_fcn, xmodule_instance_args)
|
|
except User.DoesNotExist:
|
|
msg = "Couldn't find student with that email or username."
|
|
|
|
return (success, msg)
|
|
|
|
|
|
def _get_module_instance_for_task(course_id, student, module_descriptor, module_state_key, xmodule_instance_args=None,
|
|
grade_bucket_type=None):
|
|
"""
|
|
Fetches a StudentModule instance for a given course_id, student, and module_state_key.
|
|
|
|
Includes providing information for creating a track function and an XQueue callback,
|
|
but does not require passing in a Request object.
|
|
"""
|
|
# reconstitute the problem's corresponding XModule:
|
|
model_data_cache = ModelDataCache.cache_for_descriptor_descendents(course_id, student, module_descriptor)
|
|
|
|
# get request-related tracking information from args passthrough, and supplement with task-specific
|
|
# information:
|
|
request_info = xmodule_instance_args.get('request_info', {}) if xmodule_instance_args is not None else {}
|
|
task_info = {"student": student.username, "task_id": xmodule_instance_args['task_id']}
|
|
|
|
def make_track_function():
|
|
'''
|
|
Make a tracking function that logs what happened.
|
|
For insertion into ModuleSystem, and use by CapaModule.
|
|
'''
|
|
def f(event_type, event):
|
|
return task_track(request_info, task_info, event_type, event, page='x_module_task')
|
|
return f
|
|
|
|
xqueue_callback_url_prefix = ''
|
|
if xmodule_instance_args is not None:
|
|
xqueue_callback_url_prefix = xmodule_instance_args.get('xqueue_callback_url_prefix')
|
|
|
|
return get_module_for_descriptor_internal(student, module_descriptor, model_data_cache, course_id,
|
|
make_track_function(), xqueue_callback_url_prefix,
|
|
grade_bucket_type=grade_bucket_type)
|
|
|
|
|
|
@transaction.autocommit
|
|
def _rescore_problem_module_state(module_descriptor, student_module, xmodule_instance_args=None):
|
|
'''
|
|
Takes an XModule descriptor and a corresponding StudentModule object, and
|
|
performs rescoring on the student's problem submission.
|
|
|
|
Throws exceptions if the rescoring is fatal and should be aborted if in a loop.
|
|
'''
|
|
# unpack the StudentModule:
|
|
course_id = student_module.course_id
|
|
student = student_module.student
|
|
module_state_key = student_module.module_state_key
|
|
|
|
instance = _get_module_instance_for_task(course_id, student, module_descriptor, module_state_key, xmodule_instance_args, grade_bucket_type='rescore')
|
|
|
|
if instance is None:
|
|
# Either permissions just changed, or someone is trying to be clever
|
|
# and load something they shouldn't have access to.
|
|
msg = "No module {loc} for student {student}--access denied?".format(loc=module_state_key,
|
|
student=student)
|
|
task_log.debug(msg)
|
|
raise UpdateProblemModuleStateError(msg)
|
|
|
|
if not hasattr(instance, 'rescore_problem'):
|
|
# if the first instance doesn't have a rescore method, we should
|
|
# probably assume that no other instances will either.
|
|
msg = "Specified problem does not support rescoring."
|
|
raise UpdateProblemModuleStateError(msg)
|
|
|
|
result = instance.rescore_problem()
|
|
if 'success' not in result:
|
|
# don't consider these fatal, but false means that the individual call didn't complete:
|
|
task_log.warning("error processing rescore call for problem {loc} and student {student}: "
|
|
"unexpected response {msg}".format(msg=result, loc=module_state_key, student=student))
|
|
return False
|
|
elif result['success'] != 'correct' and result['success'] != 'incorrect':
|
|
task_log.warning("error processing rescore call for problem {loc} and student {student}: "
|
|
"{msg}".format(msg=result['success'], loc=module_state_key, student=student))
|
|
return False
|
|
else:
|
|
task_log.debug("successfully processed rescore call for problem {loc} and student {student}: "
|
|
"{msg}".format(msg=result['success'], loc=module_state_key, student=student))
|
|
return True
|
|
|
|
|
|
def filter_problem_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 `problem_url` in `course_id` for all students."""
|
|
action_name = 'rescored'
|
|
update_fcn = _rescore_problem_module_state
|
|
filter_fcn = filter_problem_module_state_for_done
|
|
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):
|
|
"""
|
|
Resets problem attempts to zero for specified `student_module`.
|
|
|
|
Always returns true, if it doesn't throw an exception.
|
|
"""
|
|
problem_state = json.loads(student_module.state)
|
|
if 'attempts' in problem_state:
|
|
old_number_of_attempts = problem_state["attempts"]
|
|
if old_number_of_attempts > 0:
|
|
problem_state["attempts"] = 0
|
|
# convert back to json and save
|
|
student_module.state = json.dumps(problem_state)
|
|
student_module.save()
|
|
# get request-related tracking information from args passthrough,
|
|
# and supplement with task-specific information:
|
|
request_info = xmodule_instance_args.get('request_info', {}) if xmodule_instance_args is not None else {}
|
|
task_id = xmodule_instance_args['task_id'] if xmodule_instance_args is not None else "unknown-task_id"
|
|
task_info = {"student": student_module.student.username, "task_id": task_id}
|
|
event_info = {"old_attempts": old_number_of_attempts, "new_attempts": 0}
|
|
task_track(request_info, task_info, 'problem_reset_attempts', event_info, page='x_module_task')
|
|
|
|
# consider the reset to be successful, even if no update was performed. (It's just "optimized".)
|
|
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."""
|
|
action_name = 'reset'
|
|
update_fcn = _reset_problem_attempts_module_state
|
|
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=None,
|
|
xmodule_instance_args=xmodule_instance_args)
|
|
|
|
|
|
@transaction.autocommit
|
|
def _delete_problem_module_state(module_descriptor, student_module, xmodule_instance_args=None):
|
|
"""Delete the StudentModule entry."""
|
|
student_module.delete()
|
|
# get request-related tracking information from args passthrough,
|
|
# and supplement with task-specific information:
|
|
request_info = xmodule_instance_args.get('request_info', {}) if xmodule_instance_args is not None else {}
|
|
task_id = xmodule_instance_args['task_id'] if xmodule_instance_args is not None else "unknown-task_id"
|
|
task_info = {"student": student_module.student.username, "task_id": task_id}
|
|
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."""
|
|
action_name = 'deleted'
|
|
update_fcn = _delete_problem_module_state
|
|
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=None,
|
|
xmodule_instance_args=xmodule_instance_args)
|