Instructor Dashboard management tools for Entrance Exams
Conflicts: lms/djangoapps/courseware/courses.py lms/djangoapps/instructor_task/api.py refined entrance exam student attempts reset added rescore, delete state and task history functionality
This commit is contained in:
@@ -9,7 +9,7 @@ from django.conf import settings
|
||||
|
||||
from edxmako.shortcuts import render_to_string
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from opaque_keys.edx.keys import CourseKey, UsageKey
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
@@ -415,3 +415,29 @@ def get_studio_url(course, page):
|
||||
if is_studio_course and is_mongo_course:
|
||||
studio_link = get_cms_course_link(course, page)
|
||||
return studio_link
|
||||
|
||||
|
||||
def get_problems_in_section(section):
|
||||
"""
|
||||
This returns a dict having problems in a section.
|
||||
Returning dict has problem location as keys and problem
|
||||
descriptor as values.
|
||||
"""
|
||||
|
||||
problem_descriptors = defaultdict()
|
||||
if not isinstance(section, UsageKey):
|
||||
section_key = UsageKey.from_string(section)
|
||||
else:
|
||||
section_key = section
|
||||
# it will be a Mongo performance boost, if you pass in a depth=3 argument here
|
||||
# as it will optimize round trips to the database to fetch all children for the current node
|
||||
section_descriptor = modulestore().get_item(section_key, depth=3)
|
||||
|
||||
# iterate over section, sub-section, vertical
|
||||
for subsection in section_descriptor.get_children():
|
||||
for vertical in subsection.get_children():
|
||||
for component in vertical.get_children():
|
||||
if component.location.category == 'problem' and getattr(component, 'has_score', False):
|
||||
problem_descriptors[unicode(component.location)] = component
|
||||
|
||||
return problem_descriptors
|
||||
|
||||
@@ -45,7 +45,7 @@ from student.models import (
|
||||
CourseEnrollment, CourseEnrollmentAllowed, NonExistentCourseError
|
||||
)
|
||||
from student.tests.factories import UserFactory, CourseModeFactory
|
||||
from student.roles import CourseBetaTesterRole, CourseSalesAdminRole, CourseFinanceAdminRole
|
||||
from student.roles import CourseBetaTesterRole, CourseSalesAdminRole, CourseFinanceAdminRole, CourseInstructorRole
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
@@ -2180,6 +2180,7 @@ class TestInstructorAPIRegradeTask(ModuleStoreTestCase, LoginEnrollmentTestCase)
|
||||
self.course.id,
|
||||
'robot-some-problem-urlname'
|
||||
)
|
||||
|
||||
self.problem_urlname = self.problem_location.to_deprecated_string()
|
||||
|
||||
self.module_to_reset = StudentModule.objects.create(
|
||||
@@ -2297,7 +2298,251 @@ class TestInstructorAPIRegradeTask(ModuleStoreTestCase, LoginEnrollmentTestCase)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(act.called)
|
||||
|
||||
@patch.dict(settings.FEATURES, {'ENTRANCE_EXAMS': True})
|
||||
def test_course_has_entrance_exam_in_student_attempts_reset(self):
|
||||
""" Test course has entrance exam id set while resetting attempts"""
|
||||
url = reverse('reset_student_attempts_for_entrance_exam',
|
||||
kwargs={'course_id': unicode(self.course.id)})
|
||||
response = self.client.get(url, {
|
||||
'all_students': True,
|
||||
'delete_module': False,
|
||||
})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
@patch.dict(settings.FEATURES, {'ENTRANCE_EXAMS': True})
|
||||
def test_rescore_entrance_exam_with_invalid_exam(self):
|
||||
""" Test course has entrance exam id set while re-scoring. """
|
||||
url = reverse('rescore_entrance_exam', kwargs={'course_id': unicode(self.course.id)})
|
||||
response = self.client.get(url, {
|
||||
'unique_student_identifier': self.student.email,
|
||||
})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MOCK_MODULESTORE)
|
||||
@patch.dict(settings.FEATURES, {'ENTRANCE_EXAMS': True})
|
||||
class TestEntranceExamInstructorAPIRegradeTask(ModuleStoreTestCase, LoginEnrollmentTestCase):
|
||||
"""
|
||||
Test endpoints whereby instructors can rescore student grades,
|
||||
reset student attempts and delete state for entrance exam.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(TestEntranceExamInstructorAPIRegradeTask, self).setUp()
|
||||
self.course = CourseFactory.create(
|
||||
org='test_org',
|
||||
course='test_course',
|
||||
run='test_run',
|
||||
entrance_exam_id='i4x://{}/{}/chapter/Entrance_exam'.format('test_org', 'test_course')
|
||||
)
|
||||
self.course_with_invalid_ee = CourseFactory.create(entrance_exam_id='invalid_exam')
|
||||
|
||||
self.instructor = InstructorFactory(course_key=self.course.id)
|
||||
# Add instructor to invalid ee course
|
||||
CourseInstructorRole(self.course_with_invalid_ee.id).add_users(self.instructor)
|
||||
self.client.login(username=self.instructor.username, password='test')
|
||||
|
||||
self.student = UserFactory()
|
||||
CourseEnrollment.enroll(self.student, self.course.id)
|
||||
|
||||
self.entrance_exam = ItemFactory.create(
|
||||
parent=self.course,
|
||||
category='chapter',
|
||||
display_name='Entrance exam'
|
||||
)
|
||||
subsection = ItemFactory.create(
|
||||
parent=self.entrance_exam,
|
||||
category='sequential',
|
||||
display_name='Subsection 1'
|
||||
)
|
||||
vertical = ItemFactory.create(
|
||||
parent=subsection,
|
||||
category='vertical',
|
||||
display_name='Vertical 1'
|
||||
)
|
||||
self.ee_problem_1 = ItemFactory.create(
|
||||
parent=vertical,
|
||||
category="problem",
|
||||
display_name="Exam Problem - Problem 1"
|
||||
)
|
||||
self.ee_problem_2 = ItemFactory.create(
|
||||
parent=vertical,
|
||||
category="problem",
|
||||
display_name="Exam Problem - Problem 2"
|
||||
)
|
||||
|
||||
ee_module_to_reset1 = StudentModule.objects.create(
|
||||
student=self.student,
|
||||
course_id=self.course.id,
|
||||
module_state_key=self.ee_problem_1.location,
|
||||
state=json.dumps({'attempts': 10}),
|
||||
)
|
||||
ee_module_to_reset2 = StudentModule.objects.create(
|
||||
student=self.student,
|
||||
course_id=self.course.id,
|
||||
module_state_key=self.ee_problem_2.location,
|
||||
state=json.dumps({'attempts': 10}),
|
||||
)
|
||||
self.ee_modules = [ee_module_to_reset1.module_state_key, ee_module_to_reset2.module_state_key]
|
||||
|
||||
def test_reset_entrance_exam_student_attempts_deletall(self):
|
||||
""" Make sure no one can delete all students state on entrance exam. """
|
||||
url = reverse('reset_student_attempts_for_entrance_exam',
|
||||
kwargs={'course_id': unicode(self.course.id)})
|
||||
response = self.client.get(url, {
|
||||
'all_students': True,
|
||||
'delete_module': True,
|
||||
})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_reset_entrance_exam_student_attempts_single(self):
|
||||
""" Test reset single student attempts for entrance exam. """
|
||||
url = reverse('reset_student_attempts_for_entrance_exam',
|
||||
kwargs={'course_id': unicode(self.course.id)})
|
||||
response = self.client.get(url, {
|
||||
'unique_student_identifier': self.student.email,
|
||||
})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# make sure problem attempts have been reset.
|
||||
changed_modules = StudentModule.objects.filter(module_state_key__in=self.ee_modules)
|
||||
for changed_module in changed_modules:
|
||||
self.assertEqual(
|
||||
json.loads(changed_module.state)['attempts'],
|
||||
0
|
||||
)
|
||||
|
||||
# mock out the function which should be called to execute the action.
|
||||
@patch.object(instructor_task.api, 'submit_reset_problem_attempts_in_entrance_exam')
|
||||
def test_reset_entrance_exam_all_student_attempts(self, act):
|
||||
""" Test reset all student attempts for entrance exam. """
|
||||
url = reverse('reset_student_attempts_for_entrance_exam',
|
||||
kwargs={'course_id': unicode(self.course.id)})
|
||||
response = self.client.get(url, {
|
||||
'all_students': True,
|
||||
})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(act.called)
|
||||
|
||||
def test_reset_student_attempts_invalid_entrance_exam(self):
|
||||
""" Test reset for invalid entrance exam. """
|
||||
url = reverse('reset_student_attempts_for_entrance_exam',
|
||||
kwargs={'course_id': unicode(self.course_with_invalid_ee.id)})
|
||||
response = self.client.get(url, {
|
||||
'unique_student_identifier': self.student.email,
|
||||
})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_entrance_exam_sttudent_delete_state(self):
|
||||
""" Test delete single student entrance exam state. """
|
||||
url = reverse('reset_student_attempts_for_entrance_exam',
|
||||
kwargs={'course_id': unicode(self.course.id)})
|
||||
response = self.client.get(url, {
|
||||
'unique_student_identifier': self.student.email,
|
||||
'delete_module': True,
|
||||
})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# make sure the module has been deleted
|
||||
changed_modules = StudentModule.objects.filter(module_state_key__in=self.ee_modules)
|
||||
self.assertEqual(changed_modules.count(), 0)
|
||||
|
||||
def test_entrance_exam_delete_state_with_staff(self):
|
||||
""" Test entrance exam delete state failure with staff access. """
|
||||
self.client.logout()
|
||||
staff_user = StaffFactory(course_key=self.course.id)
|
||||
self.client.login(username=staff_user.username, password='test')
|
||||
url = reverse('reset_student_attempts_for_entrance_exam',
|
||||
kwargs={'course_id': unicode(self.course.id)})
|
||||
response = self.client.get(url, {
|
||||
'unique_student_identifier': self.student.email,
|
||||
'delete_module': True,
|
||||
})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_entrance_exam_reset_student_attempts_nonsense(self):
|
||||
""" Test failure with both unique_student_identifier and all_students. """
|
||||
url = reverse('reset_student_attempts_for_entrance_exam',
|
||||
kwargs={'course_id': unicode(self.course.id)})
|
||||
response = self.client.get(url, {
|
||||
'unique_student_identifier': self.student.email,
|
||||
'all_students': True,
|
||||
})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
@patch.object(instructor_task.api, 'submit_rescore_entrance_exam_for_student')
|
||||
def test_rescore_entrance_exam_single_student(self, act):
|
||||
""" Test re-scoring of entrance exam for single student. """
|
||||
url = reverse('rescore_entrance_exam', kwargs={'course_id': unicode(self.course.id)})
|
||||
response = self.client.get(url, {
|
||||
'unique_student_identifier': self.student.email,
|
||||
})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(act.called)
|
||||
|
||||
def test_rescore_entrance_exam_all_student(self):
|
||||
""" Test rescoring for all students. """
|
||||
url = reverse('rescore_entrance_exam', kwargs={'course_id': unicode(self.course.id)})
|
||||
response = self.client.get(url, {
|
||||
'all_students': True,
|
||||
})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_rescore_entrance_exam_all_student_and_single(self):
|
||||
""" Test re-scoring with both all students and single student parameters. """
|
||||
url = reverse('rescore_entrance_exam', kwargs={'course_id': unicode(self.course.id)})
|
||||
response = self.client.get(url, {
|
||||
'unique_student_identifier': self.student.email,
|
||||
'all_students': True,
|
||||
})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_rescore_entrance_exam_with_invalid_exam(self):
|
||||
""" Test re-scoring of entrance exam with invalid exam. """
|
||||
url = reverse('rescore_entrance_exam', kwargs={'course_id': unicode(self.course_with_invalid_ee.id)})
|
||||
response = self.client.get(url, {
|
||||
'unique_student_identifier': self.student.email,
|
||||
})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_list_entrance_exam_instructor_tasks_student(self):
|
||||
""" Test list task history for entrance exam AND student. """
|
||||
# create a re-score entrance exam task
|
||||
url = reverse('rescore_entrance_exam', kwargs={'course_id': unicode(self.course.id)})
|
||||
response = self.client.get(url, {
|
||||
'unique_student_identifier': self.student.email,
|
||||
})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
url = reverse('list_entrance_exam_instructor_tasks', kwargs={'course_id': unicode(self.course.id)})
|
||||
response = self.client.get(url, {
|
||||
'unique_student_identifier': self.student.email,
|
||||
})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# check response
|
||||
tasks = json.loads(response.content)['tasks']
|
||||
self.assertEqual(len(tasks), 1)
|
||||
|
||||
def test_list_entrance_exam_instructor_tasks_all_student(self):
|
||||
""" Test list task history for entrance exam AND all student. """
|
||||
url = reverse('list_entrance_exam_instructor_tasks', kwargs={'course_id': unicode(self.course.id)})
|
||||
response = self.client.get(url, {})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# check response
|
||||
tasks = json.loads(response.content)['tasks']
|
||||
self.assertEqual(len(tasks), 0)
|
||||
|
||||
def test_list_entrance_exam_instructor_with_invalid_exam_key(self):
|
||||
""" Test list task history for entrance exam failure if course has invalid exam. """
|
||||
url = reverse('list_entrance_exam_instructor_tasks',
|
||||
kwargs={'course_id': unicode(self.course_with_invalid_ee.id)})
|
||||
response = self.client.get(url, {
|
||||
'unique_student_identifier': self.student.email,
|
||||
})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MOCK_MODULESTORE)
|
||||
@patch('bulk_email.models.html_to_text', Mock(return_value='Mocking CourseEmail.text_message'))
|
||||
@patch.dict(settings.FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': False})
|
||||
class TestInstructorSendEmail(ModuleStoreTestCase, LoginEnrollmentTestCase):
|
||||
@@ -2432,8 +2677,9 @@ class TestInstructorAPITaskLists(ModuleStoreTestCase, LoginEnrollmentTestCase):
|
||||
|
||||
def setUp(self):
|
||||
super(TestInstructorAPITaskLists, self).setUp()
|
||||
|
||||
self.course = CourseFactory.create()
|
||||
self.course = CourseFactory.create(
|
||||
entrance_exam_id='i4x://{}/{}/chapter/Entrance_exam'.format('test_org', 'test_course')
|
||||
)
|
||||
self.instructor = InstructorFactory(course_key=self.course.id)
|
||||
self.client.login(username=self.instructor.username, password='test')
|
||||
|
||||
|
||||
@@ -1605,6 +1605,71 @@ def reset_student_attempts(request, course_id):
|
||||
return JsonResponse(response_payload)
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
@require_level('staff')
|
||||
@common_exceptions_400
|
||||
def reset_student_attempts_for_entrance_exam(request, course_id): # pylint: disable=invalid-name
|
||||
"""
|
||||
|
||||
Resets a students attempts counter or starts a task to reset all students
|
||||
attempts counters for entrance exam. Optionally deletes student state for
|
||||
entrance exam. Limited to staff access. Some sub-methods limited to instructor access.
|
||||
|
||||
Following are possible query parameters
|
||||
- unique_student_identifier is an email or username
|
||||
- all_students is a boolean
|
||||
requires instructor access
|
||||
mutually exclusive with delete_module
|
||||
- delete_module is a boolean
|
||||
requires instructor access
|
||||
mutually exclusive with all_students
|
||||
"""
|
||||
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
|
||||
course = get_course_with_access(
|
||||
request.user, 'staff', course_id, depth=None
|
||||
)
|
||||
|
||||
if not course.entrance_exam_id:
|
||||
return HttpResponseBadRequest(
|
||||
_("Course has no entrance exam section.")
|
||||
)
|
||||
|
||||
student_identifier = request.GET.get('unique_student_identifier', None)
|
||||
student = None
|
||||
if student_identifier is not None:
|
||||
student = get_student_from_identifier(student_identifier)
|
||||
all_students = request.GET.get('all_students', False) in ['true', 'True', True]
|
||||
delete_module = request.GET.get('delete_module', False) in ['true', 'True', True]
|
||||
|
||||
# parameter combinations
|
||||
if all_students and student:
|
||||
return HttpResponseBadRequest(
|
||||
_("all_students and unique_student_identifier are mutually exclusive.")
|
||||
)
|
||||
if all_students and delete_module:
|
||||
return HttpResponseBadRequest(
|
||||
_("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:
|
||||
entrance_exam_key = course_id.make_usage_key_from_deprecated_string(course.entrance_exam_id)
|
||||
if delete_module:
|
||||
instructor_task.api.submit_delete_entrance_exam_state_for_student(request, entrance_exam_key, student)
|
||||
else:
|
||||
instructor_task.api.submit_reset_problem_attempts_in_entrance_exam(request, entrance_exam_key, student)
|
||||
except InvalidKeyError:
|
||||
return HttpResponseBadRequest(_("Course has no valid entrance exam section."))
|
||||
|
||||
response_payload = {'student': student_identifier or _('All Students'), 'task': 'created'}
|
||||
return JsonResponse(response_payload)
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
@require_level('instructor')
|
||||
@@ -1660,6 +1725,58 @@ def rescore_problem(request, course_id):
|
||||
return JsonResponse(response_payload)
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
@require_level('instructor')
|
||||
@common_exceptions_400
|
||||
def rescore_entrance_exam(request, course_id):
|
||||
"""
|
||||
Starts a background process a students attempts counter for entrance exam.
|
||||
Optionally deletes student state for a problem. Limited to instructor access.
|
||||
|
||||
Takes either of the following query parameters
|
||||
- unique_student_identifier is an email or username
|
||||
- all_students is a boolean
|
||||
|
||||
all_students and unique_student_identifier cannot both be present.
|
||||
"""
|
||||
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
|
||||
course = get_course_with_access(
|
||||
request.user, 'staff', course_id, depth=None
|
||||
)
|
||||
|
||||
student_identifier = request.GET.get('unique_student_identifier', None)
|
||||
student = None
|
||||
if student_identifier is not None:
|
||||
student = get_student_from_identifier(student_identifier)
|
||||
|
||||
all_students = request.GET.get('all_students') in ['true', 'True', True]
|
||||
|
||||
if not course.entrance_exam_id:
|
||||
return HttpResponseBadRequest(
|
||||
_("Course has no entrance exam section.")
|
||||
)
|
||||
|
||||
if all_students and student:
|
||||
return HttpResponseBadRequest(
|
||||
_("Cannot rescore with all_students and unique_student_identifier.")
|
||||
)
|
||||
|
||||
try:
|
||||
entrance_exam_key = course_id.make_usage_key_from_deprecated_string(course.entrance_exam_id)
|
||||
except InvalidKeyError:
|
||||
return HttpResponseBadRequest(_("Course has no valid entrance exam section."))
|
||||
|
||||
response_payload = {}
|
||||
if student:
|
||||
response_payload['student'] = student_identifier
|
||||
else:
|
||||
response_payload['student'] = _("All Students")
|
||||
instructor_task.api.submit_rescore_entrance_exam_for_student(request, entrance_exam_key, student)
|
||||
response_payload['task'] = 'created'
|
||||
return JsonResponse(response_payload)
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
@require_level('staff')
|
||||
@@ -1741,6 +1858,40 @@ def list_instructor_tasks(request, course_id):
|
||||
return JsonResponse(response_payload)
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
@require_level('staff')
|
||||
def list_entrance_exam_instructor_tasks(request, course_id): # pylint: disable=invalid-name
|
||||
"""
|
||||
List entrance exam related instructor tasks.
|
||||
|
||||
Takes either of the following query parameters
|
||||
- unique_student_identifier is an email or username
|
||||
- all_students is a boolean
|
||||
"""
|
||||
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
|
||||
course = get_course_by_id(course_id)
|
||||
student = request.GET.get('unique_student_identifier', None)
|
||||
if student is not None:
|
||||
student = get_student_from_identifier(student)
|
||||
|
||||
try:
|
||||
entrance_exam_key = course_id.make_usage_key_from_deprecated_string(course.entrance_exam_id)
|
||||
except InvalidKeyError:
|
||||
return HttpResponseBadRequest(_("Course has no valid entrance exam section."))
|
||||
if student:
|
||||
# Specifying for a single student's entrance exam history
|
||||
tasks = instructor_task.api.get_entrance_exam_instructor_task_history(course_id, entrance_exam_key, student)
|
||||
else:
|
||||
# Specifying for all student's entrance exam history
|
||||
tasks = instructor_task.api.get_entrance_exam_instructor_task_history(course_id, entrance_exam_key)
|
||||
|
||||
response_payload = {
|
||||
'tasks': map(extract_task_features, tasks),
|
||||
}
|
||||
return JsonResponse(response_payload)
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
@require_level('staff')
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# pylint: disable=bad-continuation
|
||||
"""
|
||||
Instructor API endpoint urls.
|
||||
"""
|
||||
@@ -35,8 +36,16 @@ urlpatterns = patterns('', # nopep8
|
||||
'instructor.views.api.get_student_progress_url', name="get_student_progress_url"),
|
||||
url(r'^reset_student_attempts$',
|
||||
'instructor.views.api.reset_student_attempts', name="reset_student_attempts"),
|
||||
url(r'^rescore_problem$',
|
||||
'instructor.views.api.rescore_problem', name="rescore_problem"),
|
||||
url(r'^rescore_problem$', 'instructor.views.api.rescore_problem', name="rescore_problem"),
|
||||
# entrance exam tasks
|
||||
url(r'^reset_student_attempts_for_entrance_exam$',
|
||||
'instructor.views.api.reset_student_attempts_for_entrance_exam',
|
||||
name="reset_student_attempts_for_entrance_exam"),
|
||||
url(r'^rescore_entrance_exam$',
|
||||
'instructor.views.api.rescore_entrance_exam', name="rescore_entrance_exam"),
|
||||
url(r'^list_entrance_exam_instructor_tasks',
|
||||
'instructor.views.api.list_entrance_exam_instructor_tasks', name="list_entrance_exam_instructor_tasks"),
|
||||
|
||||
url(r'^list_instructor_tasks$',
|
||||
'instructor.views.api.list_instructor_tasks', name="list_instructor_tasks"),
|
||||
url(r'^list_background_email_tasks$',
|
||||
|
||||
@@ -304,8 +304,15 @@ def _section_student_admin(course, access):
|
||||
'get_student_progress_url_url': reverse('get_student_progress_url', kwargs={'course_id': unicode(course_key)}),
|
||||
'enrollment_url': reverse('students_update_enrollment', kwargs={'course_id': unicode(course_key)}),
|
||||
'reset_student_attempts_url': reverse('reset_student_attempts', kwargs={'course_id': unicode(course_key)}),
|
||||
'reset_student_attempts_for_entrance_exam_url': reverse(
|
||||
'reset_student_attempts_for_entrance_exam',
|
||||
kwargs={'course_id': unicode(course_key)},
|
||||
),
|
||||
'rescore_problem_url': reverse('rescore_problem', kwargs={'course_id': unicode(course_key)}),
|
||||
'rescore_entrance_exam_url': reverse('rescore_entrance_exam', kwargs={'course_id': unicode(course_key)}),
|
||||
'list_instructor_tasks_url': reverse('list_instructor_tasks', kwargs={'course_id': unicode(course_key)}),
|
||||
'list_entrace_exam_instructor_tasks_url': reverse('list_entrance_exam_instructor_tasks',
|
||||
kwargs={'course_id': unicode(course_key)}),
|
||||
'spoc_gradebook_url': reverse('spoc_gradebook', kwargs={'course_id': unicode(course_key)}),
|
||||
}
|
||||
return section_data
|
||||
|
||||
@@ -23,9 +23,13 @@ from instructor_task.tasks import (
|
||||
cohort_students,
|
||||
)
|
||||
|
||||
from instructor_task.api_helper import (check_arguments_for_rescoring,
|
||||
encode_problem_and_student_input,
|
||||
submit_task)
|
||||
from instructor_task.api_helper import (
|
||||
check_arguments_for_rescoring,
|
||||
encode_problem_and_student_input,
|
||||
encode_entrance_exam_and_student_input,
|
||||
check_entrance_exam_problems_for_rescoring,
|
||||
submit_task,
|
||||
)
|
||||
from bulk_email.models import CourseEmail
|
||||
|
||||
|
||||
@@ -57,6 +61,19 @@ def get_instructor_task_history(course_id, usage_key=None, student=None, task_ty
|
||||
return instructor_tasks.order_by('-id')
|
||||
|
||||
|
||||
def get_entrance_exam_instructor_task_history(course_id, usage_key=None, student=None): # pylint: disable=invalid-name
|
||||
"""
|
||||
Returns a query of InstructorTask objects of historical tasks for a given course,
|
||||
that optionally match an entrance exam and student if present.
|
||||
"""
|
||||
instructor_tasks = InstructorTask.objects.filter(course_id=course_id)
|
||||
if usage_key is not None or student is not None:
|
||||
_, task_key = encode_entrance_exam_and_student_input(usage_key, student)
|
||||
instructor_tasks = instructor_tasks.filter(task_key=task_key)
|
||||
|
||||
return instructor_tasks.order_by('-id')
|
||||
|
||||
|
||||
# Disabling invalid-name because this fn name is longer than 30 chars.
|
||||
def submit_rescore_problem_for_student(request, usage_key, student): # pylint: disable=invalid-name
|
||||
"""
|
||||
@@ -117,6 +134,38 @@ def submit_rescore_problem_for_all_students(request, usage_key): # pylint: disa
|
||||
return submit_task(request, task_type, task_class, usage_key.course_key, task_input, task_key)
|
||||
|
||||
|
||||
def submit_rescore_entrance_exam_for_student(request, usage_key, student=None): # pylint: disable=invalid-name
|
||||
"""
|
||||
Request entrance exam problems to be re-scored as a background task.
|
||||
|
||||
The entrance exam problems will be re-scored for given student or if student
|
||||
is None problems for all students who have accessed the entrance exam.
|
||||
|
||||
Parameters are `usage_key`, which must be a :class:`Location`
|
||||
representing entrance exam section and the `student` as a User object.
|
||||
|
||||
ItemNotFoundError is raised if entrance exam does not exists for given
|
||||
usage_key, AlreadyRunningError is raised if the entrance exam
|
||||
is already being re-scored, or NotImplementedError if the problem doesn't
|
||||
support rescoring.
|
||||
|
||||
This method makes sure the InstructorTask entry is committed.
|
||||
When called from any view that is wrapped by TransactionMiddleware,
|
||||
and thus in a "commit-on-success" transaction, an autocommit buried within here
|
||||
will cause any pending transaction to be committed by a successful
|
||||
save here. Any future database operations will take place in a
|
||||
separate transaction.
|
||||
"""
|
||||
# check problems for rescoring: let exceptions return up to the caller.
|
||||
check_entrance_exam_problems_for_rescoring(usage_key)
|
||||
|
||||
# check to see if task is already running, and reserve it otherwise
|
||||
task_type = 'rescore_problem'
|
||||
task_class = rescore_problem
|
||||
task_input, task_key = encode_entrance_exam_and_student_input(usage_key, student)
|
||||
return submit_task(request, task_type, task_class, usage_key.course_key, task_input, task_key)
|
||||
|
||||
|
||||
def submit_reset_problem_attempts_for_all_students(request, usage_key): # pylint: disable=invalid-name
|
||||
"""
|
||||
Request to have attempts reset for a problem as a background task.
|
||||
@@ -146,6 +195,37 @@ def submit_reset_problem_attempts_for_all_students(request, usage_key): # pylin
|
||||
return submit_task(request, task_type, task_class, usage_key.course_key, task_input, task_key)
|
||||
|
||||
|
||||
def submit_reset_problem_attempts_in_entrance_exam(request, usage_key, student): # pylint: disable=invalid-name
|
||||
"""
|
||||
Request to have attempts reset for a entrance exam as a background task.
|
||||
|
||||
Problem attempts for all problems in entrance exam will be reset
|
||||
for specified student. If student is None problem attempts will be
|
||||
reset for all students.
|
||||
|
||||
Parameters are `usage_key`, which must be a :class:`Location`
|
||||
representing entrance exam section and the `student` as a User object.
|
||||
|
||||
ItemNotFoundError is raised if entrance exam does not exists for given
|
||||
usage_key, AlreadyRunningError is raised if the entrance exam
|
||||
is already being reset.
|
||||
|
||||
This method makes sure the InstructorTask entry is committed.
|
||||
When called from any view that is wrapped by TransactionMiddleware,
|
||||
and thus in a "commit-on-success" transaction, an autocommit buried within here
|
||||
will cause any pending transaction to be committed by a successful
|
||||
save here. Any future database operations will take place in a
|
||||
separate transaction.
|
||||
"""
|
||||
# check arguments: make sure entrance exam(section) exists for given usage_key
|
||||
modulestore().get_item(usage_key)
|
||||
|
||||
task_type = 'reset_problem_attempts'
|
||||
task_class = reset_problem_attempts
|
||||
task_input, task_key = encode_entrance_exam_and_student_input(usage_key, student)
|
||||
return submit_task(request, task_type, task_class, usage_key.course_key, task_input, task_key)
|
||||
|
||||
|
||||
def submit_delete_problem_state_for_all_students(request, usage_key): # pylint: disable=invalid-name
|
||||
"""
|
||||
Request to have state deleted for a problem as a background task.
|
||||
@@ -175,6 +255,36 @@ def submit_delete_problem_state_for_all_students(request, usage_key): # pylint:
|
||||
return submit_task(request, task_type, task_class, usage_key.course_key, task_input, task_key)
|
||||
|
||||
|
||||
def submit_delete_entrance_exam_state_for_student(request, usage_key, student): # pylint: disable=invalid-name
|
||||
"""
|
||||
Requests reset of state for entrance exam as a background task.
|
||||
|
||||
Module state for all problems in entrance exam will be deleted
|
||||
for specified student.
|
||||
|
||||
Parameters are `usage_key`, which must be a :class:`Location`
|
||||
representing entrance exam section and the `student` as a User object.
|
||||
|
||||
ItemNotFoundError is raised if entrance exam does not exists for given
|
||||
usage_key, AlreadyRunningError is raised if the entrance exam
|
||||
is already being reset.
|
||||
|
||||
This method makes sure the InstructorTask entry is committed.
|
||||
When called from any view that is wrapped by TransactionMiddleware,
|
||||
and thus in a "commit-on-success" transaction, an autocommit buried within here
|
||||
will cause any pending transaction to be committed by a successful
|
||||
save here. Any future database operations will take place in a
|
||||
separate transaction.
|
||||
"""
|
||||
# check arguments: make sure entrance exam(section) exists for given usage_key
|
||||
modulestore().get_item(usage_key)
|
||||
|
||||
task_type = 'delete_problem_state'
|
||||
task_class = delete_problem_state
|
||||
task_input, task_key = encode_entrance_exam_and_student_input(usage_key, student)
|
||||
return submit_task(request, task_type, task_class, usage_key.course_key, task_input, task_key)
|
||||
|
||||
|
||||
def submit_bulk_course_email(request, course_key, email_id):
|
||||
"""
|
||||
Request to have bulk email sent as a background task.
|
||||
|
||||
@@ -8,10 +8,13 @@ import hashlib
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from celery.result import AsyncResult
|
||||
from celery.states import READY_STATES, SUCCESS, FAILURE, REVOKED
|
||||
|
||||
from courseware.module_render import get_xqueue_callback_url_prefix
|
||||
from courseware.courses import get_problems_in_section
|
||||
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
@@ -253,6 +256,22 @@ def check_arguments_for_rescoring(usage_key):
|
||||
raise NotImplementedError(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
|
||||
confirm that it supports re-scoring.
|
||||
|
||||
An ItemNotFoundException is raised if the corresponding module
|
||||
descriptor doesn't exist for exam_key. NotImplementedError is raised if
|
||||
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):
|
||||
msg = _("Not all problems in entrance exam support re-scoring.")
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
|
||||
def encode_problem_and_student_input(usage_key, student=None): # pylint: disable=invalid-name
|
||||
"""
|
||||
Encode optional usage_key and optional student into task_key and task_input values.
|
||||
@@ -276,6 +295,28 @@ def encode_problem_and_student_input(usage_key, student=None): # pylint: disabl
|
||||
return task_input, task_key
|
||||
|
||||
|
||||
def encode_entrance_exam_and_student_input(usage_key, student=None): # pylint: disable=invalid-name
|
||||
"""
|
||||
Encode usage_key and optional student into task_key and task_input values.
|
||||
|
||||
Args:
|
||||
usage_key (Location): The usage_key identifying the entrance exam.
|
||||
student (User): the student affected
|
||||
"""
|
||||
assert isinstance(usage_key, UsageKey)
|
||||
if student is not None:
|
||||
task_input = {'entrance_exam_url': unicode(usage_key), 'student': student.username}
|
||||
task_key_stub = "{student}_{entranceexam}".format(student=student.id, entranceexam=unicode(usage_key))
|
||||
else:
|
||||
task_input = {'entrance_exam_url': unicode(usage_key)}
|
||||
task_key_stub = "_{entranceexam}".format(entranceexam=unicode(usage_key))
|
||||
|
||||
# create the key value by using MD5 hash:
|
||||
task_key = hashlib.md5(task_key_stub).hexdigest()
|
||||
|
||||
return task_input, task_key
|
||||
|
||||
|
||||
def submit_task(request, task_type, task_class, course_key, task_input, task_key):
|
||||
"""
|
||||
Helper method to submit a task.
|
||||
|
||||
@@ -22,7 +22,7 @@ from util.file import course_filename_prefix_generator, UniversalNewlineIterator
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.split_test_module import get_split_user_partitions
|
||||
|
||||
from courseware.courses import get_course_by_id
|
||||
from courseware.courses import get_course_by_id, get_problems_in_section
|
||||
from courseware.grades import iterate_grades_for
|
||||
from courseware.models import StudentModule
|
||||
from courseware.model_data import FieldDataCache
|
||||
@@ -33,6 +33,7 @@ from instructor_task.models import ReportStore, InstructorTask, PROGRESS
|
||||
from lms.djangoapps.lms_xblock.runtime import LmsPartitionService
|
||||
from openedx.core.djangoapps.course_groups.cohorts import get_cohort
|
||||
from openedx.core.djangoapps.course_groups.models import CourseUserGroup
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort
|
||||
from student.models import CourseEnrollment
|
||||
|
||||
@@ -296,14 +297,28 @@ def perform_module_state_update(update_fcn, filter_fcn, _entry_id, course_id, ta
|
||||
|
||||
"""
|
||||
start_time = time()
|
||||
usage_key = course_id.make_usage_key_from_deprecated_string(task_input.get('problem_url'))
|
||||
usage_keys = []
|
||||
problem_url = task_input.get('problem_url')
|
||||
entrance_exam_url = task_input.get('entrance_exam_url')
|
||||
student_identifier = task_input.get('student')
|
||||
problems = {}
|
||||
|
||||
# find the problem descriptor:
|
||||
module_descriptor = modulestore().get_item(usage_key)
|
||||
# if problem_url is present make a usage key from it
|
||||
if problem_url:
|
||||
usage_key = course_id.make_usage_key_from_deprecated_string(problem_url)
|
||||
usage_keys.append(usage_key)
|
||||
|
||||
# find the module in question
|
||||
modules_to_update = StudentModule.objects.filter(course_id=course_id, module_state_key=usage_key)
|
||||
# find the problem descriptor:
|
||||
problem_descriptor = modulestore().get_item(usage_key)
|
||||
problems[unicode(usage_key)] = problem_descriptor
|
||||
|
||||
# if entrance_exam is present grab all problems in it
|
||||
if entrance_exam_url:
|
||||
problems = get_problems_in_section(entrance_exam_url)
|
||||
usage_keys = [UsageKey.from_string(location) for location in problems.keys()]
|
||||
|
||||
# find the modules in question
|
||||
modules_to_update = StudentModule.objects.filter(course_id=course_id, module_state_key__in=usage_keys)
|
||||
|
||||
# give the option of updating an individual student. If not specified,
|
||||
# then updates all students who have responded to a problem so far
|
||||
@@ -327,6 +342,7 @@ def perform_module_state_update(update_fcn, filter_fcn, _entry_id, course_id, ta
|
||||
|
||||
for module_to_update in modules_to_update:
|
||||
task_progress.attempted += 1
|
||||
module_descriptor = problems[unicode(module_to_update.module_state_key)]
|
||||
# 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.
|
||||
with dog_stats_api.timer('instructor_tasks.module.time.step', tags=[u'action:{name}'.format(name=action_name)]):
|
||||
|
||||
Reference in New Issue
Block a user