diff --git a/common/test/acceptance/fixtures/course.py b/common/test/acceptance/fixtures/course.py
index b7c61e818c..d837fbb661 100644
--- a/common/test/acceptance/fixtures/course.py
+++ b/common/test/acceptance/fixtures/course.py
@@ -102,7 +102,7 @@ class CourseFixture(XBlockContainerFixture):
between tests, you should use unique course identifiers for each fixture.
"""
- def __init__(self, org, number, run, display_name, start_date=None, end_date=None):
+ def __init__(self, org, number, run, display_name, start_date=None, end_date=None, settings=None):
"""
Configure the course fixture to create a course with
@@ -112,6 +112,8 @@ class CourseFixture(XBlockContainerFixture):
The default is for the course to have started in the distant past, which is generally what
we want for testing so students can enroll.
+ `settings` can be any additional course settings needs to be enabled. for example
+ to enable entrance exam settings would be a dict like this {"entrance_exam_enabled": "true"}
These have the same meaning as in the Studio restful API /course end-point.
"""
super(CourseFixture, self).__init__()
@@ -134,6 +136,9 @@ class CourseFixture(XBlockContainerFixture):
if end_date is not None:
self._course_details['end_date'] = end_date.isoformat()
+ if settings is not None:
+ self._course_details.update(settings)
+
self._updates = []
self._handouts = []
self._assets = []
diff --git a/common/test/acceptance/pages/lms/instructor_dashboard.py b/common/test/acceptance/pages/lms/instructor_dashboard.py
index b26da7d6f8..11a8a04e75 100644
--- a/common/test/acceptance/pages/lms/instructor_dashboard.py
+++ b/common/test/acceptance/pages/lms/instructor_dashboard.py
@@ -37,6 +37,15 @@ class InstructorDashboardPage(CoursePage):
data_download_section.wait_for_page()
return data_download_section
+ def select_student_admin(self):
+ """
+ Selects the student admin tab and returns the MembershipSection
+ """
+ self.q(css='a[data-section=student_admin]').first.click()
+ student_admin_section = StudentAdminPage(self.browser)
+ student_admin_section.wait_for_page()
+ return student_admin_section
+
@staticmethod
def get_asset_path(file_name):
"""
@@ -460,3 +469,126 @@ class DataDownloadPage(PageObject):
"""
reports = self.q(css="#report-downloads-table .file-download-link>a").map(lambda el: el.text)
return reports.results
+
+
+class StudentAdminPage(PageObject):
+ """
+ Student admin section of the Instructor dashboard.
+ """
+ url = None
+ EE_CONTAINER = ".entrance-exam-grade-container"
+
+ def is_browser_on_page(self):
+ """
+ Confirms student admin section is present
+ """
+ return self.q(css='a[data-section=student_admin].active-section').present
+
+ @property
+ def student_email_input(self):
+ """
+ Returns email address/username input box.
+ """
+ return self.q(css='{} input[name=entrance-exam-student-select-grade]'.format(self.EE_CONTAINER))
+
+ @property
+ def reset_attempts_button(self):
+ """
+ Returns reset student attempts button.
+ """
+ return self.q(css='{} input[name=reset-entrance-exam-attempts]'.format(self.EE_CONTAINER))
+
+ @property
+ def rescore_submission_button(self):
+ """
+ Returns rescore student submission button.
+ """
+ return self.q(css='{} input[name=rescore-entrance-exam]'.format(self.EE_CONTAINER))
+
+ @property
+ def delete_student_state_button(self):
+ """
+ Returns delete student state button.
+ """
+ return self.q(css='{} input[name=delete-entrance-exam-state]'.format(self.EE_CONTAINER))
+
+ @property
+ def background_task_history_button(self):
+ """
+ Returns show background task history for student button.
+ """
+ return self.q(css='{} input[name=entrance-exam-task-history]'.format(self.EE_CONTAINER))
+
+ @property
+ def top_notification(self):
+ """
+ Returns show background task history for student button.
+ """
+ return self.q(css='{} .request-response-error'.format(self.EE_CONTAINER)).first
+
+ def is_student_email_input_visible(self):
+ """
+ Returns True if student email address/username input box is present.
+ """
+ return self.student_email_input.is_present()
+
+ def is_reset_attempts_button_visible(self):
+ """
+ Returns True if reset student attempts button is present.
+ """
+ return self.reset_attempts_button.is_present()
+
+ def is_rescore_submission_button_visible(self):
+ """
+ Returns True if rescore student submission button is present.
+ """
+ return self.rescore_submission_button.is_present()
+
+ def is_delete_student_state_button_visible(self):
+ """
+ Returns True if delete student state for entrance exam button is present.
+ """
+ return self.delete_student_state_button.is_present()
+
+ def is_background_task_history_button_visible(self):
+ """
+ Returns True if show background task history for student button is present.
+ """
+ return self.background_task_history_button.is_present()
+
+ def is_background_task_history_table_visible(self):
+ """
+ Returns True if background task history table is present.
+ """
+ return self.q(css='{} .entrance-exam-task-history-table'.format(self.EE_CONTAINER)).is_present()
+
+ def click_reset_attempts_button(self):
+ """
+ clicks reset student attempts button.
+ """
+ return self.reset_attempts_button.click()
+
+ def click_rescore_submissions_button(self):
+ """
+ clicks rescore submissions button.
+ """
+ return self.rescore_submission_button.click()
+
+ def click_delete_student_state_button(self):
+ """
+ clicks delete student state button.
+ """
+ return self.delete_student_state_button.click()
+
+ def click_task_history_button(self):
+ """
+ clicks background task history button.
+ """
+ return self.background_task_history_button.click()
+
+ def set_student_email(self, email_addres):
+ """
+ Sets given email address as value of student email address/username input box.
+ """
+ input_box = self.student_email_input.first.results[0]
+ input_box.send_keys(email_addres)
diff --git a/common/test/acceptance/tests/helpers.py b/common/test/acceptance/tests/helpers.py
index 093b149f1e..45ae0ec215 100644
--- a/common/test/acceptance/tests/helpers.py
+++ b/common/test/acceptance/tests/helpers.py
@@ -13,6 +13,8 @@ from opaque_keys.edx.locator import CourseLocator
from xmodule.partitions.partitions import UserPartition
from xmodule.partitions.tests.test_partitions import MockUserPartitionScheme
from selenium.webdriver.support.select import Select
+from selenium.webdriver.support.ui import WebDriverWait
+from selenium.webdriver.support import expected_conditions as EC
def skip_if_browser(browser):
@@ -252,6 +254,15 @@ def assert_event_emitted_num_times(event_collection, event_name, event_time, eve
)
+def get_modal_alert(browser):
+ """
+ Returns instance of modal alert box shown in browser after waiting
+ for 4 seconds
+ """
+ WebDriverWait(browser, 4).until(EC.alert_is_present())
+ return browser.switch_to.alert
+
+
class UniqueCourseTest(WebAppTest):
"""
Test that provides a unique course ID.
diff --git a/common/test/acceptance/tests/lms/test_lms_instructor_dashboard.py b/common/test/acceptance/tests/lms/test_lms_instructor_dashboard.py
index a90f486af6..04256db30e 100644
--- a/common/test/acceptance/tests/lms/test_lms_instructor_dashboard.py
+++ b/common/test/acceptance/tests/lms/test_lms_instructor_dashboard.py
@@ -3,7 +3,8 @@
End-to-end tests for the LMS Instructor Dashboard.
"""
-from ..helpers import UniqueCourseTest
+from ..helpers import UniqueCourseTest, get_modal_alert
+from ...pages.common.logout import LogoutPage
from ...pages.lms.auto_auth import AutoAuthPage
from ...pages.lms.instructor_dashboard import InstructorDashboardPage
from ...fixtures.course import CourseFixture
@@ -84,3 +85,166 @@ class AutoEnrollmentWithCSVTest(UniqueCourseTest):
self.auto_enroll_section.upload_non_csv_file()
self.assertTrue(self.auto_enroll_section.is_notification_displayed(section_type=self.auto_enroll_section.NOTIFICATION_ERROR))
self.assertEqual(self.auto_enroll_section.first_notification_message(section_type=self.auto_enroll_section.NOTIFICATION_ERROR), "Make sure that the file you upload is in CSV format with no extraneous characters or rows.")
+
+
+class EntranceExamGradeTest(UniqueCourseTest):
+ """
+ Tests for Entrance exam specific student grading tasks.
+ """
+
+ def setUp(self):
+ super(EntranceExamGradeTest, self).setUp()
+ self.course_info.update({"settings": {"entrance_exam_enabled": "true"}})
+ CourseFixture(**self.course_info).install()
+ self.student_identifier = "johndoe_saee@example.com"
+ # Create the user (automatically logs us in)
+ AutoAuthPage(
+ self.browser,
+ username="johndoe_saee",
+ email=self.student_identifier,
+ course_id=self.course_id,
+ staff=False
+ ).visit()
+
+ LogoutPage(self.browser).visit()
+
+ # login as an instructor
+ AutoAuthPage(self.browser, course_id=self.course_id, staff=True).visit()
+
+ # go to the student admin page on the instructor dashboard
+ instructor_dashboard_page = InstructorDashboardPage(self.browser, self.course_id)
+ instructor_dashboard_page.visit()
+ self.student_admin_section = instructor_dashboard_page.select_student_admin()
+
+ def test_input_text_and_buttons_are_visible(self):
+ """
+ Scenario: On the Student admin tab of the Instructor Dashboard, Student Email input box,
+ Reset Student Attempt, Rescore Student Submission, Delete Student State for entrance exam
+ and Show Background Task History for Student buttons are visible
+ Given that I am on the Student Admin tab on the Instructor Dashboard
+ Then I see Student Email input box, Reset Student Attempt, Rescore Student Submission,
+ Delete Student State for entrance exam and Show Background Task History for Student buttons
+ """
+ self.assertTrue(self.student_admin_section.is_student_email_input_visible())
+ self.assertTrue(self.student_admin_section.is_reset_attempts_button_visible())
+ self.assertTrue(self.student_admin_section.is_rescore_submission_button_visible())
+ self.assertTrue(self.student_admin_section.is_delete_student_state_button_visible())
+ self.assertTrue(self.student_admin_section.is_background_task_history_button_visible())
+
+ def test_clicking_reset_student_attempts_button_without_email_shows_error(self):
+ """
+ Scenario: Clicking on the Reset Student Attempts button without entering student email
+ address or username results in error.
+ Given that I am on the Student Admin tab on the Instructor Dashboard
+ When I click the Reset Student Attempts Button under Entrance Exam Grade
+ Adjustment without enter an email address
+ Then I should be shown an Error Notification
+ And The Notification message should read 'Please enter a student email address or username.'
+ """
+ self.student_admin_section.click_reset_attempts_button()
+ self.assertEqual(
+ 'Please enter a student email address or username.',
+ self.student_admin_section.top_notification.text[0]
+ )
+
+ def test_clicking_reset_student_attempts_button_with_success(self):
+ """
+ Scenario: Clicking on the Reset Student Attempts button with valid student email
+ address or username should result in success prompt.
+ Given that I am on the Student Admin tab on the Instructor Dashboard
+ When I click the Reset Student Attempts Button under Entrance Exam Grade
+ Adjustment after entering a valid student
+ email address or username
+ Then I should be shown an alert with success message
+ """
+ self.student_admin_section.set_student_email(self.student_identifier)
+ self.student_admin_section.click_reset_attempts_button()
+ alert = get_modal_alert(self.student_admin_section.browser)
+ alert.dismiss()
+
+ def test_clicking_reset_student_attempts_button_with_error(self):
+ """
+ Scenario: Clicking on the Reset Student Attempts button with email address or username
+ of a non existing student should result in error message.
+ Given that I am on the Student Admin tab on the Instructor Dashboard
+ When I click the Reset Student Attempts Button under Entrance Exam Grade
+ Adjustment after non existing student email address or username
+ Then I should be shown an error message
+ """
+ self.student_admin_section.set_student_email('non_existing@example.com')
+ self.student_admin_section.click_reset_attempts_button()
+ self.student_admin_section.wait_for_ajax()
+ self.assertGreater(len(self.student_admin_section.top_notification.text[0]), 0)
+
+ def test_clicking_rescore_submission_button_with_success(self):
+ """
+ Scenario: Clicking on the Rescore Student Submission button with valid student email
+ address or username should result in success prompt.
+ Given that I am on the Student Admin tab on the Instructor Dashboard
+ When I click the Rescore Student Submission Button under Entrance Exam Grade
+ Adjustment after entering a valid student email address or username
+ Then I should be shown an alert with success message
+ """
+ self.student_admin_section.set_student_email(self.student_identifier)
+ self.student_admin_section.click_rescore_submissions_button()
+ alert = get_modal_alert(self.student_admin_section.browser)
+ alert.dismiss()
+
+ def test_clicking_rescore_submission_button_with_error(self):
+ """
+ Scenario: Clicking on the Rescore Student Submission button with email address or username
+ of a non existing student should result in error message.
+ Given that I am on the Student Admin tab on the Instructor Dashboard
+ When I click the Rescore Student Submission Button under Entrance Exam Grade
+ Adjustment after non existing student email address or username
+ Then I should be shown an error message
+ """
+ self.student_admin_section.set_student_email('non_existing@example.com')
+ self.student_admin_section.click_rescore_submissions_button()
+ self.student_admin_section.wait_for_ajax()
+ self.assertGreater(len(self.student_admin_section.top_notification.text[0]), 0)
+
+ def test_clicking_delete_student_attempts_button_with_success(self):
+ """
+ Scenario: Clicking on the Delete Student State for entrance exam button
+ with valid student email address or username should result in success prompt.
+ Given that I am on the Student Admin tab on the Instructor Dashboard
+ When I click the Delete Student State for entrance exam Button
+ under Entrance Exam Grade Adjustment after entering a valid student
+ email address or username
+ Then I should be shown an alert with success message
+ """
+ self.student_admin_section.set_student_email(self.student_identifier)
+ self.student_admin_section.click_delete_student_state_button()
+ alert = get_modal_alert(self.student_admin_section.browser)
+ alert.dismiss()
+
+ def test_clicking_delete_student_attempts_button_with_error(self):
+ """
+ Scenario: Clicking on the Delete Student State for entrance exam button
+ with email address or username of a non existing student should result
+ in error message.
+ Given that I am on the Student Admin tab on the Instructor Dashboard
+ When I click the Delete Student State for entrance exam Button
+ under Entrance Exam Grade Adjustment after non existing student
+ email address or username
+ Then I should be shown an error message
+ """
+ self.student_admin_section.set_student_email('non_existing@example.com')
+ self.student_admin_section.click_delete_student_state_button()
+ self.student_admin_section.wait_for_ajax()
+ self.assertGreater(len(self.student_admin_section.top_notification.text[0]), 0)
+
+ def test_clicking_task_history_button_with_success(self):
+ """
+ Scenario: Clicking on the Show Background Task History for Student
+ with valid student email address or username should result in table of tasks.
+ Given that I am on the Student Admin tab on the Instructor Dashboard
+ When I click the Show Background Task History for Student Button
+ under Entrance Exam Grade Adjustment after entering a valid student
+ email address or username
+ Then I should be shown an table listing all background tasks
+ """
+ self.student_admin_section.set_student_email(self.student_identifier)
+ self.student_admin_section.click_task_history_button()
+ self.assertTrue(self.student_admin_section.is_background_task_history_table_visible())
diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py
index 5cd8ec0572..9a4f778c0a 100644
--- a/lms/djangoapps/courseware/courses.py
+++ b/lms/djangoapps/courseware/courses.py
@@ -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
diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py
index 9a2dbbc876..f458a27ac6 100644
--- a/lms/djangoapps/instructor/tests/test_api.py
+++ b/lms/djangoapps/instructor/tests/test_api.py
@@ -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')
diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py
index 5e923bfe76..ec8fb0e1b1 100644
--- a/lms/djangoapps/instructor/views/api.py
+++ b/lms/djangoapps/instructor/views/api.py
@@ -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')
diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py
index 328190cdae..7fcc76923d 100644
--- a/lms/djangoapps/instructor/views/api_urls.py
+++ b/lms/djangoapps/instructor/views/api_urls.py
@@ -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$',
diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py
index 7a10e24395..3fb29cb651 100644
--- a/lms/djangoapps/instructor/views/instructor_dashboard.py
+++ b/lms/djangoapps/instructor/views/instructor_dashboard.py
@@ -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
diff --git a/lms/djangoapps/instructor_task/api.py b/lms/djangoapps/instructor_task/api.py
index 5f18c86342..51aa63c8e6 100644
--- a/lms/djangoapps/instructor_task/api.py
+++ b/lms/djangoapps/instructor_task/api.py
@@ -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.
diff --git a/lms/djangoapps/instructor_task/api_helper.py b/lms/djangoapps/instructor_task/api_helper.py
index 344ef2dc43..441703c851 100644
--- a/lms/djangoapps/instructor_task/api_helper.py
+++ b/lms/djangoapps/instructor_task/api_helper.py
@@ -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.
diff --git a/lms/djangoapps/instructor_task/tasks_helper.py b/lms/djangoapps/instructor_task/tasks_helper.py
index e6b0b62c4d..cf2ae7d142 100644
--- a/lms/djangoapps/instructor_task/tasks_helper.py
+++ b/lms/djangoapps/instructor_task/tasks_helper.py
@@ -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)]):
diff --git a/lms/static/coffee/src/instructor_dashboard/student_admin.coffee b/lms/static/coffee/src/instructor_dashboard/student_admin.coffee
index 3f9e9d9589..c3a7cb4e68 100644
--- a/lms/static/coffee/src/instructor_dashboard/student_admin.coffee
+++ b/lms/static/coffee/src/instructor_dashboard/student_admin.coffee
@@ -22,7 +22,7 @@ find_and_assert = ($root, selector) ->
item
-class StudentAdmin
+class @StudentAdmin
constructor: (@$section) ->
# attach self to html so that instructor_dashboard.coffee can find
# this object to call event handlers like 'onClickTitle'
@@ -41,6 +41,14 @@ class StudentAdmin
@$btn_task_history_single = @$section.find "input[name='task-history-single']"
@$table_task_history_single = @$section.find ".task-history-single-table"
+ # entrance-exam-specific
+ @$field_entrance_exam_student_select_grade = @$section.find "input[name='entrance-exam-student-select-grade']"
+ @$btn_reset_entrance_exam_attempts = @$section.find "input[name='reset-entrance-exam-attempts']"
+ @$btn_delete_entrance_exam_state = @$section.find "input[name='delete-entrance-exam-state']"
+ @$btn_rescore_entrance_exam = @$section.find "input[name='rescore-entrance-exam']"
+ @$btn_entrance_exam_task_history = @$section.find "input[name='entrance-exam-task-history']"
+ @$table_entrance_exam_task_history = @$section.find ".entrance-exam-task-history-table"
+
# course-specific
@$field_problem_select_all = @$section.find "input[name='problem-select-all']"
@$btn_reset_attempts_all = @$section.find "input[name='reset-attempts-all']"
@@ -52,6 +60,7 @@ class StudentAdmin
# response areas
@$request_response_error_progress = find_and_assert @$section, ".student-specific-container .request-response-error"
@$request_response_error_grade = find_and_assert @$section, ".student-grade-container .request-response-error"
+ @$request_response_error_ee = @$section.find ".entrance-exam-grade-container .request-response-error"
@$request_response_error_all = @$section.find ".course-specific-container .request-response-error"
# attach click handlers
@@ -171,6 +180,90 @@ class StudentAdmin
create_task_list_table @$table_task_history_single, data.tasks
error: std_ajax_err => @$request_response_error_grade.text full_error_message
+ # reset entrance exam attempts for student
+ @$btn_reset_entrance_exam_attempts.click =>
+ unique_student_identifier = @$field_entrance_exam_student_select_grade.val()
+ if not unique_student_identifier
+ return @$request_response_error_ee.text gettext("Please enter a student email address or username.")
+ send_data =
+ unique_student_identifier: unique_student_identifier
+ delete_module: false
+
+ $.ajax
+ dataType: 'json'
+ url: @$btn_reset_entrance_exam_attempts.data 'endpoint'
+ data: send_data
+ success: @clear_errors_then ->
+ success_message = gettext("Entrance exam attempts is being reset for student '{student_id}'.")
+ full_success_message = interpolate_text(success_message, {student_id: unique_student_identifier})
+ alert full_success_message
+ error: std_ajax_err =>
+ error_message = gettext("Error resetting entrance exam attempts for student '{student_id}'. Make sure student identifier is correct.")
+ full_error_message = interpolate_text(error_message, {student_id: unique_student_identifier})
+ @$request_response_error_ee.text full_error_message
+
+ # start task to rescore entrance exam for student
+ @$btn_rescore_entrance_exam.click =>
+ unique_student_identifier = @$field_entrance_exam_student_select_grade.val()
+ if not unique_student_identifier
+ return @$request_response_error_ee.text gettext("Please enter a student email address or username.")
+ send_data =
+ unique_student_identifier: unique_student_identifier
+
+ $.ajax
+ dataType: 'json'
+ url: @$btn_rescore_entrance_exam.data 'endpoint'
+ data: send_data
+ success: @clear_errors_then ->
+ success_message = gettext("Started entrance exam rescore task for student '{student_id}'. Click the 'Show Background Task History for Student' button to see the status of the task.")
+ full_success_message = interpolate_text(success_message, {student_id: unique_student_identifier})
+ alert full_success_message
+ error: std_ajax_err =>
+ error_message = gettext("Error starting a task to rescore entrance exam for student '{student_id}'. Make sure that entrance exam has problems in it and student identifier is correct.")
+ full_error_message = interpolate_text(error_message, {student_id: unique_student_identifier})
+ @$request_response_error_ee.text full_error_message
+
+ # delete student state for entrance exam
+ @$btn_delete_entrance_exam_state.click =>
+ unique_student_identifier = @$field_entrance_exam_student_select_grade.val()
+ if not unique_student_identifier
+ return @$request_response_error_ee.text gettext("Please enter a student email address or username.")
+ send_data =
+ unique_student_identifier: unique_student_identifier
+ delete_module: true
+
+ $.ajax
+ dataType: 'json'
+ url: @$btn_delete_entrance_exam_state.data 'endpoint'
+ data: send_data
+ success: @clear_errors_then ->
+ success_message = gettext("Entrance exam state is being deleted for student '{student_id}'.")
+ full_success_message = interpolate_text(success_message, {student_id: unique_student_identifier})
+ alert full_success_message
+ error: std_ajax_err =>
+ error_message = gettext("Error deleting entrance exam state for student '{student_id}'. Make sure student identifier is correct.")
+ full_error_message = interpolate_text(error_message, {student_id: unique_student_identifier})
+ @$request_response_error_ee.text full_error_message
+
+ # list entrance exam task history for student
+ @$btn_entrance_exam_task_history.click =>
+ unique_student_identifier = @$field_entrance_exam_student_select_grade.val()
+ if not unique_student_identifier
+ return @$request_response_error_ee.text gettext("Please enter a student email address or username.")
+ send_data =
+ unique_student_identifier: unique_student_identifier
+
+ $.ajax
+ dataType: 'json'
+ url: @$btn_entrance_exam_task_history.data 'endpoint'
+ data: send_data
+ success: @clear_errors_then (data) =>
+ create_task_list_table @$table_entrance_exam_task_history, data.tasks
+ error: std_ajax_err =>
+ error_message = gettext("Error getting entrance exam task history for student '{student_id}'. Make sure student identifier is correct.")
+ full_error_message = interpolate_text(error_message, {student_id: unique_student_identifier})
+ @$request_response_error_ee.text full_error_message
+
# start task to reset attempts on problem for all students
@$btn_reset_attempts_all.click =>
problem_to_reset = @$field_problem_select_all.val()
@@ -243,6 +336,7 @@ class StudentAdmin
clear_errors_then: (cb) ->
@$request_response_error_progress.empty()
@$request_response_error_grade.empty()
+ @$request_response_error_ee.empty()
@$request_response_error_all.empty()
->
cb?.apply this, arguments
@@ -251,6 +345,7 @@ class StudentAdmin
clear_errors: ->
@$request_response_error_progress.empty()
@$request_response_error_grade.empty()
+ @$request_response_error_ee.empty()
@$request_response_error_all.empty()
# handler for when the section title is clicked.
diff --git a/lms/static/coffee/src/instructor_dashboard/util.coffee b/lms/static/coffee/src/instructor_dashboard/util.coffee
index eb6a81c51c..ae6e6e85a7 100644
--- a/lms/static/coffee/src/instructor_dashboard/util.coffee
+++ b/lms/static/coffee/src/instructor_dashboard/util.coffee
@@ -19,7 +19,7 @@ find_and_assert = ($root, selector) ->
#
# wraps a `handler` function so that first
# it prints basic error information to the console.
-std_ajax_err = (handler) -> (jqXHR, textStatus, errorThrown) ->
+@std_ajax_err = (handler) -> (jqXHR, textStatus, errorThrown) ->
console.warn """ajax error
textStatus: #{textStatus}
errorThrown: #{errorThrown}"""
@@ -29,7 +29,7 @@ std_ajax_err = (handler) -> (jqXHR, textStatus, errorThrown) ->
# render a task list table to the DOM
# `$table_tasks` the $element in which to put the table
# `tasks_data`
-create_task_list_table = ($table_tasks, tasks_data) ->
+@create_task_list_table = ($table_tasks, tasks_data) ->
$table_tasks.empty()
options =
@@ -264,7 +264,7 @@ class IntervalManager
@intervalID = null
-class PendingInstructorTasks
+class @PendingInstructorTasks
### Pending Instructor Tasks Section ####
constructor: (@$section) ->
# Currently running tasks
diff --git a/lms/static/js/fixtures/instructor_dashboard/student_admin.html b/lms/static/js/fixtures/instructor_dashboard/student_admin.html
new file mode 100644
index 0000000000..f6e02f61b8
--- /dev/null
+++ b/lms/static/js/fixtures/instructor_dashboard/student_admin.html
@@ -0,0 +1,150 @@
+
+ Click here to view the gradebook for enrolled students. This feature is only visible to courses with a small number of total enrolled students.
+
+ View Gradebook
+
+ Click this link to view the student's progress page:
+
+ Student Progress Page
+
+
+ You must provide the complete location of the problem. In the Staff Debug viewer, the location looks like this:
+ Next, select an action to perform for the given user and problem:
+
+
+
+
+
+
+
+ Rescoring runs in the background, and status for active tasks will appear in the 'Pending Instructor Tasks' table. To see status for all tasks submitted for this problem and student, click on this button:
+
+ Select an action for the student's entrance exam. This action will affect every problem in the student's exam.
+
+
+
+ Rescoring runs in the background, and status for active tasks will appear in the 'Pending Instructor Tasks' table. To see status for all tasks submitted for this problem and student, click on this button:
+ You must provide the complete location of the problem. In the Staff Debug viewer, the location looks like this:
+ Then select an action:
+
+
+
+
+ The above actions run in the background, and status for active tasks will appear in a table on the Course Info tab. To see status for all tasks submitted for this problem, click on this button:
+ The status for any active tasks appears in a table below. Student Gradebook
+
+
+Student-specific grade inspection
+
+
+
+
+
+
+Student-specific grade adjustment
+
+
+
+
+
+
+ i4x://edX/Open_DemoX/problem/78c98390884243b89f6023745231c525
+Entrance Exam Adjustment
+
+
+
+Course-specific grade adjustment
+
+
+
+
+ i4x://edX/Open_DemoX/problem/78c98390884243b89f6023745231c525
+ Pending Instructor Tasks
+
+
+
+
+
+
${_("Specify a problem in the course here with its complete location:")} +
+ ## Translators: A location (string of text) follows this sentence.${_("You must provide the complete location of the problem. In the Staff Debug viewer, the location looks like this:")}
- i4x://edX/Open_DemoX/problem/78c98390884243b89f6023745231c525
i4x://edX/Open_DemoX/problem/78c98390884243b89f6023745231c525
${_("Next, select an action to perform for the given user and problem:")} @@ -67,47 +70,88 @@
%if section_data['access']['instructor']: -
${_('You may also delete the entire state of a student for the specified problem:')}
- + %endif %if settings.FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS') and section_data['access']['instructor']: -+
${_("Rescoring runs in the background, and status for active tasks will appear in the 'Pending Instructor Tasks' table. " "To see status for all tasks submitted for this problem and student, click on this button:")}
- + %endif+ ${_("Select an action for the student's entrance exam. This action will affect every problem in the student's exam.")} +
+ + + + %if settings.FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS') and section_data['access']['instructor']: + + %endif + ++ %if section_data['access']['instructor']: + + %endif +
+ + + %if settings.FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS') and section_data['access']['instructor']: ++ ${_("Rescoring runs in the background, and status for active tasks will appear in the 'Pending Instructor Tasks' table. " + "To see status for all tasks submitted for this problem and student, click on this button:")} +
+ + + + %endif ++
+ + ## Translators: A location (string of text) follows this sentence. -${_("You must provide the complete location of the problem. In the Staff Debug viewer, the location looks like this:")}
- i4x://edX/Open_DemoX/problem/78c98390884243b89f6023745231c525
${_("You must provide the complete location of the problem. In the Staff Debug viewer, the location looks like this:")}
+ i4x://edX/Open_DemoX/problem/78c98390884243b89f6023745231c525
${_("Then select an action")}:
-
+
${_("The above actions run in the background, and status for active tasks will appear in a table on the Course Info tab. " "To see status for all tasks submitted for this problem, click on this button")}:
- +