Merge pull request #17830 from edx/noraiz/EDUCATOR-2587
permit staff role to rescore, override and delete problem scores for individual learners
This commit is contained in:
@@ -688,13 +688,11 @@ def get_module_system_for_user(
|
||||
# the result would always be "False".
|
||||
masquerade_settings = user.real_user.masquerade_settings
|
||||
del user.real_user.masquerade_settings
|
||||
instructor_access = bool(has_access(user.real_user, 'instructor', descriptor, course_id))
|
||||
user.real_user.masquerade_settings = masquerade_settings
|
||||
else:
|
||||
staff_access = has_access(user, 'staff', descriptor, course_id)
|
||||
instructor_access = bool(has_access(user, 'instructor', descriptor, course_id))
|
||||
if staff_access:
|
||||
block_wrappers.append(partial(add_staff_markup, user, instructor_access, disable_staff_debug_info))
|
||||
block_wrappers.append(partial(add_staff_markup, user, disable_staff_debug_info))
|
||||
|
||||
# These modules store data using the anonymous_student_id as a key.
|
||||
# To prevent loss of data, we will continue to provide old modules with
|
||||
|
||||
@@ -205,6 +205,7 @@ INSTRUCTOR_POST_ENDPOINTS = set([
|
||||
'spent_registration_codes',
|
||||
'students_update_enrollment',
|
||||
'update_forum_role_membership',
|
||||
'override_problem_score',
|
||||
])
|
||||
|
||||
|
||||
@@ -386,11 +387,37 @@ class TestInstructorAPIDenyLevels(SharedModuleStoreTestCase, LoginEnrollmentTest
|
||||
def setUpClass(cls):
|
||||
super(TestInstructorAPIDenyLevels, cls).setUpClass()
|
||||
cls.course = CourseFactory.create()
|
||||
cls.problem_location = msk_from_problem_urlname(
|
||||
cls.course.id,
|
||||
'robot-some-problem-urlname'
|
||||
cls.chapter = ItemFactory.create(
|
||||
parent=cls.course,
|
||||
category='chapter',
|
||||
display_name="Chapter",
|
||||
publish_item=True,
|
||||
start=datetime.datetime(2018, 3, 10, tzinfo=UTC),
|
||||
)
|
||||
cls.problem_urlname = text_type(cls.problem_location)
|
||||
cls.sequential = ItemFactory.create(
|
||||
parent=cls.chapter,
|
||||
category='sequential',
|
||||
display_name="Lesson",
|
||||
publish_item=True,
|
||||
start=datetime.datetime(2018, 3, 10, tzinfo=UTC),
|
||||
metadata={'graded': True, 'format': 'Homework'},
|
||||
)
|
||||
cls.vertical = ItemFactory.create(
|
||||
parent=cls.sequential,
|
||||
category='vertical',
|
||||
display_name='Subsection',
|
||||
publish_item=True,
|
||||
start=datetime.datetime(2018, 3, 10, tzinfo=UTC),
|
||||
)
|
||||
cls.problem = ItemFactory.create(
|
||||
category="problem",
|
||||
parent=cls.vertical,
|
||||
display_name="A Problem Block",
|
||||
weight=1,
|
||||
publish_item=True,
|
||||
)
|
||||
|
||||
cls.problem_urlname = text_type(cls.problem.location)
|
||||
BulkEmailFlag.objects.create(enabled=True, require_course_email_auth=False)
|
||||
|
||||
@classmethod
|
||||
@@ -406,7 +433,7 @@ class TestInstructorAPIDenyLevels(SharedModuleStoreTestCase, LoginEnrollmentTest
|
||||
_module = StudentModule.objects.create(
|
||||
student=self.user,
|
||||
course_id=self.course.id,
|
||||
module_state_key=self.problem_location,
|
||||
module_state_key=self.problem.location,
|
||||
state=json.dumps({'attempts': 10}),
|
||||
)
|
||||
|
||||
@@ -417,8 +444,6 @@ class TestInstructorAPIDenyLevels(SharedModuleStoreTestCase, LoginEnrollmentTest
|
||||
('get_grading_config', {}),
|
||||
('get_students_features', {}),
|
||||
('get_student_progress_url', {'unique_student_identifier': self.user.username}),
|
||||
('reset_student_attempts',
|
||||
{'problem_to_reset': self.problem_urlname, 'unique_student_identifier': self.user.email}),
|
||||
('update_forum_role_membership',
|
||||
{'unique_student_identifier': self.user.email, 'rolename': 'Moderator', 'action': 'allow'}),
|
||||
('list_forum_members', {'rolename': FORUM_ROLE_COMMUNITY_TA}),
|
||||
@@ -435,15 +460,28 @@ class TestInstructorAPIDenyLevels(SharedModuleStoreTestCase, LoginEnrollmentTest
|
||||
('get_proctored_exam_results', {}),
|
||||
('get_problem_responses', {}),
|
||||
('export_ora2_data', {}),
|
||||
|
||||
('rescore_problem',
|
||||
{'problem_to_reset': self.problem_urlname, 'unique_student_identifier': self.user.email}),
|
||||
('override_problem_score',
|
||||
{'problem_to_reset': self.problem_urlname, 'unique_student_identifier': self.user.email, 'score': 0}),
|
||||
('reset_student_attempts',
|
||||
{'problem_to_reset': self.problem_urlname, 'unique_student_identifier': self.user.email}),
|
||||
(
|
||||
'reset_student_attempts',
|
||||
{
|
||||
'problem_to_reset': self.problem_urlname,
|
||||
'unique_student_identifier': self.user.email,
|
||||
'delete_module': True
|
||||
}
|
||||
),
|
||||
]
|
||||
# Endpoints that only Instructors can access
|
||||
self.instructor_level_endpoints = [
|
||||
('bulk_beta_modify_access', {'identifiers': 'foo@example.org', 'action': 'add'}),
|
||||
('modify_access', {'unique_student_identifier': self.user.email, 'rolename': 'beta', 'action': 'allow'}),
|
||||
('list_course_role_members', {'rolename': 'beta'}),
|
||||
('rescore_problem',
|
||||
{'problem_to_reset': self.problem_urlname, 'unique_student_identifier': self.user.email}),
|
||||
('rescore_problem', {'problem_to_reset': self.problem_urlname, 'all_students': True}),
|
||||
('reset_student_attempts', {'problem_to_reset': self.problem_urlname, 'all_students': True}),
|
||||
]
|
||||
|
||||
def _access_endpoint(self, endpoint, args, status_code, msg):
|
||||
@@ -569,10 +607,6 @@ class TestInstructorAPIDenyLevels(SharedModuleStoreTestCase, LoginEnrollmentTest
|
||||
|
||||
for endpoint, args in self.instructor_level_endpoints:
|
||||
expected_status = 200
|
||||
|
||||
# TODO: make this work
|
||||
if endpoint in ['rescore_problem']:
|
||||
continue
|
||||
self._access_endpoint(
|
||||
endpoint,
|
||||
args,
|
||||
|
||||
@@ -1907,13 +1907,16 @@ def reset_student_attempts(request, course_id):
|
||||
course = get_course_with_access(
|
||||
request.user, 'staff', course_id, depth=None
|
||||
)
|
||||
all_students = _get_boolean_param(request, 'all_students')
|
||||
|
||||
if all_students and not has_access(request.user, 'instructor', course):
|
||||
return HttpResponseForbidden("Requires instructor access.")
|
||||
|
||||
problem_to_reset = strip_if_string(request.POST.get('problem_to_reset'))
|
||||
student_identifier = request.POST.get('unique_student_identifier', None)
|
||||
student = None
|
||||
if student_identifier is not None:
|
||||
student = get_student_from_identifier(student_identifier)
|
||||
all_students = _get_boolean_param(request, 'all_students')
|
||||
delete_module = _get_boolean_param(request, 'delete_module')
|
||||
|
||||
# parameter combinations
|
||||
@@ -1926,11 +1929,6 @@ def reset_student_attempts(request, course_id):
|
||||
"all_students and delete_module are mutually exclusive."
|
||||
)
|
||||
|
||||
# instructor authorization
|
||||
if all_students or delete_module:
|
||||
if not has_access(request.user, 'instructor', course):
|
||||
return HttpResponseForbidden("Requires instructor access.")
|
||||
|
||||
try:
|
||||
module_state_key = UsageKey.from_string(problem_to_reset).map_into_course(course_id)
|
||||
except InvalidKeyError:
|
||||
@@ -2044,13 +2042,13 @@ def reset_student_attempts_for_entrance_exam(request, course_id): # pylint: dis
|
||||
@require_POST
|
||||
@ensure_csrf_cookie
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
@require_level('instructor')
|
||||
@require_level('staff')
|
||||
@require_post_params(problem_to_reset="problem urlname to reset")
|
||||
@common_exceptions_400
|
||||
def rescore_problem(request, course_id):
|
||||
"""
|
||||
Starts a background process a students attempts counter. Optionally deletes student state for a problem.
|
||||
Limited to instructor access.
|
||||
Rescore for all students is limited to instructor access.
|
||||
|
||||
Takes either of the following query paremeters
|
||||
- problem_to_reset is a urlname of a problem
|
||||
@@ -2060,15 +2058,19 @@ def rescore_problem(request, course_id):
|
||||
all_students and unique_student_identifier cannot both be present.
|
||||
"""
|
||||
course_id = CourseKey.from_string(course_id)
|
||||
course = get_course_with_access(request.user, 'staff', course_id)
|
||||
all_students = _get_boolean_param(request, 'all_students')
|
||||
|
||||
if all_students and not has_access(request.user, 'instructor', course):
|
||||
return HttpResponseForbidden("Requires instructor access.")
|
||||
|
||||
only_if_higher = _get_boolean_param(request, 'only_if_higher')
|
||||
problem_to_reset = strip_if_string(request.POST.get('problem_to_reset'))
|
||||
student_identifier = request.POST.get('unique_student_identifier', None)
|
||||
student = None
|
||||
if student_identifier is not None:
|
||||
student = get_student_from_identifier(student_identifier)
|
||||
|
||||
all_students = _get_boolean_param(request, 'all_students')
|
||||
only_if_higher = _get_boolean_param(request, 'only_if_higher')
|
||||
|
||||
if not (problem_to_reset and (all_students or student)):
|
||||
return HttpResponseBadRequest("Missing query parameters.")
|
||||
|
||||
@@ -2116,7 +2118,7 @@ def rescore_problem(request, course_id):
|
||||
@require_POST
|
||||
@ensure_csrf_cookie
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
@require_level('instructor')
|
||||
@require_level('staff')
|
||||
@require_post_params(problem_to_reset="problem urlname to reset", score='overriding score')
|
||||
@common_exceptions_400
|
||||
def override_problem_score(request, course_id):
|
||||
@@ -2142,7 +2144,7 @@ def override_problem_score(request, course_id):
|
||||
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)):
|
||||
if not has_access(request.user, "staff", 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
|
||||
|
||||
@@ -80,7 +80,6 @@ ${block_content}
|
||||
<button type="button" class="btn-link staff-debug-reset">${_('Reset Learner\'s Attempts to Zero')}</button>
|
||||
|
|
||||
% endif
|
||||
% if has_instructor_access:
|
||||
<button type="button" class="btn-link staff-debug-sdelete">${_('Delete Learner\'s State')}</button>
|
||||
% if can_rescore_problem:
|
||||
|
|
||||
@@ -92,7 +91,6 @@ ${block_content}
|
||||
% if can_override_problem_score:
|
||||
<button type="button" class="btn-link staff-debug-override-score">${_('Override Score')}</button>
|
||||
% endif
|
||||
% endif
|
||||
]
|
||||
</div>
|
||||
<div id="result_${location.block_id | h}"></div>
|
||||
|
||||
@@ -288,8 +288,8 @@ def sanitize_html_id(html_id):
|
||||
return sanitized_html_id
|
||||
|
||||
|
||||
@contract(user=User, has_instructor_access=bool, block=XBlock, view=basestring, frag=Fragment, context="dict|None")
|
||||
def add_staff_markup(user, has_instructor_access, disable_staff_debug_info, block, view, frag, context): # pylint: disable=unused-argument
|
||||
@contract(user=User, block=XBlock, view=basestring, frag=Fragment, context="dict|None")
|
||||
def add_staff_markup(user, disable_staff_debug_info, block, view, frag, context): # pylint: disable=unused-argument
|
||||
"""
|
||||
Updates the supplied module with a new get_html function that wraps
|
||||
the output of the old get_html function with additional information
|
||||
@@ -383,7 +383,6 @@ def add_staff_markup(user, has_instructor_access, disable_staff_debug_info, bloc
|
||||
'render_histogram': render_histogram,
|
||||
'block_content': frag.content,
|
||||
'is_released': is_released,
|
||||
'has_instructor_access': has_instructor_access,
|
||||
'can_reset_attempts': 'attempts' in block.fields,
|
||||
'can_rescore_problem': hasattr(block, 'rescore'),
|
||||
'can_override_problem_score': isinstance(block, ScorableXBlockMixin),
|
||||
|
||||
Reference in New Issue
Block a user