Merge pull request #14929 from edx/neem/capa-refactor
Refactor CAPA to use scorable XBlock mixin
This commit is contained in:
@@ -100,6 +100,17 @@ def _get_xmodule_instance_args(request, task_id):
|
||||
return xmodule_instance_args
|
||||
|
||||
|
||||
def _supports_rescore(descriptor):
|
||||
"""
|
||||
Helper method to determine whether a given item supports rescoring.
|
||||
In order to accommodate both XModules and XBlocks, we have to check
|
||||
the descriptor itself then fall back on its module class.
|
||||
"""
|
||||
return hasattr(descriptor, 'rescore') or (
|
||||
hasattr(descriptor, 'module_class') and hasattr(descriptor.module_class, 'rescore')
|
||||
)
|
||||
|
||||
|
||||
def _update_instructor_task(instructor_task, task_result):
|
||||
"""
|
||||
Updates and possibly saves a InstructorTask entry based on a task Result.
|
||||
@@ -246,10 +257,7 @@ def check_arguments_for_rescoring(usage_key):
|
||||
corresponding module doesn't support rescoring calls.
|
||||
"""
|
||||
descriptor = modulestore().get_item(usage_key)
|
||||
# TODO: Clean this up as part of TNL-6594 when CAPA uses the ScorableXBlockMixin
|
||||
if (
|
||||
not hasattr(descriptor, 'module_class') or not hasattr(descriptor.module_class, 'rescore_problem')
|
||||
) and not hasattr(descriptor, 'rescore'):
|
||||
if not _supports_rescore(descriptor):
|
||||
msg = "Specified module does not support rescoring."
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
@@ -264,8 +272,7 @@ def check_entrance_exam_problems_for_rescoring(exam_key): # pylint: disable=inv
|
||||
any of the problem in entrance exam doesn't support re-scoring calls.
|
||||
"""
|
||||
problems = get_problems_in_section(exam_key).values()
|
||||
if any(not hasattr(problem, 'module_class') or not hasattr(problem.module_class, 'rescore_problem')
|
||||
for problem in problems):
|
||||
if any(not _supports_rescore(problem) for problem in problems):
|
||||
msg = _("Not all problems in entrance exam support re-scoring.")
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from time import time
|
||||
from eventtracking import tracker
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from capa.responsetypes import StudentInputError, ResponseError, LoncapaProblemError
|
||||
from courseware.courses import get_course_by_id, get_problems_in_section
|
||||
from courseware.models import StudentModule
|
||||
from courseware.model_data import DjangoKeyValueStore, FieldDataCache
|
||||
@@ -174,38 +175,28 @@ def rescore_problem_module_state(xmodule_instance_args, module_descriptor, stude
|
||||
TASK_LOG.warning(msg)
|
||||
return UPDATE_STATUS_FAILED
|
||||
|
||||
# TODO: (TNL-6594) Remove this switch once rescore_problem support
|
||||
# once CAPA uses ScorableXBlockMixin.
|
||||
for method in ['rescore', 'rescore_problem']:
|
||||
rescore_method = getattr(instance, method, None)
|
||||
if rescore_method is not None:
|
||||
break
|
||||
else: # for-else: Neither method exists on the block.
|
||||
if not hasattr(instance, 'rescore'):
|
||||
# This should not happen, since it should be already checked in the
|
||||
# caller, but check here to be sure.
|
||||
msg = "Specified problem does not support rescoring."
|
||||
raise UpdateProblemModuleStateError(msg)
|
||||
|
||||
# TODO: Remove the first part of this if-else with TNL-6594
|
||||
# We check here to see if the problem has any submissions. If it does not, we don't want to rescore it
|
||||
if hasattr(instance, "done"):
|
||||
if not instance.done:
|
||||
return UPDATE_STATUS_SKIPPED
|
||||
elif not instance.has_submitted_answer():
|
||||
return UPDATE_STATUS_SKIPPED
|
||||
if not instance.has_submitted_answer():
|
||||
return UPDATE_STATUS_SKIPPED
|
||||
|
||||
# Set the tracking info before this call, because it makes downstream
|
||||
# calls that create events. We retrieve and store the id here because
|
||||
# the request cache will be erased during downstream calls.
|
||||
event_transaction_id = create_new_event_transaction_id()
|
||||
create_new_event_transaction_id()
|
||||
set_event_transaction_type(GRADES_RESCORE_EVENT_TYPE)
|
||||
|
||||
result = rescore_method(only_if_higher=task_input['only_if_higher'])
|
||||
instance.save()
|
||||
|
||||
if result is None or result.get(u'success') in {u'correct', u'incorrect'}:
|
||||
TASK_LOG.debug(
|
||||
u"successfully processed rescore call for course %(course)s, problem %(loc)s "
|
||||
# specific events from CAPA are not propagated up the stack. Do we want this?
|
||||
try:
|
||||
instance.rescore(only_if_higher=task_input['only_if_higher'])
|
||||
except (LoncapaProblemError, StudentInputError, ResponseError):
|
||||
TASK_LOG.warning(
|
||||
u"error processing rescore call for course %(course)s, problem %(loc)s "
|
||||
u"and student %(student)s",
|
||||
dict(
|
||||
course=course_id,
|
||||
@@ -213,45 +204,21 @@ def rescore_problem_module_state(xmodule_instance_args, module_descriptor, stude
|
||||
student=student
|
||||
)
|
||||
)
|
||||
|
||||
if result is not None: # Only for CAPA. This will get moved to the grade handler.
|
||||
new_weighted_earned, new_weighted_possible = weighted_score(
|
||||
result['new_raw_earned'] if result else None,
|
||||
result['new_raw_possible'] if result else None,
|
||||
module_descriptor.weight,
|
||||
)
|
||||
|
||||
# TODO: remove this context manager after completion of AN-6134
|
||||
context = course_context_from_course_id(course_id)
|
||||
with tracker.get_tracker().context(GRADES_RESCORE_EVENT_TYPE, context):
|
||||
tracker.emit(
|
||||
unicode(GRADES_RESCORE_EVENT_TYPE),
|
||||
{
|
||||
'course_id': unicode(course_id),
|
||||
'user_id': unicode(student.id),
|
||||
'problem_id': unicode(usage_key),
|
||||
'new_weighted_earned': new_weighted_earned,
|
||||
'new_weighted_possible': new_weighted_possible,
|
||||
'only_if_higher': task_input['only_if_higher'],
|
||||
'instructor_id': unicode(xmodule_instance_args['request_info']['user_id']),
|
||||
'event_transaction_id': unicode(event_transaction_id),
|
||||
'event_transaction_type': unicode(GRADES_RESCORE_EVENT_TYPE),
|
||||
}
|
||||
)
|
||||
return UPDATE_STATUS_SUCCEEDED
|
||||
else:
|
||||
TASK_LOG.warning(
|
||||
u"error processing rescore call for course %(course)s, problem %(loc)s "
|
||||
u"and student %(student)s: %(msg)s",
|
||||
dict(
|
||||
msg=result.get('success', result),
|
||||
course=course_id,
|
||||
loc=usage_key,
|
||||
student=student
|
||||
)
|
||||
)
|
||||
return UPDATE_STATUS_FAILED
|
||||
|
||||
instance.save()
|
||||
TASK_LOG.debug(
|
||||
u"successfully processed rescore call for course %(course)s, problem %(loc)s "
|
||||
u"and student %(student)s",
|
||||
dict(
|
||||
course=course_id,
|
||||
loc=usage_key,
|
||||
student=student
|
||||
)
|
||||
)
|
||||
|
||||
return UPDATE_STATUS_SUCCEEDED
|
||||
|
||||
|
||||
@outer_atomic
|
||||
def reset_attempts_module_state(xmodule_instance_args, _module_descriptor, student_module, _task_input):
|
||||
|
||||
@@ -18,6 +18,7 @@ from django.contrib.auth.models import User
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
from openedx.core.djangoapps.util.testing import TestConditionalContent
|
||||
from openedx.core.djangolib.testing.utils import get_mock_request
|
||||
from capa.tests.response_xml_factory import (CodeResponseXMLFactory,
|
||||
CustomResponseXMLFactory)
|
||||
from xmodule.modulestore.tests.factories import ItemFactory
|
||||
@@ -275,7 +276,7 @@ class TestRescoringTask(TestIntegrationTask):
|
||||
self.submit_student_answer('u1', problem_url_name, [OPTION_1, OPTION_1])
|
||||
|
||||
expected_message = "bad things happened"
|
||||
with patch('capa.capa_problem.LoncapaProblem.rescore_existing_answers') as mock_rescore:
|
||||
with patch('capa.capa_problem.LoncapaProblem.get_grade_from_current_answers') as mock_rescore:
|
||||
mock_rescore.side_effect = ZeroDivisionError(expected_message)
|
||||
instructor_task = self.submit_rescore_all_student_answers('instructor', problem_url_name)
|
||||
self._assert_task_failure(instructor_task.id, 'rescore_problem', problem_url_name, expected_message)
|
||||
@@ -293,7 +294,7 @@ class TestRescoringTask(TestIntegrationTask):
|
||||
|
||||
# return an input error as if it were a numerical response, with an embedded unicode character:
|
||||
expected_message = u"Could not interpret '2/3\u03a9' as a number"
|
||||
with patch('capa.capa_problem.LoncapaProblem.rescore_existing_answers') as mock_rescore:
|
||||
with patch('capa.capa_problem.LoncapaProblem.get_grade_from_current_answers') as mock_rescore:
|
||||
mock_rescore.side_effect = StudentInputError(expected_message)
|
||||
instructor_task = self.submit_rescore_all_student_answers('instructor', problem_url_name)
|
||||
|
||||
|
||||
@@ -307,30 +307,21 @@ class TestRescoreInstructorTask(TestInstructorTasks):
|
||||
action_name='rescored'
|
||||
)
|
||||
|
||||
@ddt.data(
|
||||
('rescore', None),
|
||||
('rescore_problem', {'success': 'correct', 'new_raw_earned': 1, 'new_raw_possible': 1})
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_rescoring_success(self, rescore_method, rescore_result):
|
||||
def test_rescoring_success(self):
|
||||
"""
|
||||
Tests rescores a problem in a course, for all students succeeds.
|
||||
"""
|
||||
mock_instance = MagicMock()
|
||||
other_method = ({'rescore', 'rescore_problem'} - {rescore_method}).pop()
|
||||
getattr(mock_instance, rescore_method).return_value = rescore_result
|
||||
delattr(mock_instance, other_method)
|
||||
|
||||
if rescore_method == 'rescore':
|
||||
del mock_instance.done
|
||||
mock_instance.has_submitted_answer.return_value = True
|
||||
else:
|
||||
mock_instance.done = True
|
||||
getattr(mock_instance, 'rescore').return_value = None
|
||||
mock_instance.has_submitted_answer.return_value = True
|
||||
del mock_instance.done # old CAPA code used to use this value so we delete it here to be sure
|
||||
|
||||
num_students = 10
|
||||
self._create_students_with_state(num_students)
|
||||
task_entry = self._create_input_entry()
|
||||
with patch('lms.djangoapps.instructor_task.tasks_helper.module_state.get_module_for_descriptor_internal') as mock_get_module:
|
||||
with patch(
|
||||
'lms.djangoapps.instructor_task.tasks_helper.module_state.get_module_for_descriptor_internal'
|
||||
) as mock_get_module:
|
||||
mock_get_module.return_value = mock_instance
|
||||
self._run_task_with_mock_celery(rescore_problem, task_entry.id, task_entry.task_id)
|
||||
|
||||
@@ -344,56 +335,6 @@ class TestRescoreInstructorTask(TestInstructorTasks):
|
||||
action_name='rescored'
|
||||
)
|
||||
|
||||
def test_rescoring_bad_result(self):
|
||||
"""
|
||||
Tests and confirm that rescoring does not succeed if "success" key is not an expected value.
|
||||
"""
|
||||
input_state = json.dumps({'done': True})
|
||||
num_students = 10
|
||||
self._create_students_with_state(num_students, input_state)
|
||||
task_entry = self._create_input_entry()
|
||||
mock_instance = Mock()
|
||||
mock_instance.rescore_problem = Mock(return_value={'success': 'bogus'})
|
||||
del mock_instance.rescore
|
||||
with patch('lms.djangoapps.instructor_task.tasks_helper.module_state.get_module_for_descriptor_internal') as mock_get_module:
|
||||
mock_get_module.return_value = mock_instance
|
||||
self._run_task_with_mock_celery(rescore_problem, task_entry.id, task_entry.task_id)
|
||||
|
||||
self.assert_task_output(
|
||||
output=self.get_task_output(task_entry.id),
|
||||
total=num_students,
|
||||
attempted=num_students,
|
||||
succeeded=0,
|
||||
skipped=0,
|
||||
failed=num_students,
|
||||
action_name='rescored'
|
||||
)
|
||||
|
||||
def test_rescoring_missing_result(self):
|
||||
"""
|
||||
Tests and confirm that rescoring does not succeed if "success" key is not returned.
|
||||
"""
|
||||
input_state = json.dumps({'done': True})
|
||||
num_students = 10
|
||||
self._create_students_with_state(num_students, input_state)
|
||||
task_entry = self._create_input_entry()
|
||||
mock_instance = Mock()
|
||||
mock_instance.rescore_problem = Mock(return_value={'bogus': 'value'})
|
||||
del mock_instance.rescore
|
||||
with patch('lms.djangoapps.instructor_task.tasks_helper.module_state.get_module_for_descriptor_internal') as mock_get_module:
|
||||
mock_get_module.return_value = mock_instance
|
||||
self._run_task_with_mock_celery(rescore_problem, task_entry.id, task_entry.task_id)
|
||||
|
||||
self.assert_task_output(
|
||||
output=self.get_task_output(task_entry.id),
|
||||
total=num_students,
|
||||
attempted=num_students,
|
||||
succeeded=0,
|
||||
skipped=0,
|
||||
failed=num_students,
|
||||
action_name='rescored'
|
||||
)
|
||||
|
||||
|
||||
@attr(shard=3)
|
||||
class TestResetAttemptsInstructorTask(TestInstructorTasks):
|
||||
|
||||
Reference in New Issue
Block a user