EDUCATOR-165 instructor task and UI for overriding learner grades.
This commit is contained in:
@@ -15,6 +15,7 @@ from bulk_email.models import CourseEmail
|
||||
from certificates.models import CertificateGenerationHistory
|
||||
from lms.djangoapps.instructor_task.api_helper import (
|
||||
check_arguments_for_rescoring,
|
||||
check_arguments_for_overriding,
|
||||
check_entrance_exam_problems_for_rescoring,
|
||||
encode_entrance_exam_and_student_input,
|
||||
encode_problem_and_student_input,
|
||||
@@ -22,6 +23,7 @@ from lms.djangoapps.instructor_task.api_helper import (
|
||||
)
|
||||
from lms.djangoapps.instructor_task.models import InstructorTask
|
||||
from lms.djangoapps.instructor_task.tasks import (
|
||||
override_problem_score,
|
||||
calculate_grades_csv,
|
||||
calculate_may_enroll_csv,
|
||||
calculate_problem_grade_report,
|
||||
@@ -114,6 +116,28 @@ def submit_rescore_problem_for_student(request, usage_key, student, only_if_high
|
||||
return submit_task(request, task_type, task_class, usage_key.course_key, task_input, task_key)
|
||||
|
||||
|
||||
def submit_override_score(request, usage_key, student, score):
|
||||
"""
|
||||
Request a problem score override as a background task. Only
|
||||
applicable to individual users.
|
||||
|
||||
The problem score will be overridden for the specified student only.
|
||||
Parameters are the `course_id`, the `problem_url`, the `student` as
|
||||
a User object, and the score override desired.
|
||||
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 this task is already running for this student, or NotImplementedError if
|
||||
the problem is not a ScorableXBlock.
|
||||
"""
|
||||
check_arguments_for_overriding(usage_key, score)
|
||||
task_type = override_problem_score.__name__
|
||||
task_class = override_problem_score
|
||||
task_input, task_key = encode_problem_and_student_input(usage_key, student)
|
||||
task_input['score'] = score
|
||||
return submit_task(request, task_type, task_class, usage_key.course_key, task_input, task_key)
|
||||
|
||||
|
||||
def submit_rescore_problem_for_all_students(request, usage_key, only_if_higher=False): # pylint: disable=invalid-name
|
||||
"""
|
||||
Request a problem to be rescored as a background task.
|
||||
|
||||
@@ -17,6 +17,7 @@ from courseware.courses import get_problems_in_section
|
||||
from courseware.module_render import get_xqueue_callback_url_prefix
|
||||
from lms.djangoapps.instructor_task.models import PROGRESS, InstructorTask
|
||||
from util.db import outer_atomic
|
||||
from xblock.scorable import ScorableXBlockMixin
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -263,6 +264,25 @@ def check_arguments_for_rescoring(usage_key):
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
|
||||
def check_arguments_for_overriding(usage_key, score):
|
||||
"""
|
||||
Do simple checks on the descriptor to confirm that it supports overriding
|
||||
the problem score and the score passed in is not greater than the value of
|
||||
the problem or less than 0.
|
||||
"""
|
||||
descriptor = modulestore().get_item(usage_key)
|
||||
score = float(score)
|
||||
|
||||
# some weirdness around initializing the descriptor requires this
|
||||
if not hasattr(descriptor.__class__, 'set_score'):
|
||||
msg = _("This component does not support score override.")
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
if score < 0 or score > descriptor.max_score():
|
||||
msg = _("Scores must be between 0 and the value of the problem.")
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def check_entrance_exam_problems_for_rescoring(exam_key): # pylint: disable=invalid-name
|
||||
"""
|
||||
Grabs all problem descriptors in exam and checks each descriptor to
|
||||
|
||||
@@ -45,6 +45,7 @@ from lms.djangoapps.instructor_task.tasks_helper.misc import (
|
||||
from lms.djangoapps.instructor_task.tasks_helper.module_state import (
|
||||
delete_problem_module_state,
|
||||
perform_module_state_update,
|
||||
override_score_module_state,
|
||||
rescore_problem_module_state,
|
||||
reset_attempts_module_state
|
||||
)
|
||||
@@ -80,6 +81,19 @@ def rescore_problem(entry_id, xmodule_instance_args):
|
||||
return run_main_task(entry_id, visit_fcn, action_name)
|
||||
|
||||
|
||||
@task(base=BaseInstructorTask) # pylint: disable=not-callable
|
||||
def override_problem_score(entry_id, xmodule_instance_args):
|
||||
"""
|
||||
Overrides a specific learner's score on a problem.
|
||||
"""
|
||||
# Translators: This is a past-tense verb that is inserted into task progress messages as {action}.
|
||||
action_name = ugettext_noop('overridden')
|
||||
update_fcn = partial(override_score_module_state, xmodule_instance_args)
|
||||
|
||||
visit_fcn = partial(perform_module_state_update, update_fcn, None)
|
||||
return run_main_task(entry_id, visit_fcn, action_name)
|
||||
|
||||
|
||||
@task(base=BaseInstructorTask) # pylint: disable=not-callable
|
||||
def reset_problem_attempts(entry_id, xmodule_instance_args):
|
||||
"""Resets problem attempts to zero for a particular problem for all students in a course.
|
||||
|
||||
@@ -23,6 +23,9 @@ from track.views import task_track
|
||||
from util.db import outer_atomic
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
from xblock.runtime import KvsFieldData
|
||||
from xblock.scorable import Score, ScorableXBlockMixin
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from ..exceptions import UpdateProblemModuleStateError
|
||||
from .runner import TaskProgress
|
||||
from .utils import UNKNOWN_TASK_ID, UPDATE_STATUS_FAILED, UPDATE_STATUS_SKIPPED, UPDATE_STATUS_SUCCEEDED
|
||||
@@ -31,6 +34,7 @@ TASK_LOG = logging.getLogger('edx.celery.task')
|
||||
|
||||
# define value to be used in grading events
|
||||
GRADES_RESCORE_EVENT_TYPE = 'edx.grades.problem.rescored'
|
||||
GRADES_OVERRIDE_EVENT_TYPE = 'edx.grades.problem.score_overridden'
|
||||
|
||||
|
||||
def perform_module_state_update(update_fcn, filter_fcn, _entry_id, course_id, task_input, action_name):
|
||||
@@ -168,8 +172,8 @@ def rescore_problem_module_state(xmodule_instance_args, module_descriptor, stude
|
||||
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=usage_key,
|
||||
msg = "No module {location} for student {student}--access denied?".format(
|
||||
location=usage_key,
|
||||
student=student
|
||||
)
|
||||
TASK_LOG.warning(msg)
|
||||
@@ -220,6 +224,86 @@ def rescore_problem_module_state(xmodule_instance_args, module_descriptor, stude
|
||||
return UPDATE_STATUS_SUCCEEDED
|
||||
|
||||
|
||||
@outer_atomic
|
||||
def override_score_module_state(xmodule_instance_args, module_descriptor, student_module, task_input):
|
||||
'''
|
||||
Takes an XModule descriptor and a corresponding StudentModule object, and
|
||||
performs an override on the student's problem score.
|
||||
|
||||
Throws exceptions if the override is fatal and should be aborted if in a loop.
|
||||
In particular, raises UpdateProblemModuleStateError if module fails to instantiate,
|
||||
or if the module doesn't support overriding, or if the score used for override
|
||||
is outside the acceptable range of scores (between 0 and the max score for the
|
||||
problem).
|
||||
|
||||
Returns True if problem was successfully overriden for the given student, and False
|
||||
if problem encountered some kind of error in overriding.
|
||||
'''
|
||||
# unpack the StudentModule:
|
||||
course_id = student_module.course_id
|
||||
student = student_module.student
|
||||
usage_key = student_module.module_state_key
|
||||
|
||||
with modulestore().bulk_operations(course_id):
|
||||
course = get_course_by_id(course_id)
|
||||
instance = _get_module_instance_for_task(
|
||||
course_id,
|
||||
student,
|
||||
module_descriptor,
|
||||
xmodule_instance_args,
|
||||
course=course
|
||||
)
|
||||
|
||||
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 {location} for student {student}--access denied?".format(
|
||||
location=usage_key,
|
||||
student=student
|
||||
)
|
||||
TASK_LOG.warning(msg)
|
||||
return UPDATE_STATUS_FAILED
|
||||
|
||||
if not hasattr(instance, 'set_score'):
|
||||
msg = "Scores cannot be overridden for this problem type."
|
||||
raise UpdateProblemModuleStateError(msg)
|
||||
|
||||
weighted_override_score = float(task_input['score'])
|
||||
if not (0 <= weighted_override_score <= instance.max_score()):
|
||||
msg = "Score must be between 0 and the maximum points available for the problem."
|
||||
raise UpdateProblemModuleStateError(msg)
|
||||
|
||||
# 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.
|
||||
create_new_event_transaction_id()
|
||||
set_event_transaction_type(GRADES_OVERRIDE_EVENT_TYPE)
|
||||
|
||||
problem_weight = instance.weight if instance.weight is not None else 1
|
||||
if problem_weight == 0:
|
||||
msg = "Scores cannot be overridden for a problem that has a weight of zero."
|
||||
raise UpdateProblemModuleStateError(msg)
|
||||
else:
|
||||
instance.set_score(Score(
|
||||
raw_earned=weighted_override_score / problem_weight,
|
||||
raw_possible=instance.max_score() / problem_weight
|
||||
))
|
||||
|
||||
instance.publish_grade()
|
||||
instance.save()
|
||||
TASK_LOG.debug(
|
||||
u"successfully processed score override 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):
|
||||
"""
|
||||
|
||||
@@ -25,6 +25,7 @@ from lms.djangoapps.instructor_task.api import (
|
||||
submit_detailed_enrollment_features_csv,
|
||||
submit_executive_summary_report,
|
||||
submit_export_ora2_data,
|
||||
submit_override_score,
|
||||
submit_rescore_entrance_exam_for_student,
|
||||
submit_rescore_problem_for_all_students,
|
||||
submit_rescore_problem_for_student,
|
||||
@@ -159,6 +160,7 @@ class InstructorTaskModuleSubmitTest(InstructorTaskModuleTestCase):
|
||||
),
|
||||
(submit_reset_problem_attempts_in_entrance_exam, 'reset_problem_attempts', {'student': True}),
|
||||
(submit_delete_entrance_exam_state_for_student, 'delete_problem_state', {'student': True}),
|
||||
(submit_override_score, 'override_problem_score', {'student': True, 'score': 0})
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_submit_task(self, task_function, expected_task_type, params=None):
|
||||
|
||||
@@ -25,7 +25,8 @@ from lms.djangoapps.instructor_task.tasks import (
|
||||
export_ora2_data,
|
||||
generate_certificates,
|
||||
rescore_problem,
|
||||
reset_problem_attempts
|
||||
reset_problem_attempts,
|
||||
override_problem_score
|
||||
)
|
||||
from lms.djangoapps.instructor_task.tasks_helper.misc import upload_ora2_data
|
||||
from lms.djangoapps.instructor_task.tests.factories import InstructorTaskFactory
|
||||
@@ -54,7 +55,9 @@ class TestInstructorTasks(InstructorTaskModuleTestCase):
|
||||
self.instructor = self.create_instructor('instructor')
|
||||
self.location = self.problem_location(PROBLEM_URL_NAME)
|
||||
|
||||
def _create_input_entry(self, student_ident=None, use_problem_url=True, course_id=None, only_if_higher=False):
|
||||
def _create_input_entry(
|
||||
self, student_ident=None, use_problem_url=True, course_id=None, only_if_higher=False, score=None
|
||||
):
|
||||
"""Creates a InstructorTask entry for testing."""
|
||||
task_id = str(uuid4())
|
||||
task_input = {'only_if_higher': only_if_higher}
|
||||
@@ -62,6 +65,8 @@ class TestInstructorTasks(InstructorTaskModuleTestCase):
|
||||
task_input['problem_url'] = self.location
|
||||
if student_ident is not None:
|
||||
task_input['student'] = student_ident
|
||||
if score is not None:
|
||||
task_input['score'] = score
|
||||
|
||||
course_id = course_id or self.course.id
|
||||
instructor_task = InstructorTaskFactory.create(course_id=course_id,
|
||||
@@ -220,6 +225,116 @@ class TestInstructorTasks(InstructorTaskModuleTestCase):
|
||||
self.assertEquals(output['traceback'][-3:], "...")
|
||||
|
||||
|
||||
class TestOverrideScoreInstructorTask(TestInstructorTasks):
|
||||
"""Tests instructor task to override learner's problem score"""
|
||||
def assert_task_output(self, output, **expected_output):
|
||||
"""
|
||||
Check & compare output of the task
|
||||
"""
|
||||
self.assertEqual(output.get('total'), expected_output.get('total'))
|
||||
self.assertEqual(output.get('attempted'), expected_output.get('attempted'))
|
||||
self.assertEqual(output.get('succeeded'), expected_output.get('succeeded'))
|
||||
self.assertEqual(output.get('skipped'), expected_output.get('skipped'))
|
||||
self.assertEqual(output.get('failed'), expected_output.get('failed'))
|
||||
self.assertEqual(output.get('action_name'), expected_output.get('action_name'))
|
||||
self.assertGreater(output.get('duration_ms'), expected_output.get('duration_ms', 0))
|
||||
|
||||
def get_task_output(self, task_id):
|
||||
"""Get and load instructor task output"""
|
||||
entry = InstructorTask.objects.get(id=task_id)
|
||||
return json.loads(entry.task_output)
|
||||
|
||||
def test_override_missing_current_task(self):
|
||||
self._test_missing_current_task(override_problem_score)
|
||||
|
||||
def test_override_undefined_course(self):
|
||||
self._test_undefined_course(override_problem_score)
|
||||
|
||||
def test_override_undefined_problem(self):
|
||||
self._test_undefined_problem(override_problem_score)
|
||||
|
||||
def test_override_with_no_state(self):
|
||||
self._test_run_with_no_state(override_problem_score, 'overridden')
|
||||
|
||||
def test_override_with_failure(self):
|
||||
self._test_run_with_failure(override_problem_score, 'We expected this to fail')
|
||||
|
||||
def test_override_with_long_error_msg(self):
|
||||
self._test_run_with_long_error_msg(override_problem_score)
|
||||
|
||||
def test_override_with_short_error_msg(self):
|
||||
self._test_run_with_short_error_msg(override_problem_score)
|
||||
|
||||
def test_overriding_non_scorable(self):
|
||||
input_state = json.dumps({'done': True})
|
||||
num_students = 1
|
||||
self._create_students_with_state(num_students, input_state)
|
||||
task_entry = self._create_input_entry(score=0)
|
||||
mock_instance = MagicMock()
|
||||
del mock_instance.set_score
|
||||
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
|
||||
with self.assertRaises(UpdateProblemModuleStateError):
|
||||
self._run_task_with_mock_celery(override_problem_score, task_entry.id, task_entry.task_id)
|
||||
# check values stored in table:
|
||||
entry = InstructorTask.objects.get(id=task_entry.id)
|
||||
output = json.loads(entry.task_output)
|
||||
self.assertEquals(output['exception'], "UpdateProblemModuleStateError")
|
||||
self.assertEquals(output['message'], "Scores cannot be overridden for this problem type.")
|
||||
self.assertGreater(len(output['traceback']), 0)
|
||||
|
||||
def test_overriding_unaccessable(self):
|
||||
"""
|
||||
Tests rescores a problem in a course, for all students fails if user has answered a
|
||||
problem to which user does not have access to.
|
||||
"""
|
||||
input_state = json.dumps({'done': True})
|
||||
num_students = 1
|
||||
self._create_students_with_state(num_students, input_state)
|
||||
task_entry = self._create_input_entry(score=0)
|
||||
with patch('lms.djangoapps.instructor_task.tasks_helper.module_state.get_module_for_descriptor_internal',
|
||||
return_value=None):
|
||||
self._run_task_with_mock_celery(override_problem_score, 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='overridden'
|
||||
)
|
||||
|
||||
def test_overriding_success(self):
|
||||
"""
|
||||
Tests rescores a problem in a course, for all students succeeds.
|
||||
"""
|
||||
mock_instance = MagicMock()
|
||||
getattr(mock_instance, 'override_problem_score').return_value = None
|
||||
|
||||
num_students = 10
|
||||
self._create_students_with_state(num_students)
|
||||
task_entry = self._create_input_entry(score=0)
|
||||
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(override_problem_score, 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=num_students,
|
||||
skipped=0,
|
||||
failed=0,
|
||||
action_name='overridden'
|
||||
)
|
||||
|
||||
|
||||
@attr(shard=3)
|
||||
@ddt.ddt
|
||||
class TestRescoreInstructorTask(TestInstructorTasks):
|
||||
|
||||
Reference in New Issue
Block a user