Commit 07ad189b by sanfordstudent Committed by GitHub

Merge pull request #15263 from edx/sstudent/grade-override

Learner grade override
parents 0bff6153 93277615
......@@ -28,7 +28,7 @@ from xblock.scorable import ScorableXBlockMixin, Score
from xmodule.capa_base_constants import RANDOMIZATION, SHOWANSWER
from xmodule.exceptions import NotFoundError
from xmodule.graders import ShowCorrectness
from .fields import Date, Timedelta
from .fields import Date, Timedelta, ScoreField
from .progress import Progress
from openedx.core.djangolib.markup import HTML, Text
......@@ -104,7 +104,8 @@ class CapaFields(object):
attempts = Integer(
help=_("Number of attempts taken by the student on this problem"),
default=0,
scope=Scope.user_state)
scope=Scope.user_state
)
max_attempts = Integer(
display_name=_("Maximum Attempts"),
help=_("Defines the number of times a student can try to answer this problem. "
......@@ -183,6 +184,9 @@ class CapaFields(object):
scope=Scope.user_state, default={})
input_state = Dict(help=_("Dictionary for maintaining the state of inputtypes"), scope=Scope.user_state)
student_answers = Dict(help=_("Dictionary with the current student responses"), scope=Scope.user_state)
# enforce_type is set to False here because this field is saved as a dict in the database.
score = ScoreField(help=_("Dictionary with the current student score"), scope=Scope.user_state, enforce_type=False)
has_saved_answers = Boolean(help=_("Whether or not the answers have been saved since last submit"),
scope=Scope.user_state)
done = Boolean(help=_("Whether the student has answered the problem"), scope=Scope.user_state)
......@@ -292,7 +296,8 @@ class CapaMixin(ScorableXBlockMixin, CapaFields):
self.set_state_from_lcp()
self.set_score(self.score_from_lcp())
if self.score is None:
self.set_score(self.score_from_lcp())
assert self.seed is not None
......@@ -380,9 +385,8 @@ class CapaMixin(ScorableXBlockMixin, CapaFields):
"""
For now, just return weighted earned / weighted possible
"""
score = self.get_score()
raw_earned = score.raw_earned
raw_possible = score.raw_possible
raw_earned = self.score.raw_earned
raw_possible = self.score.raw_possible
if raw_possible > 0:
if self.weight is not None:
......
......@@ -331,6 +331,7 @@ class CapaDescriptor(CapaFields, RawDescriptor):
rescore = module_attr('rescore')
reset_problem = module_attr('reset_problem')
save_problem = module_attr('save_problem')
set_score = module_attr('set_score')
set_state_from_lcp = module_attr('set_state_from_lcp')
should_show_submit_button = module_attr('should_show_submit_button')
should_show_reset_button = module_attr('should_show_reset_button')
......
......@@ -6,6 +6,7 @@ import time
import dateutil.parser
from pytz import UTC
from xblock.fields import JSONField
from xblock.scorable import Score
log = logging.getLogger(__name__)
......@@ -252,3 +253,48 @@ class RelativeTime(JSONField):
return value
return self.from_json(value)
class ScoreField(JSONField):
"""
Field for blocks that need to store a Score. XBlocks that implement
the ScorableXBlockMixin may need to store their score separately
from their problem state, specifically for use in staff override
of problem scores.
"""
MUTABLE = False
def from_json(self, value):
if value is None:
return value
if isinstance(value, Score):
return value
if set(value) != {'raw_earned', 'raw_possible'}:
raise TypeError('Scores must contain only a raw earned and raw possible value. Got {}'.format(
set(value)
))
raw_earned = value['raw_earned']
raw_possible = value['raw_possible']
if raw_possible < 0:
raise ValueError(
'Error deserializing field of type {0}: Expected a positive number for raw_possible, got {1}.'.format(
self.display_name,
raw_possible,
)
)
if not (0 <= raw_earned <= raw_possible):
raise ValueError(
'Error deserializing field of type {0}: Expected raw_earned between 0 and {1}, got {2}.'.format(
self.display_name,
raw_possible,
raw_earned
)
)
return Score(raw_earned, raw_possible)
enforce_type = from_json
......@@ -241,8 +241,9 @@
totalScore
);
}
} else if (attemptsUsed === 0 || totalScore === 0) {
} else if ((attemptsUsed === 0 || totalScore === 0) && curScore === 0) {
// Render 'x point(s) possible' if student has not yet attempted question
// But if staff has overridden score to a non-zero number, show it
if (graded) {
progressTemplate = ngettext(
// Translators: %(num_points)s is the number of points possible (examples: 1, 3, 10).;
......
......@@ -10,6 +10,7 @@ from xblock.scorable import ScorableXBlockMixin, Score
from courseware.model_data import get_score, set_score
from eventtracking import tracker
from lms.djangoapps.instructor_task.tasks_helper.module_state import GRADES_OVERRIDE_EVENT_TYPE
from openedx.core.lib.grade_utils import is_score_higher_or_equal
from student.models import user_by_anonymous_id
from submissions.models import score_reset, score_set
......@@ -274,12 +275,9 @@ def _emit_event(kwargs):
}
)
if root_type == 'edx.grades.problem.rescored':
if root_type in [GRADES_RESCORE_EVENT_TYPE, GRADES_OVERRIDE_EVENT_TYPE]:
current_user = get_current_user()
if current_user is not None and hasattr(current_user, 'id'):
instructor_id = unicode(current_user.id)
else:
instructor_id = None
instructor_id = getattr(current_user, 'id', None)
tracker.emit(
unicode(GRADES_RESCORE_EVENT_TYPE),
{
......@@ -289,8 +287,8 @@ def _emit_event(kwargs):
'new_weighted_earned': kwargs.get('weighted_earned'),
'new_weighted_possible': kwargs.get('weighted_possible'),
'only_if_higher': kwargs.get('only_if_higher'),
'instructor_id': instructor_id,
'instructor_id': unicode(instructor_id),
'event_transaction_id': unicode(get_event_transaction_id()),
'event_transaction_type': unicode(GRADES_RESCORE_EVENT_TYPE),
'event_transaction_type': unicode(root_type),
}
)
......@@ -70,6 +70,7 @@ from lms.djangoapps.instructor.enrollment import (
)
from lms.djangoapps.instructor.views import INVOICE_KEY
from lms.djangoapps.instructor.views.instructor_task_helpers import extract_email_features, extract_task_features
from lms.djangoapps.instructor_task.api import submit_override_score
from lms.djangoapps.instructor_task.api_helper import AlreadyRunningError
from lms.djangoapps.instructor_task.models import ReportStore
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
......@@ -114,6 +115,7 @@ from util.file import (
)
from util.json_request import JsonResponse, JsonResponseBadRequest
from util.views import require_global_staff
from xmodule.modulestore.django import modulestore
from .tools import (
dump_module_extensions,
......@@ -129,6 +131,8 @@ from .tools import (
log = logging.getLogger(__name__)
TASK_SUBMISSION_OK = 'created'
def common_exceptions_400(func):
"""
......@@ -2005,7 +2009,7 @@ def reset_student_attempts(request, course_id):
response_payload['student'] = student_identifier
elif all_students:
lms.djangoapps.instructor_task.api.submit_reset_problem_attempts_for_all_students(request, module_state_key)
response_payload['task'] = 'created'
response_payload['task'] = TASK_SUBMISSION_OK
response_payload['student'] = 'All Students'
else:
return HttpResponseBadRequest()
......@@ -2084,7 +2088,7 @@ def reset_student_attempts_for_entrance_exam(request, course_id): # pylint: dis
except InvalidKeyError:
return HttpResponseBadRequest(_("Course has no valid entrance exam section."))
response_payload = {'student': student_identifier or _('All Students'), 'task': 'created'}
response_payload = {'student': student_identifier or _('All Students'), 'task': TASK_SUBMISSION_OK}
return JsonResponse(response_payload)
......@@ -2155,7 +2159,64 @@ def rescore_problem(request, course_id):
else:
return HttpResponseBadRequest()
response_payload['task'] = 'created'
response_payload['task'] = TASK_SUBMISSION_OK
return JsonResponse(response_payload)
@transaction.non_atomic_requests
@require_POST
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('instructor')
@require_post_params(problem_to_reset="problem urlname to reset", score='overriding score')
@common_exceptions_400
def override_problem_score(request, course_id):
course_key = CourseKey.from_string(course_id)
score = strip_if_string(request.POST.get('score'))
problem_to_reset = strip_if_string(request.POST.get('problem_to_reset'))
student_identifier = request.POST.get('unique_student_identifier', None)
if not problem_to_reset:
return HttpResponseBadRequest("Missing query parameter problem_to_reset.")
if not student_identifier:
return HttpResponseBadRequest("Missing query parameter student_identifier.")
if student_identifier is not None:
student = get_student_from_identifier(student_identifier)
else:
return _create_error_response(request, "Invalid student ID {}.".format(student_identifier))
try:
usage_key = UsageKey.from_string(problem_to_reset).map_into_course(course_key)
except InvalidKeyError:
return _create_error_response(request, "Unable to parse problem id {}.".format(problem_to_reset))
# check the user's access to this specific problem
if not has_access(request.user, "instructor", modulestore().get_item(usage_key)):
_create_error_response(request, "User {} does not have permission to override scores for problem {}.".format(
request.user.id,
problem_to_reset
))
response_payload = {
'problem_to_reset': problem_to_reset,
'student': student_identifier
}
try:
submit_override_score(
request,
usage_key,
student,
score,
)
except NotImplementedError as exc: # if we try to override the score of a non-scorable block, catch it here
return _create_error_response(request, exc.message)
except ValueError as exc:
return _create_error_response(request, exc.message)
response_payload['task'] = TASK_SUBMISSION_OK
return JsonResponse(response_payload)
......@@ -2213,7 +2274,7 @@ def rescore_entrance_exam(request, course_id):
lms.djangoapps.instructor_task.api.submit_rescore_entrance_exam_for_student(
request, entrance_exam_key, student, only_if_higher,
)
response_payload['task'] = 'created'
response_payload['task'] = TASK_SUBMISSION_OK
return JsonResponse(response_payload)
......@@ -3402,3 +3463,11 @@ def _get_boolean_param(request, param_name):
values to boolean values.
"""
return request.POST.get(param_name, False) in ['true', 'True', True]
def _create_error_response(request, msg):
"""
Creates the appropriate error response for the current request,
in JSON form.
"""
return JsonResponse({"error": _(msg)}, 400)
......@@ -46,6 +46,10 @@ urlpatterns = patterns(
'lms.djangoapps.instructor.views.api.rescore_problem',
name="rescore_problem"
), url(
r'^override_problem_score$',
'lms.djangoapps.instructor.views.api.override_problem_score',
name="override_problem_score"
), url(
r'^reset_student_attempts_for_entrance_exam$',
'lms.djangoapps.instructor.views.api.reset_student_attempts_for_entrance_exam',
name="reset_student_attempts_for_entrance_exam"
......
......@@ -567,6 +567,7 @@ def _section_student_admin(course, access):
kwargs={'course_id': unicode(course_key)},
),
'rescore_problem_url': reverse('rescore_problem', kwargs={'course_id': unicode(course_key)}),
'override_problem_score_url': reverse('override_problem_score', kwargs={'course_id': unicode(course_key)}),
'rescore_entrance_exam_url': reverse('rescore_entrance_exam', kwargs={'course_id': unicode(course_key)}),
'student_can_skip_entrance_exam_url': reverse(
'mark_student_can_skip_entrance_exam',
......
......@@ -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
)
......@@ -81,6 +82,19 @@ def rescore_problem(entry_id, xmodule_instance_args):
@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)
......@@ -221,6 +225,86 @@ def rescore_problem_module_state(xmodule_instance_args, module_descriptor, stude
@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):
"""
Resets problem attempts to zero for specified `student_module`.
......
......@@ -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):
......
......@@ -42,6 +42,10 @@
this.$btn_rescore_problem_if_higher_single = this.$section.find(
"input[name='rescore-problem-if-higher-single']"
);
this.$btn_override_problem_score_single = this.$section.find(
"input[name='override-problem-score-single']"
);
this.$field_select_score_single = findAndAssert(this.$section, "input[name='score-select-single']");
this.$btn_task_history_single = this.$section.find("input[name='task-history-single']");
this.$table_task_history_single = this.$section.find('.task-history-single-table');
this.$field_exam_grade = this.$section.find("input[name='entrance-exam-student-select-grade']");
......@@ -408,6 +412,9 @@
this.$btn_rescore_problem_if_higher_all.click(function() {
return studentadmin.rescore_problem_all(true);
});
this.$btn_override_problem_score_single.click(function() {
return studentadmin.override_problem_score_single();
});
this.$btn_task_history_all.click(function() {
var sendData;
sendData = {
......@@ -483,6 +490,60 @@
});
};
StudentAdmin.prototype.override_problem_score_single = function() {
var defaultErrorMessage, fullDefaultErrorMessage, fullSuccessMessage,
problemToReset, score, sendData, successMessage, uniqStudentIdentifier,
that = this;
uniqStudentIdentifier = this.$field_student_select_grade.val();
problemToReset = this.$field_problem_select_single.val();
score = this.$field_select_score_single.val();
if (!uniqStudentIdentifier) {
return this.$request_err_grade.text(
gettext('Please enter a student email address or username.')
);
}
if (!problemToReset) {
return this.$request_err_grade.text(
gettext('Please enter a problem location.')
);
}
if (!score) {
return this.$request_err_grade.text(
gettext('Please enter a score.')
);
}
sendData = {
unique_student_identifier: uniqStudentIdentifier,
problem_to_reset: problemToReset,
score: score
};
successMessage = gettext("Started task to override the score for problem '<%- problem_id %>' and student '<%- student_id %>'. Click the 'Show Task Status' button to see the status of the task."); // eslint-disable-line max-len
fullSuccessMessage = _.template(successMessage)({
student_id: uniqStudentIdentifier,
problem_id: problemToReset
});
defaultErrorMessage = gettext("Error starting a task to override score for problem '<%- problem_id %>' for student '<%- student_id %>'. Make sure that the the score and the problem and student identifiers are complete and correct."); // eslint-disable-line max-len
fullDefaultErrorMessage = _.template(defaultErrorMessage)({
student_id: uniqStudentIdentifier,
problem_id: problemToReset
});
return $.ajax({
type: 'POST',
dataType: 'json',
url: this.$btn_override_problem_score_single.data('endpoint'),
data: sendData,
success: this.clear_errors_then(function() {
return alert(fullSuccessMessage); // eslint-disable-line no-alert
}),
error: statusAjaxError(function(response) {
if (response.responseText) {
return that.$request_err_grade.text(response.responseText);
}
return that.$request_err_grade.text(fullDefaultErrorMessage);
})
});
};
StudentAdmin.prototype.rescore_entrance_exam_all = function(onlyIfHigher) {
var sendData, uniqStudentIdentifier,
that = this;
......
......@@ -11,8 +11,10 @@ define([
describe('StaffDebugActions', function() {
var location = 'i4x://edX/Open_DemoX/edx_demo_course/problem/test_loc';
var locationName = 'test_loc';
var fixtureID = 'sd_fu_' + locationName;
var $fixture = $('<input>', {id: fixtureID, placeholder: 'userman'});
var usernameFixtureID = 'sd_fu_' + locationName;
var $usernameFixture = $('<input>', {id: usernameFixtureID, placeholder: 'userman'});
var scoreFixtureID = 'sd_fs_' + locationName;
var $scoreFixture = $('<input>', {id: scoreFixtureID, placeholder: '0'});
var escapableLocationName = 'test\.\*\+\?\^\:\$\{\}\(\)\|\]\[loc';
var escapableFixtureID = 'sd_fu_' + escapableLocationName;
var $escapableFixture = $('<input>', {id: escapableFixtureID, placeholder: 'userman'});
......@@ -38,17 +40,17 @@ define([
describe('getUser', function() {
it('gets the placeholder username if input field is empty', function() {
$('body').append($fixture);
$('body').append($usernameFixture);
expect(StaffDebug.getUser(locationName)).toBe('userman');
$('#' + fixtureID).remove();
$('#' + usernameFixtureID).remove();
});
it('gets a filled in name if there is one', function() {
$('body').append($fixture);
$('#' + fixtureID).val('notuserman');
$('body').append($usernameFixture);
$('#' + usernameFixtureID).val('notuserman');
expect(StaffDebug.getUser(locationName)).toBe('notuserman');
$('#' + fixtureID).val('');
$('#' + fixtureID).remove();
$('#' + usernameFixtureID).val('');
$('#' + usernameFixtureID).remove();
});
it('gets the placeholder name if the id has escapable characters', function() {
$('body').append($escapableFixture);
......@@ -56,6 +58,21 @@ define([
$("input[id^='sd_fu_']").remove();
});
});
describe('getScore', function() {
it('gets the placeholder score if input field is empty', function() {
$('body').append($scoreFixture);
expect(StaffDebug.getScore(locationName)).toBe('0');
$('#' + scoreFixtureID).remove();
});
it('gets a filled in score if there is one', function() {
$('body').append($scoreFixture);
$('#' + scoreFixtureID).val('1');
expect(StaffDebug.getScore(locationName)).toBe('1');
$('#' + scoreFixtureID).val('');
$('#' + scoreFixtureID).remove();
});
});
describe('doInstructorDashAction success', function() {
it('adds a success message to the results element after using an action', function() {
$('body').append(escapableResultArea);
......@@ -86,7 +103,7 @@ define([
});
describe('reset', function() {
it('makes an ajax call with the expected parameters', function() {
$('body').append($fixture);
$('body').append($usernameFixture);
spyOn($, 'ajax');
StaffDebug.reset(locationName, location);
......@@ -96,17 +113,18 @@ define([
problem_to_reset: location,
unique_student_identifier: 'userman',
delete_module: false,
only_if_higher: undefined
only_if_higher: undefined,
score: undefined
});
expect($.ajax.calls.mostRecent().args[0].url).toEqual(
'/instructor/api/reset_student_attempts'
);
$('#' + fixtureID).remove();
$('#' + usernameFixtureID).remove();
});
});
describe('deleteStudentState', function() {
it('makes an ajax call with the expected parameters', function() {
$('body').append($fixture);
$('body').append($usernameFixture);
spyOn($, 'ajax');
StaffDebug.deleteStudentState(locationName, location);
......@@ -116,18 +134,19 @@ define([
problem_to_reset: location,
unique_student_identifier: 'userman',
delete_module: true,
only_if_higher: undefined
only_if_higher: undefined,
score: undefined
});
expect($.ajax.calls.mostRecent().args[0].url).toEqual(
'/instructor/api/reset_student_attempts'
);
$('#' + fixtureID).remove();
$('#' + usernameFixtureID).remove();
});
});
describe('rescore', function() {
it('makes an ajax call with the expected parameters', function() {
$('body').append($fixture);
$('body').append($usernameFixture);
spyOn($, 'ajax');
StaffDebug.rescore(locationName, location);
......@@ -137,17 +156,18 @@ define([
problem_to_reset: location,
unique_student_identifier: 'userman',
delete_module: undefined,
only_if_higher: false
only_if_higher: false,
score: undefined
});
expect($.ajax.calls.mostRecent().args[0].url).toEqual(
'/instructor/api/rescore_problem'
);
$('#' + fixtureID).remove();
$('#' + usernameFixtureID).remove();
});
});
describe('rescoreIfHigher', function() {
it('makes an ajax call with the expected parameters', function() {
$('body').append($fixture);
$('body').append($usernameFixture);
spyOn($, 'ajax');
StaffDebug.rescoreIfHigher(locationName, location);
......@@ -157,12 +177,35 @@ define([
problem_to_reset: location,
unique_student_identifier: 'userman',
delete_module: undefined,
only_if_higher: true
only_if_higher: true,
score: undefined
});
expect($.ajax.calls.mostRecent().args[0].url).toEqual(
'/instructor/api/rescore_problem'
);
$('#' + fixtureID).remove();
$('#' + usernameFixtureID).remove();
});
});
describe('overrideScore', function() {
it('makes an ajax call with the expected parameters', function() {
$('body').append($usernameFixture);
$('body').append($scoreFixture);
$('#' + scoreFixtureID).val('1');
spyOn($, 'ajax');
StaffDebug.overrideScore(locationName, location);
expect($.ajax.calls.mostRecent().args[0].type).toEqual('POST');
expect($.ajax.calls.mostRecent().args[0].data).toEqual({
problem_to_reset: location,
unique_student_identifier: 'userman',
delete_module: undefined,
only_if_higher: undefined,
score: '1'
});
expect($.ajax.calls.mostRecent().args[0].url).toEqual(
'/instructor/api/override_problem_score'
);
$('#' + usernameFixtureID).remove();
});
});
});
......
......@@ -19,12 +19,22 @@ var StaffDebug = (function() {
return uname;
};
var getScore = function(locationName) {
var sanitizedLocationName = sanitizeString(locationName);
var score = $('#sd_fs_' + sanitizedLocationName).val();
if (score === '') {
score = $('#sd_fs_' + sanitizedLocationName).attr('placeholder');
}
return score;
};
var doInstructorDashAction = function(action) {
var pdata = {
problem_to_reset: action.location,
unique_student_identifier: getUser(action.locationName),
delete_module: action.delete_module,
only_if_higher: action.only_if_higher
only_if_higher: action.only_if_higher,
score: action.score
};
$.ajax({
type: 'POST',
......@@ -105,6 +115,17 @@ var StaffDebug = (function() {
});
};
var overrideScore = function(locname, location) {
this.doInstructorDashAction({
locationName: locname,
location: location,
method: 'override_problem_score',
success_msg: gettext('Successfully overrode problem score for {user}'),
error_msg: gettext('Could not override problem score for {user}.'),
score: getScore(locname)
});
};
getCurrentUrl = function() {
return window.location.pathname;
};
......@@ -114,12 +135,14 @@ var StaffDebug = (function() {
deleteStudentState: deleteStudentState,
rescore: rescore,
rescoreIfHigher: rescoreIfHigher,
overrideScore: overrideScore,
// export for testing
doInstructorDashAction: doInstructorDashAction,
getCurrentUrl: getCurrentUrl,
getURL: getURL,
getUser: getUser,
getScore: getScore,
sanitizeString: sanitizeString
}; })();
......@@ -142,4 +165,9 @@ $(document).ready(function() {
StaffDebug.rescoreIfHigher($(this).parent().data('location-name'), $(this).parent().data('location'));
return false;
});
$courseContent.on('click', '.staff-debug-override-score', function() {
StaffDebug.overrideScore($(this).parent().data('location-name'), $(this).parent().data('location'));
return false;
});
});
......@@ -501,7 +501,7 @@ html.video-fullscreen {
}
}
.vert > .xblock-student_view {
.vert {
@extend .clearfix;
border-bottom: 1px solid #ddd;
margin-bottom: ($baseline*0.75);
......
......@@ -18,7 +18,7 @@
<h4 class="hd hd-4">${_("View a specific learner's grades and progress")}</h4>
<div class="request-response-error"></div>
<label for="student-select-progress">
${_("Learner's {platform_name} email address or username *").format(platform_name=settings.PLATFORM_NAME)}
${_("Learner's {platform_name} email address or username").format(platform_name=settings.PLATFORM_NAME)}
</label>
<br>
<input type="text" name="student-select-progress" placeholder="${_('Learner email address or username')}" >
......@@ -37,7 +37,7 @@
<h4 class="hd hd-4">${_("Adjust a learner's grade for a specific problem")}</h4>
<div class="request-response-error"></div>
<label for="student-select-grade">
${_("Learner's {platform_name} email address or username *").format(platform_name=settings.PLATFORM_NAME)}
${_("Learner's {platform_name} email address or username").format(platform_name=settings.PLATFORM_NAME)}
</label>
<br>
<input type="text" name="student-select-grade" placeholder="${_('Learner email address or username')}">
......@@ -45,7 +45,7 @@
<br><br>
<label for="problem-select-single">
${_("Location of problem in course *")}<br>
${_("Location of problem in course")}<br>
<span class="location-example">${_("Example")}: block-v1:edX+DemoX+2015+type@problem+block@618c5933b8b544e4a4cc103d3e508378</span>
</label>
<br>
......@@ -71,6 +71,23 @@
<br><br>
%if settings.FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS'):
<h5 class="hd hd-5">${_("Score Override")}</h5>
<label for="override-problem-score-single">${_("For the specified problem, override the learner's score.")}</label>
<br><br>
<label for="score-select-single">
${_("New score for problem, out of the total points available for the problem")}<br>
</label>
<br>
<input type="text" name="score-select-single" placeholder="${_('Score')}">
<br><br>
<span name="override-actions-single">
<input type="button" name="override-problem-score-single" value="${_('Override Learner\'s Score')}" data-endpoint="${ section_data['override_problem_score_url'] }">
</span>
%endif
<br><br>
<h5 class="hd hd-5">${_("Problem History")}</h5>
<label for="delete-state-single">${_("For the specified problem, permanently and completely delete the learner's answers and scores from the database.")}</label>
<br>
......@@ -94,7 +111,7 @@
<div class="request-response-error"></div>
<label for="entrance-exam-student-select-grade">
${_("Learner's {platform_name} email address or username *").format(platform_name=settings.PLATFORM_NAME)}
${_("Learner's {platform_name} email address or username").format(platform_name=settings.PLATFORM_NAME)}
</label>
<br>
<input type="text" name="entrance-exam-student-select-grade" placeholder="${_('Learner email address or username')}">
......@@ -156,7 +173,7 @@
<div class="request-response-error"></div>
<label for="problem-select-all">
${_("Location of problem in course *")}<br>
${_("Location of problem in course")}<br>
<span class="location-example">${_("Example")}: block-v1:edX+DemoX+2015+type@problem+block@618c5933b8b544e4a4cc103d3e508378</span>
</label>
<br>
......
......@@ -56,7 +56,7 @@ ${block_content}
<div aria-hidden="true" role="dialog" tabindex="-1" class="modal staff-modal" id="${element_id}_debug" >
<div class="inner-wrapper">
<header>
<h2>${_('Staff Debug')}</h2>
<h2>${_('Staff Debug:')} ${dict(fields)['display_name']}</h2>
</header>
<hr />
......@@ -66,6 +66,13 @@ ${block_content}
<label for="sd_fu_${location.name | h}">${_('Username')}:</label>
<input type="text" tabindex="0" id="sd_fu_${location.name | h}" placeholder="${user.username}"/>
</div>
% if can_override_problem_score:
<div>
<label for="sd_fs_${location.name | h}">${_('Score (for override only)')}:</label>
<input type="text" tabindex="0" id="sd_fs_${location.name | h}" placeholder="0"/>
<label for="sd_fs_${location.name | h}"> / ${max_problem_score}</label>
</div>
% endif
<div data-location="${location | h}" data-location-name="${location.name | h}">
[
% if can_reset_attempts:
......@@ -79,6 +86,10 @@ ${block_content}
<button type="button" class="btn-link staff-debug-rescore">${_('Rescore Learner\'s Submission')}</button>
|
<button type="button" class="btn-link staff-debug-rescore-if-higher">${_('Rescore Only If Score Improves')}</button>
|
% endif
% if can_override_problem_score:
<button type="button" class="btn-link staff-debug-override-score">${_('Override Score')}</button>
% endif
% endif
]
......
......@@ -22,6 +22,7 @@ from edxmako.shortcuts import render_to_string
from xblock.core import XBlock
from xblock.exceptions import InvalidScopeError
from xblock.fragment import Fragment
from xblock.scorable import ScorableXBlockMixin
from xmodule.seq_module import SequenceModule
from xmodule.vertical_block import VerticalBlock
......@@ -383,9 +384,13 @@ def add_staff_markup(user, has_instructor_access, disable_staff_debug_info, bloc
'is_released': is_released,
'has_instructor_access': has_instructor_access,
'can_reset_attempts': 'attempts' in block.fields,
'can_rescore_problem': any(hasattr(block, rescore) for rescore in ['rescore_problem', 'rescore']),
'can_rescore_problem': hasattr(block, 'rescore'),
'can_override_problem_score': isinstance(block, ScorableXBlockMixin),
'disable_staff_debug_info': disable_staff_debug_info,
}
if isinstance(block, ScorableXBlockMixin):
staff_context['max_problem_score'] = block.max_score()
return wrap_fragment(frag, render_to_string("staff_problem_info.html", staff_context))
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment