Commit 9856deed by Matt Drayer

Merge pull request #6668 from edx/ziafazal/entrance-exam-management-on-instructor-dashboard-am

Ziafazal/entrance exam management on instructor dashboard
parents 80ae7176 b3f85e1d
...@@ -102,7 +102,7 @@ class CourseFixture(XBlockContainerFixture): ...@@ -102,7 +102,7 @@ class CourseFixture(XBlockContainerFixture):
between tests, you should use unique course identifiers for each fixture. 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 Configure the course fixture to create a course with
...@@ -112,6 +112,8 @@ class CourseFixture(XBlockContainerFixture): ...@@ -112,6 +112,8 @@ class CourseFixture(XBlockContainerFixture):
The default is for the course to have started in the distant past, which is generally what 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. 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. These have the same meaning as in the Studio restful API /course end-point.
""" """
super(CourseFixture, self).__init__() super(CourseFixture, self).__init__()
...@@ -134,6 +136,9 @@ class CourseFixture(XBlockContainerFixture): ...@@ -134,6 +136,9 @@ class CourseFixture(XBlockContainerFixture):
if end_date is not None: if end_date is not None:
self._course_details['end_date'] = end_date.isoformat() self._course_details['end_date'] = end_date.isoformat()
if settings is not None:
self._course_details.update(settings)
self._updates = [] self._updates = []
self._handouts = [] self._handouts = []
self._assets = [] self._assets = []
......
...@@ -37,6 +37,15 @@ class InstructorDashboardPage(CoursePage): ...@@ -37,6 +37,15 @@ class InstructorDashboardPage(CoursePage):
data_download_section.wait_for_page() data_download_section.wait_for_page()
return data_download_section 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 @staticmethod
def get_asset_path(file_name): def get_asset_path(file_name):
""" """
...@@ -460,3 +469,126 @@ class DataDownloadPage(PageObject): ...@@ -460,3 +469,126 @@ class DataDownloadPage(PageObject):
""" """
reports = self.q(css="#report-downloads-table .file-download-link>a").map(lambda el: el.text) reports = self.q(css="#report-downloads-table .file-download-link>a").map(lambda el: el.text)
return reports.results 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)
...@@ -13,6 +13,8 @@ from opaque_keys.edx.locator import CourseLocator ...@@ -13,6 +13,8 @@ from opaque_keys.edx.locator import CourseLocator
from xmodule.partitions.partitions import UserPartition from xmodule.partitions.partitions import UserPartition
from xmodule.partitions.tests.test_partitions import MockUserPartitionScheme from xmodule.partitions.tests.test_partitions import MockUserPartitionScheme
from selenium.webdriver.support.select import Select 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): def skip_if_browser(browser):
...@@ -252,6 +254,15 @@ def assert_event_emitted_num_times(event_collection, event_name, event_time, eve ...@@ -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): class UniqueCourseTest(WebAppTest):
""" """
Test that provides a unique course ID. Test that provides a unique course ID.
......
...@@ -3,7 +3,8 @@ ...@@ -3,7 +3,8 @@
End-to-end tests for the LMS Instructor Dashboard. 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.auto_auth import AutoAuthPage
from ...pages.lms.instructor_dashboard import InstructorDashboardPage from ...pages.lms.instructor_dashboard import InstructorDashboardPage
from ...fixtures.course import CourseFixture from ...fixtures.course import CourseFixture
...@@ -84,3 +85,166 @@ class AutoEnrollmentWithCSVTest(UniqueCourseTest): ...@@ -84,3 +85,166 @@ class AutoEnrollmentWithCSVTest(UniqueCourseTest):
self.auto_enroll_section.upload_non_csv_file() 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.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.") 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())
...@@ -9,7 +9,7 @@ from django.conf import settings ...@@ -9,7 +9,7 @@ from django.conf import settings
from edxmako.shortcuts import render_to_string from edxmako.shortcuts import render_to_string
from xmodule.modulestore import ModuleStoreEnum 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.modulestore.django import modulestore
from xmodule.contentstore.content import StaticContent from xmodule.contentstore.content import StaticContent
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
...@@ -415,3 +415,29 @@ def get_studio_url(course, page): ...@@ -415,3 +415,29 @@ def get_studio_url(course, page):
if is_studio_course and is_mongo_course: if is_studio_course and is_mongo_course:
studio_link = get_cms_course_link(course, page) studio_link = get_cms_course_link(course, page)
return studio_link 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
...@@ -1607,6 +1607,71 @@ def reset_student_attempts(request, course_id): ...@@ -1607,6 +1607,71 @@ def reset_student_attempts(request, course_id):
@ensure_csrf_cookie @ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True) @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') @require_level('instructor')
@require_query_params(problem_to_reset="problem urlname to reset") @require_query_params(problem_to_reset="problem urlname to reset")
@common_exceptions_400 @common_exceptions_400
...@@ -1662,6 +1727,58 @@ def rescore_problem(request, course_id): ...@@ -1662,6 +1727,58 @@ def rescore_problem(request, course_id):
@ensure_csrf_cookie @ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True) @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') @require_level('staff')
def list_background_email_tasks(request, course_id): # pylint: disable=unused-argument def list_background_email_tasks(request, course_id): # pylint: disable=unused-argument
""" """
...@@ -1744,6 +1861,40 @@ def list_instructor_tasks(request, course_id): ...@@ -1744,6 +1861,40 @@ def list_instructor_tasks(request, course_id):
@ensure_csrf_cookie @ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True) @cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff') @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')
def list_report_downloads(_request, course_id): def list_report_downloads(_request, course_id):
""" """
List grade CSV files that are available for download for this course. List grade CSV files that are available for download for this course.
......
# pylint: disable=bad-continuation
""" """
Instructor API endpoint urls. Instructor API endpoint urls.
""" """
...@@ -35,8 +36,16 @@ urlpatterns = patterns('', # nopep8 ...@@ -35,8 +36,16 @@ urlpatterns = patterns('', # nopep8
'instructor.views.api.get_student_progress_url', name="get_student_progress_url"), 'instructor.views.api.get_student_progress_url', name="get_student_progress_url"),
url(r'^reset_student_attempts$', url(r'^reset_student_attempts$',
'instructor.views.api.reset_student_attempts', name="reset_student_attempts"), 'instructor.views.api.reset_student_attempts', name="reset_student_attempts"),
url(r'^rescore_problem$', url(r'^rescore_problem$', 'instructor.views.api.rescore_problem', name="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$', url(r'^list_instructor_tasks$',
'instructor.views.api.list_instructor_tasks', name="list_instructor_tasks"), 'instructor.views.api.list_instructor_tasks', name="list_instructor_tasks"),
url(r'^list_background_email_tasks$', url(r'^list_background_email_tasks$',
......
...@@ -304,8 +304,15 @@ def _section_student_admin(course, access): ...@@ -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)}), '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)}), '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_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_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_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)}), 'spoc_gradebook_url': reverse('spoc_gradebook', kwargs={'course_id': unicode(course_key)}),
} }
return section_data return section_data
......
...@@ -23,9 +23,13 @@ from instructor_task.tasks import ( ...@@ -23,9 +23,13 @@ from instructor_task.tasks import (
cohort_students, cohort_students,
) )
from instructor_task.api_helper import (check_arguments_for_rescoring, from instructor_task.api_helper import (
encode_problem_and_student_input, check_arguments_for_rescoring,
submit_task) 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 from bulk_email.models import CourseEmail
...@@ -57,6 +61,19 @@ def get_instructor_task_history(course_id, usage_key=None, student=None, task_ty ...@@ -57,6 +61,19 @@ def get_instructor_task_history(course_id, usage_key=None, student=None, task_ty
return instructor_tasks.order_by('-id') 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. # 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 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 ...@@ -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) 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 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. 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 ...@@ -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) 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 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. 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: ...@@ -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) 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): def submit_bulk_course_email(request, course_key, email_id):
""" """
Request to have bulk email sent as a background task. Request to have bulk email sent as a background task.
......
...@@ -8,10 +8,13 @@ import hashlib ...@@ -8,10 +8,13 @@ import hashlib
import json import json
import logging import logging
from django.utils.translation import ugettext as _
from celery.result import AsyncResult from celery.result import AsyncResult
from celery.states import READY_STATES, SUCCESS, FAILURE, REVOKED from celery.states import READY_STATES, SUCCESS, FAILURE, REVOKED
from courseware.module_render import get_xqueue_callback_url_prefix 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 xmodule.modulestore.django import modulestore
from opaque_keys.edx.keys import UsageKey from opaque_keys.edx.keys import UsageKey
...@@ -253,6 +256,22 @@ def check_arguments_for_rescoring(usage_key): ...@@ -253,6 +256,22 @@ def check_arguments_for_rescoring(usage_key):
raise NotImplementedError(msg) 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 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. 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 ...@@ -276,6 +295,28 @@ def encode_problem_and_student_input(usage_key, student=None): # pylint: disabl
return task_input, task_key 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): def submit_task(request, task_type, task_class, course_key, task_input, task_key):
""" """
Helper method to submit a task. Helper method to submit a task.
......
...@@ -22,7 +22,7 @@ from util.file import course_filename_prefix_generator, UniversalNewlineIterator ...@@ -22,7 +22,7 @@ from util.file import course_filename_prefix_generator, UniversalNewlineIterator
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.split_test_module import get_split_user_partitions 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.grades import iterate_grades_for
from courseware.models import StudentModule from courseware.models import StudentModule
from courseware.model_data import FieldDataCache from courseware.model_data import FieldDataCache
...@@ -33,6 +33,7 @@ from instructor_task.models import ReportStore, InstructorTask, PROGRESS ...@@ -33,6 +33,7 @@ from instructor_task.models import ReportStore, InstructorTask, PROGRESS
from lms.djangoapps.lms_xblock.runtime import LmsPartitionService from lms.djangoapps.lms_xblock.runtime import LmsPartitionService
from openedx.core.djangoapps.course_groups.cohorts import get_cohort from openedx.core.djangoapps.course_groups.cohorts import get_cohort
from openedx.core.djangoapps.course_groups.models import CourseUserGroup 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 openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort
from student.models import CourseEnrollment from student.models import CourseEnrollment
...@@ -296,14 +297,28 @@ def perform_module_state_update(update_fcn, filter_fcn, _entry_id, course_id, ta ...@@ -296,14 +297,28 @@ def perform_module_state_update(update_fcn, filter_fcn, _entry_id, course_id, ta
""" """
start_time = time() 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') student_identifier = task_input.get('student')
problems = {}
# find the problem descriptor: # if problem_url is present make a usage key from it
module_descriptor = modulestore().get_item(usage_key) 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 # find the problem descriptor:
modules_to_update = StudentModule.objects.filter(course_id=course_id, module_state_key=usage_key) 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, # give the option of updating an individual student. If not specified,
# then updates all students who have responded to a problem so far # 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 ...@@ -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: for module_to_update in modules_to_update:
task_progress.attempted += 1 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 # 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. # 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)]): with dog_stats_api.timer('instructor_tasks.module.time.step', tags=[u'action:{name}'.format(name=action_name)]):
......
...@@ -22,7 +22,7 @@ find_and_assert = ($root, selector) -> ...@@ -22,7 +22,7 @@ find_and_assert = ($root, selector) ->
item item
class StudentAdmin class @StudentAdmin
constructor: (@$section) -> constructor: (@$section) ->
# attach self to html so that instructor_dashboard.coffee can find # attach self to html so that instructor_dashboard.coffee can find
# this object to call event handlers like 'onClickTitle' # this object to call event handlers like 'onClickTitle'
...@@ -41,6 +41,14 @@ class StudentAdmin ...@@ -41,6 +41,14 @@ class StudentAdmin
@$btn_task_history_single = @$section.find "input[name='task-history-single']" @$btn_task_history_single = @$section.find "input[name='task-history-single']"
@$table_task_history_single = @$section.find ".task-history-single-table" @$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 # course-specific
@$field_problem_select_all = @$section.find "input[name='problem-select-all']" @$field_problem_select_all = @$section.find "input[name='problem-select-all']"
@$btn_reset_attempts_all = @$section.find "input[name='reset-attempts-all']" @$btn_reset_attempts_all = @$section.find "input[name='reset-attempts-all']"
...@@ -52,6 +60,7 @@ class StudentAdmin ...@@ -52,6 +60,7 @@ class StudentAdmin
# response areas # response areas
@$request_response_error_progress = find_and_assert @$section, ".student-specific-container .request-response-error" @$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_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" @$request_response_error_all = @$section.find ".course-specific-container .request-response-error"
# attach click handlers # attach click handlers
...@@ -171,6 +180,90 @@ class StudentAdmin ...@@ -171,6 +180,90 @@ class StudentAdmin
create_task_list_table @$table_task_history_single, data.tasks create_task_list_table @$table_task_history_single, data.tasks
error: std_ajax_err => @$request_response_error_grade.text full_error_message 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 # start task to reset attempts on problem for all students
@$btn_reset_attempts_all.click => @$btn_reset_attempts_all.click =>
problem_to_reset = @$field_problem_select_all.val() problem_to_reset = @$field_problem_select_all.val()
...@@ -243,6 +336,7 @@ class StudentAdmin ...@@ -243,6 +336,7 @@ class StudentAdmin
clear_errors_then: (cb) -> clear_errors_then: (cb) ->
@$request_response_error_progress.empty() @$request_response_error_progress.empty()
@$request_response_error_grade.empty() @$request_response_error_grade.empty()
@$request_response_error_ee.empty()
@$request_response_error_all.empty() @$request_response_error_all.empty()
-> ->
cb?.apply this, arguments cb?.apply this, arguments
...@@ -251,6 +345,7 @@ class StudentAdmin ...@@ -251,6 +345,7 @@ class StudentAdmin
clear_errors: -> clear_errors: ->
@$request_response_error_progress.empty() @$request_response_error_progress.empty()
@$request_response_error_grade.empty() @$request_response_error_grade.empty()
@$request_response_error_ee.empty()
@$request_response_error_all.empty() @$request_response_error_all.empty()
# handler for when the section title is clicked. # handler for when the section title is clicked.
......
...@@ -19,7 +19,7 @@ find_and_assert = ($root, selector) -> ...@@ -19,7 +19,7 @@ find_and_assert = ($root, selector) ->
# #
# wraps a `handler` function so that first # wraps a `handler` function so that first
# it prints basic error information to the console. # 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 console.warn """ajax error
textStatus: #{textStatus} textStatus: #{textStatus}
errorThrown: #{errorThrown}""" errorThrown: #{errorThrown}"""
...@@ -29,7 +29,7 @@ std_ajax_err = (handler) -> (jqXHR, textStatus, errorThrown) -> ...@@ -29,7 +29,7 @@ std_ajax_err = (handler) -> (jqXHR, textStatus, errorThrown) ->
# render a task list table to the DOM # render a task list table to the DOM
# `$table_tasks` the $element in which to put the table # `$table_tasks` the $element in which to put the table
# `tasks_data` # `tasks_data`
create_task_list_table = ($table_tasks, tasks_data) -> @create_task_list_table = ($table_tasks, tasks_data) ->
$table_tasks.empty() $table_tasks.empty()
options = options =
...@@ -264,7 +264,7 @@ class IntervalManager ...@@ -264,7 +264,7 @@ class IntervalManager
@intervalID = null @intervalID = null
class PendingInstructorTasks class @PendingInstructorTasks
### Pending Instructor Tasks Section #### ### Pending Instructor Tasks Section ####
constructor: (@$section) -> constructor: (@$section) ->
# Currently running tasks # Currently running tasks
......
...@@ -47,6 +47,7 @@ ...@@ -47,6 +47,7 @@
'youtube': '//www.youtube.com/player_api?noext', 'youtube': '//www.youtube.com/player_api?noext',
'tender': '//api.tenderapp.com/tender_widget', 'tender': '//api.tenderapp.com/tender_widget',
'coffee/src/ajax_prefix': 'xmodule_js/common_static/coffee/src/ajax_prefix', 'coffee/src/ajax_prefix': 'xmodule_js/common_static/coffee/src/ajax_prefix',
'coffee/src/instructor_dashboard/student_admin': 'coffee/src/instructor_dashboard/student_admin',
'xmodule_js/common_static/js/test/add_ajax_prefix': 'xmodule_js/common_static/js/test/add_ajax_prefix', 'xmodule_js/common_static/js/test/add_ajax_prefix': 'xmodule_js/common_static/js/test/add_ajax_prefix',
'xblock/core': 'xmodule_js/common_static/js/xblock/core', 'xblock/core': 'xmodule_js/common_static/js/xblock/core',
'xblock/runtime.v1': 'xmodule_js/common_static/coffee/src/xblock/runtime.v1', 'xblock/runtime.v1': 'xmodule_js/common_static/coffee/src/xblock/runtime.v1',
...@@ -251,7 +252,10 @@ ...@@ -251,7 +252,10 @@
exports: 'AjaxPrefix', exports: 'AjaxPrefix',
deps: ['coffee/src/ajax_prefix'] deps: ['coffee/src/ajax_prefix']
}, },
'coffee/src/instructor_dashboard/student_admin': {
exports: 'coffee/src/instructor_dashboard/student_admin',
deps: ['jquery', 'underscore', 'coffee/src/instructor_dashboard/util', 'string_utils']
},
// LMS class loaded explicitly until they are converted to use RequireJS // LMS class loaded explicitly until they are converted to use RequireJS
'js/student_account/account': { 'js/student_account/account': {
exports: 'js/student_account/account', exports: 'js/student_account/account',
...@@ -539,6 +543,7 @@ ...@@ -539,6 +543,7 @@
'lms/include/js/spec/groups/views/cohorts_spec.js', 'lms/include/js/spec/groups/views/cohorts_spec.js',
'lms/include/js/spec/shoppingcart/shoppingcart_spec.js', 'lms/include/js/spec/shoppingcart/shoppingcart_spec.js',
'lms/include/js/spec/instructor_dashboard/ecommerce_spec.js', 'lms/include/js/spec/instructor_dashboard/ecommerce_spec.js',
'lms/include/js/spec/instructor_dashboard/student_admin_spec.js',
'lms/include/js/spec/student_account/account_spec.js', 'lms/include/js/spec/student_account/account_spec.js',
'lms/include/js/spec/student_account/access_spec.js', 'lms/include/js/spec/student_account/access_spec.js',
'lms/include/js/spec/student_account/login_spec.js', 'lms/include/js/spec/student_account/login_spec.js',
......
...@@ -47,6 +47,7 @@ lib_paths: ...@@ -47,6 +47,7 @@ lib_paths:
- xmodule_js/common_static/coffee/src/jquery.immediateDescendents.js - xmodule_js/common_static/coffee/src/jquery.immediateDescendents.js
- xmodule_js/common_static/js/xblock - xmodule_js/common_static/js/xblock
- xmodule_js/common_static/coffee/src/xblock - xmodule_js/common_static/coffee/src/xblock
- coffee/src/instructor_dashboard
- xmodule_js/common_static/js/vendor/sinon-1.7.1.js - xmodule_js/common_static/js/vendor/sinon-1.7.1.js
- xmodule_js/src/capa/ - xmodule_js/src/capa/
- xmodule_js/src/video/ - xmodule_js/src/video/
......
...@@ -19,10 +19,11 @@ ...@@ -19,10 +19,11 @@
<div class="student-specific-container action-type-container"> <div class="student-specific-container action-type-container">
<h2>${_("Student-specific grade inspection")}</h2> <h2>${_("Student-specific grade inspection")}</h2>
<div class="request-response-error"></div> <div class="request-response-error"></div>
<p> <br />
<label>
${_("Specify the {platform_name} email address or username of a student here:").format(platform_name=settings.PLATFORM_NAME)} ${_("Specify the {platform_name} email address or username of a student here:").format(platform_name=settings.PLATFORM_NAME)}
<input type="text" name="student-select-progress" placeholder="${_("Student Email or Username")}"> <input type="text" name="student-select-progress" placeholder="${_("Student Email or Username")}" >
</p> </label>
<br> <br>
<div class="progress-link-wrapper"> <div class="progress-link-wrapper">
...@@ -40,18 +41,20 @@ ...@@ -40,18 +41,20 @@
<h2>${_("Student-specific grade adjustment")}</h2> <h2>${_("Student-specific grade adjustment")}</h2>
<div class="request-response-error"></div> <div class="request-response-error"></div>
<p> <p>
<label>
${_("Specify the {platform_name} email address or username of a student here:").format(platform_name=settings.PLATFORM_NAME)} ${_("Specify the {platform_name} email address or username of a student here:").format(platform_name=settings.PLATFORM_NAME)}
<input type="text" name="student-select-grade" placeholder="${_("Student Email or Username")}"> <input type="text" name="student-select-grade" placeholder="${_("Student Email or Username")}">
</label>
</p> </p>
<br> <br>
<p> ${_("Specify a problem in the course here with its complete location:")} <label> ${_("Specify a problem in the course here with its complete location:")}
<input type="text" name="problem-select-single" placeholder="${_("Problem location")}"> <input type="text" name="problem-select-single" placeholder="${_("Problem location")}">
</p> </label>
## Translators: A location (string of text) follows this sentence. ## Translators: A location (string of text) follows this sentence.
<p>${_("You must provide the complete location of the problem. In the Staff Debug viewer, the location looks like this:")}<br/> <p>${_("You must provide the complete location of the problem. In the Staff Debug viewer, the location looks like this:")}<br/>
<tt>i4x://edX/Open_DemoX/problem/78c98390884243b89f6023745231c525</tt></p> <code>i4x://edX/Open_DemoX/problem/78c98390884243b89f6023745231c525</code></p>
<p> <p>
${_("Next, select an action to perform for the given user and problem:")} ${_("Next, select an action to perform for the given user and problem:")}
...@@ -67,47 +70,88 @@ ...@@ -67,47 +70,88 @@
<p> <p>
%if section_data['access']['instructor']: %if section_data['access']['instructor']:
<p> ${_('You may also delete the entire state of a student for the specified problem:')} </p> <label> ${_('You may also delete the entire state of a student for the specified problem:')}
<p><input type="button" class="molly-guard" name="delete-state-single" value="${_("Delete Student State for Problem")}" data-endpoint="${ section_data['reset_student_attempts_url'] }"></p> <input type="button" class="molly-guard" name="delete-state-single" value="${_("Delete Student State for Problem")}" data-endpoint="${ section_data['reset_student_attempts_url'] }"></label>
%endif %endif
</p> </p>
%if settings.FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS') and section_data['access']['instructor']: %if settings.FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS') and section_data['access']['instructor']:
<p> <p id="task-history-single-help">
${_("Rescoring runs in the background, and status for active tasks will appear in the 'Pending Instructor Tasks' table. " ${_("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:")} "To see status for all tasks submitted for this problem and student, click on this button:")}
</p> </p>
<p><input type="button" name="task-history-single" value="${_("Show Background Task History for Student")}" data-endpoint="${ section_data['list_instructor_tasks_url'] }"></p> <p><input type="button" name="task-history-single" value="${_("Show Background Task History for Student")}" data-endpoint="${ section_data['list_instructor_tasks_url'] }" aria-describedby="task-history-single-help"></p>
<div class="task-history-single-table"></div> <div class="task-history-single-table"></div>
%endif %endif
<hr> <hr>
</div> </div>
% if course.entrance_exam_enabled:
<div class="entrance-exam-grade-container action-type-container">
<h2>${_("Entrance Exam Adjustment")}</h2>
<div class="request-response-error"></div>
<label>
${_("Student's {platform_name} email address or username:").format(platform_name=settings.PLATFORM_NAME)}
<input type="text" name="entrance-exam-student-select-grade" placeholder="${_('Student Email or Username')}">
</label>
<br>
<p>
${_("Select an action for the student's entrance exam. This action will affect every problem in the student's exam.")}
</p>
<input type="button" name="reset-entrance-exam-attempts" value="${_('Reset Number of Student Attempts')}" data-endpoint="${ section_data['reset_student_attempts_for_entrance_exam_url'] }">
%if settings.FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS') and section_data['access']['instructor']:
<input type="button" name="rescore-entrance-exam" value="${_('Rescore All Problems')}" data-endpoint="${ section_data['rescore_entrance_exam_url'] }">
%endif
<p>
%if section_data['access']['instructor']:
<label> ${_("You can also delete all of the student's answers and scores for the entire entrance exam.")}
<input type="button" class="molly-guard" name="delete-entrance-exam-state" value="${_("Delete Student's Answers and Scores")}" data-endpoint="${ section_data['reset_student_attempts_for_entrance_exam_url'] }"></label>
%endif
</p>
%if settings.FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS') and section_data['access']['instructor']:
<p id="entrance-exam-task-history-help">
${_("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:")}
</p>
<p><input type="button" name="entrance-exam-task-history" value="${_("Show Student's Exam Adjustment History")}" data-endpoint="${ section_data['list_entrace_exam_instructor_tasks_url'] }" aria-describedby="entrance-exam-task-history-help"></p>
<div class="entrance-exam-task-history-table"></div>
%endif
<hr>
</div>
%endif
%if settings.FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS') and section_data['access']['instructor']: %if settings.FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS') and section_data['access']['instructor']:
<div class="course-specific-container action-type-container"> <div class="course-specific-container action-type-container">
<h2>${_('Course-specific grade adjustment')}</h2> <h2>${_('Course-specific grade adjustment')}</h2>
<div class="request-response-error"></div> <div class="request-response-error"></div>
<p> <label>
${_("Specify a problem in the course here with its complete location:")} ${_("Specify a problem in the course here with its complete location:")}
<input type="text" name="problem-select-all" size="60" placeholder="${_("Problem location")}"> <input type="text" name="problem-select-all" size="60" placeholder="${_("Problem location")}" aria-describedby="problem-select-all-help">
</p> </label>
## Translators: A location (string of text) follows this sentence. ## Translators: A location (string of text) follows this sentence.
<p>${_("You must provide the complete location of the problem. In the Staff Debug viewer, the location looks like this:")}<br/> <p id="problem-select-all-help">${_("You must provide the complete location of the problem. In the Staff Debug viewer, the location looks like this:")}<br/>
<tt>i4x://edX/Open_DemoX/problem/78c98390884243b89f6023745231c525</tt></p> <code>i4x://edX/Open_DemoX/problem/78c98390884243b89f6023745231c525</code></p>
<p> <p>
${_("Then select an action")}: ${_("Then select an action")}:
<input type="button" class="molly-guard" name="reset-attempts-all" value="${_("Reset ALL students' attempts")}" data-endpoint="${ section_data['reset_student_attempts_url'] }"> <input type="button" class="molly-guard" name="reset-attempts-all" value="${_("Reset ALL students' attempts")}" data-endpoint="${ section_data['reset_student_attempts_url'] }">
<input type="button" class="molly-guard" name="rescore-problem-all" value="${_("Rescore ALL students' problem submissions")}" data-endpoint="${ section_data['rescore_problem_url'] }"> <input type="button" class="molly-guard" name="rescore-problem-all" value="${_("Rescore ALL students' problem submissions")}" data-endpoint="${ section_data['rescore_problem_url'] }">
</p> </p>
<p> <p>
<p> <p id="task-history-all-help">
${_("The above actions run in the background, and status for active tasks will appear in a table on the Course Info tab. " ${_("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")}: "To see status for all tasks submitted for this problem, click on this button")}:
</p> </p>
<p><input type="button" name="task-history-all" value="${_("Show Background Task History for Problem")}" data-endpoint="${ section_data['list_instructor_tasks_url'] }"></p> <p><input type="button" name="task-history-all" value="${_("Show Background Task History for Problem")}" data-endpoint="${ section_data['list_instructor_tasks_url'] }" aria-describedby="task-history-all-help"></p>
<div class="task-history-all-table"></div> <div class="task-history-all-table"></div>
</p> </p>
</div> </div>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment